krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

docs/plans/2026-10-04-document-session.md

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/docs/plans/2026-10-04-document-session.md rendered · source · history · blame · raw

1081 lines · 43197 bytes

7 symbols in this file
   1# Document Session and Save Path Implementation Plan
   2
   3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
   4
   5**Goal:** The state of one open file (text, tree, revision, undo, merge base), view state that follows edits, a line-based three-way merge, and a save sequence that never loses a version another writer produced.
   6
   7**Architecture:** A new `OrgDocument` target on top of `OrgCore`. `DocumentState` is a value type: edits apply against a revision, go through `OrgParser.reparse`, and record their inverse for undo. External changes reload an unedited buffer or merge into an edited one. `Saver` runs the design's save sequence over a `FileSystem` protocol, so tests can inject another writer at every step; `CoordinatedFileSystem` is the real implementation (`NSFileCoordinator`, temp file and `replaceItemAt` with a kept backup), and `FileRecoveryStore` keeps displaced versions. Views map their own `ViewState` through the edits each change returns.
   8
   9**Tech Stack:** Swift 6.2 tools, Swift Testing, Foundation, CryptoKit (recovery folder names).
  10
  11**Spec:** `docs/design.md`, "Document session" and "Saving".
  12
  13## Global Constraints
  14
  15- Every version displaced by a save ends up on disk, in the buffer, or in recovery.
  16- Read-only documents (not valid UTF-8) are never written.
  17- Undo history is cleared by a reload or merge; it can't be mapped through an external change.
  18- `OrgDocument` builds for iOS: `xcodebuild -scheme OrgDocument -destination 'generic/platform=iOS' build`.
  19
  20## Out of scope
  21
  22Per-window session objects and main-actor wiring (they belong to the app target), file watching (workspace plan), and conflict UI.
  23
  24## File structure
  25
  26| File | Responsibility |
  27| --- | --- |
  28| `Package.swift` | Adds the `OrgDocument` library and `OrgDocumentTests` |
  29| `Sources/OrgDocument/Merge.swift` | Line diff (Myers), `threeWayMerge`, `lineEdits` |
  30| `Sources/OrgDocument/DocumentState.swift` | Edits, undo/redo, external changes, write bookkeeping |
  31| `Sources/OrgDocument/ViewState.swift` | Selection and folds mapped through edits |
  32| `Sources/OrgDocument/Saving.swift` | `FileSystem`, `RecoveryStore`, `Saver`, outcomes |
  33| `Sources/OrgDocument/FileStorage.swift` | `CoordinatedFileSystem`, `FileRecoveryStore` |
  34
  35---
  36
  37### Task 1: Three-way merge
  38
  39**Files:**
  40- Modify: `Package.swift`
  41- Create: `Sources/OrgDocument/Merge.swift`
  42- Test: `Tests/OrgDocumentTests/MergeTests.swift`
  43
  44**Interfaces:**
  45- Produces: `MergeConflict(base:ours:theirs:)`, `MergeResult` (`.merged`, `.conflict`), `threeWayMerge(base:ours:theirs:)`, `lineEdits(from:to:) -> [TextEdit]`; internal `textLines`, `matchingLines`.
  46
  47- [ ] **Step 1: Add the target**
  48
  49```swift
  50// swift-tools-version: 6.2
  51import PackageDescription
  52
  53let package = Package(
  54    name: "Orgstar",
  55    platforms: [.macOS(.v26), .iOS(.v26)],
  56    products: [
  57        .library(name: "OrgCore", targets: ["OrgCore"]),
  58        .library(name: "OrgDocument", targets: ["OrgDocument"])
  59    ],
  60    targets: [
  61        .target(name: "OrgCore"),
  62        .target(name: "OrgDocument", dependencies: ["OrgCore"]),
  63        .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
  64        .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"])
  65    ]
  66)
  67```
  68
  69- [ ] **Step 2: Write the failing tests**
  70
  71```swift
  72import OrgCore
  73import Testing
  74@testable import OrgDocument
  75
  76struct MergeTests {
  77    @Test func separateChangesMerge() {
  78        #expect(threeWayMerge(base: "a\nb\nc\n", ours: "A\nb\nc\n", theirs: "a\nb\nC\n") == .merged("A\nb\nC\n"))
  79    }
  80
  81    @Test func sameChangeOnBothSidesMergesOnce() {
  82        #expect(threeWayMerge(base: "a\nb\n", ours: "a\nX\n", theirs: "a\nX\n") == .merged("a\nX\n"))
  83    }
  84
  85    @Test func differentChangesConflict() {
  86        let result = threeWayMerge(base: "a\nb\nc\n", ours: "a\nX\nc\n", theirs: "a\nY\nc\n")
  87        #expect(result == .conflict([MergeConflict(base: "b\n", ours: "X\n", theirs: "Y\n")]))
  88    }
  89
  90    @Test func insertionsAndDeletions() {
  91        #expect(threeWayMerge(base: "a\nb\n", ours: "a\nb\nc\n", theirs: "b\n") == .merged("b\nc\n"))
  92        #expect(threeWayMerge(base: "", ours: "x\n", theirs: "") == .merged("x\n"))
  93    }
  94
  95    @Test func lineEndingsSurvive() {
  96        #expect(threeWayMerge(base: "a\r\nb\r\n", ours: "A\r\nb\r\n", theirs: "a\r\nb\r\nc") == .merged("A\r\nb\r\nc"))
  97    }
  98
  99    @Test func lineEditsRebuildTheNewText() {
 100        let pairs = [("a\nb\nc\n", "a\nx\nc\nd\n"), ("", "x"), ("x", ""), ("a\r\nb", "b\r\na"), ("same\n", "same\n")]
 101        for (old, new) in pairs {
 102            var text = old
 103            for edit in lineEdits(from: old, to: new).reversed() { text = edit.apply(to: text) }
 104            #expect(text == new)
 105        }
 106    }
 107
 108    @Test func largeFilesWithSmallChanges() {
 109        let base = (0..<20_000).map { "line \($0)\n" }.joined()
 110        let ours = base.replacingOccurrences(of: "line 100\n", with: "ours\n")
 111        let theirs = base.replacingOccurrences(of: "line 19000\n", with: "theirs\n")
 112        guard case .merged(let merged) = threeWayMerge(base: base, ours: ours, theirs: theirs) else {
 113            Issue.record("expected a clean merge")
 114            return
 115        }
 116        #expect(merged.contains("ours\n") && merged.contains("theirs\n"))
 117    }
 118}
 119```
 120
 121- [ ] **Step 3: Run to verify failure**
 122
 123Run: `swift test --filter MergeTests`
 124Expected: build failure, `cannot find 'threeWayMerge' in scope`.
 125
 126- [ ] **Step 4: Implement**
 127
 128```swift
 129import OrgCore
 130
 131public struct MergeConflict: Sendable, Equatable {
 132    public let base: String
 133    public let ours: String
 134    public let theirs: String
 135}
 136
 137public enum MergeResult: Sendable, Equatable {
 138    case merged(String)
 139    case conflict([MergeConflict])
 140}
 141
 142/// Line-based three-way merge. Lines keep their endings, so CRLF and a missing final newline
 143/// survive. A region changed on one side takes that side; changed the same way on both, takes
 144/// it once; changed differently, is a conflict.
 145public func threeWayMerge(base: String, ours: String, theirs: String) -> MergeResult {
 146    let baseLines = textLines(base)
 147    let ourLines = textLines(ours)
 148    let theirLines = textLines(theirs)
 149    var ids = LineIDs()
 150    let baseIDs = ids.encode(baseLines)
 151    let ourIDs = ids.encode(ourLines)
 152    let theirIDs = ids.encode(theirLines)
 153
 154    var toOurs = [Int?](repeating: nil, count: baseLines.count)
 155    for (b, o) in matchingLines(baseIDs, ourIDs) { toOurs[b] = o }
 156    var toTheirs = [Int?](repeating: nil, count: baseLines.count)
 157    for (b, t) in matchingLines(baseIDs, theirIDs) { toTheirs[b] = t }
 158
 159    var merged: [String] = []
 160    var conflicts: [MergeConflict] = []
 161    var nextBase = 0, nextOurs = 0, nextTheirs = 0
 162
 163    func resolve(_ baseEnd: Int, _ oursEnd: Int, _ theirsEnd: Int) {
 164        let b = baseLines[nextBase..<baseEnd]
 165        let o = ourLines[nextOurs..<oursEnd]
 166        let t = theirLines[nextTheirs..<theirsEnd]
 167        if o.elementsEqual(b) {
 168            merged += t
 169        } else if t.elementsEqual(b) || o.elementsEqual(t) {
 170            merged += o
 171        } else {
 172            conflicts.append(MergeConflict(base: b.joined(), ours: o.joined(), theirs: t.joined()))
 173        }
 174    }
 175
 176    // A base line kept by both sides is a fixed point; everything between fixed points is
 177    // resolved as one region.
 178    for k in baseLines.indices {
 179        guard let o = toOurs[k], let t = toTheirs[k], o >= nextOurs, t >= nextTheirs else { continue }
 180        resolve(k, o, t)
 181        merged.append(baseLines[k])
 182        nextBase = k + 1
 183        nextOurs = o + 1
 184        nextTheirs = t + 1
 185    }
 186    resolve(baseLines.count, ourLines.count, theirLines.count)
 187    return conflicts.isEmpty ? .merged(merged.joined()) : .conflict(conflicts)
 188}
 189
 190/// Edits, in `old` coordinates and ascending order, that turn `old` into `new` line by line.
 191public func lineEdits(from old: String, to new: String) -> [TextEdit] {
 192    let oldLines = textLines(old)
 193    let newLines = textLines(new)
 194    var ids = LineIDs()
 195    let pairs = matchingLines(ids.encode(oldLines), ids.encode(newLines))
 196    var offsets = [0]
 197    for line in oldLines { offsets.append(offsets.last! + line.utf16.count) }
 198    var edits: [TextEdit] = []
 199    var i = 0, j = 0
 200    for (pi, pj) in pairs + [(oldLines.count, newLines.count)] {
 201        if i < pi || j < pj {
 202            edits.append(TextEdit(range: offsets[i]..<offsets[pi], replacement: newLines[j..<pj].joined()))
 203        }
 204        i = pi + 1
 205        j = pj + 1
 206    }
 207    return edits
 208}
 209
 210/// Lines with their endings.
 211func textLines(_ text: String) -> [String] {
 212    var lines: [String] = []
 213    var current = ""
 214    for scalar in text.unicodeScalars {
 215        current.unicodeScalars.append(scalar)
 216        if scalar == "\n" {
 217            lines.append(current)
 218            current = ""
 219        }
 220    }
 221    if !current.isEmpty { lines.append(current) }
 222    return lines
 223}
 224
 225struct LineIDs {
 226    private var ids: [String: Int] = [:]
 227
 228    mutating func encode(_ lines: [String]) -> [Int] {
 229        lines.map { line in
 230            if let id = ids[line] { return id }
 231            let id = ids.count
 232            ids[line] = id
 233            return id
 234        }
 235    }
 236}
 237
 238/// Matched index pairs of a longest common subsequence, ascending. Common prefix and suffix
 239/// are matched directly; Myers' algorithm handles the middle. When the middle differs by more
 240/// than the memory budget allows, it is treated as having no matches.
 241func matchingLines(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
 242    var prefix = 0
 243    while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
 244    var suffix = 0
 245    while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] {
 246        suffix += 1
 247    }
 248    var pairs = (0..<prefix).map { ($0, $0) }
 249    let middleA = Array(a[prefix..<(a.count - suffix)])
 250    let middleB = Array(b[prefix..<(b.count - suffix)])
 251    pairs += myers(middleA, middleB).map { ($0.0 + prefix, $0.1 + prefix) }
 252    pairs += (0..<suffix).map { (a.count - suffix + $0, b.count - suffix + $0) }
 253    return pairs
 254}
 255
 256private func myers(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
 257    let n = a.count, m = b.count
 258    guard n > 0, m > 0 else { return [] }
 259    let maxD = n + m
 260    let offset = maxD + 1
 261    // Each step keeps a copy of the frontier for backtracking; cap that at ~20M entries.
 262    let budget = max(1, 20_000_000 / (2 * maxD + 3))
 263    var v = [Int](repeating: 0, count: 2 * maxD + 3)
 264    var trace: [[Int]] = []
 265    var found = false
 266    search: for d in 0...min(maxD, budget) {
 267        trace.append(v)
 268        for k in stride(from: -d, through: d, by: 2) {
 269            var x = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1
 270            var y = x - k
 271            while x < n, y < m, a[x] == b[y] {
 272                x += 1
 273                y += 1
 274            }
 275            v[offset + k] = x
 276            if x >= n, y >= m {
 277                found = true
 278                break search
 279            }
 280        }
 281    }
 282    guard found else { return [] }
 283
 284    var pairs: [(Int, Int)] = []
 285    var x = n, y = m
 286    for d in stride(from: trace.count - 1, through: 0, by: -1) {
 287        let v = trace[d]
 288        let k = x - y
 289        let previousK = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? k + 1 : k - 1
 290        let previousX = v[offset + previousK]
 291        let previousY = previousX - previousK
 292        while x > previousX, y > previousY {
 293            pairs.append((x - 1, y - 1))
 294            x -= 1
 295            y -= 1
 296        }
 297        if d > 0 {
 298            x = previousX
 299            y = previousY
 300        }
 301    }
 302    return pairs.reversed()
 303}
 304```
 305
 306- [ ] **Step 5: Run to verify pass, then commit**
 307
 308Run: `swift test --filter MergeTests`
 309
 310```bash
 311git add Package.swift Sources/OrgDocument/Merge.swift Tests/OrgDocumentTests/MergeTests.swift
 312git commit -m "Add OrgDocument target with three-way merge"
 313```
 314
 315---
 316
 317### Task 2: Document state and view state
 318
 319**Files:**
 320- Create: `Sources/OrgDocument/DocumentState.swift`, `Sources/OrgDocument/ViewState.swift`
 321- Test: `Tests/OrgDocumentTests/DocumentStateTests.swift`
 322
 323**Interfaces:**
 324- Consumes: `threeWayMerge`, `lineEdits` (Task 1); `OrgParser.reparse`, `SourceText`, `TextEdit`.
 325- Produces: `DocumentState(bytes:defaults:)` with `source`, `text`, `tree`, `revision`, `mergeBase`, `isDirty`, `isEditable`, `canUndo`, `canRedo`, `encodedText()`, `apply(_:baseRevision:)`, `undo()`, `redo()`, `diskChanged(to:) -> ExternalChange`, `mergeOverwritten(_:base:)`, `didWrite(_:)`; `ViewState` with `mapped(through:)`, `pruned(to:)`; internal `mapOffset`, `utf16Slice`.
 326
 327- [ ] **Step 1: Write the failing tests**
 328
 329```swift
 330import OrgCore
 331import Testing
 332@testable import OrgDocument
 333
 334func state(_ text: String) -> DocumentState {
 335    DocumentState(bytes: Array(text.utf8))
 336}
 337
 338struct DocumentStateTests {
 339    @Test func applyUndoRedo() throws {
 340        var doc = state("* a\nbody\n")
 341        try doc.apply([TextEdit(range: 2..<3, replacement: "TODO b")], baseRevision: 0)
 342        #expect(doc.text == "* TODO b\nbody\n")
 343        #expect(doc.revision == 1)
 344        #expect(doc.isDirty)
 345        #expect(doc.tree.green == OrgParser.parse(doc.text).green)
 346
 347        #expect(doc.undo() != nil)
 348        #expect(doc.text == "* a\nbody\n")
 349        #expect(!doc.isDirty)
 350        #expect(doc.tree.green == OrgParser.parse(doc.text).green)
 351
 352        #expect(doc.redo() != nil)
 353        #expect(doc.text == "* TODO b\nbody\n")
 354    }
 355
 356    @Test func groupedEditsAreOneUndoStep() throws {
 357        var doc = state("ab cd ef\n")
 358        try doc.apply([TextEdit(range: 0..<2, replacement: "X"), TextEdit(range: 6..<8, replacement: "YYY")], baseRevision: 0)
 359        #expect(doc.text == "X cd YYY\n")
 360        _ = doc.undo()
 361        #expect(doc.text == "ab cd ef\n")
 362    }
 363
 364    @Test func rejectsStaleOverlappingAndReadOnly() throws {
 365        var doc = state("abc\n")
 366        #expect(throws: DocumentState.EditError.staleRevision) { try doc.apply([], baseRevision: 5) }
 367        #expect(throws: DocumentState.EditError.overlappingEdits) {
 368            try doc.apply([TextEdit(range: 0..<2, replacement: ""), TextEdit(range: 1..<3, replacement: "")], baseRevision: 0)
 369        }
 370        var invalid = DocumentState(bytes: [0x61, 0xFF])
 371        #expect(throws: DocumentState.EditError.readOnly) { try invalid.apply([], baseRevision: 0) }
 372        #expect(throws: DocumentState.EditError.readOnly) { try invalid.encodedText() }
 373    }
 374
 375    @Test func newEditClearsRedo() throws {
 376        var doc = state("a\n")
 377        try doc.apply([TextEdit(range: 0..<0, replacement: "x")], baseRevision: 0)
 378        _ = doc.undo()
 379        try doc.apply([TextEdit(range: 0..<0, replacement: "y")], baseRevision: doc.revision)
 380        #expect(!doc.canRedo)
 381    }
 382
 383    @Test func keepsBOMWhenEncoding() throws {
 384        var doc = DocumentState(bytes: [0xEF, 0xBB, 0xBF] + Array("a\n".utf8))
 385        try doc.apply([TextEdit(range: 0..<1, replacement: "b")], baseRevision: 0)
 386        #expect(try doc.encodedText() == [0xEF, 0xBB, 0xBF] + Array("b\n".utf8))
 387    }
 388
 389    @Test func externalChangeReloadsCleanBuffer() {
 390        var doc = state("a\nb\n")
 391        #expect(doc.diskChanged(to: Array("a\nb\n".utf8)) == .unchanged)
 392        guard case .reloaded = doc.diskChanged(to: Array("a\nc\n".utf8)) else {
 393            Issue.record("expected a reload")
 394            return
 395        }
 396        #expect(doc.text == "a\nc\n")
 397        #expect(!doc.isDirty)
 398    }
 399
 400    @Test func externalChangeMergesIntoEditedBuffer() throws {
 401        var doc = state("a\nb\nc\n")
 402        try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0)
 403        guard case .merged = doc.diskChanged(to: Array("a\nb\nC\n".utf8)) else {
 404            Issue.record("expected a merge")
 405            return
 406        }
 407        #expect(doc.text == "A\nb\nC\n")
 408        #expect(doc.mergeBase == Array("a\nb\nC\n".utf8))
 409        #expect(doc.isDirty)
 410        #expect(!doc.canUndo)
 411    }
 412
 413    @Test func conflictingExternalChangeLeavesBufferAlone() throws {
 414        var doc = state("a\n")
 415        try doc.apply([TextEdit(range: 0..<1, replacement: "X")], baseRevision: 0)
 416        guard case .conflict = doc.diskChanged(to: Array("Y\n".utf8)) else {
 417            Issue.record("expected a conflict")
 418            return
 419        }
 420        #expect(doc.text == "X\n")
 421        #expect(doc.mergeBase == Array("a\n".utf8))
 422    }
 423}
 424
 425struct ViewStateTests {
 426    @Test func mapsSelectionAndFolds() {
 427        let view = ViewState(selection: [2..<6], folds: [0, 10])
 428        let mapped = view.mapped(through: [TextEdit(range: 1..<1, replacement: "xx"), TextEdit(range: 8..<9, replacement: "")])
 429        #expect(mapped.selection == [4..<8])
 430        #expect(mapped.folds == [0, 11])
 431    }
 432
 433    @Test func offsetsInsideAReplacementMoveToItsEnd() {
 434        let edits = [TextEdit(range: 2..<5, replacement: "ab")]
 435        #expect(mapOffset(2, edits) == 2)
 436        #expect(mapOffset(3, edits) == 4)
 437        #expect(mapOffset(5, edits) == 4)
 438        #expect(mapOffset(6, edits) == 5)
 439        #expect(mapOffset(2, [TextEdit(range: 2..<2, replacement: "ab")]) == 4)
 440    }
 441
 442    @Test func pruneDropsFoldsThatAreNoLongerHeadings() {
 443        let tree = OrgParser.parse("* a\ntext\n* b\n")
 444        #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9])
 445    }
 446}
 447```
 448
 449- [ ] **Step 2: Run to verify failure**
 450
 451Run: `swift test --filter DocumentStateTests`
 452Expected: build failure, `cannot find 'DocumentState' in scope`.
 453
 454- [ ] **Step 3: Implement `DocumentState.swift`**
 455
 456```swift
 457import OrgCore
 458
 459/// One open file: its text, tree, revision, undo history, and the bytes last read from or
 460/// written to disk, which are the base for merging external changes.
 461public struct DocumentState: Sendable {
 462    public enum EditError: Error, Equatable {
 463        case readOnly
 464        case staleRevision
 465        case overlappingEdits
 466    }
 467
 468    public enum ExternalChange: Sendable, Equatable {
 469        case unchanged
 470        /// The buffer had no edits and now holds the disk version. Edits are in the old text's
 471        /// coordinates, for mapping view state.
 472        case reloaded([TextEdit])
 473        /// The disk version was merged into the edited buffer.
 474        case merged([TextEdit])
 475        /// Nothing changed in the buffer.
 476        case conflict([MergeConflict])
 477    }
 478
 479    /// The bytes on disk as of the last read or write.
 480    public private(set) var source: SourceText
 481    public private(set) var text: String
 482    public private(set) var tree: OrgTree
 483    /// Increases on every change to `text`.
 484    public private(set) var revision = 0
 485    public let defaults: OrgSettings
 486    private var undoStack: [[TextEdit]] = []
 487    private var redoStack: [[TextEdit]] = []
 488
 489    public init(bytes: [UInt8], defaults: OrgSettings = .default) {
 490        source = SourceText(bytes: bytes)
 491        text = source.text
 492        tree = OrgParser.parse(text, defaults: defaults)
 493        self.defaults = defaults
 494    }
 495
 496    public var mergeBase: [UInt8] { source.originalBytes }
 497    public var isDirty: Bool { text != source.text }
 498    public var isEditable: Bool { source.isEditable }
 499    public var canUndo: Bool { !undoStack.isEmpty }
 500    public var canRedo: Bool { !redoStack.isEmpty }
 501
 502    /// Bytes to write for the current text, keeping the file's BOM.
 503    public func encodedText() throws -> [UInt8] {
 504        guard isEditable else { throw EditError.readOnly }
 505        return source.encode(text)
 506    }
 507
 508    // MARK: - Editing
 509
 510    /// Applies non-overlapping edits, computed against `baseRevision`, as one undo step.
 511    public mutating func apply(_ edits: [TextEdit], baseRevision: Int) throws {
 512        guard isEditable else { throw EditError.readOnly }
 513        guard baseRevision == revision else { throw EditError.staleRevision }
 514        let inverse = try applyGroup(edits)
 515        undoStack.append(inverse)
 516        redoStack = []
 517    }
 518
 519    /// Reverts the last edit group. Returns the edits applied, for mapping view state.
 520    public mutating func undo() -> [TextEdit]? {
 521        guard let group = undoStack.popLast() else { return nil }
 522        redoStack.append(try! applyGroup(group))
 523        return group
 524    }
 525
 526    public mutating func redo() -> [TextEdit]? {
 527        guard let group = redoStack.popLast() else { return nil }
 528        undoStack.append(try! applyGroup(group))
 529        return group
 530    }
 531
 532    /// Applies `edits` (old coordinates) and returns their inverse (new coordinates).
 533    private mutating func applyGroup(_ edits: [TextEdit]) throws -> [TextEdit] {
 534        let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
 535        for (first, second) in zip(sorted, sorted.dropFirst()) where first.range.upperBound > second.range.lowerBound {
 536            throw EditError.overlappingEdits
 537        }
 538        var inverse: [TextEdit] = []
 539        var shift = 0
 540        for edit in sorted {
 541            let start = edit.range.lowerBound + shift
 542            inverse.append(TextEdit(range: start..<(start + edit.replacement.utf16.count), replacement: utf16Slice(text, edit.range)))
 543            shift += edit.replacement.utf16.count - edit.range.count
 544        }
 545        // Back to front, so earlier offsets stay valid.
 546        for edit in sorted.reversed() {
 547            tree = OrgParser.reparse(tree, oldText: text, edit: edit, defaults: defaults)
 548            text = edit.apply(to: text)
 549        }
 550        revision += 1
 551        return inverse
 552    }
 553
 554    // MARK: - Disk
 555
 556    /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited
 557    /// one, and makes `bytes` the new merge base unless the merge conflicts.
 558    public mutating func diskChanged(to bytes: [UInt8]) -> ExternalChange {
 559        guard bytes != mergeBase else { return .unchanged }
 560        let disk = SourceText(bytes: bytes)
 561        if !isDirty {
 562            let edits = lineEdits(from: text, to: disk.text)
 563            replaceText(with: disk.text, source: disk)
 564            return .reloaded(edits)
 565        }
 566        return mergeIn(disk, base: source.text, newSource: disk)
 567    }
 568
 569    /// Our bytes are on disk, but they replaced `theirs`, which was based on `base`. Merges
 570    /// their changes into the buffer and leaves the merge base at our bytes.
 571    public mutating func mergeOverwritten(_ theirs: [UInt8], base: [UInt8]) -> ExternalChange {
 572        mergeIn(SourceText(bytes: theirs), base: SourceText(bytes: base).text, newSource: source)
 573    }
 574
 575    private mutating func mergeIn(_ theirs: SourceText, base: String, newSource: SourceText) -> ExternalChange {
 576        guard theirs.isValidUTF8 else {
 577            return .conflict([MergeConflict(base: base, ours: text, theirs: theirs.text)])
 578        }
 579        switch threeWayMerge(base: base, ours: text, theirs: theirs.text) {
 580        case .merged(let merged):
 581            let edits = lineEdits(from: text, to: merged)
 582            replaceText(with: merged, source: newSource)
 583            return .merged(edits)
 584        case .conflict(let conflicts):
 585            return .conflict(conflicts)
 586        }
 587    }
 588
 589    /// Records that `bytes` were written. The text stays as is; edits made since the write keep
 590    /// the buffer dirty.
 591    public mutating func didWrite(_ bytes: [UInt8]) {
 592        source = SourceText(bytes: bytes)
 593    }
 594
 595    /// Undo history can't be mapped through an external change, so it is cleared.
 596    private mutating func replaceText(with newText: String, source newSource: SourceText) {
 597        source = newSource
 598        if newText != text {
 599            text = newText
 600            tree = OrgParser.parse(newText, defaults: defaults)
 601            revision += 1
 602        }
 603        undoStack = []
 604        redoStack = []
 605    }
 606}
 607
 608func utf16Slice(_ text: String, _ range: Range<Int>) -> String {
 609    let start = String.Index(utf16Offset: range.lowerBound, in: text)
 610    let end = String.Index(utf16Offset: range.upperBound, in: text)
 611    return String(text.unicodeScalars[start..<end])
 612}
 613```
 614
 615- [ ] **Step 4: Implement `ViewState.swift`**
 616
 617```swift
 618import OrgCore
 619
 620/// Per-window state for one document: selection and folds, in UTF-16 offsets.
 621public struct ViewState: Sendable, Equatable {
 622    public var selection: [Range<Int>]
 623    /// Start offsets of folded headings.
 624    public var folds: Set<Int>
 625
 626    public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) {
 627        self.selection = selection
 628        self.folds = folds
 629    }
 630
 631    /// Maps through non-overlapping edits given in old coordinates.
 632    public func mapped(through edits: [TextEdit]) -> ViewState {
 633        let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
 634        return ViewState(
 635            selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
 636            folds: Set(folds.map { mapOffset($0, sorted) })
 637        )
 638    }
 639
 640    /// Drops folds that no longer sit at the start of a heading.
 641    public func pruned(to tree: OrgTree) -> ViewState {
 642        let headings = Set(tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound))
 643        return ViewState(selection: selection, folds: folds.intersection(headings))
 644    }
 645}
 646
 647/// An offset before an edit stays put; one at the start of a replaced range stays at its start;
 648/// one inside it, at its end, or at an insertion point moves past the replacement.
 649func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int {
 650    var shift = 0
 651    for edit in sortedEdits {
 652        let lower = edit.range.lowerBound, upper = edit.range.upperBound
 653        if offset < lower || (offset == lower && upper > lower) { return offset + shift }
 654        if offset <= upper { return lower + shift + edit.replacement.utf16.count }
 655        shift += edit.replacement.utf16.count - edit.range.count
 656    }
 657    return offset + shift
 658}
 659```
 660
 661- [ ] **Step 5: Run to verify pass, then commit**
 662
 663Run: `swift test --filter "DocumentStateTests|ViewStateTests"`
 664
 665```bash
 666git add Sources/OrgDocument/DocumentState.swift Sources/OrgDocument/ViewState.swift Tests/OrgDocumentTests/DocumentStateTests.swift
 667git commit -m "Add document state and view state"
 668```
 669
 670---
 671
 672### Task 3: Save path
 673
 674**Files:**
 675- Create: `Sources/OrgDocument/Saving.swift`, `Sources/OrgDocument/FileStorage.swift`
 676- Test: `Tests/OrgDocumentTests/SaveTests.swift`
 677
 678**Interfaces:**
 679- Consumes: `DocumentState` (Task 2).
 680- Produces: `FileSystem`, `RecoveryStore`, `SaveOutcome`, `SaveError`, `Saver(fileSystem:recovery:)` with `save(_:to:)`, `CoordinatedFileSystem`, `FileRecoveryStore(directory:limit:)` with `versions(for:)`.
 681
 682- [ ] **Step 1: Write the failing tests**
 683
 684The fault-injection cases put another writer's change in place before each read and before the replace, and check that both versions survive.
 685
 686```swift
 687import Foundation
 688import OrgCore
 689import Testing
 690@testable import OrgDocument
 691
 692/// An in-memory file with hooks that let another writer change it at each step of a save.
 693final class FaultyFileSystem: FileSystem, @unchecked Sendable {
 694    var file: [UInt8]?
 695    var reads = 0
 696    /// Content another writer puts in place just before the n-th read (1-based).
 697    var beforeRead: [Int: [UInt8]] = [:]
 698    /// Content another writer puts in place just before our replace.
 699    var beforeReplace: [UInt8]?
 700
 701    init(_ text: String?) {
 702        file = text.map { Array($0.utf8) }
 703    }
 704
 705    func read(_ url: URL) throws -> [UInt8]? {
 706        reads += 1
 707        if let injected = beforeRead[reads] { file = injected }
 708        return file
 709    }
 710
 711    func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
 712        if let injected = beforeReplace { file = injected }
 713        let replaced = file
 714        file = bytes
 715        return replaced
 716    }
 717
 718    var text: String? { file.map { String(decoding: $0, as: UTF8.self) } }
 719}
 720
 721final class MemoryRecovery: RecoveryStore, @unchecked Sendable {
 722    var kept: [(label: String, text: String)] = []
 723
 724    func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
 725        kept.append((label, String(decoding: bytes, as: UTF8.self)))
 726    }
 727
 728    func contains(_ text: String) -> Bool { kept.contains { $0.text == text } }
 729}
 730
 731let url = URL(fileURLWithPath: "/notes/a.org")
 732
 733/// A buffer loaded from "a\nb\nc\n" with its first line changed to "A".
 734func editedState() throws -> DocumentState {
 735    var doc = state("a\nb\nc\n")
 736    try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0)
 737    return doc
 738}
 739
 740struct SaveTests {
 741    @Test func plainSave() throws {
 742        let files = FaultyFileSystem("a\nb\nc\n")
 743        let recovery = MemoryRecovery()
 744        var doc = try editedState()
 745        #expect(try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) == .saved)
 746        #expect(files.text == "A\nb\nc\n")
 747        #expect(!doc.isDirty)
 748        #expect(recovery.kept.isEmpty)
 749    }
 750
 751    @Test func missingFileIsCreated() throws {
 752        let files = FaultyFileSystem(nil)
 753        var doc = try editedState()
 754        #expect(try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) == .saved)
 755        #expect(files.text == "A\nb\nc\n")
 756    }
 757
 758    @Test func readOnlyDocumentsAreNotSaved() {
 759        var doc = DocumentState(bytes: [0x61, 0xFF])
 760        #expect(throws: DocumentState.EditError.readOnly) {
 761            try Saver(fileSystem: FaultyFileSystem("x"), recovery: MemoryRecovery()).save(&doc, to: url)
 762        }
 763    }
 764
 765    // MARK: - Another writer at each step
 766
 767    @Test func changedBeforeSaveMerges() throws {
 768        let files = FaultyFileSystem("a\nb\nc\n")
 769        files.beforeRead[1] = Array("a\nb\nC\n".utf8)
 770        let recovery = MemoryRecovery()
 771        var doc = try editedState()
 772        guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
 773            Issue.record("expected a merge")
 774            return
 775        }
 776        #expect(files.text == "A\nb\nC\n")
 777        #expect(recovery.contains("a\nb\nC\n") && recovery.contains("A\nb\nc\n"))
 778    }
 779
 780    @Test func conflictingChangeWritesNothing() throws {
 781        let files = FaultyFileSystem("a\nb\nc\n")
 782        files.beforeRead[1] = Array("Z\nb\nc\n".utf8)
 783        var doc = try editedState()
 784        guard case .conflict = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
 785            Issue.record("expected a conflict")
 786            return
 787        }
 788        #expect(files.text == "Z\nb\nc\n")
 789        #expect(doc.text == "A\nb\nc\n")
 790    }
 791
 792    @Test func changedBetweenReadAndCheckRetries() throws {
 793        let files = FaultyFileSystem("a\nb\nc\n")
 794        files.beforeRead[2] = Array("a\nb\nC\n".utf8)
 795        var doc = try editedState()
 796        guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
 797            Issue.record("expected a merge on the second attempt")
 798            return
 799        }
 800        #expect(files.text == "A\nb\nC\n")
 801    }
 802
 803    @Test func changedJustBeforeReplaceIsRecovered() throws {
 804        let files = FaultyFileSystem("a\nb\nc\n")
 805        files.beforeReplace = Array("a\nb\nC\n".utf8)
 806        let recovery = MemoryRecovery()
 807        var doc = try editedState()
 808        guard case .overwroteExternalChange(.merged) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
 809            Issue.record("expected their change merged into the buffer")
 810            return
 811        }
 812        #expect(files.text == "A\nb\nc\n")
 813        #expect(recovery.contains("a\nb\nC\n"))
 814        #expect(doc.text == "A\nb\nC\n")
 815        #expect(doc.isDirty)
 816    }
 817
 818    @Test func changedRightAfterWriteKeepsOursInRecovery() throws {
 819        let files = FaultyFileSystem("a\nb\nc\n")
 820        files.beforeRead[3] = Array("A\nb\nc\nD\n".utf8)
 821        let recovery = MemoryRecovery()
 822        var doc = try editedState()
 823        guard case .changedAfterWrite(.reloaded) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
 824            Issue.record("expected a reload of their version")
 825            return
 826        }
 827        #expect(recovery.contains("A\nb\nc\n"))
 828        #expect(doc.text == "A\nb\nc\nD\n")
 829    }
 830
 831    @Test func keepsChangingGivesUp() throws {
 832        let files = FaultyFileSystem("a\nb\nc\n")
 833        for n in stride(from: 2, through: 6, by: 2) { files.beforeRead[n] = Array("a\nb\nc\n\(n)\n".utf8) }
 834        var doc = try editedState()
 835        #expect(throws: SaveError.fileKeepsChanging) {
 836            try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url)
 837        }
 838    }
 839}
 840
 841struct FileStorageTests {
 842    func temporaryFolder() throws -> URL {
 843        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
 844        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
 845        return folder
 846    }
 847
 848    @Test func readAndReplace() throws {
 849        let folder = try temporaryFolder()
 850        defer { try? FileManager.default.removeItem(at: folder) }
 851        let file = folder.appendingPathComponent("a.org")
 852        let files = CoordinatedFileSystem()
 853        #expect(try files.read(file) == nil)
 854        #expect(try files.replace(file, with: Array("one\n".utf8)) == nil)
 855        #expect(try files.replace(file, with: Array("two\n".utf8)) == Array("one\n".utf8))
 856        #expect(try files.read(file) == Array("two\n".utf8))
 857        #expect(try FileManager.default.contentsOfDirectory(atPath: folder.path) == ["a.org"])
 858    }
 859
 860    @Test func savesThroughTheRealFileSystem() throws {
 861        let folder = try temporaryFolder()
 862        defer { try? FileManager.default.removeItem(at: folder) }
 863        let file = folder.appendingPathComponent("a.org")
 864        try Data("a\nb\nc\n".utf8).write(to: file)
 865        let recovery = FileRecoveryStore(directory: folder.appendingPathComponent("recovery"))
 866        var doc = try editedState()
 867        #expect(try Saver(fileSystem: CoordinatedFileSystem(), recovery: recovery).save(&doc, to: file) == .saved)
 868        #expect(try String(contentsOf: file, encoding: .utf8) == "A\nb\nc\n")
 869    }
 870
 871    @Test func recoveryKeepsTheNewestVersions() throws {
 872        let folder = try temporaryFolder()
 873        defer { try? FileManager.default.removeItem(at: folder) }
 874        let store = FileRecoveryStore(directory: folder, limit: 3)
 875        for n in 0..<5 { try store.keep(Array("v\(n)".utf8), for: url, label: "local") }
 876        let versions = try store.versions(for: url)
 877        #expect(versions.count == 3)
 878        #expect(try versions.map { try String(contentsOf: $0, encoding: .utf8) } == ["v2", "v3", "v4"])
 879    }
 880}
 881```
 882
 883- [ ] **Step 2: Run to verify failure**
 884
 885Run: `swift test --filter SaveTests`
 886Expected: build failure, `cannot find type 'FileSystem' in scope`.
 887
 888- [ ] **Step 3: Implement `Saving.swift`**
 889
 890```swift
 891import Foundation
 892import OrgCore
 893
 894public protocol FileSystem: Sendable {
 895    /// The file's bytes, or nil if it doesn't exist.
 896    func read(_ url: URL) throws -> [UInt8]?
 897    /// Replaces the file through a temporary file in the same folder. Returns the bytes that
 898    /// were replaced, read from the replaced file itself, or nil if there was none.
 899    func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]?
 900}
 901
 902public protocol RecoveryStore: Sendable {
 903    func keep(_ bytes: [UInt8], for url: URL, label: String) throws
 904}
 905
 906public enum SaveOutcome: Sendable, Equatable {
 907    case saved
 908    /// The file had changed since it was read; the change merged cleanly into the buffer and
 909    /// the merge was written.
 910    case mergedAndSaved([TextEdit])
 911    /// The file had changed and the change conflicts. Nothing was written; the buffer is
 912    /// unchanged.
 913    case conflict([MergeConflict])
 914    /// Another writer replaced the file between our last check and our write. Ours is on disk;
 915    /// theirs is in recovery and was merged into the buffer where possible.
 916    case overwroteExternalChange(DocumentState.ExternalChange)
 917    /// Another writer changed the file right after our write. Theirs is on disk; ours is in
 918    /// recovery.
 919    case changedAfterWrite(DocumentState.ExternalChange)
 920}
 921
 922public enum SaveError: Error, Equatable {
 923    case fileKeepsChanging
 924}
 925
 926/// The save sequence from the design: read, merge if the file moved, check again, replace,
 927/// read back. Emacs and Syncthing don't coordinate, so the sequence can't lock them out; it
 928/// narrows the window and makes sure every version it displaces lands in recovery.
 929public struct Saver: Sendable {
 930    public let fileSystem: FileSystem
 931    public let recovery: RecoveryStore
 932    public var maxAttempts = 3
 933
 934    public init(fileSystem: FileSystem, recovery: RecoveryStore) {
 935        self.fileSystem = fileSystem
 936        self.recovery = recovery
 937    }
 938
 939    public func save(_ state: inout DocumentState, to url: URL) throws -> SaveOutcome {
 940        guard state.isEditable else { throw DocumentState.EditError.readOnly }
 941        for _ in 0..<maxAttempts {
 942            let disk = try fileSystem.read(url)
 943            var merged: [TextEdit]?
 944            if let disk, disk != state.mergeBase {
 945                try recovery.keep(disk, for: url, label: "external")
 946                try recovery.keep(state.encodedText(), for: url, label: "local")
 947                switch state.diskChanged(to: disk) {
 948                case .conflict(let conflicts): return .conflict(conflicts)
 949                case .merged(let edits), .reloaded(let edits): merged = edits
 950                case .unchanged: break
 951                }
 952            }
 953            let bytes = try state.encodedText()
 954            guard try fileSystem.read(url) == disk else { continue }
 955
 956            let replaced = try fileSystem.replace(url, with: bytes)
 957            state.didWrite(bytes)
 958            if replaced != disk, let replaced {
 959                try recovery.keep(replaced, for: url, label: "external")
 960                return .overwroteExternalChange(state.mergeOverwritten(replaced, base: disk ?? []))
 961            }
 962
 963            if let after = try fileSystem.read(url), after != bytes {
 964                try recovery.keep(bytes, for: url, label: "local")
 965                return .changedAfterWrite(state.diskChanged(to: after))
 966            }
 967            return merged.map { .mergedAndSaved($0) } ?? .saved
 968        }
 969        throw SaveError.fileKeepsChanging
 970    }
 971}
 972```
 973
 974- [ ] **Step 4: Implement `FileStorage.swift`**
 975
 976```swift
 977import CryptoKit
 978import Foundation
 979
 980/// Reads and replaces files under `NSFileCoordinator`, so iCloud and other coordinating
 981/// writers see a consistent file.
 982public struct CoordinatedFileSystem: FileSystem {
 983    public init() {}
 984
 985    public func read(_ url: URL) throws -> [UInt8]? {
 986        try coordinate(reading: url) { url in
 987            FileManager.default.fileExists(atPath: url.path) ? [UInt8](try Data(contentsOf: url)) : nil
 988        }
 989    }
 990
 991    public func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
 992        try coordinate(writing: url) { url in
 993            let manager = FileManager.default
 994            let folder = url.deletingLastPathComponent()
 995            let temporary = folder.appendingPathComponent(".\(url.lastPathComponent).orgstar-\(UUID().uuidString)")
 996            try Data(bytes).write(to: temporary)
 997            guard manager.fileExists(atPath: url.path) else {
 998                try manager.moveItem(at: temporary, to: url)
 999                return nil
1000            }
1001            // The backup is the file as it was at the moment of replacement, including any
1002            // write that landed after our last check.
1003            let backupName = ".\(url.lastPathComponent).orgstar-backup-\(UUID().uuidString)"
1004            _ = try manager.replaceItemAt(url, withItemAt: temporary, backupItemName: backupName, options: .withoutDeletingBackupItem)
1005            let backup = folder.appendingPathComponent(backupName)
1006            defer { try? manager.removeItem(at: backup) }
1007            return [UInt8](try Data(contentsOf: backup))
1008        }
1009    }
1010
1011    private func coordinate<T>(reading url: URL, _ body: (URL) throws -> T) throws -> T {
1012        var coordinationError: NSError?
1013        var result: Result<T, Error>?
1014        NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: url, options: [], error: &coordinationError) { url in
1015            result = Result { try body(url) }
1016        }
1017        if let coordinationError { throw coordinationError }
1018        return try result!.get()
1019    }
1020
1021    private func coordinate<T>(writing url: URL, _ body: (URL) throws -> T) throws -> T {
1022        var coordinationError: NSError?
1023        var result: Result<T, Error>?
1024        NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { url in
1025            result = Result { try body(url) }
1026        }
1027        if let coordinationError { throw coordinationError }
1028        return try result!.get()
1029    }
1030}
1031
1032/// Keeps the last `limit` displaced versions per file in `directory/<hash of path>/`.
1033public struct FileRecoveryStore: RecoveryStore {
1034    public let directory: URL
1035    public let limit: Int
1036
1037    public init(directory: URL, limit: Int = 20) {
1038        self.directory = directory
1039        self.limit = limit
1040    }
1041
1042    public func folder(for url: URL) -> URL {
1043        let digest = SHA256.hash(data: Data(url.standardizedFileURL.path.utf8))
1044        let name = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
1045        return directory.appendingPathComponent(name, isDirectory: true)
1046    }
1047
1048    public func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
1049        let folder = folder(for: url)
1050        let manager = FileManager.default
1051        try manager.createDirectory(at: folder, withIntermediateDirectories: true)
1052        // Zero-padded wall-clock nanoseconds sort by time; the UUID keeps same-instant names apart.
1053        let stamp = String(format: "%020llu", UInt64(Date().timeIntervalSince1970 * 1_000_000_000))
1054        let name = "\(stamp)-\(label)-\(UUID().uuidString.prefix(8))-\(url.lastPathComponent)"
1055        try Data(bytes).write(to: folder.appendingPathComponent(name))
1056        let kept = try manager.contentsOfDirectory(atPath: folder.path).sorted()
1057        for old in kept.dropLast(limit) {
1058            try manager.removeItem(at: folder.appendingPathComponent(old))
1059        }
1060    }
1061
1062    /// Kept versions, oldest first.
1063    public func versions(for url: URL) throws -> [URL] {
1064        let folder = folder(for: url)
1065        guard FileManager.default.fileExists(atPath: folder.path) else { return [] }
1066        return try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted().map { folder.appendingPathComponent($0) }
1067    }
1068}
1069```
1070
1071- [ ] **Step 5: Run everything, including the iOS build**
1072
1073Run: `swift test`, then `xcodebuild -scheme OrgDocument -destination 'generic/platform=iOS' build`
1074Expected: all pass; `BUILD SUCCEEDED`.
1075
1076- [ ] **Step 6: Commit**
1077
1078```bash
1079git add Sources/OrgDocument/Saving.swift Sources/OrgDocument/FileStorage.swift Tests/OrgDocumentTests/SaveTests.swift
1080git commit -m "Add save path with recovery and fault-injection tests"
1081```