krz/orgstar

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

Commit 5a1ebf5bef

5a1ebf5befa3237651754b0ad5db24180a609bbb

parent: 2081575ac0

Verified · cmc

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

Add a backlinks pane

Below the outline, the headings that link to the heading at the caret and to
the file, from the index's link rows with unsaved buffers overlaid. Links
resolve as org follows them: id:, file: with ::*Title or ::#custom-id,
plain paths, and *Title, #custom-id or a title within the same file.
View > Show or Hide Backlinks.

Ref docs/review-2026-10-05.md tier 3 item 4

Layout: unified · split

Sources/OrgApp/WorkspaceModel.swift +11
@@ -2,6 +2,7 @@ import Foundation
2import Observation 2import Observation
3import OrgDocument 3import OrgDocument
4import OrgPresentation 4import OrgPresentation
5import OrgCore
5import OrgIndex 6import OrgIndex
6import OrgWorkspace 7import OrgWorkspace
7 8
@@ -273,6 +274,16 @@ public final class WorkspaceModel {
273 FileTreeNode.build(root: path, files: files) 274 FileTreeNode.build(root: path, files: files)
274 } 275 }
275 276
277 /// Headings with links to `target`, the target heading's own links left out.
278 public func backlinks(to target: Backlinks.Target, excluding start: Int? = nil, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
279 var seen = Set<HeadingLocation>()
280 return ((try? index.links(overlay: overlay)) ?? []).compactMap { link in
281 guard !(link.source.path == target.path && link.source.start == start),
282 Backlinks.links(link.target, from: link.source.path, to: target), seen.insert(link.source).inserted else { return nil }
283 return link.source
284 }
285 }
286
276 public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] { 287 public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
277 (try? index.search(query, overlay: overlay)) ?? [] 288 (try? index.search(query, overlay: overlay)) ?? []
278 } 289 }
Sources/OrgCore/Links/Backlinks.swift added +58
@@ -0,0 +1,58 @@
1import Foundation
2
3/// Which links point at a file or one of its headings, read as `org-open-at-point` would
4/// follow them: `id:`, `file:` with `::*Title` or `::#custom-id`, plain paths, and `*Title`,
5/// `#custom-id` or a heading's exact title within the same file.
6public enum Backlinks {
7 public struct Target: Sendable, Equatable {
8 public var path: String
9 /// The heading, or nil for the file itself.
10 public var title: String?
11 public var id: String?
12 public var customID: String?
13
14 public init(path: String, title: String? = nil, id: String? = nil, customID: String? = nil) {
15 self.path = path
16 self.title = title
17 self.id = id
18 self.customID = customID
19 }
20 }
21
22 /// Whether `link`, written in the file at `source`, leads to `target`.
23 public static func links(_ link: String, from source: String, to target: Target, home: String = NSHomeDirectory()) -> Bool {
24 let link = Links.unescape(link)
25 if link.lowercased().hasPrefix("id:") {
26 let id = link.dropFirst(3).components(separatedBy: "::")[0]
27 return target.id.map { $0 == id } ?? false
28 }
29 var path: String
30 var search: String
31 if link.lowercased().hasPrefix("file:") {
32 let parts = link.dropFirst(5).components(separatedBy: "::")
33 path = parts[0]
34 search = parts.dropFirst().joined(separator: "::")
35 } else if ["/", "./", "../", "~/"].contains(where: link.hasPrefix) {
36 let parts = link.components(separatedBy: "::")
37 path = parts[0]
38 search = parts.dropFirst().joined(separator: "::")
39 } else if link.firstMatch(of: /^[a-zA-Z][-a-zA-Z0-9+]*:/) != nil {
40 return false
41 } else {
42 // Within the file.
43 guard source == target.path else { return false }
44 path = ""
45 search = link
46 }
47 if !path.isEmpty {
48 let directory = (source as NSString).deletingLastPathComponent
49 let expanded = Links.expand(path, directory: directory, home: home)
50 guard (expanded as NSString).standardizingPath == (target.path as NSString).standardizingPath else { return false }
51 if search.isEmpty { return target.title == nil }
52 }
53 guard let title = target.title else { return false }
54 if search.hasPrefix("#") { return target.customID.map { "#" + $0 == search } ?? false }
55 let wanted = search.hasPrefix("*") ? String(search.dropFirst()) : search
56 return Links.normalizedTitle(wanted) == Links.normalizedTitle(title)
57 }
58}
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -68,6 +68,7 @@ public enum EditorCommands {
68 EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app), 68 EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app),
69 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), 69 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
70 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 70 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
71 EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app),
71 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), 72 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
72 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), 73 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
73 EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app), 74 EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app),
Sources/OrgIndex/IndexStore.swift +18
@@ -316,6 +316,24 @@ public final class IndexStore: Sendable {
316 return indexed.filter { overlay[$0.path] == nil } + live 316 return indexed.filter { overlay[$0.path] == nil } + live
317 } 317 }
318 318
319 /// Every link and the heading it's in, unsaved buffers' in place of their files'.
320 public func links(overlay: [String: FileRecord] = [:]) throws -> [(source: HeadingLocation, target: String)] {
321 let indexed = try database.read { db in
322 try Row.fetchAll(
323 db,
324 sql: """
325 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset, l.target
326 FROM links l JOIN headings h ON h.id = l.heading_id JOIN files f ON f.id = h.file_id
327 WHERE f.kind = 'org' ORDER BY f.path, h.ordinal
328 """
329 ).map { (source: location($0), target: $0["target"] as String) }
330 }
331 let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in
332 record.headings.flatMap { heading in heading.links.map { (source: location(record, heading), target: $0.target) } }
333 }
334 return indexed.filter { overlay[$0.source.path] == nil } + live
335 }
336
319 /// IDs used by more than one heading. 337 /// IDs used by more than one heading.
320 public func duplicateIDs(overlay: [String: FileRecord] = [:]) throws -> [String: [HeadingLocation]] { 338 public func duplicateIDs(overlay: [String: FileRecord] = [:]) throws -> [String: [HeadingLocation]] {
321 let indexed = try database.read { db in 339 let indexed = try database.read { db in
Sources/Orgstar/BacklinksPane.swift added +72
@@ -0,0 +1,72 @@
1import OrgApp
2import OrgCore
3import OrgIndex
4import OrgPresentation
5import SwiftUI
6
7/// Headings that link to the heading at the caret, and to the file.
8struct BacklinksPane: View {
9 let workspace: WorkspaceModel
10 let session: DocumentSession
11 let commands: AppCommands
12 let open: (HeadingLocation) -> Void
13 @State private var toHeading: [HeadingLocation] = []
14 @State private var toFile: [HeadingLocation] = []
15 @Environment(\.orgTheme) private var theme
16
17 /// The heading at the caret.
18 private var heading: OutlineEntry? { session.outline.last { $0.start <= commands.selection.location } }
19
20 private struct Key: Equatable {
21 var path: String?
22 var heading: Int?
23 var version: Int
24 }
25
26 var body: some View {
27 List {
28 if let heading {
29 Section("Links to \(heading.title.isEmpty ? "(untitled)" : heading.title)") { rows(toHeading) }
30 }
31 Section("Links to this file") { rows(toFile) }
32 }
33 .themedListBackground(theme.color("sidebar-background"))
34 .task(id: Key(path: session.path, heading: heading?.start, version: session.editVersion)) { refresh() }
35 }
36
37 @ViewBuilder
38 private func rows(_ locations: [HeadingLocation]) -> some View {
39 if locations.isEmpty {
40 Text("None").foregroundStyle(.secondary)
41 }
42 ForEach(locations, id: \.self) { location in
43 Button {
44 open(location)
45 } label: {
46 VStack(alignment: .leading, spacing: 2) {
47 Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1)
48 Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
49 }
50 }
51 .buttonStyle(.plain)
52 }
53 }
54
55 private func refresh() {
56 guard let path = session.path else {
57 toHeading = []
58 toFile = []
59 return
60 }
61 let overlay = workspace.overlay(session.openDocuments)
62 toFile = workspace.backlinks(to: Backlinks.Target(path: path), overlay: overlay)
63 guard let heading, let tree = session.document?.tree,
64 let info = DocumentModel(tree: tree).headings.first(where: { $0.headingRange.lowerBound == heading.start }) else {
65 toHeading = []
66 return
67 }
68 let customID = info.properties.last { $0.key.uppercased() == "CUSTOM_ID" }?.value
69 let target = Backlinks.Target(path: path, title: info.title, id: info.id, customID: customID)
70 toHeading = workspace.backlinks(to: target, excluding: heading.start, overlay: overlay)
71 }
72}
Sources/Orgstar/ContentView.swift +13 −1
@@ -68,6 +68,7 @@ struct ContentView: View {
68 @State private var previewPath: String? 68 @State private var previewPath: String?
69 @AppStorage("outlineShown") private var orgOutlineShown = true 69 @AppStorage("outlineShown") private var orgOutlineShown = true
70 @AppStorage("outlineWidth") private var outlineWidth = 220.0 70 @AppStorage("outlineWidth") private var outlineWidth = 220.0
71 @AppStorage("backlinksShown") private var backlinksShown = true
71 @AppStorage("showMarkup") private var showMarkup = false 72 @AppStorage("showMarkup") private var showMarkup = false
72 @AppStorage("showLineNumbers") private var showLineNumbers = true 73 @AppStorage("showLineNumbers") private var showLineNumbers = true
73 @AppStorage("showHiddenFiles") private var showHiddenFiles = true 74 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -172,7 +173,17 @@ struct ContentView: View {
172 if outlineShown { 173 if outlineShown {
173 Group { 174 Group {
174 if query.isEmpty { 175 if query.isEmpty {
175 OutlineColumn(session: session) 176 VStack(spacing: 0) {
177 OutlineColumn(session: session)
178 if backlinksShown, session.isOrg {
179 Divider()
180 BacklinksPane(workspace: workspace, session: session, commands: commands) { location in
181 selectedFile = location.path
182 session.show(location)
183 }
184 .frame(height: 200)
185 }
186 }
176 } else { 187 } else {
177 SearchResults(results: workspace.search(query, overlay: workspace.overlay(session.openDocuments))) { location in 188 SearchResults(results: workspace.search(query, overlay: workspace.overlay(session.openDocuments))) { location in
178 selectedFile = location.path 189 selectedFile = location.path
@@ -535,6 +546,7 @@ struct ContentView: View {
535 case "app.babel.cancel": 546 case "app.babel.cancel":
536 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } 547 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") }
537 case "app.toggle-outline": toggleOutline() 548 case "app.toggle-outline": toggleOutline()
549 case "app.toggle-backlinks": backlinksShown.toggle()
538 case "app.toggle-markup": showMarkup.toggle() 550 case "app.toggle-markup": showMarkup.toggle()
539 case "app.toggle-line-numbers": showLineNumbers.toggle() 551 case "app.toggle-line-numbers": showLineNumbers.toggle()
540 case "app.import-emacs": showingImport = true 552 case "app.import-emacs": showingImport = true
Sources/Orgstar/OrgstarApp.swift +1
@@ -349,6 +349,7 @@ struct OrgstarApp: App {
349 } 349 }
350 CommandGroup(after: .sidebar) { 350 CommandGroup(after: .sidebar) {
351 Button("Show or Hide Outline") { commands.run("app.toggle-outline") } 351 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
352 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
352 .keyboardShortcut("o", modifiers: [.command, .option]) 353 .keyboardShortcut("o", modifiers: [.command, .option])
353 MarkupToggle() 354 MarkupToggle()
354 } 355 }
Tests/OrgAppTests/AppTests.swift +14
@@ -74,6 +74,20 @@ struct WorkspaceModelTests {
74 #expect(reopened.search("apples").isEmpty) 74 #expect(reopened.search("apples").isEmpty)
75 } 75 }
76 76
77 @Test func findsBacklinks() async throws {
78 let notes = try Folder()
79 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n")
80 try notes.write("b.org", "* Plumbing\n[[id:s-1][the sink]] and [[file:a.org]]\n* Other\n[[file:a.org::#sink]] [[https://x.org]]\n")
81 let model = try WorkspaceModel(directory: nil)
82 try await model.addRoot(notes.url)
83 let a = try #require(model.files.first { $0.path.hasSuffix("/a.org") }).path
84 let sink = Backlinks.Target(path: a, title: "Sink", id: "s-1", customID: "sink")
85 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other"])
86 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing"])
87 #expect(model.backlinks(to: Backlinks.Target(path: a, title: "Taps")).map(\.title) == ["Sink"])
88 #expect(model.backlinks(to: sink, excluding: 0).map(\.title) == ["Plumbing", "Other"])
89 }
90
77 @Test func createRenameAndTrashFiles() async throws { 91 @Test func createRenameAndTrashFiles() async throws {
78 let notes = try Folder() 92 let notes = try Folder()
79 try notes.write("a.org", "* Alpha\n") 93 try notes.write("a.org", "* Alpha\n")
Tests/OrgCoreTests/BacklinkTests.swift added +25
@@ -0,0 +1,25 @@
1import Testing
2@testable import OrgCore
3
4struct BacklinkTests {
5 let heading = Backlinks.Target(path: "/notes/a.org", title: "Fix the sink [1/2]", id: "abc-1", customID: "sink")
6 let file = Backlinks.Target(path: "/notes/a.org")
7
8 @Test func followsLinksAsOrgDoes() {
9 func links(_ link: String, from source: String = "/notes/b.org", to target: Backlinks.Target) -> Bool {
10 Backlinks.links(link, from: source, to: target, home: "/home")
11 }
12 #expect(links("id:abc-1", to: heading) && !links("id:abc-2", to: heading) && !links("id:abc-1", to: file))
13 #expect(links("file:a.org::*Fix the sink", to: heading))
14 #expect(links("file:./a.org::#sink", to: heading))
15 #expect(links("file:/notes/a.org", to: file) && !links("file:/notes/a.org", to: heading))
16 #expect(links("./a.org", to: file) && links("../notes/a.org::*Fix the sink", to: heading))
17 #expect(links("file:sub/../a.org", to: file))
18 #expect(!links("file:c.org::*Fix the sink", to: heading) && !links("https://a.org", to: file))
19 #expect(links("~/x.org", from: "/notes/b.org", to: Backlinks.Target(path: "/home/x.org")))
20 // Within the file.
21 #expect(links("*Fix the sink", from: "/notes/a.org", to: heading) && links("#sink", from: "/notes/a.org", to: heading))
22 #expect(links("Fix the sink", from: "/notes/a.org", to: heading))
23 #expect(!links("*Fix the sink", from: "/notes/b.org", to: heading))
24 }
25}