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