Sources/Orgstar/BacklinksPane.swift
72 lines · 2696 bytes
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}