Mac app fixes from the manual review !158
20 files changed, +267 −54
Layout: unified · split
Sources/OrgApp/BabelRunner.swift +1 −1
| @@ -12,7 +12,7 @@ public enum BabelRunner { | ||
| 12 | 12 | /// PATH with the usual install locations, which an app launched from the Dock lacks. |
| 13 | 13 | public static var environment: [String: String] { |
| 14 | 14 | var environment = ProcessInfo.processInfo.environment |
| 15 | let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] | |
| 15 | let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/Library/TeX/texbin", "/usr/bin", "/bin"] | |
| 16 | 16 | let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init) |
| 17 | 17 | environment["PATH"] = (path + extra.filter { !path.contains($0) }).joined(separator: ":") |
| 18 | 18 | return environment |
Sources/OrgApp/ConfigSync.swift +29 −16
| @@ -43,14 +43,11 @@ public final class ConfigSync { | ||
| 43 | 43 | } |
| 44 | 44 | if FileManager.default.fileExists(atPath: url.path) { |
| 45 | 45 | reload() |
| 46 | // A file from before settings took Emacs's names is written again with them; one | |
| 47 | // from before a section existed gets the section. | |
| 48 | if let text, UserConfig.usesLegacyNames(text) { | |
| 49 | let template = UserConfig.template(from: defaults) | |
| 50 | try? template.write(to: url, atomically: true, encoding: .utf8) | |
| 51 | self.text = template | |
| 52 | } else if let text { | |
| 53 | let completed = UserConfig.addingMissingSections(text, from: defaults) | |
| 46 | // A file from before settings took Emacs's names gets them in place; one from | |
| 47 | // before a section existed gets the section. | |
| 48 | if let text { | |
| 49 | let migrated = UserConfig.usesLegacyNames(text) ? UserConfig.migratingLegacyNames(text) : text | |
| 50 | let completed = UserConfig.addingMissingSections(migrated, from: defaults) | |
| 54 | 51 | if completed != text { |
| 55 | 52 | try? completed.write(to: url, atomically: true, encoding: .utf8) |
| 56 | 53 | self.text = completed |
| @@ -65,17 +62,23 @@ public final class ConfigSync { | ||
| 65 | 62 | MainActor.assumeIsolated { self?.writeBack() } |
| 66 | 63 | } |
| 67 | 64 | guard watch else { return } |
| 68 | let directory = url.deletingLastPathComponent() | |
| 69 | watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in | |
| 70 | let names = Set(events.compactMap { event -> String? in | |
| 71 | if case .changed(let url) = event { return url.lastPathComponent } | |
| 65 | let directory = url.deletingLastPathComponent().resolvingSymlinksInPath() | |
| 66 | // Files still read from where earlier versions kept them reload when edited there too. | |
| 67 | let legacy = UserConfig.legacyDirectory.resolvingSymlinksInPath() | |
| 68 | let watchesLegacy = legacy.path != directory.path && FileManager.default.fileExists(atPath: legacy.path) | |
| 69 | watcher = FSEventsWatcher(roots: [directory] + (watchesLegacy ? [legacy] : [])) { [weak self] events in | |
| 70 | let changed = Set(events.compactMap { event -> URL? in | |
| 71 | if case .changed(let url) = event { return url } | |
| 72 | 72 | return nil |
| 73 | 73 | }) |
| 74 | 74 | Task { @MainActor [weak self] in |
| 75 | 75 | guard let self else { return } |
| 76 | for name in names.sorted() { | |
| 76 | for file in changed.sorted(by: { $0.path < $1.path }) { | |
| 77 | if !file.path.hasPrefix(directory.path + "/"), | |
| 78 | UserConfig.file(file.lastPathComponent).resolvingSymlinksInPath().path != file.resolvingSymlinksInPath().path { continue } | |
| 79 | let name = file.lastPathComponent | |
| 77 | 80 | if name == self.url.lastPathComponent { self.reload() } |
| 78 | if name == self.defaults.string(forKey: "themeFile") { self.readTheme() } | |
| 81 | if self.isThemeFile(file) { self.readTheme() } | |
| 79 | 82 | self.serial += 1 |
| 80 | 83 | self.changedFile = (name, self.serial) |
| 81 | 84 | } |
| @@ -94,13 +97,23 @@ public final class ConfigSync { | ||
| 94 | 97 | readTheme() |
| 95 | 98 | } |
| 96 | 99 | |
| 100 | /// `theme-file`, relative to the configuration folder. | |
| 101 | private var themeFile: URL? { | |
| 102 | guard let name = defaults.string(forKey: "themeFile"), !name.isEmpty else { return nil } | |
| 103 | return url.deletingLastPathComponent().appendingPathComponent(name) | |
| 104 | } | |
| 105 | ||
| 106 | /// Whether `file`, as the watcher reports it, is `theme-file`, which may be in a subfolder. | |
| 107 | func isThemeFile(_ file: URL) -> Bool { | |
| 108 | themeFile?.resolvingSymlinksInPath().path == file.resolvingSymlinksInPath().path | |
| 109 | } | |
| 110 | ||
| 97 | 111 | /// The theme from the file read last and its theme file. |
| 98 | 112 | func readTheme() { |
| 99 | 113 | guard let text, let tables = try? TOML.parse(text) else { return } |
| 100 | 114 | var spec = ThemeSpec.default |
| 101 | 115 | var themeProblems: [String] = [] |
| 102 | if let name = defaults.string(forKey: "themeFile"), !name.isEmpty { | |
| 103 | let file = url.deletingLastPathComponent().appendingPathComponent(name) | |
| 116 | if let name = defaults.string(forKey: "themeFile"), let file = themeFile { | |
| 104 | 117 | if let themeText = try? String(contentsOf: file, encoding: .utf8) { |
| 105 | 118 | do { |
| 106 | 119 | (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name) |
Sources/OrgApp/EmacsImport.swift +11 −6
| @@ -234,7 +234,7 @@ public struct EmacsImport: Sendable { | ||
| 234 | 234 | return modes.isEmpty ? [nil] : modes |
| 235 | 235 | } |
| 236 | 236 | |
| 237 | /// `(kbd "C-c x")`, `"C-c x"` (keymap-set), or a vector of one key. | |
| 237 | /// `(kbd "C-c x")` or `"C-c x"` (keymap-set); key vectors such as `[f5]` are skipped. | |
| 238 | 238 | func keyString(_ sexp: Sexp) -> String? { |
| 239 | 239 | if let s = sexp.string { return s } |
| 240 | 240 | if sexp.head == "kbd", let s = sexp.elements?.dropFirst().first?.string { return s } |
| @@ -618,7 +618,7 @@ public struct EmacsImport: Sendable { | ||
| 618 | 618 | |
| 619 | 619 | /// Emacs commands and the Orgstar commands that do the same. |
| 620 | 620 | static let commands: [String: String] = [ |
| 621 | "org-todo": "org.todo.cycle", "org-shiftright": "org.todo.cycle", "org-priority-up": "org.priority.up", "org-priority-down": "org.priority.down", | |
| 621 | "org-todo": "org.todo.cycle", "org-shiftright": "org.todo.next", "org-shiftleft": "org.todo.previous", "org-priority-up": "org.priority.up", "org-priority-down": "org.priority.down", | |
| 622 | 622 | "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote", |
| 623 | 623 | "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote", |
| 624 | 624 | "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote", |
| @@ -629,8 +629,8 @@ public struct EmacsImport: Sendable { | ||
| 629 | 629 | "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous", |
| 630 | 630 | "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level", |
| 631 | 631 | "outline-up-heading": "org.heading.up", "org-up-element": "org.heading.up", |
| 632 | "org-set-tags-command": "org.tags.set", "org-set-property": "org.property.set", | |
| 633 | "org-toggle-checkbox": "org.checkbox.toggle", "org-ctrl-c-ctrl-c": "org.dwim", "+org/dwim-at-point": "org.dwim", | |
| 632 | "org-set-tags-command": "org.tags.set", "org-set-property": "org.property.read-and-set", | |
| 633 | "org-toggle-checkbox": "org.checkbox.toggle", "org-ctrl-c-ctrl-c": "org.ctrl-c-ctrl-c", "+org/dwim-at-point": "org.dwim", | |
| 634 | 634 | "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active", |
| 635 | 635 | "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive", |
| 636 | 636 | "org-fill-paragraph": "org.fill-paragraph", "org-cycle": "org.cycle", "org-shifttab": "org.cycle-global", "org-global-cycle": "org.cycle-global", |
| @@ -652,7 +652,8 @@ public struct EmacsImport: Sendable { | ||
| 652 | 652 | extension EmacsImport { |
| 653 | 653 | /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace, |
| 654 | 654 | /// capture templates and key bindings appended to capture.toml and keymap.toml in |
| 655 | /// `directory`, leaving out templates whose key is there already. Returns a summary. | |
| 655 | /// `directory`, leaving out templates whose key is there already and bindings that are | |
| 656 | /// there already. Returns a summary. | |
| 656 | 657 | @MainActor |
| 657 | 658 | public static func apply( |
| 658 | 659 | _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory |
| @@ -695,9 +696,13 @@ extension EmacsImport { | ||
| 695 | 696 | } |
| 696 | 697 | let keymapFile = directory.appendingPathComponent("keymap.toml") |
| 697 | 698 | var keymap = (try? String(contentsOf: keymapFile, encoding: .utf8)) ?? "" |
| 699 | func binding(_ keys: String?, _ command: String?, _ mode: String?) -> String { [keys ?? "", command ?? "", mode ?? ""].joined(separator: "\u{0}") } | |
| 700 | var existingBindings = Set((try? TOML.parse(keymap))?.filter { $0.name == "bind" && $0.values["when"] == nil } | |
| 701 | .map { binding($0.values["keys"]?.string, $0.values["command"]?.string, $0.values["mode"]?.string) } ?? []) | |
| 698 | 702 | var addedKeys = "" |
| 699 | 703 | for item in items { |
| 700 | guard case .binding(let keys, let command, let mode) = item.change else { continue } | |
| 704 | guard case .binding(let keys, let command, let mode) = item.change, | |
| 705 | existingBindings.insert(binding(keys, command, mode)).inserted else { continue } | |
| 701 | 706 | addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n" |
| 702 | 707 | if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" } |
| 703 | 708 | bindings += 1 |
Sources/OrgApp/EmacsRunner.swift +8 −2
| @@ -67,7 +67,7 @@ public enum EmacsRunner { | ||
| 67 | 67 | process.terminate() |
| 68 | 68 | throw Failure(description: "Emacs took too long to recalculate the table.") |
| 69 | 69 | } |
| 70 | try await Task.sleep(for: .milliseconds(50)) | |
| 70 | try? await Task.sleep(for: .milliseconds(50)) | |
| 71 | 71 | if Task.isCancelled { |
| 72 | 72 | process.terminate() |
| 73 | 73 | throw CancellationError() |
| @@ -113,6 +113,8 @@ public enum EmacsRunner { | ||
| 113 | 113 | process.executableURL = emacs |
| 114 | 114 | process.arguments = ["-Q", "--batch", "-l", script.path] |
| 115 | 115 | process.currentDirectoryURL = URL(fileURLWithPath: path).deletingLastPathComponent() |
| 116 | // Babel's PATH, so a Dock-launched app's Emacs finds latexmk and pdflatex. | |
| 117 | process.environment = BabelRunner.environment | |
| 116 | 118 | let errors = Pipe() |
| 117 | 119 | process.standardError = errors |
| 118 | 120 | process.standardOutput = FileHandle.nullDevice |
| @@ -123,7 +125,11 @@ public enum EmacsRunner { | ||
| 123 | 125 | process.terminate() |
| 124 | 126 | throw Failure(description: "Emacs took too long to export.") |
| 125 | 127 | } |
| 126 | try await Task.sleep(for: .milliseconds(100)) | |
| 128 | try? await Task.sleep(for: .milliseconds(100)) | |
| 129 | if Task.isCancelled { | |
| 130 | process.terminate() | |
| 131 | throw CancellationError() | |
| 132 | } | |
| 127 | 133 | } |
| 128 | 134 | guard let written = try? String(contentsOf: output, encoding: .utf8) else { |
| 129 | 135 | let log = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) |
Sources/OrgApp/UserConfig.swift +54 −2
| @@ -23,12 +23,16 @@ public enum UserConfig { | ||
| 23 | 23 | return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar") |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | /// Where earlier versions kept the configuration files. | |
| 27 | public static var legacyDirectory: URL { | |
| 28 | FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar") | |
| 29 | } | |
| 30 | ||
| 26 | 31 | /// A file of the configuration folder; one that exists only where earlier versions kept |
| 27 | 32 | /// it (Application Support) is still read from there. |
| 28 | 33 | public static func file(_ name: String) -> URL { |
| 29 | 34 | let url = directory.appendingPathComponent(name) |
| 30 | let legacy = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 31 | .appendingPathComponent("Orgstar").appendingPathComponent(name) | |
| 35 | let legacy = legacyDirectory.appendingPathComponent(name) | |
| 32 | 36 | let manager = FileManager.default |
| 33 | 37 | if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy } |
| 34 | 38 | return url |
| @@ -243,6 +247,54 @@ public enum UserConfig { | ||
| 243 | 247 | return values(in: text).keys.contains { names.contains($0) } |
| 244 | 248 | } |
| 245 | 249 | |
| 250 | /// `text` with settings under their old names moved to their current names and places, | |
| 251 | /// keeping comments and everything else. A section the move leaves empty is removed. | |
| 252 | public static func migratingLegacyNames(_ text: String) -> String { | |
| 253 | let values = values(in: text) | |
| 254 | var blocks: [[String]] = [[]] | |
| 255 | for line in text.components(separatedBy: "\n") { | |
| 256 | if line.trimmingCharacters(in: .whitespaces).hasPrefix("[") { blocks.append([]) } | |
| 257 | blocks[blocks.count - 1].append(line) | |
| 258 | } | |
| 259 | var moves: [(setting: Setting, value: TOML.Value, comment: String)] = [] | |
| 260 | var kept: [String] = [] | |
| 261 | for block in blocks { | |
| 262 | let header = block.first.map { $0.trimmingCharacters(in: .whitespaces) }.flatMap { $0.hasPrefix("[") ? $0 : nil } | |
| 263 | let section = header.map { String($0.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) } ?? "" | |
| 264 | var remaining: [String] = [] | |
| 265 | for (index, line) in block.enumerated() { | |
| 266 | guard header == nil || index > 0, let equals = line.firstIndex(of: "=") else { | |
| 267 | remaining.append(line) | |
| 268 | continue | |
| 269 | } | |
| 270 | let name = path(section, line[..<equals].trimmingCharacters(in: .whitespaces)) | |
| 271 | guard let setting = settings.first(where: { !$0.legacy.isEmpty && $0.legacy == name }), let value = values[name] else { | |
| 272 | remaining.append(line) | |
| 273 | continue | |
| 274 | } | |
| 275 | // A setting also under its new name keeps that one. | |
| 276 | if values[setting.path] == nil { | |
| 277 | moves.append((setting, value, commentStart(line, after: equals).map { String(line[$0...]) } ?? "")) | |
| 278 | } | |
| 279 | } | |
| 280 | let emptied = header != nil && remaining.count < block.count | |
| 281 | && remaining.dropFirst().allSatisfy { $0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 282 | if !emptied { kept += remaining } | |
| 283 | } | |
| 284 | var out = kept.joined(separator: "\n") | |
| 285 | if text.hasSuffix("\n"), !out.isEmpty, !out.hasSuffix("\n") { out += "\n" } | |
| 286 | for move in moves { | |
| 287 | out = setting(out, section: move.setting.section, key: move.setting.key, to: move.value) | |
| 288 | guard !move.comment.isEmpty else { continue } | |
| 289 | var lines = out.components(separatedBy: "\n") | |
| 290 | if let index = lines.firstIndex(of: "\(move.setting.key) = \(format(move.value))") { | |
| 291 | lines[index] += " " + move.comment | |
| 292 | out = lines.joined(separator: "\n") | |
| 293 | } | |
| 294 | } | |
| 295 | return out | |
| 296 | } | |
| 297 | ||
| 246 | 298 | private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { |
| 247 | 299 | defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) |
| 248 | 300 | } |
Sources/OrgCore/Commands/Command.swift +6 −1
| @@ -151,7 +151,12 @@ public struct Prompt: Sendable, Equatable { | ||
| 151 | 151 | |
| 152 | 152 | public struct DateRead: Sendable, Equatable { |
| 153 | 153 | public let defaultDate: Date? |
| 154 | public init(defaultDate: Date?) { self.defaultDate = defaultDate } | |
| 154 | /// Whether the answer becomes an inactive timestamp, shown in brackets. | |
| 155 | public let inactive: Bool | |
| 156 | public init(defaultDate: Date?, inactive: Bool = false) { | |
| 157 | self.defaultDate = defaultDate | |
| 158 | self.inactive = inactive | |
| 159 | } | |
| 155 | 160 | } |
| 156 | 161 | |
| 157 | 162 | public init( |
Sources/OrgCore/Commands/TimestampCommands.swift +1 −1
| @@ -113,7 +113,7 @@ public struct InsertTimestamp: OrgCommand { | ||
| 113 | 113 | let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } |
| 114 | 114 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" |
| 115 | 115 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), |
| 116 | date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp)))) | |
| 116 | date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp), inactive: inactive))) | |
| 117 | 117 | } |
| 118 | 118 | // Twice in a row on a timestamp: a range to a second one. |
| 119 | 119 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" |
Sources/OrgCore/Keymap/Presets.swift +2 −2
| @@ -1122,7 +1122,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 1122 | 1122 | keys = "M-s-<up>" |
| 1123 | 1123 | command = "org.heading.previous" |
| 1124 | 1124 | [[bind]] |
| 1125 | keys = "C-s-q" | |
| 1125 | keys = "C-s-g" | |
| 1126 | 1126 | command = "org.tags.set" |
| 1127 | 1127 | [[bind]] |
| 1128 | 1128 | keys = "s-RET" |
| @@ -1175,7 +1175,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 1175 | 1175 | keys = "C-s-s" |
| 1176 | 1176 | command = "org.schedule" |
| 1177 | 1177 | [[bind]] |
| 1178 | keys = "C-s-d" | |
| 1178 | keys = "C-s-e" | |
| 1179 | 1179 | command = "org.deadline" |
| 1180 | 1180 | [[bind]] |
| 1181 | 1181 | keys = "C-s-p" |
Sources/Orgstar/CaptureView.swift +21 −5
| @@ -20,6 +20,8 @@ struct CaptureView: View { | ||
| 20 | 20 | @State private var selection: TextSelection? |
| 21 | 21 | @State private var context = CaptureContext() |
| 22 | 22 | @State private var editing = false |
| 23 | /// The template keys typed so far, for multi-key templates such as "wb". | |
| 24 | @State private var keys = "" | |
| 23 | 25 | @FocusState private var focused: Bool |
| 24 | 26 | @Environment(\.dismissWindow) private var dismissWindow |
| 25 | 27 | |
| @@ -57,8 +59,8 @@ struct CaptureView: View { | ||
| 57 | 59 | .keyboardShortcut(.defaultAction) |
| 58 | 60 | } |
| 59 | 61 | } else { |
| 60 | Text("Capture").font(.headline) | |
| 61 | ForEach(templates) { template in | |
| 62 | Text(keys.isEmpty ? "Capture" : "Capture: \(keys)").font(.headline) | |
| 63 | ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in | |
| 62 | 64 | Button { |
| 63 | 65 | choose(template) |
| 64 | 66 | } label: { |
| @@ -71,14 +73,26 @@ struct CaptureView: View { | ||
| 71 | 73 | } |
| 72 | 74 | .buttonStyle(.plain) |
| 73 | 75 | } |
| 74 | if let problem { Text(problem).font(.caption).foregroundStyle(.red) } | |
| 75 | 76 | } |
| 77 | if let problem { Text(problem).font(.caption).foregroundStyle(.red) } | |
| 76 | 78 | } |
| 77 | 79 | .padding(16) |
| 78 | 80 | .frame(width: 520) |
| 79 | 81 | .onKeyPress(phases: .down) { press in |
| 80 | guard template == nil, let match = templates.first(where: { $0.key == press.characters }) else { return .ignored } | |
| 81 | choose(match) | |
| 82 | guard template == nil else { return .ignored } | |
| 83 | if press.key == .delete, !keys.isEmpty { | |
| 84 | keys.removeLast() | |
| 85 | return .handled | |
| 86 | } | |
| 87 | // Keys narrow the list until they name a template, as `org-capture` selects one. | |
| 88 | let typed = keys + press.characters | |
| 89 | if let match = templates.first(where: { $0.key == typed }) { | |
| 90 | choose(match) | |
| 91 | } else if !press.characters.isEmpty, templates.contains(where: { $0.key.hasPrefix(typed) }) { | |
| 92 | keys = typed | |
| 93 | } else { | |
| 94 | return .ignored | |
| 95 | } | |
| 82 | 96 | return .handled |
| 83 | 97 | } |
| 84 | 98 | .onAppear { |
| @@ -119,6 +133,7 @@ struct CaptureView: View { | ||
| 119 | 133 | problem = loaded.problems.first |
| 120 | 134 | template = nil |
| 121 | 135 | editing = false |
| 136 | keys = "" | |
| 122 | 137 | let clocked = clock.running |
| 123 | 138 | let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) } |
| 124 | 139 | context = CaptureContext( |
| @@ -149,6 +164,7 @@ struct CaptureView: View { | ||
| 149 | 164 | |
| 150 | 165 | private func choose(_ chosen: CaptureTemplate) { |
| 151 | 166 | template = chosen |
| 167 | keys = "" | |
| 152 | 168 | // `%^g` offers the target file's tags. |
| 153 | 169 | let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first) |
| 154 | 170 | if !chosen.target.file.isEmpty, let text = session.openDocuments.first(where: { $0.path == path })?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) { |
Sources/Orgstar/ContentView.swift +29 −9
| @@ -818,27 +818,47 @@ struct QuickOpenView: View { | ||
| 818 | 818 | let workspace: WorkspaceModel |
| 819 | 819 | let open: (FileEntry) -> Void |
| 820 | 820 | @State private var query = "" |
| 821 | /// The highlighted match, moved by ↑/↓ or C-p/C-n and opened by Return. | |
| 822 | @State private var selected = 0 | |
| 821 | 823 | @Environment(\.dismiss) private var dismiss |
| 822 | 824 | |
| 823 | 825 | var body: some View { |
| 824 | 826 | let matches = workspace.quickOpen(query) |
| 827 | let current = min(selected, matches.count - 1) | |
| 825 | 828 | VStack(spacing: 0) { |
| 826 | 829 | TextField("Open file", text: $query) |
| 827 | 830 | .textFieldStyle(.plain) |
| 828 | 831 | .font(.title3) |
| 829 | 832 | .padding(12) |
| 830 | .onSubmit { if let first = matches.first { open(first) } } | |
| 833 | .onSubmit { if current >= 0 { open(matches[current]) } } | |
| 834 | .onKeyPress(phases: .down) { press in | |
| 835 | let control = press.modifiers == .control | |
| 836 | let step = if press.key == .downArrow || control && press.characters == "n" { 1 } | |
| 837 | else if press.key == .upArrow || control && press.characters == "p" { -1 } else { 0 } | |
| 838 | guard step != 0 else { return .ignored } | |
| 839 | selected = max(0, min(current + step, matches.count - 1)) | |
| 840 | return .handled | |
| 841 | } | |
| 842 | .onChange(of: query) { selected = 0 } | |
| 831 | 843 | Divider() |
| 832 | List(matches) { entry in | |
| 833 | Button { | |
| 834 | open(entry) | |
| 835 | } label: { | |
| 836 | VStack(alignment: .leading, spacing: 2) { | |
| 837 | Text(entry.name) | |
| 838 | Text(entry.relativePath).font(.caption).foregroundStyle(.secondary) | |
| 844 | ScrollViewReader { proxy in | |
| 845 | List(matches.indices, id: \.self) { index in | |
| 846 | let entry = matches[index] | |
| 847 | Button { | |
| 848 | open(entry) | |
| 849 | } label: { | |
| 850 | VStack(alignment: .leading, spacing: 2) { | |
| 851 | Text(entry.name) | |
| 852 | Text(entry.relativePath).font(.caption).foregroundStyle(.secondary) | |
| 853 | } | |
| 854 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 855 | .contentShape(Rectangle()) | |
| 839 | 856 | } |
| 857 | .buttonStyle(.plain) | |
| 858 | .listRowBackground(index == current ? Color.accentColor.opacity(0.25) : Color.clear) | |
| 859 | .accessibilityAddTraits(index == current ? .isSelected : []) | |
| 840 | 860 | } |
| 841 | .buttonStyle(.plain) | |
| 861 | .onChange(of: current) { if current >= 0 { proxy.scrollTo(current) } } | |
| 842 | 862 | } |
| 843 | 863 | } |
| 844 | 864 | .frame(width: 520, height: 360) |
Sources/Orgstar/DatePickerView.swift +4 −3
| @@ -22,7 +22,7 @@ struct DatePickerView: View { | ||
| 22 | 22 | Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") } |
| 23 | 23 | .accessibilityLabel("Next month") |
| 24 | 24 | Spacer() |
| 25 | Text("=> " + Self.stamp(result, calendar)).font(.system(.callout, design: .monospaced)) | |
| 25 | Text("=> " + Self.stamp(result, calendar, inactive: read.inactive)).font(.system(.callout, design: .monospaced)) | |
| 26 | 26 | } |
| 27 | 27 | .buttonStyle(.plain) |
| 28 | 28 | Grid(horizontalSpacing: 4, verticalSpacing: 2) { |
| @@ -102,11 +102,12 @@ struct DatePickerView: View { | ||
| 102 | 102 | return Self.answer(for: day, keeping: result, calendar) |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar) -> String { | |
| 105 | static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar, inactive: Bool) -> String { | |
| 106 | 106 | let formatter = DateFormatter() |
| 107 | 107 | formatter.locale = Locale(identifier: "en_US_POSIX") |
| 108 | 108 | formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE" |
| 109 | 109 | let end = result.timeGiven ? result.endTime.map { "-" + $0 } ?? "" : "" |
| 110 | return "<" + formatter.string(from: calendar.date(from: result.components(calendar))!) + end + ">" | |
| 110 | let stamp = formatter.string(from: calendar.date(from: result.components(calendar))!) + end | |
| 111 | return inactive ? "[" + stamp + "]" : "<" + stamp + ">" | |
| 111 | 112 | } |
| 112 | 113 | } |
Sources/Orgstar/OrgstarApp.swift +2 −2
| @@ -351,10 +351,10 @@ struct OrgstarApp: App { | ||
| 351 | 351 | } |
| 352 | 352 | CommandGroup(after: .sidebar) { |
| 353 | 353 | Button("Show or Hide Outline") { commands.run("app.toggle-outline") } |
| 354 | .keyboardShortcut("o", modifiers: [.command, .option]) | |
| 354 | 355 | Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } |
| 355 | 356 | Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") } |
| 356 | 357 | .keyboardShortcut("i", modifiers: [.command, .option]) |
| 357 | .keyboardShortcut("o", modifiers: [.command, .option]) | |
| 358 | 358 | MarkupToggle() |
| 359 | 359 | } |
| 360 | 360 | CommandGroup(after: .pasteboard) { |
| @@ -629,7 +629,7 @@ struct SettingsView: View { | ||
| 629 | 629 | private var agenda: some View { |
| 630 | 630 | Form { |
| 631 | 631 | Section { |
| 632 | Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31) | |
| 632 | Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...366) | |
| 633 | 633 | Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) |
| 634 | 634 | Toggle("Include files in subfolders", isOn: $agendaSubfolders) |
| 635 | 635 | } |
Sources/Orgstar/SidebarFiles.swift +3 −3
| @@ -35,8 +35,8 @@ extension Sidebar { | ||
| 35 | 35 | session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") } |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | /// Whether `path` is an open file or a folder holding one. | |
| 39 | func holdsOpenFile(_ path: String) -> Bool { !openFiles(under: path).isEmpty } | |
| 38 | /// Whether `path` is an open file with unsaved edits or a folder holding one. | |
| 39 | func holdsUnsavedFile(_ path: String) -> Bool { openFiles(under: path).contains(where: session.dirtyPaths.contains) } | |
| 40 | 40 | |
| 41 | 41 | func finish() { |
| 42 | 42 | guard let operation = pending else { return } |
| @@ -90,7 +90,7 @@ extension View { | ||
| 90 | 90 | .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) { |
| 91 | 91 | Button("Move to Trash", role: .destructive) { sidebar.finish() } |
| 92 | 92 | } message: { |
| 93 | if case .trash(let path) = sidebar.pending, sidebar.holdsOpenFile(path), sidebar.session.isDirty { | |
| 93 | if case .trash(let path) = sidebar.pending, sidebar.holdsUnsavedFile(path) { | |
| 94 | 94 | Text("Unsaved changes to the open file will be lost.") |
| 95 | 95 | } else { |
| 96 | 96 | Text("You can put it back from the Trash in Finder.") |
Tests/OrgAppTests/AppTests.swift +32
| @@ -783,6 +783,38 @@ struct EmacsExportTests { | ||
| 783 | 783 | #expect(text.uppercased().contains("IN THE BUFFER") && text.contains("Body."), "\(text)") |
| 784 | 784 | #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n") |
| 785 | 785 | } |
| 786 | ||
| 787 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 788 | func exportUsesBabelPath() async throws { | |
| 789 | let notes = try Folder() | |
| 790 | let file = try notes.write("doc.org", "") | |
| 791 | let function = #"(lambda () (with-temp-file "path.txt" (insert (getenv "PATH"))) "path.txt")"# | |
| 792 | let written = try await EmacsRunner.export(text: "", path: file.path, function: function) | |
| 793 | #expect(try String(contentsOfFile: written, encoding: .utf8) == BabelRunner.environment["PATH"]) | |
| 794 | } | |
| 795 | ||
| 796 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 797 | func cancellingStopsTheExport() async throws { | |
| 798 | let notes = try Folder() | |
| 799 | let file = try notes.write("doc.org", "") | |
| 800 | let pidFile = notes.url.appendingPathComponent("pid") | |
| 801 | let function = #"(lambda () (with-temp-file "pid" (insert (number-to-string (emacs-pid)))) (sleep-for 60) "x")"# | |
| 802 | let export = Task { try await EmacsRunner.export(text: "", path: file.path, function: function) } | |
| 803 | var pid: pid_t? | |
| 804 | for _ in 0..<200 where pid == nil { | |
| 805 | try await Task.sleep(for: .milliseconds(50)) | |
| 806 | pid = (try? String(contentsOf: pidFile, encoding: .utf8)).flatMap { pid_t($0) } | |
| 807 | } | |
| 808 | let started = try #require(pid) | |
| 809 | export.cancel() | |
| 810 | await #expect(throws: CancellationError.self) { try await export.value } | |
| 811 | var alive = true | |
| 812 | for _ in 0..<100 where alive { | |
| 813 | alive = kill(started, 0) == 0 | |
| 814 | if alive { try await Task.sleep(for: .milliseconds(50)) } | |
| 815 | } | |
| 816 | #expect(!alive) | |
| 817 | } | |
| 786 | 818 | } |
| 787 | 819 | |
| 788 | 820 | @MainActor |
Tests/OrgAppTests/EmacsImportTests.swift +6
| @@ -170,5 +170,11 @@ struct EmacsImportTests { | ||
| 170 | 170 | #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)") |
| 171 | 171 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") |
| 172 | 172 | #expect(defaults.integer(forKey: "themeFontSize") == 14) |
| 173 | ||
| 174 | // Importing again adds no bindings or templates. | |
| 175 | let before = try String(contentsOf: keymap, encoding: .utf8) | |
| 176 | let again = await EmacsImport.apply(result.items, defaults: defaults, workspace: nil, directory: folder) | |
| 177 | #expect(try String(contentsOf: keymap, encoding: .utf8) == before) | |
| 178 | #expect(!again.contains("key binding") && !again.contains("capture template"), "\(again)") | |
| 173 | 179 | } |
| 174 | 180 | } |
Tests/OrgAppTests/UserConfigTests.swift +28
| @@ -77,6 +77,18 @@ struct UserConfigTests { | ||
| 77 | 77 | #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]")) |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | @Test func legacyNamesMoveInPlace() { | |
| 81 | let text = "# mine\n[editor]\n# my editor\nfill-column = 64 # narrow\n\n[agenda]\nspan = 7\n\n[theme]\nfont-size = 15\n" | |
| 82 | let migrated = UserConfig.migratingLegacyNames(text) | |
| 83 | #expect(migrated == "# mine\nfill-column = 64 # narrow\norg-agenda-span = 7\n[editor]\n# my editor\n\n[theme]\nfont-size = 15\n") | |
| 84 | #expect(!UserConfig.usesLegacyNames(migrated)) | |
| 85 | let store = defaults() | |
| 86 | #expect(UserConfig.apply(migrated, to: store).isEmpty) | |
| 87 | #expect(store.integer(forKey: "fillColumn") == 64 && store.integer(forKey: "agendaSpan") == 7 && store.integer(forKey: "themeFontSize") == 15) | |
| 88 | // The new name wins over the old one. | |
| 89 | #expect(UserConfig.migratingLegacyNames("fill-column = 70\n[editor]\nfill-column = 64\n") == "fill-column = 70\n") | |
| 90 | } | |
| 91 | ||
| 80 | 92 | @Test func syncsBothWays() throws { |
| 81 | 93 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") |
| 82 | 94 | defer { try? FileManager.default.removeItem(at: folder) } |
| @@ -122,4 +134,20 @@ struct ThemeConfigTests { | ||
| 122 | 134 | #expect(sync.theme.light["todo"]?.hex == "#000002") |
| 123 | 135 | #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path)) |
| 124 | 136 | } |
| 137 | ||
| 138 | @Test func themeFileInASubfolderIsRecognized() throws { | |
| 139 | let name = "orgstar-theme-\(UUID().uuidString)" | |
| 140 | let defaults = UserDefaults(suiteName: name)! | |
| 141 | defer { defaults.removePersistentDomain(forName: name) } | |
| 142 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-theme-\(UUID().uuidString)") | |
| 143 | defer { try? FileManager.default.removeItem(at: folder) } | |
| 144 | try FileManager.default.createDirectory(at: folder.appendingPathComponent("themes"), withIntermediateDirectories: true) | |
| 145 | try "[theme]\ntheme-file = \"themes/x.toml\"\n".write(to: folder.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) | |
| 146 | let sync = ConfigSync(url: folder.appendingPathComponent("config.toml"), defaults: defaults) | |
| 147 | sync.start(watch: false) | |
| 148 | // The watcher reports paths under the folder with symlinks resolved (/private/var/…). | |
| 149 | let reported = folder.resolvingSymlinksInPath().appendingPathComponent("themes/x.toml") | |
| 150 | #expect(sync.isThemeFile(reported)) | |
| 151 | #expect(!sync.isThemeFile(folder.appendingPathComponent("x.toml"))) | |
| 152 | } | |
| 125 | 153 | } |
Tests/OrgCoreTests/KeymapTests.swift +11
| @@ -115,6 +115,17 @@ struct KeymapTests { | ||
| 115 | 115 | } |
| 116 | 116 | } |
| 117 | 117 | |
| 118 | /// ⌃⌘Q locks the screen, ⌃⌘D looks up a word, ⌃⌘F toggles full screen and ⌃⌘Space opens | |
| 119 | /// the character viewer: the app never sees them. | |
| 120 | @Test func macPresetAvoidsSystemShortcuts() { | |
| 121 | let reserved = ["q", "d", "f", "SPC"].map { KeyChord($0, [.control, .command]) } | |
| 122 | for binding in KeymapPreset.mac.keymap.bindings { | |
| 123 | #expect(!reserved.contains(binding.keys[0]), "\(KeySequence.format(binding.keys)) is \(binding.command)") | |
| 124 | } | |
| 125 | #expect(KeymapPreset.mac.keymap.keys(for: "org.tags.set") == [KeyChord("g", [.control, .command])]) | |
| 126 | #expect(KeymapPreset.mac.keymap.keys(for: "org.deadline") == [KeyChord("e", [.control, .command])]) | |
| 127 | } | |
| 128 | ||
| 118 | 129 | @Test func shadowedBindingsShowNoKeys() throws { |
| 119 | 130 | var problems: [String] = [] |
| 120 | 131 | let map = Keymap.layered([KeymapPreset.emacs.keymap, try Keymap(toml: "[[bind]]\nkeys = \"C-c C-t\"\ncommand = \"app.save\"\n", problems: &problems)]) |
Tests/OrgCoreTests/TimestampCommandTests.swift +11
| @@ -28,6 +28,17 @@ struct TimestampCommandTests { | ||
| 28 | 28 | #expect(run(ShiftTimestamp(up: true), "<2026-09-10 Thu 10:07>\n", caret: 20).text == "<2026-09-10 Thu 10:10>\n") |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | @Test func promptSaysWhetherTheStampIsInactive() { | |
| 32 | for inactive in [false, true] { | |
| 33 | let context = EditContext(revision: 0, text: "x\n", tree: OrgParser.parse("x\n"), selection: [1..<1], now: Self.now, calendar: Self.calendar) | |
| 34 | guard case .prompt(let prompt) = InsertTimestamp(inactive: inactive).run(in: context) else { | |
| 35 | Issue.record("no prompt") | |
| 36 | continue | |
| 37 | } | |
| 38 | #expect(prompt.date?.inactive == inactive) | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 31 | 42 | static let texts = [ |
| 32 | 43 | "* a\n", |
| 33 | 44 | "* a\nbody <2026-09-01 Tue> text\n", |
Tests/OrgEditorAppKitTests/KeyTests.swift +7
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgCore |
| 3 | 3 | import Testing |
| 4 | @testable import OrgApp | |
| 4 | 5 | @testable import OrgEditorAppKit |
| 5 | 6 | |
| 6 | 7 | @MainActor |
| @@ -133,6 +134,12 @@ struct KeyTests { | ||
| 133 | 134 | } |
| 134 | 135 | } |
| 135 | 136 | } |
| 137 | ||
| 138 | @Test func everyEmacsImportCommandExists() { | |
| 139 | for (emacs, command) in EmacsImport.commands { | |
| 140 | #expect(EditorCommands.command(command) != nil, "\(emacs): \(command)") | |
| 141 | } | |
| 142 | } | |
| 136 | 143 | } |
| 137 | 144 | |
| 138 | 145 | @MainActor |
scripts/publish-docs.sh +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | #!/bin/sh |
| 2 | 2 | # Builds the user manual in docs/manual with orgo and force-pushes it to the `pages` |
| 3 | # branch, which gitbay serves at https://krz.gitbay.page/orgstar/. | |
| 3 | # branch, which gitbay serves at https://krz.gitbay.site/orgstar/. | |
| 4 | 4 | # Usage: scripts/publish-docs.sh [--dry-run] |
| 5 | 5 | set -eu |
| 6 | 6 | |