import SwiftUI struct ContentView: View { @Environment(SessionStore.self) private var session @Environment(PushRouter.self) private var router enum Tabs { case dashboard, feed, repositories, explore, profile } @State private var selectedTab = Tabs.dashboard @State private var dashboardPath = NavigationPath() /// Which account `dashboardPath`'s routes were resolved against. /// /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so /// this view's own state outlives an account switch while the stack /// it feeds is rebuilt. A path carried across the switch would be /// re-pushed against the new account's client — someone else's /// issue number, usually a 404. Empty while signed out. @State private var pathAccount = "" var body: some View { if let client = session.client, let account = session.current { TabView(selection: $selectedTab) { Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) { NavigationStack(path: $dashboardPath) { DashboardView(client: client) .navigationDestinations(client: client) } } Tab("Feed", systemImage: "bolt", value: Tabs.feed) { NavigationStack { FeedView(client: client) .navigationDestinations(client: client) } } Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) { NavigationStack { RepoListView(client: client) .navigationDestinations(client: client) } } Tab("Explore", systemImage: "safari", value: Tabs.explore) { NavigationStack { ExploreView(client: client) .navigationDestinations(client: client) } } Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) { NavigationStack { ProfileView(client: client, name: account.username, title: "My Profile") .toolbar { AccountMenu() } .navigationDestinations(client: client) } } } .id(account.id) // a fresh tab bar and stacks per account .task(id: DrainKey(account: account.id, target: router.pending)) { await routePendingNotification(account) } } else { NavigationStack { SignInView() } // Signing out leaves the stale path behind in this view's // state; disowning it makes the next sign-in clear it. .task { pathAccount = "" } } } /// What makes the drain run again. /// /// The account is in here because the cross-account pass leaves /// `pending` set on purpose, so the target alone does not change /// across the switch it performs. Keying on both means the drain /// does not depend on whether `.id()` propagates its identity /// outward through the `.task` modifier attached after it. nonisolated private struct DrainKey: Equatable, Sendable { let account: String let target: PushTarget? } /// Resets the navigation state an account switch invalidated, then /// routes a tapped notification, switching accounts first when it /// belongs to another one. /// /// A switch changes `account.id`, which rebuilds the tab bar. That /// is why the target is left pending across it: this runs again /// against the new account and finds it already active, so the /// second pass does the navigating. The drain is the mechanism, not /// a workaround for it — and it is also where the path is reset, /// because every switch comes through here whether a notification /// is pending or not. private func routePendingNotification(_ account: Account) async { if pathAccount != account.id { dashboardPath = NavigationPath() selectedTab = .dashboard pathAccount = account.id } guard let target = router.pending else { return } if account.id != target.accountID { guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { router.pending = nil // signed out of it since the tap return } // A token gone from the Keychain drops the account and // leaves `current` alone, so nothing would rebuild and // drain; the target would outlive every later tap, which // cannot change the key either. guard session.activate(other) else { router.pending = nil return } return // the rebuilt tree drains it } selectedTab = .dashboard switch target.destination { case .issue(let repo, let number): dashboardPath.append(IssueRoute.issue(repo: repo, number: number)) case .mr(let repo, let number): dashboardPath.append(MRRoute.mr(repo: repo, number: number)) case .build(let repo, let number): dashboardPath.append(BuildRoute.detail(repo: repo, number: number)) } router.pending = nil } } /// Every value-routed screen, attachable to any stack root. private struct RouteDestinations: ViewModifier { let client: GitbayClient func body(content: Content) -> some View { content .navigationDestination(for: RepoRoute.self) { route in destination(route, client: client) } .navigationDestination(for: MRRoute.self) { route in destination(route, client: client) } .navigationDestination(for: IssueRoute.self) { route in destination(route, client: client) } .navigationDestination(for: BuildRoute.self) { route in destination(route, client: client) } .navigationDestination(for: ReleaseRoute.self) { route in destination(route, client: client) } .navigationDestination(for: OrgRoute.self) { route in destination(route, client: client) } .navigationDestination(for: SnippetRoute.self) { route in destination(route, client: client) } } @ViewBuilder private func destination(_ route: RepoRoute, client: GitbayClient) -> some View { switch route { case .repo(let path): RepoView(client: client, path: path) case .tree(let repo, let directory, let ref): TreeView(client: client, repo: repo, directory: directory, ref: ref) case .file(let repo, let path, let ref): FileView(client: client, repo: repo, path: path, ref: ref) case .log(let repo, let ref, let path): LogView(client: client, repo: repo, ref: ref, path: path) case .settings(let repo): RepoSettingsView(client: client, repo: repo) case .grep(let repo): GrepView(client: client, repo: repo) case .blame(let repo, let path, let ref): BlameView(client: client, repo: repo, path: path, ref: ref) case .refs(let repo): RefsView(client: client, repo: repo) case .compare(let repo, let defaultBranch): CompareView(client: client, repo: repo, defaultBranch: defaultBranch) case .milestones(let repo): MilestoneListView(client: client, repo: repo) case .labels(let repo): LabelListView(client: client, repo: repo) case .bookmarks: BookmarkListView(client: client) case .webhooks(let repo): WebhookListView(client: client, repo: repo) case .webhookDeliveries(let repo): WebhookDeliveriesView(client: client, repo: repo) case .wiki(let repo): WikiView(client: client, repo: repo) case .wikiPage(let repo, let page): WikiPageView(client: client, repo: repo, page: page) case .commit(let repo, let sha): CommitView(client: client, repo: repo, sha: sha) case .profile(let name): ProfileView(client: client, name: name) case .account: AccountView(client: client) case .addAccount: SignInView() case .notifications: NotificationsView(client: client) } } @ViewBuilder private func destination(_ route: MRRoute, client: GitbayClient) -> some View { switch route { case .list(let repo): MRListView(client: client, repo: repo) case .mr(let repo, let number): MRView(client: client, repo: repo, number: number) case .diff(let repo, let number): DiffView(client: client, repo: repo, number: number) case .rangeDiff(let repo, let number, let from, let to): RangeDiffView(client: client, repo: repo, number: number, from: from, to: to) } } @ViewBuilder private func destination(_ route: IssueRoute, client: GitbayClient) -> some View { switch route { case .list(let repo): IssueListView(client: client, repo: repo) case .issue(let repo, let number): IssueView(client: client, repo: repo, number: number) } } @ViewBuilder private func destination(_ route: OrgRoute, client: GitbayClient) -> some View { switch route { case .org(let name): OrgView(client: client, org: name) case .team(let org, let team): TeamView(client: client, org: org, team: team) case .labels(let org): LabelListView(client: client, owner: .org(org)) case .milestones(let org): MilestoneListView(client: client, owner: .org(org)) } } @ViewBuilder private func destination(_ route: SnippetRoute, client: GitbayClient) -> some View { switch route { case .list(let owner): SnippetListView(client: client, owner: owner) case .snippet(let id): SnippetView(client: client, id: id) } } @ViewBuilder private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View { switch route { case .list(let repo): ReleaseListView(client: client, repo: repo) case .release(let repo, let tag): ReleaseView(client: client, repo: repo, tag: tag) } } @ViewBuilder private func destination(_ route: BuildRoute, client: GitbayClient) -> some View { switch route { case .list(let repo): BuildListView(client: client, repo: repo) case .detail(let repo, let number): BuildDetailView(client: client, repo: repo, number: number) } } } extension View { func navigationDestinations(client: GitbayClient) -> some View { modifier(RouteDestinations(client: client)) } } #Preview { // Every environment value the signed-in branch reads, so the // preview does not depend on the machine's Keychain being empty. let session = SessionStore() ContentView() .environment(session) .environment(PushRouter()) .environment(PushRegistrar(session: session)) }