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