Commit 6ecbf692e0
Verified · cmc
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +99
| @@ -5,6 +5,36 @@ import OrgDocument | ||
| 5 | 5 | import OrgIndex |
| 6 | 6 | import OrgPresentation |
| 7 | 7 | |
| 8 | /// A heading, or a whole file, a subtree can be refiled to. | |
| 9 | public 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 | ||
| 8 | 38 | public enum SaveMode: String, Sendable, CaseIterable { |
| 9 | 39 | /// Save shortly after typing stops. |
| 10 | 40 | case automatic |
| @@ -161,6 +191,75 @@ public final class DocumentSession { | ||
| 161 | 191 | refresh() |
| 162 | 192 | } |
| 163 | 193 | |
| 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 | ||
| 164 | 263 | // MARK: - Locations |
| 165 | 264 | |
| 166 | 265 | /// 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 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | import OrgDocument |
| 4 | import OrgPresentation | |
| 4 | 5 | import OrgIndex |
| 5 | 6 | import OrgWorkspace |
| 6 | 7 | |
| @@ -208,6 +209,36 @@ public final class WorkspaceModel { | ||
| 208 | 209 | return [path: record] |
| 209 | 210 | } |
| 210 | 211 | |
| 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 | ||
| 211 | 242 | public func quickOpen(_ query: String) -> [FileEntry] { |
| 212 | 243 | FuzzyMatch.rank(query, files.filter { $0.kind != .conflict }) |
| 213 | 244 | } |
Sources/OrgDocument/Buffer.swift +16
| @@ -1,10 +1,15 @@ | ||
| 1 | 1 | import Foundation |
| 2 | import OrgCore | |
| 2 | 3 | |
| 3 | 4 | /// Where an open document's live state is kept: the editor while one shows it, a |
| 4 | 5 | /// `StateBuffer` before that. |
| 5 | 6 | @MainActor |
| 6 | 7 | public protocol DocumentBuffer: AnyObject { |
| 7 | 8 | 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 | |
| 8 | 13 | /// Applies a change made outside typing (save, disk change, revert) and updates whatever |
| 9 | 14 | /// shows the document. |
| 10 | 15 | func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T |
| @@ -13,11 +18,22 @@ public protocol DocumentBuffer: AnyObject { | ||
| 13 | 18 | @MainActor |
| 14 | 19 | public final class StateBuffer: DocumentBuffer { |
| 15 | 20 | public private(set) var document: DocumentState |
| 21 | public var selection: [Range<Int>] = [0..<0] | |
| 16 | 22 | |
| 17 | 23 | public init(_ document: DocumentState) { |
| 18 | 24 | self.document = document |
| 19 | 25 | } |
| 20 | 26 | |
| 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 | ||
| 21 | 37 | public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { |
| 22 | 38 | try change(&document) |
| 23 | 39 | } |
Sources/OrgEditorAppKit/EditorCommands.swift +2
| @@ -40,6 +40,8 @@ public enum EditorCommands { | ||
| 40 | 40 | EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), |
| 41 | 41 | EditorCommand(id: "app.search", title: "Search Notes", action: .app), |
| 42 | 42 | 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), | |
| 43 | 45 | |
| 44 | 46 | movement("forward-char", "Forward Character", "moveForward:"), |
| 45 | 47 | movement("backward-char", "Backward Character", "moveBackward:"), |
Sources/OrgEditorAppKit/OrgEditor.swift +5
| @@ -585,6 +585,11 @@ public final class OrgEditor: NSObject { | ||
| 585 | 585 | } |
| 586 | 586 | |
| 587 | 587 | extension OrgEditor: DocumentBuffer { |
| 588 | public var selection: [Range<Int>] { | |
| 589 | let selected = textView.selectedRange() | |
| 590 | return [selected.location..<NSMaxRange(selected)] | |
| 591 | } | |
| 592 | ||
| 588 | 593 | /// Runs `change`; if it changed the text, reloads the view with folds and the selection |
| 589 | 594 | /// mapped through the change. Text-view undo refers to the old text, so it is cleared. |
| 590 | 595 | public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { |
Sources/Orgstar/ContentView.swift +8
| @@ -140,6 +140,14 @@ struct ContentView: View { | ||
| 140 | 140 | case "app.reload-keymap": |
| 141 | 141 | loadKeymap() |
| 142 | 142 | 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) } | |
| 143 | 151 | default: |
| 144 | 152 | if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } |
| 145 | 153 | } |
Sources/Orgstar/Keys.swift +34 −1
| @@ -31,6 +31,23 @@ struct EchoArea: View { | ||
| 31 | 31 | .padding(10) |
| 32 | 32 | } |
| 33 | 33 | 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 | } | |
| 34 | 51 | Divider() |
| 35 | 52 | HStack(spacing: 6) { |
| 36 | 53 | Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) |
| @@ -38,7 +55,13 @@ struct EchoArea: View { | ||
| 38 | 55 | .textFieldStyle(.plain) |
| 39 | 56 | .font(.system(.callout, design: .monospaced)) |
| 40 | 57 | .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 | } | |
| 42 | 65 | .onExitCommand { commands.answer(nil) } |
| 43 | 66 | } |
| 44 | 67 | .padding(.horizontal, 10) |
| @@ -68,6 +91,16 @@ struct EchoArea: View { | ||
| 68 | 91 | } |
| 69 | 92 | } |
| 70 | 93 | |
| 94 | extension 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 | ||
| 71 | 104 | /// M-x and ⇧⌘P: every command by title, with its keys. |
| 72 | 105 | struct PaletteView: View { |
| 73 | 106 | let keymap: Keymap |
Tests/OrgAppTests/AppTests.swift +47
| @@ -226,3 +226,50 @@ struct KeymapLoaderTests { | ||
| 226 | 226 | #expect(KeymapLoader.load(.mac, userFile: broken).problems == ["line 1: unclosed string"]) |
| 227 | 227 | } |
| 228 | 228 | } |
| 229 | ||
| 230 | @MainActor | |
| 231 | struct 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 | |
| 269 | enum 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 | } | |