gitbay/ContentView.swift
430 lines · 17581 bytes
1import SwiftUI
2
3struct ContentView: View {
4
5 @Environment(SessionStore.self) private var session
6 @Environment(PushRouter.self) private var router
7 @Environment(\.openURL) private var openURL
8 @Environment(\.horizontalSizeClass) private var sizeClass
9
10 enum Tabs { case dashboard, feed, repositories, explore, profile }
11
12 @State private var selectedTab = Tabs.dashboard
13 @State private var dashboardPath = NavigationPath()
14 @State private var repoPath = NavigationPath()
15 @State private var feedPath = NavigationPath()
16 @State private var explorePath = NavigationPath()
17 @State private var profilePath = NavigationPath()
18 @State private var unread = 0
19
20 /// whoami's admin flag, read on the My Profile tab, decides whether
21 /// the account menu offers Admin.
22 @State private var isAdmin = false
23
24 /// Which account `dashboardPath`'s routes were resolved against.
25 ///
26 /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so
27 /// this view's own state outlives an account switch while the stack
28 /// it feeds is rebuilt. A path carried across the switch would be
29 /// re-pushed against the new account's client — someone else's
30 /// issue number, usually a 404. Empty while signed out.
31 @State private var pathAccount = ""
32
33 var body: some View {
34 if let client = session.client, let account = session.current {
35 TabView(selection: $selectedTab) {
36 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
37 if sizeClass == .regular {
38 SplitStack(path: $dashboardPath, client: client, placeholder: "Nothing selected") {
39 DashboardView(client: client, unread: $unread)
40 }
41 } else {
42 NavigationStack(path: $dashboardPath) {
43 DashboardView(client: client, unread: $unread)
44 .navigationDestinations(client: client)
45 }
46 .pushesRoutes(into: $dashboardPath)
47 }
48 }
49 .badge(unread)
50 Tab("Feed", systemImage: "bolt", value: Tabs.feed) {
51 NavigationStack(path: $feedPath) {
52 FeedView(client: client)
53 .navigationDestinations(client: client)
54 }
55 .pushesRoutes(into: $feedPath)
56 }
57 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
58 if sizeClass == .regular {
59 SplitStack(path: $repoPath, client: client, placeholder: "No repository selected") {
60 RepoListView(client: client)
61 }
62 } else {
63 NavigationStack(path: $repoPath) {
64 RepoListView(client: client)
65 .navigationDestinations(client: client)
66 }
67 .pushesRoutes(into: $repoPath)
68 }
69 }
70 Tab("Explore", systemImage: "safari", value: Tabs.explore) {
71 NavigationStack(path: $explorePath) {
72 ExploreView(client: client)
73 .navigationDestinations(client: client)
74 }
75 .pushesRoutes(into: $explorePath)
76 }
77 Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) {
78 NavigationStack(path: $profilePath) {
79 ProfileView(client: client, name: account.username,
80 title: "My Profile")
81 .toolbar { AccountMenu(isAdmin: isAdmin) }
82 .task {
83 isAdmin = false
84 isAdmin = await AdminCheck.isAdmin(client)
85 }
86 .navigationDestinations(client: client)
87 }
88 .pushesRoutes(into: $profilePath)
89 }
90 }
91 .id(account.id) // a fresh tab bar and stacks per account
92 .task(id: DrainKey(account: account.id, target: router.pending, link: router.pendingLink)) {
93 await routePendingNotification(account)
94 }
95 } else {
96 NavigationStack {
97 SignInView()
98 }
99 // Signing out leaves the stale path behind in this view's
100 // state; disowning it makes the next sign-in clear it.
101 .task { pathAccount = "" }
102 }
103 }
104
105 /// What makes the drain run again.
106 ///
107 /// The account is in here because the cross-account pass leaves
108 /// `pending` set on purpose, so the target alone does not change
109 /// across the switch it performs. Keying on both means the drain
110 /// does not depend on whether `.id()` propagates its identity
111 /// outward through the `.task` modifier attached after it.
112 nonisolated private struct DrainKey: Equatable, Sendable {
113 let account: String
114 let target: PushTarget?
115 let link: URL?
116 }
117
118 /// Resets the navigation state an account switch invalidated, then
119 /// routes a tapped notification, switching accounts first when it
120 /// belongs to another one. With no notification pending, a pending
121 /// universal link is routed instead.
122 ///
123 /// A switch changes `account.id`, which rebuilds the tab bar. That
124 /// is why the target is left pending across it: this runs again
125 /// against the new account and finds it already active, so the
126 /// second pass does the navigating. The drain is the mechanism, not
127 /// a workaround for it — and it is also where the path is reset,
128 /// because every switch comes through here whether a notification
129 /// is pending or not.
130 private func routePendingNotification(_ account: Account) async {
131 if pathAccount != account.id {
132 dashboardPath = NavigationPath()
133 repoPath = NavigationPath()
134 feedPath = NavigationPath()
135 explorePath = NavigationPath()
136 profilePath = NavigationPath()
137 selectedTab = .dashboard
138 pathAccount = account.id
139 }
140 guard let target = router.pending else {
141 routePendingLink(account)
142 return
143 }
144 if account.id != target.accountID {
145 guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
146 router.pending = nil // signed out of it since the tap
147 return
148 }
149 // A token gone from the Keychain drops the account and
150 // leaves `current` alone, so nothing would rebuild and
151 // drain; the target would outlive every later tap, which
152 // cannot change the key either.
153 guard session.activate(other) else {
154 router.pending = nil
155 return
156 }
157 return // the rebuilt tree drains it
158 }
159 selectedTab = .dashboard
160 switch target.destination {
161 case .issue(let repo, let number):
162 dashboardPath.append(IssueRoute.issue(repo: repo, number: number))
163 case .mr(let repo, let number):
164 dashboardPath.append(MRRoute.mr(repo: repo, number: number))
165 case .build(let repo, let number):
166 dashboardPath.append(BuildRoute.detail(repo: repo, number: number))
167 }
168 router.pending = nil
169 }
170
171 /// Opens a pending universal link on the Dashboard stack, under the
172 /// current account when it is on the link's instance, otherwise under
173 /// one that is. Switching leaves the link pending for the rebuilt
174 /// tree, as a notification does. With no account there, or a token
175 /// gone, the link goes to Safari.
176 private func routePendingLink(_ account: Account) {
177 guard let url = router.pendingLink else { return }
178 guard let route = WebURLRoute(url: url),
179 let owner = WebURLRoute.account(for: url, current: account, accounts: session.accounts)
180 else {
181 router.pendingLink = nil
182 openURL(url)
183 return
184 }
185 if owner.id != account.id {
186 if !session.activate(owner) {
187 router.pendingLink = nil
188 openURL(url)
189 }
190 return
191 }
192 selectedTab = .dashboard
193 switch route {
194 case .repo(let r): dashboardPath.append(r)
195 case .issue(let r): dashboardPath.append(r)
196 case .mr(let r): dashboardPath.append(r)
197 case .build(let r): dashboardPath.append(r)
198 case .release(let r): dashboardPath.append(r)
199 case .org(let r): dashboardPath.append(r)
200 case .snippet(let r): dashboardPath.append(r)
201 }
202 router.pendingLink = nil
203 }
204
205}
206
207/// A list as a sidebar at regular width. What it selects is the root of
208/// the detail column's stack; the stack's path is the one the compact
209/// layout's stack uses, so notification and link routing, which append
210/// to it, work at either width.
211private struct SplitStack<Sidebar: View>: View {
212
213 @Binding var path: NavigationPath
214 let client: GitbayClient
215 let placeholder: String
216 @ViewBuilder let sidebar: () -> Sidebar
217
218 @State private var selection: AnyHashable?
219
220 var body: some View {
221 NavigationSplitView {
222 sidebar()
223 .environment(\.sidebarSelection, Binding(
224 get: { selection },
225 set: { route in
226 selection = route
227 path = NavigationPath()
228 }
229 ))
230 } detail: {
231 NavigationStack(path: $path) {
232 Group {
233 if let selection {
234 RouteDestinations(client: client).destination(selection)
235 } else {
236 Text(placeholder)
237 .font(.gbSans(.subheadline))
238 .foregroundStyle(.secondary)
239 }
240 }
241 .navigationDestinations(client: client)
242 }
243 .pushesRoutes(into: $path)
244 }
245 }
246}
247
248/// Every value-routed screen, attachable to any stack root.
249private struct RouteDestinations: ViewModifier {
250
251 let client: GitbayClient
252
253 func body(content: Content) -> some View {
254 content
255 .navigationDestination(for: RepoRoute.self) { route in
256 destination(route, client: client)
257 }
258 .navigationDestination(for: MRRoute.self) { route in
259 destination(route, client: client)
260 }
261 .navigationDestination(for: IssueRoute.self) { route in
262 destination(route, client: client)
263 }
264 .navigationDestination(for: BuildRoute.self) { route in
265 destination(route, client: client)
266 }
267 .navigationDestination(for: ReleaseRoute.self) { route in
268 destination(route, client: client)
269 }
270 .navigationDestination(for: OrgRoute.self) { route in
271 destination(route, client: client)
272 }
273 .navigationDestination(for: SnippetRoute.self) { route in
274 destination(route, client: client)
275 }
276 }
277
278 /// A type-erased route's screen, for a sidebar selection.
279 @ViewBuilder
280 func destination(_ route: AnyHashable) -> some View {
281 switch route.base {
282 case let r as RepoRoute: destination(r, client: client)
283 case let r as MRRoute: destination(r, client: client)
284 case let r as IssueRoute: destination(r, client: client)
285 case let r as BuildRoute: destination(r, client: client)
286 case let r as ReleaseRoute: destination(r, client: client)
287 case let r as OrgRoute: destination(r, client: client)
288 case let r as SnippetRoute: destination(r, client: client)
289 default: EmptyView()
290 }
291 }
292
293 @ViewBuilder
294 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
295 switch route {
296 case .repo(let path):
297 RepoView(client: client, path: path)
298 case .tree(let repo, let directory, let ref):
299 TreeView(client: client, repo: repo, directory: directory, ref: ref)
300 case .file(let repo, let path, let ref, let line):
301 FileView(client: client, repo: repo, path: path, ref: ref, line: line)
302 case .log(let repo, let ref, let path):
303 LogView(client: client, repo: repo, ref: ref, path: path)
304 case .settings(let repo):
305 RepoSettingsView(client: client, repo: repo)
306 case .grep(let repo):
307 GrepView(client: client, repo: repo)
308 case .symbols(let repo, let query):
309 SymbolSearchView(client: client, repo: repo, query: query)
310 case .blame(let repo, let path, let ref):
311 BlameView(client: client, repo: repo, path: path, ref: ref)
312 case .refs(let repo):
313 RefsView(client: client, repo: repo)
314 case .compare(let repo, let defaultBranch):
315 CompareView(client: client, repo: repo, defaultBranch: defaultBranch)
316 case .milestones(let repo):
317 MilestoneListView(client: client, repo: repo)
318 case .labels(let repo):
319 LabelListView(client: client, repo: repo)
320 case .bookmarks:
321 BookmarkListView(client: client)
322 case .webhooks(let repo):
323 WebhookListView(client: client, repo: repo)
324 case .webhookDeliveries(let repo):
325 WebhookDeliveriesView(client: client, repo: repo)
326 case .wiki(let repo):
327 WikiView(client: client, repo: repo)
328 case .wikiPage(let repo, let page):
329 WikiPageView(client: client, repo: repo, page: page)
330 case .commit(let repo, let sha):
331 CommitView(client: client, repo: repo, sha: sha)
332 case .profile(let name):
333 ProfileView(client: client, name: name)
334 case .account:
335 AccountView(client: client)
336 case .addAccount:
337 SignInView()
338 case .notifications:
339 NotificationsView(client: client)
340 case .admin:
341 AdminView(client: client)
342 case .adminUsers:
343 AdminUsersView(client: client)
344 }
345 }
346
347 @ViewBuilder
348 private func destination(_ route: MRRoute, client: GitbayClient) -> some View {
349 switch route {
350 case .list(let repo):
351 MRListView(client: client, repo: repo)
352 case .mr(let repo, let number):
353 MRView(client: client, repo: repo, number: number)
354 case .diff(let repo, let number):
355 DiffView(client: client, repo: repo, number: number)
356 case .rangeDiff(let repo, let number, let from, let to):
357 RangeDiffView(client: client, repo: repo, number: number, from: from, to: to)
358 }
359 }
360
361 @ViewBuilder
362 private func destination(_ route: IssueRoute, client: GitbayClient) -> some View {
363 switch route {
364 case .list(let repo):
365 IssueListView(client: client, repo: repo)
366 case .issue(let repo, let number):
367 IssueView(client: client, repo: repo, number: number)
368 }
369 }
370
371 @ViewBuilder
372 private func destination(_ route: OrgRoute, client: GitbayClient) -> some View {
373 switch route {
374 case .org(let name):
375 OrgView(client: client, org: name)
376 case .team(let org, let team):
377 TeamView(client: client, org: org, team: team)
378 case .labels(let org):
379 LabelListView(client: client, owner: .org(org))
380 case .milestones(let org):
381 MilestoneListView(client: client, owner: .org(org))
382 }
383 }
384
385 @ViewBuilder
386 private func destination(_ route: SnippetRoute, client: GitbayClient) -> some View {
387 switch route {
388 case .list(let owner):
389 SnippetListView(client: client, owner: owner)
390 case .snippet(let id):
391 SnippetView(client: client, id: id)
392 }
393 }
394
395 @ViewBuilder
396 private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View {
397 switch route {
398 case .list(let repo):
399 ReleaseListView(client: client, repo: repo)
400 case .release(let repo, let tag):
401 ReleaseView(client: client, repo: repo, tag: tag)
402 }
403 }
404
405 @ViewBuilder
406 private func destination(_ route: BuildRoute, client: GitbayClient) -> some View {
407 switch route {
408 case .list(let repo):
409 BuildListView(client: client, repo: repo)
410 case .detail(let repo, let number):
411 BuildDetailView(client: client, repo: repo, number: number)
412 }
413 }
414}
415
416extension View {
417 func navigationDestinations(client: GitbayClient) -> some View {
418 modifier(RouteDestinations(client: client))
419 }
420}
421
422#Preview {
423 // Every environment value the signed-in branch reads, so the
424 // preview does not depend on the machine's Keychain being empty.
425 let session = SessionStore()
426 ContentView()
427 .environment(session)
428 .environment(PushRouter())
429 .environment(PushRegistrar(session: session))
430}