Vectrum
Sign In
78 lines · 3.5 KB · TypeScript
"use client";

import { useState } from "react";
import { ChevronRightIcon } from "@/components/icons";
import type { StepStatus } from "@/lib/runs";
import { RunStatusIcon } from "./run-status";

export type StepView = {
  index: number;
  name: string;
  status: StepStatus;
  exitCode: number | null;
  duration: string | null;
  /** The output's last lines, already cleaned of terminal escapes. */
  lines: string[];
  /** How many lines before `lines` were left out. */
  omitted: number;
};

/**
 * A job's steps, each a row that opens onto its log. Running and failed steps open by themselves (and
 * follow along as the page refreshes) until the reader opens or closes them by hand.
 */
export function JobSteps({ steps, rawHref }: { steps: StepView[]; rawHref: string }) {
  const [toggled, setToggled] = useState<Map<number, boolean>>(new Map());
  return (
    <ol className="flex flex-col">
      {steps.map((step) => {
        const auto = step.status === "in_progress" || step.status === "failure";
        const open = toggled.get(step.index) ?? auto;
        const hasLog = step.lines.length > 0 || step.omitted > 0;
        return (
          <li key={step.index}>
            <button
              type="button"
              aria-expanded={open}
              onClick={() => setToggled((t) => new Map(t).set(step.index, !open))}
              className="flex h-10 w-full items-center gap-2.5 rounded-plate px-3 text-left transition-[background,box-shadow] duration-150 outline-none hover:recipe-row-list-hover focus-visible:focus-ring"
            >
              <ChevronRightIcon width={14} height={14} className={`shrink-0 text-ink3 transition-transform duration-150 ${open ? "rotate-90" : ""}`} />
              <RunStatusIcon status={step.status} size={15} />
              <span className={`min-w-0 flex-1 truncate type-ui ${step.status === "skipped" || step.status === "pending" ? "text-ink3" : "text-ink"}`}>
                {step.name}
              </span>
              {step.duration && <span className="shrink-0 type-readout text-ink3">{step.duration}</span>}
            </button>
            {open && (
              <div className="mx-3 mt-0.5 mb-2 overflow-hidden rounded-row well">
                {hasLog ? (
                  <pre className="max-h-[560px] overflow-auto py-2 font-mono text-[12px] leading-5 text-ink">
                    {step.omitted > 0 && (
                      <div className="px-3 pb-1 text-ink3">
                        {step.omitted.toLocaleString("en")} earlier lines not shown ·{" "}
                        <a href={rawHref} className="underline underline-offset-2 hover:text-ink">
                          view the full log
                        </a>
                      </div>
                    )}
                    {step.lines.map((line, i) => (
                      <div key={i} className="flex hover:bg-[color-mix(in_srgb,var(--mu-ink)_4%,transparent)]">
                        <span className="w-12 shrink-0 pr-3 text-right text-ink3 select-none">{step.omitted + i + 1}</span>
                        <span className="min-w-0 pr-3 break-all whitespace-pre-wrap">{line || " "}</span>
                      </div>
                    ))}
                  </pre>
                ) : (
                  <p className="px-3 py-2.5 type-meta text-ink3">
                    {step.status === "pending" ? "Not started yet." : step.status === "skipped" ? "This step was skipped." : "No output."}
                  </p>
                )}
              </div>
            )}
          </li>
        );
      })}
    </ol>
  );
}