~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
253 lines · 11.3 KB Raw History
import SwiftUI

@main
struct KnotlessApp: App {
    @UIApplicationDelegateAdaptor(PushDelegate.self) private var pushDelegate
    @Environment(\.scenePhase) private var phase
    @State private var account = Account()
    @AppStorage(Appearance.key) private var appearance = Appearance.account
    @State private var update = AppUpdate()
    private let accountTheme: AccountTheme

    init() {
        // Before anything reads them.
        DevAccount.freshen()
        accountTheme = AccountTheme.shared
    }

    private var theme: ThemeChoice? { DevAccount.theme ?? appearance.choice(account: accountTheme.choice, themes: accountTheme.themes) }

    var body: some Scene {
        // Before anything is drawn, so the first colours are the theme's.
        let _ = Themes.set(theme)
        WindowGroup {
            Group {
                if update.required {
                    UpdateScreen(reason: update.reason)
                } else {
                    RootView()
                }
            }
            .tint(.accent)
            .foregroundStyle(Color.fg)
            .environment(account)
            .environment(\.themeGeneration, Themes.generation)
            .preferredColorScheme(theme?.scheme ?? DevAccount.scheme)
            // Lists show the theme's page rather than iOS's grey.
            .scrollContentBackground(theme == nil ? .automatic : .hidden)
            .task { await account.restore() }
            .task(id: phase) {
                guard phase == .active else { return }
                await update.check()
                await account.refresh()
            }
            .task(id: "\(phase == .active) \(account.person?.did ?? "")") {
                guard phase == .active else { return }
                if account.person == nil {
                    if !account.checking { accountTheme.hold(nil) }
                } else if let did = account.person?.did {
                    await accountTheme.load(as: did)
                }
            }
        }
    }
}

enum Route: Hashable {
    case repo(RepoRef)
    case items(RepoRef, kind: String)
    case thread(RepoRef, uri: String)
    case files(RepoRef, uri: String)
    case commits(RepoRef, uri: String)
    case tree(RepoRef, ref: String?, path: String)
    case file(RepoRef, ref: String?, path: String)
    case profile(String)
    case history(RepoRef, ref: String?)
    case commit(RepoRef, sha: String)
    case releases(RepoRef)
    case release(Release)
    case runs(RepoRef)
    /// One workflow's runs, or all of them.
    case workflowRuns(RepoRef, workflow: String?)
    case run(RepoRef, uri: String)
    /// A run's step's log, or all of its log past its steps.
    case step(RepoRef, uri: String, index: Int)
    case repositories(String)
    case mine(kind: String)
    case starred(String)
    case organizations(String)
    case followers(String)
    case following(String)
    case search
    case searchCode(String)
    case searchRepositories(String)
    case searchPeople(String)
    case blocks
    case settings
    case appearance
    case notificationSettings
}

/// Opens a screen in the stack it's asked from, for what can't be a
/// `NavigationLink`, as a link in some text.
struct OpenRoute {
    let open: @MainActor (Route) -> Void

    @MainActor func callAsFunction(_ route: Route) { open(route) }
}

extension EnvironmentValues {
    @Entry var openRoute: OpenRoute?
}

extension View {
    func routes() -> some View {
        // The theme's page behind every screen, which iOS's own draws where there's none.
        background(Color.page).navigationDestination(for: Route.self) { route in
            Group {
            switch route {
            case .repo(let repo): RepoView(repo: repo)
            case .items(let repo, let kind): ItemsView(repo: repo, kind: kind)
            case .thread(let repo, let uri): ThreadView(repo: repo, uri: uri)
            case .files(let repo, let uri): FilesView(repo: repo, uri: uri)
            case .commits(let repo, let uri): CommitsView(repo: repo, uri: uri)
            case .tree(let repo, let ref, let path): TreeView(repo: repo, ref: ref, path: path)
            case .file(let repo, let ref, let path): FileView(repo: repo, ref: ref, path: path)
            case .profile(let actor): ProfileScreen(actor: actor)
            case .history(let repo, let ref): HistoryView(repo: repo, ref: ref)
            case .commit(let repo, let sha): CommitView(repo: repo, sha: sha)
            case .releases(let repo): ReleasesView(repo: repo)
            case .release(let release): ReleaseView(release: release)
            case .runs(let repo): ChecksView(repo: repo)
            case .workflowRuns(let repo, let workflow): WorkflowRunsView(repo: repo, workflow: workflow)
            case .run(let repo, let uri): RunView(repo: repo, uri: uri)
            case .step(let repo, let uri, let index): StepLogView(repo: repo, uri: uri, index: index)
            case .repositories(let actor): RepositoriesView(actor: actor)
            case .mine(let kind): MyItemsView(kind: kind)
            case .starred(let actor): StarredView(actor: actor)
            case .followers(let actor): FollowsView(actor: actor, followers: true)
            case .following(let actor): FollowsView(actor: actor, followers: false)
            case .organizations(let actor): OrganizationsView(actor: actor)
            case .search: SearchView()
            case .searchCode(let query): CodeResultsView(query: query)
            case .searchRepositories(let query): RepositoryResultsView(query: query)
            case .searchPeople(let query): PeopleResultsView(query: query)
            case .blocks: BlocksView()
            case .settings: SettingsView()
            case .appearance: AppearanceView()
            case .notificationSettings: NotificationSettingsView()
            }
            }
            .background(Color.page)
        }
    }
}

struct RootView: View {
    enum Page: String { case home, inbox, explore, profile }

