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 |
| 2 | import Observation |
2 | import Observation |
| 3 | import OrgDocument |
3 | import OrgDocument |
| 4 | import OrgPresentation |
4 | import OrgPresentation |
| |
5 | import OrgCore |
| 5 | import OrgIndex |
6 | import OrgIndex |
| 6 | import OrgWorkspace |
7 | import 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 @@ |
| |
1 | import 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. |
| |
6 | public 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 @@ |
| |
1 | import OrgApp |
| |
2 | import OrgCore |
| |
3 | import OrgIndex |
| |
4 | import OrgPresentation |
| |
5 | import SwiftUI |
| |
6 | |
| |
7 | /// Headings that link to the heading at the caret, and to the file. |
| |
8 | struct 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 @@ |
| |
1 | import Testing |
| |
2 | @testable import OrgCore |
| |
3 | |
| |
4 | struct 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 | } |