Commit b4a6bd651f

b4a6bd651f391ccb73d7001b085db7ca91a0bd2e

parent: 385ad813bb

Unsigned

cmc <hello@cleberg.net> · 2026-09-30 03:14 UTC

Repository page: stat strip and More pills (!145)

- Replaces the 2x2 grid with a four-cell strip: Files, MRs (open count), Issues (open count), Builds (latest status). Each cell is one VoiceOver button ("Merge requests, 3 open"); identifiers `repo-link-*` unchanged. Two rows at accessibility text sizes.
- The More disclosure becomes always-visible pills in two columns (Search in Files spans both); one column at accessibility sizes.
- Live suite: `moreLink` finds the pill button directly.

Ref #16

Layout: unified · split

gitbay/Views/Repos/RepoView.swift +140 −60
@@ -20,13 +20,15 @@ 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
23 @Environment(\.dynamicTypeSize) private var typeSize
24 @ScaledMetric(relativeTo: .title3) private var statValueHeight: CGFloat = 26
2425 @State private var destination: Destination?
2526
2627 /// Pushed from buttons rather than NavigationLinks: several share a
2728 /// list row, and a link there claims the whole row and adds a chevron.
2829 private enum Destination: Hashable {
2930 case files, mergeRequests, issues, builds, owner, settings
31 case history, refs, milestones, labels, releases, wiki, grep
3032 }
3133
3234 var body: some View {
@@ -50,47 +52,13 @@ struct RepoView: View {
5052 header(detail)
5153
5254 Section {
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 }
65 }
66 .listRowInsets(EdgeInsets())
55 statStrip
56 .listRowInsets(EdgeInsets())
6757 }
6858
69 Section {
70 DisclosureGroup("More", isExpanded: $showingMore) {
71 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
72 Label("History", systemImage: "clock")
73 }
74 NavigationLink(value: RepoRoute.refs(repo: path)) {
75 Label("Branches & Tags", systemImage: "arrow.triangle.branch")
76 }
77 NavigationLink(value: RepoRoute.milestones(repo: path)) {
78 Label("Milestones", systemImage: "flag")
79 }
80 NavigationLink(value: RepoRoute.labels(repo: path)) {
81 Label("Labels", systemImage: "tag")
82 }
83 NavigationLink(value: ReleaseRoute.list(repo: path)) {
84 Label("Releases", systemImage: "shippingbox")
85 }
86 NavigationLink(value: RepoRoute.wiki(repo: path)) {
87 Label("Wiki", systemImage: "book")
88 }
89 NavigationLink(value: RepoRoute.grep(repo: path)) {
90 Label("Search in Files", systemImage: "text.magnifyingglass")
91 }
92 }
93 .accessibilityIdentifier("repo-more")
59 Section("More") {
60 morePills
61 .padding(.vertical, 4)
9462 }
9563
9664 if let readme = model.readme {
@@ -125,6 +93,13 @@ struct RepoView: View {
12593 case .builds: BuildListView(client: client, repo: path)
12694 case .owner: ProfileView(client: client, name: owner)
12795 case .settings: RepoSettingsView(client: client, repo: path)
96 case .history: LogView(client: client, repo: path, ref: nil, path: nil)
97 case .refs: RefsView(client: client, repo: path)
98 case .milestones: MilestoneListView(client: client, repo: path)
99 case .labels: LabelListView(client: client, repo: path)
100 case .releases: ReleaseListView(client: client, repo: path)
101 case .wiki: WikiView(client: client, repo: path)
102 case .grep: GrepView(client: client, repo: path)
128103 }
129104 }
130105 .alert("Fork \(path)", isPresented: $confirmingFork) {
@@ -312,36 +287,141 @@ struct RepoView: View {
312287
313288 private var owner: String { String(path.split(separator: "/").first ?? "") }
314289
315 private func tile(
316 _ title: String, systemImage: String, id: String, to target: Destination,
317 count: String? = nil, build: Build? = nil
290 /// Files, merge requests, issues and builds in one row; two rows at
291 /// accessibility text sizes.
292 private var statStrip: some View {
293 let columns = typeSize.isAccessibilitySize ? 2 : 4
294 return Grid(horizontalSpacing: 0, verticalSpacing: 0) {
295 ForEach(0..<(4 / columns), id: \.self) { row in
296 GridRow {
297 ForEach(0..<columns, id: \.self) { column in
298 stat(row * columns + column, first: column == 0)
299 .overlay(alignment: .top) {
300 if row > 0 { Rectangle().fill(Color.gbLine).frame(height: 0.5) }
301 }
302 }
303 }
304 }
305 }
306 }
307
308 @ViewBuilder
309 private func stat(_ index: Int, first: Bool) -> some View {
310 switch index {
311 case 0:
312 statCell("Files", label: "Files", id: "files", to: .files, first: first) {
313 Image(systemName: "folder").foregroundStyle(Color.gbAccent)
314 }
315 case 1:
316 statCell("MRs", label: "Merge requests", value: model.openMRs.map { "\($0) open" },
317 id: "merge-requests", to: .mergeRequests, first: first) {
318 count(model.openMRs, systemImage: "arrow.triangle.merge")
319 }
320 case 2:
321 statCell("Issues", label: "Issues", value: model.openIssues.map { "\($0) open" },
322 id: "issues", to: .issues, first: first) {
323 count(model.openIssues, systemImage: "smallcircle.filled.circle")
324 }
325 default:
326 statCell("Builds", label: "Builds", value: model.latestBuild?.status,
327 id: "builds", to: .builds, first: first) {
328 if let build = model.latestBuild {
329 Image(systemName: build.statusIcon).foregroundStyle(build.statusColor)
330 } else {
331 Image(systemName: "hammer").foregroundStyle(Color.gbAccent)
332 }
333 }
334 }
335 }
336
337 /// The count when it loaded, otherwise the icon.
338 @ViewBuilder
339 private func count(_ count: String?, systemImage: String) -> some View {
340 if let count {
341 Text(count).fontWeight(.medium).foregroundStyle(Color.gbAccent)
342 } else {
343 Image(systemName: systemImage).foregroundStyle(Color.gbAccent)
344 }
345 }
346
347 private func statCell(
348 _ title: String, label: String, value: String? = nil, id: String,
349 to target: Destination, first: Bool, @ViewBuilder top: () -> some View
318350 ) -> some View {
319351 Button { destination = target } label: {
320 HStack(spacing: 8) {
321 Image(systemName: systemImage)
322 .foregroundStyle(Color.gbAccent)
323 .frame(width: 24)
352 VStack(spacing: 2) {
353 top()
354 .font(.gbSans(.title3))
355 .frame(height: statValueHeight)
324356 Text(title)
357 .font(.gbSans(.caption))
325358 .lineLimit(1)
326 .minimumScaleFactor(0.8)
327 Spacer(minLength: 4)
328 if let count {
329 Text(count)
330 .font(.gbSans(.subheadline))
331 .foregroundStyle(.secondary)
359 }
360 .padding(.vertical, 10)
361 .frame(maxWidth: .infinity)
362 .contentShape(Rectangle())
363 }
364 .buttonStyle(.plain)
365 .overlay(alignment: .leading) {
366 if !first { Rectangle().fill(Color.gbLine).frame(width: 0.5) }
367 }
368 .accessibilityElement(children: .ignore)
369 .accessibilityLabel(label)
370 .accessibilityValue(value ?? "")
371 .accessibilityAddTraits(.isButton)
372 .accessibilityIdentifier("repo-link-\(id)")
373 }
374
375 /// The rest of the repository, always visible: two columns, one at
376 /// accessibility text sizes.
377 @ViewBuilder
378 private var morePills: some View {
379 let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep)
380 if typeSize.isAccessibilitySize {
381 VStack(spacing: 8) {
382 pill("History", systemImage: "clock", to: .history)
383 pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs)
384 pill("Milestones", systemImage: "flag", to: .milestones)
385 pill("Labels", systemImage: "tag", to: .labels)
386 pill("Releases", systemImage: "shippingbox", to: .releases)
387 pill("Wiki", systemImage: "book", to: .wiki)
388 search
389 }
390 } else {
391 Grid(horizontalSpacing: 8, verticalSpacing: 8) {
392 GridRow {
393 pill("History", systemImage: "clock", to: .history)
394 pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs)
395 }
396 GridRow {
397 pill("Milestones", systemImage: "flag", to: .milestones)
398 pill("Labels", systemImage: "tag", to: .labels)
399 }
400 GridRow {
401 pill("Releases", systemImage: "shippingbox", to: .releases)
402 pill("Wiki", systemImage: "book", to: .wiki)
332403 }
333 if let build {
334 Image(systemName: build.statusIcon)
335 .foregroundStyle(build.statusColor)
336 .accessibilityLabel(build.status)
404 GridRow {
405 search.gridCellColumns(2)
337406 }
338407 }
339 .padding(.horizontal, 14)
340 .padding(.vertical, 12)
408 }
409 }
410
411 private func pill(_ title: String, systemImage: String, to target: Destination) -> some View {
412 Button { destination = target } label: {
413 Label {
414 Text(title).lineLimit(1).minimumScaleFactor(0.8)
415 } icon: {
416 Image(systemName: systemImage).foregroundStyle(Color.gbAccent)
417 }
418 .font(.gbSans(.subheadline))
419 .padding(.horizontal, 10)
420 .padding(.vertical, 8)
341421 .frame(maxWidth: .infinity, alignment: .leading)
422 .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.gbLine, lineWidth: 0.5))
342423 .contentShape(Rectangle())
343424 }
344425 .buttonStyle(.plain)
345 .accessibilityIdentifier("repo-link-\(id)")
346426 }
347427}
gitbayUITests/LiveSmokeUITests.swift +2 −7
@@ -447,14 +447,9 @@ final class LiveSmokeUITests: XCTestCase {
447447 app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch
448448 }
449449
450 /// A link in the repo screen's More section, above the README.
451 /// Expands the section when it is still collapsed.
450 /// A button in the repo screen's More section, above the README.
452451 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()
452 let link = app.buttons[title].firstMatch
458453 XCTAssertTrue(scrollTo(link), "\(title) not under More")
459454 return link
460455 }