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 |
| 2 | 2 | import Observation |
| 3 | 3 | import OrgDocument |
| 4 | 4 | import OrgPresentation |
| 5 | import OrgCore |
| 5 | 6 | import OrgIndex |
| 6 | 7 | import OrgWorkspace |
| 7 | 8 | |
| @@ -273,6 +274,16 @@ public final class WorkspaceModel { |
| 273 | 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 | 287 | public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] { |
| 277 | 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 | 68 | EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app), |
| 69 | 69 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), |
| 70 | 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 | 72 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
| 72 | 73 | EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), |
| 73 | 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 | 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 | 337 | /// IDs used by more than one heading. |
| 320 | 338 | public func duplicateIDs(overlay: [String: FileRecord] = [:]) throws -> [String: [HeadingLocation]] { |
| 321 | 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 | 68 | @State private var previewPath: String? |
| 69 | 69 | @AppStorage("outlineShown") private var orgOutlineShown = true |
| 70 | 70 | @AppStorage("outlineWidth") private var outlineWidth = 220.0 |
| 71 | @AppStorage("backlinksShown") private var backlinksShown = true |
| 71 | 72 | @AppStorage("showMarkup") private var showMarkup = false |
| 72 | 73 | @AppStorage("showLineNumbers") private var showLineNumbers = true |
| 73 | 74 | @AppStorage("showHiddenFiles") private var showHiddenFiles = true |
| @@ -172,7 +173,17 @@ struct ContentView: View { |
| 172 | 173 | if outlineShown { |
| 173 | 174 | Group { |
| 174 | 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 | 187 | } else { |
| 177 | 188 | SearchResults(results: workspace.search(query, overlay: workspace.overlay(session.openDocuments))) { location in |
| 178 | 189 | selectedFile = location.path |
| @@ -535,6 +546,7 @@ struct ContentView: View { |
| 535 | 546 | case "app.babel.cancel": |
| 536 | 547 | if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } |
| 537 | 548 | case "app.toggle-outline": toggleOutline() |
| 549 | case "app.toggle-backlinks": backlinksShown.toggle() |
| 538 | 550 | case "app.toggle-markup": showMarkup.toggle() |
| 539 | 551 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
| 540 | 552 | case "app.import-emacs": showingImport = true |
Sources/Orgstar/OrgstarApp.swift
+1
| @@ -349,6 +349,7 @@ struct OrgstarApp: App { |
| 349 | 349 | } |
| 350 | 350 | CommandGroup(after: .sidebar) { |
| 351 | 351 | Button("Show or Hide Outline") { commands.run("app.toggle-outline") } |
| 352 | Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } |
| 352 | 353 | .keyboardShortcut("o", modifiers: [.command, .option]) |
| 353 | 354 | MarkupToggle() |
| 354 | 355 | } |
Tests/OrgAppTests/AppTests.swift
+14
| @@ -74,6 +74,20 @@ struct WorkspaceModelTests { |
| 74 | 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 | 91 | @Test func createRenameAndTrashFiles() async throws { |
| 78 | 92 | let notes = try Folder() |
| 79 | 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 | } |