Commit 1f40d56d3e
Verified · cmc
Layout: unified · split
docs/plans/2026-10-05-writable-app.md added +116
| @@ -0,0 +1,116 @@ | |||
| 1 | # Writable App 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:** Editing on in the Mac app: typing and undo, ⌘S through the merge-safe save path, a save-mode setting (automatic after an idle delay, or explicit), dirty state, external changes merged into edited buffers with a conflict choice, and unsaved text visible to search. | ||
| 6 | |||
| 7 | **Architecture:** The editor owns the live `DocumentState`; the session reaches it through a `DocumentBuffer` protocol in `OrgDocument` (`document` plus `update(_:)`, which applies a change made outside typing and refreshes the view). Save, disk change, keep-mine and use-disk are all `update` calls on the buffer, so the editor maps folds and the caret through them the same way. `DocumentSession` keeps a copy of the buffer's state for SwiftUI (dirty flag, outline), runs autosave, and holds conflict state. Before an editor exists, and in tests, a `StateBuffer` holds the state. Search overlays the open file's unsaved text through `WorkspaceModel.overlay`, cached until the text changes. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6.2 tools, SwiftUI, AppKit, TextKit 2, Swift Testing. | ||
| 10 | |||
| 11 | **Spec:** `docs/design.md`, "Saving" and "Index schema" (overlay). | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Every write goes through `Saver`; every version a write or a conflict choice displaces goes to the recovery folder (`<data dir>/Recovery`, last 20 per file). | ||
| 16 | - Files that are not valid UTF-8 stay read-only. | ||
| 17 | - A conflict never writes; autosave pauses while one is open. | ||
| 18 | - `OrgApp` imports no AppKit or SwiftUI. | ||
| 19 | |||
| 20 | ## Defaults chosen (user may change) | ||
| 21 | |||
| 22 | - Save mode defaults to automatic, 1 s after the last edit. The delay is fixed. | ||
| 23 | - Conflicts: an alert with Keep Mine, Use Disk Version, Decide Later. No diff view yet. | ||
| 24 | - One document per window. Switching files with unsaved edits saves (automatic) or asks Save / Don't Save / Cancel (explicit). Closing the window quits the app, through the same check. | ||
| 25 | |||
| 26 | ## File structure | ||
| 27 | |||
| 28 | | File | Responsibility | | ||
| 29 | | --- | --- | | ||
| 30 | | `Sources/OrgDocument/Buffer.swift` | `DocumentBuffer`, `StateBuffer` | | ||
| 31 | | `Sources/OrgDocument/DocumentState.swift` | `rebase(onto:)` | | ||
| 32 | | `Sources/OrgEditorAppKit/OrgEditor.swift` | `DocumentBuffer` conformance: `update(_:)` maps folds and caret and clears text-view undo when the text is replaced; `onChange` | | ||
| 33 | | `Sources/OrgApp/DocumentSession.swift` | Buffer, dirty flag, save, autosave, conflict choices, `SaveMode` | | ||
| 34 | | `Sources/OrgApp/WorkspaceModel.swift` | `overlay(path:document:)` | | ||
| 35 | | `Sources/Orgstar/*.swift` | Editable editor, ⌘S, Settings, confirm on switch and quit, conflict alert, edited dot | | ||
| 36 | |||
| 37 | --- | ||
| 38 | |||
| 39 | ### Task 1: Buffers | ||
| 40 | |||
| 41 | **Files:** `Sources/OrgDocument/Buffer.swift`, `Sources/OrgDocument/DocumentState.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift`, `Tests/OrgDocumentTests/DocumentStateTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift` | ||
| 42 | |||
| 43 | **Produces:** | ||
| 44 | |||
| 45 | ```swift | ||
| 46 | @MainActor | ||
| 47 | public protocol DocumentBuffer: AnyObject { | ||
| 48 | var document: DocumentState { get } | ||
| 49 | /// Applies a change made outside typing and updates whatever shows the document. | ||
| 50 | func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T | ||
| 51 | } | ||
| 52 | |||
| 53 | @MainActor | ||
| 54 | public final class StateBuffer: DocumentBuffer { public init(_ document: DocumentState) } | ||
| 55 | |||
| 56 | extension DocumentState { | ||
| 57 | /// Makes `bytes` the merge base and keeps the text, so the next save replaces them. | ||
| 58 | public mutating func rebase(onto bytes: [UInt8]) | ||
| 59 | } | ||
| 60 | |||
| 61 | extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)?` | ||
| 62 | ``` | ||
| 63 | |||
| 64 | `OrgEditor.update` records the text and selection, runs the change, and if the text changed: computes `lineEdits(from:to:)`, reloads the storage, maps folds and the selection through the edits, and removes the text view's undo actions (they refer to the old text). `save(using:to:)` and `diskChanged(to:)` become `update` calls; `diskChanged` returns the `ExternalChange`. | ||
| 65 | |||
| 66 | - [ ] Tests: `rebase` keeps text and makes the buffer dirty against new bytes; editor `update` replacing text keeps the caret on the same line content, keeps folds, and leaves nothing to undo; typing calls `onChange`. | ||
| 67 | - [ ] Implement; `swift test` green. | ||
| 68 | - [ ] Commit "Add document buffers". | ||
| 69 | |||
| 70 | ### Task 2: Session saving | ||
| 71 | |||
| 72 | **Files:** `Sources/OrgApp/DocumentSession.swift`, `Sources/OrgApp/WorkspaceModel.swift`, `Tests/OrgAppTests/AppTests.swift` | ||
| 73 | |||
| 74 | **Produces:** | ||
| 75 | |||
| 76 | ```swift | ||
| 77 | public enum SaveMode: String, Sendable, CaseIterable { case automatic, explicit } | ||
| 78 | |||
| 79 | extension DocumentSession { | ||
| 80 | public init(fileSystem: FileSystem = CoordinatedFileSystem(), recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery"))) | ||
| 81 | public var saveMode: SaveMode | ||
| 82 | public private(set) var isDirty: Bool | ||
| 83 | /// Set while the buffer and the disk conflict; the message says how. | ||
| 84 | public private(set) var conflict: String? | ||
| 85 | public func attach(_ buffer: DocumentBuffer) | ||
| 86 | /// The buffer changed through typing. | ||
| 87 | public func bufferChanged() | ||
| 88 | /// Saves now. True when nothing is left unsaved. | ||
| 89 | @discardableResult public func save() -> Bool | ||
| 90 | public func keepMine() | ||
| 91 | public func useDiskVersion() | ||
| 92 | } | ||
| 93 | |||
| 94 | extension WorkspaceModel { | ||
| 95 | /// Index rows for the open file's unsaved text, keyed by path; empty when it has none. | ||
| 96 | public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] | ||
| 97 | } | ||
| 98 | ``` | ||
| 99 | |||
| 100 | `fileChanged` calls `buffer.update { $0.diskChanged(to:) }` and sets `conflict` on a conflict; `diskBytes` goes away. `show(_:)` uses indexed offsets only when the buffer is clean. | ||
| 101 | |||
| 102 | - [ ] Tests: edit then `save` writes and clears dirty; automatic mode saves after the delay, explicit does not; an external change merges into an edited buffer; a conflicting change sets `conflict`, blocks saving, and `keepMine` writes ours with theirs in recovery while `useDiskVersion` loads theirs with ours in recovery; search with the overlay finds an unsaved heading. | ||
| 103 | - [ ] Implement; `swift test` green. | ||
| 104 | - [ ] Commit "Save from the session". | ||
| 105 | |||
| 106 | ### Task 3: The app | ||
| 107 | |||
| 108 | **Files:** `Sources/Orgstar/*.swift` | ||
| 109 | |||
| 110 | - Session moves to app state so the app delegate can check it on quit; closing the last window quits. | ||
| 111 | - Editor is editable when the file is; it attaches to the session and calls `bufferChanged` on edits; the window shows the edited dot. | ||
| 112 | - File ▸ Save (⌘S); Settings (⌘,) with the save mode in `@AppStorage("saveMode")`. | ||
| 113 | - Switching files and quitting with unsaved edits: save in automatic mode, ask in explicit mode; a failed save cancels. | ||
| 114 | - Conflict alert with the three choices. | ||
| 115 | - [ ] Verify in the built app with a scratch data dir: type, ⌘S, undo, external edit merges, conflicting external edit shows the alert, search finds unsaved text. | ||
| 116 | - [ ] Commit "Edit and save in the app". | ||