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(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 `==> `; 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(_ 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 { 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() } } } }