- Element interface with Region() and Visible() methods - Label and ListView element types with constructors - Renderer that clips and draws elements in slice order - Main app loop with Gioui window and frame events Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
9.8 KiB
Conflict Resolution Specification
1. Overview
Pad operates on a directory synced by Syncthing. External modifications to files originate from edits on other devices, delivered via Syncthing. This document specifies how Pad detects, classifies, and resolves such changes.
2. Syncthing Conflict File Format
When Syncthing detects that two devices have modified the same file, it preserves both versions:
- Original file (
notes.txt) — the local device's version (what Pad edited) - Conflict file (
notes.sync-conflict-2024-05-15-1430-ABCDEF1.txt) — the remote device's version
Naming pattern: <base>.sync-conflict-<date>-<time>-<modifiedBy>.<ext>
| Component | Format | Example |
|---|---|---|
base |
Original filename without extension | notes |
date |
ISO date | 2024-05-15 |
time |
24-hour time, no separator | 1430 |
modifiedBy |
Remote device short ID | ABCDEF1 |
To resolve the original file from a conflict file, strip the .sync-conflict-<date>-<time>-<modifiedBy> suffix and reattach the extension.
3. Change Classification
When the file system watcher detects a change, Pad classifies it into one of two categories:
| Category | Trigger | Meaning |
|---|---|---|
| External update | File content changed on disk, no conflict file exists | Another device edited and synced; our device received the update cleanly |
| Sync conflict | A .sync-conflict-* file appeared for a file we have open or know about |
Both devices edited the same file; Syncthing could not merge automatically |
Detection Logic
ON file_modified(event):
original = event.path
conflict = find_conflict_file_for(original)
IF conflict exists:
→ SYNC CONFLICT
ELSE:
→ EXTERNAL UPDATE
ON new_file(event):
IF event.path matches conflict pattern:
original = resolve_original(event.path)
→ SYNC CONFLICT for original
4. External Update (No Conflict)
The file was modified on another device and synced cleanly. Our local edits (if any) were already synced before the remote edit, so there is no divergence.
Procedure
- Detect — watcher fires
file_modifiedfor the active file - Read — load the new content from disk
- Diff — compute diff between the editor's buffer (old) and disk content (new) to understand what changed
- Re-anchor undo stack — for each operation in the undo stack, attempt to locate its context in the new file content (see §6)
- Replace buffer — swap the edit buffer with the new content
- Preserve cursor — if the cursor position falls within a changed region, move it to the start of the changed region; otherwise keep it at the same byte offset
- Continue — user can edit immediately, undo works with re-anchored operations
Edge Cases
| Scenario | Behavior |
|---|---|
| User has un-saved edits (within debounce window) | Flush auto-save immediately, then proceed |
| User is actively typing | Complete the current keystroke, then apply update on next detection |
| File was truncated externally | Load empty/new content, reset cursor to 0 |
| Undo operations cannot be re-anchored | Drop unanchorable operations silently (context no longer exists in new content) |
5. Sync Conflict
Both devices edited the same file. Syncthing preserved both versions. Pad must help the user integrate the changes.
Procedure
- Detect — watcher fires
new_filefor a conflict file, orfile_modifiedwith a corresponding conflict file present - Identify — resolve the original file path from the conflict file name
- Load both versions:
- Ours (
notes.txt) — the local file, may contain the user's current edits - Theirs (
notes.sync-conflict-*.txt) — the remote device's version
- Ours (
- Diff — compute a line-based diff between ours and theirs, producing a list of conflicting hunks
- Present merge UI — show the user each hunk with resolution options (see §7)
- Apply resolution — produce a merged file from the user's choices
- Persist — write merged content to the original file (
notes.txt), delete the conflict file - Re-anchor undo stack — same as external update (§4, step 4)
- Replace buffer — swap edit buffer with merged content
- Continue — user can edit immediately
Edge Cases
| Scenario | Behavior |
|---|---|
| Conflict file for a file the user never opened | Leave it alone (out of scope; Syncthing will handle on next sync cycle) |
| Multiple conflict files for the same original (rare, from rapid edits) | Resolve the most recent one first; delete all conflict files after resolution |
| User closes the app while merge UI is shown | Persist the unresolved state; reopen merge UI on relaunch |
| Conflict file is deleted by user externally | Treat as abandoned; no action needed |
6. Undo Stack Re-anchoring
After any external change or conflict resolution, the undo stack must be reconciled with the new file content.
Algorithm
For each chain in the undo stack (most recent first):
-
Locate the head's context in the new file content:
- For insert chains: search for
before+afterpattern (text surrounding the insertion point) - For delete chains: search for
afterpattern (text after the deletion point)
- For insert chains: search for
-
If context is found:
- Update the head's
posto the new location - All tail entries inherit from the re-anchored head
- Chain is preserved
- Update the head's
-
If context is not found:
- Remove the entire chain from the undo stack
- The text surrounding the operation has changed too much to reliably replay
-
If context is found multiple times:
- Pick the occurrence closest to the original
pos - If ambiguity remains, pick the first occurrence
- Pick the occurrence closest to the original
Limitations
- Re-anchoring is best-effort. If the external change modified text within a chain's context window, that chain is lost.
- This is acceptable — the alternative (full diff-based rebasing) is significantly more complex and error-prone.
7. Merge UI
The merge UI presents conflicting hunks to the user for per-hunk resolution.
Hunk Structure
Each hunk represents a region where ours and theirs diverge:
─── Hunk 3 of 7 ─────────────────────────
Line 142 — Line 148
Context (unchanged):
The quick brown fox
Ours:
jumps over the lazy dog
The end.
Theirs:
jumps over the sleepy cat
A new paragraph begins.
The end.
Resolution Options (per hunk)
| Choice | Result |
|---|---|
| Keep ours | Use our version of this hunk |
| Keep theirs | Use their version of this hunk |
| Merge both | Concatenate ours + theirs (ours first, then a blank line, then theirs) |
UI Behavior
- One hunk at a time — shown full screen, swipe or tap to navigate between hunks
- Tap a version to preview it (highlight in the full file context)
- Tap a resolution button to accept and move to next hunk
- Apply all button appears after the last hunk is resolved
- Cancel — abort merge, keep our version, leave conflict file untouched (will reappear on next sync)
State Persistence
The merge session (which conflict file, which hunks, which resolutions) is persisted to the state file. If the app is killed during a merge:
- On relaunch, detect the unresolved conflict file
- Re-compute the diff (idempotent)
- Restore any resolutions the user already made (from state file)
- Present the merge UI from the first unresolved hunk
8. Diff Engine
Pad needs a line-based diff engine that produces hunks with context.
Requirements
- Line-based — hunks are groups of lines, not character offsets
- Streaming — must not load entire files into memory; process line by line
- Context-aware — each hunk includes N context lines before and after the change (configurable, default 3)
- Positioned — each hunk reports the starting line number in both versions
- Efficient — must handle files with millions of lines without O(n²) behavior
Output Format
type Hunk struct {
OurStartLine int // Starting line in our version (1-indexed)
OurLines []string // Lines from our version (replaced by TheirLines)
TheirStartLine int // Starting line in their version (1-indexed)
TheirLines []string // Lines from their version (replaces OurLines)
ContextBefore []string // Unchanged lines before the hunk
ContextAfter []string // Unchanged lines after the hunk
}
Implementation Options
| Approach | Pros | Cons |
|---|---|---|
Go textdiff / diffmatchpatch |
Available, battle-tested | Character-based, not line-based; may need wrapping |
Shell diff invocation |
Standard, line-based, efficient | Requires subprocess, harder to stream on Android |
| Custom Myers diff | Full control, line-based, streaming | More code to write and test |
Import github.com/sergi/go-diff |
Line-based, Go-native | External dependency, may not stream |
Recommendation: Import a Go-native line-based diff library (e.g., go-diff or diffmatchpatch with line-level wrapping). Evaluate during implementation.
9. Interaction with Auto-save
| Event | Auto-save behavior |
|---|---|
| External update applied | Auto-save the new buffer content (so our disk copy matches the synced version) |
| Merge in progress | Pause auto-save (buffer is in an inconsistent state) |
| Merge applied | Auto-save the merged content immediately |
| Conflict file detected while auto-save debounce is active | Flush auto-save first, then proceed with conflict detection |
10. Out of Scope
- Three-way merge (we don't have access to the common ancestor; Syncthing doesn't preserve it)
- Automatic merge (all changes require user confirmation)
- Binary file conflicts (Pad only handles text files)
- Folder-level conflicts (only file-level)