<script lang="ts">
import { logs } from "../../stores/logs.svelte.js";
import { overview } from "../../stores/overview.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import ScrollMarks from "../opoutput/ScrollMarks.svelte";
import { lineSegments, type FileReference } from "../../stores/op-output-view.js";
let autoScroll = $state(true);
let container: HTMLDivElement | undefined = $state();
let copyLabel = $state("Copy");
async function copy() {
const text = logs.lines
.map((l) => `${l.ts} ${l.level.padEnd(5)} ${l.target} ${l.message}`)
.join("\n");
await navigator.clipboard.writeText(text);
copyLabel = "Copied";
setTimeout(() => { copyLabel = "Copy"; }, 1500);
}
/**
* The daemon's own log, read the same way an operation's output is: a mark
* on the scroll track for every error and warning, and a link for every file
* it names. Two different logs on one screen that behaved differently would
* be two things to learn.
*/
const worktreeRoot = $derived(overview.versionInfo?.worktree_root ?? null);
function severity(level: string): "error" | "warning" | null {
const upper = level.toUpperCase();
if (upper === "ERROR") return "error";
if (upper === "WARN") return "warning";
return null;
}
function openReference(file: FileReference) {
workbench.openFile(file.path, true, { line: file.line, column: file.column });
}
function levelClass(level: string): string {
const l = level.toUpperCase();
if (l === "ERROR") return "lvl-error";
if (l === "WARN") return "lvl-warn";
if (l === "DEBUG" || l === "TRACE") return "lvl-debug";
return "lvl-info";
}
function handleScroll() {
if (!container) return;
autoScroll = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
}
// Auto-scroll whenever lines change and the user hasn't scrolled up.
$effect(() => {
const _ = logs.lines.length;
if (autoScroll && container) {
// Defer so the DOM has updated before measuring.
requestAnimationFrame(() => {
if (container) container.scrollTop = container.scrollHeight;
});
}
});
</script>
<div class="output-toolbar">
<button class="toolbar-btn" onclick={() => logs.clear()} title="Clear output">Clear</button>
<button class="toolbar-btn" onclick={copy} title="Copy all output to clipboard" disabled={logs.lines.length === 0}>{copyLabel}</button>
<label class="toolbar-toggle">
<input type="checkbox" bind:checked={autoScroll} />
Auto-scroll
</label>
<span class="line-count">{logs.lines.length} lines</span>
</div>
<div class="output-holder">
<div class="output-body" bind:this={container} onscroll={handleScroll}>
{#if logs.lines.length === 0}
<div class="output-empty">Waiting for log output…</div>
{:else}
{#each logs.lines as line (line.seq)}
<div class="log-line {levelClass(line.level)}" data-severity={severity(line.level) ?? undefined}>
<span class="log-ts">{line.ts}</span>
<span class="log-level">{line.level.padEnd(5)}</span>
<span class="log-target">{line.target}</span>
<span class="log-msg"
>{#each lineSegments(line.message, { 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}</span
>
</div>
{/each}
{/if}
</div>
<ScrollMarks {container} label="log" />
</div>
<style>
/* The ruler sits over this box's scroll track, so the box is what positions
it — the body itself is what scrolls. */
.output-holder {
position: relative;
flex: 1;
min-height: 0;
display: flex;
}
.output-toolbar {
display: flex;
align-items: center;
gap: var(--wb-spacing-12);
padding: 0 var(--wb-spacing-12);
height: var(--wsb-row-height-compact);
flex-shrink: 0;
border-bottom: 1px solid var(--vscode-widget-border);
background: var(--vscode-sideBar-background);
}
.toolbar-btn {
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
padding: var(--wb-spacing-1) var(--wb-spacing-6);
border-radius: var(--wsb-radius-sm);
border: 1px solid transparent;
}
.toolbar-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
border-color: var(--vscode-widget-border);
}
.toolbar-toggle {
display: flex;
align-items: center;
gap: var(--wb-spacing-5);
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
user-select: none;
cursor: pointer;
}
.toolbar-toggle input {
margin: 0;
cursor: pointer;
}
.line-count {
margin-left: auto;
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
font-variant-numeric: tabular-nums;
}
.output-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--wb-spacing-4) 0;
font-family: var(--wsb-font-mono, monospace);
font-size: var(--wb-font-size-11);
line-height: 1.5;
}
.output-empty {
padding: var(--wb-spacing-12) var(--wb-spacing-16);
color: var(--vscode-disabledForeground);
font-family: var(--wsb-font-sans);
font-size: var(--wb-font-size-12);
}
/* A path in a log line, as a link — the same affordance the operation
output uses, because it is the same act. */
.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;
}
.log-line {
display: flex;
gap: var(--wb-spacing-8);
padding: 0 var(--wb-spacing-12);
white-space: pre;
}
.log-line:hover {
background: var(--vscode-list-hoverBackground);
}
.log-ts {
color: var(--vscode-disabledForeground);
flex-shrink: 0;
}
.log-level {
flex-shrink: 0;
font-weight: 600;
}
.log-target {
color: var(--vscode-disabledForeground);
flex-shrink: 0;
min-width: 0;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.log-msg {
color: var(--vscode-editor-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* Level colours */
.lvl-error .log-level { color: var(--vscode-errorForeground); }
.lvl-warn .log-level { color: var(--vscode-editorWarning-foreground, #d4aa00); }
.lvl-info .log-level { color: var(--vscode-focusBorder); }
.lvl-debug .log-level { color: var(--vscode-disabledForeground); }
.lvl-error .log-msg { color: var(--vscode-errorForeground); }
.lvl-warn .log-msg { color: var(--vscode-editorWarning-foreground, #d4aa00); }
</style>