<script lang="ts">
// The children an operation is running.
//
// These were a wall of identical chips reading `bash` — the first word of
// every argv, which is the one word they all share, so eighty of them said
// nothing eighty times. What a person wants from a running child is which
// one it is, how long it has been going, and a way to stop it.
import { killOpProcesses } from "../../api/client.js";
import { formatElapsed, ticker } from "../../stores/elapsed.svelte.js";
import type { RunningChild } from "../../stores/op-output-view.js";
let { opId, running }: { opId: string; running: RunningChild[] } = $props();
let busy = $state<number | "all" | null>(null);
let error = $state<string | null>(null);
/** What a stop actually achieved. */
let note = $state<string | null>(null);
/** Processes that took the signal and carried on. */
let stubborn = $state<number[]>([]);
// A clock only while there is something to count. Children come and go, so
// the watch follows the list rather than the tab.
$effect(() => (running.length > 0 ? ticker.watch() : undefined));
/** The command as run, which is what tells two `bash` children apart. */
function commandOf(child: RunningChild): string {
// `bash -c "…"` is the shape nearly all of these take, and the script is
// the only part that differs. Shown whole rather than as its first word.
const dashC = child.argv.indexOf("-c");
if (dashC >= 0 && child.argv[dashC + 1]) return child.argv[dashC + 1]!;
return child.argv.join(" ");
}
async function kill(
request: { pids?: number[]; all?: boolean },
mark: number | "all",
force = false,
) {
busy = mark;
error = null;
note = null;
try {
const outcome = await killOpProcesses(opId, { ...request, signal: force ? "KILL" : "TERM" });
// Both halves are reported. Saying nothing on success is what made this
// look like clicking did nothing at all.
if (outcome.refused.length > 0) {
error = outcome.refused.map((r) => `${r.pid}: ${r.reason}`).join("; ");
// Something that took the signal and kept running can be stopped
// outright, so the offer to do that is put in front of the reader
// rather than left for them to work out.
stubborn = outcome.refused
.filter((r) => r.reason.includes("still running"))
.map((r) => r.pid);
} else {
stubborn = [];
}
if (outcome.signalled.length > 0) {
note = `stopped ${outcome.signalled.length} process${outcome.signalled.length === 1 ? "" : "es"}`;
}
} catch (thrown) {
error = thrown instanceof Error ? thrown.message : String(thrown);
} finally {
busy = null;
}
}
</script>
<div class="processes">
<div class="head">
<span class="count">
{running.length} running child{running.length === 1 ? "" : "ren"}
</span>
{#if running.length > 0}
<button
class="kill-all"
disabled={busy !== null}
onclick={() => kill({ all: true }, "all")}
>
{busy === "all" ? "stopping…" : "Stop all"}
</button>
{/if}
</div>
{#if error}
<p class="error" role="alert">
{error}
{#if stubborn.length > 0}
<button class="kill" onclick={() => kill({ pids: stubborn }, "all", true)}>
Stop {stubborn.length === 1 ? "it" : "them"} outright
</button>
{/if}
</p>
{/if}
{#if note}
<p class="note" role="status">{note}</p>
{/if}
{#if running.length === 0}
<p class="empty">
Nothing is running. A child appears here the moment the run spawns it and
leaves when it exits.
</p>
{:else}
<div class="rows" role="list">
{#each running as child (child.id)}
<div class="row" role="listitem">
<span class="pid" title="process id">{child.pid}</span>
<span class="command" title={child.argv.join(" ")}>{commandOf(child)}</span>
{#if child.cwd}<span class="cwd" title={child.cwd}>{child.cwd}</span>{/if}
<span class="elapsed">
{child.startedAt ? formatElapsed(child.startedAt, undefined, ticker.now) : "—"}
</span>
<button
class="kill"
disabled={busy !== null}
onclick={() => kill({ pids: [child.pid] }, child.pid)}
>
{busy === child.pid ? "…" : "Stop"}
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.processes {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
overflow: hidden;
}
.head {
display: flex;
align-items: center;
gap: var(--wb-spacing-10);
padding: var(--wb-spacing-6) var(--wb-spacing-12);
border-bottom: 1px solid var(--vscode-panel-border);
}
.count {
flex: 1;
opacity: 0.8;
}
.rows {
flex: 1;
min-height: 0;
overflow: auto;
}
.row {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-10);
padding: var(--wb-spacing-3) var(--wb-spacing-12);
font-size: 0.92em;
}
.row:hover {
background: var(--vscode-list-hoverBackground);
}
.pid {
flex: 0 0 62px;
font-family: var(--vscode-editor-font-family, monospace);
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.command {
flex: 1;
min-width: 0;
font-family: var(--vscode-editor-font-family, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cwd {
flex: 0 0 auto;
max-width: 22ch;
opacity: 0.5;
font-size: 0.88em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}
.elapsed {
flex: 0 0 62px;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--vscode-testing-iconQueued, #cca700);
}
.kill,
.kill-all {
padding: var(--wb-spacing-1) var(--wb-spacing-9);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
background: none;
color: inherit;
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
.kill:hover:not(:disabled),
.kill-all:hover:not(:disabled) {
border-color: var(--vscode-testing-iconFailed, #f44336);
color: var(--vscode-testing-iconFailed, #f44336);
}
.kill:disabled,
.kill-all:disabled {
opacity: 0.5;
cursor: default;
}
.note {
margin: 0;
padding: var(--wb-spacing-6) var(--wb-spacing-12);
border-left: 2px solid var(--vscode-testing-iconPassed, #4caf50);
background: var(--vscode-inputValidation-infoBackground, rgba(76, 175, 80, 0.1));
}
.empty,
.error {
margin: 0;
padding: var(--wb-spacing-10) var(--wb-spacing-12);
}
.empty {
opacity: 0.6;
}
.error {
border-left: 2px solid var(--vscode-testing-iconFailed, #f44336);
background: var(--vscode-inputValidation-errorBackground, rgba(244, 67, 54, 0.12));
}
</style>