Hutch/App/RootView.swift
197 lines · 6834 bytes
1import SwiftUI
2
3/// The root view of the app. Shows a TabView when authenticated, or a
4/// full-screen sheet for token entry on first launch.
5struct RootView: View {
6 @Environment(AppState.self) private var appState
7
8 enum Tab: Hashable {
9 case repositories
10 case builds
11 case tickets
12 case settings
13 }
14
15 @State private var selectedTab: Tab = .repositories
16 @State private var repoPath = NavigationPath()
17 @State private var buildsPath = NavigationPath()
18 @State private var ticketsPath = NavigationPath()
19 @State private var isResolvingDeepLink = false
20
21 var body: some View {
22 Group {
23 switch appState.authPhase {
24 case .launching:
25 ProgressView("Connecting…")
26 .task {
27 await appState.validateOnLaunch()
28 }
29
30 case .unauthenticated:
31 // Full-screen token entry that cannot be dismissed.
32 TokenEntryView()
33
34 case .authenticated:
35 tabContent
36 }
37 }
38 .onChange(of: appState.pendingDeepLink) { _, newValue in
39 consumePendingDeepLinkIfPossible(newValue)
40 }
41 .onChange(of: appState.authPhase) { _, newPhase in
42 handleAuthPhaseChange(newPhase)
43 }
44 }
45
46 // MARK: - Tab View
47
48 private var tabContent: some View {
49 TabView(selection: $selectedTab) {
50 NavigationStack(path: $repoPath) {
51 RepositoryListView()
52 }
53 .tag(Tab.repositories)
54 .tabItem {
55 Label("Repositories", systemImage: "book.closed")
56 }
57
58 NavigationStack(path: $buildsPath) {
59 BuildListView()
60 // Int destination used by deep links (hutch://builds/<id>).
61 // JobSummary destination is registered inside BuildListView.
62 .navigationDestination(for: Int.self) { jobId in
63 BuildDetailView(jobId: jobId)
64 }
65 }
66 .tag(Tab.builds)
67 .tabItem {
68 Label("Builds", systemImage: "hammer")
69 }
70
71 NavigationStack(path: $ticketsPath) {
72 TrackerListView()
73 // Deep link destination for jumping straight to a ticket.
74 .navigationDestination(for: TicketDeepLinkTarget.self) { target in
75 TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
76 }
77 }
78 .tag(Tab.tickets)
79 .tabItem {
80 Label("Tickets", systemImage: "ticket")
81 }
82
83 SettingsView()
84 .tag(Tab.settings)
85 .tabItem {
86 Label("Settings", systemImage: "gear")
87 }
88 }
89 .overlay {
90 if isResolvingDeepLink {
91 ZStack {
92 Color.black.opacity(0.3)
93 .ignoresSafeArea()
94 ProgressView("Opening link…")
95 .padding()
96 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
97 }
98 }
99 }
100 }
101
102 // MARK: - Deep Link Handling
103
104 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
105 switch newPhase {
106 case .launching:
107 break
108 case .unauthenticated:
109 repoPath = NavigationPath()
110 buildsPath = NavigationPath()
111 ticketsPath = NavigationPath()
112 selectedTab = .repositories
113 isResolvingDeepLink = false
114 case .authenticated:
115 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
116 }
117 }
118
119 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
120 guard appState.isAuthenticated, let link else { return }
121 handleDeepLink(link)
122 appState.pendingDeepLink = nil
123 }
124
125 private func handleDeepLink(_ link: DeepLink) {
126 guard appState.isAuthenticated else { return }
127
128 switch link {
129 case .repository(let owner, let repo):
130 resolveRepositoryLink(owner: owner, repo: repo)
131
132 case .build(let jobId):
133 // Reset the builds navigation and push the detail
134 buildsPath = NavigationPath()
135 selectedTab = .builds
136 // Defer the push slightly so the tab switch takes effect
137 Task { @MainActor in
138 try? await Task.sleep(for: .milliseconds(100))
139 buildsPath.append(jobId)
140 }
141
142 case .ticket(let owner, let tracker, let ticketId):
143 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
144 }
145 }
146
147 private func resolveRepositoryLink(owner: String, repo: String) {
148 isResolvingDeepLink = true
149 Task {
150 defer { isResolvingDeepLink = false }
151 do {
152 let summary = try await appState.resolveRepository(owner: owner, name: repo)
153 repoPath = NavigationPath()
154 selectedTab = .repositories
155 try? await Task.sleep(for: .milliseconds(100))
156 repoPath.append(summary)
157 } catch {
158 // Silently fail — the repo may not exist or be inaccessible
159 }
160 }
161 }
162
163 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
164 isResolvingDeepLink = true
165 Task {
166 defer { isResolvingDeepLink = false }
167 do {
168 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
169 ticketsPath = NavigationPath()
170 selectedTab = .tickets
171 try? await Task.sleep(for: .milliseconds(100))
172 ticketsPath.append(trackerSummary)
173 try? await Task.sleep(for: .milliseconds(100))
174 ticketsPath.append(TicketDeepLinkTarget(
175 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
176 trackerName: trackerSummary.name,
177 trackerId: trackerSummary.id,
178 trackerRid: trackerSummary.rid,
179 ticketId: ticketId
180 ))
181 } catch {
182 // Silently fail
183 }
184 }
185 }
186}
187
188// MARK: - Ticket Deep Link Navigation Target
189
190/// Hashable wrapper to push a ticket detail view from a deep link.
191struct TicketDeepLinkTarget: Hashable {
192 let ownerUsername: String
193 let trackerName: String
194 let trackerId: Int
195 let trackerRid: String
196 let ticketId: Int
197}