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 | 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 | } |