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