Commit 162a8a31f9
Verified · cmc
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 | ||
| 5 | struct ContentView: View { | 5 | struct 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 | |||
| 3 | import OrgEditorAppKit | 3 | import OrgEditorAppKit |
| 4 | import SwiftUI | 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 | 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. |
| 8 | struct EditorView: NSViewRepresentable { | 8 | struct 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 @@ | |||
| 1 | import AppKit | ||
| 1 | import OrgApp | 2 | import OrgApp |
| 2 | import SwiftUI | 3 | import 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 | ||
| 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 | @main | 49 | @main |
| 14 | struct OrgstarApp: App { | 50 | struct 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 | |||
| 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 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgDocument | 3 | import OrgDocument |
| 4 | import OrgPresentation | 4 | import OrgPresentation |
| 5 | @testable import OrgApp | ||
| 5 | import Testing | 6 | import 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 |