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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
954 lines · 37.2 KB Raw History
import SwiftUI

/// A repository's checks: its workflows, each with how it last ran, and its latest runs.
struct ChecksView: View {
    let repo: RepoRef

    @State private var refresh = 0

    var body: some View {
        Loading(load: { [repo] in
            let list: RunList = try await Client.shared.query("listRuns", ["repo": repo.uri, "limit": "50"])
            return list
        }, refresh: refresh) { list in
            ChecksPage(repo: repo, list: list)
                .refreshable { refresh += 1 }
        }
        .navigationTitle("Checks")
        .navigationBarTitleDisplayMode(.inline)
    }
}

private struct ChecksPage: View {
    let repo: RepoRef
    let list: RunList

    /// Those on the default branch, and any these runs ran that it no longer has:
    /// the ones a push or a pull runs, then those that run only when asked.
    private var workflows: [Workflow] {
        var all = list.workflows
        for run in list.runs where !all.contains(where: { $0.name == run.check.workflow }) {
            all.append(Workflow(name: run.check.workflow))
        }
        let pushed = all.filter { $0.asked != true }.sorted { $0.name < $1.name }
        let asked = all.filter { $0.asked == true }.sorted { $0.name < $1.name }
        return pushed + asked
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                Sections {
                    NavigationLink(value: Route.workflowRuns(repo, workflow: nil)) {
                        PageRowLabel("All runs", "play", .attention) { EmptyView() }
                    }
                    .buttonStyle(RowPress())
                }
                .padding(.top, Space.s4)
                if !workflows.isEmpty {
                    SectionTitle(icon: "git-branch", title: "Workflows") { EmptyView() }
                    Sections {
                        ForEach(Array(workflows.enumerated()), id: \.element.name) { i, workflow in
                            if i > 0 { RowDivider() }
                            NavigationLink(value: Route.workflowRuns(repo, workflow: workflow.name)) {
                                WorkflowRow(workflow: workflow, latest: list.runs.first { $0.check.workflow == workflow.name })
                            }
                            .buttonStyle(RowPress())
                        }
                    }
                }
                if !list.runs.isEmpty {
                    SectionTitle(icon: "clock", title: "Latest runs") { EmptyView() }
                    Sections {
                        ForEach(Array(list.runs.prefix(5).enumerated()), id: \.element.id) { i, run in
                            if i > 0 { RowDivider() }
                            NavigationLink(value: Route.run(repo, uri: run.check.uri)) {
                                HStack(spacing: 0) {
                                    RunRow(run: run)
                                    RowChevron(name: "chevron-right")
                                }
                                .padding(.horizontal, Space.s4)
                                .padding(.vertical, Space.s3)
                                .contentShape(Rectangle())
                            }
                            .buttonStyle(RowPress())
                        }
                    }
                } else {
                    Text("Nothing has run yet. The runners this repository names file a check for each run of its workflows, and they show here.")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .padding(Space.s4)
                }
            }
            .padding(.bottom, Space.s6)
        }
        .background(Color.groupedPage)
    }
}

private struct WorkflowRow: View {
    let workflow: Workflow
    let latest: Run?

    private var said: String {
        let asked = workflow.asked == true ? "Runs when asked" : nil
        guard let latest else { return asked ?? workflow.about ?? "No runs lately" }
        let last = "Latest \(RunWords.ago(latest.check.startedAt))"
        return [asked, last].compactMap { $0 }.joined(separator: " · ")
    }

    var body: some View {
        HStack(spacing: Space.s3) {
            Group {
                if let latest {
                    StatusMark(check: latest.check)
                } else {
                    Oct(name: "play", size: Icon.lg).foregroundStyle(.secondary)
                }
            }
            .frame(width: Size.Avatar.small)
            VStack(alignment: .leading, spacing: Space.s0_5) {
                Text(workflow.name).font(.body.weight(.medium)).lineLimit(1)
                Text(said).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
            }
            Spacer()
            RowChevron(name: "chevron-right")
        }
        .padding(.horizontal, Space.s4)
        .padding(.vertical, Space.s2)
        .frame(minHeight: Size.row)
        .contentShape(Rectangle())
    }
}

