Commit 9b8ed56d54
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +2 −2
| @@ -305,8 +305,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 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 | case .symbols(let repo, let query): | |
| 309 | SymbolSearchView(client: client, repo: repo, query: query) | |
| 310 | 310 | case .blame(let repo, let path, let ref): |
| 311 | 311 | BlameView(client: client, repo: repo, path: path, ref: ref) |
| 312 | 312 | case .refs(let repo): |
gitbay/Repos/Definition.swift added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 7 | nonisolated 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 +13 −1
| @@ -10,6 +10,10 @@ final class FileViewModel { | ||
| 10 | 10 | private(set) var state: LoadState<FileContent> = .loading |
| 11 | 11 | private(set) var actionError: String? |
| 12 | 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 | 18 | private let client: GitbayClient |
| 15 | 19 | let repoPath: String |
| @@ -29,12 +33,20 @@ final class FileViewModel { | ||
| 29 | 33 | |
| 30 | 34 | func load() async { |
| 31 | 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 | 41 | do { |
| 34 | 42 | state = .loaded(try await client.read(argv, as: FileContent.self)) |
| 35 | 43 | } catch { |
| 36 | 44 | state = .from(error) |
| 45 | return | |
| 37 | 46 | } |
| 47 | // After the file, which draws first; the links follow. | |
| 48 | let found = (try? await client.readList(defs, of: Definition.self)) ?? [] | |
| 49 | definitions = Dictionary(found.map { ($0.name, $0) }, uniquingKeysWith: { a, _ in a }) | |
| 38 | 50 | } |
| 39 | 51 | |
| 40 | 52 | /// The branch an edit commits to. `repo cat` echoes the ref it read, |
gitbay/Views/Repos/FileView.swift +26 −2
| @@ -3,6 +3,7 @@ import SwiftUI | ||
| 3 | 3 | struct FileView: View { |
| 4 | 4 | |
| 5 | 5 | @Environment(\.colorScheme) private var colorScheme |
| 6 | @Environment(\.pushRoute) private var pushRoute | |
| 6 | 7 | @State private var model: FileViewModel |
| 7 | 8 | @State private var editing = false |
| 8 | 9 | @State private var draft = "" |
| @@ -85,7 +86,21 @@ struct FileView: View { | ||
| 85 | 86 | .padding(.horizontal, 12) |
| 86 | 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 | if def.count == 1 { | |
| 98 | pushRoute(RepoRoute.file(repo: model.repoPath, path: def.path, ref: model.ref, line: def.line)) | |
| 99 | } else { | |
| 100 | pushRoute(RepoRoute.symbols(repo: model.repoPath, query: name)) | |
| 101 | } | |
| 102 | return .handled | |
| 103 | }) | |
| 89 | 104 | } |
| 90 | 105 | } |
| 91 | 106 | } |
| @@ -100,7 +115,9 @@ private struct CodeScrollView: View { | ||
| 100 | 115 | let fileName: String |
| 101 | 116 | let colorScheme: ColorScheme |
| 102 | 117 | var line: Int? |
| 118 | var definitions: [String: Definition] = [:] | |
| 103 | 119 | |
| 120 | @State private var highlighted: [AttributedString] = [] | |
| 104 | 121 | @State private var lines: [AttributedString] = [] |
| 105 | 122 | |
| 106 | 123 | var body: some View { |
| @@ -123,7 +140,8 @@ private struct CodeScrollView: View { | ||
| 123 | 140 | .defaultScrollAnchor(.topLeading) |
| 124 | 141 | .font(.gbMono(.caption)) |
| 125 | 142 | .task(id: colorScheme) { |
| 126 | lines = highlightedLines() | |
| 143 | highlighted = highlightedLines() | |
| 144 | lines = linked(highlighted) | |
| 127 | 145 | if let line, line <= lines.count { |
| 128 | 146 | // A frame for the highlighted lines to exist before the |
| 129 | 147 | // jump, or the lazy stack has nothing to scroll to. |
| @@ -131,9 +149,15 @@ private struct CodeScrollView: View { | ||
| 131 | 149 | proxy.scrollTo(line - 1, anchor: UnitPoint(x: 0, y: 0.3)) |
| 132 | 150 | } |
| 133 | 151 | } |
| 152 | // The definitions arrive after the file; link what is drawn. | |
| 153 | .onChange(of: definitions) { lines = linked(highlighted) } | |
| 134 | 154 | } |
| 135 | 155 | } |
| 136 | 156 | |
| 157 | private func linked(_ lines: [AttributedString]) -> [AttributedString] { | |
| 158 | definitions.isEmpty ? lines : lines.map { Definition.link($0, definitions: definitions) } | |
| 159 | } | |
| 160 | ||
| 137 | 161 | private func highlightedLines() -> [AttributedString] { |
| 138 | 162 | let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize |
| 139 | 163 | let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) |
gitbay/Views/Repos/RepoRoute.swift +2 −1
| @@ -10,7 +10,8 @@ nonisolated enum RepoRoute: Hashable { | ||
| 10 | 10 | case log(repo: String, ref: String?, path: String?) |
| 11 | 11 | case settings(repo: String) |
| 12 | 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 | 15 | case blame(repo: String, path: String, ref: String?) |
| 15 | 16 | case refs(repo: String) |
| 16 | 17 | case compare(repo: String, defaultBranch: String?) |
gitbay/Views/Repos/SymbolSearchView.swift +8 −2
| @@ -5,10 +5,13 @@ import SwiftUI | ||
| 5 | 5 | struct SymbolSearchView: View { |
| 6 | 6 | |
| 7 | 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 | 12 | _model = State(initialValue: SymbolSearchViewModel(client: client, repoPath: repo)) |
| 13 | _query = State(initialValue: query ?? "") | |
| 14 | initialQuery = query | |
| 12 | 15 | } |
| 13 | 16 | |
| 14 | 17 | var body: some View { |
| @@ -72,6 +75,9 @@ struct SymbolSearchView: View { | ||
| 72 | 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 | 81 | .navigationTitle("Definitions") |
| 76 | 82 | .navigationBarTitleDisplayMode(.inline) |
| 77 | 83 | } |
gitbayTests/DefinitionLinkTests.swift added +85
| @@ -0,0 +1,85 @@ | ||
| 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 | struct 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 | |
| 49 | struct 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 | } | |