krz/orgstar

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

Commit 6ecbf692e0

6ecbf692e07af991cfe740d095dce71f29a670ae

parent: 039021ab78

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 04:50 UTC

Refile and archive in the app

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +99
@@ -5,6 +5,36 @@ import OrgDocument
55import OrgIndex
66import OrgPresentation
77
8/// A heading, or a whole file, a subtree can be refiled to.
9public struct RefileTarget: Sendable, Hashable {
10 /// `file.org/Parent/Heading`, as the prompt lists it.
11 public let label: String
12 public let path: String
13 /// Start of the heading when the file had `contentHash`; nil refiles to the end of the file.
14 public let start: Int?
15 public let title: String
16 public let level: Int
17 public let contentHash: String
18
19 public init(label: String, path: String, start: Int?, title: String, level: Int, contentHash: String) {
20 self.label = label
21 self.path = path
22 self.start = start
23 self.title = title
24 self.level = level
25 self.contentHash = contentHash
26 }
27
28 /// The heading's start in `state`: its indexed offset if the file hasn't changed, else the
29 /// first heading with the same title and level. Nil for the end of the file.
30 func locate(in state: DocumentState) -> Int?? {
31 guard let start else { return .some(nil) }
32 if FileRecord.hash(state.mergeBase) == contentHash { return .some(start) }
33 let entry = Presentation.outline(state.tree).first { $0.title == title && $0.level == level }
34 return entry.map { .some($0.start) }
35 }
36}
37
838public enum SaveMode: String, Sendable, CaseIterable {
939 /// Save shortly after typing stops.
1040 case automatic
@@ -161,6 +191,75 @@ public final class DocumentSession {
161191 refresh()
162192 }
163193
194 // MARK: - Moving subtrees
195
196 /// The current context of the buffer, as commands see it.
197 private func editContext(now: Date) -> EditContext? {
198 guard let buffer else { return nil }
199 let document = buffer.document
200 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now)
201 }
202
203 /// Moves the subtree at the caret under `target`. For another file, that file is written
204 /// first, through the save path; the subtree leaves this buffer only once it has. Returns a
205 /// message for the echo area.
206 @discardableResult
207 public func refile(to target: RefileTarget, now: Date = Date()) -> String? {
208 guard let buffer, let path, let context = editContext(now: now) else { return nil }
209 if target.path == path {
210 let step = buffer.perform(RefileWithinFile(), now: now, answers: ["refile": target.start.map(String.init) ?? ""])
211 if case .failed(let message) = step { return message }
212 bufferChanged()
213 return "Refiled to \(target.label)"
214 }
215 let url = URL(fileURLWithPath: target.path)
216 do {
217 guard let bytes = try fileSystem.read(url) else { return "\((target.path as NSString).lastPathComponent) no longer exists." }
218 var state = DocumentState(bytes: bytes)
219 guard state.isEditable else { return "\((target.path as NSString).lastPathComponent) isn't UTF-8, so it can't be changed." }
220 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
221 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
222 try state.apply([result.target], baseRevision: state.revision)
223 if case .conflict = try saver.save(&state, to: url) { return "\((target.path as NSString).lastPathComponent) changed on disk; nothing was refiled." }
224 if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message }
225 bufferChanged()
226 return "Refiled to \(target.label)"
227 } catch {
228 return "Can't refile: \(error.localizedDescription)"
229 }
230 }
231
232 /// Archives the subtree at the caret where `org-archive-subtree` would. Returns a message.
233 @discardableResult
234 public func archive(now: Date = Date()) -> String? {
235 guard let buffer, let path, let context = editContext(now: now) else { return nil }
236 let destination: Archive.Destination
237 switch Archive.destination(context, sourcePath: path) {
238 case .success(let value): destination = value
239 case .failure(.message(let message)): return message
240 }
241 do {
242 let url = URL(fileURLWithPath: destination.path)
243 let existing = destination.isSameFile ? nil : try fileSystem.read(url)
244 var state = DocumentState(bytes: existing ?? [])
245 if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." }
246 let edits: Archive.Edits
247 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) {
248 case .success(let value): edits = value
249 case .failure(.message(let message)): return message
250 }
251 if let archiveEdit = edits.archive {
252 try state.apply([archiveEdit], baseRevision: state.revision)
253 if case .conflict = try saver.save(&state, to: url) { return "\(url.lastPathComponent) changed on disk; nothing was archived." }
254 }
255 if case .failed(let message) = buffer.perform(PrecomputedEdit(edits.source), now: now, answers: [:]) { return message }
256 bufferChanged()
257 return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)"
258 } catch {
259 return "Can't archive: \(error.localizedDescription)"
260 }
261 }
262
164263 // MARK: - Locations
165264
166265 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
Sources/OrgApp/WorkspaceModel.swift +31
@@ -1,6 +1,7 @@
11import Foundation
22import Observation
33import OrgDocument
4import OrgPresentation
45import OrgIndex
56import OrgWorkspace
67
@@ -208,6 +209,36 @@ public final class WorkspaceModel {
208209 return [path: record]
209210 }
210211
212 /// Headings down to level 3 in every org file, and each file itself. The open file's come
213 /// from its buffer, so they match unsaved edits.
214 public func refileTargets(openPath: String?, document: DocumentState?) -> [RefileTarget] {
215 let indexed = (try? index.outline(maxLevel: 3)) ?? []
216 var targets: [RefileTarget] = []
217 for file in files where file.kind == .org {
218 let name = file.relativePath
219 if file.path == openPath, let document {
220 let hash = FileRecord.hash(document.mergeBase)
221 targets.append(RefileTarget(label: name, path: file.path, start: nil, title: "", level: 0, contentHash: hash))
222 var stack: [String] = []
223 for entry in Presentation.outline(document.tree) {
224 stack = Array(stack.prefix(entry.level - 1)) + [entry.title]
225 guard entry.level <= 3 else { continue }
226 targets.append(RefileTarget(label: name + "/" + stack.joined(separator: "/"), path: file.path, start: entry.start, title: entry.title, level: entry.level, contentHash: hash))
227 }
228 continue
229 }
230 let headings = indexed.filter { $0.location.path == file.path }
231 targets.append(RefileTarget(label: name, path: file.path, start: nil, title: "", level: 0, contentHash: headings.first?.location.contentHash ?? ""))
232 for heading in headings {
233 targets.append(RefileTarget(
234 label: name + "/" + heading.outlinePath.joined(separator: "/"), path: file.path, start: heading.location.start,
235 title: heading.location.title, level: heading.level, contentHash: heading.location.contentHash
236 ))
237 }
238 }
239 return targets
240 }
241
211242 public func quickOpen(_ query: String) -> [FileEntry] {
212243 FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
213244 }
Sources/OrgDocument/Buffer.swift +16
@@ -1,10 +1,15 @@
11import Foundation
2import OrgCore
23
34/// Where an open document's live state is kept: the editor while one shows it, a
45/// `StateBuffer` before that.
56@MainActor
67public protocol DocumentBuffer: AnyObject {
78 var document: DocumentState { get }
9 /// Selected ranges; the first holds the caret.
10 var selection: [Range<Int>] { get }
11 /// Runs a command at the selection as one undoable change.
12 func perform(_ command: any OrgCommand, now: Date, answers: [String: String]) -> CommandStep
813 /// Applies a change made outside typing (save, disk change, revert) and updates whatever
914 /// shows the document.
1015 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T
@@ -13,11 +18,22 @@ public protocol DocumentBuffer: AnyObject {
1318@MainActor
1419public final class StateBuffer: DocumentBuffer {
1520 public private(set) var document: DocumentState
21 public var selection: [Range<Int>] = [0..<0]
1622
1723 public init(_ document: DocumentState) {
1824 self.document = document
1925 }
2026
27 public func perform(_ command: any OrgCommand, now: Date, answers: [String: String]) -> CommandStep {
28 do {
29 let step = try document.run(command, selection: selection, now: now, answers: answers)
30 if case .commit(let result) = step, let new = result.selection { selection = new }
31 return step
32 } catch {
33 return .failed("\(error)")
34 }
35 }
36
2137 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
2238 try change(&document)
2339 }
Sources/OrgEditorAppKit/EditorCommands.swift +2
@@ -40,6 +40,8 @@ public enum EditorCommands {
4040 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
4141 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
4242 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app),
43 EditorCommand(id: "app.refile", title: "Refile…", action: .app),
44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
4345
4446 movement("forward-char", "Forward Character", "moveForward:"),
4547 movement("backward-char", "Backward Character", "moveBackward:"),
Sources/OrgEditorAppKit/OrgEditor.swift +5
@@ -585,6 +585,11 @@ public final class OrgEditor: NSObject {
585585}
586586
587587extension OrgEditor: DocumentBuffer {
588 public var selection: [Range<Int>] {
589 let selected = textView.selectedRange()
590 return [selected.location..<NSMaxRange(selected)]
591 }
592
588593 /// Runs `change`; if it changed the text, reloads the view with folds and the selection
589594 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
590595 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
Sources/Orgstar/ContentView.swift +8
@@ -140,6 +140,14 @@ struct ContentView: View {
140140 case "app.reload-keymap":
141141 loadKeymap()
142142 commands.show("Keymap reloaded")
143 case "app.refile":
144 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
145 commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
146 guard let answer, let target = targets.first(where: { $0.label == answer }) else { return }
147 if let message = session.refile(to: target) { commands.show(message) }
148 }
149 case "app.archive":
150 if let message = session.archive() { commands.show(message) }
143151 default:
144152 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
145153 }
Sources/Orgstar/Keys.swift +34 −1
@@ -31,6 +31,23 @@ struct EchoArea: View {
3131 .padding(10)
3232 }
3333 if let request = commands.prompt {
34 if !request.prompt.choices.isEmpty {
35 Divider()
36 let matches = filteredChoices(request.prompt.choices)
37 ScrollView {
38 VStack(alignment: .leading, spacing: 2) {
39 ForEach(matches.prefix(12), id: \.self) { choice in
40 Button(choice) { commands.answer(choice) }
41 .buttonStyle(.plain)
42 .font(.system(.callout, design: .monospaced))
43 .foregroundStyle(choice == matches.first ? .primary : .secondary)
44 }
45 }
46 .frame(maxWidth: .infinity, alignment: .leading)
47 .padding(10)
48 }
49 .frame(maxHeight: 200)
50 }
3451 Divider()
3552 HStack(spacing: 6) {
3653 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
@@ -38,7 +55,13 @@ struct EchoArea: View {
3855 .textFieldStyle(.plain)
3956 .font(.system(.callout, design: .monospaced))
4057 .focused($answerFocused)
41 .onSubmit { commands.answer(answer) }
58 .onSubmit {
59 if request.prompt.choices.isEmpty {
60 commands.answer(answer)
61 } else if let first = filteredChoices(request.prompt.choices).first {
62 commands.answer(first)
63 }
64 }
4265 .onExitCommand { commands.answer(nil) }
4366 }
4467 .padding(.horizontal, 10)
@@ -68,6 +91,16 @@ struct EchoArea: View {
6891 }
6992}
7093
94extension EchoArea {
95 /// Choices matching what's typed, best first.
96 func filteredChoices(_ choices: [String]) -> [String] {
97 guard !answer.isEmpty else { return choices }
98 return choices.compactMap { choice in FuzzyMatch.score(answer, choice).map { (choice, $0) } }
99 .sorted { $0.1 > $1.1 }
100 .map(\.0)
101 }
102}
103
71104/// M-x and ⇧⌘P: every command by title, with its keys.
72105struct PaletteView: View {
73106 let keymap: Keymap
Tests/OrgAppTests/AppTests.swift +47
@@ -226,3 +226,50 @@ struct KeymapLoaderTests {
226226 #expect(KeymapLoader.load(.mac, userFile: broken).problems == ["line 1: unclosed string"])
227227 }
228228}
229
230@MainActor
231struct MoveSubtreeTests {
232 @Test func refileWithinAndAcrossFiles() async throws {
233 let notes = try Folder()
234 let file = try notes.write("a.org", "* one\nbody\n* two\n")
235 let other = try notes.write("b.org", "* inbox\n")
236 let workspace = try WorkspaceModel(directory: nil)
237 try await workspace.addRoot(notes.url)
238 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
239 session.saveMode = .explicit
240 session.open(file.path)
241 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
242 #expect(targets.map(\.label) == ["a.org", "a.org/one", "a.org/two", "b.org", "b.org/inbox"])
243
244 #expect(session.refile(to: targets[2]) == "Refiled to a.org/two")
245 #expect(session.document?.text == "* two\n** one\nbody\n")
246
247 let inbox = try #require(workspace.refileTargets(openPath: session.path, document: session.document).first { $0.label == "b.org/inbox" })
248 StateBufferCaret.set(session, 6)
249 #expect(session.refile(to: inbox) == "Refiled to b.org/inbox")
250 #expect(try String(contentsOf: other, encoding: .utf8) == "* inbox\n** one\nbody\n")
251 #expect(session.document?.text == "* two\n")
252 }
253
254 @Test func archiveToTheArchiveFile() throws {
255 let notes = try Folder()
256 let file = try notes.write("a.org", "* done\n* keep\n")
257 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
258 session.saveMode = .explicit
259 session.open(file.path)
260 #expect(session.archive(now: Date(timeIntervalSince1970: 0)) == "Archived to a.org_archive")
261 #expect(session.document?.text == "* keep\n")
262 let archived = try String(contentsOf: notes.url.appendingPathComponent("a.org_archive"), encoding: .utf8)
263 #expect(archived.contains("* done\n:PROPERTIES:\n:ARCHIVE_TIME:"))
264 }
265}
266
267/// Moves the caret of a session's `StateBuffer`, as clicking in the editor would.
268@MainActor
269enum StateBufferCaret {
270 static func set(_ session: DocumentSession, _ offset: Int) {
271 let buffer = StateBuffer(session.document!)
272 buffer.selection = [offset..<offset]
273 session.attach(buffer)
274 }
275}