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 | /// 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 { | |||
| 652 | extension EmacsImport { | 652 | extension 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 @@ | |||
| 1 | import AppKit | 1 | import AppKit |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import Testing | 3 | import 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] |
| 5 | set -eu | 5 | set -eu |
| 6 | 6 | ||