Port the remaining tabs to the macOS client
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import SwiftUI
|
||||
|
||||
typealias WorkflowRun = Components.Schemas.WorkflowRun
|
||||
typealias RunStage = Components.Schemas.WorkflowRunStage
|
||||
typealias StageAttempt = Components.Schemas.StageAttempt
|
||||
|
||||
private let stateColors: [RunStage.StatePayload: NodeColors] = [
|
||||
.waiting: NodeColors(fill: Palette.surface, stroke: Palette.border, text: Palette.muted),
|
||||
.running: NodeColors(fill: Palette.accentTint, stroke: Palette.accent, text: Palette.text),
|
||||
.idle: NodeColors(fill: Palette.warnTint, stroke: Palette.warn, text: Palette.text),
|
||||
.finished: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
|
||||
.merged: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
|
||||
.cleanedUp: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
|
||||
.discarded: NodeColors(fill: Palette.dangerTint, stroke: Palette.danger, text: Palette.text),
|
||||
]
|
||||
|
||||
private func stageGraph(_ run: WorkflowRun) -> Flowchart {
|
||||
var chart = Flowchart(direction: .td)
|
||||
for (i, s) in run.stages.enumerated() {
|
||||
let id = "s\(i)"
|
||||
chart.node(id, label: s.name, shape: .box)
|
||||
chart.nodes[id]!.style = stateColors[s.state]!
|
||||
}
|
||||
let ids = Dictionary(run.stages.enumerated().map { ($0.element.name, "s\($0.offset)") }, uniquingKeysWith: { a, _ in a })
|
||||
for s in run.stages {
|
||||
for a in s.after {
|
||||
if let from = ids[a], let to = ids[s.name] {
|
||||
chart.edges.append(FlowEdge(from: from, to: to, label: nil, style: .solid, arrow: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
return chart
|
||||
}
|
||||
|
||||
private func attemptLabel(_ a: StageAttempt) -> String {
|
||||
switch a.state {
|
||||
case .running: "running"
|
||||
case .idle: "idle, not finished"
|
||||
case .finished: "finished"
|
||||
case .merged: "merged"
|
||||
case .discarded: "discarded before finishing\(a.discardedBy.map { " by \($0)" } ?? "")"
|
||||
case .cleanedUp: a.discardedBy == "byre" ? "finished, then discarded by the run's cleanup" : "finished, then discarded\(a.discardedBy.map { " by \($0)" } ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
private func stageLabel(_ s: RunStage) -> String {
|
||||
if s.state != .waiting {
|
||||
return attemptLabel(s.attempts.last { $0.output != nil } ?? s.attempts.last!)
|
||||
}
|
||||
if !s.waitingOn.isEmpty {
|
||||
return "waiting on \(s.waitingOn.map { "“\($0)”" }.joined(separator: ", "))"
|
||||
}
|
||||
return "waiting to start"
|
||||
}
|
||||
|
||||
private func stateColor(_ state: String) -> Color {
|
||||
switch state {
|
||||
case "running": Palette.accent
|
||||
case "idle", "discarding": Palette.warn
|
||||
case "finished", "merged": Palette.ok
|
||||
default: Palette.muted
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkflowView: View {
|
||||
@Environment(Store.self) private var store
|
||||
@ViewState private var run: WorkflowRun?
|
||||
@ViewState private var error = ""
|
||||
@ViewState private var target = 0
|
||||
@ViewState private var loading = false
|
||||
@ViewState private var again = false
|
||||
|
||||
var body: some View {
|
||||
let task = store.selectedTaskId.flatMap { store.findTask($0) }
|
||||
VStack(spacing: 0) {
|
||||
if let task, let runId = task.workflowRun {
|
||||
if !error.isEmpty {
|
||||
EmptyText(text: error, error: true)
|
||||
} else if let run, run.run == runId {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(run.name).font(Fonts.font(13, weight: 700))
|
||||
Text(run.title).foregroundStyle(Palette.muted)
|
||||
Text(run.finished ? "workflow finished" : "in progress")
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(run.finished ? Palette.ok : Palette.accent)
|
||||
}
|
||||
.modifier(HeaderBar())
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
FlowchartCanvas(layout: layoutFlowchart(stageGraph(run)))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if !trimmed(run.brief).isEmpty {
|
||||
DetailsBox(boxed: false) {
|
||||
Text("Brief").foregroundStyle(Palette.muted)
|
||||
} content: {
|
||||
MarkdownView(blocks: parseMarkdown(run.brief), ctx: MDContext(taskId: task.id))
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
ForEach(run.stages, id: \.name) { s in
|
||||
StageCard(run: run, stage: s)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
} else {
|
||||
EmptyText(text: "Loading…")
|
||||
}
|
||||
} else {
|
||||
EmptyText(text: task != nil ? "This task isn't part of a workflow run." : "No task selected.")
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.task(id: store.selectedTaskId) {
|
||||
target += 1
|
||||
run = nil
|
||||
error = ""
|
||||
await load()
|
||||
let off = store.events.add { ev in
|
||||
if relevant(ev) {
|
||||
Task {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
}
|
||||
await untilCancelled()
|
||||
off()
|
||||
target += 1
|
||||
}
|
||||
.onChange(of: task?.workflowRun) {
|
||||
if run == nil && !loading && error.isEmpty {
|
||||
Task {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relevant(_ ev: ByreEvent) -> Bool {
|
||||
guard ev.seq != 0, let run else {
|
||||
return false
|
||||
}
|
||||
if ev.kind == "task.created" {
|
||||
return ev.payload.value["workflow_run"] as? String == run.run
|
||||
}
|
||||
let ids = Set(run.stages.flatMap { $0.attempts.map(\.taskId) })
|
||||
guard let taskId = ev.taskId else {
|
||||
return false
|
||||
}
|
||||
return ids.contains(taskId) && (ev.kind.hasPrefix("task.") || ev.kind.hasPrefix("session."))
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let taskId = store.selectedTaskId, store.findTask(taskId)?.workflowRun != nil else {
|
||||
return
|
||||
}
|
||||
if loading {
|
||||
again = true
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
target += 1
|
||||
let started = target
|
||||
do {
|
||||
let fresh = try await store.api.client.getWorkflowRun(path: .init(taskId: taskId)).ok.body.json
|
||||
if started == target {
|
||||
run = fresh
|
||||
error = ""
|
||||
}
|
||||
} catch {
|
||||
if started == target {
|
||||
self.error = errorText(error)
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
if again {
|
||||
again = false
|
||||
await load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StageCard: View {
|
||||
let run: WorkflowRun
|
||||
let stage: RunStage
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(stage.name).font(Fonts.font(13, weight: 700))
|
||||
Text(stageLabel(stage))
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(stateColor(stage.state.rawValue))
|
||||
}
|
||||
Text(stage.after.isEmpty ? "starts from the brief" : "after \(stage.after.map { "“\($0)”" }.joined(separator: ", "))")
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
if !stage.attempts.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(stage.attempts.enumerated()), id: \.offset) { i, a in
|
||||
AttemptRow(run: run, stage: stage, attempt: a, index: i)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttemptRow: View {
|
||||
let run: WorkflowRun
|
||||
let stage: RunStage
|
||||
let attempt: StageAttempt
|
||||
let index: Int
|
||||
@Environment(Store.self) private var store
|
||||
|
||||
var body: some View {
|
||||
let a = attempt
|
||||
let fed = run.stages.flatMap { s in s.attempts.filter { d in d.inputs.contains { $0.taskId == a.taskId } }.map(\.taskId) }
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
if stage.attempts.count > 1 {
|
||||
Text("#\(index + 1)").foregroundStyle(Palette.muted)
|
||||
}
|
||||
Text(link(a.taskId, a.title, bold: a.taskId == store.selectedTaskId))
|
||||
Text(attemptLabel(a))
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(stateColor(a.state.rawValue))
|
||||
if a.discarding {
|
||||
Text("discarding…").font(Fonts.font(11)).foregroundStyle(Palette.warn)
|
||||
}
|
||||
}
|
||||
if let from = a.forkedFrom {
|
||||
line([("fork of ", nil), (attemptName(from), from)])
|
||||
}
|
||||
if !a.inputs.isEmpty {
|
||||
line([("started from ", nil)] + joined(a.inputs.map(\.taskId)))
|
||||
}
|
||||
if !fed.isEmpty {
|
||||
line([("fed ", nil)] + joined(fed))
|
||||
}
|
||||
if let output = a.output {
|
||||
DetailsBox(boxed: false) {
|
||||
HStack(spacing: 0) {
|
||||
Text("finish_stage output").foregroundStyle(Palette.muted)
|
||||
if let commit = a.commit {
|
||||
Text(" \(String(commit.prefix(10)))").foregroundStyle(Palette.muted)
|
||||
}
|
||||
}
|
||||
} content: {
|
||||
MarkdownView(blocks: parseMarkdown(output), ctx: MDContext(taskId: a.taskId))
|
||||
.padding(.leading, 10)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle().fill(Palette.border).frame(width: 1)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Palette.border).frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func joined(_ ids: [String]) -> [(String, String?)] {
|
||||
var out: [(String, String?)] = []
|
||||
for (n, id) in ids.enumerated() {
|
||||
if n > 0 {
|
||||
out.append((", ", nil))
|
||||
}
|
||||
out.append((attemptName(id), id))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func attemptName(_ taskId: String) -> String {
|
||||
for s in run.stages {
|
||||
if let i = s.attempts.firstIndex(where: { $0.taskId == taskId }) {
|
||||
return s.attempts.count > 1 ? "“\(s.name)” attempt \(i + 1)" : "“\(s.name)”"
|
||||
}
|
||||
}
|
||||
return store.findTask(taskId)?.title ?? taskId
|
||||
}
|
||||
|
||||
private func link(_ taskId: String, _ label: String, bold: Bool = false) -> AttributedString {
|
||||
var a = AttributedString(label)
|
||||
a.link = AppLink.task(taskId)
|
||||
a.foregroundColor = Palette.accent
|
||||
a.font = Fonts.font(13, weight: bold ? 700 : 400)
|
||||
return a
|
||||
}
|
||||
|
||||
private func line(_ parts: [(String, String?)]) -> some View {
|
||||
var out = AttributedString()
|
||||
for (text, taskId) in parts {
|
||||
if let taskId {
|
||||
var a = link(taskId, text)
|
||||
a.font = Fonts.font(11)
|
||||
out += a
|
||||
} else {
|
||||
out += AttributedString(text)
|
||||
}
|
||||
}
|
||||
return Text(out)
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user