krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 7f4768ef8f

7f4768ef8f7de8b64da04e67a271c08d6645fef3

parent: d8259fc2c7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:35 UTC

Migrate old config names in place; reload theme files in subfolders

A config.toml with old setting names has them renamed and moved, keeping
comments and other content, instead of being replaced by a template.
The watcher compares the changed file's resolved path with theme-file's,
so a theme file such as themes/x.toml reloads when edited.

Layout: unified · split

Sources/OrgApp/ConfigSync.swift +22 −14
@@ -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
@@ -67,15 +64,16 @@ public final class ConfigSync {
67 guard watch else { return } 64 guard watch else { return }
68 let directory = url.deletingLastPathComponent() 65 let directory = url.deletingLastPathComponent()
69 watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in 66 watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in
70 let names = Set(events.compactMap { event -> String? in 67 let changed = Set(events.compactMap { event -> URL? in
71 if case .changed(let url) = event { return url.lastPathComponent } 68 if case .changed(let url) = event { return url }
72 return nil 69 return nil
73 }) 70 })
74 Task { @MainActor [weak self] in 71 Task { @MainActor [weak self] in
75 guard let self else { return } 72 guard let self else { return }
76 for name in names.sorted() { 73 for file in changed.sorted(by: { $0.path < $1.path }) {
74 let name = file.lastPathComponent
77 if name == self.url.lastPathComponent { self.reload() } 75 if name == self.url.lastPathComponent { self.reload() }
78 if name == self.defaults.string(forKey: "themeFile") { self.readTheme() } 76 if self.isThemeFile(file) { self.readTheme() }
79 self.serial += 1 77 self.serial += 1
80 self.changedFile = (name, self.serial) 78 self.changedFile = (name, self.serial)
81 } 79 }
@@ -94,13 +92,23 @@ public final class ConfigSync {
94 readTheme() 92 readTheme()
95 } 93 }
96 94
95 /// `theme-file`, relative to the configuration folder.
96 private var themeFile: URL? {
97 guard let name = defaults.string(forKey: "themeFile"), !name.isEmpty else { return nil }
98 return url.deletingLastPathComponent().appendingPathComponent(name)
99 }
100
101 /// Whether `file`, as the watcher reports it, is `theme-file`, which may be in a subfolder.
102 func isThemeFile(_ file: URL) -> Bool {
103 themeFile?.resolvingSymlinksInPath().path == file.resolvingSymlinksInPath().path
104 }
105
97 /// The theme from the file read last and its theme file. 106 /// The theme from the file read last and its theme file.
98 func readTheme() { 107 func readTheme() {
99 guard let text, let tables = try? TOML.parse(text) else { return } 108 guard let text, let tables = try? TOML.parse(text) else { return }
100 var spec = ThemeSpec.default 109 var spec = ThemeSpec.default
101 var themeProblems: [String] = [] 110 var themeProblems: [String] = []
102 if let name = defaults.string(forKey: "themeFile"), !name.isEmpty { 111 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) { 112 if let themeText = try? String(contentsOf: file, encoding: .utf8) {
105 do { 113 do {
106 (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name) 114 (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name)
Sources/OrgApp/UserConfig.swift +48
@@ -243,6 +243,54 @@ public enum UserConfig {
243 return values(in: text).keys.contains { names.contains($0) } 243 return values(in: text).keys.contains { names.contains($0) }
244 } 244 }
245 245
246 /// `text` with settings under their old names moved to their current names and places,
247 /// keeping comments and everything else. A section the move leaves empty is removed.
248 public static func migratingLegacyNames(_ text: String) -> String {
249 let values = values(in: text)
250 var blocks: [[String]] = [[]]
251 for line in text.components(separatedBy: "\n") {
252 if line.trimmingCharacters(in: .whitespaces).hasPrefix("[") { blocks.append([]) }
253 blocks[blocks.count - 1].append(line)
254 }
255 var moves: [(setting: Setting, value: TOML.Value, comment: String)] = []
256 var kept: [String] = []
257 for block in blocks {
258 let header = block.first.map { $0.trimmingCharacters(in: .whitespaces) }.flatMap { $0.hasPrefix("[") ? $0 : nil }
259 let section = header.map { String($0.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) } ?? ""
260 var remaining: [String] = []
261 for (index, line) in block.enumerated() {
262 guard header == nil || index > 0, let equals = line.firstIndex(of: "=") else {
263 remaining.append(line)
264 continue
265 }
266 let name = path(section, line[..<equals].trimmingCharacters(in: .whitespaces))
267 guard let setting = settings.first(where: { !$0.legacy.isEmpty && $0.legacy == name }), let value = values[name] else {
268 remaining.append(line)
269 continue
270 }
271 // A setting also under its new name keeps that one.
272 if values[setting.path] == nil {
273 moves.append((setting, value, commentStart(line, after: equals).map { String(line[$0...]) } ?? ""))
274 }
275 }
276 let emptied = header != nil && remaining.count < block.count
277 && remaining.dropFirst().allSatisfy { $0.trimmingCharacters(in: .whitespaces).isEmpty }
278 if !emptied { kept += remaining }
279 }
280 var out = kept.joined(separator: "\n")
281 if text.hasSuffix("\n"), !out.isEmpty, !out.hasSuffix("\n") { out += "\n" }
282 for move in moves {
283 out = setting(out, section: move.setting.section, key: move.setting.key, to: move.value)
284 guard !move.comment.isEmpty else { continue }
285 var lines = out.components(separatedBy: "\n")
286 if let index = lines.firstIndex(of: "\(move.setting.key) = \(format(move.value))") {
287 lines[index] += " " + move.comment
288 out = lines.joined(separator: "\n")
289 }
290 }
291 return out
292 }
293
246 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { 294 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
247 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) 295 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults)
248 } 296 }
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}