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 { ops, type Op } from "../../stores/ops.svelte.js";
  import { ticker, formatElapsed } from "../../stores/elapsed.svelte.js";
  import { workbench } from "../../stores/workbench.svelte.js";
  import AppIcon from "../layout/AppIcon.svelte";
  import { activityRows } from "../../stores/activity-rows.js";

  let container: HTMLDivElement | undefined = $state();

  /**
   * Running at the top, longest-running first; everything finished below it,
   * newest first and one row per repeated activity.
   *
   * There is no auto-scroll any more, and no scroll-to-bottom. Both existed
   * because the panel appended, so the newest thing was at the bottom and had
   * to be chased. It does not append: what is happening now is at the top and
   * stays there, and a repeated operation updates its own row rather than
   * adding one.
   */
  const rows = $derived(activityRows(ops.ops));

  function clear() {
    ops.clearFinished();
  }

  /**
   * The clock, watched while anything here is running.
   *
   * One watch for the whole panel rather than one per row: the ticker counts
   * watchers, and six running operations should tick once, not six times.
   */
  $effect(() => (rows.some((row) => row.running) ? ticker.watch() : undefined));

  /// Live for a running row, fixed for a finished one.
  function duration(op: Op): string {
    return formatElapsed(
      op.started_at,
      op.ended_at,
      op.status === "running" ? ticker.now : 0,
    );
  }

  function kindLabel(kind: string): string {
    switch (kind) {
      case "git_scan":
        return "scan";
      case "git_mutation":
        return "git";
      case "sync":
        return "sync";
      case "file_operation":
        return "file";
      case "catalogue_scan":
        return "catalogue";
      case "command":
        return "wsb";
      default:
        return kind;
    }
  }

  function openOutput(opId: string, label: string) {
    workbench.openOpOutput(opId, label);
  }

  // The ops store lifecycle is managed by App.svelte (connect on app mount,
  // disconnect on app destroy). ActivityPanel does not own the connection.
</script>