/// The runs of one workflow, or of all of them.
struct WorkflowRunsView: View {
    let repo: RepoRef
    let workflow: String?

    @Environment(Account.self) private var account
    @State private var at: WorkflowList?

    var body: some View {
        PagedList<Run, RunLink>(empty: "Nothing has run yet.", fetch: { [repo, workflow] cursor in
            let list: RunList = try await Client.shared.query("listRuns", ["repo": repo.uri, "workflow": workflow, "cursor": cursor])
            return .init(items: list.runs, cursor: list.cursor)
        }, header: header) { run in
            RunLink(repo: repo, run: run, showsWorkflow: workflow == nil)
        }
        .navigationTitle(workflow ?? "All runs")
        .navigationBarTitleDisplayMode(.inline)
        .task(id: owns) {
            guard owns, workflow != nil else { return }
            at = try? await Client.shared.query("listWorkflows", ["repo": repo.uri])
        }
    }

    private var owns: Bool { account.person?.did == repo.did || account.person?.handle == repo.handle }

    private var header: AnyView? {
        guard owns, let workflow, let at, let found = at.workflows.first(where: { $0.name == workflow }), found.asked else { return nil }
        return AnyView(RunWorkflowBanner(repo: repo, workflow: found, ref: at.ref))
    }
}

private struct RunLink: View {
    let repo: RepoRef
    let run: Run
    let showsWorkflow: Bool

    var body: some View {
        NavigationLink(value: Route.run(repo, uri: run.check.uri)) { RunRow(run: run, showsWorkflow: showsWorkflow) }
    }
}

/// For the owner, where they may ask for the workflow by name: a way to run it now.
private struct RunWorkflowBanner: View {
    let repo: RepoRef
    let workflow: WorkflowList.Entry
    let ref: String

    @State private var confirming = false
    @State private var asking = false
    @State private var said: String?

    var body: some View {
        VStack(alignment: .leading, spacing: Space.s2) {
            Text(workflow.push || workflow.pull
                ? "You can also run it on \(RunWords.branch(ref)) when you like."
                : "It runs on \(RunWords.branch(ref)) only when you ask for it.")
                .font(.subheadline)
            HStack {
                if let said { Text(said).font(.footnote).foregroundStyle(.secondary) }
                Spacer()
                Button(asking ? "Asking…" : "Run workflow") { confirming = true }
                    .font(.body.weight(.medium))
                    .buttonStyle(.borderless)
                    .tint(Color.accent)
                    .disabled(asking)
            }
        }
        .padding(Space.s4)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color.accentSoft)
        .confirmationDialog("Run \(workflow.name) on \(RunWords.branch(ref))?", isPresented: $confirming, titleVisibility: .visible) {
            Button("Run workflow") { run(ref: ref) }
        }
    }

    private func run(ref: String) {
        asking = true
        said = nil
        Task {
            do {
                try await Client.shared.send(Writes.rerun(repo: repo, pull: nil, ref: ref, workflow: workflow.name))
                said = "Asked. It shows here once it starts."
            } catch {
                said = error.localizedDescription
            }
            asking = false
        }
    }
}

/// One run in a list: what it ran on, its workflow, and how long, when and where.
struct RunRow: View {
    let run: Run
    var showsWorkflow = true

    private var what: String? {
        if let title = run.pull?.title { return title }
        if let batch = run.batch { return batch == 1 ? "A batch of 1 pull" : "A batch of \(batch) pulls" }
        return run.subject
    }

    private var how: String {
        run.pull != nil ? "pull" : run.batch != nil ? "merge queue" : "push"
    }

