Commit 9b5c36e88f
Verified · cmc
gitbay/ContentView.swift +8 −2
| @@ -25,6 +25,12 @@ struct ContentView: View { | ||
| 25 | 25 | .navigationDestinations(client: client) |
| 26 | 26 | } |
| 27 | 27 | } |
| 28 | Tab("Explore", systemImage: "safari") { | |
| 29 | NavigationStack { | |
| 30 | ExploreView(client: client) | |
| 31 | .navigationDestinations(client: client) | |
| 32 | } | |
| 33 | } | |
| 28 | 34 | } |
| 29 | 35 | .id(account.id) // fresh screens on account switch |
| 30 | 36 | } else { |
| @@ -72,8 +78,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 72 | 78 | TreeView(client: client, repo: repo, directory: directory, ref: ref) |
| 73 | 79 | case .file(let repo, let path, let ref): |
| 74 | 80 | FileView(client: client, repo: repo, path: path, ref: ref) |
| 75 | case .log(let repo): | |
| 76 | LogView(client: client, repo: repo) | |
| 81 | case .log(let repo, let ref, let path): | |
| 82 | LogView(client: client, repo: repo, ref: ref, path: path) | |
| 77 | 83 | case .settings(let repo): |
| 78 | 84 | RepoSettingsView(client: client, repo: repo) |
| 79 | 85 | case .grep(let repo): |
gitbay/Discovery/ExploreViewModel.swift added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One row of `explore` — a public repository on this instance. | |
| 4 | nonisolated struct PublicRepo: Decodable, Sendable, Hashable, Identifiable { | |
| 5 | let path: String | |
| 6 | let description: String? | |
| 7 | let archived: Bool? | |
| 8 | let topics: [String]? | |
| 9 | ||
| 10 | var id: String { path } | |
| 11 | var isArchived: Bool { archived ?? false } | |
| 12 | var name: String { String(path.split(separator: "/").last ?? "") } | |
| 13 | var owner: String { String(path.split(separator: "/").first ?? "") } | |
| 14 | } | |
gitbay/Repos/LogViewModel.swift +11 −2
| @@ -10,15 +10,24 @@ final class LogViewModel { | ||
| 10 | 10 | |
| 11 | 11 | private let client: GitbayClient |
| 12 | 12 | let repoPath: String |
| 13 | /// nil reads the default branch, as the command does. | |
| 14 | let ref: String? | |
| 15 | /// Set to follow one file's history, as the blob page does. | |
| 16 | let path: String? | |
| 13 | 17 | |
| 14 | init(client: GitbayClient, repoPath: String) { | |
| 18 | init(client: GitbayClient, repoPath: String, ref: String? = nil, path: String? = nil) { | |
| 15 | 19 | self.client = client |
| 16 | 20 | self.repoPath = repoPath |
| 21 | self.ref = ref | |
| 22 | self.path = path | |
| 17 | 23 | } |
| 18 | 24 | |
| 19 | 25 | func load() async { |
| 26 | var argv = ["repo", "log", repoPath] | |
| 27 | if let ref { argv.append(contentsOf: ["--ref", ref]) } | |
| 28 | if let path { argv.append(contentsOf: ["--path", path]) } | |
| 20 | 29 | do { |
| 21 | let commits = try await client.readList(["repo", "log", repoPath], of: Commit.self) | |
| 30 | let commits = try await client.readList(argv, of: Commit.self) | |
| 22 | 31 | state = commits.isEmpty ? .empty("No commits yet.") : .loaded(commits) |
| 23 | 32 | } catch { |
| 24 | 33 | state = .from(error) |
gitbay/Views/Discovery/ExploreView.swift added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// What this instance hosts. `repo search` needs a query; this is the | |
| 4 | /// listing you read when you do not know a name yet. | |
| 5 | struct ExploreView: View { | |
| 6 | ||
| 7 | @State private var list: PagedListModel<PublicRepo> | |
| 8 | ||
| 9 | init(client: GitbayClient) { | |
| 10 | _list = State(initialValue: PagedListModel( | |
| 11 | client: client, | |
| 12 | argv: ["explore"], | |
| 13 | emptyMessage: "This instance hosts no public repositories." | |
| 14 | )) | |
| 15 | } | |
| 16 | ||
| 17 | var body: some View { | |
| 18 | List { | |
| 19 | ForEach(list.state.value ?? []) { repo in | |
| 20 | NavigationLink(value: RepoRoute.repo(repo.path)) { | |
| 21 | VStack(alignment: .leading, spacing: 4) { | |
| 22 | HStack(spacing: 6) { | |
| 23 | Text(repo.owner + "/") | |
| 24 | .foregroundStyle(.secondary) | |
| 25 | + Text(repo.name) | |
| 26 | .fontWeight(.medium) | |
| 27 | if repo.isArchived { | |
| 28 | GBChip("archived", .secondary) | |
| 29 | } | |
| 30 | } | |
| 31 | .font(.gbSans(.subheadline)) | |
| 32 | .lineLimit(1) | |
| 33 | ||
| 34 | if let description = repo.description, !description.isEmpty { | |
| 35 | Text(description) | |
| 36 | .font(.gbSans(.caption)) | |
| 37 | .foregroundStyle(.secondary) | |
| 38 | .lineLimit(2) | |
| 39 | } | |
| 40 | if let topics = repo.topics, !topics.isEmpty { | |
| 41 | ScrollView(.horizontal, showsIndicators: false) { | |
| 42 | HStack(spacing: 6) { | |
| 43 | ForEach(topics, id: \.self) { topic in | |
| 44 | GBChip(topic, .gbAccent) | |
| 45 | } | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
| 50 | .padding(.vertical, 2) | |
| 51 | } | |
| 52 | } | |
| 53 | PageFooter(list: list) | |
| 54 | } | |
| 55 | .overlay { LoadStateOverlay(state: list.state) } | |
| 56 | .navigationTitle("Explore") | |
| 57 | .toolbar { AccountMenu() } | |
| 58 | .task { | |
| 59 | if list.state.value == nil { await list.reload() } | |
| 60 | } | |
| 61 | .refreshable { await list.reload() } | |
| 62 | } | |
| 63 | } | |
gitbay/Views/Repos/FileView.swift +5
| @@ -28,6 +28,11 @@ struct FileView: View { | ||
| 28 | 28 | ToolbarItem(placement: .topBarTrailing) { |
| 29 | 29 | if model.state.value?.binary == false { |
| 30 | 30 | Menu { |
| 31 | NavigationLink(value: RepoRoute.log( | |
| 32 | repo: model.repoPath, ref: model.ref, path: model.filePath | |
| 33 | )) { | |
| 34 | Label("History", systemImage: "clock") | |
| 35 | } | |
| 31 | 36 | NavigationLink(value: RepoRoute.blame( |
| 32 | 37 | repo: model.repoPath, path: model.filePath, ref: model.ref |
| 33 | 38 | )) { |
gitbay/Views/Repos/LogView.swift +14 −3
| @@ -4,10 +4,21 @@ struct LogView: View { | ||
| 4 | 4 | |
| 5 | 5 | @State private var model: LogViewModel |
| 6 | 6 | |
| 7 | init(client: GitbayClient, repo: String) { | |
| 8 | _model = State(initialValue: LogViewModel(client: client, repoPath: repo)) | |
| 7 | init(client: GitbayClient, repo: String, ref: String?, path: String?) { | |
| 8 | _model = State(initialValue: LogViewModel( | |
| 9 | client: client, repoPath: repo, ref: ref, path: path | |
| 10 | )) | |
| 9 | 11 | } |
| 10 | 12 | |
| 13 | /// Name what this history is of: a file, a ref, or the repository. | |
| 14 | private var title: String { | |
| 15 | if let path = model.path { | |
| 16 | return String(path.split(separator: "/").last ?? "") | |
| 17 | } | |
| 18 | return model.ref.map { "History · \($0)" } ?? "History" | |
| 19 | } | |
| 20 | ||
| 21 | ||
| 11 | 22 | var body: some View { |
| 12 | 23 | List { |
| 13 | 24 | ForEach(model.state.value ?? []) { commit in |
| @@ -17,7 +28,7 @@ struct LogView: View { | ||
| 17 | 28 | } |
| 18 | 29 | } |
| 19 | 30 | .overlay { LoadStateOverlay(state: model.state) } |
| 20 | .navigationTitle("History") | |
| 31 | .navigationTitle(title) | |
| 21 | 32 | .navigationBarTitleDisplayMode(.inline) |
| 22 | 33 | .task { await model.load() } |
| 23 | 34 | .refreshable { await model.load() } |
gitbay/Views/Repos/RepoRoute.swift +1 −1
| @@ -6,7 +6,7 @@ nonisolated enum RepoRoute: Hashable { | ||
| 6 | 6 | case repo(String) |
| 7 | 7 | case tree(repo: String, directory: String, ref: String?) |
| 8 | 8 | case file(repo: String, path: String, ref: String?) |
| 9 | case log(repo: String) | |
| 9 | case log(repo: String, ref: String?, path: String?) | |
| 10 | 10 | case settings(repo: String) |
| 11 | 11 | case grep(repo: String) |
| 12 | 12 | case blame(repo: String, path: String, ref: String?) |
gitbay/Views/Repos/RepoView.swift +1 −1
| @@ -26,7 +26,7 @@ struct RepoView: View { | ||
| 26 | 26 | NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) { |
| 27 | 27 | Label("Files", systemImage: "folder") |
| 28 | 28 | } |
| 29 | NavigationLink(value: RepoRoute.log(repo: path)) { | |
| 29 | NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) { | |
| 30 | 30 | Label("History", systemImage: "clock") |
| 31 | 31 | } |
| 32 | 32 | NavigationLink(value: RepoRoute.refs(repo: path)) { |
gitbay/Views/Repos/TreeView.swift +10
| @@ -37,6 +37,16 @@ struct TreeView: View { | ||
| 37 | 37 | ? String(model.repoPath.split(separator: "/").last ?? "") |
| 38 | 38 | : String(model.directory.split(separator: "/").last ?? "")) |
| 39 | 39 | .navigationBarTitleDisplayMode(.inline) |
| 40 | .toolbar { | |
| 41 | ToolbarItem(placement: .topBarTrailing) { | |
| 42 | NavigationLink(value: RepoRoute.log( | |
| 43 | repo: model.repoPath, ref: model.ref, path: nil | |
| 44 | )) { | |
| 45 | Image(systemName: "clock") | |
| 46 | } | |
| 47 | .accessibilityIdentifier("tree-history-button") | |
| 48 | } | |
| 49 | } | |
| 40 | 50 | .task { await model.load() } |
| 41 | 51 | .refreshable { await model.load() } |
| 42 | 52 | } |
gitbayTests/ExploreRefLogTests.swift added +96
| @@ -0,0 +1,96 @@ | ||
| 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 let logJSON = """ | |
| 16 | {"protocol_version":1,"data":[\ | |
| 17 | {"sha":"aaaaaaaaaabbbbbbbbbb","subject":"only on side","author_name":"cmc",\ | |
| 18 | "author_email":"c@x.test","date":"2026-08-28T02:00:00Z",\ | |
| 19 | "signature":{"state":"unsigned"}}],"exit_code":0} | |
| 20 | """ | |
| 21 | ||
| 22 | @MainActor | |
| 23 | struct LogAtRefTests { | |
| 24 | ||
| 25 | @Test func noRefReadsTheDefaultBranch() async throws { | |
| 26 | let (client, stub) = try makeClient() | |
| 27 | stub.enqueue(.init(status: 200, json: logJSON)) | |
| 28 | let model = LogViewModel(client: client, repoPath: "krz/gitbay") | |
| 29 | ||
| 30 | await model.load() | |
| 31 | ||
| 32 | #expect(stub.seen.first?.url.query() == "argv=repo&argv=log&argv=krz/gitbay") | |
| 33 | } | |
| 34 | ||
| 35 | @Test func aRefIsForwardedToTheCommand() async throws { | |
| 36 | let (client, stub) = try makeClient() | |
| 37 | stub.enqueue(.init(status: 200, json: logJSON)) | |
| 38 | let model = LogViewModel(client: client, repoPath: "krz/gitbay", ref: "side") | |
| 39 | ||
| 40 | await model.load() | |
| 41 | ||
| 42 | #expect(model.state.value?.first?.subject == "only on side") | |
| 43 | #expect(stub.seen.first?.url.query() == | |
| 44 | "argv=repo&argv=log&argv=krz/gitbay&argv=--ref&argv=side") | |
| 45 | } | |
| 46 | ||
| 47 | @Test func anUnknownRefIsAnEmptyState() async throws { | |
| 48 | let (client, stub) = try makeClient() | |
| 49 | stub.enqueue(.init(status: 404, json: | |
| 50 | #"{"protocol_version":1,"error":"no ref \"nope\" in krz/gitbay","exit_code":3}"#)) | |
| 51 | let model = LogViewModel(client: client, repoPath: "krz/gitbay", ref: "nope") | |
| 52 | ||
| 53 | await model.load() | |
| 54 | ||
| 55 | guard case .empty = model.state else { | |
| 56 | Issue.record("expected .empty, got \(model.state)") | |
| 57 | return | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | @MainActor | |
| 63 | struct ExploreTests { | |
| 64 | ||
| 65 | @Test func exploreListsPublicReposAndPages() async throws { | |
| 66 | let (client, stub) = try makeClient() | |
| 67 | stub.enqueue(.init(status: 200, json: """ | |
| 68 | {"protocol_version":1,"data":{"items":[\ | |
| 69 | {"path":"krz/gitbay","description":"a forge","topics":["cli","forge"]},\ | |
| 70 | {"path":"krz/old","archived":true}],"next":"ZXhwbG9yZTpr"},"exit_code":0} | |
| 71 | """)) | |
| 72 | stub.enqueue(.init(status: 200, json: """ | |
| 73 | {"protocol_version":1,"data":{"items":[{"path":"krz/space-wiki"}]},"exit_code":0} | |
| 74 | """)) | |
| 75 | let list = PagedListModel<PublicRepo>( | |
| 76 | client: client, argv: ["explore"], emptyMessage: "none") | |
| 77 | ||
| 78 | await list.reload() | |
| 79 | #expect(list.state.value?.map(\.path) == ["krz/gitbay", "krz/old"]) | |
| 80 | #expect(list.state.value?[0].topics == ["cli", "forge"]) | |
| 81 | #expect(list.state.value?[1].isArchived == true) | |
| 82 | #expect(list.hasMore) | |
| 83 | ||
| 84 | await list.loadMore() | |
| 85 | #expect(list.state.value?.count == 3) | |
| 86 | #expect(!list.hasMore) | |
| 87 | #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=ZXhwbG9yZTpr") == true) | |
| 88 | } | |
| 89 | ||
| 90 | @Test func ownerAndNameSplitForDisplay() { | |
| 91 | let repo = PublicRepo(path: "audit-labs/audit-tools", | |
| 92 | description: nil, archived: nil, topics: nil) | |
| 93 | #expect(repo.owner == "audit-labs") | |
| 94 | #expect(repo.name == "audit-tools") | |
| 95 | } | |
| 96 | } | |
gitbayUITests/LiveSmokeUITests.swift +39
| @@ -766,3 +766,42 @@ extension LiveSmokeUITests { | ||
| 766 | 766 | app.buttons["None"].firstMatch.tap() |
| 767 | 767 | } |
| 768 | 768 | } |
| 769 | ||
| 770 | extension LiveSmokeUITests { | |
| 771 | ||
| 772 | /// Explore and history at a ref — the two capabilities krz/gitbay!101 | |
| 773 | /// made reachable. Read-only. | |
| 774 | func testExploreAndRefLogFlows() throws { | |
| 775 | // --- explore lists what the instance hosts --- | |
| 776 | selectTab("Explore") | |
| 777 | let firstRepo = app.cells.firstMatch | |
| 778 | XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing") | |
| 779 | // Public repos this account does not own are reachable here. | |
| 780 | XCTAssertTrue(app.staticTexts | |
| 781 | .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch | |
| 782 | .waitForExistence(timeout: 10), "explore is not the public listing") | |
| 783 | firstRepo.tap() | |
| 784 | XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20), | |
| 785 | "explore row did not open its repo") | |
| 786 | ||
| 787 | // --- history at a ref --- | |
| 788 | app.terminate() | |
| 789 | app.launch() | |
| 790 | openRepo("krz/gitbay") | |
| 791 | // Browse a ref, then ask for its history — the move the tree page | |
| 792 | // offers on the web. | |
| 793 | app.staticTexts["Branches & Tags"].firstMatch.tap() | |
| 794 | let main = app.staticTexts["main"].firstMatch | |
| 795 | XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load") | |
| 796 | main.tap() | |
| 797 | let history = app.descendants(matching: .any) | |
| 798 | .matching(identifier: "tree-history-button").firstMatch | |
| 799 | XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree") | |
| 800 | history.tap() | |
| 801 | XCTAssertTrue(app.navigationBars | |
| 802 | .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch | |
| 803 | .waitForExistence(timeout: 20), "ref history did not open") | |
| 804 | XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), | |
| 805 | "ref history rendered no commits") | |
| 806 | } | |
| 807 | } | |