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 {
20 @State private var confirmingFork = false 20 @State private var confirmingFork = false
21 @State private var forkName = "" 21 @State private var forkName = ""
22 @State private var forkDestination: RepoRoute? 22 @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
24 @State private var destination: Destination? 25 @State private var destination: Destination?
25 26
26 /// Pushed from buttons rather than NavigationLinks: several share a 27 /// 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 /// list row, and a link there claims the whole row and adds a chevron.
28 private enum Destination: Hashable { 29 private enum Destination: Hashable {
29 case files, mergeRequests, issues, builds, owner, settings 30 case files, mergeRequests, issues, builds, owner, settings
31 case history, refs, milestones, labels, releases, wiki, grep
30 } 32 }
31 33
32 var body: some View { 34 var body: some View {
@@ -50,47 +52,13 @@ struct RepoView: View {
50 header(detail) 52 header(detail)
51 53
52 Section { 54 Section {
53 Grid(horizontalSpacing: 0, verticalSpacing: 0) { 55 statStrip
54 GridRow { 56 .listRowInsets(EdgeInsets())
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())
67 } 57 }
68 58
69 Section { 59 Section("More") {
70 DisclosureGroup("More", isExpanded: $showingMore) { 60 morePills
71 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) { 61 .padding(.vertical, 4)
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")
94 } 62 }
95 63
96 if let readme = model.readme { 64 if let readme = model.readme {
@@ -125,6 +93,13 @@ struct RepoView: View {
125 case .builds: BuildListView(client: client, repo: path) 93 case .builds: BuildListView(client: client, repo: path)
126 case .owner: ProfileView(client: client, name: owner) 94 case .owner: ProfileView(client: client, name: owner)
127 case .settings: RepoSettingsView(client: client, repo: path) 95 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)
128 } 103 }
129 } 104 }
130 .alert("Fork \(path)", isPresented: $confirmingFork) { 105 .alert("Fork \(path)", isPresented: $confirmingFork) {
@@ -312,36 +287,141 @@ struct RepoView: View {
312 287
313 private var owner: String { String(path.split(separator: "/").first ?? "") } 288 private var owner: String { String(path.split(separator: "/").first ?? "") }
314 289
315 private func tile( 290 /// Files, merge requests, issues and builds in one row; two rows at
316 _ title: String, systemImage: String, id: String, to target: Destination, 291 /// accessibility text sizes.
317 count: String? = nil, build: Build? = nil 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
318 ) -> some View { 350 ) -> some View {
319 Button { destination = target } label: { 351 Button { destination = target } label: {
320 HStack(spacing: 8) { 352 VStack(spacing: 2) {
321 Image(systemName: systemImage) 353 top()
322 .foregroundStyle(Color.gbAccent) 354 .font(.gbSans(.title3))
323 .frame(width: 24) 355 .frame(height: statValueHeight)
324 Text(title) 356 Text(title)
357 .font(.gbSans(.caption))
325 .lineLimit(1) 358 .lineLimit(1)
326 .minimumScaleFactor(0.8) 359 }
327 Spacer(minLength: 4) 360 .padding(.vertical, 10)
328 if let count { 361 .frame(maxWidth: .infinity)
329 Text(count) 362 .contentShape(Rectangle())
330 .font(.gbSans(.subheadline)) 363 }
331 .foregroundStyle(.secondary) 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)
332 } 403 }
333 if let build { 404 GridRow {
334 Image(systemName: build.statusIcon) 405 search.gridCellColumns(2)
335 .foregroundStyle(build.statusColor)
336 .accessibilityLabel(build.status)
337 } 406 }
338 } 407 }
339 .padding(.horizontal, 14) 408 }
340 .padding(.vertical, 12) 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)
341 .frame(maxWidth: .infinity, alignment: .leading) 421 .frame(maxWidth: .infinity, alignment: .leading)
422 .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.gbLine, lineWidth: 0.5))
342 .contentShape(Rectangle()) 423 .contentShape(Rectangle())
343 } 424 }
344 .buttonStyle(.plain) 425 .buttonStyle(.plain)
345 .accessibilityIdentifier("repo-link-\(id)")
346 } 426 }
347} 427}
gitbayUITests/LiveSmokeUITests.swift +2 −7
@@ -447,14 +447,9 @@ final class LiveSmokeUITests: XCTestCase {
447 app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch 447 app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch
448 } 448 }
449 449
450 /// A link in the repo screen's More section, above the README. 450 /// A button in the repo screen's More section, above the README.
451 /// Expands the section when it is still collapsed.
452 private func moreLink(_ title: String) -> XCUIElement { 451 private func moreLink(_ title: String) -> XCUIElement {
453 let link = app.staticTexts[title].firstMatch 452 let link = app.buttons[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") 453 XCTAssertTrue(scrollTo(link), "\(title) not under More")
459 return link 454 return link
460 } 455 }