Commit f98a9d0398

f98a9d0398070714802dc40122ddfd75e7ec94a5

parent: d3a69034f3

Verified · cmc

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

Split view for Dashboard and Repositories at regular width

The list is the sidebar and its selection the root of the detail
column's stack. That stack keeps the compact layout's path, so
notification and universal-link routing append to it at either width.
Compact width is unchanged.

Layout: unified · split

gitbay/ContentView.swift +76 −6
@@ -5,11 +5,13 @@ struct ContentView: View {
55 @Environment(SessionStore.self) private var session
66 @Environment(PushRouter.self) private var router
77 @Environment(\.openURL) private var openURL
8 @Environment(\.horizontalSizeClass) private var sizeClass
89
910 enum Tabs { case dashboard, feed, repositories, explore, profile }
1011
1112 @State private var selectedTab = Tabs.dashboard
1213 @State private var dashboardPath = NavigationPath()
14 @State private var repoPath = NavigationPath()
1315 @State private var unread = 0
1416
1517 /// whoami's admin flag, read on the My Profile tab, decides whether
@@ -29,9 +31,15 @@ struct ContentView: View {
2931 if let client = session.client, let account = session.current {
3032 TabView(selection: $selectedTab) {
3133 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
32 NavigationStack(path: $dashboardPath) {
33 DashboardView(client: client, unread: $unread)
34 .navigationDestinations(client: client)
34 if sizeClass == .regular {
35 SplitStack(path: $dashboardPath, client: client, placeholder: "Nothing selected") {
36 DashboardView(client: client, unread: $unread)
37 }
38 } else {
39 NavigationStack(path: $dashboardPath) {
40 DashboardView(client: client, unread: $unread)
41 .navigationDestinations(client: client)
42 }
3543 }
3644 }
3745 .badge(unread)
@@ -42,9 +50,15 @@ struct ContentView: View {
4250 }
4351 }
4452 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
45 NavigationStack {
46 RepoListView(client: client)
47 .navigationDestinations(client: client)
53 if sizeClass == .regular {
54 SplitStack(path: $repoPath, client: client, placeholder: "No repository selected") {
55 RepoListView(client: client)
56 }
57 } else {
58 NavigationStack(path: $repoPath) {
59 RepoListView(client: client)
60 .navigationDestinations(client: client)
61 }
4862 }
4963 }
5064 Tab("Explore", systemImage: "safari", value: Tabs.explore) {
@@ -108,6 +122,7 @@ struct ContentView: View {
108122 private func routePendingNotification(_ account: Account) async {
109123 if pathAccount != account.id {
110124 dashboardPath = NavigationPath()
125 repoPath = NavigationPath()
111126 selectedTab = .dashboard
112127 pathAccount = account.id
113128 }
@@ -178,6 +193,46 @@ struct ContentView: View {
178193
179194}
180195
196/// A list as a sidebar at regular width. What it selects is the root of
197/// the detail column's stack; the stack's path is the one the compact
198/// layout's stack uses, so notification and link routing, which append
199/// to it, work at either width.
200private struct SplitStack<Sidebar: View>: View {
201
202 @Binding var path: NavigationPath
203 let client: GitbayClient
204 let placeholder: String
205 @ViewBuilder let sidebar: () -> Sidebar
206
207 @State private var selection: AnyHashable?
208
209 var body: some View {
210 NavigationSplitView {
211 sidebar()
212 .environment(\.sidebarSelection, Binding(
213 get: { selection },
214 set: { route in
215 selection = route
216 path = NavigationPath()
217 }
218 ))
219 } detail: {
220 NavigationStack(path: $path) {
221 Group {
222 if let selection {
223 RouteDestinations(client: client).destination(selection)
224 } else {
225 Text(placeholder)
226 .font(.gbSans(.subheadline))
227 .foregroundStyle(.secondary)
228 }
229 }
230 .navigationDestinations(client: client)
231 }
232 }
233 }
234}
235
181236/// Every value-routed screen, attachable to any stack root.
182237private struct RouteDestinations: ViewModifier {
183238
@@ -208,6 +263,21 @@ private struct RouteDestinations: ViewModifier {
208263 }
209264 }
210265
266 /// A type-erased route's screen, for a sidebar selection.
267 @ViewBuilder
268 func destination(_ route: AnyHashable) -> some View {
269 switch route.base {
270 case let r as RepoRoute: destination(r, client: client)
271 case let r as MRRoute: destination(r, client: client)
272 case let r as IssueRoute: destination(r, client: client)
273 case let r as BuildRoute: destination(r, client: client)
274 case let r as ReleaseRoute: destination(r, client: client)
275 case let r as OrgRoute: destination(r, client: client)
276 case let r as SnippetRoute: destination(r, client: client)
277 default: EmptyView()
278 }
279 }
280
211281 @ViewBuilder
212282 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
213283 switch route {
gitbay/Views/Dashboard/DashboardView.swift +2 −2
@@ -18,7 +18,7 @@ struct DashboardView: View {
1818 }
1919
2020 var body: some View {
21 List {
21 RoutingList {
2222 if let data = model.state.value {
2323 itemSection(
2424 "Waiting on your review", items: data.reviewQueue,
@@ -48,7 +48,7 @@ struct DashboardView: View {
4848 .navigationTitle("Dashboard")
4949 .toolbar {
5050 ToolbarItem(placement: .topBarTrailing) {
51 NavigationLink(value: RepoRoute.notifications) {
51 RouteLink(value: RepoRoute.notifications) {
5252 HStack(spacing: 2) {
5353 Image(systemName: "bell")
5454 if let unread = model.state.value?.unread, unread > 0 {
gitbay/Views/Repos/RepoListView.swift +2 −2
@@ -14,7 +14,7 @@ struct RepoListView: View {
1414 }
1515
1616 var body: some View {
17 List {
17 RoutingList {
1818 ForEach(model.visibleRepos) { repo in
1919 NavigationLink(value: RepoRoute.repo(repo.path)) {
2020 RepoRow(repo: repo)
@@ -29,7 +29,7 @@ struct RepoListView: View {
2929 .navigationTitle("Repositories")
3030 .toolbar {
3131 ToolbarItem(placement: .topBarTrailing) {
32 NavigationLink(value: RepoRoute.bookmarks) {
32 RouteLink(value: RepoRoute.bookmarks) {
3333 Image(systemName: "bookmark")
3434 }
3535 .accessibilityLabel("Bookmarks")
gitbay/Views/Shared/SidebarRouting.swift added +40
@@ -0,0 +1,40 @@
1import SwiftUI
2
3extension EnvironmentValues {
4 /// Set on a list shown as a `NavigationSplitView` sidebar: what it
5 /// selects opens in the detail column's stack. Nil in a plain stack.
6 @Entry var sidebarSelection: Binding<AnyHashable?>?
7}
8
9/// A `List` whose rows select into `sidebarSelection` when there is one.
10/// A value-based link in a sidebar only reaches the detail column's stack
11/// through the list's selection.
12struct RoutingList<Content: View>: View {
13 @ViewBuilder let content: () -> Content
14 @Environment(\.sidebarSelection) private var selection
15
16 var body: some View {
17 if let selection {
18 List(selection: selection) { content() }
19 } else {
20 List { content() }
21 }
22 }
23}
24
25/// A `NavigationLink` in a stack; in a sidebar, a button that opens the
26/// route in the detail column. For links outside the list, such as
27/// toolbar items, which the list's selection does not cover.
28struct RouteLink<Value: Hashable, Label: View>: View {
29 let value: Value
30 @ViewBuilder let label: () -> Label
31 @Environment(\.sidebarSelection) private var selection
32
33 var body: some View {
34 if let selection {
35 Button { selection.wrappedValue = AnyHashable(value) } label: { label() }
36 } else {
37 NavigationLink(value: value) { label() }
38 }
39 }
40}