<script lang="ts">
import { ops as opsStore } from "../../stores/ops.svelte.js";
import { opOutputs as opOutputsStore } from "../../stores/op-output.svelte.js";
import { getTreeFile } from "../../api/client.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { explorer as explorerStore } from "../../stores/explorer.svelte.js";
import {
shownPane,
lineKind,
lineSegments,
lineSeverity,
lineText,
isLogStream,
type FileReference,
type StepRun,
} from "../../stores/op-output-view.js";
import RunTree from "./RunTree.svelte";
import ScrollMarks from "./ScrollMarks.svelte";
import RunProcesses from "./RunProcesses.svelte";
import { overview as overviewStore } from "../../stores/overview.svelte.js";
import { remembered, oneOf } from "../../utils/remember.svelte.js";
import RecordTable from "./RecordTable.svelte";
import { ticker, formatElapsed } from "../../stores/elapsed.svelte.js";
import { scoped, viewWorktree } from "../../stores/worktree-scope.svelte.js";
const overview = scoped(overviewStore);
const explorer = scoped(explorerStore);
const opOutputs = scoped(opOutputsStore);
const ops = scoped(opsStore);
const worktree = viewWorktree("an operation's output");
let { opId, title }: { opId: string; title: string } = $props();
const output = $derived(opOutputs.acquire(opId));
let container = $state<HTMLDivElement | undefined>();
const worktreeRoot = $derived(overview.versionInfo?.worktree_root ?? null);
let stepLogBox = $state<HTMLDivElement | undefined>();
function textSeverity(text: string): "error" | "warning" | null {
const marks = lineSegments(text, { marking: true, worktreeRoot })
.map((part) => part.mark)
.filter((mark) => mark !== null);
if (marks.includes("error")) return "error";
if (marks.includes("warning")) return "warning";
return null;
}
function openReference(file: FileReference) {
workbench.openFile(file.path, true, { line: file.line, column: file.column }, worktree);
}
let autoScroll = $state(true);
const pane = remembered<"run" | "analysis" | "processes" | "stream">(
"output.pane",
"run",
oneOf("run", "analysis", "processes", "stream"),
);
let openStep = $state<StepRun | null>(null);
let stepLog = $state<string | null>(null);
let stepLogError = $state<string | null>(null);
let stepLogLoading = $state(false);
const hasRun = $derived(output.view.groups.length > 0);
const hasTables = $derived(output.view.tables.length > 0);
const children = $derived([...output.view.running.values()]);
const shown = $derived(shownPane(pane.value, hasRun, hasTables, children.length > 0));
async function openLog(step: StepRun) {
if (openStep?.key === step.key) {
openStep = null;
stepLog = null;
stepLogError = null;
return;
}
openStep = step;
stepLog = null;
stepLogError = null;
if (!step.log) {
stepLogError = "this step has not written a log yet";
return;
}
stepLogLoading = true;
try {
const file = await getTreeFile(step.log, worktree);
stepLog = file.content;
} catch (failure) {
stepLogError = failure instanceof Error ? failure.message : String(failure);
} finally {
stepLogLoading = false;
}
}
function openArtifact(artifact: { kind: string; path: string }) {
if (artifact.kind.endsWith("-dir")) {
void explorer.revealPath(artifact.path);
return;
}
workbench.openFile(artifact.path, true, undefined, worktree);
}
$effect(() => {
const id = opId;
const stream = opOutputs.acquire(id);
stream.connect();
openStep = null;
stepLog = null;
stepLogError = null;
return () => {
if (stream.closed) opOutputs.release(id);
};
});
const op = $derived(ops.ops.find((o) => o.id === opId) ?? null);
function handleScroll() {
if (!container) return;
autoScroll =
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
}
$effect(() => {
const _ = output.lines.length;
if (autoScroll && container) {
container.scrollTop = container.scrollHeight;
}
});
let copiedPath = $state(false);
async function copyLogPath() {
const path = op?.log_path;
if (!path) return;
try {
await navigator.clipboard.writeText(path);
copiedPath = true;
setTimeout(() => (copiedPath = false), 1500);
} catch {
}
}
$effect(() => (op?.status === "running" ? ticker.watch() : undefined));
const elapsed = $derived(
op
? formatElapsed(
op.started_at,
op.ended_at,
op.status === "running" ? ticker.now : 0,
)
: "",
);
</script>
<div class="op-output-view">
<div class="op-header">
<div class="op-title-block">
<span class="op-title">{title}</span>
{#if op}
<span
class="op-status"
class:status-running={op.status === "running"}
class:status-failed={op.status === "failed"}
class:status-completed={op.status === "completed"}
>
{op.status}
· {elapsed}
</span>
{#if op.error}
<span class="op-error-text">{op.error}</span>
{/if}
{:else}
<span class="op-status">
not in the operation history — the log below is what was kept
</span>
{/if}
</div>
</div>
{#if op?.log_path}
{@const logPath = op.log_path}
<div class="op-log-path">
<button
class="log-path-copy"
title="Copy this path"
onclick={() => void copyLogPath()}
>{copiedPath ? "copied" : "copy"}</button>
<button
class="log-path-open"
title="Open the log file in the workbench"
onclick={() => workbench.openFile(logPath, true, undefined, worktree)}
>open</button>
<code class="log-path-text">{logPath}</code>
</div>
{/if}
{#if output.error && (output.gone || !output.closed)}
<div class="op-banner" class:op-banner-gone={output.gone}>{output.error}</div>
{/if}
{#if output.dropped > 0}
<div class="op-banner op-banner-note">
{output.dropped} earlier line{output.dropped === 1 ? "" : "s"} not shown —
this operation has no log file, so only the most recent output was kept.
</div>
{/if}
{#if output.view.artifacts.length > 0}
<div class="run-strip">
{#each output.view.artifacts as artifact (artifact.path)}
<button
class="chip chip-action"
title={artifact.path}
onclick={() => openArtifact(artifact)}
>
{artifact.label ?? artifact.kind}
</button>
{/each}
</div>
{/if}
{#if hasRun || hasTables}
<div class="pane-tabs">
{#if hasRun}
<button class="pane-tab" class:selected={shown === "run"} onclick={() => (pane.value = "run")}>
Run
</button>
{/if}
{#if hasTables}
<button
class="pane-tab"
class:selected={shown === "analysis"}
onclick={() => (pane.value = "analysis")}
>
Analysis ({output.view.tables.length})
</button>
{/if}
{#if children.length > 0 || shown === "processes"}
<button
class="pane-tab"
class:selected={shown === "processes"}
onclick={() => (pane.value = "processes")}
>
Processes ({children.length})
</button>
{/if}
<button class="pane-tab" class:selected={shown === "stream"} onclick={() => (pane.value = "stream")}>
Output ({output.lines.length})
</button>
</div>
{/if}
{#if shown === "analysis"}
<div class="analysis-pane">
{#each output.view.tables as table, index (index)}
<RecordTable {table} />
{/each}
</div>
{/if}
{#if shown === "processes"}
<RunProcesses {opId} running={children} />
{/if}
{#if shown === "run"}
<div class="run-pane">
<div class="run-left">
{#if hasRun}
<RunTree
view={output.view}
selected={openStep?.key ?? null}
onselect={(step) => void openLog(step)}
/>
{/if}
</div>
{#if openStep}
<div class="step-pane">
<div class="step-head">
<span class="step-title">{openStep.suite} · {openStep.step}</span>
{#if openStep.log}<span class="step-path" title={openStep.log}>{openStep.log}</span>{/if}
<button class="strip-action" onclick={() => { openStep = null; stepLog = null; }}>Close</button>
</div>
{#if stepLogLoading}
<div class="step-note">reading {openStep.log}…</div>
{:else if stepLogError}
<div class="step-note step-note-bad">{stepLogError}</div>
{:else if stepLog !== null}
<div class="step-log-holder">
<div class="step-log" bind:this={stepLogBox}>
{#each stepLog.split("\n") as text}
{@const severity = textSeverity(text)}
<div class="op-line step-log-line" data-severity={severity ?? undefined}>
{#each lineSegments(text, { marking: true, worktreeRoot }) as part}
{#if part.file}
<button
class="line-file"
title="Open {part.file.path}{part.file.line ? `:${part.file.line}` : ''}"
onclick={() => openReference(part.file!)}
>{part.text}</button>
{:else if part.mark}
<mark class="mark-{part.mark}">{part.text}</mark>
{:else}{part.text}{/if}
{/each}
</div>
{/each}
</div>
<ScrollMarks container={stepLogBox} label="step log" />
</div>
{/if}
</div>
{/if}
</div>
{/if}
{#if shown === "stream"}
<div class="op-log-holder">
<div class="op-log" bind:this={container} onscroll={handleScroll}>
{#if output.lines.length === 0 && !output.closed}
<div class="op-waiting">Waiting for output…</div>
{:else}
{#each output.lines as line (line.seq)}
{@const severity = lineSeverity(line)}
<div
class="op-line line-{lineKind(line)}"
data-severity={severity ?? undefined}
>
{#each lineSegments(lineText(line), { marking: isLogStream(line), worktreeRoot }) as part}
{#if part.file}
<button
class="line-file"
title="Open {part.file.path}{part.file.line ? `:${part.file.line}` : ''}"
onclick={() => openReference(part.file!)}
>{part.text}</button>
{:else if part.mark}
<mark class="mark-{part.mark}">{part.text}</mark>
{:else}{part.text}{/if}
{/each}
</div>
{/each}
{#if output.closed}
<div class="op-line op-line-end">— end of output —</div>
{/if}
{/if}
</div>
<ScrollMarks {container} label="log" />
</div>
{/if}
</div>
<style>
.op-banner-gone {
color: var(--text-muted, #888);
}
.op-banner-blocked {
color: var(--vscode-editorWarning-foreground, #cca700);
}
.run-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-5) var(--wb-spacing-14);
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
font-size: var(--wb-font-size-11);
}
.strip-action {
padding: var(--wb-spacing-2) var(--wb-spacing-8);
font-size: var(--wb-font-size-11);
background: color-mix(in srgb, currentColor 8%, transparent);
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: var(--wb-corner-radius-3);
color: inherit;
cursor: pointer;
}
.strip-action:hover {
background: color-mix(in srgb, currentColor 16%, transparent);
}
.pane-tabs {
display: flex;
gap: var(--wb-spacing-2);
padding: var(--wb-spacing-4) var(--wb-spacing-10) 0;
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
}
.pane-tab {
padding: var(--wb-spacing-3) var(--wb-spacing-10);
font-size: var(--wb-font-size-11);
background: none;
border: none;
border-bottom: 2px solid transparent;
color: inherit;
opacity: 0.7;
cursor: pointer;
}
.pane-tab.selected {
opacity: 1;
border-bottom-color: var(--vscode-focusBorder, #0e70c0);
}
.run-pane {
display: flex;
min-height: 0;
flex: 1;
overflow: hidden;
}
.analysis-pane {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
overflow: auto;
}
.run-left {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
overflow: auto;
}
.step-pane {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
border-left: 1px solid var(--vscode-panel-border);
overflow: hidden;
}
.step-head {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-5) var(--wb-spacing-10);
border-bottom: 1px solid var(--vscode-panel-border);
font-size: var(--wb-font-size-11);
flex-shrink: 0;
}
.step-title {
font-weight: 600;
}
.step-path {
opacity: 0.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.step-log-holder {
position: relative;
flex: 1;
min-height: 0;
display: flex;
}
.step-log-line {
white-space: pre-wrap;
word-break: break-word;
}
.step-log {
margin: 0;
padding: var(--wb-spacing-8) var(--wb-spacing-10);
flex: 1;
min-height: 0;
overflow: auto;
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--wb-font-size-11);
white-space: pre-wrap;
word-break: break-word;
}
.step-note {
padding: var(--wb-spacing-8) var(--wb-spacing-10);
font-size: var(--wb-font-size-11);
opacity: 0.75;
}
.step-note-bad {
color: var(--vscode-errorForeground, #f14c4c);
opacity: 1;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-4);
padding: var(--wb-spacing-1) var(--wb-spacing-7);
border-radius: 999px;
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
background: color-mix(in srgb, currentColor 7%, transparent);
white-space: nowrap;
}
.chip-action {
font: inherit;
cursor: pointer;
}
.chip-action:hover {
background: color-mix(in srgb, currentColor 16%, transparent);
}
.line-stream-err {
color: var(--wb-log-stderr);
}
.line-stream-out,
.line-plain,
.line-test {
color: var(--wb-log-stdout);
}
.line-command,
.line-phase {
color: var(--wb-log-command);
}
.line-test-ok,
.line-summary {
color: var(--wb-log-ok);
}
.line-failure,
.line-test-failed,
.line-test-cancelled,
.line-message-error {
color: var(--wb-log-failed);
}
.line-message-warn,
.line-test-skipped {
color: var(--wb-log-warn);
}
.line-secret,
.line-artifact,
.line-table,
.line-catalogue,
.line-message-info,
.line-message-debug {
color: var(--wb-log-quiet);
}
.line-ended {
color: var(--wb-log-quiet);
opacity: 0.85;
}
.op-output-view {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.op-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--wb-spacing-10);
padding: var(--wb-spacing-10) var(--wb-spacing-14) var(--wb-spacing-8);
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
}
.op-title-block {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-2);
min-width: 0;
}
.op-title {
font-size: var(--wb-font-size-13);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.op-status {
font-size: var(--wb-font-size-11);
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.status-failed {
color: var(--vscode-errorForeground, #f14c4c);
opacity: 1;
}
.status-completed {
color: var(--vscode-testing-iconPassed, #4caf50);
opacity: 1;
}
.op-error-text {
font-size: var(--wb-font-size-11);
color: var(--vscode-errorForeground, #f14c4c);
overflow-wrap: anywhere;
}
.op-log-path {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-4) var(--wb-spacing-14) var(--wb-spacing-5);
border-bottom: 1px solid var(--vscode-panel-border);
font-size: var(--wb-font-size-11);
flex-shrink: 0;
min-width: 0;
}
.log-path-text {
font-family: var(--vscode-editor-font-family, monospace);
color: var(--vscode-descriptionForeground, #999);
overflow-x: auto;
white-space: nowrap;
user-select: text;
min-width: 0;
}
.log-path-copy,
.log-path-open {
background: none;
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
color: var(--vscode-descriptionForeground, #999);
cursor: pointer;
font-size: var(--wb-font-size-10);
padding: var(--wb-spacing-1) var(--wb-spacing-6);
flex-shrink: 0;
}
.log-path-copy:hover,
.log-path-open:hover {
color: var(--vscode-foreground);
border-color: var(--vscode-focusBorder, #007acc);
}
.op-banner {
padding: var(--wb-spacing-5) var(--wb-spacing-14);
font-size: var(--wb-font-size-11);
color: var(--vscode-errorForeground, #f14c4c);
flex-shrink: 0;
}
.op-banner-note {
color: inherit;
opacity: 0.7;
}
.op-log-holder {
position: relative;
flex: 1;
min-height: 0;
display: flex;
}
.op-log {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--wb-spacing-8) var(--wb-spacing-14) var(--wb-spacing-16);
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--wb-font-size-12);
line-height: 1.5;
}
.line-file {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
color: inherit;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
text-underline-offset: 2px;
cursor: pointer;
}
.line-file:hover {
color: var(--wb-log-command);
text-decoration-color: currentColor;
}
.op-line {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.op-line-end,
.op-waiting {
opacity: 0.55;
font-style: italic;
}
</style>