    var body: some View {
        HStack(alignment: .top, spacing: Space.s3) {
            StatusMark(check: run.check).frame(width: Size.Avatar.small).padding(.top, Space.s0_5)
            VStack(alignment: .leading, spacing: Space.s1) {
                Text(what ?? run.check.workflow).font(.body).lineLimit(2).multilineTextAlignment(.leading)
                Text(what != nil && showsWorkflow ? "\(run.check.workflow) · \(how)" : how)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
                HStack(spacing: Space.s1_5) {
                    if let took = RunWords.took(run.check) {
                        RunChip(symbol: "stopwatch", text: took)
                    } else {
                        RunChip(symbol: "hourglass", text: RunWords.status(run.check))
                    }
                    RunChip(symbol: "calendar", text: RunWords.ago(run.check.startedAt))
                    RunChip(symbol: "arrow.triangle.branch", text: RunWords.branch(run.ref), mono: true)
                }
                .padding(.top, Space.s1)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .accessibilityElement(children: .combine)
    }
}

private struct RunChip: View {
    let symbol: String
    let text: String
    var mono = false

    var body: some View {
        HStack(spacing: Space.s1) {
            Image(systemName: symbol).imageScale(.small)
            Text(text).font(mono ? .footnote.monospaced() : .footnote).lineLimit(1)
        }
        .foregroundStyle(.secondary)
        .padding(.horizontal, Space.s2)
        .padding(.vertical, Space.s0_5 + 1)
        .overlay(Capsule().strokeBorder(Color.line))
    }
}

/// A run or a step's mark, in the colour of how it went.
struct StatusMark: View {
    let status: String
    var size: CGFloat = Icon.xl

    init(check: Check, size: CGFloat = Icon.xl) {
        status = check.skipped != nil ? "skipped" : check.status
        self.size = size
    }

    init(status: String, size: CGFloat = Icon.xl) {
        self.status = status
        self.size = size
    }

    var body: some View {
        mark.accessibilityLabel(RunWords.status(status))
    }

    @ViewBuilder private var mark: some View {
        switch status {
        case "success": Oct(name: "check-circle-fill", size: size).foregroundStyle(Color.success)
        case "failure", "error": Oct(name: "x-circle-fill", size: size).foregroundStyle(Color.danger)
        case "queued": Oct(name: "clock", size: size).foregroundStyle(Color.attention)
        case "running":
            Image(systemName: "circle.dotted")
                .font(.system(size: size * 0.9, weight: .bold))
                .foregroundStyle(Color.attention)
                .symbolEffect(.rotate, options: .repeating)
                .frame(width: size, height: size)
        default:
            Image(systemName: "slash.circle")
                .font(.system(size: size * 0.9))
                .foregroundStyle(Color.neutral)
                .frame(width: size, height: size)
        }
    }
}

/// How a run or a step is said.
enum RunWords {
    static func status(_ status: String) -> String {
        switch status {
        case "success": "Passed"
        case "failure": "Failed"
        case "error": "Couldn't run"
        case "queued": "Queued"
        case "running": "Running"
        case "skipped": "Skipped"
        case "cancelled": "Cancelled"
        default: status.capitalized
        }
    }

    static func status(_ check: Check) -> String {
        status(check.skipped != nil ? "skipped" : check.status)
    }

    /// `1m 35s`, `45s`.
    static func took(ms: Int) -> String {
        let s = (ms + 500) / 1000
        return s < 60 ? "\(s)s" : "\(s / 60)m \(s % 60)s"
    }

    /// How long it took, once it has finished.
    static func took(_ check: Check) -> String? {
        guard let start = When.date(check.startedAt), let end = When.date(check.finishedAt) else { return nil }
        return took(ms: Int(end.timeIntervalSince(start) * 1000))
    }

    static func ago(_ iso: String?) -> String {
        let short = When.short(iso)
        return short.isEmpty || short == "now" ? "now" : "\(short) ago"
    }

    static func branch(_ ref: String) -> String {
        for prefix in ["refs/heads/", "refs/tags/"] where ref.hasPrefix(prefix) {
            return String(ref.dropFirst(prefix.count))
        }
        return ref
    }

    /// What a step says after its name: how it ended and how long it took.
    static func ended(_ step: Step) -> String {
        switch step.status {
        case "skipped": return "Skipped"
        case "running": return "Running"
        default:
            let time = took(ms: step.durationMs ?? 0)
            if let code = step.exitCode { return code == 0 ? time : "Exit \(code) · \(time)" }
            return step.status == "cancelled" ? "Cancelled after \(time)" : "Killed after \(time)"
        }
    }
}

/// A run's log, as each of its steps printed it.
enum StepLogs {
    /// Each step's lines, from a log that starts each with `==> <name>`; what
    /// comes before the first goes with the step before it, or the first.
    static func split(_ log: String, steps: [String]) -> [String] {
        var out = steps.map { _ in "" }
        guard !steps.isEmpty else { return out }
        let lines = log.components(separatedBy: "\n")
        let heads = steps.map { "==> \($0)" }
        let first = heads.firstIndex { lines.contains($0) }
        var next = first ?? steps.count
        var at = max(next - 1, 0)
        for line in lines {
            if next < steps.count, line == heads[next] {
                at = next
                next += 1
                continue
            }
            out[at] += line + "\n"
        }
        return out.map { clean($0) }
    }

    /// Without the terminal's colour codes, which show as noise here, or the blank lines at its end.
    static func clean(_ text: String) -> String {
        var text = text.replacingOccurrences(of: "\u{1B}\\[[0-9;?]*[A-Za-z]", with: "", options: .regularExpression)
        while text.hasSuffix("\n") { text.removeLast() }
        return text
    }

    /// The last of its lines, to show under a step that failed.
    static func tail(_ text: String, lines: Int) -> String {
        let last = text.components(separatedBy: "\n").suffix(lines).drop { $0.trimmingCharacters(in: .whitespaces).isEmpty }
        return last.joined(separator: "\n")
    }
}

/// One run: what it ran on, how it went, its steps and what it made.
struct RunView: View {
    let repo: RepoRef
    let uri: String

    @Environment(Account.self) private var account
    @State private var refresh = 0
    @State private var asked: String?

    private struct Answer: Sendable, Hashable {
        let run: RunDetail
        let subject: String?
    }

    private var owns: Bool { account.person?.did == repo.did || account.person?.handle == repo.handle }

    var body: some View {
        Loading(load: { [repo, uri] in
            let run: RunDetail = try await Client.shared.query("getCheck", ["repo": repo.uri, "uri": uri])
            var subject: String?
            if let sha = run.check.commit {
                let commits: CommitList? = try? await Client.shared.query("listCommits", ["repo": repo.uri, "ref": sha, "limit": "1", "subjectOnly": "true"])
                subject = commits?.commits.first?.subject
            }
            return Answer(run: run, subject: subject)
        }, refresh: refresh) { answer in
            RunPage(repo: repo, run: answer.run, subject: answer.subject, asked: asked) { refresh += 1 }
                .toolbar { toolbar(answer.run) }
        }
        .navigationTitle("")
        .navigationBarTitleDisplayMode(.inline)
    }

    @ToolbarContentBuilder private func toolbar(_ run: RunDetail) -> some ToolbarContent {
        ToolbarItem(placement: .principal) {
            VStack(spacing: 0) {
                Text(run.check.workflow).font(.headline).lineLimit(1)
                Text(RunWords.status(run.check) + (RunWords.took(run.check).map { " in \($0)" } ?? ""))
                    .font(.caption).foregroundStyle(.secondary)
            }
        }
        ToolbarItem(placement: .topBarTrailing) {
            Menu {
                if owns, run.check.status != "queued" {
                    Section {
                        Button {
                            rerun(run, workflow: run.check.workflow)
                        } label: {
                            SwiftUI.Label("Run \(run.check.workflow) again", systemImage: "arrow.clockwise")
                        }
                        Button {
                            rerun(run, workflow: nil)
                        } label: {
                            SwiftUI.Label(run.pull != nil ? "Run all the pull's checks again" : "Run all at \(RunWords.branch(run.ref)) again", systemImage: "arrow.clockwise.circle")
                        }
                    }
                }
                if let log = run.log {
                    Button {
                        UIPasteboard.general.string = StepLogs.clean(log)
                    } label: {
                        SwiftUI.Label("Copy all logs", systemImage: "doc.on.doc")
                    }
                }
                if let link = RunLinks.page(repo: repo, uri: uri) {
                    ShareLink(item: link) { SwiftUI.Label("Share", systemImage: "square.and.arrow.up") }
                    Button {
                        UIPasteboard.general.url = link
                    } label: {
                        SwiftUI.Label("Copy link", systemImage: "link")
                    }
                }
            } label: {
                SwiftUI.Label("More", systemImage: "ellipsis")
            }
        }
    }

    private func rerun(_ run: RunDetail, workflow: String?) {
        asked = "Asking…"
        Task {
            do {
                try await Client.shared.send(Writes.rerun(repo: repo, pull: run.pull, ref: run.ref, workflow: workflow))
                asked = "Asked its runners to run \(workflow ?? "them all") again. The new run shows in Checks once it starts."
            } catch {
                asked = error.localizedDescription
            }
        }
    }
}

enum RunLinks {
    /// Its page on the web.
    static func page(repo: RepoRef, uri: String) -> URL? {
        let parts = uri.dropFirst("at://".count).split(separator: "/")
        guard parts.count == 3 else { return nil }
        return URL(string: "https://knotless.dev/\(repo.handle)/\(repo.name)/check/\(parts[0])/\(parts[2])")
    }
}

private struct RunPage: View {
    let repo: RepoRef
    let run: RunDetail
    let subject: String?
    let asked: String?
    let reread: () -> Void

    @State private var live: LiveRun?

    private var check: Check { run.check }
    private var logs: [String] { StepLogs.split(run.log ?? "", steps: run.steps.map(\.name)) }
    private var going: Bool { check.status == "running" || check.status == "queued" }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                header
                facts
                if let asked {
                    Text(asked).font(.footnote).foregroundStyle(.secondary).padding(.horizontal, Space.s4).padding(.top, Space.s3)
                }
                note
                steps
                pictures
            }
            .padding(.bottom, Space.s6)
        }
        .background(Color.groupedPage)
        .refreshable { reread() }
        .task(id: "\(check.uri) \(check.status)") { await follow() }
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: Space.s1) {
            Text(subject ?? check.workflow).font(.title2.bold()).fixedSize(horizontal: false, vertical: true)
            if subject != nil {
                Text(check.workflow).font(.title3).foregroundStyle(.secondary)
            }
        }
        .padding(.horizontal, Space.s4)
        .padding(.top, Space.s4)
        .padding(.bottom, Space.s5)
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    private var facts: some View {
        Sections {
            HStack(alignment: .top, spacing: 0) {
                fact("Status") {
                    HStack(spacing: Space.s1_5) {
                        StatusMark(check: check, size: Icon.md)
                        Text(RunWords.status(check))
                    }
                }
                Divider()
                fact("Took") { Text(RunWords.took(check) ?? "–") }
                Divider()
                fact("Started") { Text(RunWords.ago(check.startedAt)) }
            }
            .fixedSize(horizontal: false, vertical: true)
            RowDivider().padding(.leading, -Size.smallRowInset + Space.s4)
            ThreadRow {
                Oct(name: "git-branch").foregroundStyle(.secondary)
            } content: {
                Text(run.pull != nil ? "Pull from" : "On").foregroundStyle(.secondary)
                BranchChip(name: RunWords.branch(run.ref))
                Spacer()
            }
            if let sha = check.commit {
                RowDivider()
                NavigationLink(value: Route.commit(repo, sha: sha)) {
                    ThreadRow {
                        Oct(name: "git-commit").foregroundStyle(.secondary)
                    } content: {
                        Text("Commit").foregroundStyle(.secondary)
                        Text(String(sha.prefix(7))).font(.body.monospaced())
                        Spacer()
                        RowChevron(name: "chevron-right")
                    }
                }
                .buttonStyle(RowPress())
            }
            if let pull = run.pull {
                RowDivider()
                NavigationLink(value: Route.thread(repo, uri: pull)) {
                    ThreadRow {
                        Oct(name: "git-pull-request").foregroundStyle(.secondary)
                    } content: {
                        Text("Its pull")
                        Spacer()
                        RowChevron(name: "chevron-right")
                    }
                }
                .buttonStyle(RowPress())
            }
            RowDivider()
            NavigationLink(value: Route.profile(run.runner.handle)) {
                ThreadRow {
                    Avatar(url: run.runner.avatar, name: run.runner.handle, did: run.runner.did, size: Size.Avatar.small)
                } content: {
                    Text("Ran by").foregroundStyle(.secondary)
                    Text(run.runner.handle).lineLimit(1)
                    Spacer()
                    RowChevron(name: "chevron-right")
                }
            }
            .buttonStyle(RowPress())
        }
    }

    private func fact<Value: View>(_ title: String, @ViewBuilder _ value: () -> Value) -> some View {
        VStack(alignment: .leading, spacing: Space.s1) {
            Text(title).font(.footnote).foregroundStyle(.secondary)
            value().font(.body.weight(.medium)).lineLimit(1)
        }
        .padding(Space.s4)
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    @ViewBuilder private var note: some View {
        if let error = check.error {
            Text(error)
                .font(.footnote.monospaced())
                .foregroundStyle(Color.danger)
                .textSelection(.enabled)
                .padding(Space.s4)
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(Color.card)
                .padding(.top, Space.s4)
        } else if let skipped = check.skipped {
            said(skipped.prefix(1).uppercased() + skipped.dropFirst())
        } else if check.status == "queued" {
            said("It starts once its runner has a job free.")
        } else if check.status == "running", (live?.steps.isEmpty ?? true), live?.going == nil {
            said("Running. What it prints shows here as it prints it.")
        }
    }

    private func said(_ text: String) -> some View {
        Text(text).font(.subheadline).foregroundStyle(.secondary)
            .padding(.horizontal, Space.s4).padding(.top, Space.s4)
    }

    @ViewBuilder private var steps: some View {
        let shown = live.map { $0.shown } ?? run.steps.enumerated().map { LiveRun.Shown(step: $0.element, log: logs[$0.offset]) }
        if !shown.isEmpty {
            SectionTitle(icon: "play", title: "Steps") { EmptyView() }
            Sections {
                ForEach(Array(shown.enumerated()), id: \.offset) { i, item in
                    if i > 0 { RowDivider() }
                    StepRow(repo: repo, uri: run.check.uri, workflow: check.workflow, index: i, step: item.step, log: item.log, linked: live == nil)
                }
            }
        }
    }

    @ViewBuilder private var pictures: some View {
        if let images = check.images, !images.isEmpty {
            SectionTitle(icon: "file", title: "Pictures") { EmptyView() }
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: Space.s3) {
                    ForEach(images, id: \.url) { RunPicture(image: $0) }
                }
                .padding(.horizontal, Space.s4)
            }
        }
    }

    /// While it runs, what it prints as its runner sends it; once it ends, the
    /// run as filed. One waiting its turn is read again now and then.
    private func follow() async {
        live = nil
        guard going else { return }
        #if DEBUG
        if DevAccount.recording != nil { return }
        #endif
        if check.status == "running" {
            for await heard in LiveRun.follow(repo: repo.uri, uri: check.uri) {
                live = heard
                if heard.ended { break }
            }
        } else {
            try? await Task.sleep(for: .seconds(5))
        }
        guard !Task.isCancelled else { return }
        // Filed a moment after it ends.
        try? await Task.sleep(for: .seconds(1))
        reread()
    }
}

