Writable app !11

merged merged by cmc on 2026-10-05 03:48 UTC · krz/orgstar:phase2-writable into main

13 files changed, +604 −60

Layout: unified · split

Package.swift +2 −2
@@ -33,7 +33,7 @@ let package = Package(
3333 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
3434 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
3535 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]),
36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]),
37 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp"])
36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit", "OrgApp"]),
37 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp", "OrgDocument"])
3838 ]
3939)
Sources/OrgApp/DocumentSession.swift +145 −21
@@ -5,53 +5,173 @@ import OrgDocument
55import OrgIndex
66import OrgPresentation
77
8/// The file open in a window: its state, its outline, and requests for the editor to show a
9/// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`.
8public enum SaveMode: String, Sendable, CaseIterable {
9 /// Save shortly after typing stops.
10 case automatic
11 /// Save only on ⌘S.
12 case explicit
13}
14
15/// The file open in a window: its buffer, outline, saving, and requests for the editor to
16/// show a location. The view layer attaches the editor as the buffer and follows
17/// `revealRequest`.
1018@MainActor
1119@Observable
1220public final class DocumentSession {
1321 public private(set) var path: String?
14 public private(set) var document: DocumentState?
22 /// A copy of the buffer's state as of its last change. Not observed: it changes on every
23 /// keystroke; views follow `path`, `isDirty` and `outline`.
24 @ObservationIgnored public private(set) var document: DocumentState?
1525 public private(set) var outline: [OutlineEntry] = []
26 public private(set) var isDirty = false
27 /// The file isn't UTF-8, so it can't be edited or saved.
28 public private(set) var isReadOnly = false
29 /// Set while the buffer and the file on disk conflict; says how to resolve it.
30 public private(set) var conflict: String?
1631 /// Bumped to ask the editor to reveal an offset.
1732 public private(set) var revealRequest: (offset: Int, id: Int)?
18 /// New bytes from disk for the open file, for the editor to merge or reload.
19 public private(set) var diskBytes: (bytes: [UInt8], id: Int)?
2033 public var lastError: String?
34 public var saveMode: SaveMode = .automatic
35 public var autosaveDelay: Duration = .seconds(1)
2136
2237 private let fileSystem: FileSystem
38 private let recovery: RecoveryStore
39 @ObservationIgnored private var buffer: DocumentBuffer?
40 @ObservationIgnored var autosave: Task<Void, Never>?
41 @ObservationIgnored private var outlineUpdate: Task<Void, Never>?
2342 private var requestID = 0
2443
25 public init(fileSystem: FileSystem = CoordinatedFileSystem()) {
44 public init(
45 fileSystem: FileSystem = CoordinatedFileSystem(),
46 recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery"))
47 ) {
2648 self.fileSystem = fileSystem
49 self.recovery = recovery
2750 }
2851
52 private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
53 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
54
2955 public func open(_ path: String) {
3056 do {
3157 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
3258 lastError = "\((path as NSString).lastPathComponent) no longer exists."
3359 return
3460 }
35 let document = DocumentState(bytes: bytes)
61 autosave?.cancel()
3662 self.path = path
37 self.document = document
38 outline = Presentation.outline(document.tree)
63 buffer = StateBuffer(DocumentState(bytes: bytes))
64 conflict = nil
3965 revealRequest = nil
40 diskBytes = nil
66 refresh()
4167 } catch {
4268 lastError = "Can't open \(path): \(error.localizedDescription)"
4369 }
4470 }
4571
72 /// Makes `buffer`, built from `document`, the live state of the open file.
73 public func attach(_ buffer: DocumentBuffer) {
74 self.buffer = buffer
75 refresh()
76 }
77
78 /// The buffer changed through typing. The outline follows once typing pauses.
79 public func bufferChanged() {
80 refresh(outline: false)
81 outlineUpdate?.cancel()
82 outlineUpdate = Task { [weak self] in
83 try? await Task.sleep(for: .milliseconds(300))
84 guard !Task.isCancelled, let self, let document = self.document else { return }
85 self.outline = Presentation.outline(document.tree)
86 }
87 scheduleAutosave()
88 }
89
90 private func refresh(outline updateOutline: Bool = true) {
91 guard let buffer else { return }
92 let state = buffer.document
93 document = state
94 if isDirty != state.isDirty { isDirty = state.isDirty }
95 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
96 if updateOutline { outline = Presentation.outline(state.tree) }
97 }
98
99 // MARK: - Saving
100
101 private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) }
102
103 private func scheduleAutosave() {
104 autosave?.cancel()
105 guard saveMode == .automatic, isDirty, conflict == nil else { return }
106 autosave = Task { [weak self, autosaveDelay] in
107 try? await Task.sleep(for: autosaveDelay)
108 guard !Task.isCancelled else { return }
109 self?.save()
110 }
111 }
112
113 /// Saves now. True when nothing is left unsaved.
114 @discardableResult
115 public func save() -> Bool {
116 autosave?.cancel()
117 guard let buffer, let url, isDirty else { return !isDirty }
118 guard conflict == nil else { return false }
119 do {
120 let outcome = try buffer.update { try saver.save(&$0, to: url) }
121 switch outcome {
122 case .saved, .mergedAndSaved:
123 break
124 case .conflict:
125 conflict = "\(name) changed on disk, and the changes conflict with yours."
126 case .overwroteExternalChange(let change), .changedAfterWrite(let change):
127 if case .conflict = change {
128 conflict = "\(name) changed on disk while saving, and the changes conflict with yours."
129 }
130 }
131 } catch {
132 lastError = "Can't save \(name): \(error.localizedDescription)"
133 }
134 refresh()
135 return !isDirty
136 }
137
138 /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery.
139 public func keepMine() {
140 guard let buffer, let url else { return }
141 do {
142 try buffer.update { try saver.overwrite(&$0, to: url) }
143 conflict = nil
144 } catch {
145 lastError = "Can't save \(name): \(error.localizedDescription)"
146 }
147 refresh()
148 }
149
150 /// Resolves a conflict by loading the disk version; the buffer goes to recovery.
151 public func useDiskVersion() {
152 guard let buffer, let url else { return }
153 do {
154 guard let bytes = try fileSystem.read(url) else { return }
155 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
156 buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) }
157 conflict = nil
158 } catch {
159 lastError = "Can't read \(name): \(error.localizedDescription)"
160 }
161 refresh()
162 }
163
164 // MARK: - Locations
165
46166 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
47 /// are used only if the file is unchanged since it was indexed; otherwise the heading is
48 /// found again by position and title.
167 /// are used only if the buffer matches what was indexed; otherwise the heading is found
168 /// again by position and title.
49169 public func show(_ location: HeadingLocation) {
50170 if path != location.path { open(location.path) }
51171 guard let document else { return }
52172 let hash = FileRecord.hash(document.mergeBase)
53173 let offset: Int
54 if hash == location.contentHash {
174 if !isDirty, hash == location.contentHash {
55175 offset = location.start
56176 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
57177 offset = outline[location.ordinal].start
@@ -70,13 +190,17 @@ public final class DocumentSession {
70190
71191 /// Called when the workspace sees the open file change on disk.
72192 public func fileChanged(_ path: String) {
73 guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
74 guard bytes != document?.mergeBase else { return }
75 var updated = document ?? DocumentState(bytes: bytes)
76 _ = updated.diskChanged(to: bytes)
77 document = updated
78 outline = Presentation.outline(updated.tree)
79 requestID += 1
80 diskBytes = (bytes, requestID)
193 guard path == self.path, let buffer, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
194 guard bytes != buffer.document.mergeBase else { return }
195 switch buffer.update({ $0.diskChanged(to: bytes) }) {
196 case .conflict:
197 autosave?.cancel()
198 conflict = "\(name) changed on disk, and the changes conflict with yours."
199 case .merged, .reloaded:
200 conflict = nil
201 case .unchanged:
202 break
203 }
204 refresh()
81205 }
82206}
Sources/OrgApp/WorkspaceModel.swift +16
@@ -1,5 +1,6 @@
11import Foundation
22import Observation
3import OrgDocument
34import OrgIndex
45import OrgWorkspace
56
@@ -22,6 +23,7 @@ public final class WorkspaceModel {
2223 private var resolvedRoots: [String: URL] = [:]
2324 @ObservationIgnored private var watcher: AnyObject?
2425 private var changeVersion = 0
26 @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)?
2527
2628 /// `directory` holds the index and the list of roots; nil keeps both in memory.
2729 public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws {
@@ -192,6 +194,20 @@ public final class WorkspaceModel {
192194 (try? index.search(query, overlay: overlay)) ?? []
193195 }
194196
197 /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place
198 /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text
199 /// changes.
200 public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] {
201 guard let path, let document, document.isDirty, let bytes = try? document.encodedText(),
202 let entry = files.first(where: { $0.path == path }) else { return [:] }
203 if let cached = overlayCache, cached.record.path == path, cached.text == document.text {
204 return [path: cached.record]
205 }
206 let record = FileRecord(path: path, root: entry.root, kind: entry.kind, bytes: bytes, mtime: Date().timeIntervalSince1970, settings: settings)
207 overlayCache = (document.text, record)
208 return [path: record]
209 }
210
195211 public func quickOpen(_ query: String) -> [FileEntry] {
196212 FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
197213 }
Sources/OrgDocument/Buffer.swift added +24
@@ -0,0 +1,24 @@
1import Foundation
2
3/// Where an open document's live state is kept: the editor while one shows it, a
4/// `StateBuffer` before that.
5@MainActor
6public protocol DocumentBuffer: AnyObject {
7 var document: DocumentState { get }
8 /// Applies a change made outside typing (save, disk change, revert) and updates whatever
9 /// shows the document.
10 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T
11}
12
13@MainActor
14public final class StateBuffer: DocumentBuffer {
15 public private(set) var document: DocumentState
16
17 public init(_ document: DocumentState) {
18 self.document = document
19 }
20
21 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
22 try change(&document)
23 }
24}
Sources/OrgDocument/Saving.swift +10
@@ -78,4 +78,14 @@ public struct Saver: Sendable {
7878 }
7979 throw SaveError.fileKeepsChanging
8080 }
81
82 /// Writes the buffer over whatever is on disk, for keeping our version over a conflicting
83 /// one. The replaced version goes to recovery unless it is the merge base.
84 public func overwrite(_ state: inout DocumentState, to url: URL) throws {
85 let bytes = try state.encodedText()
86 if let replaced = try fileSystem.replace(url, with: bytes), replaced != state.mergeBase {
87 try recovery.keep(replaced, for: url, label: "external")
88 }
89 state.didWrite(bytes)
90 }
8191}
Sources/OrgEditorAppKit/OrgEditor.swift +34 −20
@@ -153,21 +153,15 @@ public final class OrgEditor: NSObject {
153153
154154 // MARK: - Saving
155155
156 /// Saves through `saver`. When a merge changes the buffer, the text view is reloaded and
157 /// folds are mapped through the change.
156 /// Saves through `saver`. A merge that changes the buffer reloads the text view.
158157 @discardableResult
159158 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
160 let before = document.text
161 let outcome = try saver.save(&document, to: url)
162 if document.text != before {
163 let edits = lineEdits(from: before, to: document.text)
164 load(document.text)
165 let mapped = view.mapped(through: edits)
166 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
167 }
168 return outcome
159 try update { try saver.save(&$0, to: url) }
169160 }
170161
162 /// Called after every change to the document, typed or not.
163 public var onChange: (() -> Void)?
164
171165 // MARK: - Commands
172166
173167 /// Called with messages commands report, such as why one couldn't run here.
@@ -219,15 +213,9 @@ public final class OrgEditor: NSObject {
219213
220214 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
221215 /// its headings.
222 public func diskChanged(to bytes: [UInt8]) {
223 let edits: [TextEdit]
224 switch document.diskChanged(to: bytes) {
225 case .reloaded(let changes), .merged(let changes): edits = changes
226 case .unchanged, .conflict: return
227 }
228 let mapped = view.mapped(through: edits)
229 load(document.text)
230 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
216 @discardableResult
217 public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange {
218 update { $0.diskChanged(to: bytes) }
231219 }
232220
233221 // MARK: - Folding
@@ -417,6 +405,7 @@ public final class OrgEditor: NSObject {
417405 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
418406 }
419407 onEditTiming?(clock.now - start)
408 onChange?()
420409 }
421410
422411 /// Columns a wrapped continuation of `line` should start past the line's own start.
@@ -475,6 +464,31 @@ public final class OrgEditor: NSObject {
475464 }
476465}
477466
467extension OrgEditor: DocumentBuffer {
468 /// Runs `change`; if it changed the text, reloads the view with folds and the selection
469 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
470 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
471 let before = document.text
472 let selected = textView.selectedRange()
473 let result = try change(&document)
474 if document.text != before {
475 let edits = lineEdits(from: before, to: document.text)
476 var state = view
477 state.selection = [selected.location..<NSMaxRange(selected)]
478 let mapped = state.mapped(through: edits)
479 load(document.text)
480 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
481 let selection = mapped.selection[0]
482 setCaret(selection.lowerBound)
483 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
484 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
485 textView.undoManager?.removeAllActions(withTarget: textView)
486 }
487 onChange?()
488 return result
489 }
490}
491
478492extension OrgEditor: NSTextViewDelegate {
479493 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
480494 switch selector {
Sources/Orgstar/ContentView.swift +29 −6
@@ -4,8 +4,9 @@ import SwiftUI
44
55struct ContentView: View {
66 let workspace: WorkspaceModel
7 let session: DocumentSession
78 @Bindable var commands: AppCommands
8 @State private var session = DocumentSession()
9 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
910 @State private var selectedFile: String?
1011 @State private var query = ""
1112 @FocusState private var searchFocused: Bool
@@ -19,7 +20,8 @@ struct ContentView: View {
1920 if query.isEmpty {
2021 OutlineColumn(session: session)
2122 } else {
22 SearchResults(results: workspace.search(query)) { location in
23 SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in
24 guard location.path == session.path || UnsavedChanges.resolve(session) else { return }
2325 selectedFile = location.path
2426 session.show(location)
2527 }
@@ -27,7 +29,7 @@ struct ContentView: View {
2729 }
2830 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
2931 } detail: {
30 if session.document != nil {
32 if session.path != nil {
3133 EditorView(session: session)
3234 } else {
3335 ContentUnavailableView(
@@ -38,13 +40,34 @@ struct ContentView: View {
3840 }
3941 }
4042 .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
43 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
4144 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
4245 .searchFocused($searchFocused)
4346 .toolbar {
4447 if workspace.isIndexing { ProgressView().controlSize(.small) }
48 if session.conflict != nil {
49 Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true }
50 .help("The file changed on disk, and the changes conflict with yours")
51 }
4552 }
46 .onChange(of: selectedFile) { _, path in
47 if let path, path != session.path { session.open(path) }
53 .onChange(of: selectedFile) { old, path in
54 guard let path, path != session.path else { return }
55 if UnsavedChanges.resolve(session) {
56 session.open(path)
57 } else {
58 selectedFile = old
59 }
60 }
61 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
62 .onChange(of: session.conflict) { _, conflict in
63 if conflict != nil { commands.showingConflict = true }
64 }
65 .alert("Conflicting changes", isPresented: $commands.showingConflict) {
66 Button("Keep Mine") { session.keepMine() }
67 Button("Use Disk Version") { session.useDiskVersion() }
68 Button("Decide Later", role: .cancel) {}
69 } message: {
70 Text((session.conflict ?? "") + " The version you don't keep is saved in the recovery folder.")
4871 }
4972 .onChange(of: workspace.changedFile?.version) {
5073 if let path = workspace.changedFile?.path { session.fileChanged(path) }
@@ -135,7 +158,7 @@ struct OutlineColumn: View {
135158 .buttonStyle(.plain)
136159 }
137160 .overlay {
138 if session.document != nil, session.outline.isEmpty {
161 if session.path != nil, session.outline.isEmpty {
139162 ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
140163 }
141164 }
Sources/Orgstar/EditorView.swift +6 −8
@@ -3,8 +3,8 @@ import OrgApp
33import OrgEditorAppKit
44import SwiftUI
55
6/// The read-only TextKit 2 editor for the session's file. A new file gets a new editor; reveal
7/// requests and changes on disk go to the existing one, so folds survive a reload.
6/// The TextKit 2 editor for the session's file. A new file gets a new editor, which becomes the
7/// session's buffer; reveal requests go to the existing one.
88struct EditorView: NSViewRepresentable {
99 let session: DocumentSession
1010
@@ -12,7 +12,6 @@ struct EditorView: NSViewRepresentable {
1212 var editor: OrgEditor?
1313 var path: String?
1414 var revealID = 0
15 var diskID = 0
1615 }
1716
1817 func makeCoordinator() -> Coordinator { Coordinator() }
@@ -28,22 +27,21 @@ struct EditorView: NSViewRepresentable {
2827 container.subviews.forEach { $0.removeFromSuperview() }
2928 coordinator.editor = nil
3029 if let document = session.document {
31 let editor = OrgEditor(document: document, frame: container.bounds, editable: false)
30 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable)
31 editor.onChange = { [weak session] in session?.bufferChanged() }
3232 let scroll = editor.makeScrollView()
3333 scroll.frame = container.bounds
3434 scroll.autoresizingMask = [.width, .height]
3535 container.addSubview(scroll)
3636 container.window?.makeFirstResponder(editor.textView)
3737 coordinator.editor = editor
38 session.attach(editor)
3839 }
3940 }
41 container.window?.isDocumentEdited = session.isDirty
4042 if let request = session.revealRequest, request.id != coordinator.revealID {
4143 coordinator.revealID = request.id
4244 coordinator.editor?.reveal(request.offset)
4345 }
44 if let disk = session.diskBytes, disk.id != coordinator.diskID {
45 coordinator.diskID = disk.id
46 coordinator.editor?.diskChanged(to: disk.bytes)
47 }
4846 }
4947}
Sources/Orgstar/OrgstarApp.swift +66 −1
@@ -1,3 +1,4 @@
1import AppKit
12import OrgApp
23import SwiftUI
34
@@ -8,18 +9,56 @@ final class AppCommands {
89 var showingQuickOpen = false
910 var showingFolderPicker = false
1011 var focusSearch = false
12 var showingConflict = false
13}
14
15@MainActor
16final class AppDelegate: NSObject, NSApplicationDelegate {
17 var session: DocumentSession?
18
19 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
20
21 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
22 guard let session else { return .terminateNow }
23 return UnsavedChanges.resolve(session) ? .terminateNow : .terminateCancel
24 }
25}
26
27/// Unsaved edits when leaving a file or quitting.
28@MainActor
29enum UnsavedChanges {
30 /// Whether the open file can be left: saved in automatic mode, or as the user chooses in
31 /// explicit mode. A save that fails or conflicts keeps the file open.
32 static func resolve(_ session: DocumentSession) -> Bool {
33 guard session.isDirty else { return true }
34 if session.saveMode == .automatic { return session.save() }
35 let alert = NSAlert()
36 alert.messageText = "Save changes to \(session.path.map { ($0 as NSString).lastPathComponent } ?? "this file")?"
37 alert.informativeText = "Your changes will be lost if you don't save them."
38 alert.addButton(withTitle: "Save")
39 alert.addButton(withTitle: "Don't Save")
40 alert.addButton(withTitle: "Cancel")
41 switch alert.runModal() {
42 case .alertFirstButtonReturn: return session.save()
43 case .alertSecondButtonReturn: return true
44 default: return false
45 }
46 }
1147}
1248
1349@main
1450struct OrgstarApp: App {
51 @NSApplicationDelegateAdaptor private var delegate: AppDelegate
1552 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
1653 @State private var commands = AppCommands()
54 @State private var session = DocumentSession()
1755
1856 var body: some Scene {
1957 Window("Orgstar", id: "main") {
2058 switch workspace {
2159 case .success(let model):
22 ContentView(workspace: model, commands: commands)
60 ContentView(workspace: model, session: session, commands: commands)
61 .onAppear { delegate.session = session }
2362 case .failure(let error):
2463 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
2564 }
@@ -32,10 +71,36 @@ struct OrgstarApp: App {
3271 Button("Quick Open…") { commands.showingQuickOpen = true }
3372 .keyboardShortcut("p")
3473 }
74 CommandGroup(replacing: .saveItem) {
75 Button("Save") {
76 if !session.save(), session.conflict != nil { commands.showingConflict = true }
77 }
78 .keyboardShortcut("s")
79 .disabled(session.path == nil || session.isReadOnly)
80 }
3581 CommandGroup(after: .textEditing) {
3682 Button("Search Notes") { commands.focusSearch = true }
3783 .keyboardShortcut("f", modifiers: [.command, .shift])
3884 }
3985 }
86 Settings {
87 SettingsView()
88 }
89 }
90}
91
92struct SettingsView: View {
93 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
94
95 var body: some View {
96 Form {
97 Picker("Save files:", selection: $saveMode) {
98 Text("Automatically, when typing stops").tag(SaveMode.automatic)
99 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
100 }
101 .pickerStyle(.radioGroup)
102 }
103 .padding(20)
104 .frame(width: 420)
40105 }
41106}
Tests/OrgAppTests/AppTests.swift +98 −2
@@ -1,5 +1,6 @@
11import Foundation
22import OrgCore
3import OrgDocument
34import OrgIndex
45import Testing
56@testable import OrgApp
@@ -90,7 +91,7 @@ struct DocumentSessionTests {
9091 @Test func openShowAndReload() throws {
9192 let notes = try Folder()
9293 let file = try notes.write("a.org", "* One\n* Two\n")
93 let session = DocumentSession()
94 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
9495 session.open(file.path)
9596 #expect(session.outline.map(\.title) == ["One", "Two"])
9697
@@ -101,7 +102,6 @@ struct DocumentSessionTests {
101102 try notes.write("a.org", "intro\n* One\n* Two\n")
102103 session.fileChanged(file.path)
103104 #expect(session.outline.map(\.start) == [6, 12])
104 #expect(session.diskBytes != nil)
105105 session.show(location)
106106 #expect(session.revealRequest?.offset == 12)
107107 }
@@ -113,3 +113,99 @@ struct DocumentSessionTests {
113113 #expect(session.document == nil)
114114 }
115115}
116
117@MainActor
118struct SessionSavingTests {
119 let notes: Folder
120 let file: URL
121 let recovery: FileRecoveryStore
122 let session: DocumentSession
123
124 init() throws {
125 notes = try Folder()
126 file = try notes.write("a.org", "a\nb\nc\n")
127 recovery = FileRecoveryStore(directory: notes.url.appendingPathComponent(".recovery"))
128 session = DocumentSession(recovery: recovery)
129 session.saveMode = .explicit
130 session.open(file.path)
131 }
132
133 /// Types `text` at `offset`, as the editor would.
134 func type(_ text: String, at offset: Int) throws {
135 let buffer = StateBuffer(session.document!)
136 session.attach(buffer)
137 try buffer.update { try $0.apply([TextEdit(range: offset..<offset, replacement: text)], baseRevision: $0.revision) }
138 session.bufferChanged()
139 }
140
141 var disk: String { (try? String(contentsOf: file, encoding: .utf8)) ?? "" }
142
143 @Test func saveWritesAndClearsDirty() throws {
144 try type("x", at: 0)
145 #expect(session.isDirty)
146 #expect(session.save())
147 #expect(!session.isDirty)
148 #expect(disk == "xa\nb\nc\n")
149 }
150
151 @Test func automaticModeSavesAfterTheDelay() async throws {
152 session.saveMode = .automatic
153 session.autosaveDelay = .milliseconds(10)
154 try type("x", at: 0)
155 #expect(disk == "a\nb\nc\n")
156 await session.autosave?.value
157 #expect(disk == "xa\nb\nc\n")
158 #expect(!session.isDirty)
159 }
160
161 @Test func explicitModeWaitsForSave() throws {
162 try type("x", at: 0)
163 #expect(session.autosave == nil)
164 }
165
166 @Test func externalChangeMergesIntoEdits() throws {
167 try type("x", at: 0)
168 try notes.write("a.org", "a\nb\nC\n")
169 session.fileChanged(file.path)
170 #expect(session.document?.text == "xa\nb\nC\n")
171 #expect(session.conflict == nil)
172 #expect(session.save())
173 #expect(disk == "xa\nb\nC\n")
174 }
175
176 @Test func conflictKeepMine() throws {
177 try type("x", at: 0)
178 try notes.write("a.org", "z\nb\nc\n")
179 session.fileChanged(file.path)
180 #expect(session.conflict != nil)
181 #expect(!session.save())
182 #expect(disk == "z\nb\nc\n")
183 session.keepMine()
184 #expect(session.conflict == nil)
185 #expect(disk == "xa\nb\nc\n")
186 let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) }
187 #expect(kept.contains("z\nb\nc\n"))
188 }
189
190 @Test func conflictUseDiskVersion() throws {
191 try type("x", at: 0)
192 try notes.write("a.org", "z\nb\nc\n")
193 #expect(!session.save())
194 #expect(session.conflict != nil)
195 session.useDiskVersion()
196 #expect(session.conflict == nil)
197 #expect(!session.isDirty)
198 #expect(session.document?.text == "z\nb\nc\n")
199 let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) }
200 #expect(kept.contains("xa\nb\nc\n"))
201 }
202
203 @Test func searchSeesUnsavedHeadings() async throws {
204 let workspace = try WorkspaceModel(directory: nil)
205 try await workspace.addRoot(notes.url)
206 try type("* Unsaved heading\n", at: 0)
207 let overlay = workspace.overlay(path: session.path, document: session.document)
208 #expect(workspace.search("unsaved").isEmpty)
209 #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"])
210 }
211}
Tests/OrgDocumentTests/SaveTests.swift +10
@@ -103,6 +103,16 @@ struct SaveTests {
103103 #expect(doc.text == "A\nb\nc\n")
104104 }
105105
106 @Test func overwriteKeepsTheReplacedVersion() throws {
107 let files = FaultyFileSystem("Z\nb\nc\n")
108 let recovery = MemoryRecovery()
109 var doc = try editedState()
110 try Saver(fileSystem: files, recovery: recovery).overwrite(&doc, to: url)
111 #expect(files.text == "A\nb\nc\n")
112 #expect(!doc.isDirty)
113 #expect(recovery.contains("Z\nb\nc\n"))
114 }
115
106116 @Test func changedBetweenReadAndCheckRetries() throws {
107117 let files = FaultyFileSystem("a\nb\nc\n")
108118 files.beforeRead[2] = Array("a\nb\nC\n".utf8)
Tests/OrgEditorAppKitTests/EditorTests.swift +48
@@ -2,6 +2,7 @@ import AppKit
22import OrgCore
33import OrgDocument
44import OrgPresentation
5@testable import OrgApp
56import Testing
67@testable import OrgEditorAppKit
78
@@ -229,6 +230,53 @@ struct EditingTests {
229230 }
230231}
231232
233@MainActor
234struct BufferTests {
235 @Test func updateMapsCaretAndFoldsAndClearsUndo() {
236 let h = Harness(outline)
237 var changes = 0
238 h.editor.onChange = { changes += 1 }
239 h.caret(at: 1)
240 h.textView.insertText("x", replacementRange: NSRange(location: 4, length: 0))
241 #expect(changes == 1)
242 RunLoop.current.run(until: Date())
243 #expect(h.textView.undoManager?.canUndo == true)
244 h.editor.setFolds(FoldState(folds: [h.offset(of: "** b")]))
245 h.caret(at: h.offset(of: "* c") + 2)
246 h.editor.update { $0 = DocumentState(bytes: Array(("new\n" + outline).utf8)) }
247 #expect(changes == 2)
248 #expect(h.string == "new\n" + outline)
249 #expect(h.caret == h.offset(of: "* c") + 2)
250 #expect(h.editor.foldState.folds == [h.offset(of: "** b")])
251 #expect(h.textView.undoManager?.canUndo != true)
252 h.checkInSync()
253 }
254
255 /// The wiring the app uses: the editor is the session's buffer and reports typing.
256 @Test func sessionSavesWhatIsTyped() async throws {
257 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
258 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
259 defer { try? FileManager.default.removeItem(at: folder) }
260 let file = folder.appendingPathComponent("a.org")
261 try Data(outline.utf8).write(to: file)
262 let session = DocumentSession(recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r")))
263 session.autosaveDelay = .milliseconds(10)
264 session.open(file.path)
265 let h = Harness(try #require(session.document))
266 h.editor.onChange = { [weak session] in session?.bufferChanged() }
267 session.attach(h.editor)
268 h.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0))
269 #expect(session.isDirty)
270 await session.autosave?.value
271 #expect(!session.isDirty)
272 #expect(try String(contentsOf: file, encoding: .utf8) == "x" + outline)
273
274 try Data(("x" + outline + "end\n").utf8).write(to: file)
275 session.fileChanged(file.path)
276 #expect(h.string == "x" + outline + "end\n")
277 }
278}
279
232280@MainActor
233281struct RestyleTests {
234282 func indent(_ h: Harness, at line: String) -> CGFloat? {
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/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".