Mac app fixes from the manual review !158

merged merged by cmc on 2026-10-08 03:45 UTC · krz/orgstar:mac-fixes into main

20 files changed, +267 −54

Layout: unified · split

Sources/OrgApp/BabelRunner.swift +1 −1
@@ -12,7 +12,7 @@ public enum BabelRunner {
12 /// PATH with the usual install locations, which an app launched from the Dock lacks. 12 /// PATH with the usual install locations, which an app launched from the Dock lacks.
13 public static var environment: [String: String] { 13 public static var environment: [String: String] {
14 var environment = ProcessInfo.processInfo.environment 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 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init) 16 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
17 environment["PATH"] = (path + extra.filter { !path.contains($0) }).joined(separator: ":") 17 environment["PATH"] = (path + extra.filter { !path.contains($0) }).joined(separator: ":")
18 return environment 18 return environment
Sources/OrgApp/ConfigSync.swift +29 −16
@@ -43,14 +43,11 @@ public final class ConfigSync {
43 } 43 }
44 if FileManager.default.fileExists(atPath: url.path) { 44 if FileManager.default.fileExists(atPath: url.path) {
45 reload() 45 reload()
46 // A file from before settings took Emacs's names is written again with them; one 46 // A file from before settings took Emacs's names gets them in place; one from
47 // from before a section existed gets the section. 47 // before a section existed gets the section.
48 if let text, UserConfig.usesLegacyNames(text) { 48 if let text {
49 let template = UserConfig.template(from: defaults) 49 let migrated = UserConfig.usesLegacyNames(text) ? UserConfig.migratingLegacyNames(text) : text
50 try? template.write(to: url, atomically: true, encoding: .utf8) 50 let completed = UserConfig.addingMissingSections(migrated, from: defaults)
51 self.text = template
52 } else if let text {
53 let completed = UserConfig.addingMissingSections(text, from: defaults)
54 if completed != text { 51 if completed != text {
55 try? completed.write(to: url, atomically: true, encoding: .utf8) 52 try? completed.write(to: url, atomically: true, encoding: .utf8)
56 self.text = completed 53 self.text = completed
@@ -65,17 +62,23 @@ public final class ConfigSync {
65 MainActor.assumeIsolated { self?.writeBack() } 62 MainActor.assumeIsolated { self?.writeBack() }
66 } 63 }
67 guard watch else { return } 64 guard watch else { return }
68 let directory = url.deletingLastPathComponent() 65 let directory = url.deletingLastPathComponent().resolvingSymlinksInPath()
69 watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in 66 // Files still read from where earlier versions kept them reload when edited there too.
70 let names = Set(events.compactMap { event -> String? in 67 let legacy = UserConfig.legacyDirectory.resolvingSymlinksInPath()
71 if case .changed(let url) = event { return url.lastPathComponent } 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 return nil 72 return nil
73 }) 73 })
74 Task { @MainActor [weak self] in 74 Task { @MainActor [weak self] in
75 guard let self else { return } 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 if name == self.url.lastPathComponent { self.reload() } 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 self.serial += 1 82 self.serial += 1
80 self.changedFile = (name, self.serial) 83 self.changedFile = (name, self.serial)
81 } 84 }
@@ -94,13 +97,23 @@ public final class ConfigSync {
94 readTheme() 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 /// The theme from the file read last and its theme file. 111 /// The theme from the file read last and its theme file.
98 func readTheme() { 112 func readTheme() {
99 guard let text, let tables = try? TOML.parse(text) else { return } 113 guard let text, let tables = try? TOML.parse(text) else { return }
100 var spec = ThemeSpec.default 114 var spec = ThemeSpec.default
101 var themeProblems: [String] = [] 115 var themeProblems: [String] = []
102 if let name = defaults.string(forKey: "themeFile"), !name.isEmpty { 116 if let name = defaults.string(forKey: "themeFile"), let file = themeFile {
103 let file = url.deletingLastPathComponent().appendingPathComponent(name)
104 if let themeText = try? String(contentsOf: file, encoding: .utf8) { 117 if let themeText = try? String(contentsOf: file, encoding: .utf8) {
105 do { 118 do {
106 (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name) 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 return modes.isEmpty ? [nil] : modes 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 func keyString(_ sexp: Sexp) -> String? { 238 func keyString(_ sexp: Sexp) -> String? {
239 if let s = sexp.string { return s } 239 if let s = sexp.string { return s }
240 if sexp.head == "kbd", let s = sexp.elements?.dropFirst().first?.string { return s } 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 /// Emacs commands and the Orgstar commands that do the same. 619 /// Emacs commands and the Orgstar commands that do the same.
620 static let commands: [String: String] = [ 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 "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote", 622 "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote",
623 "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote", 623 "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote",
624 "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote", 624 "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote",
@@ -629,8 +629,8 @@ public struct EmacsImport: Sendable {
629 "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous", 629 "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous",
630 "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level", 630 "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level",
631 "outline-up-heading": "org.heading.up", "org-up-element": "org.heading.up", 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", 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.dwim", "+org/dwim-at-point": "org.dwim", 633 "org-toggle-checkbox": "org.checkbox.toggle", "org-ctrl-c-ctrl-c": "org.ctrl-c-ctrl-c", "+org/dwim-at-point": "org.dwim",
634 "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active", 634 "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active",
635 "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive", 635 "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive",
636 "org-fill-paragraph": "org.fill-paragraph", "org-cycle": "org.cycle", "org-shifttab": "org.cycle-global", "org-global-cycle": "org.cycle-global", 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 {
652extension EmacsImport { 652extension EmacsImport {
653 /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace, 653 /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace,
654 /// capture templates and key bindings appended to capture.toml and keymap.toml in 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 @MainActor 657 @MainActor
657 public static func apply( 658 public static func apply(
658 _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory 659 _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory
@@ -695,9 +696,13 @@ extension EmacsImport {
695 } 696 }
696 let keymapFile = directory.appendingPathComponent("keymap.toml") 697 let keymapFile = directory.appendingPathComponent("keymap.toml")
697 var keymap = (try? String(contentsOf: keymapFile, encoding: .utf8)) ?? "" 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 var addedKeys = "" 702 var addedKeys = ""
699 for item in items { 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 addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n" 706 addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n"
702 if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" } 707 if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" }
703 bindings += 1 708 bindings += 1
Sources/OrgApp/EmacsRunner.swift +8 −2
@@ -67,7 +67,7 @@ public enum EmacsRunner {
67 process.terminate() 67 process.terminate()
68 throw Failure(description: "Emacs took too long to recalculate the table.") 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 if Task.isCancelled { 71 if Task.isCancelled {
72 process.terminate() 72 process.terminate()
73 throw CancellationError() 73 throw CancellationError()
@@ -113,6 +113,8 @@ public enum EmacsRunner {
113 process.executableURL = emacs 113 process.executableURL = emacs
114 process.arguments = ["-Q", "--batch", "-l", script.path] 114 process.arguments = ["-Q", "--batch", "-l", script.path]
115 process.currentDirectoryURL = URL(fileURLWithPath: path).deletingLastPathComponent() 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 let errors = Pipe() 118 let errors = Pipe()
117 process.standardError = errors 119 process.standardError = errors
118 process.standardOutput = FileHandle.nullDevice 120 process.standardOutput = FileHandle.nullDevice
@@ -123,7 +125,11 @@ public enum EmacsRunner {
123 process.terminate() 125 process.terminate()
124 throw Failure(description: "Emacs took too long to export.") 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 guard let written = try? String(contentsOf: output, encoding: .utf8) else { 134 guard let written = try? String(contentsOf: output, encoding: .utf8) else {
129 let log = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) 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 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar") 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 /// A file of the configuration folder; one that exists only where earlier versions kept 31 /// A file of the configuration folder; one that exists only where earlier versions kept
27 /// it (Application Support) is still read from there. 32 /// it (Application Support) is still read from there.
28 public static func file(_ name: String) -> URL { 33 public static func file(_ name: String) -> URL {
29 let url = directory.appendingPathComponent(name) 34 let url = directory.appendingPathComponent(name)
30 let legacy = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] 35 let legacy = legacyDirectory.appendingPathComponent(name)
31 .appendingPathComponent("Orgstar").appendingPathComponent(name)
32 let manager = FileManager.default 36 let manager = FileManager.default
33 if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy } 37 if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy }
34 return url 38 return url
@@ -243,6 +247,54 @@ public enum UserConfig {
243 return values(in: text).keys.contains { names.contains($0) } 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 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { 298 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
247 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) 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 public struct DateRead: Sendable, Equatable { 152 public struct DateRead: Sendable, Equatable {
153 public let defaultDate: Date? 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 public init( 162 public init(
Sources/OrgCore/Commands/TimestampCommands.swift +1 −1
@@ -113,7 +113,7 @@ public struct InsertTimestamp: OrgCommand {
113 let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } 113 let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
114 let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" 114 let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive"
115 return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), 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 // Twice in a row on a timestamp: a range to a second one. 118 // Twice in a row on a timestamp: a range to a second one.
119 let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" 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 keys = "M-s-<up>" 1122 keys = "M-s-<up>"
1123 command = "org.heading.previous" 1123 command = "org.heading.previous"
1124 [[bind]] 1124 [[bind]]
1125 keys = "C-s-q" 1125 keys = "C-s-g"
1126 command = "org.tags.set" 1126 command = "org.tags.set"
1127 [[bind]] 1127 [[bind]]
1128 keys = "s-RET" 1128 keys = "s-RET"
@@ -1175,7 +1175,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
1175 keys = "C-s-s" 1175 keys = "C-s-s"
1176 command = "org.schedule" 1176 command = "org.schedule"
1177 [[bind]] 1177 [[bind]]
1178 keys = "C-s-d" 1178 keys = "C-s-e"
1179 command = "org.deadline" 1179 command = "org.deadline"
1180 [[bind]] 1180 [[bind]]
1181 keys = "C-s-p" 1181 keys = "C-s-p"
Sources/Orgstar/CaptureView.swift +21 −5
@@ -20,6 +20,8 @@ struct CaptureView: View {
20 @State private var selection: TextSelection? 20 @State private var selection: TextSelection?
21 @State private var context = CaptureContext() 21 @State private var context = CaptureContext()
22 @State private var editing = false 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 @FocusState private var focused: Bool 25 @FocusState private var focused: Bool
24 @Environment(\.dismissWindow) private var dismissWindow 26 @Environment(\.dismissWindow) private var dismissWindow
25 27
@@ -57,8 +59,8 @@ struct CaptureView: View {
57 .keyboardShortcut(.defaultAction) 59 .keyboardShortcut(.defaultAction)
58 } 60 }
59 } else { 61 } else {
60 Text("Capture").font(.headline) 62 Text(keys.isEmpty ? "Capture" : "Capture: \(keys)").font(.headline)
61 ForEach(templates) { template in 63 ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in
62 Button { 64 Button {
63 choose(template) 65 choose(template)
64 } label: { 66 } label: {
@@ -71,14 +73,26 @@ struct CaptureView: View {
71 } 73 }
72 .buttonStyle(.plain) 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 .padding(16) 79 .padding(16)
78 .frame(width: 520) 80 .frame(width: 520)
79 .onKeyPress(phases: .down) { press in 81 .onKeyPress(phases: .down) { press in
80 guard template == nil, let match = templates.first(where: { $0.key == press.characters }) else { return .ignored } 82 guard template == nil else { return .ignored }
81 choose(match) 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 return .handled 96 return .handled
83 } 97 }
84 .onAppear { 98 .onAppear {
@@ -119,6 +133,7 @@ struct CaptureView: View {
119 problem = loaded.problems.first 133 problem = loaded.problems.first
120 template = nil 134 template = nil
121 editing = false 135 editing = false
136 keys = ""
122 let clocked = clock.running 137 let clocked = clock.running
123 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) } 138 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
124 context = CaptureContext( 139 context = CaptureContext(
@@ -149,6 +164,7 @@ struct CaptureView: View {
149 164
150 private func choose(_ chosen: CaptureTemplate) { 165 private func choose(_ chosen: CaptureTemplate) {
151 template = chosen 166 template = chosen
167 keys = ""
152 // `%^g` offers the target file's tags. 168 // `%^g` offers the target file's tags.
153 let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first) 169 let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first)
154 if !chosen.target.file.isEmpty, let text = session.openDocuments.first(where: { $0.path == path })?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) { 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 let workspace: WorkspaceModel 818 let workspace: WorkspaceModel
819 let open: (FileEntry) -> Void 819 let open: (FileEntry) -> Void
820 @State private var query = "" 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 @Environment(\.dismiss) private var dismiss 823 @Environment(\.dismiss) private var dismiss
822 824
823 var body: some View { 825 var body: some View {
824 let matches = workspace.quickOpen(query) 826 let matches = workspace.quickOpen(query)
827 let current = min(selected, matches.count - 1)
825 VStack(spacing: 0) { 828 VStack(spacing: 0) {
826 TextField("Open file", text: $query) 829 TextField("Open file", text: $query)
827 .textFieldStyle(.plain) 830 .textFieldStyle(.plain)
828 .font(.title3) 831 .font(.title3)
829 .padding(12) 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 Divider() 843 Divider()
832 List(matches) { entry in 844 ScrollViewReader { proxy in
833 Button { 845 List(matches.indices, id: \.self) { index in
834 open(entry) 846 let entry = matches[index]
835 } label: { 847 Button {
836 VStack(alignment: .leading, spacing: 2) { 848 open(entry)
837 Text(entry.name) 849 } label: {
838 Text(entry.relativePath).font(.caption).foregroundStyle(.secondary) 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 .frame(width: 520, height: 360) 864 .frame(width: 520, height: 360)
Sources/Orgstar/DatePickerView.swift +4 −3
@@ -22,7 +22,7 @@ struct DatePickerView: View {
22 Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") } 22 Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") }
23 .accessibilityLabel("Next month") 23 .accessibilityLabel("Next month")
24 Spacer() 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 .buttonStyle(.plain) 27 .buttonStyle(.plain)
28 Grid(horizontalSpacing: 4, verticalSpacing: 2) { 28 Grid(horizontalSpacing: 4, verticalSpacing: 2) {
@@ -102,11 +102,12 @@ struct DatePickerView: View {
102 return Self.answer(for: day, keeping: result, calendar) 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 let formatter = DateFormatter() 106 let formatter = DateFormatter()
107 formatter.locale = Locale(identifier: "en_US_POSIX") 107 formatter.locale = Locale(identifier: "en_US_POSIX")
108 formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE" 108 formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
109 let end = result.timeGiven ? result.endTime.map { "-" + $0 } ?? "" : "" 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 CommandGroup(after: .sidebar) { 352 CommandGroup(after: .sidebar) {
353 Button("Show or Hide Outline") { commands.run("app.toggle-outline") } 353 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
354 .keyboardShortcut("o", modifiers: [.command, .option])
354 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } 355 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
355 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") } 356 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") }
356 .keyboardShortcut("i", modifiers: [.command, .option]) 357 .keyboardShortcut("i", modifiers: [.command, .option])
357 .keyboardShortcut("o", modifiers: [.command, .option])
358 MarkupToggle() 358 MarkupToggle()
359 } 359 }
360 CommandGroup(after: .pasteboard) { 360 CommandGroup(after: .pasteboard) {
@@ -629,7 +629,7 @@ struct SettingsView: View {
629 private var agenda: some View { 629 private var agenda: some View {
630 Form { 630 Form {
631 Section { 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 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) 633 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
634 Toggle("Include files in subfolders", isOn: $agendaSubfolders) 634 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
635 } 635 }
Sources/Orgstar/SidebarFiles.swift +3 −3
@@ -35,8 +35,8 @@ extension Sidebar {
35 session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") } 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. 38 /// Whether `path` is an open file with unsaved edits or a folder holding one.
39 func holdsOpenFile(_ path: String) -> Bool { !openFiles(under: path).isEmpty } 39 func holdsUnsavedFile(_ path: String) -> Bool { openFiles(under: path).contains(where: session.dirtyPaths.contains) }
40 40
41 func finish() { 41 func finish() {
42 guard let operation = pending else { return } 42 guard let operation = pending else { return }
@@ -90,7 +90,7 @@ extension View {
90 .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) { 90 .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) {
91 Button("Move to Trash", role: .destructive) { sidebar.finish() } 91 Button("Move to Trash", role: .destructive) { sidebar.finish() }
92 } message: { 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 Text("Unsaved changes to the open file will be lost.") 94 Text("Unsaved changes to the open file will be lost.")
95 } else { 95 } else {
96 Text("You can put it back from the Trash in Finder.") 96 Text("You can put it back from the Trash in Finder.")
Tests/OrgAppTests/AppTests.swift +32
@@ -783,6 +783,38 @@ struct EmacsExportTests {
783 #expect(text.uppercased().contains("IN THE BUFFER") && text.contains("Body."), "\(text)") 783 #expect(text.uppercased().contains("IN THE BUFFER") && text.contains("Body."), "\(text)")
784 #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n") 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@MainActor 820@MainActor
Tests/OrgAppTests/EmacsImportTests.swift +6
@@ -170,5 +170,11 @@ struct EmacsImportTests {
170 #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)") 170 #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)")
171 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") 171 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
172 #expect(defaults.integer(forKey: "themeFontSize") == 14) 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 #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]")) 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 @Test func syncsBothWays() throws { 92 @Test func syncsBothWays() throws {
81 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") 93 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
82 defer { try? FileManager.default.removeItem(at: folder) } 94 defer { try? FileManager.default.removeItem(at: folder) }
@@ -122,4 +134,20 @@ struct ThemeConfigTests {
122 #expect(sync.theme.light["todo"]?.hex == "#000002") 134 #expect(sync.theme.light["todo"]?.hex == "#000002")
123 #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path)) 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 @Test func shadowedBindingsShowNoKeys() throws { 129 @Test func shadowedBindingsShowNoKeys() throws {
119 var problems: [String] = [] 130 var problems: [String] = []
120 let map = Keymap.layered([KeymapPreset.emacs.keymap, try Keymap(toml: "[[bind]]\nkeys = \"C-c C-t\"\ncommand = \"app.save\"\n", problems: &problems)]) 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 #expect(run(ShiftTimestamp(up: true), "<2026-09-10 Thu 10:07>\n", caret: 20).text == "<2026-09-10 Thu 10:10>\n") 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 static let texts = [ 42 static let texts = [
32 "* a\n", 43 "* a\n",
33 "* a\nbody <2026-09-01 Tue> text\n", 44 "* a\nbody <2026-09-01 Tue> text\n",
Tests/OrgEditorAppKitTests/KeyTests.swift +7
@@ -1,6 +1,7 @@
1import AppKit 1import AppKit
2import OrgCore 2import OrgCore
3import Testing 3import Testing
4@testable import OrgApp
4@testable import OrgEditorAppKit 5@testable import OrgEditorAppKit
5 6
6@MainActor 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@MainActor 145@MainActor
scripts/publish-docs.sh +1 −1
@@ -1,6 +1,6 @@
1#!/bin/sh 1#!/bin/sh
2# Builds the user manual in docs/manual with orgo and force-pushes it to the `pages` 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# Usage: scripts/publish-docs.sh [--dry-run] 4# Usage: scripts/publish-docs.sh [--dry-run]
5set -eu 5set -eu
6 6