Jump to a definition from a file !163

merged merged by cmc on 2026-10-14 06:11 UTC · krz/gitbay-ios:jump-to-definition into main

7 files changed, +196 −8

Layout: unified · split

gitbay/ContentView.swift +2 −2
@@ -305,8 +305,8 @@ private struct RouteDestinations: ViewModifier {
305 RepoSettingsView(client: client, repo: repo) 305 RepoSettingsView(client: client, repo: repo)
306 case .grep(let repo): 306 case .grep(let repo):
307 GrepView(client: client, repo: repo) 307 GrepView(client: client, repo: repo)
308 case .symbols(let repo): 308 case .symbols(let repo, let query):
309 SymbolSearchView(client: client, repo: repo) 309 SymbolSearchView(client: client, repo: repo, query: query)
310 case .blame(let repo, let path, let ref): 310 case .blame(let repo, let path, let ref):
311 BlameView(client: client, repo: repo, path: path, ref: ref) 311 BlameView(client: client, repo: repo, path: path, ref: ref)
312 case .refs(let repo): 312 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 +17 −1
@@ -10,6 +10,10 @@ final class FileViewModel {
10 private(set) var state: LoadState<FileContent> = .loading 10 private(set) var state: LoadState<FileContent> = .loading
11 private(set) var actionError: String? 11 private(set) var actionError: String?
12 private(set) var working = false 12 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] = [:]
13 17
14 private let client: GitbayClient 18 private let client: GitbayClient
15 let repoPath: String 19 let repoPath: String
@@ -29,12 +33,24 @@ final class FileViewModel {
29 33
30 func load() async { 34 func load() async {
31 var argv = ["repo", "cat", repoPath, filePath] 35 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 }
33 do { 41 do {
34 state = .loaded(try await client.read(argv, as: FileContent.self)) 42 state = .loaded(try await client.read(argv, as: FileContent.self))
35 } catch { 43 } catch {
36 state = .from(error) 44 state = .from(error)
45 return
37 } 46 }
47 // Prose is not code: a word in it that names a section or a
48 // function would link from every sentence it appears in.
49 guard MarkupPreview.format(forFile: fileName) == nil,
50 !["txt", "text"].contains((fileName as NSString).pathExtension.lowercased()) else { return }
51 // After the file, which draws first; the links follow.
52 let found = (try? await client.readList(defs, of: Definition.self)) ?? []
53 definitions = Dictionary(found.map { ($0.name, $0) }, uniquingKeysWith: { a, _ in a })
38 } 54 }
39 55
40 /// The branch an edit commits to. `repo cat` echoes the ref it read, 56 /// The branch an edit commits to. `repo cat` echoes the ref it read,
gitbay/Views/Repos/FileView.swift +23 −2
@@ -3,6 +3,7 @@ import SwiftUI
3struct FileView: View { 3struct FileView: View {
4 4
5 @Environment(\.colorScheme) private var colorScheme 5 @Environment(\.colorScheme) private var colorScheme
6 @Environment(\.pushRoute) private var pushRoute
6 @State private var model: FileViewModel 7 @State private var model: FileViewModel
7 @State private var editing = false 8 @State private var editing = false
8 @State private var draft = "" 9 @State private var draft = ""
@@ -85,7 +86,23 @@ struct FileView: View {
85 .padding(.horizontal, 12) 86 .padding(.horizontal, 12)
86 .padding(.vertical, 6) 87 .padding(.vertical, 6)
87 } 88 }
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 // repo symbols takes two characters at least, so a
98 // one-letter name defined more often opens its first.
99 if def.count == 1 || name.count < 2 {
100 pushRoute(RepoRoute.file(repo: model.repoPath, path: def.path, ref: model.ref, line: def.line))
101 } else {
102 pushRoute(RepoRoute.symbols(repo: model.repoPath, query: name))
103 }
104 return .handled
105 })
89 } 106 }
90 } 107 }
91 } 108 }
@@ -100,6 +117,7 @@ private struct CodeScrollView: View {
100 let fileName: String 117 let fileName: String
101 let colorScheme: ColorScheme 118 let colorScheme: ColorScheme
102 var line: Int? 119 var line: Int?
120 var definitions: [String: Definition] = [:]
103 121
104 @State private var lines: [AttributedString] = [] 122 @State private var lines: [AttributedString] = []
105 123
@@ -111,7 +129,10 @@ private struct CodeScrollView: View {
111 // scrollTo names: a lazy row not yet drawn has no other. 129 // scrollTo names: a lazy row not yet drawn has no other.
112 ForEach(lines.indices, id: \.self) { index in 130 ForEach(lines.indices, id: \.self) { index in
113 let target = index + 1 == line 131 let target = index + 1 == line
114 Text(lines[index].characters.isEmpty ? " " : lines[index]) 132 // Linked as the row is drawn, so a long file costs
133 // only the rows on screen.
134 let shown = definitions.isEmpty ? lines[index] : Definition.link(lines[index], definitions: definitions)
135 Text(shown.characters.isEmpty ? " " : shown)
115 .fixedSize() 136 .fixedSize()
116 .frame(maxWidth: .infinity, alignment: .leading) 137 .frame(maxWidth: .infinity, alignment: .leading)
117 .background(target ? Color.gbMark.opacity(0.18) : .clear) 138 .background(target ? Color.gbMark.opacity(0.18) : .clear)
gitbay/Views/Repos/RepoRoute.swift +2 −1
@@ -10,7 +10,8 @@ nonisolated enum RepoRoute: Hashable {
10 case log(repo: String, ref: String?, path: String?) 10 case log(repo: String, ref: String?, path: String?)
11 case settings(repo: String) 11 case settings(repo: String)
12 case grep(repo: String) 12 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)
14 case blame(repo: String, path: String, ref: String?) 15 case blame(repo: String, path: String, ref: String?)
15 case refs(repo: String) 16 case refs(repo: String)
16 case compare(repo: String, defaultBranch: String?) 17 case compare(repo: String, defaultBranch: String?)
gitbay/Views/Repos/SymbolSearchView.swift +8 −2
@@ -5,10 +5,13 @@ import SwiftUI
5struct SymbolSearchView: View { 5struct SymbolSearchView: View {
6 6
7 @State private var model: SymbolSearchViewModel 7 @State private var model: SymbolSearchViewModel
8 @State private var query = "" 8 @State private var query: String
9 private let initialQuery: String?
9 10
10 init(client: GitbayClient, repo: String) { 11 init(client: GitbayClient, repo: String, query: String? = nil) {
11 _model = State(initialValue: SymbolSearchViewModel(client: client, repoPath: repo)) 12 _model = State(initialValue: SymbolSearchViewModel(client: client, repoPath: repo))
13 _query = State(initialValue: query ?? "")
14 initialQuery = query
12 } 15 }
13 16
14 var body: some View { 17 var body: some View {
@@ -72,6 +75,9 @@ struct SymbolSearchView: View {
72 Task { await model.search(query) } 75 Task { await model.search(query) }
73 } 76 }
74 } 77 }
78 .task {
79 if let initialQuery, model.state == nil { await model.search(initialQuery) }
80 }
75 .navigationTitle("Definitions") 81 .navigationTitle("Definitions")
76 .navigationBarTitleDisplayMode(.inline) 82 .navigationBarTitleDisplayMode(.inline)
77 } 83 }
gitbayTests/DefinitionLinkTests.swift added +99
@@ -0,0 +1,99 @@
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
86 @Test func proseFilesAreNotLinked() async throws {
87 let (client, stub) = try makeClient()
88 stub.enqueue(.init(status: 200, json: """
89 {"protocol_version":1,"data":{"path":"krz/app","ref":"main","file":"README.md","size":10,\
90 "binary":false,"content":"# Parse\\n"},"exit_code":0}
91 """, match: "argv=cat"))
92 let model = FileViewModel(client: client, repoPath: "krz/app", filePath: "README.md", ref: "main")
93
94 await model.load()
95
96 #expect(model.state.value != nil)
97 #expect(!stub.seen.contains { $0.url.absoluteString.contains("argv=definitions") })
98 }
99}