Pad/doc/conflict_resolution.md
Greg Pomerantz 6720359360 Add Gioui scaffolding with Label and ListView rendering
- 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>
2026-05-07 17:45:56 -04:00

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

  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

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)