@crvouga/atlas 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { IconAlertTriangle, IconArrowLeft, IconVideo } from '@tabler/icons-react';
2
- import { Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
2
+ import { Alert, Anchor, Badge, Box, Button, Code, Flex, Group, List, NativeSelect, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
3
3
  /**
4
4
  * @module Runs view
5
5
  *
@@ -16,16 +16,22 @@ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
16
16
  import type { FilterName } from '@crvouga/atlas/reports/artifact-query';
17
17
  import { formatMs, type RunResult, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
18
18
  import { Link } from '@tanstack/react-router';
19
- import { useMemo, useState } from 'react';
19
+ import { useEffect, useMemo, useState } from 'react';
20
+ import { behaviorDocs, behaviorId, describeHit, forRuns, resultDoc, runDoc } from '../find/behaviors';
21
+ import { useDocSearch } from '../find/doc-search';
22
+ import { hasFilters, passesFilters, type ParsedSearch, type SearchFilters } from '../find/query';
23
+ import type { FindDoc, Ranked } from '../find/rank';
20
24
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
21
25
  import { Meter, StatusMark } from '../evidence/marks';
22
- import { useRunDetail, useRunList } from './catalog';
26
+ import { useRunDetail, useRunList, useTestCatalog } from './catalog';
23
27
  import { RunArtifacts } from './RunArtifacts';
24
28
  import { FilterBar } from './filters';
29
+ import { SearchNotes, TypedFilters } from './search-notes';
25
30
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
26
31
  import { runName } from './parts';
27
32
  import { filtered, filterOf, NO_FILTERS } from './search';
28
33
  const RUN_ROWS = 50;
34
+ const NO_RESULTS: readonly RunResult[] = [];
29
35
  const RESULT_ROWS = 200;
30
36
  /** The Runs tab: the run list, or the run opened in it. */
31
37
  export function RunsView() {
@@ -151,6 +157,56 @@ function HasVideo({ count, pressed, noun, onToggle }: { count: number; pressed:
151
157
  </Button>
152
158
  );
153
159
  }
160
+ /** The search box of the Runs tab and of an opened run: one query in the address, so a link reproduces it. */
161
+ function SearchBox({ label, placeholder, parsed, onSearch }: { label: string; placeholder: string; parsed: ParsedSearch; onSearch: (value: string) => void }) {
162
+ const search = useExplorerSearch();
163
+ const [draft, setDraft] = useState(search.q ?? '');
164
+ // The address may change from elsewhere (a link, back, a chip): follow it.
165
+ useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
166
+ return (
167
+ <Flex gap="xs" align="center" wrap="wrap">
168
+ <TextInput
169
+ type="search"
170
+ aria-label={label}
171
+ placeholder={placeholder}
172
+ value={draft}
173
+ miw={{ base: '100%', sm: '20rem' }}
174
+ onChange={(event) => {
175
+ setDraft(event.target.value);
176
+ onSearch(event.target.value.trim());
177
+ }}
178
+ />
179
+ <TypedFilters
180
+ parsed={parsed}
181
+ query={draft}
182
+ onChange={(next) => {
183
+ setDraft(next);
184
+ onSearch(next);
185
+ }}
186
+ />
187
+ </Flex>
188
+ );
189
+ }
190
+ /** Why a result is in the list, beside it: exact, or the fields the words were found in. */
191
+ function WhyMatched({ hit }: { hit: Ranked | undefined }) {
192
+ const why = hit ? describeHit(hit) : '';
193
+ if (!hit || !why) return null;
194
+ return (
195
+ <Text size="xs" c="dimmed" data-why>
196
+ {hit.exact && (
197
+ <Badge component="span" color="blue" mr="xs">
198
+ Exact
199
+ </Badge>
200
+ )}
201
+ {why}
202
+ </Text>
203
+ );
204
+ }
205
+ const RUN_SORTS = [
206
+ { id: 'recent', label: 'Newest' },
207
+ { id: 'failures', label: 'Most failures' },
208
+ { id: 'relevance', label: 'Best match' }
209
+ ] as const;
154
210
  /** What a filter is called when a page says a server could not apply it. */
155
211
  const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec' };
156
212
  /** Says which filters the list is not narrowed by, because the server that answered ignored them. */
@@ -167,11 +223,32 @@ function RunTable() {
167
223
  const search = useExplorerSearch();
168
224
  const go = useExplorerNavigate();
169
225
  const video = search.video === true;
170
- const filter = useMemo(() => filterOf(search), [search]);
226
+ const searching = !!search.q;
227
+ // The structured filters of the address narrow the runs on the server; the words are matched here by the shared search,
228
+ // over every run, so the Runs tab judges "savde answer" as the finder and the test list do.
229
+ const filter = useMemo(() => {
230
+ const { q: _words, ...rest } = filterOf(search);
231
+ return rest;
232
+ }, [search]);
171
233
  const narrow = filtered(search);
172
- const runs = useRunList(true, filter);
234
+ const runs = useRunList(true, filter, searching);
173
235
  const now = useNow();
174
236
  const [shown, setShown] = useState(RUN_ROWS);
237
+ const docs = useMemo(() => (runs.data?.runs ?? []).map((run) => runDoc(run, runLabel(run))), [runs.data]);
238
+ const found = useDocSearch(docs, search.q);
239
+ const parsed = useMemo(() => forRuns(found.parsed), [found.parsed]);
240
+ const sortValue = search.sort === 'failures' ? 'failures' : (search.sort === 'relevance' || !search.sort) && found.hits ? 'relevance' : 'recent';
241
+ const visible = useMemo(() => {
242
+ const runsOf = runs.data?.runs ?? [];
243
+ const order = found.hits ? new Map([...found.hits.keys()].map((id, at) => [id, at] as const)) : null;
244
+ const kept = runsOf.filter((run, at) => {
245
+ const doc = docs[at]!;
246
+ return (!found.hits || found.hits.has(doc.id)) && passesFilters(doc.facets, parsed.filters);
247
+ });
248
+ if (sortValue === 'failures') return [...kept].sort((a, b) => b.counts.failed - a.counts.failed || b.endedAtMs - a.endedAtMs);
249
+ if (sortValue === 'relevance' && order) return [...kept].sort((a, b) => (order.get(`runs:${a.key}`) ?? 0) - (order.get(`runs:${b.key}`) ?? 0));
250
+ return kept;
251
+ }, [runs.data, docs, found.hits, parsed.filters, sortValue]);
175
252
  if (runs.isPending)
176
253
  return (
177
254
  <Text role="status" component="div" c="dimmed">
@@ -184,24 +261,60 @@ function RunTable() {
184
261
  The runs could not be read. {runs.error.message}
185
262
  </Alert>
186
263
  );
187
- const list = runs.data.runs;
264
+ const everything = runs.data.runs;
265
+ const list = visible;
188
266
  const withVideo = runs.data.withVideo;
189
267
  const noun = narrow ? 'matching' : '';
268
+ const hasVideoNow = video || parsed.filters.video;
190
269
  return (
191
270
  <Box aria-label="Runs" component="section" mb="lg">
192
- {(narrow || list.length > 0) && <FilterBar facets={runs.data.facets} search={search} tab="runs" extra={withVideo !== undefined || video ? <HasVideo count={withVideo ?? list.length} pressed={video} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} /> : undefined} />}
271
+ {(narrow || everything.length > 0) && (
272
+ <FilterBar
273
+ facets={runs.data.facets}
274
+ search={search}
275
+ tab="runs"
276
+ extra={
277
+ withVideo !== undefined || video ? (
278
+ <HasVideo count={withVideo ?? list.length} pressed={hasVideoNow} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} />
279
+ ) : undefined
280
+ }
281
+ />
282
+ )}
193
283
  <NotApplied names={runs.data.notApplied} noun="runs" />
194
- {!list.length && !narrow && (
284
+ {(parsed.tokens.length > 0 || parsed.issues.length > 0 || found.status !== 'idle') && (
285
+ <>
286
+ <Flex gap="xs" align="center" wrap="wrap">
287
+ <TypedFilters parsed={parsed} query={search.q ?? ''} onChange={(next) => go({ q: next || undefined }, { replace: true })} />
288
+ </Flex>
289
+ <SearchNotes issues={parsed.issues} semantic={found.status} />
290
+ </>
291
+ )}
292
+ {!everything.length && !narrow && (
195
293
  <Text component="div" c="dimmed">
196
294
  No runs recorded yet.
197
295
  </Text>
198
296
  )}
199
- {list.length > 0 && (
200
- <Text role="status" component="div">
201
- {runs.data.total > list.length
202
- ? `The newest ${list.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
203
- : `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
204
- </Text>
297
+ {everything.length > 0 && (
298
+ <Flex gap="xs" align="center" wrap="wrap" role="status">
299
+ <Text span inherit>
300
+ {runs.data.total > everything.length
301
+ ? `The newest ${everything.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
302
+ : searching || list.length !== everything.length
303
+ ? `${list.length.toLocaleString()} of ${plural(everything.length, 'run', 'runs')} ${noun}`.trim()
304
+ : `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
305
+ </Text>
306
+ <NativeSelect
307
+ aria-label="Sort runs"
308
+ value={sortValue}
309
+ onChange={(event) => go({ sort: event.target.value === (found.hits ? 'relevance' : 'recent') ? undefined : (event.target.value as 'recent' | 'failures' | 'relevance') })}
310
+ >
311
+ {RUN_SORTS.filter((option) => option.id !== 'relevance' || found.hits !== null).map((option) => (
312
+ <option key={option.id} value={option.id}>
313
+ {option.label}
314
+ </option>
315
+ ))}
316
+ </NativeSelect>
317
+ </Flex>
205
318
  )}
206
319
  {!list.length && narrow && (
207
320
  <Stack gap="xs" align="flex-start" role="status" data-no-match>
@@ -249,6 +362,7 @@ function RunTable() {
249
362
  {run.title}
250
363
  </Text>
251
364
  )}
365
+ <WhyMatched hit={found.hits?.get(`runs:${run.key}`)} />
252
366
  </Table.Td>
253
367
  <Table.Td title={run.branch ?? ''} c="dimmed">
254
368
  {run.branch ?? '—'}
@@ -322,20 +436,39 @@ function RunDetailView({ runKey }: { runKey: string }) {
322
436
  const detail = useRunDetail(runKey);
323
437
  const now = useNow();
324
438
  const [status, setStatus] = useState('');
325
- const [words, setWords] = useState('');
326
439
  const [shown, setShown] = useState(RESULT_ROWS);
327
- const results = detail.data?.results ?? [];
328
- const filtered = useMemo(() => {
329
- const terms = words.toLowerCase().split(/\s+/).filter(Boolean);
330
- return results.filter(
331
- (result) =>
332
- (!status || result.status === status) &&
333
- (!video || (videoCounts(result.videos)?.recordings ?? 0) > 0) &&
334
- terms.every((term) =>
335
- `${result.title}\n${result.specPath ?? ''}\n${result.environment ?? ''}\n${result.producer}`.toLowerCase().includes(term)
336
- )
337
- );
338
- }, [results, status, words, video]);
440
+ const catalog = useTestCatalog();
441
+ const model = catalog.model;
442
+ const results = detail.data?.results ?? NO_RESULTS;
443
+ // The words are judged against every behavior of the report, as in the test list, so the same words make the same
444
+ // behaviors eligible; the run's results are then the ones of those behaviors it recorded.
445
+ const docs = useMemo<readonly FindDoc[]>(() => {
446
+ if (!search.q) return [];
447
+ const base = model ? behaviorDocs(model.tests) : new Map<string, FindDoc>();
448
+ const extra = new Map<string, FindDoc>();
449
+ for (const result of results) if (!base.has(result.key) && !extra.has(result.key)) extra.set(result.key, resultDoc(result, undefined));
450
+ return [...base.values(), ...extra.values()];
451
+ }, [search.q, model, results]);
452
+ const found = useDocSearch(docs, search.q);
453
+ const resultDocs = useMemo(() => results.map((result) => resultDoc(result, model?.byKey.get(result.key))), [results, model]);
454
+ const narrowedResults = useMemo(
455
+ () =>
456
+ results.filter(
457
+ (result, at) =>
458
+ (!found.hits || found.hits.has(behaviorId(result.key))) &&
459
+ (!video || (videoCounts(result.videos)?.recordings ?? 0) > 0) &&
460
+ passesFilters(resultDocs[at]!.facets, { ...found.parsed.filters, status: [] })
461
+ ),
462
+ [results, resultDocs, found, video]
463
+ );
464
+ const statusFilters = found.parsed.filters.status;
465
+ const filtered = useMemo(
466
+ () =>
467
+ narrowedResults.filter(
468
+ (result) => (!status || result.status === status) && (!statusFilters.length || statusFilters.some((wanted) => (wanted === 'flaky' ? model?.byKey.get(result.key)?.stats.flaky === true : result.status === wanted)))
469
+ ),
470
+ [narrowedResults, status, statusFilters, model]
471
+ );
339
472
  const bySpec = useMemo(() => {
340
473
  const groups = new Map<string, RunResult[]>();
341
474
  for (const result of filtered.slice(0, shown)) {
@@ -458,14 +591,19 @@ function RunDetailView({ runKey }: { runKey: string }) {
458
591
  )}
459
592
  </Flex>
460
593
  <Flex gap="xs" align="center" wrap="wrap">
461
- <TextInput
462
- type="search"
463
- aria-label="Search this run"
464
- placeholder="Search this run…"
465
- value={words}
466
- onChange={(event) => setWords(event.target.value)}
594
+ <SearchBox
595
+ label="Search this run"
596
+ placeholder='Search this run… or filter: status:failed has:video "exact phrase"'
597
+ parsed={found.parsed}
598
+ onSearch={(value) => go({ q: value || undefined }, { replace: true })}
467
599
  />
468
600
  </Flex>
601
+ <SearchNotes issues={found.parsed.issues} semantic={found.status} />
602
+ {(search.q || found.parsed.tokens.length > 0) && (
603
+ <Text role="status" size="sm" c="dimmed">
604
+ {filtered.length.toLocaleString()} of {plural(results.length, 'result', 'results')} match
605
+ </Text>
606
+ )}
469
607
  <Flex role="group" aria-label="Result status" gap="xs" align="center" wrap="wrap">
470
608
  {RESULT_FILTERS.map((filter) => (
471
609
  <Button
@@ -478,7 +616,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
478
616
  >
479
617
  {filter.label}{' '}
480
618
  <Text span inherit>
481
- {filter.id ? results.filter((result) => result.status === filter.id).length : results.length}
619
+ {filter.id ? narrowedResults.filter((result) => result.status === filter.id).length : narrowedResults.length}
482
620
  </Text>
483
621
  </Button>
484
622
  ))}
@@ -487,7 +625,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
487
625
  <Flex gap="xs" align="center" wrap="wrap">
488
626
  <HasVideo
489
627
  count={results.filter((result) => (videoCounts(result.videos)?.recordings ?? 0) > 0).length}
490
- pressed={video}
628
+ pressed={video || found.parsed.filters.video}
491
629
  noun={['result', 'results']}
492
630
  onToggle={() => go({ video: video ? undefined : true })}
493
631
  />
@@ -52,6 +52,8 @@ import { useEvidence } from '../evidence/evidence-context';
52
52
 
53
53
  /** How many runs the Runs tab lists. */
54
54
  export const RUNS_SHOWN = 500;
55
+ /** How many runs are read to search them all (a search must see every run, not only the newest). */
56
+ export const RUNS_SEARCHED = 20_000;
55
57
  /** How many recorded results the Artifacts tab lists. */
56
58
  export const ARTIFACTS_SHOWN = 200;
57
59
  /** The server's catalog is read again at most this often while results keep arriving. */
@@ -284,22 +286,24 @@ export function runsAnswer(list: RunList, filter: ArtifactFilter): RunsAnswer {
284
286
 
285
287
  /**
286
288
  * CI runs, newest first, narrowed by a query (see `artifact-query.ts`). The server filters every run before it pages
287
- * them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}.
289
+ * them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}. With
290
+ * `all` every run is read (up to {@link RUNS_SEARCHED}), because a typed search must match the whole history rather than
291
+ * the newest page of it.
288
292
  */
289
- export function useRunList(enabled = true, filter: ArtifactFilter = {}) {
293
+ export function useRunList(enabled = true, filter: ArtifactFilter = {}, all = false) {
290
294
  const catalog = useTestCatalog();
291
295
  const query = forRuns(filter);
292
296
  const key = filterParams(query).toString();
293
297
  return useQuery({
294
- queryKey: ['test-runs', catalog.version, key],
298
+ queryKey: ['test-runs', catalog.version, key, all],
295
299
  queryFn: async ({ signal }): Promise<RunsAnswer> => {
296
300
  if (catalog.source === 'server') {
297
301
  const params = filterParams(query);
298
- params.set('limit', String(RUNS_SHOWN));
302
+ params.set('limit', String(all ? RUNS_SEARCHED : RUNS_SHOWN));
299
303
  return runsAnswer(await reportJson<RunList>(`/runs?${params}`, signal), query);
300
304
  }
301
305
  const history = catalog.model?.history;
302
- const list = history ? runList(history, catalog.version, RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
306
+ const list = history ? runList(history, catalog.version, all ? RUNS_SEARCHED : RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
303
307
  return runsAnswer(list, query);
304
308
  },
305
309
  enabled: enabled && catalog.model !== null,
@@ -0,0 +1,74 @@
1
+ import { Alert, Button, Stack, Text } from '@mantine/core';
2
+ import { IconX } from '@tabler/icons-react';
3
+ /**
4
+ * @module Search guidance
5
+ *
6
+ * What every search box in the explorer says beside its results, from one place: the filters typed in the box shown as
7
+ * removable chips, the parts of the query that were not understood (never silently dropped), and whether meaning-based
8
+ * matching is working, still refining, or off, so a search that is words only says so.
9
+ */
10
+ import { withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
11
+ import { semanticNote, type SemanticStatus } from '../find/semantic-search';
12
+
13
+ const FILTER_LABEL: Record<FilterKey, string> = {
14
+ status: 'Status',
15
+ env: 'Environment',
16
+ producer: 'Producer',
17
+ domain: 'Domain',
18
+ spec: 'Spec',
19
+ tag: 'Tag',
20
+ branch: 'Branch',
21
+ video: 'Has video'
22
+ };
23
+
24
+ /** The label of a typed filter chip: `Status: failed`, or `Has video`. */
25
+ export function filterLabel(key: FilterKey, value: string): string {
26
+ return key === 'video' ? FILTER_LABEL.video : `${FILTER_LABEL[key]}: ${value}`;
27
+ }
28
+
29
+ /** The filters typed in the box, each a button that removes its text from the query. */
30
+ export function TypedFilters({ parsed, query, onChange }: { parsed: ParsedSearch; query: string; onChange: (next: string) => void }) {
31
+ return (
32
+ <>
33
+ {parsed.tokens.map((token) => {
34
+ const label = filterLabel(token.key, token.value);
35
+ return (
36
+ <Button key={`typed:${token.text}:${token.value}`} type="button" onClick={() => onChange(withoutToken(query, token))} aria-label={`Remove ${label}`} variant="light">
37
+ {label}{' '}
38
+ <Text aria-hidden="true" span inherit>
39
+ <IconX size={16} aria-hidden="true" />
40
+ </Text>
41
+ </Button>
42
+ );
43
+ })}
44
+ </>
45
+ );
46
+ }
47
+
48
+ /** The parts of a query that could not be used, with what to type instead, and the meaning-based search's state. */
49
+ export function SearchNotes({ issues, semantic }: { issues: ParsedSearch['issues']; semantic: SemanticStatus }) {
50
+ const note = semanticNote(semantic);
51
+ return (
52
+ <>
53
+ {issues.length > 0 && (
54
+ <Alert role="status" color="yellow" my="sm" title="Part of this search was not understood">
55
+ <Stack gap="xs">
56
+ {issues.map((issue) => (
57
+ <Text key={issue.token + issue.message} size="sm">
58
+ <Text span inherit fw={600}>
59
+ {issue.token}
60
+ </Text>{' '}
61
+ {issue.message}
62
+ </Text>
63
+ ))}
64
+ </Stack>
65
+ </Alert>
66
+ )}
67
+ {note && (
68
+ <Text role="status" size="xs" c="dimmed">
69
+ {note}
70
+ </Text>
71
+ )}
72
+ </>
73
+ );
74
+ }
@@ -10,7 +10,7 @@
10
10
  * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
11
11
  */
12
12
  import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
13
- import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
13
+ import { TEST_SORTS, type TestQuery } from '@crvouga/atlas/reports/test-catalog';
14
14
  import { z } from 'zod/v4';
15
15
 
16
16
  /** How the explorer groups its tests. `spec` is the default. */
@@ -18,6 +18,11 @@ export const TEST_GROUPS = ['spec', 'domain', 'environment', 'status', 'producer
18
18
  /** One of {@link TEST_GROUPS}. */
19
19
  export type TestGroup = (typeof TEST_GROUPS)[number];
20
20
 
21
+ /** The orders the explorer offers: `relevance` (best match first, only with words to match) and those of the catalog. */
22
+ export const EXPLORER_SORTS = ['relevance', ...TEST_SORTS] as const;
23
+ /** One of {@link EXPLORER_SORTS}. */
24
+ export type ExplorerSort = (typeof EXPLORER_SORTS)[number];
25
+
21
26
  /** A search value as text: the router reads `123` as a number and `["a"]` as an array, both of which are meant as text here. */
22
27
  const text = z
23
28
  .preprocess((value) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value !== null && typeof value === 'object' ? JSON.stringify(value) : value), z.string().min(1))
@@ -56,7 +61,7 @@ export const ExplorerSearchFields = {
56
61
  /** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
57
62
  inRun: text,
58
63
  group: z.enum(TEST_GROUPS).optional().catch(undefined),
59
- sort: z.enum(TEST_SORTS).optional().catch(undefined),
64
+ sort: z.enum(EXPLORER_SORTS).optional().catch(undefined),
60
65
  tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
61
66
  /** The run opened in the Runs tab. */
62
67
  openRun: text,
@@ -89,7 +94,7 @@ export type ExplorerSearch = {
89
94
  readonly until?: string;
90
95
  readonly inRun?: string;
91
96
  readonly group?: TestGroup;
92
- readonly sort?: TestSort;
97
+ readonly sort?: ExplorerSort;
93
98
  readonly tab?: 'runs' | 'artifacts';
94
99
  readonly openRun?: string;
95
100
  readonly layer?: string;
@@ -113,7 +118,12 @@ export function toggled(value: string | undefined, item: string): string | undef
113
118
  return joined(list.includes(item) ? list.filter((entry) => entry !== item) : [...list, item]);
114
119
  }
115
120
 
116
- /** The catalog query an address asks for, without the run (it is matched separately) and without paging. */
121
+ /** The order a list is in: what the address says, else best match when words are searched, else by spec. */
122
+ export function sortOf(search: ExplorerSearch): ExplorerSort {
123
+ return search.sort ?? (search.q ? 'relevance' : 'spec');
124
+ }
125
+
126
+ /** The catalog query an address asks for, without the run (it is matched separately) and without paging. The words in `q` are matched by the shared search, not by the catalog. */
117
127
  export function queryOf(search: ExplorerSearch): TestQuery {
118
128
  return {
119
129
  ...(search.q ? { q: search.q } : {}),
@@ -126,7 +136,7 @@ export function queryOf(search: ExplorerSearch): TestQuery {
126
136
  ...(values(search.scope).length ? { scope: values(search.scope) } : {}),
127
137
  artifactTag: values(search.artifactTag),
128
138
  ...(search.inherited ? { inherited: true } : {}),
129
- sort: search.sort ?? 'spec'
139
+ sort: search.sort === 'relevance' || !search.sort ? 'spec' : search.sort
130
140
  };
131
141
  }
132
142
 
@@ -137,7 +147,7 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
137
147
 
138
148
  /** True when any filter or search narrows the list. */
139
149
  export function narrowed(search: ExplorerSearch): boolean {
140
- return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun);
150
+ return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
141
151
  }
142
152
 
143
153
  /** The filters of the Runs and Artifacts tabs, in the address. */