krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.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 /// Replaces the target tab's path in one assignment.
295 ///
296 /// Resetting the path and appending to it afterwards races when the target tab
297 /// is already the one on screen: the reset starts an animated pop of the view
298 /// the user is standing on, and the appends land mid-animation, leaving a blank
299 /// screen. That is why opening a mailing list from a pinned project on Home
300 /// worked while the same tap under More → Projects did not — one changes tabs
301 /// and the other does not.
302 ///
303 /// Building the whole path first and assigning once gives SwiftUI a single
304 /// diff, with nothing to race.
305 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
306 switch target {
307 case .repository(let repository):
308 var path = NavigationPath()
309 path.append(repository)
310 repoPath = path
311 appState.selectedTab = .repositories
312
313 case .tracker(let tracker):
314 var path = NavigationPath()
315 path.append(tracker)
316 ticketsPath = path
317 appState.selectedTab = .tickets
318
319 case .mailingList(let mailingList):
320 // .lists first so back lands on Mailing Lists rather than dead-ending.
321 var path = NavigationPath()
322 path.append(MoreRoute.lists)
323 path.append(MoreRoute.mailingList(mailingList))
324 morePath = path
325 appState.selectedTab = .more
326
327 case .systemStatus:
328 var path = NavigationPath()
329 path.append(MoreRoute.systemStatus)
330 morePath = path
331 appState.selectedTab = .more
332
333 case .builds:
334 buildsPath = NavigationPath()
335 appState.selectedTab = .builds
336 }
337 }
338
339 private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
340 isResolvingDeepLink = true
341 Task {
342 defer { isResolvingDeepLink = false }
343 do {
344 let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
345 repoPath = NavigationPath()
346 appState.selectedTab = .repositories
347 await settleNavigationTransition()
348 repoPath.append(summary)
349 } catch {
350 appState.presentRepositoryDeepLinkError()
351 }
352 }
353 }
354
355 private func resolveTrackerLink(owner: String, tracker: String) {
356 isResolvingDeepLink = true
357 Task {
358 defer { isResolvingDeepLink = false }
359 do {
360 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
361 ticketsPath = NavigationPath()
362 appState.selectedTab = .tickets
363 await settleNavigationTransition()
364 ticketsPath.append(trackerSummary)
365 } catch {
366 appState.presentTicketDeepLinkError()
367 }
368 }
369 }
370
371 private func resolveMailingListLink(owner: String, list: String) {
372 isResolvingDeepLink = true
373 Task {
374 defer { isResolvingDeepLink = false }
375 do {
376 let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
377 morePath = NavigationPath()
378 appState.selectedTab = .more
379 await settleNavigationTransition()
380 morePath.append(MoreRoute.lists)
381 morePath.append(MoreRoute.mailingList(mailingList))
382 } catch {
383 appState.deepLinkError = "The mailing list could not be found or is inaccessible."
384 }
385 }
386 }
387
388 private func resolveUserProfileLink(owner: String) {
389 rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
390 morePath = NavigationPath()
391 appState.selectedTab = .more
392 Task {
393 await settleNavigationTransition()
394 rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
395 morePath.append(MoreRoute.userProfile(owner))
396 }
397 }
398
399 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
400 isResolvingDeepLink = true
401 Task {
402 defer { isResolvingDeepLink = false }
403 do {
404 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
405 ticketsPath = NavigationPath()
406 appState.selectedTab = .tickets
407 await settleNavigationTransition()
408 ticketsPath.append(trackerSummary)
409 ticketsPath.append(TicketDeepLinkTarget(
410 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
411 trackerName: trackerSummary.name,
412 trackerId: trackerSummary.id,
413 trackerRid: trackerSummary.rid,
414 ticketId: ticketId
415 ))
416 } catch {
417 appState.presentTicketDeepLinkError()
418 }
419 }
420 }
421
422 @MainActor
423 private func settleNavigationTransition() async {
424 await Task.yield()
425 await Task.yield()
426 }
427}
428
429enum MoreDestination: Hashable {
430 case lists
431 case pastes
432 case settings
433}
434
435enum MoreRoute: Hashable {
436 case lookup(query: String?)
437 case projects
438 case lists
439 case pastes
440 case profile
441 case systemStatus
442 case settings
443 case about
444 case userProfile(String)
445 case projectDashboard(id: String, title: String?)
446 case mailingList(InboxMailingListReference)
447 case thread(InboxThreadSummary)
448 case manPageBrowser
449 case manPage(URL)
450}
451
452private struct MoreNavigationRoot: View {
453 @Environment(AppState.self) private var appState
454
455 var body: some View {
456 MoreView()
457 .navigationDestination(for: MoreRoute.self) { route in
458 switch route {
459 case .lookup(let query):
460 LookupView(initialQuery: query ?? "")
461 case .projects:
462 ProjectsListView()
463 case .lists:
464 MailingListListView()
465 case .pastes:
466 PasteListView()
467 case .profile:
468 ProfileView()
469 case .systemStatus:
470 SystemStatusView()
471 case .settings:
472 SettingsView()
473 case .about:
474 AboutView()
475 case .userProfile(let owner):
476 UserProfileDeepLinkView(owner: owner)
477 case .projectDashboard(let id, let title):
478 ProjectDashboardDeepLinkView(projectID: id, title: title)
479 case .mailingList(let mailingList):
480 MailingListDetailView(mailingList: mailingList)
481 case .thread(let thread):
482 ThreadDetailView(
483 thread: thread,
484 onViewed: {
485 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
486 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
487 },
488 onMarkRead: {
489 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
490 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
491 },
492 onMarkUnread: {
493 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
494 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
495 }
496 )
497 case .manPageBrowser:
498 ManPageBrowserView()
499 case .manPage(let url):
500 ManPageDetailView(url: url)
501 }
502 }
503 }
504}
505
506struct UserProfileDeepLinkView: View {
507 private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
508 @Environment(AppState.self) private var appState
509 let owner: String
510 @State private var user: User?
511 @State private var errorMessage: String?
512
513 var body: some View {
514 Group {
515 if let user {
516 UserProfileView(user: user)
517 } else if let errorMessage {
518 ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
519 } else {
520 SRHTLoadingStateView(message: "Loading profile...")
521 }
522 }
523 .navigationTitle(displayOwner)
524 .navigationBarTitleDisplayMode(.inline)
525 .task(id: owner) {
526 await loadProfile()
527 }
528 }
529
530 private var displayOwner: String {
531 owner.hasPrefix("~") ? owner : "~\(owner)"
532 }
533
534 @MainActor
535 private func loadProfile() async {
536 logger.info("Loading user profile for owner=\(owner, privacy: .public)")
537 errorMessage = nil
538 do {
539 user = try await appState.resolveUser(username: owner)
540 logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
541 } catch {
542 logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
543 errorMessage = "The user profile could not be found or is inaccessible."
544 }
545 }
546}
547
548struct ProjectDashboardDeepLinkView: View {
549 @Environment(AppState.self) private var appState
550 let projectID: String
551 let title: String?
552 @State private var project: Project?
553 @State private var errorMessage: String?
554
555 var body: some View {
556 Group {
557 if let project {
558 ProjectDetailView(project: project)
559 } else if let errorMessage {
560 ContentUnavailableView(
561 "Couldn't Open Project",
562 systemImage: "square.stack.3d.up.slash",
563 description: Text(errorMessage)
564 )
565 } else {
566 SRHTLoadingStateView(message: "Loading project...")
567 }
568 }
569 .navigationTitle(title ?? "Project")
570 .navigationBarTitleDisplayMode(.inline)
571 .task(id: projectID) {
572 await loadProject()
573 }
574 }
575
576 @MainActor
577 private func loadProject() async {
578 errorMessage = nil
579 do {
580 project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID)
581 } catch {
582 errorMessage = "The project could not be found or is inaccessible."
583 }
584 }
585}
586
587// MARK: - Ticket Deep Link Navigation Target
588
589/// Hashable wrapper to push a ticket detail view from a deep link.
590struct TicketDeepLinkTarget: Hashable {
591 let ownerUsername: String
592 let trackerName: String
593 let trackerId: Int
594 let trackerRid: String
595 let ticketId: Int
596}
597
598// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
599
600/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
601private struct TabKeyboardShortcuts: ViewModifier {
602 @Binding var selectedTab: AppState.Tab
603
604 private static let tabMap: [String: AppState.Tab] = [
605 "1": .home,
606 "2": .repositories,
607 "3": .tickets,
608 "4": .builds,
609 "5": .more,
610 ]
611
612 func body(content: Content) -> some View {
613 content
614 .onKeyPress(characters: .decimalDigits, phases: .down) { press in
615 guard press.modifiers == .command else { return .ignored }
616 let key = String(press.characters)
617 if let tab = Self.tabMap[key] {
618 selectedTab = tab
619 return .handled
620 }
621 return .ignored
622 }
623 }
624}
625
626// MARK: - AMOLED Toolbar Styling
627
628/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
629private struct AMOLEDToolbarStyle: ViewModifier {
630 let isAMOLED: Bool
631
632 func body(content: Content) -> some View {
633 if isAMOLED {
634 content
635 .toolbarBackground(Color.black, for: .tabBar)
636 .toolbarBackground(.visible, for: .tabBar)
637 .toolbarBackground(Color.black, for: .navigationBar)
638 .toolbarBackground(.visible, for: .navigationBar)
639 } else {
640 content
641 }
642 }
643}
644
645// MARK: - iPad Sidebar Adaptable
646
647/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
648/// becomes a full sidebar on iPad, while falling back to the standard tab
649/// bar on earlier releases.
650private struct SidebarAdaptableTabStyle: ViewModifier {
651 func body(content: Content) -> some View {
652 if #available(iOS 18.0, *) {
653 content.tabViewStyle(.sidebarAdaptable)
654 } else {
655 content
656 }
657 }
658}