<div class="activity-toolbar">
  <span class="toolbar-title">
    {#if ops.active.length > 0}
      <span class="running-badge">{ops.active.length} running</span>
    {:else}
      <span class="idle-label">Idle</span>
    {/if}
  </span>
  <button class="toolbar-btn" onclick={clear} title="Clear completed operations">Clear</button>
</div>

<div class="activity-body" bind:this={container}>
  {#if rows.length === 0}
    <div class="activity-empty">No background operations recorded yet.</div>
  {:else}
    <!-- Keyed by the ROW, not the operation: a repeated activity keeps the
         same key, so this updates a node in place instead of adding one. -->
    {#each rows as row (row.key)}
      {@const op = row.op}
      <div class="op-row op-{op.status}" class:op-repeated={row.count > 1}>
        <span class="op-icon">
          {#if op.status === "running"}
            <span class="spin"><AppIcon name="refresh" size={11} /></span>
          {:else if op.status === "completed"}
            <AppIcon name="circle-check" size={11} />
          {:else}
            <AppIcon name="circle-x" size={11} />
          {/if}
        </span>
        <span class="op-kind">{kindLabel(op.kind)}</span>
        <!-- Where it is running. A row for a terminal is not the daemon's own
             work: it will not stop when this page is closed, and the window it
             is in is where its prompt comes back. Marking it is what keeps
             that from reading as a defect. -->
        {#if op.started_from === "command-line"}
          <span class="op-where" title="running in a terminal, not started here">
            <AppIcon name="square-terminal" size={10} />
          </span>
        {/if}
        <span class="op-label">{row.title}</span>
        <!-- What this row stands for. A count instead of N identical rows, and
             a separate count of the ones that FAILED — a row that reported
             only its latest outcome would hide a failure behind the next
             success. -->
        {#if row.count > 1}
          <!-- `×N` is a multiplication sign, read as a word: "times four". Not
               a sign standing in for an icon, and an icon here would say
               nothing. -->
          <span class="op-repeat" title="{row.count} runs of this activity">×{row.count}</span>
        {/if}
        {#if row.failed > 0 && op.status !== "failed"}
          <span
            class="op-repeat op-repeat-failed"
            title="{row.failed} of these runs failed"
          >{row.failed} failed</span>
        {/if}
        {#if op.status === "running" && op.progress > 0}
          <span class="op-progress-bar">
            <span
              class="op-progress-fill"
              style="width: {Math.round(op.progress * 100)}%"
            ></span>
          </span>
          <span class="op-pct">{Math.round(op.progress * 100)}%</span>
        {/if}
        {#if op.status === "failed" && op.error}
          <span class="op-error" title={op.error}>{op.error}</span>
        {/if}
        {#if op.has_output}
          <button class="op-action" onclick={() => openOutput(op.id, op.label)}>
            output
          </button>
        {/if}
        <span class="op-duration">{duration(op)}</span>
      </div>
    {/each}
  {/if}
</div>

<style>
  .activity-toolbar {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-8);
    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-title {
    flex: 1;
    font-size: var(--wb-font-size-11);
  }

  .running-badge {
    color: var(--vscode-testing-iconQueued, #ccb233);
    font-weight: 600;
  }

  .idle-label {
    color: var(--vscode-disabledForeground);
  }

  .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);
  }

  .activity-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;
  }

  .activity-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);
  }

  .op-row {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-6);
    padding: var(--wb-spacing-1) var(--wb-spacing-12);
    min-height: var(--wb-control-size-18);
  }

  .op-row:hover {
    background: var(--vscode-list-hoverBackground);
  }

  .op-icon {
    display: flex;
    align-items: center;
    flex-shrink: 0;
  }

  .op-running .op-icon {
    color: var(--vscode-testing-iconQueued, #ccb233);
  }

  .op-completed .op-icon {
    color: var(--vscode-testing-iconPassed, #73c991);
  }

  .op-failed .op-icon {
    color: var(--vscode-errorForeground);
  }

  .op-where {
    display: inline-flex;
    align-items: center;
    opacity: 0.6;
    flex-shrink: 0;
  }

  .op-kind {
    font-size: var(--wb-font-size-10);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    flex-shrink: 0;
    min-width: var(--wb-control-size-40);
    color: var(--vscode-disabledForeground);
  }

  .op-running .op-kind {
    color: var(--vscode-testing-iconQueued, #ccb233);
  }

  .op-label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--vscode-editor-foreground);
  }

  .op-failed .op-label {
    color: var(--vscode-disabledForeground);
    text-decoration: line-through;
  }

  .op-progress-bar {
    width: var(--wb-control-size-60);
    height: var(--wb-control-size-4);
    border-radius: var(--wb-corner-radius-2);
    background: var(--vscode-list-hoverBackground);
    flex-shrink: 0;
    overflow: hidden;
  }

  .op-progress-fill {
    display: block;
    height: 100%;
    background: var(--vscode-testing-iconQueued, #ccb233);
    border-radius: var(--wb-corner-radius-2);
    transition: width 0.2s ease;
  }

  .op-pct {
    flex-shrink: 0;
    font-size: var(--wb-font-size-10);
    color: var(--vscode-disabledForeground);
    font-variant-numeric: tabular-nums;
    min-width: var(--wb-control-size-28);
    text-align: right;
  }

  /* A count, not an alarm: repetition is ordinary and should read as quiet. */
  .op-repeat {
    color: var(--vscode-descriptionForeground);
    font-size: var(--wb-font-size-10);
    font-variant-numeric: tabular-nums;
    flex-shrink: 0;
  }

  /* Except when some of them failed, which is the one thing collapsing could
     have hidden. */
  .op-repeat-failed {
    color: var(--vscode-errorForeground);
  }

  .op-error {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--vscode-errorForeground);
    font-size: var(--wb-font-size-10);
  }

  .op-duration {
    flex-shrink: 0;
    font-size: var(--wb-font-size-10);
    color: var(--vscode-disabledForeground);
    font-variant-numeric: tabular-nums;
    margin-left: auto;
  }

  .op-action {
    flex-shrink: 0;
    font-size: var(--wb-font-size-10);
    padding: 0 var(--wb-spacing-6);
    border-radius: var(--wsb-radius-sm);
    border: 1px solid var(--vscode-widget-border);
    color: var(--vscode-disabledForeground);
    background: none;
    cursor: pointer;
  }

  .op-action:hover {
    color: var(--vscode-editor-foreground);
    background: var(--vscode-list-hoverBackground);
  }

  @keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
  }

  .spin {
    display: inline-flex;
    animation: spin 1.2s linear infinite;
  }
</style>