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 {
1212 /// PATH with the usual install locations, which an app launched from the Dock lacks.
1313 public static var environment: [String: String] {
1414 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"]
1616 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
1717 environment["PATH"] = (path + extra.filter { !path.contains($0) }).joined(separator: ":")
1818 return environment
Sources/OrgApp/ConfigSync.swift +29 −16
@@ -43,14 +43,11 @@ public final class ConfigSync {
4343 }
4444 if FileManager.default.fileExists(atPath: url.path) {
4545 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)
5451 if completed != text {
5552 try? completed.write(to: url, atomically: true, encoding: .utf8)
5653 self.text = completed
@@ -65,17 +62,23 @@ public final class ConfigSync {
6562 MainActor.assumeIsolated { self?.writeBack() }
6663 }
6764 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 }
7272 return nil
7373 })
7474 Task { @MainActor [weak self] in
7575 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
7780 if name == self.url.lastPathComponent { self.reload() }
78 if name == self.defaults.string(forKey: "themeFile") { self.readTheme() }
81 if self.isThemeFile(file) { self.readTheme() }
7982 self.serial += 1
8083 self.changedFile = (name, self.serial)
8184 }
@@ -94,13 +97,23 @@ public final class ConfigSync {
9497 readTheme()
9598 }
9699
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
97111 /// The theme from the file read last and its theme file.
98112 func readTheme() {
99113 guard let text, let tables = try? TOML.parse(text) else { return }
100114 var spec = ThemeSpec.default
101115 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 {
104117 if let themeText = try? String(contentsOf: file, encoding: .utf8) {
105118 do {
106119 (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name)
Sources/OrgApp/EmacsImport.swift +11 −6
@@ -234,7 +234,7 @@ public struct EmacsImport: Sendable {
234234 return modes.isEmpty ? [nil] : modes
235235 }
236236
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.
238238 func keyString(_ sexp: Sexp) -> String? {
239239 if let s = sexp.string { return s }
240240 if sexp.head == "kbd", let s = sexp.elements?.dropFirst().first?.string { return s }
@@ -618,7 +618,7 @@ public struct EmacsImport: Sendable {
618618
619619 /// Emacs commands and the Orgstar commands that do the same.
620620 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",
622622 "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote",
623623 "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote",
624624 "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote",
@@ -629,8 +629,8 @@ public struct EmacsImport: Sendable {
629629 "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous",
630630 "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level",
631631 "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",
634634 "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active",
635635 "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive",
636636 "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 {
652652extension EmacsImport {
653653 /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace,
654654 /// 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.
656657 @MainActor
657658 public static func apply(
658659 _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory
@@ -695,9 +696,13 @@ extension EmacsImport {
695696 }
696697 let keymapFile = directory.appendingPathComponent("keymap.toml")
697698 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) } ?? [])
698702 var addedKeys = ""
699703 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 }
701706 addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n"
702707 if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" }
703708 bindings += 1
Sources/OrgApp/EmacsRunner.swift +8 −2
@@ -67,7 +67,7 @@ public enum EmacsRunner {
6767 process.terminate()
6868 throw Failure(description: "Emacs took too long to recalculate the table.")
6969 }
70 try await Task.sleep(for: .milliseconds(50))
70 try? await Task.sleep(for: .milliseconds(50))
7171 if Task.isCancelled {
7272 process.terminate()
7373 throw CancellationError()
@@ -113,6 +113,8 @@ public enum EmacsRunner {
113113 process.executableURL = emacs
114114 process.arguments = ["-Q", "--batch", "-l", script.path]
115115 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
116118 let errors = Pipe()
117119 process.standardError = errors
118120 process.standardOutput = FileHandle.nullDevice
@@ -123,7 +125,11 @@ public enum EmacsRunner {
123125 process.terminate()
124126 throw Failure(description: "Emacs took too long to export.")
125127 }
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 }
127133 }
128134 guard let written = try? String(contentsOf: output, encoding: .utf8) else {
129135 let log = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
Sources/OrgApp/UserConfig.swift +54 −2
@@ -23,12 +23,16 @@ public enum UserConfig {
2323 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar")
2424 }
2525
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
2631 /// A file of the configuration folder; one that exists only where earlier versions kept
2732 /// it (Application Support) is still read from there.
2833 public static func file(_ name: String) -> URL {
2934 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)
3236 let manager = FileManager.default
3337 if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy }
3438 return url
@@ -243,6 +247,54 @@ public enum UserConfig {
243247 return values(in: text).keys.contains { names.contains($0) }
244248 }
245249
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
246298 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
247299 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults)
248300 }
Sources/OrgCore/Commands/Command.swift +6 −1
@@ -151,7 +151,12 @@ public struct Prompt: Sendable, Equatable {
151151
152152 public struct DateRead: Sendable, Equatable {
153153 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 }
155160 }
156161
157162 public init(
Sources/OrgCore/Commands/TimestampCommands.swift +1 −1
@@ -113,7 +113,7 @@ public struct InsertTimestamp: OrgCommand {
113113 let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
114114 let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive"
115115 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)))
117117 }
118118 // Twice in a row on a timestamp: a range to a second one.
119119 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 {
11221122 keys = "M-s-<up>"
11231123 command = "org.heading.previous"
11241124 [[bind]]
1125 keys = "C-s-q"
1125 keys = "C-s-g"
11261126 command = "org.tags.set"
11271127 [[bind]]
11281128 keys = "s-RET"
@@ -1175,7 +1175,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
11751175 keys = "C-s-s"
11761176 command = "org.schedule"
11771177 [[bind]]
1178 keys = "C-s-d"
1178 keys = "C-s-e"
11791179 command = "org.deadline"
11801180 [[bind]]
11811181 keys = "C-s-p"
Sources/Orgstar/CaptureView.swift +21 −5
@@ -20,6 +20,8 @@ struct CaptureView: View {
2020 @State private var selection: TextSelection?
2121 @State private var context = CaptureContext()
2222 @State private var editing = false
23 /// The template keys typed so far, for multi-key templates such as "wb".
24 @State private var keys = ""
2325 @FocusState private var focused: Bool
2426 @Environment(\.dismissWindow) private var dismissWindow
2527
@@ -57,8 +59,8 @@ struct CaptureView: View {
5759 .keyboardShortcut(.defaultAction)
5860 }
5961 } 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
6264 Button {
6365 choose(template)
6466 } label: {
@@ -71,14 +73,26 @@ struct CaptureView: View {
7173 }
7274 .buttonStyle(.plain)
7375 }
74 if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
7576 }
77 if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
7678 }
7779 .padding(16)
7880 .frame(width: 520)
7981 .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 }
8296 return .handled
8397 }
8498 .onAppear {
@@ -119,6 +133,7 @@ struct CaptureView: View {
119133 problem = loaded.problems.first
120134 template = nil
121135 editing = false
136 keys = ""
122137 let clocked = clock.running
123138 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
124139 context = CaptureContext(
@@ -149,6 +164,7 @@ struct CaptureView: View {
149164
150165 private func choose(_ chosen: CaptureTemplate) {
151166 template = chosen
167 keys = ""
152168 // `%^g` offers the target file's tags.
153169 let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first)
154170 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 {
818818 let workspace: WorkspaceModel
819819 let open: (FileEntry) -> Void
820820 @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
821823 @Environment(\.dismiss) private var dismiss
822824
823825 var body: some View {
824826 let matches = workspace.quickOpen(query)
827 let current = min(selected, matches.count - 1)
825828 VStack(spacing: 0) {
826829 TextField("Open file", text: $query)
827830 .textFieldStyle(.plain)
828831 .font(.title3)
829832 .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 }
831843 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())
839856 }
857 .buttonStyle(.plain)
858 .listRowBackground(index == current ? Color.accentColor.opacity(0.25) : Color.clear)
859 .accessibilityAddTraits(index == current ? .isSelected : [])
840860 }
841 .buttonStyle(.plain)
861 .onChange(of: current) { if current >= 0 { proxy.scrollTo(current) } }
842862 }
843863 }
844864 .frame(width: 520, height: 360)
Sources/Orgstar/DatePickerView.swift +4 −3
@@ -22,7 +22,7 @@ struct DatePickerView: View {
2222 Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") }
2323 .accessibilityLabel("Next month")
2424 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))
2626 }
2727 .buttonStyle(.plain)
2828 Grid(horizontalSpacing: 4, verticalSpacing: 2) {
@@ -102,11 +102,12 @@ struct DatePickerView: View {
102102 return Self.answer(for: day, keeping: result, calendar)
103103 }
104104
105 static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar) -> String {
105 static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar, inactive: Bool) -> String {
106106 let formatter = DateFormatter()
107107 formatter.locale = Locale(identifier: "en_US_POSIX")
108108 formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
109109 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 + ">"
111112 }
112113}
Sources/Orgstar/OrgstarApp.swift +2 −2
@@ -351,10 +351,10 @@ struct OrgstarApp: App {
351351 }
352352 CommandGroup(after: .sidebar) {
353353 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
354 .keyboardShortcut("o", modifiers: [.command, .option])
354355 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
355356 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") }
356357 .keyboardShortcut("i", modifiers: [.command, .option])
357 .keyboardShortcut("o", modifiers: [.command, .option])
358358 MarkupToggle()
359359 }
360360 CommandGroup(after: .pasteboard) {
@@ -629,7 +629,7 @@ struct SettingsView: View {
629629 private var agenda: some View {
630630 Form {
631631 Section {
632 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
632 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...366)
633633 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
634634 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
635635 }
Sources/Orgstar/SidebarFiles.swift +3 −3
@@ -35,8 +35,8 @@ extension Sidebar {
3535 session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") }
3636 }
3737
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) }
4040
4141 func finish() {
4242 guard let operation = pending else { return }
@@ -90,7 +90,7 @@ extension View {
9090 .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) {
9191 Button("Move to Trash", role: .destructive) { sidebar.finish() }
9292 } 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) {
9494 Text("Unsaved changes to the open file will be lost.")
9595 } else {
9696 Text("You can put it back from the Trash in Finder.")
Tests/OrgAppTests/AppTests.swift +32
@@ -783,6 +783,38 @@ struct EmacsExportTests {
783783 #expect(text.uppercased().contains("IN THE BUFFER") && text.contains("Body."), "\(text)")
784784 #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n")
785785 }
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 }
786818}
787819
788820@MainActor
Tests/OrgAppTests/EmacsImportTests.swift +6
@@ -170,5 +170,11 @@ struct EmacsImportTests {
170170 #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)")
171171 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
172172 #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)")
173179 }
174180}
Tests/OrgAppTests/UserConfigTests.swift +28
@@ -77,6 +77,18 @@ struct UserConfigTests {
7777 #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]"))
7878 }
7979
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
8092 @Test func syncsBothWays() throws {
8193 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
8294 defer { try? FileManager.default.removeItem(at: folder) }
@@ -122,4 +134,20 @@ struct ThemeConfigTests {
122134 #expect(sync.theme.light["todo"]?.hex == "#000002")
123135 #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path))
124136 }
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 }
125153}
Tests/OrgCoreTests/KeymapTests.swift +11
@@ -115,6 +115,17 @@ struct KeymapTests {
115115 }
116116 }
117117
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
118129 @Test func shadowedBindingsShowNoKeys() throws {
119130 var problems: [String] = []
120131 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 {
2828 #expect(run(ShiftTimestamp(up: true), "<2026-09-10 Thu 10:07>\n", caret: 20).text == "<2026-09-10 Thu 10:10>\n")
2929 }
3030
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
3142 static let texts = [
3243 "* a\n",
3344 "* a\nbody <2026-09-01 Tue> text\n",
Tests/OrgEditorAppKitTests/KeyTests.swift +7
@@ -1,6 +1,7 @@
11import AppKit
22import OrgCore
33import Testing
4@testable import OrgApp
45@testable import OrgEditorAppKit
56
67@MainActor
@@ -133,6 +134,12 @@ struct KeyTests {
133134 }
134135 }
135136 }
137
138 @Test func everyEmacsImportCommandExists() {
139 for (emacs, command) in EmacsImport.commands {
140 #expect(EditorCommands.command(command) != nil, "\(emacs): \(command)")
141 }
142 }
136143}
137144
138145@MainActor
scripts/publish-docs.sh +1 −1
@@ -1,6 +1,6 @@
11#!/bin/sh
22# 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/.
44# Usage: scripts/publish-docs.sh [--dry-run]
55set -eu
66