- 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>
234 lines
9.8 KiB
Markdown
234 lines
9.8 KiB
Markdown
# 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
|
|
|
|
1. **Detect** — watcher fires `file_modified` for the active file
|
|
2. **Read** — load the new content from disk
|
|
3. **Diff** — compute diff between the editor's buffer (old) and disk content (new) to understand what changed
|
|
4. **Re-anchor undo stack** — for each operation in the undo stack, attempt to locate its context in the new file content (see §6)
|
|
5. **Replace buffer** — swap the edit buffer with the new content
|
|
6. **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
|
|
7. **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
|
|
|
|
1. **Detect** — watcher fires `new_file` for a conflict file, or `file_modified` with a corresponding conflict file present
|
|
2. **Identify** — resolve the original file path from the conflict file name
|
|
3. **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
|
|
4. **Diff** — compute a line-based diff between ours and theirs, producing a list of conflicting hunks
|
|
5. **Present merge UI** — show the user each hunk with resolution options (see §7)
|
|
6. **Apply resolution** — produce a merged file from the user's choices
|
|
7. **Persist** — write merged content to the original file (`notes.txt`), delete the conflict file
|
|
8. **Re-anchor undo stack** — same as external update (§4, step 4)
|
|
9. **Replace buffer** — swap edit buffer with merged content
|
|
10. **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):
|
|
|
|
1. **Locate the head's context** in the new file content:
|
|
- For **insert chains**: search for `before` + `after` pattern (text surrounding the insertion point)
|
|
- For **delete chains**: search for `after` pattern (text after the deletion point)
|
|
|
|
2. **If context is found:**
|
|
- Update the head's `pos` to the new location
|
|
- All tail entries inherit from the re-anchored head
|
|
- Chain is preserved
|
|
|
|
3. **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
|
|
|
|
4. **If context is found multiple times:**
|
|
- Pick the occurrence closest to the original `pos`
|
|
- If ambiguity remains, pick the first occurrence
|
|
|
|
### 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:
|
|
|
|
1. On relaunch, detect the unresolved conflict file
|
|
2. Re-compute the diff (idempotent)
|
|
3. Restore any resolutions the user already made (from state file)
|
|
4. 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
|
|
|
|
```go
|
|
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)
|