Settings in config.toml !46
10 files changed, +448 −4
Layout: unified · split
Sources/OrgApp/AgendaViews.swift +1 −1
| @@ -36,7 +36,7 @@ public struct AgendaViewDefinition: Hashable, Sendable, Identifiable { | ||
| 36 | 36 | /// (`WAIT|HOLD`). |
| 37 | 37 | @MainActor |
| 38 | 38 | public enum AgendaViewLoader { |
| 39 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("views.toml") } | |
| 39 | public static var userFile: URL { UserConfig.file("views.toml") } | |
| 40 | 40 | |
| 41 | 41 | public static let builtIn = [ |
| 42 | 42 | AgendaViewDefinition(name: "Agenda", mode: .agenda(span: nil, startOffset: nil)), |
Sources/OrgApp/CaptureModel.swift +1 −1
| @@ -17,7 +17,7 @@ import OrgDocument | ||
| 17 | 17 | /// ``` |
| 18 | 18 | @MainActor |
| 19 | 19 | public enum CaptureTemplates { |
| 20 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("capture.toml") } | |
| 20 | public static var userFile: URL { UserConfig.file("capture.toml") } | |
| 21 | 21 | |
| 22 | 22 | /// The templates in your Doom configuration. |
| 23 | 23 | public static let defaults = [ |
Sources/OrgApp/ConfigSync.swift added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | #if os(macOS) | |
| 2 | import Foundation | |
| 3 | import OrgCore | |
| 4 | import OrgWorkspace | |
| 5 | ||
| 6 | /// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch | |
| 7 | /// and whenever it changes on disk, and a change made in the app is written into the file. A | |
| 8 | /// missing file is created with every setting at its current value. | |
| 9 | @MainActor | |
| 10 | @Observable | |
| 11 | public final class ConfigSync { | |
| 12 | public static let shared = ConfigSync() | |
| 13 | ||
| 14 | /// Problems in the file, from the last time it was read. | |
| 15 | public private(set) var problems: [String] = [] | |
| 16 | /// The last file of the configuration folder that changed, with a serial number. | |
| 17 | public private(set) var changedFile: (name: String, serial: Int)? | |
| 18 | ||
| 19 | private let url: URL | |
| 20 | private let defaults: UserDefaults | |
| 21 | @ObservationIgnored private var text: String? | |
| 22 | @ObservationIgnored private var applying = false | |
| 23 | @ObservationIgnored private var watcher: FSEventsWatcher? | |
| 24 | @ObservationIgnored private var observer: NSObjectProtocol? | |
| 25 | @ObservationIgnored private var serial = 0 | |
| 26 | ||
| 27 | public init(url: URL = UserConfig.configFile, defaults: UserDefaults = .standard) { | |
| 28 | self.url = url | |
| 29 | self.defaults = defaults | |
| 30 | } | |
| 31 | ||
| 32 | public func start(watch: Bool = true) { | |
| 33 | guard observer == nil else { return } | |
| 34 | try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 35 | if FileManager.default.fileExists(atPath: url.path) { | |
| 36 | reload() | |
| 37 | } else { | |
| 38 | let template = UserConfig.template(from: defaults) | |
| 39 | try? template.write(to: url, atomically: true, encoding: .utf8) | |
| 40 | text = template | |
| 41 | } | |
| 42 | observer = NotificationCenter.default.addObserver(forName: UserDefaults.didChangeNotification, object: defaults, queue: .main) { [weak self] _ in | |
| 43 | MainActor.assumeIsolated { self?.writeBack() } | |
| 44 | } | |
| 45 | guard watch else { return } | |
| 46 | let directory = url.deletingLastPathComponent() | |
| 47 | watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in | |
| 48 | let names = Set(events.compactMap { event -> String? in | |
| 49 | if case .changed(let url) = event { return url.lastPathComponent } | |
| 50 | return nil | |
| 51 | }) | |
| 52 | Task { @MainActor [weak self] in | |
| 53 | guard let self else { return } | |
| 54 | for name in names.sorted() { | |
| 55 | if name == self.url.lastPathComponent { self.reload() } | |
| 56 | self.serial += 1 | |
| 57 | self.changedFile = (name, self.serial) | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | watcher?.start() | |
| 62 | } | |
| 63 | ||
| 64 | /// Reads the file and applies it when it changed since the last read or write. | |
| 65 | public func reload() { | |
| 66 | guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return } | |
| 67 | text = new | |
| 68 | applying = true | |
| 69 | problems = UserConfig.apply(new, to: defaults) | |
| 70 | applying = false | |
| 71 | } | |
| 72 | ||
| 73 | /// Writes settings that differ from the file into it. | |
| 74 | func writeBack() { | |
| 75 | guard !applying, var current = text ?? (try? String(contentsOf: url, encoding: .utf8)) else { return } | |
| 76 | let values = UserConfig.values(in: current) | |
| 77 | let original = current | |
| 78 | for setting in UserConfig.settings where defaults.object(forKey: setting.defaultsKey) != nil { | |
| 79 | let value = UserConfig.value(setting, in: defaults) | |
| 80 | if values[setting.section + "." + setting.key] != value { | |
| 81 | current = UserConfig.setting(current, section: setting.section, key: setting.key, to: value) | |
| 82 | } | |
| 83 | } | |
| 84 | guard current != original else { return } | |
| 85 | text = current | |
| 86 | try? current.write(to: url, atomically: true, encoding: .utf8) | |
| 87 | } | |
| 88 | } | |
| 89 | #endif | |
Sources/OrgApp/KeymapLoader.swift +1 −1
| @@ -4,7 +4,7 @@ import OrgCore | ||
| 4 | 4 | @MainActor |
| 5 | 5 | public enum KeymapLoader { |
| 6 | 6 | /// `<data dir>/keymap.toml`. |
| 7 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("keymap.toml") } | |
| 7 | public static var userFile: URL { UserConfig.file("keymap.toml") } | |
| 8 | 8 | |
| 9 | 9 | /// The preset with the user's file on top, and the problems found in the file. A missing |
| 10 | 10 | /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. |
Sources/OrgApp/UserConfig.swift added +242
| @@ -0,0 +1,242 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | /// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and | |
| 5 | /// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a | |
| 6 | /// dotfiles repository and share. The file is the source of truth: Settings writes its changes | |
| 7 | /// into it, keeping comments and order, and edits to it apply while the app runs. | |
| 8 | public enum UserConfig { | |
| 9 | public static var directory: URL { | |
| 10 | let environment = ProcessInfo.processInfo.environment | |
| 11 | if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) } | |
| 12 | // Development runs keep everything in their data folder. | |
| 13 | if let data = environment["ORGSTAR_DATA_DIR"] { return URL(fileURLWithPath: data) } | |
| 14 | if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty { | |
| 15 | return URL(fileURLWithPath: xdg).appendingPathComponent("orgstar") | |
| 16 | } | |
| 17 | return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/orgstar") | |
| 18 | } | |
| 19 | ||
| 20 | /// A file of the configuration folder; one that exists only where earlier versions kept | |
| 21 | /// it (Application Support) is still read from there. | |
| 22 | public static func file(_ name: String) -> URL { | |
| 23 | let url = directory.appendingPathComponent(name) | |
| 24 | let legacy = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 25 | .appendingPathComponent("Orgstar").appendingPathComponent(name) | |
| 26 | let manager = FileManager.default | |
| 27 | if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy } | |
| 28 | return url | |
| 29 | } | |
| 30 | ||
| 31 | public static var configFile: URL { directory.appendingPathComponent("config.toml") } | |
| 32 | ||
| 33 | /// One setting: where it sits in the file, its user-defaults key, and how values convert. | |
| 34 | public struct Setting: Sendable { | |
| 35 | public enum Kind: Sendable { | |
| 36 | case bool, integer, string | |
| 37 | /// A string in the file for each stored value. | |
| 38 | case choice([(name: String, stored: String)]) | |
| 39 | case integerChoice([(name: String, stored: Int)]) | |
| 40 | } | |
| 41 | ||
| 42 | public let section: String | |
| 43 | public let key: String | |
| 44 | public let defaultsKey: String | |
| 45 | public let kind: Kind | |
| 46 | public let fallback: TOML.Value | |
| 47 | public let comment: String | |
| 48 | } | |
| 49 | ||
| 50 | public static let settings: [Setting] = [ | |
| 51 | Setting(section: "general", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]), | |
| 52 | fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)"), | |
| 53 | Setting(section: "general", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]), | |
| 54 | fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml"), | |
| 55 | Setting(section: "general", key: "option-as-meta", defaultsKey: "optionAsMeta", | |
| 56 | kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"), comment: "left, right, both or none"), | |
| 57 | Setting(section: "editor", key: "tags-column", defaultsKey: "tagsColumn", kind: .integer, fallback: .integer(-77), | |
| 58 | comment: "org-tags-column: negative aligns tags to end there, 0 puts one space before them"), | |
| 59 | Setting(section: "editor", key: "insert-heading-respect-content", defaultsKey: "headingAfterSubtree", kind: .bool, fallback: .bool(true), | |
| 60 | comment: "M-RET adds the new heading after the subtree"), | |
| 61 | Setting(section: "editor", key: "meta-return-may-split-line", defaultsKey: "splitLine", kind: .bool, fallback: .bool(false), | |
| 62 | comment: "M-RET splits the line at the caret"), | |
| 63 | Setting(section: "editor", key: "list-allow-alphabetical", defaultsKey: "alphabeticalLists", kind: .bool, fallback: .bool(true), | |
| 64 | comment: "lists can use a. b. c."), | |
| 65 | Setting(section: "editor", key: "fill-column", defaultsKey: "fillColumn", kind: .integer, fallback: .integer(80), comment: "M-q fills to this column"), | |
| 66 | Setting(section: "editor", key: "hide-emphasis-markers", defaultsKey: "hiddenMarkers", kind: .bool, fallback: .bool(true), | |
| 67 | comment: "org-hide-emphasis-markers: hide *bold* and =code= markers when markup is hidden"), | |
| 68 | Setting(section: "editor", key: "pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true), | |
| 69 | comment: "org-pretty-entities: \\alpha shows as α and x_{1} is lowered when markup is hidden"), | |
| 70 | Setting(section: "editor", key: "todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"), | |
| 71 | comment: "for files without #+TODO, in #+TODO syntax; read at launch"), | |
| 72 | Setting(section: "editor", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false), | |
| 73 | comment: "show link brackets and emphasis markers (View ▸ Show Markup)"), | |
| 74 | Setting(section: "editor", key: "line-numbers", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true), | |
| 75 | comment: "View ▸ Show Line Numbers"), | |
| 76 | Setting(section: "agenda", key: "span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10), comment: "days shown"), | |
| 77 | Setting(section: "agenda", key: "start-offset", defaultsKey: "agendaStartOffset", kind: .integer, fallback: .integer(-3), | |
| 78 | comment: "first day, relative to today"), | |
| 79 | Setting(section: "agenda", key: "include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false), | |
| 80 | comment: "read org files in subfolders too"), | |
| 81 | Setting(section: "agenda", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true), | |
| 82 | comment: "notify before timed entries"), | |
| 83 | Setting(section: "agenda", key: "reminder-lead", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), | |
| 84 | comment: "minutes before; APPT_WARNTIME overrides"), | |
| 85 | Setting(section: "capture", key: "global-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true), | |
| 86 | comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml"), | |
| 87 | ] | |
| 88 | ||
| 89 | // MARK: - Values | |
| 90 | ||
| 91 | /// The file value for what `defaults` holds, or the fallback. | |
| 92 | public static func value(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value { | |
| 93 | guard defaults.object(forKey: setting.defaultsKey) != nil else { return setting.fallback } | |
| 94 | switch setting.kind { | |
| 95 | case .bool: return .bool(defaults.bool(forKey: setting.defaultsKey)) | |
| 96 | case .integer: return .integer(defaults.integer(forKey: setting.defaultsKey)) | |
| 97 | case .string: return .string(defaults.string(forKey: setting.defaultsKey) ?? "") | |
| 98 | case .choice(let choices): | |
| 99 | let stored = defaults.string(forKey: setting.defaultsKey) | |
| 100 | return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback | |
| 101 | case .integerChoice(let choices): | |
| 102 | let stored = defaults.integer(forKey: setting.defaultsKey) | |
| 103 | return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | /// Stores a file value; a value of the wrong kind is a problem. | |
| 108 | static func store(_ value: TOML.Value, _ setting: Setting, in defaults: UserDefaults) -> String? { | |
| 109 | let key = setting.defaultsKey | |
| 110 | switch (setting.kind, value) { | |
| 111 | case (.bool, .bool(let b)): defaults.set(b, forKey: key) | |
| 112 | case (.integer, .integer(let i)): defaults.set(i, forKey: key) | |
| 113 | case (.string, .string(let s)): defaults.set(s, forKey: key) | |
| 114 | case (.choice(let choices), .string(let s)): | |
| 115 | guard let choice = choices.first(where: { $0.name == s }) else { | |
| 116 | return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))" | |
| 117 | } | |
| 118 | defaults.set(choice.stored, forKey: key) | |
| 119 | case (.integerChoice(let choices), .string(let s)): | |
| 120 | guard let choice = choices.first(where: { $0.name == s }) else { | |
| 121 | return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))" | |
| 122 | } | |
| 123 | defaults.set(choice.stored, forKey: key) | |
| 124 | default: | |
| 125 | return "\(setting.section).\(setting.key) has the wrong kind of value" | |
| 126 | } | |
| 127 | return nil | |
| 128 | } | |
| 129 | ||
| 130 | /// Applies a file to `defaults`; settings it leaves out go back to their defaults. | |
| 131 | /// Returns problems. | |
| 132 | @discardableResult | |
| 133 | public static func apply(_ text: String, to defaults: UserDefaults) -> [String] { | |
| 134 | let tables: [TOML.Table] | |
| 135 | do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] } | |
| 136 | var problems: [String] = [] | |
| 137 | let present = Set(tables.flatMap { table in table.values.keys.map { table.name + "." + $0 } }) | |
| 138 | for setting in settings where !present.contains(setting.section + "." + setting.key) { | |
| 139 | if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) } | |
| 140 | } | |
| 141 | for table in tables { | |
| 142 | for (key, value) in table.values.sorted(by: { $0.key < $1.key }) { | |
| 143 | guard let setting = settings.first(where: { $0.section == table.name && $0.key == key }) else { | |
| 144 | problems.append("config.toml line \(table.line): unknown setting \(table.name.isEmpty ? "" : table.name + ".")\(key)") | |
| 145 | continue | |
| 146 | } | |
| 147 | if let current = currentValue(setting, in: defaults), current == value { continue } | |
| 148 | if let problem = store(value, setting, in: defaults) { problems.append("config.toml: \(problem)") } | |
| 149 | } | |
| 150 | } | |
| 151 | return problems | |
| 152 | } | |
| 153 | ||
| 154 | private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { | |
| 155 | defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) | |
| 156 | } | |
| 157 | ||
| 158 | /// The values a file sets, by `section.key`. | |
| 159 | static func values(in text: String) -> [String: TOML.Value] { | |
| 160 | guard let tables = try? TOML.parse(text) else { return [:] } | |
| 161 | var result: [String: TOML.Value] = [:] | |
| 162 | for table in tables { for (key, value) in table.values { result[table.name + "." + key] = value } } | |
| 163 | return result | |
| 164 | } | |
| 165 | ||
| 166 | // MARK: - Text | |
| 167 | ||
| 168 | static func format(_ value: TOML.Value) -> String { | |
| 169 | switch value { | |
| 170 | case .bool(let b): b ? "true" : "false" | |
| 171 | case .integer(let i): String(i) | |
| 172 | case .string(let s): "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | /// A commented file with every setting at its current value. | |
| 177 | public static func template(from defaults: UserDefaults) -> String { | |
| 178 | var out = """ | |
| 179 | # Orgstar settings. The Settings window writes its changes here, and edits made here | |
| 180 | # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml | |
| 181 | # and saved agenda views in views.toml, all in this folder. | |
| 182 | ||
| 183 | """ | |
| 184 | var section = "" | |
| 185 | for setting in settings { | |
| 186 | if setting.section != section { | |
| 187 | section = setting.section | |
| 188 | out += "\n[\(section)]\n" | |
| 189 | } | |
| 190 | out += "\(setting.key) = \(format(value(setting, in: defaults))) # \(setting.comment)\n" | |
| 191 | } | |
| 192 | return out | |
| 193 | } | |
| 194 | ||
| 195 | /// `text` with `section.key` set to `value`: the line's value is replaced (its comment | |
| 196 | /// stays), or a line is added at the end of the section, or the section at the end. | |
| 197 | public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String { | |
| 198 | var lines = text.components(separatedBy: "\n") | |
| 199 | var current = "" | |
| 200 | var lastInSection: Int? | |
| 201 | for (index, line) in lines.enumerated() { | |
| 202 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 203 | if trimmed.hasPrefix("[") && !trimmed.hasPrefix("[[") { | |
| 204 | current = String(trimmed.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) | |
| 205 | if current == section { lastInSection = index } | |
| 206 | continue | |
| 207 | } | |
| 208 | guard current == section else { continue } | |
| 209 | if !trimmed.isEmpty { lastInSection = index } | |
| 210 | guard let equals = line.firstIndex(of: "="), | |
| 211 | line[..<equals].trimmingCharacters(in: .whitespaces) == key else { continue } | |
| 212 | let comment = commentStart(line, after: equals).map { " " + line[$0...] } ?? "" | |
| 213 | lines[index] = line[..<equals] + "= " + format(value) + comment | |
| 214 | return lines.joined(separator: "\n") | |
| 215 | } | |
| 216 | if let lastInSection { | |
| 217 | lines.insert("\(key) = \(format(value))", at: lastInSection + 1) | |
| 218 | } else { | |
| 219 | if lines.last == "" { lines.removeLast() } | |
| 220 | lines += ["", "[\(section)]", "\(key) = \(format(value))", ""] | |
| 221 | } | |
| 222 | return lines.joined(separator: "\n") | |
| 223 | } | |
| 224 | ||
| 225 | /// Where a `#` comment starts after the value, outside quotes. | |
| 226 | private static func commentStart(_ line: String, after equals: String.Index) -> String.Index? { | |
| 227 | var quote: Character? | |
| 228 | var index = line.index(after: equals) | |
| 229 | while index < line.endIndex { | |
| 230 | let c = line[index] | |
| 231 | if let q = quote { | |
| 232 | if c == "\\" && q == "\"" { index = line.index(after: index) } else if c == q { quote = nil } | |
| 233 | } else if c == "\"" || c == "'" { | |
| 234 | quote = c | |
| 235 | } else if c == "#" { | |
| 236 | return index | |
| 237 | } | |
| 238 | if index < line.endIndex { index = line.index(after: index) } | |
| 239 | } | |
| 240 | return nil | |
| 241 | } | |
| 242 | } | |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -42,6 +42,7 @@ public enum EditorCommands { | ||
| 42 | 42 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), |
| 43 | 43 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
| 44 | 44 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
| 45 | EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), | |
| 45 | 46 | EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app), |
| 46 | 47 | EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), |
| 47 | 48 | EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), |
Sources/Orgstar/ContentView.swift +9
| @@ -197,6 +197,14 @@ struct ContentView: View { | ||
| 197 | 197 | .onChange(of: commands.runRequest?.serial) { |
| 198 | 198 | if let id = commands.runRequest?.id { perform(id) } |
| 199 | 199 | } |
| 200 | .onChange(of: ConfigSync.shared.problems, initial: true) { _, problems in | |
| 201 | if let first = problems.first { | |
| 202 | commands.show(first + (problems.count > 1 ? " (and \(problems.count - 1) more)" : "")) | |
| 203 | } | |
| 204 | } | |
| 205 | .onChange(of: ConfigSync.shared.changedFile?.serial) { | |
| 206 | if ConfigSync.shared.changedFile?.name == "keymap.toml" { loadKeymap() } | |
| 207 | } | |
| 200 | 208 | .onChange(of: commands.jumpRequest?.id) { |
| 201 | 209 | guard let request = commands.jumpRequest else { return } |
| 202 | 210 | if request.path != session.path { |
| @@ -386,6 +394,7 @@ struct ContentView: View { | ||
| 386 | 394 | case "app.toggle-outline": toggleOutline() |
| 387 | 395 | case "app.toggle-markup": showMarkup.toggle() |
| 388 | 396 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
| 397 | case "app.edit-config": commands.jump(path: UserConfig.configFile.path, offset: 0) | |
| 389 | 398 | case "app.quick-open": commands.showingQuickOpen = true |
| 390 | 399 | case "app.palette": commands.showingPalette = true |
| 391 | 400 | case "app.search": commands.focusSearch = true |
Sources/Orgstar/OrgstarApp.swift +27 −1
| @@ -245,8 +245,14 @@ struct OrgstarApp: App { | ||
| 245 | 245 | OrgMenuItems(commands: commands) |
| 246 | 246 | } |
| 247 | 247 | } |
| 248 | .commands { | |
| 249 | CommandGroup(after: .appSettings) { | |
| 250 | Button("Edit Config File") { commands.jump(path: UserConfig.configFile.path, offset: 0) } | |
| 251 | .keyboardShortcut(",", modifiers: [.command, .option]) | |
| 252 | } | |
| 253 | } | |
| 248 | 254 | Settings { |
| 249 | SettingsView() | |
| 255 | SettingsView(commands: commands) | |
| 250 | 256 | } |
| 251 | 257 | } |
| 252 | 258 | } |
| @@ -315,6 +321,8 @@ struct AgendaMenuItem: View { | ||
| 315 | 321 | } |
| 316 | 322 | |
| 317 | 323 | struct SettingsView: View { |
| 324 | let commands: AppCommands | |
| 325 | @Environment(\.openWindow) private var openWindow | |
| 318 | 326 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 319 | 327 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| 320 | 328 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue |
| @@ -344,8 +352,26 @@ struct SettingsView: View { | ||
| 344 | 352 | .frame(width: 600) |
| 345 | 353 | } |
| 346 | 354 | |
| 355 | private func editConfig() { | |
| 356 | ConfigSync.shared.start() | |
| 357 | openWindow(id: "main") | |
| 358 | commands.jump(path: UserConfig.configFile.path, offset: 0) | |
| 359 | } | |
| 360 | ||
| 347 | 361 | private var general: some View { |
| 348 | 362 | Form { |
| 363 | Section { | |
| 364 | LabeledContent("config.toml") { | |
| 365 | HStack { | |
| 366 | Button("Edit in Orgstar") { editConfig() } | |
| 367 | Button("Open with Default App") { NSWorkspace.shared.open(UserConfig.configFile) } | |
| 368 | Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([UserConfig.configFile]) } | |
| 369 | } | |
| 370 | } | |
| 371 | } footer: { | |
| 372 | Text("Every setting here is also in \(UserConfig.configFile.path), which you can edit, share or keep with your dotfiles. Changes in either place show up in the other.") | |
| 373 | .settingsFooter() | |
| 374 | } | |
| 349 | 375 | Section { |
| 350 | 376 | Picker("Save files", selection: $saveMode) { |
| 351 | 377 | Text("Automatically, when typing stops").tag(SaveMode.automatic) |
Sources/Orgstar/Preferences.swift +4
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import Foundation |
| 2 | import OrgApp | |
| 2 | 3 | import OrgCore |
| 3 | 4 | |
| 4 | 5 | /// Org preferences, stored in user defaults by the Settings window. |
| @@ -6,7 +7,10 @@ enum Preferences { | ||
| 6 | 7 | static let defaultTodoKeywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" |
| 7 | 8 | |
| 8 | 9 | /// TODO keywords for files without `#+TODO`, read at launch. |
| 10 | @MainActor | |
| 9 | 11 | static var orgSettings: OrgSettings { |
| 12 | // config.toml applies before anything reads settings. | |
| 13 | ConfigSync.shared.start() | |
| 10 | 14 | let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords |
| 11 | 15 | return OrgSettings.default.withTodoKeywords(line) ?? .default |
| 12 | 16 | } |
Tests/OrgAppTests/UserConfigTests.swift added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import Testing | |
| 4 | @testable import OrgApp | |
| 5 | ||
| 6 | @MainActor | |
| 7 | struct UserConfigTests { | |
| 8 | func defaults() -> UserDefaults { | |
| 9 | let name = "orgstar-config-\(UUID().uuidString)" | |
| 10 | let defaults = UserDefaults(suiteName: name)! | |
| 11 | defaults.removePersistentDomain(forName: name) | |
| 12 | return defaults | |
| 13 | } | |
| 14 | ||
| 15 | @Test func templateRoundTrips() { | |
| 16 | let source = defaults() | |
| 17 | source.set("doom", forKey: "keymap") | |
| 18 | source.set(3, forKey: "optionAsMeta") | |
| 19 | source.set(72, forKey: "fillColumn") | |
| 20 | source.set(false, forKey: "showLineNumbers") | |
| 21 | let text = UserConfig.template(from: source) | |
| 22 | #expect(text.contains("keymap = \"doom\"")) | |
| 23 | #expect(text.contains("option-as-meta = \"both\"")) | |
| 24 | let target = defaults() | |
| 25 | #expect(UserConfig.apply(text, to: target).isEmpty) | |
| 26 | #expect(target.string(forKey: "keymap") == "doom") | |
| 27 | #expect(target.integer(forKey: "optionAsMeta") == 3) | |
| 28 | #expect(target.integer(forKey: "fillColumn") == 72) | |
| 29 | #expect(target.bool(forKey: "showLineNumbers") == false) | |
| 30 | } | |
| 31 | ||
| 32 | @Test func problemsAndRemovedLines() { | |
| 33 | let target = defaults() | |
| 34 | target.set(60, forKey: "fillColumn") | |
| 35 | let problems = UserConfig.apply("[general]\nkeymap = \"vim\"\ncolour = 1\n[editor]\nfill-column = \"wide\"\n", to: target) | |
| 36 | #expect(problems.count == 3) | |
| 37 | #expect(target.integer(forKey: "fillColumn") == 60) | |
| 38 | #expect(UserConfig.apply("[editor]\n", to: target).isEmpty) | |
| 39 | #expect(target.object(forKey: "fillColumn") == nil) | |
| 40 | } | |
| 41 | ||
| 42 | @Test func editsKeepCommentsAndOrder() { | |
| 43 | let text = "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n" | |
| 44 | #expect(UserConfig.setting(text, section: "editor", key: "fill-column", to: .integer(72)) | |
| 45 | == "# mine\n[editor]\nfill-column = 72 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n") | |
| 46 | #expect(UserConfig.setting(text, section: "editor", key: "show-markup", to: .bool(true)) | |
| 47 | == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\nshow-markup = true\n\n[agenda]\nspan = 10\n") | |
| 48 | #expect(UserConfig.setting(text, section: "capture", key: "global-hotkey", to: .bool(false)) | |
| 49 | == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n\n[capture]\nglobal-hotkey = false\n") | |
| 50 | #expect(UserConfig.setting("[general]\nkeymap = \"emacs#1\" # c\n", section: "general", key: "keymap", to: .string("doom")) | |
| 51 | == "[general]\nkeymap = \"doom\" # c\n") | |
| 52 | } | |
| 53 | ||
| 54 | @Test func syncsBothWays() throws { | |
| 55 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") | |
| 56 | defer { try? FileManager.default.removeItem(at: folder) } | |
| 57 | let url = folder.appendingPathComponent("config.toml") | |
| 58 | let store = defaults() | |
| 59 | let sync = ConfigSync(url: url, defaults: store) | |
| 60 | sync.start(watch: false) | |
| 61 | #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 80")) | |
| 62 | ||
| 63 | store.set(66, forKey: "fillColumn") | |
| 64 | #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 66")) | |
| 65 | ||
| 66 | var text = try String(contentsOf: url, encoding: .utf8) | |
| 67 | text = text.replacingOccurrences(of: "span = 10", with: "span = 7") | |
| 68 | try text.write(to: url, atomically: true, encoding: .utf8) | |
| 69 | sync.reload() | |
| 70 | #expect(store.integer(forKey: "agendaSpan") == 7) | |
| 71 | #expect(sync.problems.isEmpty) | |
| 72 | } | |
| 73 | } | |