Reread setup files on C-c C-c on a keyword line !137

merged merged by cmc on 2026-10-07 08:03 UTC · krz/orgstar:setup-refresh into main

9 files changed, +55 −6

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +15 −4
@@ -79,10 +79,21 @@ public final class DocumentSession {
79 79
80 /// A buffer for `bytes` read from `path`, with the keywords of the setup files it names. 80 /// A buffer for `bytes` read from `path`, with the keywords of the setup files it names.
81 func loaded(_ bytes: [UInt8], path: String) -> DocumentState { 81 func loaded(_ bytes: [UInt8], path: String) -> DocumentState {
82 let settings = defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path) { file in 82 DocumentState(bytes: bytes, defaults: defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path, read: readText))
83 (try? self.fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) } 83 }
84 } 84
85 return DocumentState(bytes: bytes, defaults: settings) 85 /// A file's text, for setup files.
86 func readText(_ file: String) -> String? {
87 (try? fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) }
88 }
89
90 /// C-c C-c on a keyword line: the open file's setup files read again (`org-mode-restart`).
91 public func refreshSetup() {
92 guard let entry = current else { return }
93 let settings = defaults.resolvingSetupFiles(in: entry.buffer.document.text, path: entry.path, read: readText)
94 entry.buffer.update { $0.setDefaults(settings) }
95 refresh()
96 editVersion += 1
86 } 97 }
87 98
88 /// One open file. 99 /// One open file.
Sources/OrgCore/Commands/Command.swift +2
@@ -194,6 +194,8 @@ public enum Effect: Sendable, Equatable {
194 case sparseTree(show: [Int], highlights: [Range<Int>]) 194 case sparseTree(show: [Int], highlights: [Range<Int>])
195 /// Fold the subtree of the heading starting here (in the new text). 195 /// Fold the subtree of the heading starting here (in the new text).
196 case fold(Int) 196 case fold(Int)
197 /// Read the file's `#+SETUPFILE`s again (`org-mode-restart`).
198 case refreshSetup
197} 199}
198 200
199public struct EditResult: Sendable, Equatable { 201public struct EditResult: Sendable, Equatable {
Sources/OrgCore/Commands/CtrlCCtrlC.swift +1 −1
@@ -62,7 +62,7 @@ public struct CtrlCCtrlC: OrgCommand {
62 } 62 }
63 } 63 }
64 if buffer.lookingAtLine("^[ \\t]*#\\+[A-Za-z_]+:") { 64 if buffer.lookingAtLine("^[ \\t]*#\\+[A-Za-z_]+:") {
65 return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message("Local setup has been refreshed")])) 65 return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.refreshSetup, .message("Local setup has been refreshed")]))
66 } 66 }
67 if buffer.propertyAtPoint() != nil || buffer.lookingAtLine("^[ \\t]*:(?:PROPERTIES|END):[ \\t]*$") && buffer.existingPropertyBlock() != nil { 67 if buffer.propertyAtPoint() != nil || buffer.lookingAtLine("^[ \\t]*:(?:PROPERTIES|END):[ \\t]*$") && buffer.existingPropertyBlock() != nil {
68 return PropertyAction().run(in: context) 68 return PropertyAction().run(in: context)
Sources/OrgDocument/DocumentState.swift +8 −1
@@ -27,7 +27,7 @@ public struct DocumentState: Sendable {
27 public private(set) var tree: OrgTree 27 public private(set) var tree: OrgTree
28 /// Increases on every change to `text`. 28 /// Increases on every change to `text`.
29 public private(set) var revision = 0 29 public private(set) var revision = 0
30 public let defaults: OrgSettings 30 public private(set) var defaults: OrgSettings
31 private var undoStack: [[TextEdit]] = [] 31 private var undoStack: [[TextEdit]] = []
32 private var redoStack: [[TextEdit]] = [] 32 private var redoStack: [[TextEdit]] = []
33 33
@@ -38,6 +38,13 @@ public struct DocumentState: Sendable {
38 self.defaults = defaults 38 self.defaults = defaults
39 } 39 }
40 40
41 /// New settings for the same text, as when setup files are read again; undo history stays.
42 public mutating func setDefaults(_ defaults: OrgSettings) {
43 self.defaults = defaults
44 tree = OrgParser.parse(text, defaults: defaults)
45 revision += 1
46 }
47
41 public var mergeBase: [UInt8] { source.originalBytes } 48 public var mergeBase: [UInt8] { source.originalBytes }
42 public var isDirty: Bool { text != source.text } 49 public var isDirty: Bool { text != source.text }
43 public var isEditable: Bool { source.isEditable } 50 public var isEditable: Bool { source.isEditable }
Sources/OrgEditorAppKit/OrgEditor.swift +4
@@ -1350,6 +1350,7 @@ extension OrgEditor: DocumentBuffer {
1350 /// mapped through the change. Text-view undo refers to the old text, so it is cleared. 1350 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
1351 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { 1351 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
1352 let before = document.text 1352 let before = document.text
1353 let defaultsBefore = document.defaults
1353 let selected = textView.selectedRange() 1354 let selected = textView.selectedRange()
1354 let result = try change(&document) 1355 let result = try change(&document)
1355 if document.text != before { 1356 if document.text != before {
@@ -1364,6 +1365,9 @@ extension OrgEditor: DocumentBuffer {
1364 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } 1365 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
1365 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!) 1366 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
1366 textView.undoManager?.removeAllActions(withTarget: textView) 1367 textView.undoManager?.removeAllActions(withTarget: textView)
1368 } else if document.defaults != defaultsBefore {
1369 // New settings for the same text: TODO keywords may color differently.
1370 restyleEverything()
1367 } 1371 }
1368 onChange?() 1372 onChange?()
1369 return result 1373 return result
Sources/Orgstar/EditorView.swift +1
@@ -114,6 +114,7 @@ struct EditorView: NSViewRepresentable {
114 case .copy(let text): 114 case .copy(let text):
115 NSPasteboard.general.clearContents() 115 NSPasteboard.general.clearContents()
116 NSPasteboard.general.setString(text, forType: .string) 116 NSPasteboard.general.setString(text, forType: .string)
117 case .refreshSetup: session.refreshSetup()
117 case .message, .sparseTree, .fold: break 118 case .message, .sparseTree, .fold: break
118 } 119 }
119 } 120 }
Sources/OrgstarMobile/EditorScreen.swift +1
@@ -162,6 +162,7 @@ struct EditorScreen: View {
162 editor.onEditBlock = { edit in sheet = .block(edit) } 162 editor.onEditBlock = { edit in sheet = .block(edit) }
163 editor.onTangle = { scope in message = session.tangle(scope) } 163 editor.onTangle = { scope in message = session.tangle(scope) }
164 editor.onBabel = { job in run(job) } 164 editor.onBabel = { job in run(job) }
165 editor.onRefreshSetup = { session.refreshSetup() }
165 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } 166 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
166 editor.onAppCommand = { id in 167 editor.onAppCommand = { id in
167 switch id { 168 switch id {
Sources/OrgstarMobile/MobileEditor.swift +6
@@ -66,6 +66,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
66 var onTangle: ((Tangle.Scope) -> Void)? 66 var onTangle: ((Tangle.Scope) -> Void)?
67 /// A source block to run (C-c C-c). 67 /// A source block to run (C-c C-c).
68 var onBabel: ((BabelJob) -> Void)? 68 var onBabel: ((BabelJob) -> Void)?
69 /// The file's setup files to read again (C-c C-c on a keyword line).
70 var onRefreshSetup: (() -> Void)?
69 /// A block, a table's formulas or a field to edit apart (C-c ', C-c `). 71 /// A block, a table's formulas or a field to edit apart (C-c ', C-c `).
70 var onEditBlock: ((BlockEdit) -> Void)? 72 var onEditBlock: ((BlockEdit) -> Void)?
71 /// A followed timestamp's agenda days: the first (a `Days` number) and how many. 73 /// A followed timestamp's agenda days: the first (a `Days` number) and how many.
@@ -119,6 +121,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
119 121
120 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { 122 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
121 let before = document.text 123 let before = document.text
124 let defaultsBefore = document.defaults
122 let selected = textView.selectedRange 125 let selected = textView.selectedRange
123 let result = try change(&document) 126 let result = try change(&document)
124 if document.text != before { 127 if document.text != before {
@@ -128,6 +131,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
128 textView.selectedRange = NSRange(location: min(selected.location, length), length: 0) 131 textView.selectedRange = NSRange(location: min(selected.location, length), length: 0)
129 textView.undoManager?.removeAllActions() 132 textView.undoManager?.removeAllActions()
130 setFolds(fold) 133 setFolds(fold)
134 } else if document.defaults != defaultsBefore {
135 restyle(0..<(document.text as NSString).length)
131 } 136 }
132 return result 137 return result
133 } 138 }
@@ -157,6 +162,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
157 var state = fold 162 var state = fold
158 state.folds.insert(start) 163 state.folds.insert(start)
159 setFolds(state) 164 setFolds(state)
165 case .refreshSetup: onRefreshSetup?()
160 default: break 166 default: break
161 } 167 }
162 } 168 }
Tests/OrgAppTests/AppTests.swift +17
@@ -199,6 +199,23 @@ struct DocumentSessionTests {
199 #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true) 199 #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true)
200 } 200 }
201 201
202 @Test func keywordCtrlCCtrlCRereadsSetupFiles() throws {
203 let notes = try Folder()
204 try notes.write("setup.org", "#+TODO: NEXT | DONE\n")
205 let file = try notes.write("a.org", "#+SETUPFILE: setup.org\n* WAIT One\n")
206 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
207 session.open(file.path)
208 #expect(session.document?.tree.settings.todoKeywordNames.contains("WAIT") == false)
209 try notes.write("setup.org", "#+TODO: NEXT WAIT | DONE\n")
210 let text = session.document!.text
211 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
212 guard case .commit(let result) = CtrlCCtrlC().run(in: context) else { Issue.record("no commit"); return }
213 #expect(result.effects.contains(.refreshSetup))
214 session.refreshSetup()
215 #expect(session.document?.tree.settings.todoKeywordNames.contains("WAIT") == true)
216 #expect(session.document?.isDirty == false)
217 }
218
202 @Test func missingFileReportsAnError() { 219 @Test func missingFileReportsAnError() {
203 let session = DocumentSession() 220 let session = DocumentSession()
204 session.open("/nonexistent/a.org") 221 session.open("/nonexistent/a.org")