johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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() }
}
}
}