Commit c108ce3805

c108ce38051dee4be3c4aed9aed7383c4478f345

parent: 215138b9ca

Unsigned

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

Fix push order, list tops, build jump labels, More pill borders (!148)

- Push order: `navigationDestination(item:)` pushes put later value-link pushes underneath (repo → Builds → a build showed the list; Back showed the build). All five tab stacks own a `NavigationPath`; RepoView (strip, More, owner, settings, fork), the created-issue push and body reference links append via the new `pushRoute` environment value. No `navigationDestination(item:)` remains.
- Segmented-filter lists: picker in its own section so the rows' section keeps rounded top corners (issues, MRs, milestones, notifications, explore search, admin users).
- Build detail: labelled "Jump to failure" / "Jump to end" buttons under the header; iOS 26 rendered the toolbar versions icon-only.
- Repository More pills: border removed.

573 unit tests, Release build, UI target compiles. Checked in stub-hosted snapshots (issues list, failed build, repo page); push order not exercised by a test.

Layout: unified · split

gitbay/ContentView.swift +15 −3
@@ -12,6 +12,9 @@ struct ContentView: View {
1212 @State private var selectedTab = Tabs.dashboard
1313 @State private var dashboardPath = NavigationPath()
1414 @State private var repoPath = NavigationPath()
15 @State private var feedPath = NavigationPath()
16 @State private var explorePath = NavigationPath()
17 @State private var profilePath = NavigationPath()
1518 @State private var unread = 0
1619
1720 /// whoami's admin flag, read on the My Profile tab, decides whether
@@ -40,14 +43,16 @@ struct ContentView: View {
4043 DashboardView(client: client, unread: $unread)
4144 .navigationDestinations(client: client)
4245 }
46 .pushesRoutes(into: $dashboardPath)
4347 }
4448 }
4549 .badge(unread)
4650 Tab("Feed", systemImage: "bolt", value: Tabs.feed) {
47 NavigationStack {
51 NavigationStack(path: $feedPath) {
4852 FeedView(client: client)
4953 .navigationDestinations(client: client)
5054 }
55 .pushesRoutes(into: $feedPath)
5156 }
5257 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
5358 if sizeClass == .regular {
@@ -59,16 +64,18 @@ struct ContentView: View {
5964 RepoListView(client: client)
6065 .navigationDestinations(client: client)
6166 }
67 .pushesRoutes(into: $repoPath)
6268 }
6369 }
6470 Tab("Explore", systemImage: "safari", value: Tabs.explore) {
65 NavigationStack {
71 NavigationStack(path: $explorePath) {
6672 ExploreView(client: client)
6773 .navigationDestinations(client: client)
6874 }
75 .pushesRoutes(into: $explorePath)
6976 }
7077 Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) {
71 NavigationStack {
78 NavigationStack(path: $profilePath) {
7279 ProfileView(client: client, name: account.username,
7380 title: "My Profile")
7481 .toolbar { AccountMenu(isAdmin: isAdmin) }
@@ -78,6 +85,7 @@ struct ContentView: View {
7885 }
7986 .navigationDestinations(client: client)
8087 }
88 .pushesRoutes(into: $profilePath)
8189 }
8290 }
8391 .id(account.id) // a fresh tab bar and stacks per account
@@ -123,6 +131,9 @@ struct ContentView: View {
123131 if pathAccount != account.id {
124132 dashboardPath = NavigationPath()
125133 repoPath = NavigationPath()
134 feedPath = NavigationPath()
135 explorePath = NavigationPath()
136 profilePath = NavigationPath()
126137 selectedTab = .dashboard
127138 pathAccount = account.id
128139 }
@@ -229,6 +240,7 @@ private struct SplitStack<Sidebar: View>: View {
229240 }
230241 .navigationDestinations(client: client)
231242 }
243 .pushesRoutes(into: $path)
232244 }
233245 }
234246}
gitbay/Views/Account/NotificationsView.swift +8 −6
@@ -17,13 +17,15 @@ struct NotificationsView: View {
1717
1818 var body: some View {
1919 List {
20 Picker("Filter", selection: Bindable(model).showAll) {
21 Text("Unread").tag(false)
22 Text("All").tag(true)
20 Section {
21 Picker("Filter", selection: Bindable(model).showAll) {
22 Text("Unread").tag(false)
23 Text("All").tag(true)
24 }
25 .pickerStyle(.segmented)
26 .listRowBackground(Color.clear)
27 .listRowInsets(EdgeInsets())
2328 }
24 .pickerStyle(.segmented)
25 .listRowBackground(Color.clear)
26 .listRowInsets(EdgeInsets())
2729
2830 if let actionError = model.actionError {
2931 Section { GBNotice(actionError) }
gitbay/Views/Admin/AdminUsersView.swift +9 −7
@@ -20,15 +20,17 @@ struct AdminUsersView: View {
2020
2121 var body: some View {
2222 List {
23 Picker("State", selection: Bindable(model).filter) {
24 ForEach(AdminUserFilter.allCases) { filter in
25 Text(filter.label).tag(filter)
23 Section {
24 Picker("State", selection: Bindable(model).filter) {
25 ForEach(AdminUserFilter.allCases) { filter in
26 Text(filter.label).tag(filter)
27 }
2628 }
29 .pickerStyle(.segmented)
30 .listRowBackground(Color.clear)
31 .listRowInsets(EdgeInsets())
32 .accessibilityIdentifier("admin-user-filter")
2733 }
28 .pickerStyle(.segmented)
29 .listRowBackground(Color.clear)
30 .listRowInsets(EdgeInsets())
31 .accessibilityIdentifier("admin-user-filter")
3234
3335 if let error = model.actionError {
3436 Section {
gitbay/Views/Builds/BuildDetailView.swift +24 −14
@@ -29,6 +29,9 @@ struct BuildDetailView: View {
2929 .padding(.vertical, 8)
3030 }
3131 header(detail.build)
32 if !detail.log.isEmpty {
33 jumpBar(failed: detail.lines.failed != nil)
34 }
3235 Divider()
3336 log(detail)
3437 }
@@ -39,20 +42,6 @@ struct BuildDetailView: View {
3942 .navigationBarTitleDisplayMode(.inline)
4043 .searchable(text: $query, prompt: "Filter log lines")
4144 .toolbar {
42 ToolbarItemGroup(placement: .topBarTrailing) {
43 if let detail = model.state.value, !detail.log.isEmpty {
44 if detail.lines.failed != nil {
45 Button("Jump to failure", systemImage: "exclamationmark.triangle") {
46 jump = .failure
47 }
48 .accessibilityIdentifier("build-jump-failure")
49 }
50 Button("Jump to end", systemImage: "arrow.down.to.line") {
51 jump = .end
52 }
53 .accessibilityIdentifier("build-jump-end")
54 }
55 }
5645 ToolbarItem(placement: .topBarTrailing) {
5746 if model.isCancellable {
5847 if model.working {
@@ -89,6 +78,27 @@ struct BuildDetailView: View {
8978 }
9079 }
9180
81 private func jumpBar(failed: Bool) -> some View {
82 HStack(spacing: 20) {
83 if failed {
84 Button("Jump to failure", systemImage: "exclamationmark.triangle") {
85 jump = .failure
86 }
87 .accessibilityIdentifier("build-jump-failure")
88 }
89 Button("Jump to end", systemImage: "arrow.down.to.line") {
90 jump = .end
91 }
92 .accessibilityIdentifier("build-jump-end")
93 Spacer()
94 }
95 .font(.gbSans(.caption))
96 .foregroundStyle(Color.gbAccent)
97 .buttonStyle(.borderless)
98 .padding(.horizontal, 16)
99 .padding(.bottom, 10)
100 }
101
92102 private func header(_ build: Build) -> some View {
93103 VStack(alignment: .leading, spacing: 6) {
94104 HStack(spacing: 8) {
gitbay/Views/Discovery/ExploreView.swift +8 −6
@@ -42,14 +42,16 @@ struct ExploreView: View {
4242 var body: some View {
4343 List {
4444 if search.isSearching {
45 Picker("Kind", selection: Bindable(search).kind) {
46 ForEach(SearchKind.allCases) { kind in
47 Text(kind.label).tag(kind)
45 Section {
46 Picker("Kind", selection: Bindable(search).kind) {
47 ForEach(SearchKind.allCases) { kind in
48 Text(kind.label).tag(kind)
49 }
4850 }
51 .pickerStyle(.segmented)
52 .listRowBackground(Color.clear)
53 .listRowInsets(EdgeInsets())
4954 }
50 .pickerStyle(.segmented)
51 .listRowBackground(Color.clear)
52 .listRowInsets(EdgeInsets())
5355
5456 ForEach(search.state.value ?? []) { result in
5557 searchRow(for: result)
gitbay/Views/Issues/IssueListView.swift +10 −11
@@ -9,7 +9,7 @@ struct IssueListView: View {
99 @State private var draftTitle = ""
1010 @State private var draftBody = ""
1111 @State private var draftFormat = "md"
12 @State private var createdIssue: Int64?
12 @Environment(\.pushRoute) private var pushRoute
1313
1414 private let client: GitbayClient
1515
@@ -21,14 +21,16 @@ struct IssueListView: View {
2121
2222 var body: some View {
2323 List {
24 Picker("State", selection: Bindable(model).filter.state) {
25 ForEach(IssueFilter.State.allCases) { filter in
26 Text(filter.label).tag(filter)
24 Section {
25 Picker("State", selection: Bindable(model).filter.state) {
26 ForEach(IssueFilter.State.allCases) { filter in
27 Text(filter.label).tag(filter)
28 }
2729 }
30 .pickerStyle(.segmented)
31 .listRowBackground(Color.clear)
32 .listRowInsets(EdgeInsets())
2833 }
29 .pickerStyle(.segmented)
30 .listRowBackground(Color.clear)
31 .listRowInsets(EdgeInsets())
3234
3335 ForEach(model.state.value ?? []) { issue in
3436 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) {
@@ -101,15 +103,12 @@ struct IssueListView: View {
101103 draftBody = ""
102104 draftFormat = "md"
103105 composing = false
104 createdIssue = number
106 pushRoute(IssueRoute.issue(repo: model.repoPath, number: number))
105107 await model.load()
106108 }
107109 }
108110 }
109111 }
110 .navigationDestination(item: $createdIssue) { number in
111 IssueView(client: client, repo: model.repoPath, number: number)
112 }
113112 .task { await model.loadIfNeeded() }
114113 .task { await createModel.loadTemplates() }
115114 .refreshable { await model.load() }
gitbay/Views/Issues/MilestoneListView.swift +8 −6
@@ -16,14 +16,16 @@ struct MilestoneListView: View {
1616
1717 var body: some View {
1818 List {
19 Picker("State", selection: Bindable(model).filter) {
20 ForEach(MilestoneListViewModel.StateFilter.allCases) { filter in
21 Text(filter.rawValue.capitalized).tag(filter)
19 Section {
20 Picker("State", selection: Bindable(model).filter) {
21 ForEach(MilestoneListViewModel.StateFilter.allCases) { filter in
22 Text(filter.rawValue.capitalized).tag(filter)
23 }
2224 }
25 .pickerStyle(.segmented)
26 .listRowBackground(Color.clear)
27 .listRowInsets(EdgeInsets())
2328 }
24 .pickerStyle(.segmented)
25 .listRowBackground(Color.clear)
26 .listRowInsets(EdgeInsets())
2729
2830 if let error = model.actionError {
2931 Section {
gitbay/Views/MRs/MRListView.swift +8 −6
@@ -14,14 +14,16 @@ struct MRListView: View {
1414
1515 var body: some View {
1616 List {
17 Picker("State", selection: Bindable(model).filter.state) {
18 ForEach(MRFilter.State.allCases) { filter in
19 Text(filter.label).tag(filter)
17 Section {
18 Picker("State", selection: Bindable(model).filter.state) {
19 ForEach(MRFilter.State.allCases) { filter in
20 Text(filter.label).tag(filter)
21 }
2022 }
23 .pickerStyle(.segmented)
24 .listRowBackground(Color.clear)
25 .listRowInsets(EdgeInsets())
2126 }
22 .pickerStyle(.segmented)
23 .listRowBackground(Color.clear)
24 .listRowInsets(EdgeInsets())
2527
2628 ForEach(model.state.value ?? []) { mr in
2729 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: mr.number)) {
gitbay/Views/Repos/RepoView.swift +24 −30
@@ -19,10 +19,9 @@ struct RepoView: View {
1919 @State private var confirmingArchive = false
2020 @State private var confirmingFork = false
2121 @State private var forkName = ""
22 @State private var forkDestination: RepoRoute?
2322 @Environment(\.dynamicTypeSize) private var typeSize
2423 @ScaledMetric(relativeTo: .title3) private var statValueHeight: CGFloat = 26
25 @State private var destination: Destination?
24 @Environment(\.pushRoute) private var pushRoute
2625
2726 /// Pushed from buttons rather than NavigationLinks: several share a
2827 /// list row, and a link there claims the whole row and adds a chevron.
@@ -31,6 +30,24 @@ struct RepoView: View {
3130 case history, refs, milestones, labels, releases, wiki, grep
3231 }
3332
33 private func open(_ target: Destination) {
34 switch target {
35 case .files: pushRoute(RepoRoute.tree(repo: path, directory: "", ref: nil))
36 case .mergeRequests: pushRoute(MRRoute.list(repo: path))
37 case .issues: pushRoute(IssueRoute.list(repo: path))
38 case .builds: pushRoute(BuildRoute.list(repo: path))
39 case .owner: pushRoute(RepoRoute.profile(owner))
40 case .settings: pushRoute(RepoRoute.settings(repo: path))
41 case .history: pushRoute(RepoRoute.log(repo: path, ref: nil, path: nil))
42 case .refs: pushRoute(RepoRoute.refs(repo: path))
43 case .milestones: pushRoute(RepoRoute.milestones(repo: path))
44 case .labels: pushRoute(RepoRoute.labels(repo: path))
45 case .releases: pushRoute(ReleaseRoute.list(repo: path))
46 case .wiki: pushRoute(RepoRoute.wiki(repo: path))
47 case .grep: pushRoute(RepoRoute.grep(repo: path))
48 }
49 }
50
3451 var body: some View {
3552 List {
3653 if let detail = model.state.value {
@@ -80,28 +97,6 @@ struct RepoView: View {
8097 .toolbar { toolbar }
8198 .task { await model.load() }
8299 .refreshable { await model.load() }
83 .navigationDestination(item: $forkDestination) { route in
84 if case .repo(let forkedPath) = route {
85 RepoView(client: client, path: forkedPath)
86 }
87 }
88 .navigationDestination(item: $destination) { destination in
89 switch destination {
90 case .files: TreeView(client: client, repo: path, directory: "", ref: nil)
91 case .mergeRequests: MRListView(client: client, repo: path)
92 case .issues: IssueListView(client: client, repo: path)
93 case .builds: BuildListView(client: client, repo: path)
94 case .owner: ProfileView(client: client, name: owner)
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)
103 }
104 }
105100 .alert("Fork \(path)", isPresented: $confirmingFork) {
106101 TextField("New name (optional)", text: $forkName)
107102 .autocorrectionDisabled()
@@ -109,7 +104,7 @@ struct RepoView: View {
109104 Button("Fork") {
110105 Task {
111106 if let result = await actionsModel.fork(named: forkName) {
112 forkDestination = .repo(result.path)
107 pushRoute(RepoRoute.repo(result.path))
113108 }
114109 }
115110 }
@@ -208,7 +203,7 @@ struct RepoView: View {
208203 }
209204 Divider()
210205 Button {
211 destination = .settings
206 open(.settings)
212207 } label: {
213208 Label("Settings", systemImage: "gearshape")
214209 }
@@ -239,7 +234,7 @@ struct RepoView: View {
239234 VStack(alignment: .leading, spacing: 8) {
240235 HStack(spacing: 6) {
241236 HStack(spacing: 0) {
242 Button(owner) { destination = .owner }
237 Button(owner) { open(.owner) }
243238 .buttonStyle(.borderless)
244239 .accessibilityIdentifier("repo-owner-link")
245240 Text("/" + (detail.path.split(separator: "/").last.map(String.init) ?? ""))
@@ -348,7 +343,7 @@ struct RepoView: View {
348343 _ title: String, label: String, value: String? = nil, id: String,
349344 to target: Destination, first: Bool, @ViewBuilder top: () -> some View
350345 ) -> some View {
351 Button { destination = target } label: {
346 Button { open(target) } label: {
352347 VStack(spacing: 2) {
353348 top()
354349 .font(.gbSans(.title3))
@@ -409,7 +404,7 @@ struct RepoView: View {
409404 }
410405
411406 private func pill(_ title: String, systemImage: String, to target: Destination) -> some View {
412 Button { destination = target } label: {
407 Button { open(target) } label: {
413408 Label {
414409 Text(title).lineLimit(1).minimumScaleFactor(0.8)
415410 } icon: {
@@ -419,7 +414,6 @@ struct RepoView: View {
419414 .padding(.horizontal, 10)
420415 .padding(.vertical, 8)
421416 .frame(maxWidth: .infinity, alignment: .leading)
422 .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.gbLine, lineWidth: 0.5))
423417 .contentShape(Rectangle())
424418 }
425419 .buttonStyle(.plain)
gitbay/Views/Shared/BodyReferences.swift +6 −15
@@ -87,27 +87,18 @@ extension View {
8787
8888private struct ReferenceNavigation: ViewModifier {
8989
90 @Environment(SessionStore.self) private var session
91 @State private var target: NotificationDestination?
90 @Environment(\.pushRoute) private var pushRoute
9291
9392 func body(content: Content) -> some View {
9493 content
9594 .environment(\.openURL, OpenURLAction { url in
9695 guard let destination = referenceDestination(url) else { return .systemAction }
97 target = destination
96 switch destination {
97 case .issue(let repo, let number): pushRoute(IssueRoute.issue(repo: repo, number: number))
98 case .mr(let repo, let number): pushRoute(MRRoute.mr(repo: repo, number: number))
99 case .build(let repo, let number): pushRoute(BuildRoute.detail(repo: repo, number: number))
100 }
98101 return .handled
99102 })
100 .navigationDestination(item: $target) { destination in
101 if let client = session.client {
102 switch destination {
103 case .issue(let repo, let number):
104 IssueView(client: client, repo: repo, number: number)
105 case .mr(let repo, let number):
106 MRView(client: client, repo: repo, number: number)
107 case .build(let repo, let number):
108 BuildDetailView(client: client, repo: repo, number: number)
109 }
110 }
111 }
112103 }
113104}
gitbay/Views/Shared/SidebarRouting.swift +12
@@ -4,6 +4,18 @@ extension EnvironmentValues {
44 /// Set on a list shown as a `NavigationSplitView` sidebar: what it
55 /// selects opens in the detail column's stack. Nil in a plain stack.
66 @Entry var sidebarSelection: Binding<AnyHashable?>?
7
8 /// Appends a route to the enclosing stack's path. Screens that push
9 /// from a button use it instead of `navigationDestination(item:)`,
10 /// which puts any later value-link push underneath the pushed screen.
11 @Entry var pushRoute: (any Hashable) -> Void = { _ in }
12}
13
14extension View {
15 /// Lets screens in this stack push onto `path` with `pushRoute`.
16 func pushesRoutes(into path: Binding<NavigationPath>) -> some View {
17 environment(\.pushRoute) { path.wrappedValue.append($0) }
18 }
719}
820
921/// A `List` whose rows select into `sidebarSelection` when there is one.