private struct StepRow: View {
    let repo: RepoRef
    let uri: String
    let workflow: String
    let index: Int
    let step: Step
    let log: String
    /// Its log has a screen of its own, once the run is filed.
    let linked: Bool

    private var failed: Bool { step.status == "failure" || step.status == "running" }

    var body: some View {
        if linked {
            NavigationLink(value: Route.step(repo, uri: uri, index: index)) { content(chevron: true) }
                .buttonStyle(RowPress())
        } else {
            content(chevron: false)
        }
    }

    private func content(chevron: Bool) -> some View {
        VStack(alignment: .leading, spacing: Space.s2) {
            HStack(spacing: Space.s3) {
                StatusMark(status: step.status).frame(width: Size.Avatar.small)
                VStack(alignment: .leading, spacing: Space.s0_5) {
                    Text(step.name).font(.body.monospaced()).lineLimit(2).multilineTextAlignment(.leading)
                    Text(RunWords.ended(step)).font(.subheadline).foregroundStyle(.secondary)
                }
                Spacer()
                if chevron { RowChevron(name: "chevron-right") }
            }
            if failed, !log.isEmpty {
                Text(StepLogs.tail(log, lines: 8))
                    .font(.caption.monospaced())
                    .foregroundStyle(.secondary)
                    .lineLimit(8)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(Space.s2_5)
                    .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.medium))
                    .padding(.leading, Size.Avatar.small + Space.s3)
            }
        }
        .padding(.horizontal, Space.s4)
        .padding(.vertical, Space.s3)
        .frame(minHeight: Size.row)
        .contentShape(Rectangle())
    }
}

