krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.7.0: Hutch/App/RootView.swift · raw
1import SwiftUI
2import os
3
4private let rootDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
5
6/// The root view of the app. Shows a TabView when authenticated, or a
7/// full-screen sheet for token entry on first launch.
8struct RootView: View {
9 @Environment(AppState.self) private var appState
10 @Environment(\.isAMOLEDTheme) private var isAMOLED
11 @State private var homePath = NavigationPath()
12 @State private var morePath = NavigationPath()
13 @State private var repoPath = NavigationPath()
14 @State private var buildsPath = NavigationPath()
15 @State private var ticketsPath = NavigationPath()
16 @State private var isResolvingDeepLink = false
17 @State private var hasValidatedLaunch = false
18
19 var body: some View {
20 @Bindable var appState = appState
21
22 Group {
23 switch appState.authPhase {
24 case .launching:
25 ProgressView(appState.authStatusMessage)
26 .task {
27 guard !hasValidatedLaunch else { return }
28 hasValidatedLaunch = true
29 await appState.validateOnLaunch()
30 }
31
32 case .unauthenticated:
33 // Full-screen token entry that cannot be dismissed.
34 TokenEntryView()
35
36 case .authenticated:
37 tabContent
38 }
39 }
40 .onChange(of: appState.pendingDeepLink) { _, newValue in
41 consumePendingDeepLinkIfPossible(newValue)
42 }
43 .onChange(of: appState.authPhase) { _, newPhase in
44 handleAuthPhaseChange(newPhase)
45 }
46 .onChange(of: appState.pendingTabNavigation) { _, newValue in
47 consumePendingTabNavigationIfPossible(newValue)
48 }
49 .alert(
50 "Couldn't Open Link",
51 isPresented: Binding(
52 get: { appState.deepLinkError != nil },
53 set: { isPresented in
54 if !isPresented {
55 appState.deepLinkError = nil
56 }
57 }
58 )
59 ) {
60 Button("OK") {
61 appState.deepLinkError = nil
62 }
63 } message: {
64 Text(appState.deepLinkError ?? "")
65 }
66 }
67
68 // MARK: - Tab View
69
70 private var tabContent: some View {
71 @Bindable var appState = appState
72
73 return TabView(selection: $appState.selectedTab) {
74 NavigationStack(path: $homePath) {
75 HomeView()
76 .navigationDestination(for: HomeRoute.self) { route in
77 switch route {
78 case .work(let scope):
79 WorkView(initialScope: scope)
80 }
81 }
82 }
83 .tag(AppState.Tab.home)
84 .tabItem {
85 Label("Home", systemImage: "house")
86 }
87
88 NavigationStack(path: $repoPath) {
89 RepositoryListView()
90 }
91 .tag(AppState.Tab.repositories)
92 .tabItem {
93 Label("Repositories", systemImage: "book.closed")
94 }
95
96 NavigationStack(path: $ticketsPath) {
97 TrackerListView()
98 // Deep link destination for jumping straight to a ticket.
99 .navigationDestination(for: TicketDeepLinkTarget.self) { target in
100 TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
101 }
102 }
103 .tag(AppState.Tab.tickets)
104 .tabItem {
105 Label("Trackers", systemImage: "checklist")
106 }
107
108 NavigationStack(path: $buildsPath) {
109 BuildListView()
110 // Int destination used by deep links (hutch://builds/<id>).
111 // JobSummary destination is registered inside BuildListView.
112 .navigationDestination(for: Int.self) { jobId in
113 BuildDetailView(jobId: jobId)
114 }
115 }
116 .tag(AppState.Tab.builds)
117 .tabItem {
118 Label("Builds", systemImage: "hammer")
119 }
120
121 NavigationStack(path: $morePath) {
122 MoreNavigationRoot()
123 }
124 .tag(AppState.Tab.more)
125 .tabItem {
126 Label("More", systemImage: "ellipsis.circle")
127 }
128 }
129 .id(appState.sessionIdentity)
130 .defaultAppStorage(appState.accountDefaults)
131 .modifier(SidebarAdaptableTabStyle())
132 .modifier(AMOLEDToolbarStyle(isAMOLED: isAMOLED))
133 .modifier(TabKeyboardShortcuts(selectedTab: Binding(
134 get: { appState.selectedTab },
135 set: { appState.selectedTab = $0 }
136 )))
137 .safeAreaInset(edge: .bottom) {
138 if let message = appState.copyConfirmationMessage {
139 CopyConfirmationBadge(message: message)
140 .padding(.bottom, 4)
141 .transition(.move(edge: .bottom).combined(with: .opacity))
142 }
143 }
144 .overlay {
145 if isResolvingDeepLink {
146 ZStack {
147 Color.black.opacity(0.3)
148 .ignoresSafeArea()
149 ProgressView("Opening link…")
150 .padding()
151 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
152 }
153 }
154 }
155 }
156
157 // MARK: - Deep Link Handling
158
159 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
160 rootDeepLinkLogger.info("Auth phase changed: \(String(describing: newPhase), privacy: .public); pendingDeepLink=\(String(describing: appState.pendingDeepLink), privacy: .public)")
161 switch newPhase {
162 case .launching:
163 break
164 case .unauthenticated:
165 homePath = NavigationPath()
166 morePath = NavigationPath()
167 repoPath = NavigationPath()
168 buildsPath = NavigationPath()
169 ticketsPath = NavigationPath()
170 appState.selectedTab = .home
171 isResolvingDeepLink = false
172 case .authenticated:
173 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
174 }
175 }
176
177 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
178 rootDeepLinkLogger.info("Attempting to consume pending deep link. authenticated=\(appState.isAuthenticated, privacy: .public), link=\(String(describing: link), privacy: .public)")
179 guard appState.isAuthenticated, let link else {
180 rootDeepLinkLogger.info("Deferred deep link consumption.")
181 return
182 }
183 handleDeepLink(link)
184 appState.pendingDeepLink = nil
185 }
186
187 private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
188 guard appState.isAuthenticated, let target else { return }
189 handleTabNavigation(target)
190 appState.pendingTabNavigation = nil
191 }
192
193 private func handleDeepLink(_ link: DeepLink) {
194 rootDeepLinkLogger.info("Handling deep link: \(String(describing: link), privacy: .public)")
195 guard appState.isAuthenticated else {
196 rootDeepLinkLogger.info("Ignoring deep link while unauthenticated: \(String(describing: link), privacy: .public)")
197 return
198 }
199
200 switch link {
201 case .home:
202 homePath = NavigationPath()
203 appState.selectedTab = .home
204
205 case .recentActivity:
206 homePath = NavigationPath()
207 appState.selectedTab = .home
208
209 case .repository(let service, let owner, let repo):
210 resolveRepositoryLink(service: service, owner: owner, repo: repo)
211
212 case .tracker(let owner, let tracker):
213 resolveTrackerLink(owner: owner, tracker: tracker)
214
215 case .build(let jobId):
216 buildsPath = NavigationPath()
217 appState.selectedTab = .builds
218 Task {
219 await settleNavigationTransition()
220 buildsPath.append(jobId)
221 }
222
223 case .ticket(let owner, let tracker, let ticketId):
224 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
225
226 case .mailingList(let owner, let list):
227 resolveMailingListLink(owner: owner, list: list)
228
229 case .userProfile(let owner):
230 resolveUserProfileLink(owner: owner)
231
232 case .work:
233 navigateToWork(scope: .all)
234
235 case .workQueue(let scope):
236 navigateToWork(scope: scope)
237
238 case .projectDashboard(let id, let title):
239 morePath = NavigationPath()
240 appState.selectedTab = .more
241 Task {
242 await settleNavigationTransition()
243 morePath.append(MoreRoute.projects)
244 morePath.append(MoreRoute.projectDashboard(id: id, title: title))
245 }
246
247 case .failedBuilds:
248 buildsPath = NavigationPath()
249 appState.pendingBuildListFilter = .failed
250 appState.selectedTab = .builds
251
252 case .search(let query):
253 morePath = NavigationPath()
254 appState.selectedTab = .more
255 Task {
256 await settleNavigationTransition()
257 morePath.append(MoreRoute.lookup(query: query))
258 }
259
260 case .lookup:
261 morePath = NavigationPath()
262 appState.selectedTab = .more
263 Task {
264 await settleNavigationTransition()
265 morePath.append(MoreRoute.lookup(query: nil))
266 }
267
268 case .buildsTab:
269 buildsPath = NavigationPath()
270 appState.selectedTab = .builds
271
272 case .repositoriesTab:
273 repoPath = NavigationPath()
274 appState.selectedTab = .repositories
275
276 case .trackersTab:
277 ticketsPath = NavigationPath()
278 appState.selectedTab = .tickets
279
280 case .systemStatus:
281 appState.navigateToSystemStatus()
282 }
283 }
284
285 private func navigateToWork(scope: HutchWorkQueueScope) {
286 homePath = NavigationPath()
287 appState.selectedTab = .home
288 Task {
289 await settleNavigationTransition()
290 homePath.append(HomeRoute.work(scope: scope))
291 }
292 }
293
294 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
295 switch target {
296 case .repository(let repository):
297 repoPath = NavigationPath()
298 appState.selectedTab = .repositories
299 Task {
300 await settleNavigationTransition()
301 repoPath.append(repository)
302 }
303
304 case .tracker(let tracker):
305 ticketsPath = NavigationPath()
306 appState.selectedTab = .tickets
307 Task {
308 await settleNavigationTransition()
309 ticketsPath.append(tracker)
310 }
311
312 case .mailingList(let mailingList):
313 morePath = NavigationPath()
314 appState.selectedTab = .more
315 Task {
316 await settleNavigationTransition()
317 morePath.append(MoreRoute.lists)
318 morePath.append(MoreRoute.mailingList(mailingList))
319 }
320 case .systemStatus:
321 morePath = NavigationPath()
322 appState.selectedTab = .more
323 Task {
324 await settleNavigationTransition()
325 morePath.append(MoreRoute.systemStatus)
326 }
327 case .builds:
328 buildsPath = NavigationPath()
329 appState.selectedTab = .builds
330 }
331 }
332
333 private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
334 isResolvingDeepLink = true
335 Task {
336 defer { isResolvingDeepLink = false }
337 do {
338 let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
339 repoPath = NavigationPath()
340 appState.selectedTab = .repositories
341 await settleNavigationTransition()
342 repoPath.append(summary)
343 } catch {
344 appState.presentRepositoryDeepLinkError()
345 }
346 }
347 }
348
349 private func resolveTrackerLink(owner: String, tracker: String) {
350 isResolvingDeepLink = true
351 Task {
352 defer { isResolvingDeepLink = false }
353 do {
354 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
355 ticketsPath = NavigationPath()
356 appState.selectedTab = .tickets
357 await settleNavigationTransition()
358 ticketsPath.append(trackerSummary)
359 } catch {
360 appState.presentTicketDeepLinkError()
361 }
362 }
363 }
364
365 private func resolveMailingListLink(owner: String, list: String) {
366 isResolvingDeepLink = true
367 Task {
368 defer { isResolvingDeepLink = false }
369 do {
370 let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
371 morePath = NavigationPath()
372 appState.selectedTab = .more
373 await settleNavigationTransition()
374 morePath.append(MoreRoute.lists)
375 morePath.append(MoreRoute.mailingList(mailingList))
376 } catch {
377 appState.deepLinkError = "The mailing list could not be found or is inaccessible."
378 }
379 }
380 }
381
382 private func resolveUserProfileLink(owner: String) {
383 rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
384 morePath = NavigationPath()
385 appState.selectedTab = .more
386 Task {
387 await settleNavigationTransition()
388 rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
389 morePath.append(MoreRoute.userProfile(owner))
390 }
391 }
392
393 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
394 isResolvingDeepLink = true
395 Task {
396 defer { isResolvingDeepLink = false }
397 do {
398 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
399 ticketsPath = NavigationPath()
400 appState.selectedTab = .tickets
401 await settleNavigationTransition()
402 ticketsPath.append(trackerSummary)
403 ticketsPath.append(TicketDeepLinkTarget(
404 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
405 trackerName: trackerSummary.name,
406 trackerId: trackerSummary.id,
407 trackerRid: trackerSummary.rid,
408 ticketId: ticketId
409 ))
410 } catch {
411 appState.presentTicketDeepLinkError()
412 }
413 }
414 }
415
416 @MainActor
417 private func settleNavigationTransition() async {
418 await Task.yield()
419 await Task.yield()
420 }
421}
422
423enum MoreDestination: Hashable {
424 case lists
425 case pastes
426 case settings
427}
428
429enum MoreRoute: Hashable {
430 case lookup(query: String?)
431 case projects
432 case lists
433 case pastes
434 case profile
435 case systemStatus
436 case settings
437 case about
438 case userProfile(String)
439 case projectDashboard(id: String, title: String?)
440 case mailingList(InboxMailingListReference)
441 case thread(InboxThreadSummary)
442 case manPageBrowser
443 case manPage(URL)
444}
445
446private struct MoreNavigationRoot: View {
447 @Environment(AppState.self) private var appState
448
449 var body: some View {
450 MoreView()
451 .navigationDestination(for: MoreRoute.self) { route in
452 switch route {
453 case .lookup(let query):
454 LookupView(initialQuery: query ?? "")
455 case .projects:
456 ProjectsListView()
457 case .lists:
458 MailingListListView()
459 case .pastes:
460 PasteListView()
461 case .profile:
462 ProfileView()
463 case .systemStatus:
464 SystemStatusView()
465 case .settings:
466 SettingsView()
467 case .about:
468 AboutView()
469 case .userProfile(let owner):
470 UserProfileDeepLinkView(owner: owner)
471 case .projectDashboard(let id, let title):
472 ProjectDashboardDeepLinkView(projectID: id, title: title)
473 case .mailingList(let mailingList):
474 MailingListDetailView(mailingList: mailingList)
475 case .thread(let thread):
476 ThreadDetailView(
477 thread: thread,
478 onViewed: {
479 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
480 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
481 },
482 onMarkRead: {
483 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
484 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
485 },
486 onMarkUnread: {
487 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
488 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
489 }
490 )
491 case .manPageBrowser:
492 ManPageBrowserView()
493 case .manPage(let url):
494 ManPageDetailView(url: url)
495 }
496 }
497 }
498}
499
500struct UserProfileDeepLinkView: View {
501 private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
502 @Environment(AppState.self) private var appState
503 let owner: String
504 @State private var user: User?
505 @State private var errorMessage: String?
506
507 var body: some View {
508 Group {
509 if let user {
510 UserProfileView(user: user)
511 } else if let errorMessage {
512 ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
513 } else {
514 SRHTLoadingStateView(message: "Loading profile...")
515 }
516 }
517 .navigationTitle(displayOwner)
518 .navigationBarTitleDisplayMode(.inline)
519 .task(id: owner) {
520 await loadProfile()
521 }
522 }
523
524 private var displayOwner: String {
525 owner.hasPrefix("~") ? owner : "~\(owner)"
526 }
527
528 @MainActor
529 private func loadProfile() async {
530 logger.info("Loading user profile for owner=\(owner, privacy: .public)")
531 errorMessage = nil
532 do {
533 user = try await appState.resolveUser(username: owner)
534 logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
535 } catch {
536 logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
537 errorMessage = "The user profile could not be found or is inaccessible."
538 }
539 }
540}
541
542struct ProjectDashboardDeepLinkView: View {
543 @Environment(AppState.self) private var appState
544 let projectID: String
545 let title: String?
546 @State private var project: Project?
547 @State private var errorMessage: String?
548
549 var body: some View {
550 Group {
551 if let project {
552 ProjectDetailView(project: project)
553 } else if let errorMessage {
554 ContentUnavailableView(
555 "Couldn't Open Project",
556 systemImage: "square.stack.3d.up.slash",
557 description: Text(errorMessage)
558 )
559 } else {
560 SRHTLoadingStateView(message: "Loading project...")
561 }
562 }
563 .navigationTitle(title ?? "Project")
564 .navigationBarTitleDisplayMode(.inline)
565 .task(id: projectID) {
566 await loadProject()
567 }
568 }
569
570 @MainActor
571 private func loadProject() async {
572 errorMessage = nil
573 do {
574 project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID)
575 } catch {
576 errorMessage = "The project could not be found or is inaccessible."
577 }
578 }
579}
580
581// MARK: - Ticket Deep Link Navigation Target
582
583/// Hashable wrapper to push a ticket detail view from a deep link.
584struct TicketDeepLinkTarget: Hashable {
585 let ownerUsername: String
586 let trackerName: String
587 let trackerId: Int
588 let trackerRid: String
589 let ticketId: Int
590}
591
592// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
593
594/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
595private struct TabKeyboardShortcuts: ViewModifier {
596 @Binding var selectedTab: AppState.Tab
597
598 private static let tabMap: [String: AppState.Tab] = [
599 "1": .home,
600 "2": .repositories,
601 "3": .tickets,
602 "4": .builds,
603 "5": .more,
604 ]
605
606 func body(content: Content) -> some View {
607 content
608 .onKeyPress(characters: .decimalDigits, phases: .down) { press in
609 guard press.modifiers == .command else { return .ignored }
610 let key = String(press.characters)
611 if let tab = Self.tabMap[key] {
612 selectedTab = tab
613 return .handled
614 }
615 return .ignored
616 }
617 }
618}
619
620// MARK: - AMOLED Toolbar Styling
621
622/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
623private struct AMOLEDToolbarStyle: ViewModifier {
624 let isAMOLED: Bool
625
626 func body(content: Content) -> some View {
627 if isAMOLED {
628 content
629 .toolbarBackground(Color.black, for: .tabBar)
630 .toolbarBackground(.visible, for: .tabBar)
631 .toolbarBackground(Color.black, for: .navigationBar)
632 .toolbarBackground(.visible, for: .navigationBar)
633 } else {
634 content
635 }
636 }
637}
638
639// MARK: - iPad Sidebar Adaptable
640
641/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
642/// becomes a full sidebar on iPad, while falling back to the standard tab
643/// bar on earlier releases.
644private struct SidebarAdaptableTabStyle: ViewModifier {
645 func body(content: Content) -> some View {
646 if #available(iOS 18.0, *) {
647 content.tabViewStyle(.sidebarAdaptable)
648 } else {
649 content
650 }
651 }
652}