Backlinks pane !105

merged merged by cmc on 2026-10-06 07:07 UTC · krz/orgstar:backlinks into main

9 files changed, +213 −1

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}