krz/orgstar

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

Commit 162a8a31f9

162a8a31f92fc97726d824e9f4ed04ee8d6c91af

parent: 923f63b7fc

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 03:48 UTC

Edit and save in the app

Layout: unified · split

Package.swift +1 −1
@@ -33,7 +33,7 @@ let package = Package(
33 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), 33 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
34 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), 34 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
35 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), 35 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]),
36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]), 36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit", "OrgApp"]),
37 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp", "OrgDocument"]) 37 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp", "OrgDocument"])
38 ] 38 ]
39) 39)
Sources/OrgApp/DocumentSession.swift +3
@@ -24,6 +24,8 @@ public final class DocumentSession {
24 @ObservationIgnored public private(set) var document: DocumentState? 24 @ObservationIgnored public private(set) var document: DocumentState?
25 public private(set) var outline: [OutlineEntry] = [] 25 public private(set) var outline: [OutlineEntry] = []
26 public private(set) var isDirty = false 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
27 /// Set while the buffer and the file on disk conflict; says how to resolve it. 29 /// Set while the buffer and the file on disk conflict; says how to resolve it.
28 public private(set) var conflict: String? 30 public private(set) var conflict: String?
29 /// Bumped to ask the editor to reveal an offset. 31 /// Bumped to ask the editor to reveal an offset.
@@ -90,6 +92,7 @@ public final class DocumentSession {
90 let state = buffer.document 92 let state = buffer.document
91 document = state 93 document = state
92 if isDirty != state.isDirty { isDirty = state.isDirty } 94 if isDirty != state.isDirty { isDirty = state.isDirty }
95 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
93 if updateOutline { outline = Presentation.outline(state.tree) } 96 if updateOutline { outline = Presentation.outline(state.tree) }
94 } 97 }
95 98
Sources/Orgstar/ContentView.swift +27 −4
@@ -4,8 +4,9 @@ import SwiftUI
4 4
5struct ContentView: View { 5struct ContentView: View {
6 let workspace: WorkspaceModel 6 let workspace: WorkspaceModel
7 let session: DocumentSession
7 @Bindable var commands: AppCommands 8 @Bindable var commands: AppCommands
8 @State private var session = DocumentSession() 9 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
9 @State private var selectedFile: String? 10 @State private var selectedFile: String?
10 @State private var query = "" 11 @State private var query = ""
11 @FocusState private var searchFocused: Bool 12 @FocusState private var searchFocused: Bool
@@ -19,7 +20,8 @@ struct ContentView: View {
19 if query.isEmpty { 20 if query.isEmpty {
20 OutlineColumn(session: session) 21 OutlineColumn(session: session)
21 } else { 22 } 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 }
23 selectedFile = location.path 25 selectedFile = location.path
24 session.show(location) 26 session.show(location)
25 } 27 }
@@ -38,13 +40,34 @@ struct ContentView: View {
38 } 40 }
39 } 41 }
40 .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar") 42 .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
43 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
41 .searchable(text: $query, placement: .toolbar, prompt: "Search notes") 44 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
42 .searchFocused($searchFocused) 45 .searchFocused($searchFocused)
43 .toolbar { 46 .toolbar {
44 if workspace.isIndexing { ProgressView().controlSize(.small) } 47 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 }
45 } 52 }
46 .onChange(of: selectedFile) { _, path in 53 .onChange(of: selectedFile) { old, path in
47 if let path, path != session.path { session.open(path) } 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.")
48 } 71 }
49 .onChange(of: workspace.changedFile?.version) { 72 .onChange(of: workspace.changedFile?.version) {
50 if let path = workspace.changedFile?.path { session.fileChanged(path) } 73 if let path = workspace.changedFile?.path { session.fileChanged(path) }
Sources/Orgstar/EditorView.swift +5 −3
@@ -3,8 +3,8 @@ import OrgApp
3import OrgEditorAppKit 3import OrgEditorAppKit
4import SwiftUI 4import SwiftUI
5 5
6/// The read-only TextKit 2 editor for the session's file. A new file gets a new editor; reveal 6/// The TextKit 2 editor for the session's file. A new file gets a new editor, which becomes the
7/// requests and changes on disk go to the existing one, so folds survive a reload. 7/// session's buffer; reveal requests go to the existing one.
8struct EditorView: NSViewRepresentable { 8struct EditorView: NSViewRepresentable {
9 let session: DocumentSession 9 let session: DocumentSession
10 10
@@ -27,7 +27,8 @@ struct EditorView: NSViewRepresentable {
27 container.subviews.forEach { $0.removeFromSuperview() } 27 container.subviews.forEach { $0.removeFromSuperview() }
28 coordinator.editor = nil 28 coordinator.editor = nil
29 if let document = session.document { 29 if let document = session.document {
30 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() }
31 let scroll = editor.makeScrollView() 32 let scroll = editor.makeScrollView()
32 scroll.frame = container.bounds 33 scroll.frame = container.bounds
33 scroll.autoresizingMask = [.width, .height] 34 scroll.autoresizingMask = [.width, .height]
@@ -37,6 +38,7 @@ struct EditorView: NSViewRepresentable {
37 session.attach(editor) 38 session.attach(editor)
38 } 39 }
39 } 40 }
41 container.window?.isDocumentEdited = session.isDirty
40 if let request = session.revealRequest, request.id != coordinator.revealID { 42 if let request = session.revealRequest, request.id != coordinator.revealID {
41 coordinator.revealID = request.id 43 coordinator.revealID = request.id
42 coordinator.editor?.reveal(request.offset) 44 coordinator.editor?.reveal(request.offset)
Sources/Orgstar/OrgstarApp.swift +66 −1
@@ -1,3 +1,4 @@
1import AppKit
1import OrgApp 2import OrgApp
2import SwiftUI 3import SwiftUI
3 4
@@ -8,18 +9,56 @@ final class AppCommands {
8 var showingQuickOpen = false 9 var showingQuickOpen = false
9 var showingFolderPicker = false 10 var showingFolderPicker = false
10 var focusSearch = false 11 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 }
11} 47}
12 48
13@main 49@main
14struct OrgstarApp: App { 50struct OrgstarApp: App {
51 @NSApplicationDelegateAdaptor private var delegate: AppDelegate
15 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) } 52 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
16 @State private var commands = AppCommands() 53 @State private var commands = AppCommands()
54 @State private var session = DocumentSession()
17 55
18 var body: some Scene { 56 var body: some Scene {
19 Window("Orgstar", id: "main") { 57 Window("Orgstar", id: "main") {
20 switch workspace { 58 switch workspace {
21 case .success(let model): 59 case .success(let model):
22 ContentView(workspace: model, commands: commands) 60 ContentView(workspace: model, session: session, commands: commands)
61 .onAppear { delegate.session = session }
23 case .failure(let error): 62 case .failure(let error):
24 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) 63 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
25 } 64 }
@@ -32,10 +71,36 @@ struct OrgstarApp: App {
32 Button("Quick Open…") { commands.showingQuickOpen = true } 71 Button("Quick Open…") { commands.showingQuickOpen = true }
33 .keyboardShortcut("p") 72 .keyboardShortcut("p")
34 } 73 }
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 }
35 CommandGroup(after: .textEditing) { 81 CommandGroup(after: .textEditing) {
36 Button("Search Notes") { commands.focusSearch = true } 82 Button("Search Notes") { commands.focusSearch = true }
37 .keyboardShortcut("f", modifiers: [.command, .shift]) 83 .keyboardShortcut("f", modifiers: [.command, .shift])
38 } 84 }
39 } 85 }
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)
40 } 105 }
41} 106}
Tests/OrgEditorAppKitTests/EditorTests.swift +25
@@ -2,6 +2,7 @@ import AppKit
2import OrgCore 2import OrgCore
3import OrgDocument 3import OrgDocument
4import OrgPresentation 4import OrgPresentation
5@testable import OrgApp
5import Testing 6import Testing
6@testable import OrgEditorAppKit 7@testable import OrgEditorAppKit
7 8
@@ -250,6 +251,30 @@ struct BufferTests {
250 #expect(h.textView.undoManager?.canUndo != true) 251 #expect(h.textView.undoManager?.canUndo != true)
251 h.checkInSync() 252 h.checkInSync()
252 } 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 }
253} 278}
254 279
255@MainActor 280@MainActor