Commit 9b8ed56d54
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +2 −2
| @@ -305,8 +305,8 @@ private struct RouteDestinations: ViewModifier { | |||
| 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): | 308 | case .symbols(let repo, let query): |
| 309 | SymbolSearchView(client: client, repo: repo) | 309 | SymbolSearchView(client: client, repo: repo, query: query) |
| 310 | case .blame(let repo, let path, let ref): | 310 | case .blame(let repo, let path, let ref): |
| 311 | BlameView(client: client, repo: repo, path: path, ref: ref) | 311 | BlameView(client: client, repo: repo, path: path, ref: ref) |
| 312 | case .refs(let repo): | 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 | private(set) var state: LoadState<FileContent> = .loading | 10 | private(set) var state: LoadState<FileContent> = .loading |
| 11 | private(set) var actionError: String? | 11 | private(set) var actionError: String? |
| 12 | private(set) var working = false | 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 | private let client: GitbayClient | 18 | private let client: GitbayClient |
| 15 | let repoPath: String | 19 | let repoPath: String |
| @@ -29,12 +33,20 @@ final class FileViewModel { | |||
| 29 | 33 | ||
| 30 | func load() async { | 34 | func load() async { |
| 31 | var argv = ["repo", "cat", repoPath, filePath] | 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 | do { | 41 | do { |
| 34 | state = .loaded(try await client.read(argv, as: FileContent.self)) | 42 | state = .loaded(try await client.read(argv, as: FileContent.self)) |
| 35 | } catch { | 43 | } catch { |
| 36 | state = .from(error) | 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 | /// The branch an edit commits to. `repo cat` echoes the ref it read, | 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 | struct FileView: View { | 3 | struct FileView: View { |
| 4 | 4 | ||
| 5 | @Environment(\.colorScheme) private var colorScheme | 5 | @Environment(\.colorScheme) private var colorScheme |
| 6 | @Environment(\.pushRoute) private var pushRoute | ||
| 6 | @State private var model: FileViewModel | 7 | @State private var model: FileViewModel |
| 7 | @State private var editing = false | 8 | @State private var editing = false |
| 8 | @State private var draft = "" | 9 | @State private var draft = "" |
| @@ -85,7 +86,21 @@ struct FileView: View { | |||
| 85 | .padding(.horizontal, 12) | 86 | .padding(.horizontal, 12) |
| 86 | .padding(.vertical, 6) | 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 | let fileName: String | 115 | let fileName: String |
| 101 | let colorScheme: ColorScheme | 116 | let colorScheme: ColorScheme |
| 102 | var line: Int? | 117 | var line: Int? |
| 118 | var definitions: [String: Definition] = [:] | ||
| 103 | 119 | ||
| 120 | @State private var highlighted: [AttributedString] = [] | ||
| 104 | @State private var lines: [AttributedString] = [] | 121 | @State private var lines: [AttributedString] = [] |
| 105 | 122 | ||
| 106 | var body: some View { | 123 | var body: some View { |
| @@ -123,7 +140,8 @@ private struct CodeScrollView: View { | |||
| 123 | .defaultScrollAnchor(.topLeading) | 140 | .defaultScrollAnchor(.topLeading) |
| 124 | .font(.gbMono(.caption)) | 141 | .font(.gbMono(.caption)) |
| 125 | .task(id: colorScheme) { | 142 | .task(id: colorScheme) { |
| 126 | lines = highlightedLines() | 143 | highlighted = highlightedLines() |
| 144 | lines = linked(highlighted) | ||
| 127 | if let line, line <= lines.count { | 145 | if let line, line <= lines.count { |
| 128 | // A frame for the highlighted lines to exist before the | 146 | // A frame for the highlighted lines to exist before the |
| 129 | // jump, or the lazy stack has nothing to scroll to. | 147 | // jump, or the lazy stack has nothing to scroll to. |
| @@ -131,9 +149,15 @@ private struct CodeScrollView: View { | |||
| 131 | proxy.scrollTo(line - 1, anchor: UnitPoint(x: 0, y: 0.3)) | 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 | private func highlightedLines() -> [AttributedString] { | 161 | private func highlightedLines() -> [AttributedString] { |
| 138 | let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize | 162 | let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize |
| 139 | let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) | 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 | case log(repo: String, ref: String?, path: String?) | 10 | case log(repo: String, ref: String?, path: String?) |
| 11 | case settings(repo: String) | 11 | case settings(repo: String) |
| 12 | case grep(repo: String) | 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 | case blame(repo: String, path: String, ref: String?) | 15 | case blame(repo: String, path: String, ref: String?) |
| 15 | case refs(repo: String) | 16 | case refs(repo: String) |
| 16 | case compare(repo: String, defaultBranch: String?) | 17 | case compare(repo: String, defaultBranch: String?) |
gitbay/Views/Repos/SymbolSearchView.swift +8 −2
| @@ -5,10 +5,13 @@ import SwiftUI | |||
| 5 | struct SymbolSearchView: View { | 5 | struct SymbolSearchView: View { |
| 6 | 6 | ||
| 7 | @State private var model: SymbolSearchViewModel | 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 | _model = State(initialValue: SymbolSearchViewModel(client: client, repoPath: repo)) | 12 | _model = State(initialValue: SymbolSearchViewModel(client: client, repoPath: repo)) |
| 13 | _query = State(initialValue: query ?? "") | ||
| 14 | initialQuery = query | ||
| 12 | } | 15 | } |
| 13 | 16 | ||
| 14 | var body: some View { | 17 | var body: some View { |
| @@ -72,6 +75,9 @@ struct SymbolSearchView: View { | |||
| 72 | Task { await model.search(query) } | 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 | .navigationTitle("Definitions") | 81 | .navigationTitle("Definitions") |
| 76 | .navigationBarTitleDisplayMode(.inline) | 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 | } | ||