Hutch/App/RootView.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/App/RootView.swift history · blame · raw

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}