Find a definition with the symbol index !162

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

8 files changed, +261 −17

Layout: unified · split

gitbay/ContentView.swift +4 −2
@@ -297,14 +297,16 @@ private struct RouteDestinations: ViewModifier {
297 RepoView(client: client, path: path) 297 RepoView(client: client, path: path)
298 case .tree(let repo, let directory, let ref): 298 case .tree(let repo, let directory, let ref):
299 TreeView(client: client, repo: repo, directory: directory, ref: ref) 299 TreeView(client: client, repo: repo, directory: directory, ref: ref)
300 case .file(let repo, let path, let ref): 300 case .file(let repo, let path, let ref, let line):
301 FileView(client: client, repo: repo, path: path, ref: ref) 301 FileView(client: client, repo: repo, path: path, ref: ref, line: line)
302 case .log(let repo, let ref, let path): 302 case .log(let repo, let ref, let path):
303 LogView(client: client, repo: repo, ref: ref, path: path) 303 LogView(client: client, repo: repo, ref: ref, path: path)
304 case .settings(let repo): 304 case .settings(let repo):
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):
309 SymbolSearchView(client: client, repo: repo)
308 case .blame(let repo, let path, let ref): 310 case .blame(let repo, let path, let ref):
309 BlameView(client: client, repo: repo, path: path, ref: ref) 311 BlameView(client: client, repo: repo, path: path, ref: ref)
310 case .refs(let repo): 312 case .refs(let repo):
gitbay/Repos/SymbolSearchViewModel.swift added +49
@@ -0,0 +1,49 @@
1import Foundation
2import Observation
3
4/// `repo symbols <owner/name> [--kind <k>] <query>`: where names are
5/// defined, from the default branch's symbol index (gitbay v1.39.0).
6@Observable
7@MainActor
8final class SymbolSearchViewModel {
9
10 nonisolated struct Symbol: Decodable, Sendable, Hashable, Identifiable {
11 let name: String
12 let kind: String
13 let path: String
14 let line: Int
15 var id: String { "\(path):\(line):\(kind):\(name)" }
16 }
17
18 /// The kinds `--kind` takes, as the command's help lists them.
19 static let kinds = ["function", "method", "class", "struct", "enum", "interface",
20 "type", "const", "var", "module", "macro", "section"]
21
22 /// nil until the first search; distinct from an empty result.
23 private(set) var state: LoadState<[Symbol]>?
24 /// nil searches every kind.
25 var kind: String?
26
27 private let client: GitbayClient
28 let repoPath: String
29
30 init(client: GitbayClient, repoPath: String) {
31 self.client = client
32 self.repoPath = repoPath
33 }
34
35 func search(_ query: String) async {
36 let trimmed = query.trimmingCharacters(in: .whitespaces)
37 guard !trimmed.isEmpty else { return }
38 state = .loading
39 var argv = ["repo", "symbols", repoPath]
40 if let kind { argv += ["--kind", kind] }
41 argv.append(trimmed)
42 do {
43 let symbols = try await client.readList(argv, of: Symbol.self)
44 state = symbols.isEmpty ? .empty("No definitions matching \"\(trimmed)\".") : .loaded(symbols)
45 } catch {
46 state = .from(error)
47 }
48 }
49}
gitbay/Views/Discovery/GrepView.swift +1 −1
@@ -16,7 +16,7 @@ struct GrepView: View {
16 Section { 16 Section {
17 ForEach(group.matches) { match in 17 ForEach(group.matches) { match in
18 NavigationLink(value: RepoRoute.file( 18 NavigationLink(value: RepoRoute.file(
19 repo: model.repoPath, path: match.path, ref: nil 19 repo: model.repoPath, path: match.path, ref: nil, line: match.line
20 )) { 20 )) {
21 HStack(alignment: .firstTextBaseline, spacing: 8) { 21 HStack(alignment: .firstTextBaseline, spacing: 8) {
22 Text(String(match.line)) 22 Text(String(match.line))
gitbay/Views/Repos/FileView.swift +47 −11
@@ -6,11 +6,14 @@ struct FileView: View {
6 @State private var model: FileViewModel 6 @State private var model: FileViewModel
7 @State private var editing = false 7 @State private var editing = false
8 @State private var draft = "" 8 @State private var draft = ""
9 /// The line to open at, 1-based: a definition, or a search match.
10 private let line: Int?
9 11
10 init(client: GitbayClient, repo: String, path: String, ref: String?) { 12 init(client: GitbayClient, repo: String, path: String, ref: String?, line: Int? = nil) {
11 _model = State(initialValue: FileViewModel( 13 _model = State(initialValue: FileViewModel(
12 client: client, repoPath: repo, filePath: path, ref: ref 14 client: client, repoPath: repo, filePath: path, ref: ref
13 )) 15 ))
16 self.line = line
14 } 17 }
15 18
16 var body: some View { 19 var body: some View {
@@ -82,7 +85,7 @@ struct FileView: View {
82 .padding(.horizontal, 12) 85 .padding(.horizontal, 12)
83 .padding(.vertical, 6) 86 .padding(.vertical, 6)
84 } 87 }
85 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme) 88 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme, line: line)
86 } 89 }
87 } 90 }
88 } 91 }
@@ -96,22 +99,39 @@ private struct CodeScrollView: View {
96 let text: String 99 let text: String
97 let fileName: String 100 let fileName: String
98 let colorScheme: ColorScheme 101 let colorScheme: ColorScheme
102 var line: Int?
99 103
100 @State private var lines: [AttributedString] = [] 104 @State private var lines: [AttributedString] = []
101 105
102 var body: some View { 106 var body: some View {
103 ScrollView([.horizontal, .vertical]) { 107 ScrollViewReader { proxy in
104 LazyVStack(alignment: .leading, spacing: 0) { 108 ScrollView([.horizontal, .vertical]) {
105 ForEach(lines.indices, id: \.self) { index in 109 LazyVStack(alignment: .leading, spacing: 0) {
106 Text(lines[index].characters.isEmpty ? " " : lines[index]) 110 // The row's identity is its 0-based index, the one
107 .fixedSize() 111 // scrollTo names: a lazy row not yet drawn has no other.
112 ForEach(lines.indices, id: \.self) { index in
113 let target = index + 1 == line
114 Text(lines[index].characters.isEmpty ? " " : lines[index])
115 .fixedSize()
116 .frame(maxWidth: .infinity, alignment: .leading)
117 .background(target ? Color.gbMark.opacity(0.18) : .clear)
118 .modifier(TargetLine(on: target))
119 }
120 }
121 .padding(12)
122 }
123 .defaultScrollAnchor(.topLeading)
124 .font(.gbMono(.caption))
125 .task(id: colorScheme) {
126 lines = highlightedLines()
127 if let line, line <= lines.count {
128 // A frame for the highlighted lines to exist before the
129 // jump, or the lazy stack has nothing to scroll to.
130 await Task.yield()
131 proxy.scrollTo(line - 1, anchor: UnitPoint(x: 0, y: 0.3))
108 } 132 }
109 } 133 }
110 .padding(12)
111 } 134 }
112 .defaultScrollAnchor(.topLeading)
113 .font(.gbMono(.caption))
114 .task(id: colorScheme) { lines = highlightedLines() }
115 } 135 }
116 136
117 private func highlightedLines() -> [AttributedString] { 137 private func highlightedLines() -> [AttributedString] {
@@ -124,6 +144,22 @@ private struct CodeScrollView: View {
124 } 144 }
125} 145}
126 146
147/// The line a definition or a match opened at: named for VoiceOver and
148/// for UI tests, and left alone on every other row.
149private struct TargetLine: ViewModifier {
150 let on: Bool
151
152 func body(content: Content) -> some View {
153 if on {
154 content
155 .accessibilityIdentifier("file-target-line")
156 .accessibilityAddTraits(.isSelected)
157 } else {
158 content
159 }
160 }
161}
162
127/// A file's lines, each keeping its highlighting. A trailing newline ends 163/// A file's lines, each keeping its highlighting. A trailing newline ends
128/// the last line rather than starting an empty one. 164/// the last line rather than starting an empty one.
129nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] { 165nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] {
gitbay/Views/Repos/RepoRoute.swift +3 −1
@@ -5,10 +5,12 @@ import Foundation
5nonisolated enum RepoRoute: Hashable { 5nonisolated enum RepoRoute: Hashable {
6 case repo(String) 6 case repo(String)
7 case tree(repo: String, directory: String, ref: String?) 7 case tree(repo: String, directory: String, ref: String?)
8 case file(repo: String, path: String, ref: String?) 8 /// `line` opens the file scrolled to that line and marks it.
9 case file(repo: String, path: String, ref: String?, line: Int? = nil)
9 case log(repo: String, ref: String?, path: String?) 10 case log(repo: String, ref: String?, path: String?)
10 case settings(repo: String) 11 case settings(repo: String)
11 case grep(repo: String) 12 case grep(repo: String)
13 case symbols(repo: String)
12 case blame(repo: String, path: String, ref: String?) 14 case blame(repo: String, path: String, ref: String?)
13 case refs(repo: String) 15 case refs(repo: String)
14 case compare(repo: String, defaultBranch: String?) 16 case compare(repo: String, defaultBranch: String?)
gitbay/Views/Repos/RepoView.swift +6 −2
@@ -27,7 +27,7 @@ struct RepoView: View {
27 /// list row, and a link there claims the whole row and adds a chevron. 27 /// list row, and a link there claims the whole row and adds a chevron.
28 private enum Destination: Hashable { 28 private enum Destination: Hashable {
29 case files, mergeRequests, issues, builds, owner, settings 29 case files, mergeRequests, issues, builds, owner, settings
30 case history, refs, milestones, labels, releases, wiki, grep 30 case history, refs, milestones, labels, releases, wiki, grep, symbols
31 } 31 }
32 32
33 private func open(_ target: Destination) { 33 private func open(_ target: Destination) {
@@ -45,6 +45,7 @@ struct RepoView: View {
45 case .releases: pushRoute(ReleaseRoute.list(repo: path)) 45 case .releases: pushRoute(ReleaseRoute.list(repo: path))
46 case .wiki: pushRoute(RepoRoute.wiki(repo: path)) 46 case .wiki: pushRoute(RepoRoute.wiki(repo: path))
47 case .grep: pushRoute(RepoRoute.grep(repo: path)) 47 case .grep: pushRoute(RepoRoute.grep(repo: path))
48 case .symbols: pushRoute(RepoRoute.symbols(repo: path))
48 } 49 }
49 } 50 }
50 51
@@ -372,6 +373,7 @@ struct RepoView: View {
372 @ViewBuilder 373 @ViewBuilder
373 private var morePills: some View { 374 private var morePills: some View {
374 let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep) 375 let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep)
376 let symbols = pill("Definitions", systemImage: "function", to: .symbols)
375 if typeSize.isAccessibilitySize { 377 if typeSize.isAccessibilitySize {
376 VStack(spacing: 8) { 378 VStack(spacing: 8) {
377 pill("History", systemImage: "clock", to: .history) 379 pill("History", systemImage: "clock", to: .history)
@@ -381,6 +383,7 @@ struct RepoView: View {
381 pill("Releases", systemImage: "shippingbox", to: .releases) 383 pill("Releases", systemImage: "shippingbox", to: .releases)
382 pill("Wiki", systemImage: "book", to: .wiki) 384 pill("Wiki", systemImage: "book", to: .wiki)
383 search 385 search
386 symbols
384 } 387 }
385 } else { 388 } else {
386 Grid(horizontalSpacing: 8, verticalSpacing: 8) { 389 Grid(horizontalSpacing: 8, verticalSpacing: 8) {
@@ -397,7 +400,8 @@ struct RepoView: View {
397 pill("Wiki", systemImage: "book", to: .wiki) 400 pill("Wiki", systemImage: "book", to: .wiki)
398 } 401 }
399 GridRow { 402 GridRow {
400 search.gridCellColumns(2) 403 search
404 symbols
401 } 405 }
402 } 406 }
403 } 407 }
gitbay/Views/Repos/SymbolSearchView.swift added +78
@@ -0,0 +1,78 @@
1import SwiftUI
2
3/// Where a name is defined, from the repository's symbol index. A result
4/// opens the file at its line on the default branch, the one indexed.
5struct SymbolSearchView: View {
6
7 @State private var model: SymbolSearchViewModel
8 @State private var query = ""
9
10 init(client: GitbayClient, repo: String) {
11 _model = State(initialValue: SymbolSearchViewModel(client: client, repoPath: repo))
12 }
13
14 var body: some View {
15 List {
16 ForEach(model.state?.value ?? []) { symbol in
17 NavigationLink(value: RepoRoute.file(
18 repo: model.repoPath, path: symbol.path, ref: nil, line: symbol.line
19 )) {
20 VStack(alignment: .leading, spacing: 3) {
21 HStack(spacing: 6) {
22 Text(symbol.name)
23 .font(.gbMono(.subheadline).weight(.medium))
24 .lineLimit(1)
25 GBChip(symbol.kind, .secondary)
26 }
27 Text("\(symbol.path):\(symbol.line)")
28 .font(.gbMono(.caption))
29 .foregroundStyle(.secondary)
30 .lineLimit(1)
31 .truncationMode(.head)
32 }
33 .accessibilityElement(children: .combine)
34 }
35 .accessibilityIdentifier("symbol-\(symbol.name)")
36 }
37 }
38 .overlay {
39 if let state = model.state {
40 LoadStateOverlay(state: state)
41 } else {
42 ContentUnavailableView {
43 Label("Find a definition", systemImage: "function")
44 } description: {
45 Text("A name or its start, on the default branch.")
46 }
47 }
48 }
49 .searchable(text: $query, prompt: "Find a definition")
50 .onSubmit(of: .search) {
51 Task { await model.search(query) }
52 }
53 .toolbar {
54 ToolbarItem(placement: .topBarTrailing) {
55 Menu {
56 Picker("Kind", selection: Binding(get: { model.kind }, set: { model.kind = $0 })) {
57 Text("Any kind").tag(String?.none)
58 ForEach(SymbolSearchViewModel.kinds, id: \.self) { kind in
59 Text(kind).tag(String?.some(kind))
60 }
61 }
62 } label: {
63 Image(systemName: model.kind == nil
64 ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
65 }
66 .accessibilityLabel(model.kind.map { "Kind: \($0)" } ?? "Kind: any")
67 .accessibilityIdentifier("symbols-kind")
68 }
69 }
70 .onChange(of: model.kind) {
71 if !query.trimmingCharacters(in: .whitespaces).isEmpty {
72 Task { await model.search(query) }
73 }
74 }
75 .navigationTitle("Definitions")
76 .navigationBarTitleDisplayMode(.inline)
77 }
78}
gitbayTests/SymbolTests.swift added +73
@@ -0,0 +1,73 @@
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
19@MainActor
20struct SymbolSearchTests {
21
22 @Test func aQueryReadsTheSymbolIndex() async throws {
23 let (client, stub) = try makeClient()
24 stub.enqueue(.init(status: 200, json: """
25 {"protocol_version":1,"data":[\
26 {"name":"Dispatch","kind":"function","path":"internal/control/control.go","line":120},\
27 {"name":"DispatchIn","kind":"function","path":"internal/control/test.go","line":9}]}
28 """))
29 let model = SymbolSearchViewModel(client: client, repoPath: "krz/gitbay")
30
31 await model.search(" Dispatch ")
32
33 #expect(argv(try #require(stub.seen.first).url) == ["repo", "symbols", "krz/gitbay", "Dispatch"])
34 #expect(model.state?.value?.map(\.id) == ["internal/control/control.go:120:function:Dispatch", "internal/control/test.go:9:function:DispatchIn"])
35 }
36
37 @Test func aKindNarrowsTheSearch() async throws {
38 let (client, stub) = try makeClient()
39 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1}"#))
40 let model = SymbolSearchViewModel(client: client, repoPath: "krz/gitbay")
41 model.kind = "method"
42
43 await model.search("Ctx.")
44
45 #expect(argv(try #require(stub.seen.first).url) == ["repo", "symbols", "krz/gitbay", "--kind", "method", "Ctx."])
46 guard case .empty(let message) = model.state else { Issue.record("not empty: \(String(describing: model.state))"); return }
47 #expect(message == "No definitions matching \"Ctx.\".")
48 }
49
50 /// The index is built on push; a repository not pushed to since gitbay
51 /// v1.39.0 has none, and the server's not-found says so.
52 @Test func aRepositoryWithNoIndexShowsWhy() async throws {
53 let (client, stub) = try makeClient()
54 let why = "krz/old has no symbol index yet; one is built after a push to main"
55 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"\#(why)","exit_code":3}"#))
56 let model = SymbolSearchViewModel(client: client, repoPath: "krz/old")
57
58 await model.search("x")
59
60 guard case .empty(let message) = model.state else { Issue.record("not empty"); return }
61 #expect(message == why)
62 }
63}
64
65struct FileLineTests {
66
67 @Test func aFileRouteCarriesTheLineToOpenAt() {
68 let route = RepoRoute.file(repo: "krz/gitbay", path: "a.go", ref: nil, line: 12)
69 guard case .file(_, _, _, let line) = route else { Issue.record("not a file route"); return }
70 #expect(line == 12)
71 #expect(RepoRoute.file(repo: "krz/gitbay", path: "a.go", ref: nil) != route)
72 }
73}