Commit 8618aa6dc1

8618aa6dc178ef77bb962512caa85dc5f100ba58

parent: 41d60b2d2b

Unsigned

cmc <hello@cleberg.net> · 2026-09-30 02:18 UTC

Repository page: grid for the main four, More section (!140)

- Files, Merge Requests, Issues, Builds in a 2x2 grid under the header. Counts come from one page (limit 50) of `mr list` / `issue list`, shown as "50+" when a next page exists; build status from `build list --limit 1`. `repo show` carries none of this, so these are three extra reads per load, run concurrently after the README.
- History, Branches & Tags, Milestones, Labels, Releases, Wiki, Search in Files under a collapsed More section below the README.
- Settings moves to the `…` menu (it had no gating before; still shown to everyone, keeps `repo-settings-link`).
- Owner is a link in the header (`repo-owner-link`) instead of its own row.
- LiveSmokeUITests: grid tiles found by `repo-link-*`, More links expand `repo-more` first, Settings opened through the menu, rename check reads the nav title.

Closes #16

Layout: unified · split

gitbay/Repos/RepoDetailViewModel.swift +24
@@ -14,6 +14,11 @@ final class RepoDetailViewModel {
1414 /// Whether this repo is on the account's dashboard. nil until known —
1515 /// pin state only exists in the dashboard aggregate.
1616 private(set) var isPinned: Bool?
17 /// Open merge request and issue counts from one page each, with a
18 /// trailing "+" when more pages exist. nil when the read failed.
19 private(set) var openMRs: String?
20 private(set) var openIssues: String?
21 private(set) var latestBuild: Build?
1722 private(set) var actionError: String?
1823 private(set) var working = false
1924
@@ -35,6 +40,25 @@ final class RepoDetailViewModel {
3540 }
3641 await loadPinned()
3742 await loadReadme()
43 await loadSummary()
44 }
45
46 private nonisolated struct Numbered: Decodable, Sendable {
47 let number: Int64
48 }
49
50 private func loadSummary() async {
51 async let mrs = try? client.readPage(["mr", "list", path], of: Numbered.self, limit: 50)
52 async let issues = try? client.readPage(["issue", "list", path], of: Numbered.self, limit: 50)
53 async let builds = try? client.readPage(["build", "list", path], of: Build.self, limit: 1)
54 openMRs = Self.countLabel(await mrs)
55 openIssues = Self.countLabel(await issues)
56 latestBuild = await builds?.items.first
57 }
58
59 private static func countLabel(_ page: GitbayClient.Page<Numbered>?) -> String? {
60 guard let page else { return nil }
61 return "\(page.items.count)\(page.next == nil ? "" : "+")"
3862 }
3963
4064 private func loadPinned() async {
gitbay/Views/Repos/RepoView.swift +106 −41
@@ -20,6 +20,14 @@ struct RepoView: View {
2020 @State private var confirmingFork = false
2121 @State private var forkName = ""
2222 @State private var forkDestination: RepoRoute?
23 @State private var showingMore = false
24 @State private var destination: Destination?
25
26 /// Pushed from buttons rather than NavigationLinks: several share a
27 /// list row, and a link there claims the whole row and adds a chevron.
28 private enum Destination: Hashable {
29 case files, mergeRequests, issues, builds, owner, settings
30 }
2331
2432 var body: some View {
2533 List {
@@ -42,46 +50,20 @@ struct RepoView: View {
4250 header(detail)
4351
4452 Section {
45 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
46 Label("Files", systemImage: "folder")
47 }
48 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
49 Label("History", systemImage: "clock")
50 }
51 NavigationLink(value: RepoRoute.refs(repo: path)) {
52 Label("Branches & Tags", systemImage: "arrow.triangle.branch")
53 }
54 NavigationLink(value: MRRoute.list(repo: path)) {
55 Label("Merge Requests", systemImage: "arrow.triangle.merge")
56 }
57 NavigationLink(value: IssueRoute.list(repo: path)) {
58 Label("Issues", systemImage: "smallcircle.filled.circle")
59 }
60 NavigationLink(value: RepoRoute.milestones(repo: path)) {
61 Label("Milestones", systemImage: "flag")
62 }
63 NavigationLink(value: RepoRoute.labels(repo: path)) {
64 Label("Labels", systemImage: "tag")
65 }
66 NavigationLink(value: BuildRoute.list(repo: path)) {
67 Label("Builds", systemImage: "hammer")
68 }
69 NavigationLink(value: ReleaseRoute.list(repo: path)) {
70 Label("Releases", systemImage: "shippingbox")
71 }
72 NavigationLink(value: RepoRoute.wiki(repo: path)) {
73 Label("Wiki", systemImage: "book")
74 }
75 NavigationLink(value: RepoRoute.grep(repo: path)) {
76 Label("Search in Files", systemImage: "text.magnifyingglass")
77 }
78 NavigationLink(value: RepoRoute.settings(repo: path)) {
79 Label("Settings", systemImage: "gearshape")
80 }
81 .accessibilityIdentifier("repo-settings-link")
82 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
83 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
53 Grid(horizontalSpacing: 0, verticalSpacing: 0) {
54 GridRow {
55 tile("Files", systemImage: "folder", id: "files", to: .files)
56 tile("Merge Requests", systemImage: "arrow.triangle.merge", id: "merge-requests",
57 to: .mergeRequests, count: model.openMRs)
58 }
59 GridRow {
60 tile("Issues", systemImage: "smallcircle.filled.circle", id: "issues",
61 to: .issues, count: model.openIssues)
62 tile("Builds", systemImage: "hammer", id: "builds",
63 to: .builds, build: model.latestBuild)
64 }
8465 }
66 .listRowInsets(EdgeInsets())
8567 }
8668
8769 if let readme = model.readme {
@@ -95,6 +77,33 @@ struct RepoView: View {
9577 .padding(.vertical, 4)
9678 }
9779 }
80
81 Section {
82 DisclosureGroup("More", isExpanded: $showingMore) {
83 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
84 Label("History", systemImage: "clock")
85 }
86 NavigationLink(value: RepoRoute.refs(repo: path)) {
87 Label("Branches & Tags", systemImage: "arrow.triangle.branch")
88 }
89 NavigationLink(value: RepoRoute.milestones(repo: path)) {
90 Label("Milestones", systemImage: "flag")
91 }
92 NavigationLink(value: RepoRoute.labels(repo: path)) {
93 Label("Labels", systemImage: "tag")
94 }
95 NavigationLink(value: ReleaseRoute.list(repo: path)) {
96 Label("Releases", systemImage: "shippingbox")
97 }
98 NavigationLink(value: RepoRoute.wiki(repo: path)) {
99 Label("Wiki", systemImage: "book")
100 }
101 NavigationLink(value: RepoRoute.grep(repo: path)) {
102 Label("Search in Files", systemImage: "text.magnifyingglass")
103 }
104 }
105 .accessibilityIdentifier("repo-more")
106 }
98107 }
99108 }
100109 .overlay { LoadStateOverlay(state: model.state) }
@@ -108,6 +117,16 @@ struct RepoView: View {
108117 RepoView(client: client, path: forkedPath)
109118 }
110119 }
120 .navigationDestination(item: $destination) { destination in
121 switch destination {
122 case .files: TreeView(client: client, repo: path, directory: "", ref: nil)
123 case .mergeRequests: MRListView(client: client, repo: path)
124 case .issues: IssueListView(client: client, repo: path)
125 case .builds: BuildListView(client: client, repo: path)
126 case .owner: ProfileView(client: client, name: owner)
127 case .settings: RepoSettingsView(client: client, repo: path)
128 }
129 }
111130 .alert("Fork \(path)", isPresented: $confirmingFork) {
112131 TextField("New name (optional)", text: $forkName)
113132 .autocorrectionDisabled()
@@ -213,6 +232,12 @@ struct RepoView: View {
213232 Label("Fork", systemImage: "tuningfork")
214233 }
215234 Divider()
235 Button {
236 destination = .settings
237 } label: {
238 Label("Settings", systemImage: "gearshape")
239 }
240 .accessibilityIdentifier("repo-settings-link")
216241 Button(role: detail.isArchived ? nil : .destructive) {
217242 confirmingArchive = true
218243 } label: {
@@ -238,8 +263,13 @@ struct RepoView: View {
238263 Section {
239264 VStack(alignment: .leading, spacing: 8) {
240265 HStack(spacing: 6) {
241 Text(detail.path)
242 .font(.gbSans(.headline))
266 HStack(spacing: 0) {
267 Button(owner) { destination = .owner }
268 .buttonStyle(.borderless)
269 .accessibilityIdentifier("repo-owner-link")
270 Text("/" + (detail.path.split(separator: "/").last.map(String.init) ?? ""))
271 }
272 .font(.gbSans(.headline))
243273 if detail.visibility == "private" {
244274 Image(systemName: "lock.fill")
245275 .font(.gbSans(.caption))
@@ -279,4 +309,39 @@ struct RepoView: View {
279309 .padding(.vertical, 4)
280310 }
281311 }
312
313 private var owner: String { String(path.split(separator: "/").first ?? "") }
314
315 private func tile(
316 _ title: String, systemImage: String, id: String, to target: Destination,
317 count: String? = nil, build: Build? = nil
318 ) -> some View {
319 Button { destination = target } label: {
320 HStack(spacing: 8) {
321 Image(systemName: systemImage)
322 .foregroundStyle(Color.gbAccent)
323 .frame(width: 24)
324 Text(title)
325 .lineLimit(1)
326 .minimumScaleFactor(0.8)
327 Spacer(minLength: 4)
328 if let count {
329 Text(count)
330 .font(.gbSans(.subheadline))
331 .foregroundStyle(.secondary)
332 }
333 if let build {
334 Image(systemName: build.statusIcon)
335 .foregroundStyle(build.statusColor)
336 .accessibilityLabel(build.status)
337 }
338 }
339 .padding(.horizontal, 14)
340 .padding(.vertical, 12)
341 .frame(maxWidth: .infinity, alignment: .leading)
342 .contentShape(Rectangle())
343 }
344 .buttonStyle(.plain)
345 .accessibilityIdentifier("repo-link-\(id)")
346 }
282347}
gitbayTests/RepoViewModelTests.swift +44 −1
@@ -126,7 +126,7 @@ struct RepoDetailViewModelTests {
126126 #expect(model.readme == "# gitbay\n\na forge")
127127 #expect(model.isPinned == false)
128128 // The README was fetched by name from the tree listing.
129 let catRequest = try #require(stub.seen.last)
129 let catRequest = try #require(stub.seen.first { ($0.url.query() ?? "").contains("argv=cat") })
130130 #expect(catRequest.url.query()?.contains("argv=README.md") == true)
131131 }
132132
@@ -162,6 +162,49 @@ struct RepoDetailViewModelTests {
162162 }
163163 #expect(message == "no such repository krz/nope")
164164 }
165
166 @Test func loadsOpenCountsAndTheLatestBuild() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
169 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
170 stub.enqueue(.init(status: 200, json: """
171 {"protocol_version":1,"data":{"items":[{"number":3},{"number":2}]},"exit_code":0}
172 """, match: "argv=mr&argv=list"))
173 stub.enqueue(.init(status: 200, json: """
174 {"protocol_version":1,"data":{"items":[{"number":9}],"next":"aXNzdWU6OQ"},"exit_code":0}
175 """, match: "argv=issue&argv=list"))
176 stub.enqueue(.init(status: 200, json: """
177 {"protocol_version":1,"data":{"items":[{"number":1761,"job":"test","status":"failure",\
178 "sha":"d0c63c43655f5d6f03e2b77ca1f2c07dd53a43a3","ref":"main",\
179 "created_at":"2026-09-29T17:12:08Z"}],"next":"YnVpbGQ6MTc2MQ"},"exit_code":0}
180 """, match: "argv=build&argv=list"))
181 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
182
183 await model.load()
184
185 #expect(model.openMRs == "2")
186 // A further page means the count is a floor.
187 #expect(model.openIssues == "1+")
188 #expect(model.latestBuild?.status == "failure")
189 let buildQuery = try #require(stub.seen.first {
190 ($0.url.query() ?? "").contains("argv=build&argv=list")
191 }?.url.query())
192 #expect(buildQuery.hasSuffix("argv=--limit&argv=1"))
193 }
194
195 @Test func missingCountsLeaveTheGridBare() async throws {
196 let (client, stub) = try makeClient()
197 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
198 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
199 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
200
201 await model.load()
202
203 #expect(model.state.value != nil)
204 #expect(model.openMRs == nil)
205 #expect(model.openIssues == nil)
206 #expect(model.latestBuild == nil)
207 }
165208}
166209
167210@MainActor
gitbayUITests/LiveSmokeUITests.swift +64 −48
@@ -310,7 +310,7 @@ final class LiveSmokeUITests: XCTestCase {
310310 openRepo(Self.fixtureRepo)
311311
312312 // --- the fixture issue: milestone picker assigns the fixture ---
313 app.staticTexts["Issues"].firstMatch.tap()
313 repoLink("issues").tap()
314314 let issueRow = app.staticTexts
315315 .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
316316 XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
@@ -367,7 +367,7 @@ final class LiveSmokeUITests: XCTestCase {
367367 back() // to repo screen
368368
369369 // --- MR: create from ui-smoke, then close it ---
370 app.staticTexts["Merge Requests"].firstMatch.tap()
370 repoLink("merge-requests").tap()
371371 app.descendants(matching: .any).matching(identifier: "mr-create-button")
372372 .firstMatch.tap()
373373
@@ -434,7 +434,7 @@ final class LiveSmokeUITests: XCTestCase {
434434 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
435435 row.tap()
436436 // Repo screen is loaded once its links render.
437 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10),
437 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10),
438438 "repo screen did not open")
439439 }
440440
@@ -442,6 +442,34 @@ final class LiveSmokeUITests: XCTestCase {
442442 app.navigationBars.buttons.firstMatch.tap()
443443 }
444444
445 /// One of the four links in the grid at the top of the repo screen.
446 private func repoLink(_ id: String) -> XCUIElement {
447 app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch
448 }
449
450 /// A link in the repo screen's More section, below the README.
451 /// Expands the section when it is still collapsed.
452 private func moreLink(_ title: String) -> XCUIElement {
453 let link = app.staticTexts[title].firstMatch
454 if link.exists { return link }
455 let more = app.descendants(matching: .any).matching(identifier: "repo-more").firstMatch
456 XCTAssertTrue(scrollTo(more, swipes: 20), "More section not reachable")
457 more.tap()
458 XCTAssertTrue(scrollTo(link), "\(title) not under More")
459 return link
460 }
461
462 /// Settings is in the repo screen's actions menu.
463 private func openRepoSettings() {
464 let menu = app.descendants(matching: .any)
465 .matching(identifier: "repo-actions-menu").firstMatch
466 XCTAssertTrue(menu.waitForExistence(timeout: 10), "repo actions menu missing")
467 menu.tap()
468 let settings = app.buttons["Settings"].firstMatch
469 XCTAssertTrue(settings.waitForExistence(timeout: 5), "Settings missing from the actions menu")
470 settings.tap()
471 }
472
445473 /// Keys, PGP and email live behind the account menu, which is on the
446474 /// My Profile tab and nowhere else.
447475 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
@@ -589,8 +617,8 @@ extension LiveSmokeUITests {
589617
590618 // --- access: grant a user on the scratch repo, then revoke ---
591619 app.staticTexts[Self.scratchRepo].firstMatch.tap()
592 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
593 app.staticTexts["Settings"].firstMatch.tap()
620 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
621 openRepoSettings()
594622 let grantUser = app.descendants(matching: .any)
595623 .matching(identifier: "settings-grant-user").firstMatch
596624 XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable")
@@ -639,12 +667,7 @@ extension LiveSmokeUITests {
639667 if firstRename {
640668 firstRename = false // already on the settings screen
641669 } else {
642 // Fresh repo screen: scroll to the top so no row hides
643 // under the navigation bar, then reach Settings.
644 self.app.swipeDown()
645 let settingsLink = self.app.descendants(matching: .any).matching(identifier: "repo-settings-link").firstMatch
646 XCTAssertTrue(self.scrollTo(settingsLink), "settings link not reachable")
647 settingsLink.tap()
670 self.openRepoSettings()
648671 }
649672 let renameButton = self.app.descendants(matching: .any)
650673 .matching(identifier: "settings-rename-button").firstMatch
@@ -664,8 +687,10 @@ extension LiveSmokeUITests {
664687 for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() }
665688 XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land")
666689 renamedLink.tap()
667 XCTAssertTrue(self.app.staticTexts[expectedPath].firstMatch.waitForExistence(timeout: 15),
668 "renamed repo did not open as \(expectedPath)")
690 // The repo screen's title is the name alone; the header
691 // splits the path into the owner link and the rest.
692 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch
693 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)")
669694 }
670695 rename("ui-smoke-2", Self.scratchRepo + "-2")
671696 rename("ui-smoke", Self.scratchRepo)
@@ -681,7 +706,7 @@ extension LiveSmokeUITests {
681706 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
682707 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
683708 repoRow.tap()
684 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
709 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
685710 let menu = app.descendants(matching: .any)
686711 .matching(identifier: "repo-actions-menu").firstMatch
687712 XCTAssertTrue(menu.waitForExistence(timeout: 10))
@@ -729,12 +754,7 @@ extension LiveSmokeUITests {
729754 unbookmark.tap()
730755
731756 // --- settings: topic and merge-rule round-trips ---
732 // The links section can sit below the README fold, and the row's
733 // label is not always exposed as a static text.
734 let settingsLink = app.descendants(matching: .any)
735 .matching(identifier: "repo-settings-link").firstMatch
736 XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable")
737 settingsLink.tap()
757 openRepoSettings()
738758 let addTopic = app.descendants(matching: .any)
739759 .matching(identifier: "settings-add-topic").firstMatch
740760 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
@@ -822,14 +842,11 @@ extension LiveSmokeUITests {
822842
823843
824844 back() // settings -> repo
825 // The repo screen keeps the scroll that reached Settings, which
826 // can leave Builds under the navigation bar.
827 app.swipeDown()
828845
829846 // --- nothing to trigger without a job file ---
830847 // This repo has no .gitbay/ci.yml, so `build jobs` returns none
831848 // and the control is gated rather than failing after a guess.
832 app.staticTexts["Builds"].firstMatch.tap()
849 repoLink("builds").tap()
833850 let trigger = app.descendants(matching: .any)
834851 .matching(identifier: "build-trigger-button").firstMatch
835852 XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
@@ -877,13 +894,12 @@ extension LiveSmokeUITests {
877894 XCTAssertTrue(hit.waitForExistence(timeout: 15),
878895 "server-side topic search found nothing")
879896 hit.tap()
880 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
897 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
881898
882899 // --- owner profile from the repo screen ---
883 // The row's label merges; match it at any element type. It is
884 // the last row of the links section, below the fold.
885 let ownerLink = app.descendants(matching: .any)["krz"].firstMatch
886 XCTAssertTrue(scrollTo(ownerLink), "owner link not reachable")
900 let ownerLink = app.descendants(matching: .any)
901 .matching(identifier: "repo-owner-link").firstMatch
902 XCTAssertTrue(ownerLink.waitForExistence(timeout: 10), "owner link missing")
887903 ownerLink.tap()
888904 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
889905 .waitForExistence(timeout: 15), "profile did not load")
@@ -893,7 +909,7 @@ extension LiveSmokeUITests {
893909 back()
894910
895911 // --- grep inside the repo, last: its search UI owns the screen ---
896 app.staticTexts["Search in Files"].firstMatch.tap()
912 moreLink("Search in Files").tap()
897913 let grepField = app.searchFields.firstMatch
898914 XCTAssertTrue(grepField.waitForExistence(timeout: 10))
899915 focusAndType(grepField, "space")
@@ -910,7 +926,7 @@ extension LiveSmokeUITests {
910926 /// missing-tag refusal on create. Nothing changes state.
911927 func testReleaseFlows() throws {
912928 openRepo("krz/gitbay")
913 app.staticTexts["Releases"].firstMatch.tap()
929 moreLink("Releases").tap()
914930
915931 // Newest first, and the list has grown past the fold, so the
916932 // first row is whichever release is current.
@@ -1119,7 +1135,7 @@ extension LiveSmokeUITests {
11191135 openRepo("krz/gitbay")
11201136 snap("repo")
11211137
1122 app.staticTexts["Merge Requests"].firstMatch.tap()
1138 repoLink("merge-requests").tap()
11231139 let all = app.segmentedControls.buttons["All"].firstMatch
11241140 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
11251141 all.tap()
@@ -1208,7 +1224,7 @@ extension LiveSmokeUITests {
12081224 func testBlameAndEditFlows() throws {
12091225 // --- blame on a real file ---
12101226 openRepo("krz/gitbay")
1211 app.staticTexts["Files"].firstMatch.tap()
1227 repoLink("files").tap()
12121228 let goMod = app.staticTexts["go.mod"].firstMatch
12131229 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
12141230 goMod.tap()
@@ -1228,7 +1244,7 @@ extension LiveSmokeUITests {
12281244 app.terminate()
12291245 app.launch()
12301246 openRepo(Self.fixtureRepo)
1231 app.staticTexts["Files"].firstMatch.tap()
1247 repoLink("files").tap()
12321248 let notes = app.staticTexts["notes.txt"].firstMatch
12331249 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
12341250 notes.tap()
@@ -1344,7 +1360,7 @@ extension LiveSmokeUITests {
13441360 app.terminate()
13451361 app.launch()
13461362 openRepo("krz/gitbay")
1347 app.staticTexts["History"].firstMatch.tap()
1363 moreLink("History").tap()
13481364 let firstCommit = app.cells.firstMatch
13491365 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
13501366 firstCommit.tap()
@@ -1368,7 +1384,7 @@ extension LiveSmokeUITests {
13681384 openRepo("krz/gitbay")
13691385
13701386 // --- branches and tags, and browsing at a ref ---
1371 app.staticTexts["Branches & Tags"].firstMatch.tap()
1387 moreLink("Branches & Tags").tap()
13721388 let main = app.staticTexts["main"].firstMatch
13731389 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
13741390 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
@@ -1382,7 +1398,7 @@ extension LiveSmokeUITests {
13821398 back()
13831399
13841400 // --- milestones with progress ---
1385 app.staticTexts["Milestones"].firstMatch.tap()
1401 moreLink("Milestones").tap()
13861402 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
13871403 "milestones did not load")
13881404 app.segmentedControls.buttons["All"].firstMatch.tap()
@@ -1392,7 +1408,7 @@ extension LiveSmokeUITests {
13921408 back()
13931409
13941410 // --- an MR's milestone reads back, and can be cleared ---
1395 app.staticTexts["Merge Requests"].firstMatch.tap()
1411 repoLink("merge-requests").tap()
13961412 app.segmentedControls.buttons["All"].firstMatch.tap()
13971413 let mrNumber = app.staticTexts
13981414 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
@@ -1427,7 +1443,7 @@ extension LiveSmokeUITests {
14271443 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
14281444 .waitForExistence(timeout: 10), "explore is not the public listing")
14291445 firstRepo.tap()
1430 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1446 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20),
14311447 "explore row did not open its repo")
14321448
14331449 // --- history at a ref ---
@@ -1436,7 +1452,7 @@ extension LiveSmokeUITests {
14361452 openRepo("krz/gitbay")
14371453 // Browse a ref, then ask for its history — the move the tree page
14381454 // offers on the web.
1439 app.staticTexts["Branches & Tags"].firstMatch.tap()
1455 moreLink("Branches & Tags").tap()
14401456 let main = app.staticTexts["main"].firstMatch
14411457 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
14421458 main.tap()
@@ -1458,7 +1474,7 @@ extension LiveSmokeUITests {
14581474 /// krz/gitbay#48. Read-only; editing is a push, on every surface.
14591475 func testWikiFlows() throws {
14601476 openRepo("krz/gitbay")
1461 app.staticTexts["Wiki"].firstMatch.tap()
1477 moreLink("Wiki").tap()
14621478
14631479 // The landing page leads and is marked.
14641480 let home = app.staticTexts["Home"].firstMatch
@@ -1488,7 +1504,7 @@ extension LiveSmokeUITests {
14881504 /// server names, and the detail screen says more than the log.
14891505 func testBuildJobsAndDetail() throws {
14901506 openRepo("krz/gitbay")
1491 app.staticTexts["Builds"].firstMatch.tap()
1507 repoLink("builds").tap()
14921508
14931509 // The picker lists jobs from `build jobs` — no name to type.
14941510 let trigger = app.descendants(matching: .any)
@@ -1535,7 +1551,7 @@ extension LiveSmokeUITests {
15351551 let firstRepo = app.cells.firstMatch
15361552 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
15371553 firstRepo.tap()
1538 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1554 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20),
15391555 "repo screen did not open")
15401556 snap("repo")
15411557
@@ -1782,7 +1798,7 @@ extension LiveSmokeUITests {
17821798 /// filtered empty state. Clearing brings the list back.
17831799 func testBuildFilterFlow() throws {
17841800 openRepo("krz/gitbay")
1785 app.staticTexts["Builds"].firstMatch.tap()
1801 repoLink("builds").tap()
17861802 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
17871803
17881804 let filter = app.descendants(matching: .any)
@@ -1837,7 +1853,7 @@ extension LiveSmokeUITests {
18371853 else { throw XCTSkip("could not read krz/gitbay's builds over the API") }
18381854
18391855 openRepo("krz/gitbay")
1840 app.staticTexts["Builds"].firstMatch.tap()
1856 repoLink("builds").tap()
18411857 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
18421858 let thirtyFirst = app.staticTexts
18431859 .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch
@@ -1850,7 +1866,7 @@ extension LiveSmokeUITests {
18501866 /// links to it.
18511867 func testCloseInFavourFlow() throws {
18521868 openRepo(Self.fixtureRepo)
1853 app.staticTexts["Merge Requests"].firstMatch.tap()
1869 repoLink("merge-requests").tap()
18541870 let superseded = app.staticTexts[Self.supersededTitle].firstMatch
18551871 XCTAssertTrue(superseded.waitForExistence(timeout: 15), "fixture MR not listed")
18561872 superseded.tap()
@@ -1885,7 +1901,7 @@ extension LiveSmokeUITests {
18851901 /// list filters to it, and removing it clears the chip.
18861902 func testMRLabelFlow() throws {
18871903 openRepo(Self.fixtureRepo)
1888 app.staticTexts["Merge Requests"].firstMatch.tap()
1904 repoLink("merge-requests").tap()
18891905 let row = app.staticTexts[Self.labelledTitle].firstMatch
18901906 XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed")
18911907 row.tap()
@@ -1923,7 +1939,7 @@ extension LiveSmokeUITests {
19231939 /// the bold markers are gone and the word remains. Nothing is posted.
19241940 func testMarkupPreviewFlow() throws {
19251941 openRepo(Self.fixtureRepo)
1926 app.staticTexts["Issues"].firstMatch.tap()
1942 repoLink("issues").tap()
19271943 let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch
19281944 XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed")
19291945 issue.tap()