krz/orgstar

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

Commit a831086741

a831086741e83ef42f7b688ea43b68e6528c2e94

parent: a6139aad41

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 21:22 UTC

Buffers

Files stay open as buffers, as in Emacs: each keeps its editor (text,
caret, folds, its own undo) and unsaved edits while another shows, and
saves on its own in automatic mode. The agenda, board, capture, clock,
refile targets and search read and edit open buffers by path, and
buffers follow their files on disk. Switching files no longer saves or
asks; closing a buffer, closing others or all, and quitting do.

Commands: switch (SPC b b, SPC ,, C-x b), next/previous (SPC b n/p,
] b/[ b, C-x <right>/<left>, ⌃Tab, ⇧⌘]/[), last (SPC `), close (SPC b k,
SPC b d, C-x k, ⌘W), close others (SPC b O), close all (SPC b K), save
all (SPC b S, C-x s, ⌥⌘S). An optional tab bar (tab-bar, View ▸ Show
Tab Bar) is off by default. Open buffers, their order and carets come
back at launch. A theme color set for one appearance leaves the other
to the system.

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +7 −7
@@ -41,8 +41,8 @@ public final class AgendaModel {
41 .sorted() 41 .sorted()
42 } 42 }
43 43
44 /// Recomputes the days. `open` is the open file's path and text. 44 /// Recomputes the days. `open` is the open buffers' text by path.
45 public func refresh(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current) async { 45 public func refresh(open: [String: String], now: Date = Date(), calendar: Calendar = .current) async {
46 generation += 1 46 generation += 1
47 let id = generation 47 let id = generation
48 isComputing = true 48 isComputing = true
@@ -78,25 +78,25 @@ public final class AgendaModel {
78 } 78 }
79 79
80 /// Reminders for the timed entries of the next `days` days. 80 /// Reminders for the timed entries of the next `days` days.
81 public func reminders(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current, days: Int = 7, lead: Int = 12) async -> [Reminder] { 81 public func reminders(open: [String: String], now: Date = Date(), calendar: Calendar = .current, days: Int = 7, lead: Int = 12) async -> [Reminder] {
82 let all = await sources(open: open) 82 let all = await sources(open: open)
83 return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value 83 return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value
84 } 84 }
85 85
86 /// Entries for the table and kanban views, and the keywords their files use. 86 /// Entries for the table and kanban views, and the keywords their files use.
87 public func board(open: (path: String, text: String)?, match: String) async -> (entries: [BoardEntry], keywords: [String]) { 87 public func board(open: [String: String], match: String) async -> (entries: [BoardEntry], keywords: [String]) {
88 let all = await sources(open: open) 88 let all = await sources(open: open)
89 return await Task.detached { (Board.entries(all, match: match), Board.keywords(all)) }.value 89 return await Task.detached { (Board.entries(all, match: match), Board.keywords(all)) }.value
90 } 90 }
91 91
92 /// The agenda files' sources: the open file's from its text, the others from the cache 92 /// The agenda files' sources: the open file's from its text, the others from the cache
93 /// while their modification date and size are unchanged. 93 /// while their modification date and size are unchanged.
94 private func sources(open: (path: String, text: String)?) async -> [AgendaSource] { 94 private func sources(open: [String: String]) async -> [AgendaSource] {
95 let defaults = workspace.settings.org 95 let defaults = workspace.settings.org
96 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] 96 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
97 for path in files { 97 for path in files {
98 if let open, open.path == path { 98 if let text = open[path] {
99 reads.append((path, nil, .distantPast, -1, open.text)) 99 reads.append((path, nil, .distantPast, -1, text))
100 continue 100 continue
101 } 101 }
102 let attributes = try? FileManager.default.attributesOfItem(atPath: path) 102 let attributes = try? FileManager.default.attributesOfItem(atPath: path)
Sources/OrgApp/CaptureModel.swift +1 −1
@@ -70,7 +70,7 @@ extension DocumentSession {
70 /// otherwise in the file, created if missing, through the save path. 70 /// otherwise in the file, created if missing, through the save path.
71 public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? { 71 public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? {
72 let path = Self.resolve(template.target.file, root: root) 72 let path = Self.resolve(template.target.file, root: root)
73 if path != self.path, !FileManager.default.fileExists(atPath: path) { 73 if !isOpen(path), !FileManager.default.fileExists(atPath: path) {
74 do { 74 do {
75 try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true) 75 try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true)
76 try Data().write(to: URL(fileURLWithPath: path)) 76 try Data().write(to: URL(fileURLWithPath: path))
Sources/OrgApp/ClockModel.swift +1 −1
@@ -79,7 +79,7 @@ public final class ClockModel {
79 /// Where the running clock's line is, for `org-clock-goto`. 79 /// Where the running clock's line is, for `org-clock-goto`.
80 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? { 80 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
81 guard let clock = running else { return nil } 81 guard let clock = running else { return nil }
82 let open = session.path == clock.path ? session.document?.text : nil 82 let open = session.openDocuments.first { $0.path == clock.path }?.document.text
83 guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil } 83 guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil }
84 let buffer = text as NSString 84 let buffer = text as NSString
85 let range = buffer.range(of: "CLOCK: [\(clock.start)]") 85 let range = buffer.range(of: "CLOCK: [\(clock.start)]")
Sources/OrgApp/DocumentSession.swift +189 −59
@@ -42,15 +42,17 @@ public enum SaveMode: String, Sendable, CaseIterable {
42 case explicit 42 case explicit
43} 43}
44 44
45/// The file open in a window: its buffer, outline, saving, and requests for the editor to 45/// The files open in a window, as Emacs's buffers: each keeps its text, caret, undo and
46/// show a location. The view layer attaches the editor as the buffer and follows 46/// unsaved edits while another is shown. The current buffer's outline, saving and requests
47/// `revealRequest`. 47/// for the editor to show a location are what most views use. The view layer attaches an
48/// editor to each buffer and follows `path` and `revealRequest`.
48@MainActor 49@MainActor
49@Observable 50@Observable
50public final class DocumentSession { 51public final class DocumentSession {
52 /// The current buffer's file.
51 public private(set) var path: String? 53 public private(set) var path: String?
52 /// A copy of the buffer's state as of its last change. Not observed: it changes on every 54 /// A copy of the current buffer's state as of its last change. Not observed: it changes
53 /// keystroke; views follow `path`, `isDirty` and `outline`. 55 /// on every keystroke; views follow `path`, `isDirty` and `outline`.
54 @ObservationIgnored public private(set) var document: DocumentState? 56 @ObservationIgnored public private(set) var document: DocumentState?
55 public private(set) var outline: [OutlineEntry] = [] 57 public private(set) var outline: [OutlineEntry] = []
56 /// Bumped when the buffer settles after edits, and when a file opens: views that read 58 /// Bumped when the buffer settles after edits, and when a file opens: views that read
@@ -63,6 +65,10 @@ public final class DocumentSession {
63 public private(set) var conflict: String? 65 public private(set) var conflict: String?
64 /// Bumped to ask the editor to reveal an offset. 66 /// Bumped to ask the editor to reveal an offset.
65 public private(set) var revealRequest: (offset: Int, id: Int)? 67 public private(set) var revealRequest: (offset: Int, id: Int)?
68 /// Open files, in the order buffers were opened (each after the one current then).
69 public private(set) var openPaths: [String] = []
70 /// Open files with unsaved edits.
71 public private(set) var dirtyPaths: Set<String> = []
66 public var lastError: String? 72 public var lastError: String?
67 public var saveMode: SaveMode = .automatic 73 public var saveMode: SaveMode = .automatic
68 public var autosaveDelay: Duration = .seconds(1) 74 public var autosaveDelay: Duration = .seconds(1)
@@ -71,10 +77,26 @@ public final class DocumentSession {
71 /// TODO keywords and priorities for files without their own settings. 77 /// TODO keywords and priorities for files without their own settings.
72 public var defaults: OrgSettings = .default 78 public var defaults: OrgSettings = .default
73 79
80 /// One open file.
81 @MainActor
82 final class Entry {
83 let path: String
84 var buffer: DocumentBuffer
85 var conflict: String?
86 var autosave: Task<Void, Never>?
87
88 init(path: String, buffer: DocumentBuffer) {
89 self.path = path
90 self.buffer = buffer
91 }
92 }
93
74 private let fileSystem: FileSystem 94 private let fileSystem: FileSystem
75 private let recovery: RecoveryStore 95 private let recovery: RecoveryStore
76 @ObservationIgnored private var buffer: DocumentBuffer? 96 @ObservationIgnored private var entries: [Entry] = []
77 @ObservationIgnored var autosave: Task<Void, Never>? 97 @ObservationIgnored private var current: Entry?
98 /// Paths, most recently shown first.
99 @ObservationIgnored private var history: [String] = []
78 @ObservationIgnored private var outlineUpdate: Task<Void, Never>? 100 @ObservationIgnored private var outlineUpdate: Task<Void, Never>?
79 private var requestID = 0 101 private var requestID = 0
80 102
@@ -86,6 +108,9 @@ public final class DocumentSession {
86 self.recovery = recovery 108 self.recovery = recovery
87 } 109 }
88 110
111 private var buffer: DocumentBuffer? { current?.buffer }
112 /// The current buffer's pending autosave, for tests to wait on.
113 var autosave: Task<Void, Never>? { current?.autosave }
89 private var url: URL? { path.map { URL(fileURLWithPath: $0) } } 114 private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
90 /// Whether the open file is an org file (`.org`, `.org_archive`), not plain text. 115 /// Whether the open file is an org file (`.org`, `.org_archive`), not plain text.
91 public var isOrg: Bool { path.map(Self.isOrgPath) ?? false } 116 public var isOrg: Bool { path.map(Self.isOrgPath) ?? false }
@@ -98,39 +123,101 @@ public final class DocumentSession {
98 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 } 123 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
99 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } 124 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
100 125
126 private func entry(_ path: String) -> Entry? { entries.first { $0.path == path } }
127
128 public func isOpen(_ path: String) -> Bool { entry(path) != nil }
129
130 /// The caret of an open file.
131 public func caret(of path: String) -> Int? { entry(path)?.buffer.selection.first?.lowerBound }
132
133 /// The text of every open file, for views that read files (the agenda, search).
134 public var openDocuments: [(path: String, document: DocumentState)] {
135 entries.map { ($0.path, $0.buffer.document) }
136 }
137
138 /// The text of every open file by path, for the agenda.
139 public var openTexts: [String: String] {
140 Dictionary(entries.map { ($0.path, $0.buffer.document.text) }, uniquingKeysWith: { a, _ in a })
141 }
142
143 /// Open buffers, most recently shown first.
144 public var recentPaths: [String] { history.filter(isOpen) }
145
146 /// Shows the buffer of `path`, opening the file if it isn't open.
101 public func open(_ path: String) { 147 public func open(_ path: String) {
148 if let existing = entry(path) {
149 activate(existing)
150 return
151 }
102 do { 152 do {
103 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { 153 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
104 lastError = "\((path as NSString).lastPathComponent) no longer exists." 154 lastError = "\((path as NSString).lastPathComponent) no longer exists."
105 return 155 return
106 } 156 }
107 autosave?.cancel() 157 let entry = Entry(path: path, buffer: StateBuffer(DocumentState(bytes: bytes, defaults: defaults)))
108 self.path = path 158 let index = current.flatMap { c in entries.firstIndex { $0 === c } }.map { $0 + 1 } ?? entries.count
109 buffer = StateBuffer(DocumentState(bytes: bytes, defaults: defaults)) 159 entries.insert(entry, at: index)
110 conflict = nil 160 activate(entry)
111 revealRequest = nil
112 refresh()
113 } catch { 161 } catch {
114 lastError = "Can't open \(path): \(error.localizedDescription)" 162 lastError = "Can't open \(path): \(error.localizedDescription)"
115 } 163 }
116 } 164 }
117 165
118 /// Closes the open file without saving, as when it was deleted. 166 private func activate(_ entry: Entry) {
119 public func close() { 167 current = entry
120 autosave?.cancel() 168 path = entry.path
121 outlineUpdate?.cancel() 169 conflict = entry.conflict
122 path = nil
123 buffer = nil
124 document = nil
125 outline = []
126 isDirty = false
127 conflict = nil
128 revealRequest = nil 170 revealRequest = nil
171 history.removeAll { $0 == entry.path }
172 history.insert(entry.path, at: 0)
173 refresh()
129 } 174 }
130 175
131 /// Makes `buffer`, built from `document`, the live state of the open file. 176 /// The buffer before or after the current one, wrapping.
177 public func cycle(_ step: Int) {
178 guard let current, let index = entries.firstIndex(where: { $0 === current }), entries.count > 1 else { return }
179 activate(entries[((index + step) % entries.count + entries.count) % entries.count])
180 }
181
182 /// The buffer shown before the current one.
183 public func showPrevious() {
184 guard let previous = recentPaths.dropFirst().first else { return }
185 open(previous)
186 }
187
188 /// Closes the buffer of `path` without saving it; the most recent other buffer shows.
189 public func kill(_ path: String) {
190 guard let entry = entry(path) else { return }
191 entry.autosave?.cancel()
192 entries.removeAll { $0 === entry }
193 history.removeAll { $0 == path }
194 if entry === current {
195 current = nil
196 if let next = recentPaths.first.flatMap(self.entry) ?? entries.last {
197 activate(next)
198 } else {
199 outlineUpdate?.cancel()
200 self.path = nil
201 document = nil
202 outline = []
203 isDirty = false
204 conflict = nil
205 revealRequest = nil
206 refreshLists()
207 }
208 } else {
209 refreshLists()
210 }
211 }
212
213 /// Closes the current buffer without saving, as when its file was deleted.
214 public func close() {
215 if let path { kill(path) }
216 }
217
218 /// Makes `buffer`, built from `document`, the live state of the current file.
132 public func attach(_ buffer: DocumentBuffer) { 219 public func attach(_ buffer: DocumentBuffer) {
133 self.buffer = buffer 220 current?.buffer = buffer
134 refresh() 221 refresh()
135 } 222 }
136 223
@@ -144,58 +231,87 @@ public final class DocumentSession {
144 self.outline = self.isOrg ? Presentation.outline(document.tree) : [] 231 self.outline = self.isOrg ? Presentation.outline(document.tree) : []
145 self.editVersion += 1 232 self.editVersion += 1
146 } 233 }
147 scheduleAutosave() 234 if let current { scheduleAutosave(current) }
148 } 235 }
149 236
150 private func refresh(outline updateOutline: Bool = true) { 237 private func refresh(outline updateOutline: Bool = true) {
238 refreshLists()
151 guard let buffer else { return } 239 guard let buffer else { return }
152 let state = buffer.document 240 let state = buffer.document
153 document = state 241 document = state
154 if isDirty != state.isDirty { isDirty = state.isDirty } 242 if isDirty != state.isDirty { isDirty = state.isDirty }
155 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } 243 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
244 if conflict != current?.conflict { conflict = current?.conflict }
156 if updateOutline { 245 if updateOutline {
157 outline = isOrg ? Presentation.outline(state.tree) : [] 246 outline = isOrg ? Presentation.outline(state.tree) : []
158 editVersion += 1 247 editVersion += 1
159 } 248 }
160 } 249 }
161 250
251 private func refreshLists() {
252 let paths = entries.map(\.path)
253 if paths != openPaths { openPaths = paths }
254 let dirty = Set(entries.filter { $0.buffer.document.isDirty }.map(\.path))
255 if dirty != dirtyPaths { dirtyPaths = dirty }
256 }
257
162 // MARK: - Saving 258 // MARK: - Saving
163 259
164 private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) } 260 private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) }
165 261
166 private func scheduleAutosave() { 262 private func scheduleAutosave(_ entry: Entry) {
167 autosave?.cancel() 263 entry.autosave?.cancel()
168 guard saveMode == .automatic, isDirty, conflict == nil else { return } 264 guard saveMode == .automatic, entry.buffer.document.isDirty, entry.conflict == nil else { return }
169 autosave = Task { [weak self, autosaveDelay] in 265 entry.autosave = Task { [weak self, weak entry, autosaveDelay] in
170 try? await Task.sleep(for: autosaveDelay) 266 try? await Task.sleep(for: autosaveDelay)
171 guard !Task.isCancelled else { return } 267 guard !Task.isCancelled, let self, let entry else { return }
172 self?.save() 268 self.save(entry)
173 } 269 }
174 } 270 }
175 271
176 /// Saves now. True when nothing is left unsaved. 272 /// Saves the current buffer now. True when nothing is left unsaved.
177 @discardableResult 273 @discardableResult
178 public func save() -> Bool { 274 public func save() -> Bool {
179 autosave?.cancel() 275 guard let current else { return true }
180 guard let buffer, let url, isDirty else { return !isDirty } 276 return save(current)
181 guard conflict == nil else { return false } 277 }
278
279 /// Saves the buffer of `path`. True when nothing is left unsaved.
280 @discardableResult
281 public func save(_ path: String) -> Bool {
282 guard let entry = entry(path) else { return true }
283 return save(entry)
284 }
285
286 /// Saves every open buffer. True when nothing is left unsaved.
287 @discardableResult
288 public func saveAll() -> Bool {
289 entries.map { save($0) }.allSatisfy { $0 }
290 }
291
292 @discardableResult
293 private func save(_ entry: Entry) -> Bool {
294 entry.autosave?.cancel()
295 let name = (entry.path as NSString).lastPathComponent
296 guard entry.buffer.document.isDirty else { return true }
297 guard entry.conflict == nil else { return false }
182 do { 298 do {
183 let outcome = try buffer.update { try saver.save(&$0, to: url) } 299 let outcome = try entry.buffer.update { try saver.save(&$0, to: URL(fileURLWithPath: entry.path)) }
184 switch outcome { 300 switch outcome {
185 case .saved, .mergedAndSaved: 301 case .saved, .mergedAndSaved:
186 break 302 break
187 case .conflict: 303 case .conflict:
188 conflict = "\(name) changed on disk, and the changes conflict with yours." 304 entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
189 case .overwroteExternalChange(let change), .changedAfterWrite(let change): 305 case .overwroteExternalChange(let change), .changedAfterWrite(let change):
190 if case .conflict = change { 306 if case .conflict = change {
191 conflict = "\(name) changed on disk while saving, and the changes conflict with yours." 307 entry.conflict = "\(name) changed on disk while saving, and the changes conflict with yours."
192 } 308 }
193 } 309 }
194 } catch { 310 } catch {
195 lastError = "Can't save \(name): \(error.localizedDescription)" 311 lastError = "Can't save \(name): \(error.localizedDescription)"
196 } 312 }
197 refresh() 313 if entry === current { refresh() } else { refreshLists() }
198 return !isDirty 314 return !entry.buffer.document.isDirty
199 } 315 }
200 316
201 /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery. 317 /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery.
@@ -203,7 +319,7 @@ public final class DocumentSession {
203 guard let buffer, let url else { return } 319 guard let buffer, let url else { return }
204 do { 320 do {
205 try buffer.update { try saver.overwrite(&$0, to: url) } 321 try buffer.update { try saver.overwrite(&$0, to: url) }
206 conflict = nil 322 current?.conflict = nil
207 } catch { 323 } catch {
208 lastError = "Can't save \(name): \(error.localizedDescription)" 324 lastError = "Can't save \(name): \(error.localizedDescription)"
209 } 325 }
@@ -217,7 +333,7 @@ public final class DocumentSession {
217 guard let bytes = try fileSystem.read(url) else { return } 333 guard let bytes = try fileSystem.read(url) else { return }
218 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") } 334 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
219 buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) } 335 buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) }
220 conflict = nil 336 current?.conflict = nil
221 } catch { 337 } catch {
222 lastError = "Can't read \(name): \(error.localizedDescription)" 338 lastError = "Can't read \(name): \(error.localizedDescription)"
223 } 339 }
@@ -310,12 +426,15 @@ public final class DocumentSession {
310 guard expect.start + (expect.line as NSString).length <= ns.length else { return false } 426 guard expect.start + (expect.line as NSString).length <= ns.length else { return false }
311 return ns.substring(with: NSRange(location: expect.start, length: (expect.line as NSString).length)) == expect.line 427 return ns.substring(with: NSRange(location: expect.start, length: (expect.line as NSString).length)) == expect.line
312 } 428 }
313 if path == self.path, let buffer { 429 if let entry = entry(path) {
430 let buffer = entry.buffer
314 guard matches(buffer.document.text) else { return stale } 431 guard matches(buffer.document.text) else { return stale }
315 do { 432 do {
316 let options = self.options 433 let options = self.options
317 let step = try buffer.update { try $0.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) } 434 let step = try buffer.update { try $0.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) }
318 if case .commit = step { bufferChanged() } 435 if case .commit = step {
436 if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
437 }
319 return step 438 return step
320 } catch { 439 } catch {
321 return .failed("\(error)") 440 return .failed("\(error)")
@@ -343,14 +462,16 @@ public final class DocumentSession {
343 /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table 462 /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table
344 /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area. 463 /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area.
345 public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String { 464 public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String {
346 guard let buffer, let path else { return "No file is open." } 465 guard let entry = current else { return "No file is open." }
347 let snapshot = buffer.document.text as NSString 466 let path = entry.path
467 let snapshot = entry.buffer.document.text as NSString
348 guard table.upperBound <= snapshot.length else { return "The table moved; try again." } 468 guard table.upperBound <= snapshot.length else { return "The table moved; try again." }
349 let old = snapshot.substring(with: NSRange(table)) 469 let old = snapshot.substring(with: NSRange(table))
350 let directory = URL(fileURLWithPath: path).deletingLastPathComponent() 470 let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
351 do { 471 do {
352 let new = try await EmacsRunner.recalculateTable(text: snapshot as String, directory: directory, table: table, point: point, form: form) 472 let new = try await EmacsRunner.recalculateTable(text: snapshot as String, directory: directory, table: table, point: point, form: form)
353 guard let current = self.buffer, self.path == path else { return "The file was closed before Emacs finished." } 473 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before Emacs finished." }
474 let current = entry.buffer
354 let text = current.document.text as NSString 475 let text = current.document.text as NSString
355 guard table.upperBound <= text.length, text.substring(with: NSRange(table)) == old else { 476 guard table.upperBound <= text.length, text.substring(with: NSRange(table)) == old else {
356 return "The table changed while Emacs recalculated it; nothing was changed." 477 return "The table changed while Emacs recalculated it; nothing was changed."
@@ -358,7 +479,7 @@ public final class DocumentSession {
358 guard new != old else { return "Recalculated in Emacs" } 479 guard new != old else { return "Recalculated in Emacs" }
359 let result = EditResult(baseRevision: current.document.revision, edits: [TextEdit(range: table, replacement: new)]) 480 let result = EditResult(baseRevision: current.document.revision, edits: [TextEdit(range: table, replacement: new)])
360 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } 481 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
361 bufferChanged() 482 changed(entry)
362 return "Recalculated in Emacs" 483 return "Recalculated in Emacs"
363 } catch { 484 } catch {
364 return "\(error)" 485 return "\(error)"
@@ -368,11 +489,12 @@ public final class DocumentSession {
368 /// Runs a src block and writes its result under it, unless the block changed while it 489 /// Runs a src block and writes its result under it, unless the block changed while it
369 /// ran. Returns a message for the echo area. 490 /// ran. Returns a message for the echo area.
370 public func runBabel(_ job: BabelJob) async -> String { 491 public func runBabel(_ job: BabelJob) async -> String {
371 guard let path, buffer != nil else { return "No file is open." } 492 guard let entry = current else { return "No file is open." }
372 let directory = URL(fileURLWithPath: path).deletingLastPathComponent() 493 let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent()
373 do { 494 do {
374 let output = try await BabelRunner.run(job, fileDirectory: directory) 495 let output = try await BabelRunner.run(job, fileDirectory: directory)
375 guard let current = self.buffer, self.path == path else { return "The file was closed before the block finished." } 496 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
497 let current = entry.buffer
376 let document = current.document 498 let document = current.document
377 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) { 499 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) {
378 case .failure(.message(let message)): 500 case .failure(.message(let message)):
@@ -381,7 +503,7 @@ public final class DocumentSession {
381 guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." } 503 guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." }
382 let result = EditResult(baseRevision: document.revision, edits: edits) 504 let result = EditResult(baseRevision: document.revision, edits: edits)
383 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } 505 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
384 bufferChanged() 506 changed(entry)
385 return "Code block evaluation complete." 507 return "Code block evaluation complete."
386 } 508 }
387 } catch { 509 } catch {
@@ -389,6 +511,11 @@ public final class DocumentSession {
389 } 511 }
390 } 512 }
391 513
514 /// A buffer changed outside typing: the current one updates its views, another saves.
515 private func changed(_ entry: Entry) {
516 if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
517 }
518
392 // MARK: - Locations 519 // MARK: - Locations
393 520
394 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets 521 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
@@ -416,19 +543,22 @@ public final class DocumentSession {
416 revealRequest = (offset, requestID) 543 revealRequest = (offset, requestID)
417 } 544 }
418 545
419 /// Called when the workspace sees the open file change on disk. 546 /// Called when the workspace sees a file change on disk: an open buffer of it takes the
547 /// change in.
420 public func fileChanged(_ path: String) { 548 public func fileChanged(_ path: String) {
421 guard path == self.path, let buffer, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } 549 guard let entry = entry(path), let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
550 let buffer = entry.buffer
422 guard bytes != buffer.document.mergeBase else { return } 551 guard bytes != buffer.document.mergeBase else { return }
552 let name = (path as NSString).lastPathComponent
423 switch buffer.update({ $0.diskChanged(to: bytes) }) { 553 switch buffer.update({ $0.diskChanged(to: bytes) }) {
424 case .conflict: 554 case .conflict:
425 autosave?.cancel() 555 entry.autosave?.cancel()
426 conflict = "\(name) changed on disk, and the changes conflict with yours." 556 entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
427 case .merged, .reloaded: 557 case .merged, .reloaded:
428 conflict = nil 558 entry.conflict = nil
429 case .unchanged: 559 case .unchanged:
430 break 560 break
431 } 561 }
432 refresh() 562 if entry === current { refresh() } else { refreshLists() }
433 } 563 }
434} 564}
Sources/OrgApp/UserConfig.swift +2
@@ -99,6 +99,8 @@ public enum UserConfig {
99 Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta", 99 Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta",
100 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"), 100 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"),
101 comment: "left, right, both or none", legacy: "general.option-as-meta"), 101 comment: "left, right, both or none", legacy: "general.option-as-meta"),
102 Setting(section: "orgstar", key: "tab-bar", defaultsKey: "showTabBar", kind: .bool, fallback: .bool(false),
103 comment: "a tab for each open buffer above the editor (View ▸ Show Tab Bar)", legacy: ""),
102 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false), 104 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
103 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"), 105 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
104 Setting(section: "orgstar", key: "show-hidden-files", defaultsKey: "showHiddenFiles", kind: .bool, fallback: .bool(true), 106 Setting(section: "orgstar", key: "show-hidden-files", defaultsKey: "showHiddenFiles", kind: .bool, fallback: .bool(true),
Sources/OrgApp/WorkspaceModel.swift +9
@@ -266,6 +266,15 @@ public final class WorkspaceModel {
266 /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place 266 /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place
267 /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text 267 /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text
268 /// changes. 268 /// changes.
269 /// Index records for open buffers with unsaved edits, so search sees what they hold.
270 public func overlay(_ documents: [(path: String, document: DocumentState)]) -> [String: FileRecord] {
271 var result: [String: FileRecord] = [:]
272 for (path, document) in documents where document.isDirty {
273 result.merge(overlay(path: path, document: document)) { a, _ in a }
274 }
275 return result
276 }
277
269 public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] { 278 public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] {
270 guard let path, let document, document.isDirty, let bytes = try? document.encodedText(), 279 guard let path, let document, document.isDirty, let bytes = try? document.encodedText(),
271 let entry = files.first(where: { $0.path == path }) else { return [:] } 280 let entry = files.first(where: { $0.path == path }) else { return [:] }
Sources/OrgCore/Keymap/Presets.swift +89
@@ -94,6 +94,71 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
94 keys = "SPC f s" 94 keys = "SPC f s"
95 command = "app.save" 95 command = "app.save"
96 mode = "normal" 96 mode = "normal"
97 # Buffers
98 [[bind]]
99 keys = "SPC b b"
100 command = "app.buffer.switch"
101 mode = "normal"
102 [[bind]]
103 keys = "SPC b B"
104 command = "app.buffer.switch"
105 mode = "normal"
106 [[bind]]
107 keys = "SPC ,"
108 command = "app.buffer.switch"
109 mode = "normal"
110 [[bind]]
111 keys = "SPC `"
112 command = "app.buffer.last"
113 mode = "normal"
114 [[bind]]
115 keys = "SPC b k"
116 command = "app.buffer.kill"
117 mode = "normal"
118 [[bind]]
119 keys = "SPC b d"
120 command = "app.buffer.kill"
121 mode = "normal"
122 [[bind]]
123 keys = "SPC b K"
124 command = "app.buffer.kill-all"
125 mode = "normal"
126 [[bind]]
127 keys = "SPC b O"
128 command = "app.buffer.kill-others"
129 mode = "normal"
130 [[bind]]
131 keys = "SPC b n"
132 command = "app.buffer.next"
133 mode = "normal"
134 [[bind]]
135 keys = "SPC b p"
136 command = "app.buffer.previous"
137 mode = "normal"
138 [[bind]]
139 keys = "SPC b ]"
140 command = "app.buffer.next"
141 mode = "normal"
142 [[bind]]
143 keys = "SPC b ["
144 command = "app.buffer.previous"
145 mode = "normal"
146 [[bind]]
147 keys = "] b"
148 command = "app.buffer.next"
149 mode = "normal"
150 [[bind]]
151 keys = "[ b"
152 command = "app.buffer.previous"
153 mode = "normal"
154 [[bind]]
155 keys = "SPC b s"
156 command = "app.save"
157 mode = "normal"
158 [[bind]]
159 keys = "SPC b S"
160 command = "app.save-all"
161 mode = "normal"
97 [[bind]] 162 [[bind]]
98 keys = "SPC s s" 163 keys = "SPC s s"
99 command = "edit.find" 164 command = "edit.find"
@@ -490,6 +555,24 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
490 keys = "C-x C-s" 555 keys = "C-x C-s"
491 command = "app.save" 556 command = "app.save"
492 [[bind]] 557 [[bind]]
558 keys = "C-x b"
559 command = "app.buffer.switch"
560 [[bind]]
561 keys = "C-x C-b"
562 command = "app.buffer.switch"
563 [[bind]]
564 keys = "C-x k"
565 command = "app.buffer.kill"
566 [[bind]]
567 keys = "C-x <right>"
568 command = "app.buffer.next"
569 [[bind]]
570 keys = "C-x <left>"
571 command = "app.buffer.previous"
572 [[bind]]
573 keys = "C-x s"
574 command = "app.save-all"
575 [[bind]]
493 keys = "C-x C-f" 576 keys = "C-x C-f"
494 command = "app.quick-open" 577 command = "app.quick-open"
495 [[bind]] 578 [[bind]]
@@ -745,6 +828,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
745 /// Standard Mac text keys stay as they are; org commands use Control-Command chords, which 828 /// Standard Mac text keys stay as they are; org commands use Control-Command chords, which
746 /// macOS text editing leaves free. 829 /// macOS text editing leaves free.
747 static let macBindings = """ 830 static let macBindings = """
831 [[bind]]
832 keys = "C-TAB"
833 command = "app.buffer.next"
834 [[bind]]
835 keys = "C-S-TAB"
836 command = "app.buffer.previous"
748 [[bind]] 837 [[bind]]
749 keys = "C-s-t" 838 keys = "C-s-t"
750 command = "org.todo.cycle" 839 command = "org.todo.cycle"
Sources/OrgEditorAppKit/EditorCommands.swift +9
@@ -40,6 +40,15 @@ public enum EditorCommands {
40 40
41 EditorCommand(id: "app.save", title: "Save", action: .app), 41 EditorCommand(id: "app.save", title: "Save", action: .app),
42 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), 42 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
43 EditorCommand(id: "app.save-all", title: "Save All Buffers", action: .app),
44 EditorCommand(id: "app.buffer.switch", title: "Switch to Buffer…", action: .app),
45 EditorCommand(id: "app.buffer.kill", title: "Close Buffer", action: .app),
46 EditorCommand(id: "app.buffer.kill-others", title: "Close Other Buffers", action: .app),
47 EditorCommand(id: "app.buffer.kill-all", title: "Close All Buffers", action: .app),
48 EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app),
49 EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app),
50 EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app),
51 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 52 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
44 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), 53 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
45 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), 54 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
Sources/OrgEditorAppKit/Keys.swift +3
@@ -15,6 +15,9 @@ final class OrgTextView: NSTextView {
15 /// Returns true when the key was handled. 15 /// Returns true when the key was handled.
16 var keyHandler: ((NSEvent) -> Bool)? 16 var keyHandler: ((NSEvent) -> Bool)?
17 var onMouseDown: (() -> Void)? 17 var onMouseDown: (() -> Void)?
18 /// Each editor keeps its own undo history, not the window's.
19 private let ownUndoManager = UndoManager()
20 override var undoManager: UndoManager? { ownUndoManager }
18 var lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) 21 var lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
19 var lineNumberColor = NSColor.tertiaryLabelColor 22 var lineNumberColor = NSColor.tertiaryLabelColor
20 var currentLineNumberColor = NSColor.labelColor 23 var currentLineNumberColor = NSColor.labelColor
Sources/OrgEditorAppKit/Theme.swift +4 −2
@@ -19,8 +19,10 @@ struct Theme {
19 19
20 /// A color that follows the appearance, or nil when the theme has none for `key`. 20 /// A color that follows the appearance, or nil when the theme has none for `key`.
21 func color(_ key: String) -> NSColor? { 21 func color(_ key: String) -> NSColor? {
22 let light = spec.light[key] ?? spec.dark[key] 22 // Editor colors are set for both appearances by the default theme; a key the theme
23 let dark = spec.dark[key] ?? spec.light[key] 23 // has for one only takes the default for the other.
24 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
25 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
24 guard let light, let dark else { return nil } 26 guard let light, let dark else { return nil }
25 return Self.dynamic(light, dark) 27 return Self.dynamic(light, dark)
26 } 28 }
Sources/Orgstar/AgendaView.swift +1 −1
@@ -162,7 +162,7 @@ struct AgendaView: View {
162 agenda.span = span 162 agenda.span = span
163 agenda.startOffset = startOffset 163 agenda.startOffset = startOffset
164 agenda.includeSubfolders = includeSubfolders 164 agenda.includeSubfolders = includeSubfolders
165 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } } 165 let open = session.openTexts
166 await agenda.refresh(open: open) 166 await agenda.refresh(open: open)
167 } 167 }
168 .task { 168 .task {
Sources/Orgstar/BoardView.swift +1 −1
@@ -78,7 +78,7 @@ struct BoardView: View {
78 .navigationTitle(kanban ? "Kanban" : "Entries") 78 .navigationTitle(kanban ? "Kanban" : "Entries")
79 .task(id: RefreshKey(match: match, subfolders: includeSubfolders, files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, path: session.path, tick: tick)) { 79 .task(id: RefreshKey(match: match, subfolders: includeSubfolders, files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, path: session.path, tick: tick)) {
80 agenda.includeSubfolders = includeSubfolders 80 agenda.includeSubfolders = includeSubfolders
81 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } } 81 let open = session.openTexts
82 let result = await agenda.board(open: open, match: match) 82 let result = await agenda.board(open: open, match: match)
83 entries = result.entries 83 entries = result.entries
84 keywords = result.keywords 84 keywords = result.keywords
Sources/Orgstar/ContentView.swift +108 −15
@@ -67,6 +67,9 @@ struct ContentView: View {
67 /// The outline for a file that isn't org, off until asked for. 67 /// The outline for a file that isn't org, off until asked for.
68 @State private var plainOutlineShown = false 68 @State private var plainOutlineShown = false
69 @State private var showingImport = false 69 @State private var showingImport = false
70 @AppStorage("showTabBar") private var showTabBar = false
71 /// Carets of restored buffers, shown when each first shows.
72 @State private var pendingCarets: [String: Int] = [:]
70 @State private var query = "" 73 @State private var query = ""
71 @FocusState private var searchFocused: Bool 74 @FocusState private var searchFocused: Bool
72 @Environment(\.openWindow) private var openWindow 75 @Environment(\.openWindow) private var openWindow
@@ -87,13 +90,16 @@ struct ContentView: View {
87 if let previewPath { 90 if let previewPath {
88 FilePreview(path: previewPath) 91 FilePreview(path: previewPath)
89 } else if session.path != nil { 92 } else if session.path != nil {
90 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, theme: theme, onAppCommand: perform, onExternal: runExternal) 93 VStack(spacing: 0) {
91 .safeAreaInset(edge: .bottom, spacing: 0) { 94 if showTabBar { BufferTabs(session: session) }
92 VStack(spacing: 0) { 95 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, theme: theme, onAppCommand: perform, onExternal: runExternal)
93 ModeLine(session: session, commands: commands, clock: clock, perform: perform) 96 }
94 EchoArea(commands: commands) 97 .safeAreaInset(edge: .bottom, spacing: 0) {
95 } 98 VStack(spacing: 0) {
99 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
100 EchoArea(commands: commands)
96 } 101 }
102 }
97 } else { 103 } else {
98 ContentUnavailableView( 104 ContentUnavailableView(
99 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", 105 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
@@ -146,8 +152,7 @@ struct ContentView: View {
146 if query.isEmpty { 152 if query.isEmpty {
147 OutlineColumn(session: session) 153 OutlineColumn(session: session)
148 } else { 154 } else {
149 SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in 155 SearchResults(results: workspace.search(query, overlay: workspace.overlay(session.openDocuments))) { location in
150 guard location.path == session.path || UnsavedChanges.resolve(session) else { return }
151 selectedFile = location.path 156 selectedFile = location.path
152 session.show(location) 157 session.show(location)
153 } 158 }
@@ -177,12 +182,15 @@ struct ContentView: View {
177 } 182 }
178 previewPath = nil 183 previewPath = nil
179 guard path != session.path else { return } 184 guard path != session.path else { return }
180 if UnsavedChanges.resolve(session) { 185 session.open(path)
181 session.open(path)
182 } else {
183 selectedFile = old
184 }
185 } 186 }
187 .onChange(of: session.path) {
188 // Buffers switched by keys show in the sidebar too; a restored caret shows once.
189 if let path = session.path, selectedFile != path { selectedFile = path }
190 if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) }
191 BufferRestore.save(session)
192 }
193 .onChange(of: session.openPaths) { BufferRestore.save(session) }
186 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } 194 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
187 .onChange(of: options, initial: true) { _, options in session.options = options } 195 .onChange(of: options, initial: true) { _, options in session.options = options }
188 .onChange(of: preset, initial: true) { loadKeymap() } 196 .onChange(of: preset, initial: true) { loadKeymap() }
@@ -233,7 +241,6 @@ struct ContentView: View {
233 .onChange(of: commands.jumpRequest?.id) { 241 .onChange(of: commands.jumpRequest?.id) {
234 guard let request = commands.jumpRequest else { return } 242 guard let request = commands.jumpRequest else { return }
235 if request.path != session.path { 243 if request.path != session.path {
236 guard UnsavedChanges.resolve(session) else { return }
237 session.open(request.path) 244 session.open(request.path)
238 selectedFile = request.path 245 selectedFile = request.path
239 } 246 }
@@ -279,6 +286,8 @@ struct ContentView: View {
279 } 286 }
280 .task { 287 .task {
281 DevelopmentHooks.scheduleSnapshot() 288 DevelopmentHooks.scheduleSnapshot()
289 pendingCarets = BufferRestore.restore(session)
290 if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) }
282 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 291 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
283 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } 292 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
284 if let command = DevelopmentHooks.commandToRun { perform(command) } 293 if let command = DevelopmentHooks.commandToRun { perform(command) }
@@ -389,7 +398,7 @@ struct ContentView: View {
389 let agenda = reminderAgenda ?? AgendaModel(workspace: workspace) 398 let agenda = reminderAgenda ?? AgendaModel(workspace: workspace)
390 reminderAgenda = agenda 399 reminderAgenda = agenda
391 agenda.includeSubfolders = agendaSubfolders 400 agenda.includeSubfolders = agendaSubfolders
392 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } } 401 let open = session.openTexts
393 let reminders = await agenda.reminders(open: open, lead: reminderLead) 402 let reminders = await agenda.reminders(open: open, lead: reminderLead)
394 guard !Task.isCancelled else { return } 403 guard !Task.isCancelled else { return }
395 await scheduler.reconcile(reminders) 404 await scheduler.reconcile(reminders)
@@ -406,6 +415,22 @@ struct ContentView: View {
406 } 415 }
407 } 416 }
408 417
418 /// `SPC b b`: open buffers, most recent first, by name (with the folder when names repeat).
419 private func switchBuffer() {
420 let paths = session.recentPaths.dropFirst() + session.recentPaths.prefix(1)
421 let names = paths.map { ($0 as NSString).lastPathComponent }
422 let labels = paths.map { path in
423 let name = (path as NSString).lastPathComponent
424 guard names.filter({ $0 == name }).count > 1 else { return name }
425 return name + " — " + ((path as NSString).deletingLastPathComponent as NSString).lastPathComponent
426 }
427 guard !labels.isEmpty else { return commands.show("No buffers are open") }
428 commands.ask(Prompt(key: "buffer", message: "Switch to buffer:", choices: labels)) { answer in
429 guard let answer, let index = labels.firstIndex(of: answer) else { return }
430 session.open(Array(paths)[index])
431 }
432 }
433
409 /// Runs a command from a key, the palette or the Org menu. 434 /// Runs a command from a key, the palette or the Org menu.
410 private func perform(_ id: String) { 435 private func perform(_ id: String) {
411 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.") 436 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.")
@@ -417,6 +442,25 @@ struct ContentView: View {
417 case "app.save": 442 case "app.save":
418 if !session.save(), session.conflict != nil { commands.showingConflict = true } 443 if !session.save(), session.conflict != nil { commands.showingConflict = true }
419 case "app.quit": NSApp.terminate(nil) 444 case "app.quit": NSApp.terminate(nil)
445 case "app.save-all":
446 if !session.saveAll(), session.conflict != nil { commands.showingConflict = true }
447 case "app.buffer.switch": switchBuffer()
448 case "app.buffer.next": session.cycle(1)
449 case "app.buffer.previous": session.cycle(-1)
450 case "app.buffer.last": session.showPrevious()
451 case "app.buffer.kill":
452 guard let path = session.path else {
453 NSApp.keyWindow?.performClose(nil)
454 return
455 }
456 if UnsavedChanges.resolve(session) { session.kill(path) }
457 case "app.buffer.kill-others":
458 let others = session.openPaths.filter { $0 != session.path }
459 if UnsavedChanges.resolve(session, paths: others) { others.forEach(session.kill) }
460 case "app.buffer.kill-all":
461 let all = session.openPaths
462 if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) }
463 case "app.toggle-tab-bar": showTabBar.toggle()
420 case "app.toggle-outline": toggleOutline() 464 case "app.toggle-outline": toggleOutline()
421 case "app.toggle-markup": showMarkup.toggle() 465 case "app.toggle-markup": showMarkup.toggle()
422 case "app.toggle-line-numbers": showLineNumbers.toggle() 466 case "app.toggle-line-numbers": showLineNumbers.toggle()
@@ -680,3 +724,52 @@ struct PaneDivider: View {
680 .accessibilityHidden(true) 724 .accessibilityHidden(true)
681 } 725 }
682} 726}
727
728/// A tab for each open buffer: click to show it, × to close it, a dot for unsaved edits.
729struct BufferTabs: View {
730 let session: DocumentSession
731 @Environment(\.orgTheme) private var theme
732
733 var body: some View {
734 ScrollView(.horizontal, showsIndicators: false) {
735 HStack(spacing: 0) {
736 ForEach(session.openPaths, id: \.self) { path in
737 tab(path)
738 }
739 }
740 }
741 .frame(height: 28)
742 .background { if let bg = theme.color("modeline-background") { bg } else { Rectangle().fill(.bar) } }
743 .overlay(alignment: .bottom) { Divider() }
744 }
745
746 private func tab(_ path: String) -> some View {
747 let current = path == session.path
748 return HStack(spacing: 6) {
749 Text((path as NSString).lastPathComponent)
750 .lineLimit(1)
751 .foregroundStyle(current ? .primary : .secondary)
752 Button {
753 if path == session.path {
754 if UnsavedChanges.resolve(session) { session.kill(path) }
755 } else if UnsavedChanges.resolve(session, paths: [path]) {
756 session.kill(path)
757 }
758 } label: {
759 Image(systemName: session.dirtyPaths.contains(path) ? "circle.fill" : "xmark")
760 .font(.system(size: session.dirtyPaths.contains(path) ? 6 : 9, weight: .semibold))
761 .frame(width: 12, height: 12)
762 }
763 .buttonStyle(.borderless)
764 .help("Close")
765 }
766 .font(.callout)
767 .padding(.horizontal, 12)
768 .frame(maxHeight: .infinity)
769 .background(current ? theme.color("background") ?? Color(nsColor: .textBackgroundColor) : .clear)
770 .overlay(alignment: .trailing) { Divider() }
771 .contentShape(Rectangle())
772 .onTapGesture { session.open(path) }
773 .help(path)
774 }
775}
Sources/Orgstar/EditorView.swift +42 −25
@@ -6,8 +6,9 @@ import OrgPresentation
6import OrgHighlight 6import OrgHighlight
7import SwiftUI 7import SwiftUI
8 8
9/// The TextKit 2 editor for the session's file. A new file gets a new editor, which becomes the 9/// The TextKit 2 editors for the session's buffers: one per open file, kept while another
10/// session's buffer; reveal requests go to the existing one. 10/// shows so its caret, folds and undo stay. An editor becomes its buffer's live state; reveal
11/// requests go to the one showing.
11struct EditorView: NSViewRepresentable { 12struct EditorView: NSViewRepresentable {
12 /// One highlighter for every editor: it caches compiled grammars and results. 13 /// One highlighter for every editor: it caches compiled grammars and results.
13 static let highlighter = TreeSitterHighlighter() 14 static let highlighter = TreeSitterHighlighter()
@@ -28,6 +29,8 @@ struct EditorView: NSViewRepresentable {
28 var editor: OrgEditor? 29 var editor: OrgEditor?
29 var path: String? 30 var path: String?
30 var revealID = 0 31 var revealID = 0
32 /// Editors of open buffers, with their scroll views.
33 var editors: [String: (editor: OrgEditor, scroll: NSScrollView)] = [:]
31 } 34 }
32 35
33 func makeCoordinator() -> Coordinator { Coordinator() } 36 func makeCoordinator() -> Coordinator { Coordinator() }
@@ -38,32 +41,25 @@ struct EditorView: NSViewRepresentable {
38 41
39 func updateNSView(_ container: NSView, context: Context) { 42 func updateNSView(_ container: NSView, context: Context) {
40 let coordinator = context.coordinator 43 let coordinator = context.coordinator
44 // Buffers that were killed take their editors with them.
45 for path in coordinator.editors.keys where !session.openPaths.contains(path) {
46 coordinator.editors[path]?.scroll.removeFromSuperview()
47 coordinator.editors[path] = nil
48 }
41 if coordinator.path != session.path { 49 if coordinator.path != session.path {
42 coordinator.path = session.path 50 coordinator.path = session.path
43 container.subviews.forEach { $0.removeFromSuperview() } 51 container.subviews.forEach { $0.removeFromSuperview() }
44 coordinator.editor = nil 52 coordinator.editor = nil
45 if let document = session.document { 53 if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container) {
46 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) 54 coordinator.editors[path] = shown
47 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode) 55 shown.scroll.frame = container.bounds
48 editor.onChange = { [weak session] in session?.bufferChanged() } 56 container.addSubview(shown.scroll)
49 editor.onMessage = { [weak commands] in commands?.show($0) } 57 container.window?.makeFirstResponder(shown.editor.textView)
50 editor.onKeysPending = { [weak commands] keys, next in 58 coordinator.editor = shown.editor
51 commands?.pendingKeys = keys 59 commands.editor = shown.editor
52 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } 60 session.attach(shown.editor)
53 } 61 } else {
54 editor.onModeChange = { [weak commands] in commands?.editorState = $0 } 62 commands.editor = nil
55 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 }
56 editor.onAppCommand = onAppCommand
57 editor.onExternal = onExternal
58 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
59 commands.editor = editor
60 let scroll = editor.makeScrollView()
61 scroll.frame = container.bounds
62 scroll.autoresizingMask = [.width, .height]
63 container.addSubview(scroll)
64 container.window?.makeFirstResponder(editor.textView)
65 coordinator.editor = editor
66 session.attach(editor)
67 } 63 }
68 } 64 }
69 if let editor = coordinator.editor { 65 if let editor = coordinator.editor {
@@ -75,10 +71,31 @@ struct EditorView: NSViewRepresentable {
75 editor.showLineNumbers = showLineNumbers 71 editor.showLineNumbers = showLineNumbers
76 editor.themeSpec = theme 72 editor.themeSpec = theme
77 } 73 }
78 container.window?.isDocumentEdited = session.isDirty 74 container.window?.isDocumentEdited = !session.dirtyPaths.isEmpty
79 if let request = session.revealRequest, request.id != coordinator.revealID { 75 if let request = session.revealRequest, request.id != coordinator.revealID {
80 coordinator.revealID = request.id 76 coordinator.revealID = request.id
81 coordinator.editor?.reveal(request.offset) 77 coordinator.editor?.reveal(request.offset)
82 } 78 }
83 } 79 }
80
81 /// An editor for the current buffer, which becomes the buffer's live state.
82 private func makeEditor(_ container: NSView) -> (editor: OrgEditor, scroll: NSScrollView)? {
83 guard let document = session.document else { return nil }
84 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
85 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode)
86 editor.onChange = { [weak session] in session?.bufferChanged() }
87 editor.onMessage = { [weak commands] in commands?.show($0) }
88 editor.onKeysPending = { [weak commands] keys, next in
89 commands?.pendingKeys = keys
90 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) }
91 }
92 editor.onModeChange = { [weak commands] in commands?.editorState = $0 }
93 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 }
94 editor.onAppCommand = onAppCommand
95 editor.onExternal = onExternal
96 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
97 let scroll = editor.makeScrollView()
98 scroll.autoresizingMask = [.width, .height]
99 return (editor, scroll)
100 }
84} 101}
Sources/Orgstar/OrgstarApp.swift +74 −1
@@ -115,7 +115,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
115 115
116 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { 116 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
117 guard let session else { return .terminateNow } 117 guard let session else { return .terminateNow }
118 return UnsavedChanges.resolve(session) ? .terminateNow : .terminateCancel 118 BufferRestore.save(session)
119 return UnsavedChanges.resolve(session, paths: session.openPaths) ? .terminateNow : .terminateCancel
119 } 120 }
120} 121}
121 122
@@ -139,6 +140,54 @@ enum UnsavedChanges {
139 default: return false 140 default: return false
140 } 141 }
141 } 142 }
143
144 /// Whether the buffers of `paths` can be closed: saved in automatic mode, or as the user
145 /// chooses for all of them in explicit mode.
146 static func resolve(_ session: DocumentSession, paths: [String]) -> Bool {
147 let dirty = paths.filter(session.dirtyPaths.contains)
148 guard !dirty.isEmpty else { return true }
149 if session.saveMode == .automatic { return dirty.map(session.save).allSatisfy { $0 } }
150 if dirty == [session.path] { return resolve(session) }
151 let alert = NSAlert()
152 alert.messageText = "Save changes to \(dirty.count) files?"
153 alert.informativeText = dirty.map { ($0 as NSString).lastPathComponent }.joined(separator: ", ")
154 alert.addButton(withTitle: "Save All")
155 alert.addButton(withTitle: "Don't Save")
156 alert.addButton(withTitle: "Cancel")
157 switch alert.runModal() {
158 case .alertFirstButtonReturn: return dirty.map(session.save).allSatisfy { $0 }
159 case .alertSecondButtonReturn: return true
160 default: return false
161 }
162 }
163}
164
165/// The open buffers, their order and carets, kept for the next launch.
166@MainActor
167enum BufferRestore {
168 private static let key = "openBuffers"
169
170 struct State: Codable {
171 var paths: [String]
172 var current: String?
173 var carets: [String: Int]
174 }
175
176 static func save(_ session: DocumentSession, defaults: UserDefaults = .standard) {
177 var carets: [String: Int] = [:]
178 for path in session.openPaths { carets[path] = session.caret(of: path) }
179 let state = State(paths: session.openPaths, current: session.path, carets: carets)
180 if let data = try? JSONEncoder().encode(state) { defaults.set(data, forKey: key) }
181 }
182
183 /// Opens the buffers that were open, in order, and returns the carets to show them at.
184 static func restore(_ session: DocumentSession, defaults: UserDefaults = .standard) -> [String: Int] {
185 guard let data = defaults.data(forKey: key), let state = try? JSONDecoder().decode(State.self, from: data) else { return [:] }
186 let existing = state.paths.filter { FileManager.default.fileExists(atPath: $0) }
187 for path in existing { session.open(path) }
188 if let current = state.current, existing.contains(current) { session.open(current) }
189 return state.carets
190 }
142} 191}
143 192
144@main 193@main
@@ -216,11 +265,26 @@ struct OrgstarApp: App {
216 .disabled(session.path == nil) 265 .disabled(session.path == nil)
217 } 266 }
218 CommandGroup(replacing: .saveItem) { 267 CommandGroup(replacing: .saveItem) {
268 Button("Close Buffer") {
269 // ⌘W closes the buffer in the main window, and other windows themselves.
270 if let key = NSApp.keyWindow, key.identifier?.rawValue.contains("main") != true {
271 key.performClose(nil)
272 } else {
273 commands.run("app.buffer.kill")
274 }
275 }
276 .keyboardShortcut("w")
277 Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
278 .keyboardShortcut("w", modifiers: [.command, .shift])
279 Divider()
219 Button("Save") { 280 Button("Save") {
220 if !session.save(), session.conflict != nil { commands.showingConflict = true } 281 if !session.save(), session.conflict != nil { commands.showingConflict = true }
221 } 282 }
222 .keyboardShortcut("s") 283 .keyboardShortcut("s")
223 .disabled(session.path == nil || session.isReadOnly) 284 .disabled(session.path == nil || session.isReadOnly)
285 Button("Save All") { commands.run("app.save-all") }
286 .keyboardShortcut("s", modifiers: [.command, .option])
287 .disabled(session.dirtyPaths.isEmpty)
224 } 288 }
225 CommandGroup(after: .sidebar) { 289 CommandGroup(after: .sidebar) {
226 Button("Show or Hide Outline") { commands.run("app.toggle-outline") } 290 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
@@ -237,6 +301,13 @@ struct OrgstarApp: App {
237 } 301 }
238 CommandGroup(before: .windowList) { 302 CommandGroup(before: .windowList) {
239 AgendaMenuItem() 303 AgendaMenuItem()
304 Divider()
305 Button("Switch to Buffer…") { commands.run("app.buffer.switch") }
306 Button("Next Buffer") { commands.run("app.buffer.next") }
307 .keyboardShortcut("]", modifiers: [.command, .shift])
308 Button("Previous Buffer") { commands.run("app.buffer.previous") }
309 .keyboardShortcut("[", modifiers: [.command, .shift])
310 Divider()
240 } 311 }
241 CommandGroup(after: .newItem) { 312 CommandGroup(after: .newItem) {
242 Button("Capture…") { commands.run("app.capture") } 313 Button("Capture…") { commands.run("app.capture") }
@@ -279,6 +350,7 @@ struct OrgMenuItems: View {
279 350
280/// View ▸ Show Markup (link brackets and targets, emphasis markers) and Show Line Numbers. 351/// View ▸ Show Markup (link brackets and targets, emphasis markers) and Show Line Numbers.
281struct MarkupToggle: View { 352struct MarkupToggle: View {
353 @AppStorage("showTabBar") private var showTabBar = false
282 @AppStorage("showMarkup") private var showMarkup = false 354 @AppStorage("showMarkup") private var showMarkup = false
283 @AppStorage("showLineNumbers") private var showLineNumbers = true 355 @AppStorage("showLineNumbers") private var showLineNumbers = true
284 356
@@ -287,6 +359,7 @@ struct MarkupToggle: View {
287 .keyboardShortcut("m", modifiers: [.command, .shift]) 359 .keyboardShortcut("m", modifiers: [.command, .shift])
288 Toggle("Show Line Numbers", isOn: $showLineNumbers) 360 Toggle("Show Line Numbers", isOn: $showLineNumbers)
289 .keyboardShortcut("l", modifiers: [.command, .shift]) 361 .keyboardShortcut("l", modifiers: [.command, .shift])
362 Toggle("Show Tab Bar", isOn: $showTabBar)
290 } 363 }
291} 364}
292 365
Sources/Orgstar/SidebarFiles.swift +21 −13
@@ -30,12 +30,14 @@ extension Sidebar {
30 pending = operation 30 pending = operation
31 } 31 }
32 32
33 /// Whether `path` is the open file or a folder holding it. 33 /// The open buffers of `path`, a file or a folder holding them.
34 func holdsOpenFile(_ path: String) -> Bool { 34 func openFiles(under path: String) -> [String] {
35 guard let open = session.path else { return false } 35 session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") }
36 return open == path || open.hasPrefix(path + "/")
37 } 36 }
38 37
38 /// Whether `path` is an open file or a folder holding one.
39 func holdsOpenFile(_ path: String) -> Bool { !openFiles(under: path).isEmpty }
40
39 func finish() { 41 func finish() {
40 guard let operation = pending else { return } 42 guard let operation = pending else { return }
41 pending = nil 43 pending = nil
@@ -45,20 +47,26 @@ extension Sidebar {
45 case .create(let folder): 47 case .create(let folder):
46 selection = try await workspace.createFile(named: name, in: folder) 48 selection = try await workspace.createFile(named: name, in: folder)
47 case .rename(let path): 49 case .rename(let path):
48 let affected = holdsOpenFile(path) 50 let affected = openFiles(under: path)
49 // The open file is saved first, so the rename carries its edits. 51 // Open files are saved first, so the rename carries their edits, and open
50 if affected, !UnsavedChanges.resolve(session) { return } 52 // again under their new paths.
53 if !UnsavedChanges.resolve(session, paths: affected) { return }
54 let shown = session.path
51 let renamed = try await workspace.rename(path, to: name) 55 let renamed = try await workspace.rename(path, to: name)
52 if affected, let open = session.path { 56 for open in affected {
53 let moved = renamed + open.dropFirst(path.count) 57 session.kill(open)
58 session.open(renamed + open.dropFirst(path.count))
59 }
60 if let shown, affected.contains(shown) {
61 let moved = renamed + shown.dropFirst(path.count)
54 session.open(moved) 62 session.open(moved)
55 selection = moved 63 selection = moved
64 } else if let shown {
65 session.open(shown)
56 } 66 }
57 case .trash(let path): 67 case .trash(let path):
58 if holdsOpenFile(path) { 68 for open in openFiles(under: path) { session.kill(open) }
59 session.close() 69 if session.path == nil { selection = nil }
60 selection = nil
61 }
62 try await workspace.trash(path) 70 try await workspace.trash(path)
63 } 71 }
64 } catch { 72 } catch {
Sources/Orgstar/ThemeColors.swift +16 −2
@@ -9,14 +9,28 @@ extension EnvironmentValues {
9 9
10extension ThemeSpec { 10extension ThemeSpec {
11 /// A color that follows the appearance, or nil when the theme leaves `key` to the system. 11 /// A color that follows the appearance, or nil when the theme leaves `key` to the system.
12 /// A key set for one appearance only leaves the other to the system.
12 func color(_ key: String) -> Color? { 13 func color(_ key: String) -> Color? {
13 guard let light = self.light[key] ?? self.dark[key], let dark = self.dark[key] ?? self.light[key] else { return nil } 14 guard light[key] != nil || dark[key] != nil else { return nil }
15 let (light, dark) = (light[key], dark[key])
16 let fallback = Self.systemColor(key)
14 return Color(nsColor: NSColor(name: nil) { appearance in 17 return Color(nsColor: NSColor(name: nil) { appearance in
15 let c = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light 18 let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
19 guard let c = isDark ? dark : light else { return fallback }
16 return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha) 20 return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
17 }) 21 })
18 } 22 }
19 23
24 /// What the system shows for a key the theme leaves unset in one appearance.
25 static func systemColor(_ key: String) -> NSColor {
26 switch key {
27 case "sidebar-background", "modeline-background": .windowBackgroundColor
28 case "agenda-background", "background": .textBackgroundColor
29 case "sidebar-header", "modeline-foreground", "agenda-time", "agenda-category", "agenda-scheduled", "tags": .secondaryLabelColor
30 default: .labelColor
31 }
32 }
33
20 /// A TODO keyword's color: its own from `[theme.todo]`, else the theme's TODO or DONE. 34 /// A TODO keyword's color: its own from `[theme.todo]`, else the theme's TODO or DONE.
21 func keywordColor(_ keyword: String, done: Bool) -> Color { 35 func keywordColor(_ keyword: String, done: Bool) -> Color {
22 if let c = todo[keyword] { return Color(.sRGB, red: c.red, green: c.green, blue: c.blue, opacity: c.alpha) } 36 if let c = todo[keyword] { return Color(.sRGB, red: c.red, green: c.green, blue: c.blue, opacity: c.alpha) }
Tests/OrgAppTests/AppTests.swift +60 −3
@@ -561,14 +561,71 @@ struct ClockModelTests {
561 #expect(clock.running?.heading == "Task") 561 #expect(clock.running?.heading == "Task")
562 #expect(ClockModel(file: stateFile).running == clock.running) 562 #expect(ClockModel(file: stateFile).running == clock.running)
563 563
564 // Out from another file: the clocked file is closed now (saved first, as leaving a 564 // Out from another file: the clocked file's buffer is still open behind it and takes
565 // file does in the app). 565 // the change.
566 #expect(session.save())
567 session.open(other.path) 566 session.open(other.path)
568 let message = clock.clockOut(session, now: start.addingTimeInterval(95 * 60)) 567 let message = clock.clockOut(session, now: start.addingTimeInterval(95 * 60))
569 #expect(message == "Clock stopped after 1:35") 568 #expect(message == "Clock stopped after 1:35")
570 #expect(clock.running == nil) 569 #expect(clock.running == nil)
570 #expect(session.dirtyPaths == [work.path])
571 #expect(session.saveAll())
571 let text = try String(contentsOf: work, encoding: .utf8) 572 let text = try String(contentsOf: work, encoding: .utf8)
572 #expect(text.hasPrefix("* Task\n:LOGBOOK:\nCLOCK: [") && text.contains("=> 1:35\n:END:\nBody\n"), "\(text)") 573 #expect(text.hasPrefix("* Task\n:LOGBOOK:\nCLOCK: [") && text.contains("=> 1:35\n:END:\nBody\n"), "\(text)")
573 } 574 }
574} 575}
576
577@MainActor
578struct BufferTests {
579 @Test func buffersKeepEditsWhileOthersShow() throws {
580 let notes = try Folder()
581 let a = try notes.write("a.org", "* A\n")
582 let b = try notes.write("b.org", "* B\n")
583 let c = try notes.write("c.org", "* C\n")
584 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
585 session.saveMode = .explicit
586 session.open(a.path)
587 _ = session.perform(SetTodo(keyword: "TODO"), path: a.path, at: 0, expect: nil)
588 session.open(b.path)
589 #expect(session.path == b.path)
590 #expect(session.openPaths == [a.path, b.path])
591 #expect(session.dirtyPaths == [a.path])
592 // A background buffer takes commands for its file, not the disk.
593 _ = session.perform(SetTodo(keyword: "DONE"), path: a.path, at: 0, expect: nil)
594 #expect(try String(contentsOf: a, encoding: .utf8) == "* A\n")
595 #expect(session.openDocuments.first { $0.path == a.path }?.document.text.hasPrefix("* DONE A") == true)
596
597 session.open(a.path)
598 #expect(session.document?.text.hasPrefix("* DONE A") == true)
599 session.open(c.path)
600 // Opened after the current buffer.
601 #expect(session.openPaths == [a.path, c.path, b.path])
602 session.cycle(1)
603 #expect(session.path == b.path)
604 session.cycle(1)
605 #expect(session.path == a.path)
606 session.showPrevious()
607 #expect(session.path == b.path)
608 #expect(session.recentPaths == [b.path, a.path, c.path])
609
610 session.kill(b.path)
611 #expect(session.path == a.path)
612 #expect(session.openPaths == [a.path, c.path])
613 #expect(session.saveAll())
614 #expect(try String(contentsOf: a, encoding: .utf8).hasPrefix("* DONE A"))
615 session.kill(a.path)
616 session.kill(c.path)
617 #expect(session.path == nil && session.document == nil && session.openPaths.isEmpty)
618 }
619
620 @Test func backgroundBuffersFollowTheDisk() throws {
621 let notes = try Folder()
622 let a = try notes.write("a.org", "* A\n")
623 let b = try notes.write("b.org", "* B\n")
624 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
625 session.open(a.path)
626 session.open(b.path)
627 try notes.write("a.org", "* A changed\n")
628 session.fileChanged(a.path)
629 #expect(session.openDocuments.first { $0.path == a.path }?.document.text == "* A changed\n")
630 }
631}
Tests/OrgCoreTests/KeymapTests.swift +3
@@ -95,6 +95,9 @@ struct KeymapTests {
95 let labels = doom.keyLabels(for: "org.todo.cycle", modes: ["normal", "insert"]) 95 let labels = doom.keyLabels(for: "org.todo.cycle", modes: ["normal", "insert"])
96 #expect(labels == ["C-c C-t", "SPC m t"]) 96 #expect(labels == ["C-c C-t", "SPC m t"])
97 #expect(doom.keyLabels(for: "app.quit", modes: ["normal", "insert"]) == ["SPC q q"]) 97 #expect(doom.keyLabels(for: "app.quit", modes: ["normal", "insert"]) == ["SPC q q"])
98 #expect(doom.keyLabels(for: "app.buffer.kill", modes: ["normal"], limit: 3) == ["SPC b d", "SPC b k"])
99 #expect(doom.keyLabels(for: "app.buffer.next", modes: ["normal"]) == ["] b", "SPC b ]"])
100 #expect(emacs.keyLabels(for: "app.buffer.switch") == ["C-x b", "C-x C-b"])
98 } 101 }
99 102
100 @Test func dispatchesPrefixes() { 103 @Test func dispatchesPrefixes() {