@crvouga/atlas 1.17.0 → 1.19.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.
- package/CHANGELOG.md +14 -1
- package/dist/reports/src/artifact-query.d.ts +14 -2
- package/dist/reports/src/artifact-query.js +13 -4
- package/dist/reports/src/evidence-detail.js +19 -6
- package/dist/reports/src/evidence-types.d.ts +8 -0
- package/dist/reports/src/recording-receipt.d.ts +23 -0
- package/dist/reports/src/recording-receipt.js +16 -0
- package/dist/reports/src/recording-verify.d.ts +74 -0
- package/dist/reports/src/recording-verify.js +142 -0
- package/dist/reports/src/report-cli.d.ts +2 -2
- package/dist/reports/src/report-cli.js +53 -3
- package/dist/reports/src/test-catalog.d.ts +7 -1
- package/dist/reports/src/test-catalog.js +10 -4
- package/dist/schema/src/api.d.ts +134 -0
- package/dist/schema/src/api.js +13 -2
- package/dist/schema/src/cli-results.d.ts +51 -1
- package/dist/schema/src/cli-results.js +25 -1
- package/dist/server/src/openapi.js +1 -0
- package/docs/agents.md +1 -0
- package/docs/reference/cli.md +11 -2
- package/docs/reference/http-api.md +2 -0
- package/docs/reference/reports.md +189 -9
- package/docs/reference/schema.md +19 -1
- package/docs/rich-evidence.md +33 -5
- package/docs/server.md +1 -1
- package/docs/ui.md +40 -1
- package/llms-full.txt +88 -9
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +26 -0
- package/packages/schema/schemas/effective-evidence.schema.json +26 -0
- package/packages/schema/schemas/evidence-page.schema.json +13 -0
- package/packages/schema/schemas/layer-rows.schema.json +13 -0
- package/packages/schema/schemas/report-run.schema.json +83 -0
- package/packages/schema/schemas/report-runs.schema.json +86 -0
- package/packages/schema/schemas/review-collection.schema.json +13 -0
- package/packages/schema/schemas/review-manifest.schema.json +13 -0
- package/packages/schema/schemas/test-catalog.schema.json +13 -0
- package/packages/schema/schemas/test-history.schema.json +26 -0
- package/packages/schema/schemas/verify-recordings-result.schema.json +97 -0
- package/packages/server/openapi.json +378 -0
- package/packages/visualizer/src/app/AppShell.tsx +2 -2
- package/packages/visualizer/src/app/router.tsx +25 -1
- package/packages/visualizer/src/components/Header.tsx +15 -4
- package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
- package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
- package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
- package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
- package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
- package/packages/visualizer/src/evidence/behavior.ts +32 -1
- package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
- package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
- package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
- package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
- package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +7 -6
- package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
- package/packages/visualizer/src/explorer/RunsView.tsx +32 -7
- package/packages/visualizer/src/explorer/collections.tsx +5 -5
- package/packages/visualizer/src/explorer/filters.tsx +11 -3
- package/packages/visualizer/src/explorer/navigate.ts +17 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
- package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
- package/packages/visualizer/src/explorer/playlist.tsx +43 -8
- package/packages/visualizer/src/explorer/search.ts +13 -3
- package/packages/visualizer/src/find/Finder.tsx +1 -62
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Only the clip on screen is read: the list is made of the metadata the catalog already sent, the current result's
|
|
9
9
|
* evidence is read when it is selected, and its video is loaded with `preload="metadata"`, so opening the playlist of a
|
|
10
|
-
* large library downloads no recordings in bulk.
|
|
10
|
+
* large library downloads no recordings in bulk. A row shows a poster (lazily loaded) and a length only when the catalog
|
|
11
|
+
* already carried them for its clip; none is read to find out. There is one `<video>`; selecting another clip replaces it and keeps
|
|
11
12
|
* the position of the one left, so returning resumes there. A clip that will not load (or whose autoplay the browser
|
|
12
13
|
* blocked) stays in the list and offers Retry, Play and Next. Recordings are always labelled illustrative: playback
|
|
13
14
|
* length and speed are those of the recording, never production latency.
|
|
@@ -16,7 +17,7 @@ import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvoug
|
|
|
16
17
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
17
18
|
import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
|
|
18
19
|
import type { Attachment, Step } from '@crvouga/atlas/schema';
|
|
19
|
-
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
|
|
20
|
+
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, Image, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
|
|
20
21
|
import { IconAlertTriangle, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward } from '@tabler/icons-react';
|
|
21
22
|
import { useQuery } from '@tanstack/react-query';
|
|
22
23
|
import { Link } from '@tanstack/react-router';
|
|
@@ -31,7 +32,7 @@ import { duration, exactTime, plural, statusWord } from '../evidence/format';
|
|
|
31
32
|
import { StatusMark } from '../evidence/marks';
|
|
32
33
|
import { patched } from './navigate';
|
|
33
34
|
import { clock, TEXT_KIND_WORD, type RecordingFocus } from './recording-text';
|
|
34
|
-
import { playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
35
|
+
import { clipHints, playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
35
36
|
|
|
36
37
|
/** Entries listed before "more"; the selected entry is always listed. */
|
|
37
38
|
const LISTED = 50;
|
|
@@ -70,6 +71,14 @@ export function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
|
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** The poster the catalog named for a clip, loaded only when its row is near the screen (`loading="lazy"`); a poster that will not load is left out. */
|
|
75
|
+
function Poster({ entry, bundleKey }: { entry: PlaylistEntry; bundleKey: string }) {
|
|
76
|
+
const [broken, setBroken] = useState(false);
|
|
77
|
+
const url = attachmentSource({ name: 'poster', bundleKey }, entry.layer).url;
|
|
78
|
+
if (!url || broken) return null;
|
|
79
|
+
return <Image src={url} alt="" loading="lazy" decoding="async" w={80} fit="contain" radius="sm" data-poster onError={() => setBroken(true)} />;
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
/** One step of the scenario: in words, with a control to show it in the recording and a mark while it is playing. */
|
|
74
83
|
function ScenarioStep({ step }: { step: Step }) {
|
|
75
84
|
const playing = useIsPlaying(step);
|
|
@@ -269,7 +278,7 @@ function Clip({
|
|
|
269
278
|
const developer = (
|
|
270
279
|
<Anchor
|
|
271
280
|
renderRoot={(props) => (
|
|
272
|
-
<Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
|
|
281
|
+
<Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, watch: undefined, watchClip: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
|
|
273
282
|
)}
|
|
274
283
|
>
|
|
275
284
|
Developer details
|
|
@@ -412,7 +421,7 @@ function Clip({
|
|
|
412
421
|
steps={steps}
|
|
413
422
|
attachments={placedIn}
|
|
414
423
|
layer={entry.layer}
|
|
415
|
-
linkSearch={{ test: entry.key, layer: entry.layer, tab: undefined, openRun: undefined, artifact: undefined }}
|
|
424
|
+
linkSearch={{ test: entry.key, layer: entry.layer, tab: undefined, openRun: undefined, artifact: undefined, watch: undefined, watchClip: undefined }}
|
|
416
425
|
>
|
|
417
426
|
<Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
|
|
418
427
|
{body}
|
|
@@ -432,9 +441,26 @@ function Clip({
|
|
|
432
441
|
* The watch session for `entries`: opens as a modal over the explorer. `total` is how many results matched when the list
|
|
433
442
|
* holds only the newest of them, so the session says it is not the whole set. `focus` opens it on one found moment: that
|
|
434
443
|
* result, that attempt's video and, when the time is usable, that time. `initial` is the entry to start on when there is no
|
|
435
|
-
* `focus`, and `title` the heading.
|
|
444
|
+
* `focus`, and `title` the heading. `onSelect` is told the id of the entry on screen when the session opens and each time it
|
|
445
|
+
* changes, so the address can name it. Closing it discards positions.
|
|
436
446
|
*/
|
|
437
|
-
export function WatchPlaylist({
|
|
447
|
+
export function WatchPlaylist({
|
|
448
|
+
entries,
|
|
449
|
+
total,
|
|
450
|
+
focus,
|
|
451
|
+
onClose,
|
|
452
|
+
onSelect,
|
|
453
|
+
initial,
|
|
454
|
+
title = 'Watch recordings'
|
|
455
|
+
}: {
|
|
456
|
+
entries: readonly PlaylistEntry[];
|
|
457
|
+
total?: number;
|
|
458
|
+
focus?: RecordingFocus;
|
|
459
|
+
onClose: () => void;
|
|
460
|
+
onSelect?: (id: string) => void;
|
|
461
|
+
initial?: string;
|
|
462
|
+
title?: string;
|
|
463
|
+
}) {
|
|
438
464
|
const first = entries.find((entry) => entry.id === initial) ?? entries.find((entry) => recordingState(entry) === 'playable') ?? entries[0];
|
|
439
465
|
const [chosen, setChosen] = useState<string | null>(focus && entries.some((entry) => entry.id === focus.entryId) ? focus.entryId : (first?.id ?? null));
|
|
440
466
|
// Selecting anything else leaves the found moment behind: it describes one video and one time.
|
|
@@ -449,6 +475,12 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
449
475
|
const active = entries.find((entry) => entry.id === chosen) ?? first ?? null;
|
|
450
476
|
const counts = useMemo(() => playlistCounts(entries), [entries]);
|
|
451
477
|
const activeAt = active ? entries.indexOf(active) : -1;
|
|
478
|
+
const activeId = active?.id;
|
|
479
|
+
const selected = useRef(onSelect);
|
|
480
|
+
selected.current = onSelect;
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
if (activeId !== undefined && !focus) selected.current?.(activeId);
|
|
483
|
+
}, [activeId, focus]);
|
|
452
484
|
const next = stepFrom(entries, active?.id ?? null, 1, repeat);
|
|
453
485
|
const previous = stepFrom(entries, active?.id ?? null, -1, repeat);
|
|
454
486
|
const select = (entry: PlaylistEntry, play: boolean) => {
|
|
@@ -524,6 +556,7 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
524
556
|
{listed.map((entry, index) => {
|
|
525
557
|
const state = recordingState(entry);
|
|
526
558
|
const here = entry.id === active?.id;
|
|
559
|
+
const hints = clipHints(entry);
|
|
527
560
|
return (
|
|
528
561
|
<List.Item key={entry.id} data-entry={entry.id} data-status={entry.status} data-recording-state={state}>
|
|
529
562
|
<Button
|
|
@@ -534,11 +567,12 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
534
567
|
py="xs"
|
|
535
568
|
justify="flex-start"
|
|
536
569
|
aria-current={here ? 'true' : undefined}
|
|
537
|
-
aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}`}
|
|
570
|
+
aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}${hints.durationMs ? `, ${duration(hints.durationMs)} long` : ''}`}
|
|
538
571
|
onClick={() => select(entry, state === 'playable')}
|
|
539
572
|
>
|
|
540
573
|
<Flex component="span" gap="xs" wrap="nowrap" align="flex-start">
|
|
541
574
|
<StatusMark status={entry.status} size={14} />
|
|
575
|
+
{hints.poster && <Poster entry={entry} bundleKey={hints.poster} />}
|
|
542
576
|
<Flex component="span" direction="column" align="flex-start">
|
|
543
577
|
<Text span inherit ta="left">
|
|
544
578
|
{entry.title}
|
|
@@ -546,6 +580,7 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
546
580
|
<Text span inherit c="dimmed" size="xs" ta="left">
|
|
547
581
|
{statusWord(entry.status)} · {environmentLabel(entry.environment)} ·{' '}
|
|
548
582
|
{failedIds.has(entry.id) ? 'Could not be loaded' : RECORDING_STATE_TEXT[state].label}
|
|
583
|
+
{hints.durationMs ? ` · ${duration(hints.durationMs)} recording` : ''}
|
|
549
584
|
</Text>
|
|
550
585
|
</Flex>
|
|
551
586
|
</Flex>
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* @module Explorer address
|
|
3
3
|
*
|
|
4
4
|
* Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
|
|
5
|
-
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the Catalog health tab with the root mappings it tries (`root`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
|
|
5
|
+
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the Catalog health tab with the root mappings it tries (`root`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), the watch playlist that is open over the list or run and the clip it is on (`watch`, `watchClip`), and the one file of a run's layer its Artifacts select. Filters that
|
|
6
6
|
* take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
|
|
7
7
|
* or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
|
|
8
8
|
*
|
|
9
9
|
* The Runs and Artifacts tabs share the tests' `q`, `env`, `producer`, `domain` and `spec` and add `artifactTag`,
|
|
10
|
-
* `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
|
|
10
|
+
* `type`, `label`, `branch`, `outcome`, `since` and `until` (and the Runs tab alone `recordings`, the completeness of its recordings), 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
13
|
import type { ReliabilityScope } from '@crvouga/atlas/reports/reliability';
|
|
@@ -55,6 +55,8 @@ export const ExplorerSearchFields = {
|
|
|
55
55
|
branch: text,
|
|
56
56
|
/** Outcomes of a run or recorded result (`passed`, `failed`…). */
|
|
57
57
|
outcome: text,
|
|
58
|
+
/** Recording completeness of a run (`complete`, `incomplete`, `none-expected`, `unknown`): runs only, apart from the outcome. */
|
|
59
|
+
recordings: text,
|
|
58
60
|
/** Only records from this day (`YYYY-MM-DD`, the viewer's local time). */
|
|
59
61
|
since: text,
|
|
60
62
|
/** Only records until the end of this day (`YYYY-MM-DD`, local time). */
|
|
@@ -107,6 +109,10 @@ export const ExplorerSearchFields = {
|
|
|
107
109
|
relRevision: z.enum(['any']).optional().catch(undefined),
|
|
108
110
|
/** The reliability's independent executions (its denominator) are open. */
|
|
109
111
|
relOpen: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
|
|
112
|
+
/** The watch playlist is open over the list, opened run or collection the rest of the address names. A link with it restores the playlist; without it the page is as it was before. */
|
|
113
|
+
watch: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
|
|
114
|
+
/** With `watch`: the entry of the playlist that is playing (its id, which names the result and the layer that recorded it). Unknown or absent: the first one that can be watched. */
|
|
115
|
+
watchClip: text,
|
|
110
116
|
/** Words to find inside the recordings of the Artifacts tab (what was said or shown, from the text producers supplied). Not a filter of the list. */
|
|
111
117
|
inside: text,
|
|
112
118
|
/** Only the runs, and the results of an opened run, that have a playable video recording. */
|
|
@@ -130,6 +136,7 @@ export type ExplorerSearch = {
|
|
|
130
136
|
readonly label?: string;
|
|
131
137
|
readonly branch?: string;
|
|
132
138
|
readonly outcome?: string;
|
|
139
|
+
readonly recordings?: string;
|
|
133
140
|
readonly since?: string;
|
|
134
141
|
readonly until?: string;
|
|
135
142
|
readonly inRun?: string;
|
|
@@ -163,6 +170,8 @@ export type ExplorerSearch = {
|
|
|
163
170
|
readonly relOpen?: true;
|
|
164
171
|
readonly inside?: string;
|
|
165
172
|
readonly video?: true;
|
|
173
|
+
readonly watch?: true;
|
|
174
|
+
readonly watchClip?: string;
|
|
166
175
|
};
|
|
167
176
|
|
|
168
177
|
/** The values of a comma-separated filter, empty when it is not set. */
|
|
@@ -229,7 +238,7 @@ export function reliabilityScopeOf(search: ExplorerSearch): ReliabilityScope {
|
|
|
229
238
|
}
|
|
230
239
|
|
|
231
240
|
/** The filters of the Runs and Artifacts tabs, in the address. */
|
|
232
|
-
export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
|
|
241
|
+
export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'recordings', 'domain', 'spec', 'since', 'until', 'video'] as const;
|
|
233
242
|
|
|
234
243
|
/** A patch that removes every filter of the Runs and Artifacts tabs. */
|
|
235
244
|
export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
|
|
@@ -263,6 +272,7 @@ export function filterOf(search: ExplorerSearch): ArtifactFilter {
|
|
|
263
272
|
producer: list(search.producer),
|
|
264
273
|
branch: list(search.branch),
|
|
265
274
|
outcome: list(search.outcome),
|
|
275
|
+
recordings: list(search.recordings),
|
|
266
276
|
domain: list(search.domain),
|
|
267
277
|
spec: list(search.spec),
|
|
268
278
|
since,
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { Badge, Button, Group, Kbd, List, Modal, Stack, Text } from '@mantine/core';
|
|
2
2
|
import { Spotlight, useSpotlight } from '@mantine/spotlight';
|
|
3
|
-
import { useQueryClient } from '@tanstack/react-query';
|
|
4
3
|
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
5
4
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
6
5
|
import { useAtlas } from '../app/atlas-context';
|
|
7
6
|
import { useTestCatalog } from '../explorer/catalog';
|
|
8
7
|
import { SearchNotes } from '../explorer/search-notes';
|
|
9
|
-
import { keys, specKey, useAdapter } from '../data/queries';
|
|
10
8
|
import { useEvidence } from '../evidence/evidence-context';
|
|
11
9
|
import { plural } from '../evidence/format';
|
|
10
|
+
import { useSpecTexts } from '../evidence/spec-texts';
|
|
12
11
|
import { StatusMark } from '../evidence/marks';
|
|
13
12
|
import { kindOfFile } from '../spec-kind';
|
|
14
13
|
import { describeHit } from './behaviors';
|
|
@@ -51,66 +50,6 @@ function isTypingTarget(target: EventTarget | null) {
|
|
|
51
50
|
const tag = target.tagName;
|
|
52
51
|
return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'A';
|
|
53
52
|
}
|
|
54
|
-
/**
|
|
55
|
-
* Reads the text of every spec for search, a few at a time, once `enabled`. Each file lands as it arrives, so a search
|
|
56
|
-
* can use what is read so far; `read` of `total` say how far it is, and `failed` how many could not be read at all.
|
|
57
|
-
*/
|
|
58
|
-
function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean) {
|
|
59
|
-
const adapter = useAdapter();
|
|
60
|
-
const client = useQueryClient();
|
|
61
|
-
const source = specKey(adapter);
|
|
62
|
-
const cache = useRef(new Map<string, string>());
|
|
63
|
-
const unreadable = useRef(new Set<string>());
|
|
64
|
-
const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
65
|
-
const [pending, setPending] = useState(false);
|
|
66
|
-
const [failedFiles, setFailedFiles] = useState<ReadonlySet<string>>(() => new Set());
|
|
67
|
-
const key = files.join('\n');
|
|
68
|
-
// Opening the finder is another chance for the files that could not be read.
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
if (!open || unreadable.current.size === 0) return;
|
|
71
|
-
for (const file of unreadable.current) cache.current.delete(file);
|
|
72
|
-
unreadable.current.clear();
|
|
73
|
-
setFailedFiles(new Set());
|
|
74
|
-
}, [open]);
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
if (!enabled) return;
|
|
77
|
-
const wanted = key.length === 0 ? [] : key.split('\n');
|
|
78
|
-
let cancelled = false;
|
|
79
|
-
const missing = wanted.filter((file) => !cache.current.has(file));
|
|
80
|
-
if (missing.length === 0) {
|
|
81
|
-
setPending(false);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
setPending(true);
|
|
85
|
-
let cursor = 0;
|
|
86
|
-
const pull = async () => {
|
|
87
|
-
for (;;) {
|
|
88
|
-
const file = missing[cursor++];
|
|
89
|
-
if (file === undefined || cancelled) return;
|
|
90
|
-
let text = '';
|
|
91
|
-
try {
|
|
92
|
-
text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file), retry: false });
|
|
93
|
-
} catch {
|
|
94
|
-
text = '';
|
|
95
|
-
unreadable.current.add(file);
|
|
96
|
-
}
|
|
97
|
-
if (cancelled) return;
|
|
98
|
-
cache.current.set(file, text);
|
|
99
|
-
setTexts(new Map(cache.current));
|
|
100
|
-
if (unreadable.current.has(file)) setFailedFiles(new Set(unreadable.current));
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
void Promise.all([pull(), pull(), pull(), pull()]).then(() => {
|
|
104
|
-
if (!cancelled) setPending(false);
|
|
105
|
-
});
|
|
106
|
-
return () => {
|
|
107
|
-
cancelled = true;
|
|
108
|
-
};
|
|
109
|
-
}, [adapter, client, enabled, key, source]);
|
|
110
|
-
const read = useMemo(() => files.filter((file) => texts.has(file)).length, [files, texts]);
|
|
111
|
-
const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
|
|
112
|
-
return { texts, pending, total: files.length, read, failed };
|
|
113
|
-
}
|
|
114
53
|
export function Finder() {
|
|
115
54
|
const open = useFinderOpen();
|
|
116
55
|
const help = useHelpOpen();
|