private struct RunPicture: View {
    let image: CheckImage

    @State private var open = false

    private var link: URL? { URL(string: image.url, relativeTo: Client.site) }

    var body: some View {
        Button { open = true } label: {
            VStack(alignment: .leading, spacing: Space.s1_5) {
                AsyncImage(url: link) { picture in
                    picture.resizable().scaledToFill()
                } placeholder: {
                    Color.fill
                }
                .frame(width: 200, height: 130)
                .clipShape(RoundedRectangle(cornerRadius: Radius.medium))
                .overlay(RoundedRectangle(cornerRadius: Radius.medium).strokeBorder(Color.line))
                Text(image.alt).font(.footnote).foregroundStyle(.secondary).lineLimit(1).frame(width: 200, alignment: .leading)
            }
        }
        .buttonStyle(.plain)
        .accessibilityLabel(image.alt)
        .sheet(isPresented: $open) {
            NavigationStack {
                ScrollView([.horizontal, .vertical]) {
                    AsyncImage(url: link) { $0.resizable().scaledToFit() } placeholder: { ProgressView() }
                        .containerRelativeFrame(.horizontal)
                }
                .navigationTitle(image.alt)
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) { Button("Done") { open = false } }
                }
            }
        }
    }
}

/// A step's log, or the whole run's when `index` is past its steps.
struct StepLogView: View {
    let repo: RepoRef
    let uri: String
    let index: Int

