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 {
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() {