<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>