diff options
| author | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
| commit | 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch) | |
| tree | ee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/App/RootView.swift | |
| parent | 8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff) | |
| download | hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2 hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip | |
v1.0
Diffstat (limited to 'Hutch/App/RootView.swift')
| -rw-r--r-- | Hutch/App/RootView.swift | 197 |
1 files changed, 197 insertions, 0 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift new file mode 100644 index 0000000..7af162d --- /dev/null +++ b/Hutch/App/RootView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +/// The root view of the app. Shows a TabView when authenticated, or a +/// full-screen sheet for token entry on first launch. +struct RootView: View { + @Environment(AppState.self) private var appState + + enum Tab: Hashable { + case repositories + case builds + case tickets + case settings + } + + @State private var selectedTab: Tab = .repositories + @State private var repoPath = NavigationPath() + @State private var buildsPath = NavigationPath() + @State private var ticketsPath = NavigationPath() + @State private var isResolvingDeepLink = false + + var body: some View { + Group { + switch appState.authPhase { + case .launching: + ProgressView("Connecting…") + .task { + await appState.validateOnLaunch() + } + + case .unauthenticated: + // Full-screen token entry that cannot be dismissed. + TokenEntryView() + + case .authenticated: + tabContent + } + } + .onChange(of: appState.pendingDeepLink) { _, newValue in + consumePendingDeepLinkIfPossible(newValue) + } + .onChange(of: appState.authPhase) { _, newPhase in + handleAuthPhaseChange(newPhase) + } + } + + // MARK: - Tab View + + private var tabContent: some View { + TabView(selection: $selectedTab) { + NavigationStack(path: $repoPath) { + RepositoryListView() + } + .tag(Tab.repositories) + .tabItem { + Label("Repositories", systemImage: "book.closed") + } + + NavigationStack(path: $buildsPath) { + BuildListView() + // Int destination used by deep links (hutch://builds/<id>). + // JobSummary destination is registered inside BuildListView. + .navigationDestination(for: Int.self) { jobId in + BuildDetailView(jobId: jobId) + } + } + .tag(Tab.builds) + .tabItem { + Label("Builds", systemImage: "hammer") + } + + NavigationStack(path: $ticketsPath) { + TrackerListView() + // Deep link destination for jumping straight to a ticket. + .navigationDestination(for: TicketDeepLinkTarget.self) { target in + TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) + } + } + .tag(Tab.tickets) + .tabItem { + Label("Tickets", systemImage: "ticket") + } + + SettingsView() + .tag(Tab.settings) + .tabItem { + Label("Settings", systemImage: "gear") + } + } + .overlay { + if isResolvingDeepLink { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView("Opening link…") + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + } + + // MARK: - Deep Link Handling + + private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) { + switch newPhase { + case .launching: + break + case .unauthenticated: + repoPath = NavigationPath() + buildsPath = NavigationPath() + ticketsPath = NavigationPath() + selectedTab = .repositories + isResolvingDeepLink = false + case .authenticated: + consumePendingDeepLinkIfPossible(appState.pendingDeepLink) + } + } + + private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) { + guard appState.isAuthenticated, let link else { return } + handleDeepLink(link) + appState.pendingDeepLink = nil + } + + private func handleDeepLink(_ link: DeepLink) { + guard appState.isAuthenticated else { return } + + switch link { + case .repository(let owner, let repo): + resolveRepositoryLink(owner: owner, repo: repo) + + case .build(let jobId): + // Reset the builds navigation and push the detail + buildsPath = NavigationPath() + selectedTab = .builds + // Defer the push slightly so the tab switch takes effect + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + buildsPath.append(jobId) + } + + case .ticket(let owner, let tracker, let ticketId): + resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) + } + } + + private func resolveRepositoryLink(owner: String, repo: String) { + isResolvingDeepLink = true + Task { + defer { isResolvingDeepLink = false } + do { + let summary = try await appState.resolveRepository(owner: owner, name: repo) + repoPath = NavigationPath() + selectedTab = .repositories + try? await Task.sleep(for: .milliseconds(100)) + repoPath.append(summary) + } catch { + // Silently fail — the repo may not exist or be inaccessible + } + } + } + + private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) { + isResolvingDeepLink = true + Task { + defer { isResolvingDeepLink = false } + do { + let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) + ticketsPath = NavigationPath() + selectedTab = .tickets + try? await Task.sleep(for: .milliseconds(100)) + ticketsPath.append(trackerSummary) + try? await Task.sleep(for: .milliseconds(100)) + ticketsPath.append(TicketDeepLinkTarget( + ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()), + trackerName: trackerSummary.name, + trackerId: trackerSummary.id, + trackerRid: trackerSummary.rid, + ticketId: ticketId + )) + } catch { + // Silently fail + } + } + } +} + +// MARK: - Ticket Deep Link Navigation Target + +/// Hashable wrapper to push a ticket detail view from a deep link. +struct TicketDeepLinkTarget: Hashable { + let ownerUsername: String + let trackerName: String + let trackerId: Int + let trackerRid: String + let ticketId: Int +} |
