Buffers !54

merged merged by cmc on 2026-10-05 21:22 UTC · krz/orgstar:buffers into main

19 files changed, +640 −131

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +7 −7
@@ -41,8 +41,8 @@ public final class AgendaModel {
4141 .sorted()
4242 }
4343
44 /// Recomputes the days. `open` is the open file's path and text.
45 public func refresh(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current) async {
44 /// Recomputes the days. `open` is the open buffers' text by path.
45 public func refresh(open: [String: String], now: Date = Date(), calendar: Calendar = .current) async {
4646 generation += 1
4747 let id = generation
4848 isComputing = true
@@ -78,25 +78,25 @@ public final class AgendaModel {
7878 }
7979
8080 /// 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] {
8282 let all = await sources(open: open)
8383 return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value
8484 }
8585
8686 /// 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]) {
8888 let all = await sources(open: open)
8989 return await Task.detached { (Board.entries(all, match: match), Board.keywords(all)) }.value
9090 }
9191
9292 /// The agenda files' sources: the open file's from its text, the others from the cache
9393 /// 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] {
9595 let defaults = workspace.settings.org
9696 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
9797 for path in files {
98 if let open, open.path == path {
99 reads.append((path, nil, .distantPast, -1, open.text))
98 if let text = open[path] {
99 reads.append((path, nil, .distantPast, -1, text))
100100 continue
101101 }
102102 let attributes = try? FileManager.default.attributesOfItem(atPath: path)
Sources/OrgApp/CaptureModel.swift +1 −1
@@ -70,7 +70,7 @@ extension DocumentSession {
7070 /// otherwise in the file, created if missing, through the save path.
7171 public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? {
7272 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) {
7474 do {
7575 try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true)
7676 try Data().write(to: URL(fileURLWithPath: path))
Sources/OrgApp/ClockModel.swift +1 −1
@@ -79,7 +79,7 @@ public final class ClockModel {
7979 /// Where the running clock's line is, for `org-clock-goto`.
8080 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
8181 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
8383 guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil }
8484 let buffer = text as NSString
8585 let range = buffer.range(of: "CLOCK: [\(clock.start)]")
Sources/OrgApp/DocumentSession.swift +189 −59
@@ -42,15 +42,17 @@ public enum SaveMode: String, Sendable, CaseIterable {
4242 case explicit
4343}
4444
45/// The file open in a window: its buffer, outline, saving, and requests for the editor to
46/// show a location. The view layer attaches the editor as the buffer and follows
47/// `revealRequest`.
45/// The files open in a window, as Emacs's buffers: each keeps its text, caret, undo and
46/// unsaved edits while another is shown. The current buffer's outline, saving and requests
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`.
4849@MainActor
4950@Observable
5051public final class DocumentSession {
52 /// The current buffer's file.
5153 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
53 /// keystroke; views follow `path`, `isDirty` and `outline`.
54 /// A copy of the current buffer's state as of its last change. Not observed: it changes
55 /// on every keystroke; views follow `path`, `isDirty` and `outline`.
5456 @ObservationIgnored public private(set) var document: DocumentState?
5557 public private(set) var outline: [OutlineEntry] = []
5658 /// Bumped when the buffer settles after edits, and when a file opens: views that read
@@ -63,6 +65,10 @@ public final class DocumentSession {
6365 public private(set) var conflict: String?
6466 /// Bumped to ask the editor to reveal an offset.
6567 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> = []
6672 public var lastError: String?
6773 public var saveMode: SaveMode = .automatic
6874 public var autosaveDelay: Duration = .seconds(1)
@@ -71,10 +77,26 @@ public final class DocumentSession {
7177 /// TODO keywords and priorities for files without their own settings.
7278 public var defaults: OrgSettings = .default
7379
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
7494 private let fileSystem: FileSystem
7595 private let recovery: RecoveryStore
76 @ObservationIgnored private var buffer: DocumentBuffer?
77 @ObservationIgnored var autosave: Task<Void, Never>?
96 @ObservationIgnored private var entries: [Entry] = []
97 @ObservationIgnored private var current: Entry?
98 /// Paths, most recently shown first.
99 @ObservationIgnored private var history: [String] = []
78100 @ObservationIgnored private var outlineUpdate: Task<Void, Never>?
79101 private var requestID = 0
80102
@@ -86,6 +108,9 @@ public final class DocumentSession {
86108 self.recovery = recovery
87109 }
88110
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 }
89114 private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
90115 /// Whether the open file is an org file (`.org`, `.org_archive`), not plain text.
91116 public var isOrg: Bool { path.map(Self.isOrgPath) ?? false }
@@ -98,39 +123,101 @@ public final class DocumentSession {
98123 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
99124 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
100125
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.
101147 public func open(_ path: String) {
148 if let existing = entry(path) {
149 activate(existing)
150 return
151 }
102152 do {
103153 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
104154 lastError = "\((path as NSString).lastPathComponent) no longer exists."
105155 return
106156 }
107 autosave?.cancel()
108 self.path = path
109 buffer = StateBuffer(DocumentState(bytes: bytes, defaults: defaults))
110 conflict = nil
111 revealRequest = nil
112 refresh()
157 let entry = Entry(path: path, buffer: StateBuffer(DocumentState(bytes: bytes, defaults: defaults)))
158 let index = current.flatMap { c in entries.firstIndex { $0 === c } }.map { $0 + 1 } ?? entries.count
159 entries.insert(entry, at: index)
160 activate(entry)
113161 } catch {
114162 lastError = "Can't open \(path): \(error.localizedDescription)"
115163 }
116164 }
117165
118 /// Closes the open file without saving, as when it was deleted.
119 public func close() {
120 autosave?.cancel()
121 outlineUpdate?.cancel()
122 path = nil
123 buffer = nil
124 document = nil
125 outline = []
126 isDirty = false
127 conflict = nil
166 private func activate(_ entry: Entry) {
167 current = entry
168 path = entry.path
169 conflict = entry.conflict
128170 revealRequest = nil
171 history.removeAll { $0 == entry.path }
172 history.insert(entry.path, at: 0)
173 refresh()
129174 }
130175
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.
132219 public func attach(_ buffer: DocumentBuffer) {
133 self.buffer = buffer
220 current?.buffer = buffer
134221 refresh()
135222 }
136223
@@ -144,58 +231,87 @@ public final class DocumentSession {
144231 self.outline = self.isOrg ? Presentation.outline(document.tree) : []
145232 self.editVersion += 1
146233 }
147 scheduleAutosave()
234 if let current { scheduleAutosave(current) }
148235 }
149236
150237 private func refresh(outline updateOutline: Bool = true) {
238 refreshLists()
151239 guard let buffer else { return }
152240 let state = buffer.document
153241 document = state
154242 if isDirty != state.isDirty { isDirty = state.isDirty }
155243 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
244 if conflict != current?.conflict { conflict = current?.conflict }
156245 if updateOutline {
157246 outline = isOrg ? Presentation.outline(state.tree) : []
158247 editVersion += 1
159248 }
160249 }
161250
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
162258 // MARK: - Saving
163259
164260 private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) }
165261
166 private func scheduleAutosave() {
167 autosave?.cancel()
168 guard saveMode == .automatic, isDirty, conflict == nil else { return }
169 autosave = Task { [weak self, autosaveDelay] in
262 private func scheduleAutosave(_ entry: Entry) {
263 entry.autosave?.cancel()
264 guard saveMode == .automatic, entry.buffer.document.isDirty, entry.conflict == nil else { return }
265 entry.autosave = Task { [weak self, weak entry, autosaveDelay] in
170266 try? await Task.sleep(for: autosaveDelay)
171 guard !Task.isCancelled else { return }
172 self?.save()
267 guard !Task.isCancelled, let self, let entry else { return }
268 self.save(entry)
173269 }
174270 }
175271
176 /// Saves now. True when nothing is left unsaved.
272 /// Saves the current buffer now. True when nothing is left unsaved.
177273 @discardableResult
178274 public func save() -> Bool {
179 autosave?.cancel()
180 guard let buffer, let url, isDirty else { return !isDirty }
181 guard conflict == nil else { return false }
275 guard let current else { return true }
276 return save(current)
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 }
182298 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)) }
184300 switch outcome {
185301 case .saved, .mergedAndSaved:
186302 break
187303 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."
189305 case .overwroteExternalChange(let change), .changedAfterWrite(let change):
190306 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."
192308 }
193309 }
194310 } catch {
195311 lastError = "Can't save \(name): \(error.localizedDescription)"
196312 }
197 refresh()
198 return !isDirty
313 if entry === current { refresh() } else { refreshLists() }
314 return !entry.buffer.document.isDirty
199315 }
200316
201317 /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery.
@@ -203,7 +319,7 @@ public final class DocumentSession {
203319 guard let buffer, let url else { return }
204320 do {
205321 try buffer.update { try saver.overwrite(&$0, to: url) }
206 conflict = nil
322 current?.conflict = nil
207323 } catch {
208324 lastError = "Can't save \(name): \(error.localizedDescription)"
209325 }
@@ -217,7 +333,7 @@ public final class DocumentSession {
217333 guard let bytes = try fileSystem.read(url) else { return }
218334 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
219335 buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) }
220 conflict = nil
336 current?.conflict = nil
221337 } catch {
222338 lastError = "Can't read \(name): \(error.localizedDescription)"
223339 }
@@ -310,12 +426,15 @@ public final class DocumentSession {
310426 guard expect.start + (expect.line as NSString).length <= ns.length else { return false }
311427 return ns.substring(with: NSRange(location: expect.start, length: (expect.line as NSString).length)) == expect.line
312428 }
313 if path == self.path, let buffer {
429 if let entry = entry(path) {
430 let buffer = entry.buffer
314431 guard matches(buffer.document.text) else { return stale }
315432 do {
316433 let options = self.options
317434 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 }
319438 return step
320439 } catch {
321440 return .failed("\(error)")
@@ -343,14 +462,16 @@ public final class DocumentSession {
343462 /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table
344463 /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area.
345464 public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String {
346 guard let buffer, let path else { return "No file is open." }
347 let snapshot = buffer.document.text as NSString
465 guard let entry = current else { return "No file is open." }
466 let path = entry.path
467 let snapshot = entry.buffer.document.text as NSString
348468 guard table.upperBound <= snapshot.length else { return "The table moved; try again." }
349469 let old = snapshot.substring(with: NSRange(table))
350470 let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
351471 do {
352472 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
354475 let text = current.document.text as NSString
355476 guard table.upperBound <= text.length, text.substring(with: NSRange(table)) == old else {
356477 return "The table changed while Emacs recalculated it; nothing was changed."
@@ -358,7 +479,7 @@ public final class DocumentSession {
358479 guard new != old else { return "Recalculated in Emacs" }
359480 let result = EditResult(baseRevision: current.document.revision, edits: [TextEdit(range: table, replacement: new)])
360481 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
361 bufferChanged()
482 changed(entry)
362483 return "Recalculated in Emacs"
363484 } catch {
364485 return "\(error)"
@@ -368,11 +489,12 @@ public final class DocumentSession {
368489 /// Runs a src block and writes its result under it, unless the block changed while it
369490 /// ran. Returns a message for the echo area.
370491 public func runBabel(_ job: BabelJob) async -> String {
371 guard let path, buffer != nil else { return "No file is open." }
372 let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
492 guard let entry = current else { return "No file is open." }
493 let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent()
373494 do {
374495 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
376498 let document = current.document
377499 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) {
378500 case .failure(.message(let message)):
@@ -381,7 +503,7 @@ public final class DocumentSession {
381503 guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." }
382504 let result = EditResult(baseRevision: document.revision, edits: edits)
383505 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
384 bufferChanged()
506 changed(entry)
385507 return "Code block evaluation complete."
386508 }
387509 } catch {
@@ -389,6 +511,11 @@ public final class DocumentSession {
389511 }
390512 }
391513
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
392519 // MARK: - Locations
393520
394521 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
@@ -416,19 +543,22 @@ public final class DocumentSession {
416543 revealRequest = (offset, requestID)
417544 }
418545
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.
420548 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
422551 guard bytes != buffer.document.mergeBase else { return }
552 let name = (path as NSString).lastPathComponent
423553 switch buffer.update({ $0.diskChanged(to: bytes) }) {
424554 case .conflict:
425 autosave?.cancel()
426 conflict = "\(name) changed on disk, and the changes conflict with yours."
555 entry.autosave?.cancel()
556 entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
427557 case .merged, .reloaded:
428 conflict = nil
558 entry.conflict = nil
429559 case .unchanged:
430560 break
431561 }
432 refresh()
562 if entry === current { refresh() } else { refreshLists() }
433563 }
434564}
Sources/OrgApp/UserConfig.swift +2
@@ -99,6 +99,8 @@ public enum UserConfig {
9999 Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta",
100100 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"),
101101 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: ""),
102104 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
103105 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
104106 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 {
266266 /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place
267267 /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text
268268 /// 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
269278 public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] {
270279 guard let path, let document, document.isDirty, let bytes = try? document.encodedText(),
271280 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 {
9494 keys = "SPC f s"
9595 command = "app.save"
9696 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"
97162 [[bind]]
98163 keys = "SPC s s"
99164 command = "edit.find"
@@ -490,6 +555,24 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
490555 keys = "C-x C-s"
491556 command = "app.save"
492557 [[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]]
493576 keys = "C-x C-f"
494577 command = "app.quick-open"
495578 [[bind]]
@@ -745,6 +828,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
745828 /// Standard Mac text keys stay as they are; org commands use Control-Command chords, which
746829 /// macOS text editing leaves free.
747830 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"
748837 [[bind]]
749838 keys = "C-s-t"
750839 command = "org.todo.cycle"
Sources/OrgEditorAppKit/EditorCommands.swift +9
@@ -40,6 +40,15 @@ public enum EditorCommands {
4040
4141 EditorCommand(id: "app.save", title: "Save", action: .app),
4242 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),
4352 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
4453 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
4554 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
Sources/OrgEditorAppKit/Keys.swift +3
@@ -15,6 +15,9 @@ final class OrgTextView: NSTextView {
1515 /// Returns true when the key was handled.
1616 var keyHandler: ((NSEvent) -> Bool)?
1717 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 }
1821 var lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
1922 var lineNumberColor = NSColor.tertiaryLabelColor
2023 var currentLineNumberColor = NSColor.labelColor
Sources/OrgEditorAppKit/Theme.swift +4 −2
@@ -19,8 +19,10 @@ struct Theme {
1919
2020 /// A color that follows the appearance, or nil when the theme has none for `key`.
2121 func color(_ key: String) -> NSColor? {
22 let light = spec.light[key] ?? spec.dark[key]
23 let dark = spec.dark[key] ?? spec.light[key]
22 // Editor colors are set for both appearances by the default theme; a key the theme
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]
2426 guard let light, let dark else { return nil }
2527 return Self.dynamic(light, dark)
2628 }
Sources/Orgstar/AgendaView.swift +1 −1
@@ -162,7 +162,7 @@ struct AgendaView: View {
162162 agenda.span = span
163163 agenda.startOffset = startOffset
164164 agenda.includeSubfolders = includeSubfolders
165 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
165 let open = session.openTexts
166166 await agenda.refresh(open: open)
167167 }
168168 .task {
Sources/Orgstar/BoardView.swift +1 −1
@@ -78,7 +78,7 @@ struct BoardView: View {
7878 .navigationTitle(kanban ? "Kanban" : "Entries")
7979 .task(id: RefreshKey(match: match, subfolders: includeSubfolders, files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, path: session.path, tick: tick)) {
8080 agenda.includeSubfolders = includeSubfolders
81 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
81 let open = session.openTexts
8282 let result = await agenda.board(open: open, match: match)
8383 entries = result.entries
8484 keywords = result.keywords
Sources/Orgstar/ContentView.swift +108 −15
@@ -67,6 +67,9 @@ struct ContentView: View {
6767 /// The outline for a file that isn't org, off until asked for.
6868 @State private var plainOutlineShown = false
6969 @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] = [:]
7073 @State private var query = ""
7174 @FocusState private var searchFocused: Bool
7275 @Environment(\.openWindow) private var openWindow
@@ -87,13 +90,16 @@ struct ContentView: View {
8790 if let previewPath {
8891 FilePreview(path: previewPath)
8992 } 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)
91 .safeAreaInset(edge: .bottom, spacing: 0) {
92 VStack(spacing: 0) {
93 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
94 EchoArea(commands: commands)
95 }
93 VStack(spacing: 0) {
94 if showTabBar { BufferTabs(session: session) }
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)
96 }
97 .safeAreaInset(edge: .bottom, spacing: 0) {
98 VStack(spacing: 0) {
99 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
100 EchoArea(commands: commands)
96101 }
102 }
97103 } else {
98104 ContentUnavailableView(
99105 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
@@ -146,8 +152,7 @@ struct ContentView: View {
146152 if query.isEmpty {
147153 OutlineColumn(session: session)
148154 } else {
149 SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in
150 guard location.path == session.path || UnsavedChanges.resolve(session) else { return }
155 SearchResults(results: workspace.search(query, overlay: workspace.overlay(session.openDocuments))) { location in
151156 selectedFile = location.path
152157 session.show(location)
153158 }
@@ -177,12 +182,15 @@ struct ContentView: View {
177182 }
178183 previewPath = nil
179184 guard path != session.path else { return }
180 if UnsavedChanges.resolve(session) {
181 session.open(path)
182 } else {
183 selectedFile = old
184 }
185 session.open(path)
185186 }
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) }
186194 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
187195 .onChange(of: options, initial: true) { _, options in session.options = options }
188196 .onChange(of: preset, initial: true) { loadKeymap() }
@@ -233,7 +241,6 @@ struct ContentView: View {
233241 .onChange(of: commands.jumpRequest?.id) {
234242 guard let request = commands.jumpRequest else { return }
235243 if request.path != session.path {
236 guard UnsavedChanges.resolve(session) else { return }
237244 session.open(request.path)
238245 selectedFile = request.path
239246 }
@@ -279,6 +286,8 @@ struct ContentView: View {
279286 }
280287 .task {
281288 DevelopmentHooks.scheduleSnapshot()
289 pendingCarets = BufferRestore.restore(session)
290 if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) }
282291 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
283292 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
284293 if let command = DevelopmentHooks.commandToRun { perform(command) }
@@ -389,7 +398,7 @@ struct ContentView: View {
389398 let agenda = reminderAgenda ?? AgendaModel(workspace: workspace)
390399 reminderAgenda = agenda
391400 agenda.includeSubfolders = agendaSubfolders
392 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
401 let open = session.openTexts
393402 let reminders = await agenda.reminders(open: open, lead: reminderLead)
394403 guard !Task.isCancelled else { return }
395404 await scheduler.reconcile(reminders)
@@ -406,6 +415,22 @@ struct ContentView: View {
406415 }
407416 }
408417
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
409434 /// Runs a command from a key, the palette or the Org menu.
410435 private func perform(_ id: String) {
411436 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.")
@@ -417,6 +442,25 @@ struct ContentView: View {
417442 case "app.save":
418443 if !session.save(), session.conflict != nil { commands.showingConflict = true }
419444 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()
420464 case "app.toggle-outline": toggleOutline()
421465 case "app.toggle-markup": showMarkup.toggle()
422466 case "app.toggle-line-numbers": showLineNumbers.toggle()
@@ -680,3 +724,52 @@ struct PaneDivider: View {
680724 .accessibilityHidden(true)
681725 }
682726}
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
66import OrgHighlight
77import SwiftUI
88
9/// The TextKit 2 editor for the session's file. A new file gets a new editor, which becomes the
10/// session's buffer; reveal requests go to the existing one.
9/// The TextKit 2 editors for the session's buffers: one per open file, kept while another
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.
1112struct EditorView: NSViewRepresentable {
1213 /// One highlighter for every editor: it caches compiled grammars and results.
1314 static let highlighter = TreeSitterHighlighter()
@@ -28,6 +29,8 @@ struct EditorView: NSViewRepresentable {
2829 var editor: OrgEditor?
2930 var path: String?
3031 var revealID = 0
32 /// Editors of open buffers, with their scroll views.
33 var editors: [String: (editor: OrgEditor, scroll: NSScrollView)] = [:]
3134 }
3235
3336 func makeCoordinator() -> Coordinator { Coordinator() }
@@ -38,32 +41,25 @@ struct EditorView: NSViewRepresentable {
3841
3942 func updateNSView(_ container: NSView, context: Context) {
4043 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 }
4149 if coordinator.path != session.path {
4250 coordinator.path = session.path
4351 container.subviews.forEach { $0.removeFromSuperview() }
4452 coordinator.editor = nil
45 if let document = session.document {
46 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
47 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode)
48 editor.onChange = { [weak session] in session?.bufferChanged() }
49 editor.onMessage = { [weak commands] in commands?.show($0) }
50 editor.onKeysPending = { [weak commands] keys, next in
51 commands?.pendingKeys = keys
52 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) }
53 }
54 editor.onModeChange = { [weak commands] in commands?.editorState = $0 }
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)
53 if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container) {
54 coordinator.editors[path] = shown
55 shown.scroll.frame = container.bounds
56 container.addSubview(shown.scroll)
57 container.window?.makeFirstResponder(shown.editor.textView)
58 coordinator.editor = shown.editor
59 commands.editor = shown.editor
60 session.attach(shown.editor)
61 } else {
62 commands.editor = nil
6763 }
6864 }
6965 if let editor = coordinator.editor {
@@ -75,10 +71,31 @@ struct EditorView: NSViewRepresentable {
7571 editor.showLineNumbers = showLineNumbers
7672 editor.themeSpec = theme
7773 }
78 container.window?.isDocumentEdited = session.isDirty
74 container.window?.isDocumentEdited = !session.dirtyPaths.isEmpty
7975 if let request = session.revealRequest, request.id != coordinator.revealID {
8076 coordinator.revealID = request.id
8177 coordinator.editor?.reveal(request.offset)
8278 }
8379 }
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 }
84101}
Sources/Orgstar/OrgstarApp.swift +74 −1
@@ -115,7 +115,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
115115
116116 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
117117 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
119120 }
120121}
121122
@@ -139,6 +140,54 @@ enum UnsavedChanges {
139140 default: return false
140141 }
141142 }
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 }
142191}
143192
144193@main
@@ -216,11 +265,26 @@ struct OrgstarApp: App {
216265 .disabled(session.path == nil)
217266 }
218267 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()
219280 Button("Save") {
220281 if !session.save(), session.conflict != nil { commands.showingConflict = true }
221282 }
222283 .keyboardShortcut("s")
223284 .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)
224288 }
225289 CommandGroup(after: .sidebar) {
226290 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
@@ -237,6 +301,13 @@ struct OrgstarApp: App {
237301 }
238302 CommandGroup(before: .windowList) {
239303 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()
240311 }
241312 CommandGroup(after: .newItem) {
242313 Button("Capture…") { commands.run("app.capture") }
@@ -279,6 +350,7 @@ struct OrgMenuItems: View {
279350
280351/// View ▸ Show Markup (link brackets and targets, emphasis markers) and Show Line Numbers.
281352struct MarkupToggle: View {
353 @AppStorage("showTabBar") private var showTabBar = false
282354 @AppStorage("showMarkup") private var showMarkup = false
283355 @AppStorage("showLineNumbers") private var showLineNumbers = true
284356
@@ -287,6 +359,7 @@ struct MarkupToggle: View {
287359 .keyboardShortcut("m", modifiers: [.command, .shift])
288360 Toggle("Show Line Numbers", isOn: $showLineNumbers)
289361 .keyboardShortcut("l", modifiers: [.command, .shift])
362 Toggle("Show Tab Bar", isOn: $showTabBar)
290363 }
291364}
292365
Sources/Orgstar/SidebarFiles.swift +21 −13
@@ -30,12 +30,14 @@ extension Sidebar {
3030 pending = operation
3131 }
3232
33 /// Whether `path` is the open file or a folder holding it.
34 func holdsOpenFile(_ path: String) -> Bool {
35 guard let open = session.path else { return false }
36 return open == path || open.hasPrefix(path + "/")
33 /// The open buffers of `path`, a file or a folder holding them.
34 func openFiles(under path: String) -> [String] {
35 session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") }
3736 }
3837
38 /// Whether `path` is an open file or a folder holding one.
39 func holdsOpenFile(_ path: String) -> Bool { !openFiles(under: path).isEmpty }
40
3941 func finish() {
4042 guard let operation = pending else { return }
4143 pending = nil
@@ -45,20 +47,26 @@ extension Sidebar {
4547 case .create(let folder):
4648 selection = try await workspace.createFile(named: name, in: folder)
4749 case .rename(let path):
48 let affected = holdsOpenFile(path)
49 // The open file is saved first, so the rename carries its edits.
50 if affected, !UnsavedChanges.resolve(session) { return }
50 let affected = openFiles(under: path)
51 // Open files are saved first, so the rename carries their edits, and open
52 // again under their new paths.
53 if !UnsavedChanges.resolve(session, paths: affected) { return }
54 let shown = session.path
5155 let renamed = try await workspace.rename(path, to: name)
52 if affected, let open = session.path {
53 let moved = renamed + open.dropFirst(path.count)
56 for open in affected {
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)
5462 session.open(moved)
5563 selection = moved
64 } else if let shown {
65 session.open(shown)
5666 }
5767 case .trash(let path):
58 if holdsOpenFile(path) {
59 session.close()
60 selection = nil
61 }
68 for open in openFiles(under: path) { session.kill(open) }
69 if session.path == nil { selection = nil }
6270 try await workspace.trash(path)
6371 }
6472 } catch {
Sources/Orgstar/ThemeColors.swift +16 −2
@@ -9,14 +9,28 @@ extension EnvironmentValues {
99
1010extension ThemeSpec {
1111 /// 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.
1213 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)
1417 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 }
1620 return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
1721 })
1822 }
1923
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
2034 /// A TODO keyword's color: its own from `[theme.todo]`, else the theme's TODO or DONE.
2135 func keywordColor(_ keyword: String, done: Bool) -> Color {
2236 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 {
561561 #expect(clock.running?.heading == "Task")
562562 #expect(ClockModel(file: stateFile).running == clock.running)
563563
564 // Out from another file: the clocked file is closed now (saved first, as leaving a
565 // file does in the app).
566 #expect(session.save())
564 // Out from another file: the clocked file's buffer is still open behind it and takes
565 // the change.
567566 session.open(other.path)
568567 let message = clock.clockOut(session, now: start.addingTimeInterval(95 * 60))
569568 #expect(message == "Clock stopped after 1:35")
570569 #expect(clock.running == nil)
570 #expect(session.dirtyPaths == [work.path])
571 #expect(session.saveAll())
571572 let text = try String(contentsOf: work, encoding: .utf8)
572573 #expect(text.hasPrefix("* Task\n:LOGBOOK:\nCLOCK: [") && text.contains("=> 1:35\n:END:\nBody\n"), "\(text)")
573574 }
574575}
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 {
9595 let labels = doom.keyLabels(for: "org.todo.cycle", modes: ["normal", "insert"])
9696 #expect(labels == ["C-c C-t", "SPC m t"])
9797 #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"])
98101 }
99102
100103 @Test func dispatchesPrefixes() {