    var body: some View {
        Loading(load: { [repo, uri] in
            let run: RunDetail = try await Client.shared.query("getCheck", ["repo": repo.uri, "uri": uri])
            return run
        }) { run in
            let whole = !run.steps.indices.contains(index)
            let text = whole
                ? StepLogs.clean(run.log ?? "")
                : StepLogs.split(run.log ?? "", steps: run.steps.map(\.name))[index]
            LogPage(title: whole ? "All logs" : run.steps[index].name, workflow: run.check.workflow, text: text, repo: repo, uri: uri, whole: whole)
        }
        .navigationBarTitleDisplayMode(.inline)
    }
}

private struct LogPage: View {
    let title: String
    let workflow: String
    let text: String
    let repo: RepoRef
    let uri: String
    let whole: Bool

    @AppStorage("logs.wrap") private var wrap = false
    @State private var jump: Int?

    private var lines: [String] { text.isEmpty ? [] : text.components(separatedBy: "\n") }

    var body: some View {
        let lines = lines
        let width = CGFloat(String(lines.count).count) * 9 + Space.s2
        ScrollViewReader { reader in
            ScrollView(wrap ? .vertical : [.vertical, .horizontal]) {
                if lines.isEmpty {
                    Text("It printed nothing.").foregroundStyle(.secondary).padding(Space.s4)
                }
                LazyVStack(alignment: .leading, spacing: 0) {
                    ForEach(Array(lines.enumerated()), id: \.offset) { i, line in
                        HStack(alignment: .firstTextBaseline, spacing: Space.s3) {
                            Text("\(i + 1)")
                                .foregroundStyle(.tertiary)
                                .frame(width: width, alignment: .trailing)
                            Text(line.isEmpty ? " " : line)
                                .fixedSize(horizontal: !wrap, vertical: true)
                                .frame(maxWidth: wrap ? .infinity : nil, alignment: .leading)
                        }
                        .font(.footnote.monospaced())
                        .padding(.vertical, 1)
                        .id(i)
                    }
                }
                .textSelection(.enabled)
                .padding(.vertical, Space.s3)
                .padding(.trailing, Space.s4)
            }
            .defaultScrollAnchor(.topLeading)
            .onChange(of: jump) {
                guard let jump else { return }
                withAnimation { reader.scrollTo(jump, anchor: jump == 0 ? .top : .bottom) }
                self.jump = nil
            }
        }
        .background(Color.page)
        .toolbar {
            ToolbarItem(placement: .principal) {
                VStack(spacing: 0) {
                    Text(title).font(.headline).lineLimit(1)
                    Text(workflow).font(.caption).foregroundStyle(.secondary)
                }
            }
            ToolbarItem(placement: .topBarTrailing) {
                Menu {
                    Button {
                        UIPasteboard.general.string = text
                    } label: {
                        SwiftUI.Label(whole ? "Copy all logs" : "Copy log", systemImage: "doc.on.doc")
                    }
                    ShareLink(item: text) { SwiftUI.Label("Share log", systemImage: "square.and.arrow.up") }
                    Toggle(isOn: $wrap) { SwiftUI.Label("Wrap lines", systemImage: "text.word.spacing") }
                    Section {
                        Button { jump = 0 } label: { SwiftUI.Label("Go to top", systemImage: "arrow.up.to.line") }
                        Button { jump = max(lines.count - 1, 0) } label: { SwiftUI.Label("Go to end", systemImage: "arrow.down.to.line") }
                    }
                    if !whole {
                        NavigationLink(value: Route.step(repo, uri: uri, index: Int.max)) {
                            SwiftUI.Label("All logs", systemImage: "list.bullet.rectangle")
                        }
                    }
                } label: {
                    SwiftUI.Label("More", systemImage: "ellipsis")
                }
            }
        }
    }
}

