Find a definition with the symbol index !162
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 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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 | ||
| 8 | final 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. | ||
| 149 | private 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. |
| 129 | nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] { | 165 | nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] { |
gitbay/Views/Repos/RepoRoute.swift +3 −1
| @@ -5,10 +5,12 @@ import Foundation | |||
| 5 | nonisolated enum RepoRoute: Hashable { | 5 | nonisolated 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 @@ | |||
| 1 | import 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. | ||
| 5 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private 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 | |||
| 15 | private func argv(_ url: URL) -> [String] { | ||
| 16 | URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? [] | ||
| 17 | } | ||
| 18 | |||
| 19 | @MainActor | ||
| 20 | struct 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 | |||
| 65 | struct 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 | } | ||