wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<!--
  One worktree, as the machine home and the project view list it: what is
  checked out, how it stands against its upstream, whether its services run,
  and its prj session slots.

  The name is the way in. A worktree that is not open is opened on the way, so
  following the link never lands on a workbench that answers 404.
-->
<script lang="ts">
  import type { Worktree } from "../../api/types/levels.js";
  import { actionKey, levels } from "../../stores/levels.svelte.js";
  import { context } from "../../stores/context.svelte.js";
  import { router } from "../../stores/router.svelte.js";
  import { workbench } from "../../stores/workbench.svelte.js";
  import { opensInWorkbench } from "../../utils/open-link.js";
  import { formatUnixRelative } from "../../utils/format.js";
  import SlotRow from "./SlotRow.svelte";

  interface Props {
    worktree: Worktree;
    /** The project view's fuller row: path, commit and upstream. */
    detailed?: boolean;
  }

  let { worktree, detailed = false }: Props = $props();

  let confirmingRemove = $state(false);

  const href = $derived(router.href({ level: "worktree", worktree: worktree.id }));
  const startOutcome = $derived(levels.outcome(actionKey.start(worktree.id)));
  const removeOutcome = $derived(levels.outcome(actionKey.remove(worktree.id)));
  const openFailure = $derived(
    levels.openFailure?.worktree === worktree.id ? levels.openFailure.message : null,
  );
  /** The branch, or the commit a detached head is at, or nothing git could say. */
  const checkedOut = $derived(
    worktree.branch ??
      (worktree.head !== null ? `detached at ${worktree.head.slice(0, 8)}` : "unknown"),
  );
  /** main-worktree holds the project's own repositories; prj never removes it. */
  const removable = $derived(worktree.project !== null && worktree.name !== "main");

  /** Work in it, with its overview open: opened first when it is closed. */
  const enter = opensInWorkbench(async (beside) => {
    if (await context.enter(worktree.id)) workbench.openOverview(worktree.id, { beside });
  });

  async function remove() {
    confirmingRemove = false;
    await levels.removeWorktree(worktree.id);
  }
</script>

