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">
  // The catalogue as a list you can read, not a count.
  //
  // Every test the worktree holds, searchable, filtered by repository and
  // language, grouped by the repository it lives in, and each one opens the
  // file it is in. That is the difference between "8941 tests" and "where is
  // TEST8114".
  import { onMount } from "svelte";
  import type { CataloguedTest } from "../../api/types/catalogue.js";
  import {
    catalogue as catalogueStore,
    testMatches,
    BROWSE_LIMIT,
    type CatalogueQuery,
  } from "../../stores/catalogue.svelte.js";
  import { workbench } from "../../stores/workbench.svelte.js";
  import AppIcon from "../layout/AppIcon.svelte";
  import { scoped, viewWorktree } from "../../stores/worktree-scope.svelte.js";
  const catalogue = scoped(catalogueStore);
  const worktree = viewWorktree("the test catalogue");

  // The list is this view's own reading, so it reads it rather than relying
  // on something else having done so first.
  onMount(() => {
    void catalogue.loadTests();
  });

  let text = $state("");
  let repo = $state<string | null>(null);
  let language = $state<string | null>(null);
  let unnumberedOnly = $state(false);
  /** Repositories the reader has opened. Nothing is expanded to begin with. */
  let opened = $state<Set<string>>(new Set());

  const query = $derived<CatalogueQuery>({ text, repo, language, unnumberedOnly });
  const matching = $derived(catalogue.tests.filter((test) => testMatches(test, query)));

  /** The matches grouped by repository, largest group first. */
  const groups = $derived.by(() => {
    const byRepo = new Map<string, CataloguedTest[]>();
    for (const test of matching) {
      const list = byRepo.get(test.repo);
      if (list) list.push(test);
      else byRepo.set(test.repo, [test]);
    }
    return [...byRepo.entries()]
      .map(([name, tests]) => ({ name, tests }))
      .sort((left, right) => right.tests.length - left.tests.length);
  });

  /** A search narrow enough to show its groups open. */
  const searching = $derived(
    text.trim().length > 0 || repo !== null || language !== null || unnumberedOnly,
  );

  function toggleRepo(name: string) {
    const next = new Set(opened);
    if (next.has(name)) next.delete(name);
    else next.add(name);
    opened = next;
  }

  /** A repository group is open when opened, or when a search picked it out. */
  function isOpen(name: string, size: number): boolean {
    if (opened.has(name)) return true;
    return searching && size <= 40;
  }

  function testLabel(test: CataloguedTest): string {
    return test.number === null ? test.function_name : `TEST${test.number}`;
  }

  function open(test: CataloguedTest) {
    // The catalogue records a path relative to its repository; the workbench
    // opens paths relative to the worktree root, and the superproject's own
    // tests carry an empty repo.
    const path = test.repo === "" ? test.file_path : `${test.repo}/${test.file_path}`;
    workbench.openFile(path, true, undefined, worktree);
  }
</script>

<div class="browser">
  <div class="controls">
    <input
      class="search"
      bind:value={text}
      placeholder="search name, description, file or number"
      aria-label="search the catalogue"
    />
    <select bind:value={repo} aria-label="repository">
      <option value={null}>every repository</option>
      {#each catalogue.repositories as name (name)}
        <option value={name}>{catalogue.labelFor(name)}</option>
      {/each}
    </select>
    <select bind:value={language} aria-label="language">
      <option value={null}>every language</option>
      {#each catalogue.languages as name (name)}
        <option value={name}>{name}</option>
      {/each}
    </select>
    <label class="check">
      <input type="checkbox" bind:checked={unnumberedOnly} />
      only tests that claim no number
    </label>
  </div>

  <div class="summary">
    {#if catalogue.loadingTests}
      reading the catalogue…
    {:else}
      <span class="count">{matching.length}</span> of {catalogue.tests.length} tests
    {/if}
  </div>

  {#if !catalogue.loadingTests && catalogue.tests.length === 0}
    <p class="empty">Nothing catalogued yet. Rescan to read the worktree.</p>
  {:else if matching.length === 0 && !catalogue.loadingTests}
    <p class="empty">No test matches this search.</p>
  {/if}

  {#each groups as group (group.name)}
    {@const open_ = isOpen(group.name, group.tests.length)}
    {@const shown = open_ ? group.tests.slice(0, BROWSE_LIMIT) : []}
    <div class="group">
      <button class="group-head" onclick={() => toggleRepo(group.name)}>
        <span class="twist"><AppIcon name={open_ ? "chevron-down" : "chevron-right"} size={11} /></span>
        <span class="group-name" title={catalogue.labelFor(group.name)}>
          {catalogue.labelFor(group.name)}
        </span>
        <span class="group-count">{group.tests.length}</span>
      </button>

      {#if open_}
        {#each shown as test (test.id)}
          <button class="test-open" onclick={() => open(test)} title={test.description}>
            <span class="test-name" class:unnumbered={test.number === null}>
              {testLabel(test)}
            </span>
            <span class="test-fn">{test.function_name}</span>
            <span class="test-where">{test.file_path}:{test.line_number}</span>
          </button>
        {/each}
        {#if group.tests.length > shown.length}
          <p class="more">
            {group.tests.length - shown.length} more — search to narrow this down
          </p>
        {/if}
      {/if}
    </div>
  {/each}
</div>

<style>
  .browser {
    display: flex;
    flex-direction: column;
    gap: var(--wb-spacing-2);
    font-size: var(--wb-font-size-12);
  }

  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--wb-spacing-6);
    padding-bottom: var(--wb-spacing-6);
  }

  .search {
    flex: 1 1 22rem;
  }

  .search,
  .controls select {
    padding: var(--wb-spacing-4) var(--wb-spacing-7);
    font-size: var(--wb-font-size-12);
    color: inherit;
    background: color-mix(in srgb, currentColor 6%, transparent);
    border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
    border-radius: var(--wb-corner-radius-3);
  }

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

  .summary {
    padding-bottom: var(--wb-spacing-6);
    opacity: 0.75;
  }

  .count {
    font-variant-numeric: tabular-nums;
    font-weight: 600;
  }

  .group-head {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-6);
    width: 100%;
    padding: var(--wb-spacing-3) var(--wb-spacing-4);
    background: none;
    border: none;
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
  }

  .group-head:hover,
  .test-open:hover {
    background: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.14));
  }

  .twist {
    width: var(--wb-control-size-9);
    opacity: 0.7;
  }

  .group-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-weight: 600;
  }

  .group-count {
    margin-left: auto;
    font-variant-numeric: tabular-nums;
    opacity: 0.6;
  }

  /* A test is about a file, so it opens the file. */
  .test-open {
    display: flex;
    align-items: baseline;
    gap: var(--wb-spacing-10);
    width: 100%;
    padding: var(--wb-spacing-2) var(--wb-spacing-4) var(--wb-spacing-2) var(--wb-spacing-24);
    background: none;
    border: none;
    color: inherit;
    font-family: var(--vscode-editor-font-family, monospace);
    font-size: var(--wb-font-size-11);
    text-align: left;
    cursor: pointer;
  }

  .test-name {
    font-variant-numeric: tabular-nums;
    flex-shrink: 0;
  }

  .test-name.unnumbered {
    opacity: 0.75;
    font-style: italic;
  }

  .test-fn,
  .test-where {
    opacity: 0.6;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .test-where {
    margin-left: auto;
    flex-shrink: 1;
  }

  .more,
  .empty {
    margin: 0;
    padding: var(--wb-spacing-4) var(--wb-spacing-4) var(--wb-spacing-6) var(--wb-spacing-24);
    opacity: 0.65;
  }
</style>