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