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 {
5 @Environment(SessionStore.self) private var session 5 @Environment(SessionStore.self) private var session
6 @Environment(PushRouter.self) private var router 6 @Environment(PushRouter.self) private var router
7 @Environment(\.openURL) private var openURL 7 @Environment(\.openURL) private var openURL
8 @Environment(\.horizontalSizeClass) private var sizeClass
8 9
9 enum Tabs { case dashboard, feed, repositories, explore, profile } 10 enum Tabs { case dashboard, feed, repositories, explore, profile }
10 11
11 @State private var selectedTab = Tabs.dashboard 12 @State private var selectedTab = Tabs.dashboard
12 @State private var dashboardPath = NavigationPath() 13 @State private var dashboardPath = NavigationPath()
14 @State private var repoPath = NavigationPath()
13 @State private var unread = 0 15 @State private var unread = 0
14 16
15 /// whoami's admin flag, read on the My Profile tab, decides whether 17 /// whoami's admin flag, read on the My Profile tab, decides whether
@@ -29,9 +31,15 @@ struct ContentView: View {
29 if let client = session.client, let account = session.current { 31 if let client = session.client, let account = session.current {
30 TabView(selection: $selectedTab) { 32 TabView(selection: $selectedTab) {
31 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) { 33 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
32 NavigationStack(path: $dashboardPath) { 34 if sizeClass == .regular {
33 DashboardView(client: client, unread: $unread) 35 SplitStack(path: $dashboardPath, client: client, placeholder: "Nothing selected") {
34 .navigationDestinations(client: client) 36 DashboardView(client: client, unread: $unread)
37 }
38 } else {
39 NavigationStack(path: $dashboardPath) {
40 DashboardView(client: client, unread: $unread)
41 .navigationDestinations(client: client)
42 }
35 } 43 }
36 } 44 }
37 .badge(unread) 45 .badge(unread)
@@ -42,9 +50,15 @@ struct ContentView: View {
42 } 50 }
43 } 51 }
44 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) { 52 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
45 NavigationStack { 53 if sizeClass == .regular {
46 RepoListView(client: client) 54 SplitStack(path: $repoPath, client: client, placeholder: "No repository selected") {
47 .navigationDestinations(client: client) 55 RepoListView(client: client)
56 }
57 } else {
58 NavigationStack(path: $repoPath) {
59 RepoListView(client: client)
60 .navigationDestinations(client: client)
61 }
48 } 62 }
49 } 63 }
50 Tab("Explore", systemImage: "safari", value: Tabs.explore) { 64 Tab("Explore", systemImage: "safari", value: Tabs.explore) {
@@ -108,6 +122,7 @@ struct ContentView: View {
108 private func routePendingNotification(_ account: Account) async { 122 private func routePendingNotification(_ account: Account) async {
109 if pathAccount != account.id { 123 if pathAccount != account.id {
110 dashboardPath = NavigationPath() 124 dashboardPath = NavigationPath()
125 repoPath = NavigationPath()
111 selectedTab = .dashboard 126 selectedTab = .dashboard
112 pathAccount = account.id 127 pathAccount = account.id
113 } 128 }
@@ -178,6 +193,46 @@ struct ContentView: View {
178 193
179} 194}
180 195
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
181/// Every value-routed screen, attachable to any stack root. 236/// Every value-routed screen, attachable to any stack root.
182private struct RouteDestinations: ViewModifier { 237private struct RouteDestinations: ViewModifier {
183 238
@@ -208,6 +263,21 @@ private struct RouteDestinations: ViewModifier {
208 } 263 }
209 } 264 }
210 265
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
211 @ViewBuilder 281 @ViewBuilder
212 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View { 282 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
213 switch route { 283 switch route {
gitbay/Views/Dashboard/DashboardView.swift +2 −2
@@ -18,7 +18,7 @@ struct DashboardView: View {
18 } 18 }
19 19
20 var body: some View { 20 var body: some View {
21 List { 21 RoutingList {
22 if let data = model.state.value { 22 if let data = model.state.value {
23 itemSection( 23 itemSection(
24 "Waiting on your review", items: data.reviewQueue, 24 "Waiting on your review", items: data.reviewQueue,
@@ -48,7 +48,7 @@ struct DashboardView: View {
48 .navigationTitle("Dashboard") 48 .navigationTitle("Dashboard")
49 .toolbar { 49 .toolbar {
50 ToolbarItem(placement: .topBarTrailing) { 50 ToolbarItem(placement: .topBarTrailing) {
51 NavigationLink(value: RepoRoute.notifications) { 51 RouteLink(value: RepoRoute.notifications) {
52 HStack(spacing: 2) { 52 HStack(spacing: 2) {
53 Image(systemName: "bell") 53 Image(systemName: "bell")
54 if let unread = model.state.value?.unread, unread > 0 { 54 if let unread = model.state.value?.unread, unread > 0 {
gitbay/Views/Repos/RepoListView.swift +2 −2
@@ -14,7 +14,7 @@ struct RepoListView: View {
14 } 14 }
15 15
16 var body: some View { 16 var body: some View {
17 List { 17 RoutingList {
18 ForEach(model.visibleRepos) { repo in 18 ForEach(model.visibleRepos) { repo in
19 NavigationLink(value: RepoRoute.repo(repo.path)) { 19 NavigationLink(value: RepoRoute.repo(repo.path)) {
20 RepoRow(repo: repo) 20 RepoRow(repo: repo)
@@ -29,7 +29,7 @@ struct RepoListView: View {
29 .navigationTitle("Repositories") 29 .navigationTitle("Repositories")
30 .toolbar { 30 .toolbar {
31 ToolbarItem(placement: .topBarTrailing) { 31 ToolbarItem(placement: .topBarTrailing) {
32 NavigationLink(value: RepoRoute.bookmarks) { 32 RouteLink(value: RepoRoute.bookmarks) {
33 Image(systemName: "bookmark") 33 Image(systemName: "bookmark")
34 } 34 }
35 .accessibilityLabel("Bookmarks") 35 .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}