<div class="worktree" class:current={worktree.id === context.worktree} data-worktree={worktree.id}>
  <div class="worktree-head">
    <a class="worktree-name" {href} onclick={enter}>{worktree.name}</a>
    {#if levels.opening === worktree.id}
      <span class="chip">opening…</span>
    {:else if worktree.open}
      <span class="chip chip--open" title="Its services are running in this daemon">open</span>
    {:else}
      <span class="chip" title="Opened when you go into it">not open</span>
    {/if}
    <span class="worktree-branch" title={worktree.head ?? undefined}>
      {checkedOut}
    </span>
  </div>

  {#if worktree.error !== null}
    <p class="worktree-failure" role="alert">Its git state could not be read: {worktree.error}</p>
  {:else}

  <div class="worktree-facts subtle">
    <span class:dirty={worktree.dirty > 0}>
      {worktree.dirty === 0 ? "clean" : `${worktree.dirty} changed`}
    </span>
    {#if worktree.upstream === null}
      <span>no upstream</span>
    {:else}
      <span title="Against {worktree.upstream}">
        {worktree.ahead ?? 0} ahead · {worktree.behind ?? 0} behind
      </span>
    {/if}
    <span title="The checked-out commit">{formatUnixRelative(worktree.last_commit_at)}</span>
  </div>
  {/if}

  {#if detailed}
    <dl class="worktree-detail">
      <dt>Path</dt>
      <dd>{worktree.path}</dd>
      {#if worktree.head !== null}
        <dt>Commit</dt>
        <dd>{worktree.head}</dd>
      {/if}
      {#if worktree.upstream !== null}
        <dt>Upstream</dt>
        <dd>{worktree.upstream}</dd>
      {/if}
    </dl>
  {/if}

  {#if openFailure}
    <p class="worktree-failure" role="alert">Could not open it: {openFailure}</p>
  {/if}

  {#if worktree.slots.length > 0}
    <ul class="worktree-slots" aria-label="Session slots">
      {#each worktree.slots as slot (slot.key)}
        <SlotRow {slot} />
      {/each}
    </ul>
  {:else if detailed}
    <p class="empty">No session slots.</p>
  {/if}

  <!-- prj keeps slots and worktrees for its own projects only: a worktree
       outside `$PRJ` has neither to offer. -->
  {#if worktree.project !== null}
  <div class="button-row worktree-actions">
    <button
      class="secondary tiny"
      disabled={startOutcome?.running}
      onclick={() => void levels.startSession(worktree.id)}
    >
      {startOutcome?.running ? "Starting…" : "New session"}
    </button>
    {#if removable}
      {#if confirmingRemove}
        <span class="subtle">Remove {worktree.name}-worktree? prj refuses unless it is clean and pushed.</span>
        <button class="danger tiny" onclick={() => void remove()}>Remove</button>
        <button class="secondary tiny" onclick={() => (confirmingRemove = false)}>Cancel</button>
      {:else}
        <button
          class="danger-outline tiny"
          disabled={removeOutcome?.running}
          onclick={() => (confirmingRemove = true)}
        >
          {removeOutcome?.running ? "Removing…" : "Remove"}
        </button>
      {/if}
    {/if}
  </div>
  {/if}

  {#each [{ key: actionKey.start(worktree.id), outcome: startOutcome }, { key: actionKey.remove(worktree.id), outcome: removeOutcome }] as entry (entry.key)}
    {#if entry.outcome && !entry.outcome.running}
      <div class="worktree-outcome" class:failed={entry.outcome.error !== null}>
        {#if entry.outcome.error !== null}
          <span role="alert">{entry.outcome.error}</span>
        {:else if entry.outcome.output}
          <pre>{entry.outcome.output}</pre>
        {/if}
        <button class="quiet" onclick={() => levels.dismiss(entry.key)}>Dismiss</button>
      </div>
    {/if}
  {/each}
</div>

<style>
  .worktree {
    padding: var(--wb-spacing-8) var(--wb-spacing-10);
    border-top: 1px solid var(--vscode-panel-border);
  }

  .worktree.current {
    box-shadow: inset 2px 0 0 var(--vscode-focusBorder);
  }

  .worktree-head {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-8);
    min-width: 0;
  }

  .worktree-name {
    font-weight: 600;
    font-size: var(--wb-font-size-13);
    color: var(--vscode-textLink-foreground);
    text-decoration: none;
  }

  .worktree-name:hover {
    text-decoration: underline;
  }

  .worktree-branch {
    margin-left: auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: var(--vscode-editor-font-family, monospace);
    font-size: var(--wb-font-size-11);
    color: var(--vscode-descriptionForeground);
  }

  .worktree-facts {
    display: flex;
    flex-wrap: wrap;
    gap: var(--wb-spacing-10);
    margin-top: var(--wb-spacing-4);
    font-size: var(--wb-font-size-11);
  }

  .worktree-facts .dirty {
    color: var(--vscode-gitDecoration-modifiedResourceForeground, var(--vscode-editor-foreground));
  }

  .worktree-detail {
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: var(--wb-spacing-2) var(--wb-spacing-10);
    margin: var(--wb-spacing-6) 0 0;
    font-size: var(--wb-font-size-11);
  }

  .worktree-detail dt {
    color: var(--vscode-descriptionForeground);
  }

  .worktree-detail dd {
    margin: 0;
    min-width: 0;
    overflow-wrap: anywhere;
    font-family: var(--vscode-editor-font-family, monospace);
  }

  .worktree-failure {
    margin: var(--wb-spacing-6) 0 0;
    font-size: var(--wb-font-size-11);
    color: var(--wb-error);
  }

  .worktree-slots {
    margin: var(--wb-spacing-6) 0 0;
    padding: 0;
  }

  .worktree-actions {
    margin-top: var(--wb-spacing-6);
    align-items: center;
  }

  .worktree-outcome {
    display: flex;
    align-items: flex-start;
    gap: var(--wb-spacing-8);
    margin-top: var(--wb-spacing-4);
    font-size: var(--wb-font-size-11);
    color: var(--vscode-descriptionForeground);
  }

  .worktree-outcome.failed {
    color: var(--wb-error);
  }

  .worktree-outcome pre {
    margin: 0;
    flex: 1;
    min-width: 0;
    white-space: pre-wrap;
    font-family: var(--vscode-editor-font-family, monospace);
  }

  .worktree-outcome span {
    flex: 1;
  }
</style>