gitbay/ContentView.swift
304 lines · 12294 bytes
1import SwiftUI
2
3struct ContentView: View {
4
5 @Environment(SessionStore.self) private var session
6 @Environment(PushRouter.self) private var router
7
8 enum Tabs { case dashboard, feed, repositories, explore, profile }
9
10 @State private var selectedTab = Tabs.dashboard
11 @State private var dashboardPath = NavigationPath()
12
13 /// whoami's admin flag, read on the My Profile tab, decides whether
14 /// the account menu offers Admin.
15 @State private var isAdmin = false
16
17 /// Which account `dashboardPath`'s routes were resolved against.
18 ///
19 /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so
20 /// this view's own state outlives an account switch while the stack
21 /// it feeds is rebuilt. A path carried across the switch would be
22 /// re-pushed against the new account's client — someone else's
23 /// issue number, usually a 404. Empty while signed out.
24 @State private var pathAccount = ""
25
26 var body: some View {
27 if let client = session.client, let account = session.current {
28 TabView(selection: $selectedTab) {
29 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
30 NavigationStack(path: $dashboardPath) {
31 DashboardView(client: client)
32 .navigationDestinations(client: client)
33 }
34 }
35 Tab("Feed", systemImage: "bolt", value: Tabs.feed) {
36 NavigationStack {
37 FeedView(client: client)
38 .navigationDestinations(client: client)
39 }
40 }
41 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
42 NavigationStack {
43 RepoListView(client: client)
44 .navigationDestinations(client: client)
45 }
46 }
47 Tab("Explore", systemImage: "safari", value: Tabs.explore) {
48 NavigationStack {
49 ExploreView(client: client)
50 .navigationDestinations(client: client)
51 }
52 }
53 Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) {
54 NavigationStack {
55 ProfileView(client: client, name: account.username,
56 title: "My Profile")
57 .toolbar { AccountMenu(isAdmin: isAdmin) }
58 .task {
59 isAdmin = false
60 isAdmin = await AdminCheck.isAdmin(client)
61 }
62 .navigationDestinations(client: client)
63 }
64 }
65 }
66 .id(account.id) // a fresh tab bar and stacks per account
67 .task(id: DrainKey(account: account.id, target: router.pending)) {
68 await routePendingNotification(account)
69 }
70 } else {
71 NavigationStack {
72 SignInView()
73 }
74 // Signing out leaves the stale path behind in this view's
75 // state; disowning it makes the next sign-in clear it.
76 .task { pathAccount = "" }
77 }
78 }
79
80 /// What makes the drain run again.
81 ///
82 /// The account is in here because the cross-account pass leaves
83 /// `pending` set on purpose, so the target alone does not change
84 /// across the switch it performs. Keying on both means the drain
85 /// does not depend on whether `.id()` propagates its identity
86 /// outward through the `.task` modifier attached after it.
87 nonisolated private struct DrainKey: Equatable, Sendable {
88 let account: String
89 let target: PushTarget?
90 }
91
92 /// Resets the navigation state an account switch invalidated, then
93 /// routes a tapped notification, switching accounts first when it
94 /// belongs to another one.
95 ///
96 /// A switch changes `account.id`, which rebuilds the tab bar. That
97 /// is why the target is left pending across it: this runs again
98 /// against the new account and finds it already active, so the
99 /// second pass does the navigating. The drain is the mechanism, not
100 /// a workaround for it — and it is also where the path is reset,
101 /// because every switch comes through here whether a notification
102 /// is pending or not.
103 private func routePendingNotification(_ account: Account) async {
104 if pathAccount != account.id {
105 dashboardPath = NavigationPath()
106 selectedTab = .dashboard
107 pathAccount = account.id
108 }
109 guard let target = router.pending else { return }
110 if account.id != target.accountID {
111 guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
112 router.pending = nil // signed out of it since the tap
113 return
114 }
115 // A token gone from the Keychain drops the account and
116 // leaves `current` alone, so nothing would rebuild and
117 // drain; the target would outlive every later tap, which
118 // cannot change the key either.
119 guard session.activate(other) else {
120 router.pending = nil
121 return
122 }
123 return // the rebuilt tree drains it
124 }
125 selectedTab = .dashboard
126 switch target.destination {
127 case .issue(let repo, let number):
128 dashboardPath.append(IssueRoute.issue(repo: repo, number: number))
129 case .mr(let repo, let number):
130 dashboardPath.append(MRRoute.mr(repo: repo, number: number))
131 case .build(let repo, let number):
132 dashboardPath.append(BuildRoute.detail(repo: repo, number: number))
133 }
134 router.pending = nil
135 }
136
137}
138
139/// Every value-routed screen, attachable to any stack root.
140private struct RouteDestinations: ViewModifier {
141
142 let client: GitbayClient
143
144 func body(content: Content) -> some View {
145 content
146 .navigationDestination(for: RepoRoute.self) { route in
147 destination(route, client: client)
148 }
149 .navigationDestination(for: MRRoute.self) { route in
150 destination(route, client: client)
151 }
152 .navigationDestination(for: IssueRoute.self) { route in
153 destination(route, client: client)
154 }
155 .navigationDestination(for: BuildRoute.self) { route in
156 destination(route, client: client)
157 }
158 .navigationDestination(for: ReleaseRoute.self) { route in
159 destination(route, client: client)
160 }
161 .navigationDestination(for: OrgRoute.self) { route in
162 destination(route, client: client)
163 }
164 .navigationDestination(for: SnippetRoute.self) { route in
165 destination(route, client: client)
166 }
167 }
168
169 @ViewBuilder
170 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
171 switch route {
172 case .repo(let path):
173 RepoView(client: client, path: path)
174 case .tree(let repo, let directory, let ref):
175 TreeView(client: client, repo: repo, directory: directory, ref: ref)
176 case .file(let repo, let path, let ref):
177 FileView(client: client, repo: repo, path: path, ref: ref)
178 case .log(let repo, let ref, let path):
179 LogView(client: client, repo: repo, ref: ref, path: path)
180 case .settings(let repo):
181 RepoSettingsView(client: client, repo: repo)
182 case .grep(let repo):
183 GrepView(client: client, repo: repo)
184 case .blame(let repo, let path, let ref):
185 BlameView(client: client, repo: repo, path: path, ref: ref)
186 case .refs(let repo):
187 RefsView(client: client, repo: repo)
188 case .compare(let repo, let defaultBranch):
189 CompareView(client: client, repo: repo, defaultBranch: defaultBranch)
190 case .milestones(let repo):
191 MilestoneListView(client: client, repo: repo)
192 case .labels(let repo):
193 LabelListView(client: client, repo: repo)
194 case .bookmarks:
195 BookmarkListView(client: client)
196 case .webhooks(let repo):
197 WebhookListView(client: client, repo: repo)
198 case .webhookDeliveries(let repo):
199 WebhookDeliveriesView(client: client, repo: repo)
200 case .wiki(let repo):
201 WikiView(client: client, repo: repo)
202 case .wikiPage(let repo, let page):
203 WikiPageView(client: client, repo: repo, page: page)
204 case .commit(let repo, let sha):
205 CommitView(client: client, repo: repo, sha: sha)
206 case .profile(let name):
207 ProfileView(client: client, name: name)
208 case .account:
209 AccountView(client: client)
210 case .addAccount:
211 SignInView()
212 case .notifications:
213 NotificationsView(client: client)
214 case .admin:
215 AdminView(client: client)
216 case .adminUsers:
217 AdminUsersView(client: client)
218 }
219 }
220
221 @ViewBuilder
222 private func destination(_ route: MRRoute, client: GitbayClient) -> some View {
223 switch route {
224 case .list(let repo):
225 MRListView(client: client, repo: repo)
226 case .mr(let repo, let number):
227 MRView(client: client, repo: repo, number: number)
228 case .diff(let repo, let number):
229 DiffView(client: client, repo: repo, number: number)
230 case .rangeDiff(let repo, let number, let from, let to):
231 RangeDiffView(client: client, repo: repo, number: number, from: from, to: to)
232 }
233 }
234
235 @ViewBuilder
236 private func destination(_ route: IssueRoute, client: GitbayClient) -> some View {
237 switch route {
238 case .list(let repo):
239 IssueListView(client: client, repo: repo)
240 case .issue(let repo, let number):
241 IssueView(client: client, repo: repo, number: number)
242 }
243 }
244
245 @ViewBuilder
246 private func destination(_ route: OrgRoute, client: GitbayClient) -> some View {
247 switch route {
248 case .org(let name):
249 OrgView(client: client, org: name)
250 case .team(let org, let team):
251 TeamView(client: client, org: org, team: team)
252 case .labels(let org):
253 LabelListView(client: client, owner: .org(org))
254 case .milestones(let org):
255 MilestoneListView(client: client, owner: .org(org))
256 }
257 }
258
259 @ViewBuilder
260 private func destination(_ route: SnippetRoute, client: GitbayClient) -> some View {
261 switch route {
262 case .list(let owner):
263 SnippetListView(client: client, owner: owner)
264 case .snippet(let id):
265 SnippetView(client: client, id: id)
266 }
267 }
268
269 @ViewBuilder
270 private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View {
271 switch route {
272 case .list(let repo):
273 ReleaseListView(client: client, repo: repo)
274 case .release(let repo, let tag):
275 ReleaseView(client: client, repo: repo, tag: tag)
276 }
277 }
278
279 @ViewBuilder
280 private func destination(_ route: BuildRoute, client: GitbayClient) -> some View {
281 switch route {
282 case .list(let repo):
283 BuildListView(client: client, repo: repo)
284 case .detail(let repo, let number):
285 BuildDetailView(client: client, repo: repo, number: number)
286 }
287 }
288}
289
290extension View {
291 func navigationDestinations(client: GitbayClient) -> some View {
292 modifier(RouteDestinations(client: client))
293 }
294}
295
296#Preview {
297 // Every environment value the signed-in branch reads, so the
298 // preview does not depend on the machine's Keychain being empty.
299 let session = SessionStore()
300 ContentView()
301 .environment(session)
302 .environment(PushRouter())
303 .environment(PushRegistrar(session: session))
304}