    @State private var page: Page
    @State private var homePath: [Route]
    @State private var inboxPath: [Route] = []
    @State private var profilePath: [Route] = []
    @State private var explorePath: [Route] = []
    @Environment(Account.self) private var account
    private let pushes = Pushes.shared

    init() {
        let start = DevRoute.parse(DevAccount.route)
        _page = State(initialValue: start.page)
        _homePath = State(initialValue: start.path)
    }

    var body: some View {
        // Above the tabs rather than a safe area inset, which a TabView's
        // screens draw under, hiding their toolbars.
        VStack(spacing: 0) {
            if account.signedIn, account.reauth { ReauthNote() }
            tabs
        }
        .task(id: account.person?.did) {
            if account.person != nil { await pushes.start() }
        }
        // Signed out from a screen of the account's, as Settings: nothing of it stays open.
        .onChange(of: account.person?.did) { _, did in
            guard did == nil else { return }
            homePath = []
            inboxPath = []
            explorePath = []
            profilePath = []
        }
        .task(id: pushes.opened) {
            guard let target = pushes.opened else { return }
            pushes.opened = nil
            page = .inbox
            inboxPath = target.route.map { [$0] } ?? []
        }
    }

    private var tabs: some View {
        TabView(selection: $page) {
            Tab("Home", image: "oct-home-fill", value: Page.home) {
                NavigationStack(path: $homePath) { HomeView().routes() }
                    .environment(\.openRoute, OpenRoute { homePath.append($0) })
            }
            Tab("Inbox", image: "oct-inbox-fill", value: Page.inbox) {
                NavigationStack(path: $inboxPath) { InboxView().routes() }
                    .environment(\.openRoute, OpenRoute { inboxPath.append($0) })
            }
            Tab("Explore", image: "oct-telescope-fill", value: Page.explore) {
                NavigationStack(path: $explorePath) { ExploreView().routes() }
                    .environment(\.openRoute, OpenRoute { explorePath.append($0) })
            }
            Tab("Profile", image: "oct-person-fill", value: Page.profile) {
                NavigationStack(path: $profilePath) { OwnProfileView().routes() }
                    .environment(\.openRoute, OpenRoute { profilePath.append($0) })
            }
        }
    }
}

/// Opens a screen at launch, for `ios/run.sh`: `inbox`, or
/// `thread|<did>|<handle>|<name>|<uri>`, `files|…`, `repo|<did>|<handle>|<name>`, `tree|…|<path>`.
enum DevRoute {
    static func parse(_ text: String?) -> (page: RootView.Page, path: [Route]) {
        guard let text else { return (.home, []) }
        if let page = RootView.Page(rawValue: text) { return (page, []) }
        let p = text.split(separator: "|").map(String.init)
        if p == ["search"] { return (.home, [.search]) }
        if p.count == 2, p[0] == "repositories" { return (.home, [.repositories(p[1])]) }
        if p.count == 2, p[0] == "mine" { return (.home, [.mine(kind: p[1])]) }
        if p.count == 2, p[0] == "starred" { return (.home, [.starred(p[1])]) }
        if p.count == 2, p[0] == "organizations" { return (.home, [.organizations(p[1])]) }
        if p.count == 2, p[0] == "followers" { return (.home, [.followers(p[1])]) }
        if p == ["blocks"] { return (.home, [.blocks]) }
        if p == ["settings"] { return (.home, [.settings]) }
        if p == ["appearance"] { return (.home, [.settings, .appearance]) }
        if p == ["notification-settings"] { return (.home, [.settings, .notificationSettings]) }
        if p.count == 2, p[0] == "following" { return (.home, [.following(p[1])]) }
        if p.count == 2, p[0] == "search-code" { return (.home, [.searchCode(p[1])]) }
        if p.count == 2, p[0] == "search-repositories" { return (.home, [.searchRepositories(p[1])]) }
        if p.count == 2, p[0] == "search-people" { return (.home, [.searchPeople(p[1])]) }
        if p.count == 2, p[0] == "user" { return (.home, [.profile(p[1])]) }
        guard p.count >= 4 else { return (.home, []) }
        let repo = RepoRef(did: p[1], handle: p[2], name: p[3])
        switch p[0] {
        case "repo": return (.home, [.repo(repo)])
        case "history": return (.home, [.history(repo, ref: nil)])
        case "commit" where p.count == 5: return (.home, [.commit(repo, sha: p[4])])
        case "releases": return (.home, [.releases(repo)])
        case "runs": return (.home, [.runs(repo)])
        case "workflow-runs" where p.count == 5: return (.home, [.runs(repo), .workflowRuns(repo, workflow: p[4])])
        case "run" where p.count == 5: return (.home, [.runs(repo), .run(repo, uri: p[4])])
        case "step" where p.count == 6: return (.home, [.runs(repo), .run(repo, uri: p[4]), .step(repo, uri: p[4], index: Int(p[5]) ?? 0)])
        case "tree" where p.count == 5: return (.home, [.tree(repo, ref: nil, path: p[4])])
        case "thread" where p.count == 5: return (.home, [.thread(repo, uri: p[4])])
        case "files" where p.count == 5: return (.home, [.files(repo, uri: p[4])])
        case "commits" where p.count == 5: return (.home, [.commits(repo, uri: p[4])])
        case "items" where p.count == 5: return (.home, [.items(repo, kind: p[4])])
        case "file" where p.count == 5: return (.home, [.file(repo, ref: nil, path: p[4])])
        default: return (.home, [])
        }
    }
}