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 {
7979
8080 /// A buffer for `bytes` read from `path`, with the keywords of the setup files it names.
8181 func loaded(_ bytes: [UInt8], path: String) -> DocumentState {
82 let settings = defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path) { file in
83 (try? self.fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) }
84 }
85 return DocumentState(bytes: bytes, defaults: settings)
82 DocumentState(bytes: bytes, defaults: defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path, read: readText))
83 }
84
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
8697 }
8798
8899 /// One open file.
Sources/OrgCore/Commands/Command.swift +2
@@ -194,6 +194,8 @@ public enum Effect: Sendable, Equatable {
194194 case sparseTree(show: [Int], highlights: [Range<Int>])
195195 /// Fold the subtree of the heading starting here (in the new text).
196196 case fold(Int)
197 /// Read the file's `#+SETUPFILE`s again (`org-mode-restart`).
198 case refreshSetup
197199}
198200
199201public struct EditResult: Sendable, Equatable {
Sources/OrgCore/Commands/CtrlCCtrlC.swift +1 −1
@@ -62,7 +62,7 @@ public struct CtrlCCtrlC: OrgCommand {
6262 }
6363 }
6464 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")]))
6666 }
6767 if buffer.propertyAtPoint() != nil || buffer.lookingAtLine("^[ \\t]*:(?:PROPERTIES|END):[ \\t]*$") && buffer.existingPropertyBlock() != nil {
6868 return PropertyAction().run(in: context)
Sources/OrgDocument/DocumentState.swift +8 −1
@@ -27,7 +27,7 @@ public struct DocumentState: Sendable {
2727 public private(set) var tree: OrgTree
2828 /// Increases on every change to `text`.
2929 public private(set) var revision = 0
30 public let defaults: OrgSettings
30 public private(set) var defaults: OrgSettings
3131 private var undoStack: [[TextEdit]] = []
3232 private var redoStack: [[TextEdit]] = []
3333
@@ -38,6 +38,13 @@ public struct DocumentState: Sendable {
3838 self.defaults = defaults
3939 }
4040
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
4148 public var mergeBase: [UInt8] { source.originalBytes }
4249 public var isDirty: Bool { text != source.text }
4350 public var isEditable: Bool { source.isEditable }
Sources/OrgEditorAppKit/OrgEditor.swift +4
@@ -1350,6 +1350,7 @@ extension OrgEditor: DocumentBuffer {
13501350 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
13511351 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
13521352 let before = document.text
1353 let defaultsBefore = document.defaults
13531354 let selected = textView.selectedRange()
13541355 let result = try change(&document)
13551356 if document.text != before {
@@ -1364,6 +1365,9 @@ extension OrgEditor: DocumentBuffer {
13641365 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
13651366 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
13661367 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()
13671371 }
13681372 onChange?()
13691373 return result
Sources/Orgstar/EditorView.swift +1
@@ -114,6 +114,7 @@ struct EditorView: NSViewRepresentable {
114114 case .copy(let text):
115115 NSPasteboard.general.clearContents()
116116 NSPasteboard.general.setString(text, forType: .string)
117 case .refreshSetup: session.refreshSetup()
117118 case .message, .sparseTree, .fold: break
118119 }
119120 }
Sources/OrgstarMobile/EditorScreen.swift +1
@@ -162,6 +162,7 @@ struct EditorScreen: View {
162162 editor.onEditBlock = { edit in sheet = .block(edit) }
163163 editor.onTangle = { scope in message = session.tangle(scope) }
164164 editor.onBabel = { job in run(job) }
165 editor.onRefreshSetup = { session.refreshSetup() }
165166 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
166167 editor.onAppCommand = { id in
167168 switch id {
Sources/OrgstarMobile/MobileEditor.swift +6
@@ -66,6 +66,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
6666 var onTangle: ((Tangle.Scope) -> Void)?
6767 /// A source block to run (C-c C-c).
6868 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)?
6971 /// A block, a table's formulas or a field to edit apart (C-c ', C-c `).
7072 var onEditBlock: ((BlockEdit) -> Void)?
7173 /// A followed timestamp's agenda days: the first (a `Days` number) and how many.
@@ -119,6 +121,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
119121
120122 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
121123 let before = document.text
124 let defaultsBefore = document.defaults
122125 let selected = textView.selectedRange
123126 let result = try change(&document)
124127 if document.text != before {
@@ -128,6 +131,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
128131 textView.selectedRange = NSRange(location: min(selected.location, length), length: 0)
129132 textView.undoManager?.removeAllActions()
130133 setFolds(fold)
134 } else if document.defaults != defaultsBefore {
135 restyle(0..<(document.text as NSString).length)
131136 }
132137 return result
133138 }
@@ -157,6 +162,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
157162 var state = fold
158163 state.folds.insert(start)
159164 setFolds(state)
165 case .refreshSetup: onRefreshSetup?()
160166 default: break
161167 }
162168 }
Tests/OrgAppTests/AppTests.swift +17
@@ -199,6 +199,23 @@ struct DocumentSessionTests {
199199 #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true)
200200 }
201201
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
202219 @Test func missingFileReportsAnError() {
203220 let session = DocumentSession()
204221 session.open("/nonexistent/a.org")