Commit a615251f3a

a615251f3aea6399d959779287bef0b4fa294751

parent: 174b677412

Verified · cmc

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

Find a definition with the symbol index

Definitions, beside Search in Files on the repository page, reads repo
symbols for a name or its start, narrowed by kind from the toolbar. A
result opens the file on the default branch scrolled to the line and
marks it; a grep match now opens at its line the same way.

Closes #28

Layout: unified · split

gitbay/ContentView.swift +4 −2
@@ -297,14 +297,16 @@ private struct RouteDestinations: ViewModifier {
297297 RepoView(client: client, path: path)
298298 case .tree(let repo, let directory, let ref):
299299 TreeView(client: client, repo: repo, directory: directory, ref: ref)
300 case .file(let repo, let path, let ref):
301 FileView(client: client, repo: repo, path: path, ref: ref)
300 case .file(let repo, let path, let ref, let line):
301 FileView(client: client, repo: repo, path: path, ref: ref, line: line)
302302 case .log(let repo, let ref, let path):
303303 LogView(client: client, repo: repo, ref: ref, path: path)
304304 case .settings(let repo):
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)
308310 case .blame(let repo, let path, let ref):
309311 BlameView(client: client, repo: repo, path: path, ref: ref)
310312 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):\(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 {
1616 Section {
1717 ForEach(group.matches) { match in
1818 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
2020 )) {
2121 HStack(alignment: .firstTextBaseline, spacing: 8) {
2222 Text(String(match.line))
gitbay/Views/Repos/FileView.swift +28 −11
@@ -6,11 +6,14 @@ struct FileView: View {
66 @State private var model: FileViewModel
77 @State private var editing = false
88 @State private var draft = ""
9 /// The line to open at, 1-based: a definition, or a search match.
10 private let line: Int?
911
10 init(client: GitbayClient, repo: String, path: String, ref: String?) {
12 init(client: GitbayClient, repo: String, path: String, ref: String?, line: Int? = nil) {
1113 _model = State(initialValue: FileViewModel(
1214 client: client, repoPath: repo, filePath: path, ref: ref
1315 ))
16 self.line = line
1417 }
1518
1619 var body: some View {
@@ -82,7 +85,7 @@ struct FileView: View {
8285 .padding(.horizontal, 12)
8386 .padding(.vertical, 6)
8487 }
85 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme)
88 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme, line: line)
8689 }
8790 }
8891 }
@@ -96,22 +99,36 @@ private struct CodeScrollView: View {
9699 let text: String
97100 let fileName: String
98101 let colorScheme: ColorScheme
102 var line: Int?
99103
100104 @State private var lines: [AttributedString] = []
101105
102106 var body: some View {
103 ScrollView([.horizontal, .vertical]) {
104 LazyVStack(alignment: .leading, spacing: 0) {
105 ForEach(lines.indices, id: \.self) { index in
106 Text(lines[index].characters.isEmpty ? " " : lines[index])
107 .fixedSize()
107 ScrollViewReader { proxy in
108 ScrollView([.horizontal, .vertical]) {
109 LazyVStack(alignment: .leading, spacing: 0) {
110 ForEach(lines.indices, id: \.self) { index in
111 Text(lines[index].characters.isEmpty ? " " : lines[index])
112 .fixedSize()
113 .background(index + 1 == line ? Color.gbMark.opacity(0.18) : .clear)
114 .id(index + 1)
115 .accessibilityIdentifier(index + 1 == line ? "file-target-line" : "")
116 }
117 }
118 .padding(12)
119 }
120 .defaultScrollAnchor(.topLeading)
121 .font(.gbMono(.caption))
122 .task(id: colorScheme) {
123 lines = highlightedLines()
124 if let line, line <= lines.count {
125 // A frame for the highlighted lines to exist before the
126 // jump, or the lazy stack has nothing to scroll to.
127 await Task.yield()
128 proxy.scrollTo(line, anchor: UnitPoint(x: 0, y: 0.3))
108129 }
109130 }
110 .padding(12)
111131 }
112 .defaultScrollAnchor(.topLeading)
113 .font(.gbMono(.caption))
114 .task(id: colorScheme) { lines = highlightedLines() }
115132 }
116133
117134 private func highlightedLines() -> [AttributedString] {
gitbay/Views/Repos/RepoRoute.swift +3 −1
@@ -5,10 +5,12 @@ import Foundation
55nonisolated enum RepoRoute: Hashable {
66 case repo(String)
77 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)
910 case log(repo: String, ref: String?, path: String?)
1011 case settings(repo: String)
1112 case grep(repo: String)
13 case symbols(repo: String)
1214 case blame(repo: String, path: String, ref: String?)
1315 case refs(repo: String)
1416 case compare(repo: String, defaultBranch: String?)
gitbay/Views/Repos/RepoView.swift +6 −2
@@ -27,7 +27,7 @@ struct RepoView: View {
2727 /// list row, and a link there claims the whole row and adds a chevron.
2828 private enum Destination: Hashable {
2929 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
3131 }
3232
3333 private func open(_ target: Destination) {
@@ -45,6 +45,7 @@ struct RepoView: View {
4545 case .releases: pushRoute(ReleaseRoute.list(repo: path))
4646 case .wiki: pushRoute(RepoRoute.wiki(repo: path))
4747 case .grep: pushRoute(RepoRoute.grep(repo: path))
48 case .symbols: pushRoute(RepoRoute.symbols(repo: path))
4849 }
4950 }
5051
@@ -372,6 +373,7 @@ struct RepoView: View {
372373 @ViewBuilder
373374 private var morePills: some View {
374375 let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep)
376 let symbols = pill("Definitions", systemImage: "function", to: .symbols)
375377 if typeSize.isAccessibilitySize {
376378 VStack(spacing: 8) {
377379 pill("History", systemImage: "clock", to: .history)
@@ -381,6 +383,7 @@ struct RepoView: View {
381383 pill("Releases", systemImage: "shippingbox", to: .releases)
382384 pill("Wiki", systemImage: "book", to: .wiki)
383385 search
386 symbols
384387 }
385388 } else {
386389 Grid(horizontalSpacing: 8, verticalSpacing: 8) {
@@ -397,7 +400,8 @@ struct RepoView: View {
397400 pill("Wiki", systemImage: "book", to: .wiki)
398401 }
399402 GridRow {
400 search.gridCellColumns(2)
403 search
404 symbols
401405 }
402406 }
403407 }
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:Dispatch", "internal/control/test.go:9: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}