wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<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>