krz/orgstar

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

docs/plans/2026-10-05-writable-app.md

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/docs/plans/2026-10-05-writable-app.md rendered · source · history · blame · raw

116 lines · 6915 bytes

  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/Saving.swift` | `Saver.overwrite(_:to:)` |
 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/Saving.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift`, `Tests/OrgDocumentTests/DocumentStateTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift`
 42
 43**Produces:**
 44
 45```swift
 46@MainActor
 47public 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
 54public final class StateBuffer: DocumentBuffer { public init(_ document: DocumentState) }
 55
 56extension Saver {
 57    /// Writes the buffer over whatever is on disk; the replaced version goes to recovery.
 58    public func overwrite(_ state: inout DocumentState, to url: URL) throws
 59}
 60
 61extension 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: `overwrite` writes ours and keeps the replaced version; 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
 77public enum SaveMode: String, Sendable, CaseIterable { case automatic, explicit }
 78
 79extension 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
 94extension 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".