Commit 9b8ed56d54

9b8ed56d5410ce1774459b55931bb5ff10a0e59f

parent: 54f83256b5

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 05:53 UTC

Jump to a definition from a file

The file view reads repo definitions after the file and links each name
the index defines, keeping its highlighting with a dotted underline as
the web does. A name defined once opens that line; one defined more
often opens Definitions with the search run. A server without the
command, or a ref that is not indexed, leaves the file as it was.

Closes #29

Layout: unified · split

gitbay/ContentView.swift +2 −2
@@ -305,8 +305,8 @@ private struct RouteDestinations: ViewModifier {
305305 RepoSettingsView(client: client, repo: repo)
306306 case .grep(let repo):
307307 GrepView(client: client, repo: repo)
308 case .symbols(let repo):
309 SymbolSearchView(client: client, repo: repo)
308 case .symbols(let repo, let query):
309 SymbolSearchView(client: client, repo: repo, query: query)
310310 case .blame(let repo, let path, let ref):
311311 BlameView(client: client, repo: repo, path: path, ref: ref)
312312 case .refs(let repo):
gitbay/Repos/Definition.swift added +45
@@ -0,0 +1,45 @@
1import Foundation
2import SwiftUI
3
4/// Where a name a file uses is defined, from `repo definitions`
5/// (krz/gitbay#333): the first definition, and how many there are. A name defined
6/// once links to its line; one defined more often to a search.
7nonisolated struct Definition: Decodable, Sendable, Hashable {
8 let name: String
9 let count: Int
10 let path: String
11 let line: Int
12
13 private static let scheme = "gitbay-definition"
14 /// The index's names, as the server matches them.
15 nonisolated(unsafe) private static let identifier = /[\p{L}_$][\p{L}\p{N}_$]*/
16
17 /// `line` with each defined name marked as a link the file view opens.
18 /// The highlighting stays; names the index does not define stay plain.
19 static func link(_ line: AttributedString, definitions: [String: Definition]) -> AttributedString {
20 guard !definitions.isEmpty else { return line }
21 var out = line
22 let text = String(line.characters)
23 for match in text.matches(of: identifier) {
24 let name = String(match.output)
25 guard definitions[name] != nil,
26 let lower = AttributedString.Index(match.range.lowerBound, within: out),
27 let upper = AttributedString.Index(match.range.upperBound, within: out) else { continue }
28 var components = URLComponents()
29 components.scheme = scheme
30 components.host = "name"
31 components.path = "/" + name
32 out[lower..<upper].link = components.url
33 out[lower..<upper].underlineStyle = .init(pattern: .dot)
34 }
35 return out
36 }
37
38 /// The name a link from `link(_:definitions:)` carries; nil for any
39 /// other URL.
40 static func name(in url: URL) -> String? {
41 guard url.scheme == scheme else { return nil }
42 let path = url.path(percentEncoded: false)
43 return path.hasPrefix("/") ? String(path.dropFirst()) : nil
44 }
45}
gitbay/Repos/FileViewModel.swift +13 −1
@@ -10,6 +10,10 @@ final class FileViewModel {
1010 private(set) var state: LoadState<FileContent> = .loading
1111 private(set) var actionError: String?
1212 private(set) var working = false
13 /// The names the file uses that the symbol index defines, by name.
14 /// Empty when the server has no `repo definitions`, the ref is not the
15 /// indexed one, or the repository has no index: the file stays plain.
16 private(set) var definitions: [String: Definition] = [:]
1317
1418 private let client: GitbayClient
1519 let repoPath: String
@@ -29,12 +33,20 @@ final class FileViewModel {
2933
3034 func load() async {
3135 var argv = ["repo", "cat", repoPath, filePath]
32 if let ref { argv.append(contentsOf: ["--ref", ref]) }
36 var defs = ["repo", "definitions", repoPath, filePath]
37 if let ref {
38 argv.append(contentsOf: ["--ref", ref])
39 defs.append(contentsOf: ["--ref", ref])
40 }
3341 do {
3442 state = .loaded(try await client.read(argv, as: FileContent.self))
3543 } catch {
3644 state = .from(error)
45 return
3746 }
47 // After the file, which draws first; the links follow.
48 let found = (try? await client.readList(defs, of: Definition.self)) ?? []
49 definitions = Dictionary(found.map { ($0.name, $0) }, uniquingKeysWith: { a, _ in a })
3850 }
3951
4052 /// The branch an edit commits to. `repo cat` echoes the ref it read,
gitbay/Views/Repos/FileView.swift +26 −2
@@ -3,6 +3,7 @@ import SwiftUI
33struct FileView: View {
44
55 @Environment(\.colorScheme) private var colorScheme
6 @Environment(\.pushRoute) private var pushRoute
67 @State private var model: FileViewModel
78 @State private var editing = false
89 @State private var draft = ""
@@ -85,7 +86,21 @@ struct FileView: View {
8586 .padding(.horizontal, 12)
8687 .padding(.vertical, 6)
8788 }
88 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme, line: line)
89 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme, line: line,
90 definitions: model.definitions)
91 .environment(\.openURL, OpenURLAction { url in
92 guard let name = Definition.name(in: url), let def = model.definitions[name] else {
93 return .systemAction
94 }
95 // Defined once: that line, at the ref on screen, as the
96 // web links it. More often: the search, to choose.
97 if def.count == 1 {
98 pushRoute(RepoRoute.file(repo: model.repoPath, path: def.path, ref: model.ref, line: def.line))
99 } else {
100 pushRoute(RepoRoute.symbols(repo: model.repoPath, query: name))
101 }
102 return .handled
103 })
89104 }
90105 }
91106 }
@@ -100,7 +115,9 @@ private struct CodeScrollView: View {
100115 let fileName: String
101116 let colorScheme: ColorScheme
102117 var line: Int?
118 var definitions: [String: Definition] = [:]
103119
120 @State private var highlighted: [AttributedString] = []
104121 @State private var lines: [AttributedString] = []
105122
106123 var body: some View {
@@ -123,7 +140,8 @@ private struct CodeScrollView: View {
123140 .defaultScrollAnchor(.topLeading)
124141 .font(.gbMono(.caption))
125142 .task(id: colorScheme) {
126 lines = highlightedLines()
143 highlighted = highlightedLines()
144 lines = linked(highlighted)
127145 if let line, line <= lines.count {
128146 // A frame for the highlighted lines to exist before the
129147 // jump, or the lazy stack has nothing to scroll to.
@@ -131,9 +149,15 @@ private struct CodeScrollView: View {
131149 proxy.scrollTo(line - 1, anchor: UnitPoint(x: 0, y: 0.3))
132150 }
133151 }
152 // The definitions arrive after the file; link what is drawn.
153 .onChange(of: definitions) { lines = linked(highlighted) }
134154 }
135155 }
136156
157 private func linked(_ lines: [AttributedString]) -> [AttributedString] {
158 definitions.isEmpty ? lines : lines.map { Definition.link($0, definitions: definitions) }
159 }
160
137161 private func highlightedLines() -> [AttributedString] {
138162 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
139163 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
gitbay/Views/Repos/RepoRoute.swift +2 −1
@@ -10,7 +10,8 @@ nonisolated enum RepoRoute: Hashable {
1010 case log(repo: String, ref: String?, path: String?)
1111 case settings(repo: String)
1212 case grep(repo: String)
13 case symbols(repo: String)
13 /// `query` opens with that search run, for a name defined more than once.
14 case symbols(repo: String, query: String? = nil)
1415 case blame(repo: String, path: String, ref: String?)
1516 case refs(repo: String)
1617 case compare(repo: String, defaultBranch: String?)
gitbay/Views/Repos/SymbolSearchView.swift +8 −2
@@ -5,10 +5,13 @@ import SwiftUI
55struct SymbolSearchView: View {
66
77 @State private var model: SymbolSearchViewModel
8 @State private var query = ""
8 @State private var query: String
9 private let initialQuery: String?
910
10 init(client: GitbayClient, repo: String) {
11 init(client: GitbayClient, repo: String, query: String? = nil) {
1112 _model = State(initialValue: SymbolSearchViewModel(client: client, repoPath: repo))
13 _query = State(initialValue: query ?? "")
14 initialQuery = query
1215 }
1316
1417 var body: some View {
@@ -72,6 +75,9 @@ struct SymbolSearchView: View {
7275 Task { await model.search(query) }
7376 }
7477 }
78 .task {
79 if let initialQuery, model.state == nil { await model.search(initialQuery) }
80 }
7581 .navigationTitle("Definitions")
7682 .navigationBarTitleDisplayMode(.inline)
7783 }
gitbayTests/DefinitionLinkTests.swift added +85
@@ -0,0 +1,85 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argv(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? []
17}
18
19struct DefinitionLinkTests {
20
21 private let defs: [String: Definition] = [
22 "Parse": Definition(name: "Parse", count: 1, path: "lib.go", line: 3),
23 "Run": Definition(name: "Run", count: 2, path: "lib.go", line: 4),
24 ]
25
26 @Test func definedNamesLinkAndOthersStayPlain() {
27 let line = Definition.link(AttributedString("\tParse(); Parser(); Run(x)"), definitions: defs)
28
29 let linked = line.runs.compactMap { run -> String? in
30 run.link.map { _ in String(line[run.range].characters) }
31 }
32 // Parser is a different name, not Parse with a tail.
33 #expect(linked == ["Parse", "Run"])
34 #expect(line.runs.compactMap(\.link).map { Definition.name(in: $0) } == ["Parse", "Run"])
35 }
36
37 @Test func aLineWithNoDefinitionsIsUnchanged() {
38 let text = AttributedString("// nothing here")
39 #expect(Definition.link(text, definitions: defs) == text)
40 #expect(Definition.link(AttributedString("Parse"), definitions: [:]) == AttributedString("Parse"))
41 }
42
43 @Test func aDefinitionURLIsOnlyOurs() {
44 #expect(Definition.name(in: URL(string: "https://gitbay.org/x")!) == nil)
45 }
46}
47
48@MainActor
49struct FileDefinitionsTests {
50
51 @Test func theFileReadsTheDefinitionsItUses() async throws {
52 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: """
54 {"protocol_version":1,"data":{"path":"krz/app","ref":"main","file":"use.go","size":10,\
55 "binary":false,"content":"Parse()\\n"},"exit_code":0}
56 """, match: "argv=cat"))
57 stub.enqueue(.init(status: 200, json: """
58 {"protocol_version":1,"data":[{"name":"Parse","count":1,"path":"lib.go","line":3}],"exit_code":0}
59 """, match: "argv=definitions"))
60 let model = FileViewModel(client: client, repoPath: "krz/app", filePath: "use.go", ref: "main")
61
62 await model.load()
63
64 let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=definitions") })
65 #expect(argv(read.url) == ["repo", "definitions", "krz/app", "use.go", "--ref", "main"])
66 #expect(model.definitions["Parse"]?.line == 3)
67 }
68
69 /// A server without the command, or a ref that is not indexed, leaves
70 /// the file plain rather than failing it.
71 @Test func noDefinitionsLeaveTheFileReadable() async throws {
72 let (client, stub) = try makeClient()
73 stub.enqueue(.init(status: 200, json: """
74 {"protocol_version":1,"data":{"path":"krz/app","ref":"dev","file":"use.go","size":10,\
75 "binary":false,"content":"Parse()\\n"},"exit_code":0}
76 """, match: "argv=cat"))
77 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"only the default branch is indexed","exit_code":3}"#, match: "argv=definitions"))
78 let model = FileViewModel(client: client, repoPath: "krz/app", filePath: "use.go", ref: "dev")
79
80 await model.load()
81
82 #expect(model.state.value?.content == "Parse()\n")
83 #expect(model.definitions.isEmpty)
84 }
85}