Commit 162a8a31f9
Verified · cmc
Layout: unified · split
Package.swift +1 −1
| @@ -33,7 +33,7 @@ let package = Package( | ||
| 33 | 33 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
| 34 | 34 | .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), |
| 35 | 35 | .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), |
| 36 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]), | |
| 36 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit", "OrgApp"]), | |
| 37 | 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 | 24 | @ObservationIgnored public private(set) var document: DocumentState? |
| 25 | 25 | public private(set) var outline: [OutlineEntry] = [] |
| 26 | 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 | 29 | /// Set while the buffer and the file on disk conflict; says how to resolve it. |
| 28 | 30 | public private(set) var conflict: String? |
| 29 | 31 | /// Bumped to ask the editor to reveal an offset. |
| @@ -90,6 +92,7 @@ public final class DocumentSession { | ||
| 90 | 92 | let state = buffer.document |
| 91 | 93 | document = state |
| 92 | 94 | if isDirty != state.isDirty { isDirty = state.isDirty } |
| 95 | if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } | |
| 93 | 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 | |
| 5 | 5 | struct ContentView: View { |
| 6 | 6 | let workspace: WorkspaceModel |
| 7 | let session: DocumentSession | |
| 7 | 8 | @Bindable var commands: AppCommands |
| 8 | @State private var session = DocumentSession() | |
| 9 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | |
| 9 | 10 | @State private var selectedFile: String? |
| 10 | 11 | @State private var query = "" |
| 11 | 12 | @FocusState private var searchFocused: Bool |
| @@ -19,7 +20,8 @@ struct ContentView: View { | ||
| 19 | 20 | if query.isEmpty { |
| 20 | 21 | OutlineColumn(session: session) |
| 21 | 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 | 25 | selectedFile = location.path |
| 24 | 26 | session.show(location) |
| 25 | 27 | } |
| @@ -38,13 +40,34 @@ struct ContentView: View { | ||
| 38 | 40 | } |
| 39 | 41 | } |
| 40 | 42 | .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar") |
| 43 | .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "") | |
| 41 | 44 | .searchable(text: $query, placement: .toolbar, prompt: "Search notes") |
| 42 | 45 | .searchFocused($searchFocused) |
| 43 | 46 | .toolbar { |
| 44 | 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 | |
| 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.") | |
| 48 | 71 | } |
| 49 | 72 | .onChange(of: workspace.changedFile?.version) { |
| 50 | 73 | if let path = workspace.changedFile?.path { session.fileChanged(path) } |
Sources/Orgstar/EditorView.swift +5 −3
| @@ -3,8 +3,8 @@ import OrgApp | ||
| 3 | 3 | import OrgEditorAppKit |
| 4 | 4 | import SwiftUI |
| 5 | 5 | |
| 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. | |
| 8 | 8 | struct EditorView: NSViewRepresentable { |
| 9 | 9 | let session: DocumentSession |
| 10 | 10 | |
| @@ -27,7 +27,8 @@ struct EditorView: NSViewRepresentable { | ||
| 27 | 27 | container.subviews.forEach { $0.removeFromSuperview() } |
| 28 | 28 | coordinator.editor = nil |
| 29 | 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 | 32 | let scroll = editor.makeScrollView() |
| 32 | 33 | scroll.frame = container.bounds |
| 33 | 34 | scroll.autoresizingMask = [.width, .height] |
| @@ -37,6 +38,7 @@ struct EditorView: NSViewRepresentable { | ||
| 37 | 38 | session.attach(editor) |
| 38 | 39 | } |
| 39 | 40 | } |
| 41 | container.window?.isDocumentEdited = session.isDirty | |
| 40 | 42 | if let request = session.revealRequest, request.id != coordinator.revealID { |
| 41 | 43 | coordinator.revealID = request.id |
| 42 | 44 | coordinator.editor?.reveal(request.offset) |
Sources/Orgstar/OrgstarApp.swift +66 −1
| @@ -1,3 +1,4 @@ | ||
| 1 | import AppKit | |
| 1 | 2 | import OrgApp |
| 2 | 3 | import SwiftUI |
| 3 | 4 | |
| @@ -8,18 +9,56 @@ final class AppCommands { | ||
| 8 | 9 | var showingQuickOpen = false |
| 9 | 10 | var showingFolderPicker = false |
| 10 | 11 | var focusSearch = false |
| 12 | var showingConflict = false | |
| 13 | } | |
| 14 | ||
| 15 | @MainActor | |
| 16 | final 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 | |
| 29 | enum 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 | 49 | @main |
| 14 | 50 | struct OrgstarApp: App { |
| 51 | @NSApplicationDelegateAdaptor private var delegate: AppDelegate | |
| 15 | 52 | @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) } |
| 16 | 53 | @State private var commands = AppCommands() |
| 54 | @State private var session = DocumentSession() | |
| 17 | 55 | |
| 18 | 56 | var body: some Scene { |
| 19 | 57 | Window("Orgstar", id: "main") { |
| 20 | 58 | switch workspace { |
| 21 | 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 | 62 | case .failure(let error): |
| 24 | 63 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) |
| 25 | 64 | } |
| @@ -32,10 +71,36 @@ struct OrgstarApp: App { | ||
| 32 | 71 | Button("Quick Open…") { commands.showingQuickOpen = true } |
| 33 | 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 | 81 | CommandGroup(after: .textEditing) { |
| 36 | 82 | Button("Search Notes") { commands.focusSearch = true } |
| 37 | 83 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 38 | 84 | } |
| 39 | 85 | } |
| 86 | Settings { | |
| 87 | SettingsView() | |
| 88 | } | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | struct 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 | ||
| 2 | 2 | import OrgCore |
| 3 | 3 | import OrgDocument |
| 4 | 4 | import OrgPresentation |
| 5 | @testable import OrgApp | |
| 5 | 6 | import Testing |
| 6 | 7 | @testable import OrgEditorAppKit |
| 7 | 8 | |
| @@ -250,6 +251,30 @@ struct BufferTests { | ||
| 250 | 251 | #expect(h.textView.undoManager?.canUndo != true) |
| 251 | 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 | 280 | @MainActor |