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
22import Observation
33import OrgDocument
44import OrgPresentation
5import OrgCore
56import OrgIndex
67import OrgWorkspace
78
@@ -273,6 +274,16 @@ public final class WorkspaceModel {
273274 FileTreeNode.build(root: path, files: files)
274275 }
275276
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
276287 public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
277288 (try? index.search(query, overlay: overlay)) ?? []
278289 }
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 {
6868 EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app),
6969 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
7070 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),
7172 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
7273 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
7374 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 {
316316 return indexed.filter { overlay[$0.path] == nil } + live
317317 }
318318
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
319337 /// IDs used by more than one heading.
320338 public func duplicateIDs(overlay: [String: FileRecord] = [:]) throws -> [String: [HeadingLocation]] {
321339 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 {
6868 @State private var previewPath: String?
6969 @AppStorage("outlineShown") private var orgOutlineShown = true
7070 @AppStorage("outlineWidth") private var outlineWidth = 220.0
71 @AppStorage("backlinksShown") private var backlinksShown = true
7172 @AppStorage("showMarkup") private var showMarkup = false
7273 @AppStorage("showLineNumbers") private var showLineNumbers = true
7374 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -172,7 +173,17 @@ struct ContentView: View {
172173 if outlineShown {
173174 Group {
174175 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 }
176187 } else {
177188 SearchResults(results: workspace.search(query, overlay: workspace.overlay(session.openDocuments))) { location in
178189 selectedFile = location.path
@@ -535,6 +546,7 @@ struct ContentView: View {
535546 case "app.babel.cancel":
536547 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") }
537548 case "app.toggle-outline": toggleOutline()
549 case "app.toggle-backlinks": backlinksShown.toggle()
538550 case "app.toggle-markup": showMarkup.toggle()
539551 case "app.toggle-line-numbers": showLineNumbers.toggle()
540552 case "app.import-emacs": showingImport = true
Sources/Orgstar/OrgstarApp.swift +1
@@ -349,6 +349,7 @@ struct OrgstarApp: App {
349349 }
350350 CommandGroup(after: .sidebar) {
351351 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
352 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
352353 .keyboardShortcut("o", modifiers: [.command, .option])
353354 MarkupToggle()
354355 }
Tests/OrgAppTests/AppTests.swift +14
@@ -74,6 +74,20 @@ struct WorkspaceModelTests {
7474 #expect(reopened.search("apples").isEmpty)
7575 }
7676
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
7791 @Test func createRenameAndTrashFiles() async throws {
7892 let notes = try Folder()
7993 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}