import SwiftUI struct ContentView: View { @Environment(SessionStore.self) private var session @Environment(PushRouter.self) private var router @Environment(\.openURL) private var openURL @Environment(\.horizontalSizeClass) private var sizeClass enum Tabs { case dashboard, feed, repositories, explore, profile } @State private var selectedTab = Tabs.dashboard @State private var dashboardPath = NavigationPath() @State private var repoPath = NavigationPath() @State private var feedPath = NavigationPath() @State private var explorePath = NavigationPath() @State private var profilePath = NavigationPath() @State private var unread = 0 /// whoami's admin flag, read on the My Profile tab, decides whether /// the account menu offers Admin. @State private var isAdmin = false /// 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) { if sizeClass == .regular { SplitStack(path: $dashboardPath, client: client, placeholder: "Nothing selected") { DashboardView(client: client, unread: $unread) } } else { NavigationStack(path: $dashboardPath) { DashboardView(client: client, unread: $unread) .navigationDestinations(client: client) } .pushesRoutes(into: $dashboardPath) } } .badge(unread) Tab("Feed", systemImage: "bolt", value: Tabs.feed) { NavigationStack(path: $feedPath) { FeedView(client: client) .navigationDestinations(client: client) } .pushesRoutes(into: $feedPath) } Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) { if sizeClass == .regular { SplitStack(path: $repoPath, client: client, placeholder: "No repository selected") { RepoListView(client: client) } } else { NavigationStack(path: $repoPath) { RepoListView(client: client) .navigationDestinations(client: client) } .pushesRoutes(into: $repoPath) } } Tab("Explore", systemImage: "safari", value: Tabs.explore) { NavigationStack(path: $explorePath) { ExploreView(client: client) .navigationDestinations(client: client) } .pushesRoutes(into: $explorePath) } Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) { NavigationStack(path: $profilePath) { ProfileView(client: client, name: account.username, title: "My Profile") .toolbar { AccountMenu(isAdmin: isAdmin) } .task { isAdmin = false isAdmin = await AdminCheck.isAdmin(client) } .navigationDestinations(client: client) } .pushesRoutes(into: $profilePath) } } .id(account.id) // a fresh tab bar and stacks per account .task(id: DrainKey(account: account.id, target: router.pending, link: router.pendingLink)) { 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? let link: URL? } /// Resets the navigation state an account switch invalidated, then /// routes a tapped notification, switching accounts first when it /// belongs to another one. With no notification pending, a pending /// universal link is routed instead. /// /// 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() repoPath = NavigationPath() feedPath = NavigationPath() explorePath = NavigationPath() profilePath = NavigationPath() selectedTab = .dashboard pathAccount = account.id } guard let target = router.pending else { routePendingLink(account) 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 } /// Opens a pending universal link on the Dashboard stack, under the /// current account when it is on the link's instance, otherwise under /// one that is. Switching leaves the link pending for the rebuilt /// tree, as a notification does. With no account there, or a token /// gone, the link goes to Safari. private func routePendingLink(_ account: Account) { guard let url = router.pendingLink else { return } guard let route = WebURLRoute(url: url), let owner = WebURLRoute.account(for: url, current: account, accounts: session.accounts) else { router.pendingLink = nil openURL(url) return } if owner.id != account.id { if !session.activate(owner) { router.pendingLink = nil openURL(url) } return } selectedTab = .dashboard switch route { case .repo(let r): dashboardPath.append(r) case .issue(let r): dashboardPath.append(r) case .mr(let r): dashboardPath.append(r) case .build(let r): dashboardPath.append(r) case .release(let r): dashboardPath.append(r) case .org(let r): dashboardPath.append(r) case .snippet(let r): dashboardPath.append(r) } router.pendingLink = nil } } /// A list as a sidebar at regular width. What it selects is the root of /// the detail column's stack; the stack's path is the one the compact /// layout's stack uses, so notification and link routing, which append /// to it, work at either width. private struct SplitStack: View { @Binding var path: NavigationPath let client: GitbayClient let placeholder: String @ViewBuilder let sidebar: () -> Sidebar @State private var selection: AnyHashable? var body: some View { NavigationSplitView { sidebar() .environment(\.sidebarSelection, Binding( get: { selection }, set: { route in selection = route path = NavigationPath() } )) } detail: { NavigationStack(path: $path) { Group { if let selection { RouteDestinations(client: client).destination(selection) } else { Text(placeholder) .font(.gbSans(.subheadline)) .foregroundStyle(.secondary) } } .navigationDestinations(client: client) } .pushesRoutes(into: $path) } } } /// 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) } } /// A type-erased route's screen, for a sidebar selection. @ViewBuilder func destination(_ route: AnyHashable) -> some View { switch route.base { case let r as RepoRoute: destination(r, client: client) case let r as MRRoute: destination(r, client: client) case let r as IssueRoute: destination(r, client: client) case let r as BuildRoute: destination(r, client: client) case let r as ReleaseRoute: destination(r, client: client) case let r as OrgRoute: destination(r, client: client) case let r as SnippetRoute: destination(r, client: client) default: EmptyView() } } @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, let line): FileView(client: client, repo: repo, path: path, ref: ref, line: line) 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 .symbols(let repo, let query): SymbolSearchView(client: client, repo: repo, query: query) 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 .savedQueries: SavedQueriesView(client: client) case .queryResults(let name): QueryResultsView(client: client, name: name) 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 .tokens: TokensView(client: client) case .addAccount: SignInView() case .notifications: NotificationsView(client: client) case .admin: AdminView(client: client) case .adminUsers: AdminUsersView(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)) }