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 peer, and every worktree this machine could carry with it.

  A peer is a MACHINE. Whether anything crosses is decided per worktree, and
  per repository inside it, so this card is a list of relationships rather than
  a single on/off switch.
-->
<script lang="ts">
  import { peers } from "../../stores/peers.svelte.js";
  import type { PeerSummary, Service } from "../../api/types/peer.js";
  import RelationshipPanel from "./RelationshipPanel.svelte";

  import AppIcon from "../layout/AppIcon.svelte";
  interface Props {
    peer: PeerSummary;
  }
  let { peer }: Props = $props();

  let expanded = $state(false);

  const worktrees = $derived(peers.worktrees[peer.id] ?? []);

  async function toggle() {
    expanded = !expanded;
    if (expanded) {
      await peers.loadWorkspaces(peer.id);
      await peers.loadServices(peer.id);
    }
  }

  /**
   * What this machine will do with that one.
   *
   * Both independent service switches live here, from the same peer store.
   * This machine page is the single place to operate the relationship before
   * choosing a worktree.
   */
  function switchOf(service: Service): "on" | "off" {
    return (
      (peers.services[peer.id] ?? []).find((entry) => entry.service === service)?.switch ?? "on"
    );
  }

  function describes(service: Service): string {
    return (
      (peers.services[peer.id] ?? []).find((entry) => entry.service === service)?.describes ?? ""
    );
  }

  async function flip(service: Service) {
    await peers.setService(peer.id, service, switchOf(service) === "on" ? "off" : "on");
  }

  /**
   * A state that has held too long is worth saying, because "connecting" and
   * "has been trying to connect for ten minutes" look identical otherwise.
   */
  const stuck = $derived(
    peer.state === "connecting" && peer.state_for_seconds > 60,
  );
</script>

<article class="peer">
  <header>
    <button class="disclose" onclick={toggle} aria-expanded={expanded}>
      <AppIcon name={expanded ? "chevron-down" : "chevron-right"} size={11} />
      <span class="name">{peer.name}</span>
    </button>
    <span class="state {peer.state}">{peer.state}</span>
    {#if stuck}
      <span class="stuck">for {Math.round(peer.state_for_seconds / 60)} min</span>
    {/if}
    {#if peer.detail}
      <span class="detail">{peer.detail}</span>
    {/if}
    <span class="spacer"></span>
    <span class="fingerprint" title="Compare this with the other machine">
      {peer.fingerprint}
    </span>
    <button
      class="forget"
      disabled={peers.busy === `forget:${peer.id}`}
      onclick={() => peers.forget(peer.id)}
      title="End this relationship. Baselines go with it; files already carried stay."
    >
      Forget
    </button>
  </header>

  {#if expanded}
    <div class="services">
      {#each ["files"] as const as service (service)}
        <label class="service" title={describes(service)}>
          <input
            type="checkbox"
            checked={switchOf(service) === "on"}
            disabled={peers.busy === `service:${peer.id}:${service}`}
            onchange={() => void flip(service)}
          />
          File sync
          <span class="service-switch">{switchOf(service)}</span>
        </label>
      {/each}
    </div>
    {#if switchOf("files") === "off"}
      <p class="subtle">
        Working trees are not being compared with this machine.
      </p>
    {:else if !worktrees.length}
      <p class="subtle">
        Nothing to compare: either the peer is not reachable, or the two
        machines have no worktree in common.
      </p>
    {:else}
      {#each worktrees as worktree (worktree.id)}
        <RelationshipPanel {peer} {worktree} />
      {/each}
    {/if}
  {/if}
</article>

<style>
  .services {
    display: flex;
    gap: var(--wb-spacing-16);
    margin: var(--wb-spacing-6) 0 var(--wb-spacing-2);
  }

  .service {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-6);
    font-size: var(--wb-font-size-12);
    cursor: pointer;
  }

  .service-switch {
    font-size: var(--wb-font-size-10);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--vscode-descriptionForeground);
  }

  .peer {
    border: 1px solid var(--vscode-panel-border);
    border-radius: var(--wb-corner-radius-4);
    padding: var(--wb-spacing-8) var(--wb-spacing-12);
    margin-bottom: var(--wb-spacing-10);
  }

  header {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-10);
  }

  .disclose {
    background: none;
    border: none;
    color: var(--vscode-editor-foreground);
    cursor: pointer;
    padding: 0;
    font-size: var(--wb-font-size-13);
  }

  .name {
    font-weight: 600;
  }

  .state {
    font-size: var(--wb-font-size-10);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    padding: var(--wb-spacing-1) var(--wb-spacing-6);
    border-radius: var(--wb-corner-radius-8);
    background: var(--vscode-badge-background);
    color: var(--vscode-badge-foreground);
  }

  .state.connected {
    background: var(--vscode-testing-iconPassed, #2d7d46);
    color: #fff;
  }

  .state.error {
    background: var(--vscode-inputValidation-errorBackground, #5a1d1d);
    color: var(--vscode-errorForeground);
  }

  .stuck,
  .detail,
  .fingerprint,
  .subtle {
    color: var(--vscode-descriptionForeground);
    font-size: var(--wb-font-size-11);
  }

  .fingerprint {
    font-family: var(--vscode-editor-font-family, monospace);
  }

  .spacer {
    flex: 1;
  }

  .forget {
    background: none;
    border: 1px solid var(--vscode-panel-border);
    color: var(--vscode-descriptionForeground);
    cursor: pointer;
    padding: var(--wb-spacing-2) var(--wb-spacing-8);
    border-radius: var(--wb-corner-radius-2);
    font-size: var(--wb-font-size-11);
  }
</style>