krz/orgstar

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

Commit eeaa3c1d16

eeaa3c1d16ff966a5f725b3c40df0aada2f336ce

parent: 2cd54205f3

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 01:03 UTC

Insert and store links, create IDs

- org.link.insert (C-c C-l, SPC m l l): org-insert-link with stored links,
  link types, editing the link at point, region as description, file paths
  adapted to the buffer's file.
- org.link.store (C-c l, SPC m l s, SPC n l): org-store-link in org files
  (targets, #+NAME, CUSTOM_ID, heading, region, line).
- org.id.store-link (SPC m l i): org-id-get-create, then an id: link.
- org.id.create: org-id-get-create.
- Prompts can offer choices without requiring one; Tab completes.
- ORGSTAR_COMMAND takes a comma-separated list.

Ref #1 (Tier 1, links). Oracle tests against Org 9.8.7.

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +7 −3
@@ -85,14 +85,16 @@ public struct Prompt: Sendable, Equatable {
8585 public let message: String
8686 /// Text the answer field starts with.
8787 public let initial: String
88 /// When set, the answer is one of these.
88 /// When set, the answer is one of these, or with `requireMatch` false, these are offered.
8989 public let choices: [String]
90 public let requireMatch: Bool
9091
91 public init(key: String, message: String, initial: String = "", choices: [String] = []) {
92 public init(key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true) {
9293 self.key = key
9394 self.message = message
9495 self.initial = initial
9596 self.choices = choices
97 self.requireMatch = requireMatch
9698 }
9799}
98100
@@ -112,6 +114,8 @@ public struct PrecomputedEdit: OrgCommand {
112114/// Things a command asks for besides text changes; the platform layer carries them out.
113115public enum Effect: Sendable, Equatable {
114116 case message(String)
117 /// An inserted link was a stored one; it leaves `org-stored-links`.
118 case storedLinkUsed(String)
115119}
116120
117121public struct EditResult: Sendable, Equatable {
@@ -177,7 +181,7 @@ public enum Commands {
177181 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
178182 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
179183 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
180 OpenLinkAtPoint(),
184 OpenLinkAtPoint(), CreateID(),
181185 ]
182186
183187 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Keymap/Presets.swift +22
@@ -190,6 +190,22 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
190190
191191 # Localleader (SPC m)
192192 [[bind]]
193 keys = "SPC m l l"
194 command = "org.link.insert"
195 mode = "normal"
196 [[bind]]
197 keys = "SPC m l s"
198 command = "org.link.store"
199 mode = "normal"
200 [[bind]]
201 keys = "SPC m l i"
202 command = "org.id.store-link"
203 mode = "normal"
204 [[bind]]
205 keys = "SPC n l"
206 command = "org.link.store"
207 mode = "normal"
208 [[bind]]
193209 keys = "SPC m t"
194210 command = "org.todo.cycle"
195211 mode = "normal"
@@ -554,6 +570,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
554570 keys = "C-c C-o"
555571 command = "org.link.open"
556572 [[bind]]
573 keys = "C-c C-l"
574 command = "org.link.insert"
575 [[bind]]
576 keys = "C-c l"
577 command = "org.link.store"
578 [[bind]]
557579 keys = "C-x b"
558580 command = "app.buffer.switch"
559581 [[bind]]
Sources/OrgCore/Links/LinkStore.swift added +319
@@ -0,0 +1,319 @@
1import Foundation
2
3/// An entry of `org-stored-links`.
4public struct StoredLink: Sendable, Equatable, Codable {
5 public let link: String
6 public let description: String?
7
8 public init(link: String, description: String?) {
9 self.link = link
10 self.description = description
11 }
12}
13
14extension Links {
15 /// `org-store-link` in an org file, with `org-link-context-for-files` t: a file link to
16 /// `path` with a search string for the target, named element, heading or line at the
17 /// caret (the selection when there is one). `path` is as `abbreviate-file-name` gives it.
18 public static func store(in context: EditContext, path: String) -> StoredLink {
19 let link = "file:" + path
20 guard let target = targetAtCaret(context) else {
21 guard let (search, description, _) = preciseTarget(context) else { return StoredLink(link: link, description: nil) }
22 return StoredLink(link: link + "::" + search, description: description.map(displayFormat))
23 }
24 return StoredLink(link: link + "::" + target, description: nil)
25 }
26
27 /// `org-id-store-link` after `org-id-get-create`: an `id:` link to the entry at the caret,
28 /// described by its title, or before the first heading by `#+TITLE` or the file name.
29 public static func storeID(in context: EditContext, fileName: String) -> StoredLink? {
30 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
31 buffer.backToHeadingOrPointMin()
32 guard let id = buffer.entryID() else { return nil }
33 let link = "id:" + id
34 // With `org-id-link-use-context`, a target after the entry's start narrows the link.
35 if let (search, description, position) = preciseTarget(context), position > buffer.point {
36 return StoredLink(link: link + "::" + search, description: description)
37 }
38 if buffer.beforeFirstHeading { return StoredLink(link: link, description: buffer.keyword("TITLE") ?? fileName) }
39 guard let match = buffer.lookingAt(buffer.complexHeadingPattern), let all = match[0] else { return StoredLink(link: link, description: link) }
40 let title = match[4] ?? all
41 return StoredLink(link: link, description: buffer.substring(title.lowerBound, title.upperBound))
42 }
43
44 /// A `<<target>>` on the caret's line that the caret touches, as `org-in-regexp` finds it.
45 static func targetAtCaret(_ context: EditContext) -> String? {
46 let ns = context.text as NSString
47 let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))
48 var end = NSMaxRange(line)
49 if end > line.location, ns.character(at: end - 1) == 10 { end -= 1 }
50 let regex = try! NSRegularExpression(pattern: "[^<]<<([^<>]+)>>[^>]")
51 var from = line.location
52 while from < end, let m = regex.firstMatch(in: context.text, range: NSRange(from..<end)), m.range.location <= context.caret {
53 if NSMaxRange(m.range) >= context.caret { return ns.substring(with: m.range(at: 1)) }
54 from = m.range.location + 1
55 }
56 return nil
57 }
58
59 /// `org-link-precise-link-target`: the search string and description for the caret, and
60 /// where the search matches.
61 static func preciseTarget(_ context: EditContext) -> (String, String?, Int)? {
62 let ns = context.text as NSString
63 let result: (String, String?, Int)
64 if let range = context.selection.first, !range.isEmpty {
65 result = (normalize(ns.substring(with: NSRange(range)), context: true), nil, range.lowerBound)
66 } else if let (name, start) = namedElement(at: context.caret, in: context.tree) {
67 result = (name, name, start)
68 } else if let heading = DocumentModel(tree: context.tree).headings.last(where: { $0.headingRange.lowerBound <= context.caret }) {
69 let title = normalizedTitle(heading.title)
70 let customID = heading.properties.last { $0.key.uppercased() == "CUSTOM_ID" }?.value
71 result = (customID.map { "#" + $0 } ?? "*" + title, title, heading.headingRange.lowerBound)
72 } else {
73 let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))
74 result = (normalize(ns.substring(with: line).trimmingCharacters(in: .newlines), context: true), nil, line.location)
75 }
76 return result.0.contains(where: { !$0.isWhitespace }) ? result : nil
77 }
78
79 /// The `#+NAME` of the element at `offset`, and where the element starts (its first
80 /// affiliated keyword).
81 static func namedElement(at offset: Int, in tree: OrgTree) -> (String, Int)? {
82 let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer, .footnoteDefinition]
83 var node = tree.root
84 while true {
85 let children = node.children
86 guard let index = children.firstIndex(where: { $0.range.contains(offset) || ($0.range.upperBound == offset && offset == tree.root.range.upperBound) }) else { return nil }
87 var element = index
88 while element < children.count, children[element].kind == .affiliatedKeyword { element += 1 }
89 if element > index, element == children.count || children[element - 1].range.upperBound != children[element].range.lowerBound { return nil }
90 if element == index, containers.contains(children[index].kind) {
91 node = children[index]
92 continue
93 }
94 var first = element
95 while first > 0, children[first - 1].kind == .affiliatedKeyword, children[first - 1].range.upperBound == children[first].range.lowerBound { first -= 1 }
96 for keyword in children[first..<element].reversed() {
97 if let range = keyword.text.range(of: "^[ \\t]*#\\+NAME:[ \\t]*(.*?)[ \\t]*\\n?$", options: [.regularExpression, .caseInsensitive]) {
98 let value = keyword.text[range].replacingOccurrences(of: "^[ \\t]*#\\+[Nn][Aa][Mm][Ee]:[ \\t]*|[ \\t]*\\n?$", with: "", options: .regularExpression)
99 if !value.isEmpty { return (value, children[first].range.lowerBound) }
100 }
101 }
102 return nil
103 }
104 }
105
106 /// `org-link--normalize-string`: statistics cookies and runs of blanks become one space;
107 /// with `context`, enclosing parentheses and leading `#` and `*` go too.
108 static func normalize(_ s: String, context: Bool) -> String {
109 var t = s.replacingOccurrences(of: "\\[[0-9]*(%|/[0-9]*)\\]", with: " ", options: .regularExpression)
110 t = t.replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
111 t = t.trimmingCharacters(in: .whitespacesAndNewlines)
112 guard context else { return t }
113 while true {
114 if t.hasPrefix("("), t.hasSuffix(")"), t.count >= 2 {
115 t = String(t.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
116 } else if let range = t.range(of: "^[#*]+[ \\t]*", options: .regularExpression) {
117 t.removeSubrange(range)
118 } else {
119 return t
120 }
121 }
122 }
123
124 /// `org-link-display-format`: bracket links become their descriptions.
125 static func displayFormat(_ s: String) -> String {
126 let regex = try! NSRegularExpression(pattern: "\\[\\[((?:[^\\]\\[\\\\]|\\\\(?:\\\\\\\\)*[\\]\\[]|\\\\+[^\\]\\[])+)\\](?:\\[((?:.|\\n)+?)\\])?\\]")
127 let ns = s as NSString
128 var out = s
129 for m in regex.matches(in: s, range: NSRange(location: 0, length: ns.length)).reversed() {
130 let group = m.range(at: 2).location != NSNotFound ? m.range(at: 2) : m.range(at: 1)
131 out = (out as NSString).replacingCharacters(in: m.range, with: ns.substring(with: group))
132 }
133 return out
134 }
135
136 /// `org-link-escape`.
137 static func escape(_ link: String) -> String {
138 let regex = try! NSRegularExpression(pattern: "(\\\\*)([\\[\\]]|$)")
139 let ns = link as NSString
140 var out = link
141 for m in regex.matches(in: link, range: NSRange(location: 0, length: ns.length)).reversed() {
142 let run = ns.substring(with: m.range(at: 1))
143 out = (out as NSString).replacingCharacters(in: m.range(at: 1), with: run + run + (m.range(at: 2).length > 0 ? "\\" : ""))
144 }
145 return out
146 }
147
148 /// `org-link-make-string`.
149 static func makeString(_ link: String, _ description: String?) -> String {
150 let zeroWidthSpace = "\u{200B}"
151 let description = description.flatMap { d -> String? in
152 guard d.contains(where: { !$0.isWhitespace }) else { return nil }
153 return d.trimmingCharacters(in: .whitespacesAndNewlines)
154 .replacingOccurrences(of: "\\]$", with: "]" + zeroWidthSpace, options: .regularExpression)
155 .replacingOccurrences(of: "\\]\\]", with: "]" + zeroWidthSpace + "]", options: .regularExpression)
156 }
157 return "[[" + escape(link) + "]" + (description.map { "[" + $0 + "]" } ?? "") + "]"
158 }
159
160 /// `expand-file-name` of `path` in `directory`, with `~` for `home`.
161 static func expand(_ path: String, directory: String?, home: String) -> String {
162 var p = path
163 if p == "~" || p.hasPrefix("~/") { p = home + p.dropFirst() }
164 if !p.hasPrefix("/"), let directory { p = (directory as NSString).appendingPathComponent(p) }
165 return (p as NSString).standardizingPath
166 }
167
168 /// `abbreviate-file-name`: `~` for the home folder.
169 public static func abbreviate(_ path: String, home: String) -> String {
170 if path == home { return "~" }
171 return path.hasPrefix(home + "/") ? "~" + path.dropFirst(home.count) : path
172 }
173
174 /// `org-link-make-string-for-buffer` before its description prompt: the link a buffer
175 /// visiting `file` gets, and the description to offer.
176 static func forBuffer(_ link: String, description: String?, file: String?, home: String) -> (link: String, description: String?) {
177 var link = link
178 var description = description
179 if link.hasPrefix("<"), link.hasSuffix(">"), typesPattern.firstMatch(in: String(link.dropFirst()), range: NSRange(location: 0, length: (link as NSString).length - 1)) != nil {
180 link = String(link.dropFirst().dropLast())
181 }
182 let directory = file.map { ($0 as NSString).deletingLastPathComponent }
183 if let file, let range = link.range(of: "^file:(.+?)::", options: .regularExpression) {
184 let path = String(link[range].dropFirst(5).dropLast(2))
185 if expand(path, directory: directory, home: home) == expand(file, directory: nil, home: home) {
186 link = String(link[range.upperBound...])
187 }
188 }
189 if let prefix = ["file:", "docview:"].first(where: link.hasPrefix) {
190 var rest = String(link.dropFirst(prefix.count))
191 var search: String?
192 if let range = rest.range(of: "::(.*)$", options: .regularExpression) {
193 search = String(rest[range].dropFirst(2))
194 rest.removeSubrange(range)
195 }
196 let original = rest.contains(where: { !$0.isWhitespace }) ? rest : file ?? ""
197 let expanded = expand(original, directory: directory, home: home)
198 let base = directory.map { expand($0, directory: nil, home: home) + "/" }
199 let path = base.map(expanded.hasPrefix) == true ? String(expanded.dropFirst(base!.count)) : abbreviate(expanded, home: home)
200 link = prefix + path + (search.map { "::" + $0 } ?? "")
201 if description == original { description = path }
202 }
203 return (link, description)
204 }
205}
206
207/// `org-insert-link` (C-c C-l): asks for a link, offering stored links and link types, and a
208/// description; on a link, edits it.
209public struct InsertLink: OrgCommand {
210 /// `org-stored-links`, most recent first.
211 public let stored: [StoredLink]
212 /// The file the buffer visits.
213 public let file: String?
214 public let home: String
215
216 public init(stored: [StoredLink] = [], file: String? = nil, home: String = NSHomeDirectory()) {
217 self.stored = stored
218 self.file = file
219 self.home = home
220 }
221
222 public var id: String { "org.link.insert" }
223 public var title: String { "Insert Link" }
224 public func applies(in context: EditContext) -> Bool { true }
225
226 public func run(in context: EditContext) -> CommandStep {
227 let ns = context.text as NSString
228 var remove: Range<Int>?
229 var description: String?
230 if let range = context.selection.first, !range.isEmpty {
231 remove = range
232 description = ns.substring(with: NSRange(range))
233 }
234 var link: String
235 if let found = Links.link(at: context.caret, text: context.text, tree: context.tree) {
236 let whole = ns.substring(with: NSRange(found.range))
237 remove = found.range
238 let initial: String
239 if whole.hasPrefix("[[") {
240 let inside = whole.dropFirst(2).dropLast(1)
241 let split = inside.range(of: "][")
242 initial = Links.unescape(String(split.map { inside[..<$0.lowerBound] } ?? inside.dropLast()))
243 description = split.map { String(inside[$0.upperBound...].dropLast()) }
244 } else {
245 initial = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole
246 }
247 guard let answer = context.answers["link"] else { return .prompt(Prompt(key: "link", message: "Link:", initial: initial)) }
248 link = answer
249 } else {
250 let prefixes = Array(Links.abbreviations(in: context.text).keys).sorted() + Links.types
251 guard let answer = context.answers["link"] else {
252 let message = stored.first.map { "Insert link (default \($0.link)):" } ?? "Insert link:"
253 let choices = stored.map(\.link) + stored.compactMap(\.description) + prefixes.map { $0 + ":" }
254 return .prompt(Prompt(key: "link", message: message, choices: choices, requireMatch: false))
255 }
256 link = answer.isEmpty ? stored.first?.link ?? "" : answer
257 guard link.contains(where: { !$0.isWhitespace }) else { return .failed("No link selected") }
258 if let byDescription = stored.last(where: { $0.description == link }) { link = byDescription.link }
259 let type = link.hasSuffix(":") ? String(link.dropLast()) : link
260 if prefixes.contains(type) {
261 guard let special = context.answers["special"] else {
262 return .prompt(Prompt(key: "special", message: "Link (no completion support):", initial: type + ":"))
263 }
264 link = special
265 }
266 description = description ?? stored.first { $0.link == link }?.description
267 }
268 let used = link
269 let prepared = Links.forBuffer(link, description: description, file: file, home: home)
270 guard let typed = context.answers["description"] else {
271 return .prompt(Prompt(key: "description", message: "Description:", initial: prepared.description ?? ""))
272 }
273 let text = Links.makeString(prepared.link, typed)
274 let at = remove ?? context.caret..<context.caret
275 let end = at.lowerBound + (text as NSString).length
276 let effects: [Effect] = stored.contains { $0.link == used } ? [.storedLinkUsed(used)] : []
277 return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: at, replacement: text)], selection: [end..<end], effects: effects))
278 }
279}
280
281/// `org-id-get-create`: gives the entry an `ID` property unless it has one.
282public struct CreateID: OrgCommand {
283 let newID: @Sendable () -> String
284
285 public init(newID: @escaping @Sendable () -> String = { UUID().uuidString }) {
286 self.newID = newID
287 }
288
289 public var id: String { "org.id.create" }
290 public var title: String { "Create ID" }
291 public func applies(in context: EditContext) -> Bool { true }
292 public func run(in context: EditContext) -> CommandStep {
293 commitBuffer(context) { buffer in
294 buffer.saveExcursion {
295 buffer.backToHeadingOrPointMin()
296 if buffer.entryID() == nil { buffer.entryPut("ID", newID()) }
297 }
298 }
299 }
300}
301
302extension EmacsBuffer {
303 /// The entry's own `ID` when it has non-blank one, from point at its heading (or the
304 /// start of the file).
305 func entryID() -> String? {
306 saveExcursion {
307 if beforeFirstHeading {
308 while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
309 } else {
310 forwardLine(1)
311 if lookingAt(planningPattern) != nil { forwardLine(1) }
312 }
313 guard let drawer = looking(Self.propertyDrawerPattern, caseInsensitive: true) else { return nil }
314 let body = substring(drawer[0]!.lowerBound, drawer[0]!.upperBound)
315 guard let range = body.range(of: "(?m)^[ \\t]*:ID:[ \\t]+(\\S.*?)[ \\t]*$", options: [.regularExpression, .caseInsensitive]) else { return nil }
316 return body[range].replacingOccurrences(of: "^[ \\t]*:[Ii][Dd]:[ \\t]+|[ \\t]+$", with: "", options: .regularExpression)
317 }
318 }
319}
Sources/OrgEditorAppKit/EditorCommands.swift +3
@@ -37,6 +37,9 @@ public enum EditorCommands {
3737 public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [
3838 EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view),
3939 EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view),
40 EditorCommand(id: "org.link.insert", title: "Insert Link…", action: .app),
41 EditorCommand(id: "org.link.store", title: "Store Link", action: .app),
42 EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app),
4043
4144 EditorCommand(id: "app.save", title: "Save", action: .app),
4245 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
Sources/OrgEditorAppKit/OrgEditor.swift +6 −2
@@ -260,6 +260,8 @@ public final class OrgEditor: NSObject {
260260 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)?
261261 /// Runs work outside the text, such as a table recalculation in Emacs.
262262 public var onExternal: ((ExternalRequest) -> Void)?
263 /// Effects other than messages, for the app to carry out.
264 public var onEffect: ((Effect) -> Void)?
263265
264266 public var keymap: Keymap {
265267 get { dispatcher.keymap }
@@ -308,7 +310,7 @@ public final class OrgEditor: NSObject {
308310 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
309311 private(set) var markActive = false
310312
311 private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext {
313 public func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext {
312314 let selected = textView.selectedRange()
313315 return EditContext(
314316 revision: document.revision, text: document.text, tree: document.tree,
@@ -423,7 +425,9 @@ public final class OrgEditor: NSObject {
423425 setCaret(selection.lowerBound)
424426 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
425427 }
426 for case .message(let text) in result.effects { onMessage?(text) }
428 for effect in result.effects {
429 if case .message(let text) = effect { onMessage?(text) } else { onEffect?(effect) }
430 }
427431 case .failed(let message):
428432 onMessage?(message)
429433 case .external(let request):
Sources/Orgstar/ContentView.swift +17 −2
@@ -293,7 +293,11 @@ struct ContentView: View {
293293 AppDelegate.pendingOpens = []
294294 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
295295 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
296 if let command = DevelopmentHooks.commandToRun { perform(command) }
296 if let commandList = DevelopmentHooks.commandToRun {
297 // The file opened above needs its editor first.
298 for _ in 0..<20 where DevelopmentHooks.fileToOpen != nil && commands.editor == nil { try? await Task.sleep(for: .milliseconds(100)) }
299 for command in commandList.split(separator: ",") { perform(String(command)) }
300 }
297301 if let id = DevelopmentHooks.windowToOpen {
298302 if id == "settings" { openSettings() } else { openWindow(id: id) }
299303 }
@@ -438,7 +442,7 @@ struct ContentView: View {
438442
439443 /// Runs a command from a key, the palette or the Org menu.
440444 private func perform(_ id: String) {
441 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.")
445 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.")
442446 if orgOnly, !session.isOrg {
443447 commands.show("Not an org file")
444448 return
@@ -487,6 +491,17 @@ struct ContentView: View {
487491 }
488492 case "app.archive":
489493 if let message = session.archive() { commands.show(message) }
494 case "org.link.insert":
495 commands.editor?.perform(InsertLink(stored: commands.storedLinks, file: session.path))
496 case "org.link.store":
497 guard let editor = commands.editor, let path = session.path else { return commands.show("No method for storing a link from this buffer") }
498 commands.storeLink(Links.store(in: editor.context(), path: Links.abbreviate(path, home: NSHomeDirectory())))
499 case "org.id.store-link":
500 guard let editor = commands.editor, let path = session.path else { return }
501 if case .commit = editor.perform(CreateID()),
502 let link = Links.storeID(in: editor.context(), fileName: (path as NSString).lastPathComponent) {
503 commands.storeLink(link)
504 }
490505 case "app.agenda":
491506 openWindow(id: "agenda")
492507 case "app.clock.in":
Sources/Orgstar/DevelopmentHooks.swift +2 −2
@@ -5,8 +5,8 @@ import AppKit
55/// - `ORGSTAR_OPEN`: a file to select at launch.
66/// - `ORGSTAR_ROOT`: a folder to add at launch.
77/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it.
8/// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is
9/// what the snapshot renders.
8/// - `ORGSTAR_COMMAND`: commands to run at launch, separated by commas (`app.import-emacs`); a
9/// sheet or prompt they open is what the snapshot renders.
1010/// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's.
1111/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
1212/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
Sources/Orgstar/EditorView.swift +3
@@ -95,6 +95,9 @@ struct EditorView: NSViewRepresentable {
9595 editor.onAppCommand = onAppCommand
9696 editor.onExternal = onExternal
9797 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
98 editor.onEffect = { [weak commands] effect in
99 if case .storedLinkUsed(let link) = effect { commands?.storedLinks.removeAll { $0.link == link } }
100 }
98101 let scroll = editor.makeScrollView()
99102 scroll.autoresizingMask = [.width, .height]
100103 return (editor, scroll)
Sources/Orgstar/Keys.swift +9 −1
@@ -52,17 +52,25 @@ struct EchoArea: View {
5252 Divider()
5353 HStack(spacing: 6) {
5454 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
55 .lineLimit(1)
56 .truncationMode(.middle)
5557 TextField("", text: $answer)
58 .frame(minWidth: 240)
5659 .textFieldStyle(.plain)
5760 .font(.system(.callout, design: .monospaced))
5861 .focused($answerFocused)
5962 .onSubmit {
60 if request.prompt.choices.isEmpty {
63 if request.prompt.choices.isEmpty || !request.prompt.requireMatch {
6164 commands.answer(answer)
6265 } else if let first = filteredChoices(request.prompt.choices).first {
6366 commands.answer(first)
6467 }
6568 }
69 .onKeyPress(.tab) {
70 guard let first = filteredChoices(request.prompt.choices).first else { return .ignored }
71 answer = first
72 return .handled
73 }
6674 .onExitCommand { commands.answer(nil) }
6775 }
6876 .padding(.horizontal, 10)
Sources/Orgstar/OrgstarApp.swift +14
@@ -26,6 +26,20 @@ final class AppCommands {
2626 /// The keymap in use, for showing keys in menus and the palette.
2727 var keymap = KeymapPreset.emacs.keymap
2828 var modal = false
29 /// `org-stored-links`, most recent first; kept for the session, as in Emacs.
30 var storedLinks: [StoredLink] = []
31
32 /// `org-link--add-to-stored-links`: puts `link` first and says what happened.
33 func storeLink(_ link: StoredLink) {
34 if storedLinks.first == link {
35 show("This link has already been stored")
36 } else {
37 let moved = storedLinks.contains(link)
38 storedLinks.removeAll { $0 == link }
39 storedLinks.insert(link, at: 0)
40 show((moved ? "Link moved to front: " : "Stored: ") + (link.description ?? link.link))
41 }
42 }
2943
3044 /// The keys that run `command`, as menus and the palette show them.
3145 func keyLabel(for command: String) -> String? {
Tests/OrgCoreTests/LinkStoreTests.swift added +212
@@ -0,0 +1,212 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct LinkStoreTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let text = """
9 #+TITLE: Store
10 intro (line) here
11 * TODO [#A] First [1/2] heading :tag:
12 body <<my target>> here
13 text with [[https://x.org][a link]] in it
14 * Second
15 :PROPERTIES:
16 :CUSTOM_ID: sec
17 :END:
18 #+NAME: tbl
19 | a |
20
21 #+name: code
22 #+begin_src sh
23 echo hi
24 #+end_src
25 * COMMENT Third [[https://y.org][linked]] title
26 *\u{20}
27
28 """
29
30 /// Carets as text before them, and selections as `start…end` pairs of such text.
31 static let cases: [(String, String?)] = [
32 ("#+TITLE", nil), ("intro", nil), ("* TODO", nil), ("First", nil), ("<<my", nil), ("y target", nil), ("here\ntext", nil),
33 ("body", nil), ("text with", nil), (":CUSTOM_ID", nil), ("#+NAME", nil), ("| a", nil), ("#+name", nil), ("echo", nil),
34 ("#+end_src", nil), ("Third", nil), ("* \n", nil), ("intro", "First"), ("(line)", "here"),
35 ]
36
37 static func offset(_ s: String) -> Int { (text as NSString).range(of: s).location }
38
39 @Test(.enabled(if: oracle))
40 func storesLikeOrgStoreLink() throws {
41 let points = Self.cases.map { c -> String in
42 let start = EmacsOracle.point(Self.offset(c.0), in: Self.text)
43 let end = c.1.map { EmacsOracle.point(Self.offset($0), in: Self.text) }
44 return "(\(start) \(end.map(String.init) ?? "nil"))"
45 }
46 let emacs = try EmacsOracle.evaluate(Self.text, """
47 (let ((dir (file-name-directory buffer-file-name)) out)
48 (dolist (c '(\(points.joined(separator: " "))))
49 (deactivate-mark)
50 (goto-char (car c))
51 (when (cadr c) (set-mark (car c)) (goto-char (cadr c)) (activate-mark))
52 (setq org-stored-links nil)
53 (call-interactively #'org-store-link)
54 (push (format "%S" (mapcar (lambda (l) (list (replace-regexp-in-string (regexp-quote dir) "/d/" (car l) t t) (cadr l))) org-stored-links)) out))
55 (nreverse out))
56 """, prelude: "(transient-mark-mode 1)")
57 var ours: [String] = []
58 let tree = OrgParser.parse(Self.text)
59 for c in Self.cases {
60 let start = Self.offset(c.0)
61 let selection = c.1.map { start..<Self.offset($0) } ?? start..<start
62 let context = EditContext(revision: 0, text: Self.text, tree: tree, selection: [selection])
63 let stored = Links.store(in: context, path: "/d/oracle.org")
64 ours.append("((\(lisp(stored.link)) \(stored.description.map(lisp) ?? "nil")))")
65 }
66 #expect(ours == emacs)
67 }
68
69 func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" }
70
71 struct InsertCase {
72 var text: String
73 /// Where the caret goes: before this text, or with `|` marking it.
74 var caret: Int
75 var region: Range<Int>?
76 var stored: [StoredLink]
77 var link: String
78 /// nil accepts the offered description.
79 var description: String?
80 var special: String? = nil
81 }
82
83 static func insertCase(_ marked: String, region: String? = nil, stored: [StoredLink] = [], link: String, description: String? = nil, special: String? = nil) -> InsertCase {
84 let caret = (marked as NSString).range(of: "|").location
85 let text = marked.replacingOccurrences(of: "|", with: "")
86 let range = region.map { r -> Range<Int> in
87 let start = (text as NSString).range(of: r).location
88 return start..<(start + (r as NSString).length)
89 }
90 return InsertCase(text: text, caret: range?.upperBound ?? caret, region: range, stored: stored, link: link, description: description, special: special)
91 }
92
93 static let insertCases: [InsertCase] = [
94 insertCase("* A\n|\n* Heading\n", stored: [StoredLink(link: "file:/d/oracle.org::*Heading", description: "Heading")], link: ""),
95 insertCase("* A\n|\n", stored: [StoredLink(link: "file:/d/sub/b.org::*X", description: "X"), StoredLink(link: "file:/d/oracle.org::#c", description: "C")], link: "file:/d/sub/b.org::*X"),
96 insertCase("* A\n|\n", stored: [StoredLink(link: "file:/elsewhere/c.org", description: nil)], link: "", description: "Elsewhere"),
97 insertCase("* A\n|\n", stored: [StoredLink(link: "file:~/notes/x.org::42", description: nil)], link: ""),
98 insertCase("Some |text\n", link: "https://orgmode.org", description: "Org"),
99 insertCase("Some text here\n", region: "text", link: "https://orgmode.org"),
100 insertCase("See [[https://a.org][the [a] site]] |\n", link: "https://b.org"),
101 insertCase("See [[https://a.org][the site]]\n", region: nil, link: "https://b.org", description: "B"),
102 insertCase("Visit https://a.org/x| now\n", link: "https://c.org"),
103 insertCase("x |\n", link: "a [b] c\\", description: "ends]] with]"),
104 insertCase("x |\n", link: " "),
105 insertCase("x |\n", stored: [StoredLink(link: "file:/d/oracle.org::*H", description: "Heading H")], link: "Heading H"),
106 insertCase("x |\n", link: "https:", special: "https://typed.org"),
107 insertCase("x |\n", link: "<https://angle.org>", description: ""),
108 insertCase("x |\n", stored: [StoredLink(link: "file:/d/sub/b.org", description: "/d/sub/b.org")], link: ""),
109 ]
110
111 @Test(.enabled(if: oracle))
112 func insertsLikeOrgInsertLink() throws {
113 var cases = Self.insertCases
114 // The edit-link case puts its caret inside the link.
115 cases[7].caret = (cases[7].text as NSString).range(of: "the site").location
116 var forms: [String] = []
117 for c in cases {
118 let stored = c.stored.map { "(list (funcall fix \(lisp($0.link))) \($0.description.map { "(funcall fix \(lisp($0)))" } ?? "nil"))" }.joined(separator: " ")
119 let region = c.region.map { "(set-mark \(EmacsOracle.point($0.lowerBound, in: c.text))) (goto-char \(EmacsOracle.point($0.upperBound, in: c.text))) (activate-mark)" } ?? ""
120 forms.append("""
121 (progn (erase-buffer) (insert \(lisp(c.text))) (deactivate-mark) (goto-char \(EmacsOracle.point(c.caret, in: c.text))) \(region)
122 (setq org-stored-links (list \(stored)))
123 (let ((answers (list (cons "link" (funcall fix \(lisp(c.link)))) (cons "description" \(c.description.map(lisp) ?? "nil")) (cons "special" \(c.special.map(lisp) ?? "nil")))))
124 (let ((err (condition-case e
125 (cl-letf (((symbol-function 'org-completing-read)
126 (lambda (_prompt _coll &optional _p _r _i _h def &rest _)
127 (let ((a (cdr (assoc "link" answers)))) (if (equal a "") (or def "") a))))
128 ((symbol-function 'read-string)
129 (lambda (prompt &optional initial &rest _)
130 (cond ((string-prefix-p "Description" prompt) (or (cdr (assoc "description" answers)) initial ""))
131 ((string-prefix-p "Link (no" prompt) (cdr (assoc "special" answers)))
132 (t (cdr (assoc "link" answers)))))))
133 (call-interactively #'org-insert-link) "")
134 (error (error-message-string e)))))
135 (format "%s|%d|%d|%s" (replace-regexp-in-string (regexp-quote dir) "/d/" (buffer-string) t t) (1- (point)) (length org-stored-links) err))))
136 """)
137 }
138 let emacs = try EmacsOracle.evaluate("", """
139 (let* ((dir (file-name-directory buffer-file-name))
140 (fix (lambda (s) (replace-regexp-in-string "/d/" dir s t t))))
141 (list \(forms.joined(separator: "\n"))))
142 """, prelude: "(require 'cl-lib) (transient-mark-mode 1)")
143 var ours: [String] = []
144 for c in cases {
145 let tree = OrgParser.parse(c.text)
146 var answers: [String: String] = [:]
147 let command = InsertLink(stored: c.stored, file: "/d/oracle.org", home: NSHomeDirectory())
148 var line = ""
149 loop: while true {
150 let context = EditContext(revision: 0, text: c.text, tree: tree, selection: [c.region ?? c.caret..<c.caret], answers: answers)
151 switch command.run(in: context) {
152 case .prompt(let prompt):
153 switch prompt.key {
154 case "link": answers["link"] = c.link
155 case "special": answers["special"] = c.special ?? ""
156 default: answers["description"] = c.description ?? prompt.initial
157 }
158 case .commit(let result):
159 var new = c.text
160 for edit in result.edits { new = edit.apply(to: new) }
161 let used = result.effects.contains { if case .storedLinkUsed = $0 { true } else { false } }
162 let point = EmacsOracle.point(result.selection!.first!.lowerBound, in: new) - 1
163 line = "\(new)|\(point)|\(c.stored.count - (used ? 1 : 0))|"
164 break loop
165 case .failed(let message):
166 line = "\(c.text)|\(EmacsOracle.point(c.caret, in: c.text) - 1)|\(c.stored.count)|\(message)"
167 break loop
168 case .external:
169 break loop
170 }
171 }
172 ours.append(line)
173 }
174 for (o, e) in zip(ours, emacs) { #expect(o == e) }
175 #expect(ours.count == emacs.count)
176 }
177
178 @Test(.enabled(if: oracle))
179 func createsIDsLikeOrgIDGetCreate() throws {
180 let cases = [
181 ("* A\nbody\n", "body"), ("* A\nSCHEDULED: <2026-10-05 Mon>\n", "A"),
182 ("* A\n:PROPERTIES:\n:ID: old-id\n:END:\n", "A"), ("* A\n:PROPERTIES:\n:CUSTOM_ID: x\n:END:\n", "A"),
183 ("#+TITLE: T\ntext\n* A\n", "text"), ("* A\n:PROPERTIES:\n:ID:\n:END:\n", "A"),
184 ]
185 let forms = cases.map { c in
186 "(progn (erase-buffer) (insert \(lisp(c.0))) (goto-char \(EmacsOracle.point((c.0 as NSString).range(of: c.1).location, in: c.0))) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (call-interactively #'org-id-get-create)) (format \"%s|%d\" (buffer-string) (1- (point))))"
187 }
188 let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(require 'org-id) (require 'cl-lib)")
189 let ours = cases.map { c -> String in
190 let result = runCommand(CreateID { "NEW-ID" }, c.0, caret: (c.0 as NSString).range(of: c.1).location)
191 return "\(result.text)|\(EmacsOracle.point(result.caret, in: result.text) - 1)"
192 }
193 #expect(ours == emacs)
194 }
195
196 @Test(.enabled(if: oracle))
197 func storesIDLinksLikeOrgIDStoreLink() throws {
198 let cases = [("* TODO [#A] First [1/2] :tag:\nbody\n", "body"), ("#+TITLE: The Title\ntext\n* A\n", "text"), ("text\n* A\n", "text"), ("* A\n:PROPERTIES:\n:ID: kept\n:END:\n", "A")]
199 let forms = cases.map { c in
200 "(progn (erase-buffer) (insert \(lisp(c.0))) (goto-char \(EmacsOracle.point((c.0 as NSString).range(of: c.1).location, in: c.0))) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (call-interactively #'org-id-store-link)) (format \"%s|%s\" (plist-get org-store-link-plist :link) (plist-get org-store-link-plist :description)))"
201 }
202 let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(require 'org-id) (require 'cl-lib)")
203 let ours = cases.map { c -> String in
204 let caret = (c.0 as NSString).range(of: c.1).location
205 let created = runCommand(CreateID { "NEW-ID" }, c.0, caret: caret)
206 let context = EditContext(revision: 0, text: created.text, tree: OrgParser.parse(created.text), selection: [created.caret..<created.caret])
207 let stored = Links.storeID(in: context, fileName: "oracle.org")
208 return "\(stored?.link ?? "nil")|\(stored?.description ?? "nil")"
209 }
210 #expect(ours == emacs)
211 }
212}