/// What a running check has printed so far, as its runner sends it.
struct LiveRun: Sendable {
    var text = ""
    /// Those that ended.
    var steps: [Step] = []
    /// The one going, as its runner names it.
    var going: String?
    var ended = false

    struct Shown {
        let step: Step
        let log: String
    }

    /// Those that ended, then the one going, each with what it printed.
    var shown: [Shown] {
        var names = steps.map(\.name)
        if let going, !names.contains(going) { names.append(going) }
        let logs = StepLogs.split(text, steps: names)
        var out = steps.enumerated().map { Shown(step: $0.element, log: logs[$0.offset]) }
        if names.count > steps.count {
            out.append(Shown(step: Step(name: names[names.count - 1], status: "running"), log: logs[names.count - 1]))
        }
        return out
    }

    /// Each time more is heard, all of it so far, until it ends.
    static func follow(repo: String, uri: String) -> AsyncStream<LiveRun> {
        AsyncStream { continuation in
            let task = Task {
                var parts = URLComponents(url: Client.site.appendingPathComponent("_live/check"), resolvingAgainstBaseURL: false)
                parts?.queryItems = [URLQueryItem(name: "repo", value: repo), URLQueryItem(name: "uri", value: uri)]
                guard let url = parts?.url else { return continuation.finish() }
                var request = URLRequest(url: url)
                request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
                request.timeoutInterval = 660
                var run = LiveRun()
                var event = ""
                var data: [String] = []
                do {
                    let (bytes, _) = try await URLSession.shared.bytes(for: request)
                    for try await line in bytes.lines {
                        if line.hasPrefix("event:") {
                            event = line.dropFirst(6).trimmingCharacters(in: .whitespaces)
                        } else if line.hasPrefix("data:") {
                            data.append(String(line.dropFirst(5).drop { $0 == " " }))
                        }
                        // URLSession's lines drop the blank one that ends an event, so each data line ends one.
                        guard !data.isEmpty else { continue }
                        let payload = Data(data.joined(separator: "\n").utf8)
                        data = []
                        switch event {
                        case "output": run.text += (try? JSONDecoder().decode(String.self, from: payload)) ?? ""
                        case "steps": run.steps = (try? JSONDecoder().decode([Step].self, from: payload)) ?? run.steps
                        case "going": run.going = (try? JSONDecoder().decode(String?.self, from: payload)) ?? nil
                        case "ended": run.ended = true
                        default: continue
                        }
                        continuation.yield(run)
                        if run.ended { break }
                    }
                } catch {}
                continuation.finish()
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }
}