Settings in config.toml !46

merged merged by cmc on 2026-10-05 19:10 UTC · krz/orgstar:config-file into main

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/// (`WAIT|HOLD`). 36/// (`WAIT|HOLD`).
37@MainActor 37@MainActor
38public enum AgendaViewLoader { 38public 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 public static let builtIn = [ 41 public static let builtIn = [
42 AgendaViewDefinition(name: "Agenda", mode: .agenda(span: nil, startOffset: nil)), 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@MainActor 18@MainActor
19public enum CaptureTemplates { 19public 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 /// The templates in your Doom configuration. 22 /// The templates in your Doom configuration.
23 public static let defaults = [ 23 public static let defaults = [
Sources/OrgApp/ConfigSync.swift added +89
@@ -0,0 +1,89 @@
1#if os(macOS)
2import Foundation
3import OrgCore
4import 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
11public 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@MainActor 4@MainActor
5public enum KeymapLoader { 5public enum KeymapLoader {
6 /// `<data dir>/keymap.toml`. 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 /// The preset with the user's file on top, and the problems found in the file. A missing 9 /// The preset with the user's file on top, and the problems found in the file. A missing
10 /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. 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 @@
1import Foundation
2import 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.
8public 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 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), 42 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
44 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), 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 EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app), 46 EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app),
46 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), 47 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
47 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), 48 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
Sources/Orgstar/ContentView.swift +9
@@ -197,6 +197,14 @@ struct ContentView: View {
197 .onChange(of: commands.runRequest?.serial) { 197 .onChange(of: commands.runRequest?.serial) {
198 if let id = commands.runRequest?.id { perform(id) } 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 .onChange(of: commands.jumpRequest?.id) { 208 .onChange(of: commands.jumpRequest?.id) {
201 guard let request = commands.jumpRequest else { return } 209 guard let request = commands.jumpRequest else { return }
202 if request.path != session.path { 210 if request.path != session.path {
@@ -386,6 +394,7 @@ struct ContentView: View {
386 case "app.toggle-outline": toggleOutline() 394 case "app.toggle-outline": toggleOutline()
387 case "app.toggle-markup": showMarkup.toggle() 395 case "app.toggle-markup": showMarkup.toggle()
388 case "app.toggle-line-numbers": showLineNumbers.toggle() 396 case "app.toggle-line-numbers": showLineNumbers.toggle()
397 case "app.edit-config": commands.jump(path: UserConfig.configFile.path, offset: 0)
389 case "app.quick-open": commands.showingQuickOpen = true 398 case "app.quick-open": commands.showingQuickOpen = true
390 case "app.palette": commands.showingPalette = true 399 case "app.palette": commands.showingPalette = true
391 case "app.search": commands.focusSearch = true 400 case "app.search": commands.focusSearch = true
Sources/Orgstar/OrgstarApp.swift +27 −1
@@ -245,8 +245,14 @@ struct OrgstarApp: App {
245 OrgMenuItems(commands: commands) 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 Settings { 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
317struct SettingsView: View { 323struct SettingsView: View {
324 let commands: AppCommands
325 @Environment(\.openWindow) private var openWindow
318 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 326 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
319 @AppStorage("keymap") private var preset = KeymapPreset.emacs 327 @AppStorage("keymap") private var preset = KeymapPreset.emacs
320 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue 328 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
@@ -344,8 +352,26 @@ struct SettingsView: View {
344 .frame(width: 600) 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 private var general: some View { 361 private var general: some View {
348 Form { 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 Section { 375 Section {
350 Picker("Save files", selection: $saveMode) { 376 Picker("Save files", selection: $saveMode) {
351 Text("Automatically, when typing stops").tag(SaveMode.automatic) 377 Text("Automatically, when typing stops").tag(SaveMode.automatic)
Sources/Orgstar/Preferences.swift +4
@@ -1,4 +1,5 @@
1import Foundation 1import Foundation
2import OrgApp
2import OrgCore 3import OrgCore
3 4
4/// Org preferences, stored in user defaults by the Settings window. 5/// Org preferences, stored in user defaults by the Settings window.
@@ -6,7 +7,10 @@ enum Preferences {
6 static let defaultTodoKeywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" 7 static let defaultTodoKeywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"
7 8
8 /// TODO keywords for files without `#+TODO`, read at launch. 9 /// TODO keywords for files without `#+TODO`, read at launch.
10 @MainActor
9 static var orgSettings: OrgSettings { 11 static var orgSettings: OrgSettings {
12 // config.toml applies before anything reads settings.
13 ConfigSync.shared.start()
10 let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords 14 let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords
11 return OrgSettings.default.withTodoKeywords(line) ?? .default 15 return OrgSettings.default.withTodoKeywords(line) ?? .default
12 } 16 }
Tests/OrgAppTests/UserConfigTests.swift added +73
@@ -0,0 +1,73 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgApp
5
6@MainActor
7struct 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}