Commit a615251f3a
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +4 −2
| @@ -297,14 +297,16 @@ private struct RouteDestinations: ViewModifier { | ||
| 297 | 297 | RepoView(client: client, path: path) |
| 298 | 298 | case .tree(let repo, let directory, let ref): |
| 299 | 299 | 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) | |
| 302 | 302 | case .log(let repo, let ref, let path): |
| 303 | 303 | LogView(client: client, repo: repo, ref: ref, path: path) |
| 304 | 304 | case .settings(let repo): |
| 305 | 305 | RepoSettingsView(client: client, repo: repo) |
| 306 | 306 | case .grep(let repo): |
| 307 | 307 | GrepView(client: client, repo: repo) |
| 308 | case .symbols(let repo): | |
| 309 | SymbolSearchView(client: client, repo: repo) | |
| 308 | 310 | case .blame(let repo, let path, let ref): |
| 309 | 311 | BlameView(client: client, repo: repo, path: path, ref: ref) |
| 310 | 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):\(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 | 16 | Section { |
| 17 | 17 | ForEach(group.matches) { match in |
| 18 | 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 | 21 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 22 | 22 | Text(String(match.line)) |
gitbay/Views/Repos/FileView.swift +28 −11
| @@ -6,11 +6,14 @@ struct FileView: View { | ||
| 6 | 6 | @State private var model: FileViewModel |
| 7 | 7 | @State private var editing = false |
| 8 | 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 | 13 | _model = State(initialValue: FileViewModel( |
| 12 | 14 | client: client, repoPath: repo, filePath: path, ref: ref |
| 13 | 15 | )) |
| 16 | self.line = line | |
| 14 | 17 | } |
| 15 | 18 | |
| 16 | 19 | var body: some View { |
| @@ -82,7 +85,7 @@ struct FileView: View { | ||
| 82 | 85 | .padding(.horizontal, 12) |
| 83 | 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,36 @@ private struct CodeScrollView: View { | ||
| 96 | 99 | let text: String |
| 97 | 100 | let fileName: String |
| 98 | 101 | let colorScheme: ColorScheme |
| 102 | var line: Int? | |
| 99 | 103 | |
| 100 | 104 | @State private var lines: [AttributedString] = [] |
| 101 | 105 | |
| 102 | 106 | 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)) | |
| 108 | 129 | } |
| 109 | 130 | } |
| 110 | .padding(12) | |
| 111 | 131 | } |
| 112 | .defaultScrollAnchor(.topLeading) | |
| 113 | .font(.gbMono(.caption)) | |
| 114 | .task(id: colorScheme) { lines = highlightedLines() } | |
| 115 | 132 | } |
| 116 | 133 | |
| 117 | 134 | private func highlightedLines() -> [AttributedString] { |
gitbay/Views/Repos/RepoRoute.swift +3 −1
| @@ -5,10 +5,12 @@ import Foundation | ||
| 5 | 5 | nonisolated enum RepoRoute: Hashable { |
| 6 | 6 | case repo(String) |
| 7 | 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 | 10 | case log(repo: String, ref: String?, path: String?) |
| 10 | 11 | case settings(repo: String) |
| 11 | 12 | case grep(repo: String) |
| 13 | case symbols(repo: String) | |
| 12 | 14 | case blame(repo: String, path: String, ref: String?) |
| 13 | 15 | case refs(repo: String) |
| 14 | 16 | case compare(repo: String, defaultBranch: String?) |
gitbay/Views/Repos/RepoView.swift +6 −2
| @@ -27,7 +27,7 @@ struct RepoView: View { | ||
| 27 | 27 | /// list row, and a link there claims the whole row and adds a chevron. |
| 28 | 28 | private enum Destination: Hashable { |
| 29 | 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 | 33 | private func open(_ target: Destination) { |
| @@ -45,6 +45,7 @@ struct RepoView: View { | ||
| 45 | 45 | case .releases: pushRoute(ReleaseRoute.list(repo: path)) |
| 46 | 46 | case .wiki: pushRoute(RepoRoute.wiki(repo: path)) |
| 47 | 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 | 373 | @ViewBuilder |
| 373 | 374 | private var morePills: some View { |
| 374 | 375 | let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep) |
| 376 | let symbols = pill("Definitions", systemImage: "function", to: .symbols) | |
| 375 | 377 | if typeSize.isAccessibilitySize { |
| 376 | 378 | VStack(spacing: 8) { |
| 377 | 379 | pill("History", systemImage: "clock", to: .history) |
| @@ -381,6 +383,7 @@ struct RepoView: View { | ||
| 381 | 383 | pill("Releases", systemImage: "shippingbox", to: .releases) |
| 382 | 384 | pill("Wiki", systemImage: "book", to: .wiki) |
| 383 | 385 | search |
| 386 | symbols | |
| 384 | 387 | } |
| 385 | 388 | } else { |
| 386 | 389 | Grid(horizontalSpacing: 8, verticalSpacing: 8) { |
| @@ -397,7 +400,8 @@ struct RepoView: View { | ||
| 397 | 400 | pill("Wiki", systemImage: "book", to: .wiki) |
| 398 | 401 | } |
| 399 | 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: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 | ||
| 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 | } | |