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(
3333 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
3434 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
3535 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]),
36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]),
36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit", "OrgApp"]),
3737 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp", "OrgDocument"])
3838 ]
3939)
Sources/OrgApp/DocumentSession.swift +3
@@ -24,6 +24,8 @@ public final class DocumentSession {
2424 @ObservationIgnored public private(set) var document: DocumentState?
2525 public private(set) var outline: [OutlineEntry] = []
2626 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
2729 /// Set while the buffer and the file on disk conflict; says how to resolve it.
2830 public private(set) var conflict: String?
2931 /// Bumped to ask the editor to reveal an offset.
@@ -90,6 +92,7 @@ public final class DocumentSession {
9092 let state = buffer.document
9193 document = state
9294 if isDirty != state.isDirty { isDirty = state.isDirty }
95 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
9396 if updateOutline { outline = Presentation.outline(state.tree) }
9497 }
9598
Sources/Orgstar/ContentView.swift +27 −4
@@ -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 }
@@ -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) }
Sources/Orgstar/EditorView.swift +5 −3
@@ -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
@@ -27,7 +27,8 @@ struct EditorView: NSViewRepresentable {
2727 container.subviews.forEach { $0.removeFromSuperview() }
2828 coordinator.editor = nil
2929 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() }
3132 let scroll = editor.makeScrollView()
3233 scroll.frame = container.bounds
3334 scroll.autoresizingMask = [.width, .height]
@@ -37,6 +38,7 @@ struct EditorView: NSViewRepresentable {
3738 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)
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/OrgEditorAppKitTests/EditorTests.swift +25
@@ -2,6 +2,7 @@ import AppKit
22import OrgCore
33import OrgDocument
44import OrgPresentation
5@testable import OrgApp
56import Testing
67@testable import OrgEditorAppKit
78
@@ -250,6 +251,30 @@ struct BufferTests {
250251 #expect(h.textView.undoManager?.canUndo != true)
251252 h.checkInSync()
252253 }
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 }
253278}
254279
255280@MainActor