@deepwatch/dsh-library 0.1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/lib/client/components.d.ts +75 -0
- package/lib/client/components.js +60 -0
- package/lib/client/index.d.ts +46 -0
- package/lib/client/index.js +52 -0
- package/lib/client/library-mode.d.ts +37 -0
- package/lib/client/library-mode.js +21 -0
- package/lib/client/read-plane.d.ts +134 -0
- package/lib/client/read-plane.js +193 -0
- package/lib/client/search-view.d.ts +44 -0
- package/lib/client/search-view.js +233 -0
- package/lib/client.js +1882 -0
- package/lib/client.js.map +1 -0
- package/lib/index-store.d.ts +221 -0
- package/lib/index-store.js +570 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +20 -0
- package/lib/search.d.ts +129 -0
- package/lib/search.js +180 -0
- package/lib/sources.d.ts +146 -0
- package/lib/sources.js +135 -0
- package/package.json +101 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library's end of the read plane: what the surface asks, and what it does
|
|
3
|
+
* with the answer.
|
|
4
|
+
*
|
|
5
|
+
* The Host end is `@deepwatch/dsh-tools`, which registers `WatchQueryService`
|
|
6
|
+
* and lets Typert generate a strict Remote from it. The Library does not import
|
|
7
|
+
* that generated artifact and does not mount it. Doing either would make the
|
|
8
|
+
* package that owns the Library capability depend on the package that reads it,
|
|
9
|
+
* which is the cycle `@deepwatch/dsh-client-remotes` exists to remove.
|
|
10
|
+
*
|
|
11
|
+
* So the namespace is described here from the contracts both ends already
|
|
12
|
+
* share. `@deepwatch/dsh-contracts/query/wire` is the single definition of
|
|
13
|
+
* every request and response on this wire — the generated declaration imports
|
|
14
|
+
* its types from exactly that module — and `RemoteResult` is upstream's own
|
|
15
|
+
* envelope. Nothing below restates a shape either side owns.
|
|
16
|
+
*
|
|
17
|
+
* That leaves one thing a shared contract cannot prove: that the namespace
|
|
18
|
+
* really is called `watchQuery` and really carries these two methods. Two
|
|
19
|
+
* things hold it. `@deepwatch/dsh-client-remotes` compares this interface
|
|
20
|
+
* against the generated one at compile time, so a changed signature stops the
|
|
21
|
+
* build; and `tests/remote-client-mount.test.mjs` mounts the real contribution
|
|
22
|
+
* through the real Gateway and calls it, so a changed *name* fails a test
|
|
23
|
+
* rather than a page.
|
|
24
|
+
*
|
|
25
|
+
* @module @deepwatch/dsh-library/client/read-plane
|
|
26
|
+
*/
|
|
27
|
+
import { WATCH_QUERY_WIRE_VERSION } from '@deepwatch/dsh-contracts/query/wire';
|
|
28
|
+
/**
|
|
29
|
+
* Correlation ids, from a counter rather than from randomness.
|
|
30
|
+
*
|
|
31
|
+
* The host refuses a `requestId` that is not an identifier, and a counter
|
|
32
|
+
* produces one by construction. It is also what makes a failing request
|
|
33
|
+
* quotable: `library-7` names a call somebody can find twice.
|
|
34
|
+
*/
|
|
35
|
+
let sequence = 0;
|
|
36
|
+
/** The next correlation id for a Library read. */
|
|
37
|
+
export function nextRequestId() {
|
|
38
|
+
sequence += 1;
|
|
39
|
+
return `library-${String(sequence)}`;
|
|
40
|
+
}
|
|
41
|
+
/** An answer that produced no rows, and says why. */
|
|
42
|
+
function nothing(note, health = 'stale') {
|
|
43
|
+
return { rows: [], total: 0, health, generation: 0, notes: [note], pageable: false };
|
|
44
|
+
}
|
|
45
|
+
/** The host's own vocabulary for index condition, in the surface's terms. */
|
|
46
|
+
function healthOf(state) {
|
|
47
|
+
return state === 'rebuilding' ? 'indexing' : state;
|
|
48
|
+
}
|
|
49
|
+
/** One wire record as a row. */
|
|
50
|
+
function rowOf(record) {
|
|
51
|
+
return {
|
|
52
|
+
key: `${record.recordId}@${record.revisionId}`,
|
|
53
|
+
recordId: record.recordId,
|
|
54
|
+
title: record.title,
|
|
55
|
+
kind: record.modality,
|
|
56
|
+
// The wire record carries provenance, not excerpts: the Host answers with
|
|
57
|
+
// what it persisted, and inventing a snippet from a title would put text on
|
|
58
|
+
// screen that no record contains.
|
|
59
|
+
snippets: [],
|
|
60
|
+
evidenceCount: record.evidenceIds.length,
|
|
61
|
+
current: record.current,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Ask the host, and turn whatever comes back into something renderable.
|
|
66
|
+
*
|
|
67
|
+
* Every outcome is an answer the surface shows rather than an exception it
|
|
68
|
+
* swallows. A refusal, an elapsed deadline and an expired cursor are different
|
|
69
|
+
* facts, and a person acts differently on each, so each keeps its own sentence.
|
|
70
|
+
*/
|
|
71
|
+
export async function readLibraryPage(reads, query, signal) {
|
|
72
|
+
const answer = await reads.librarySearch({
|
|
73
|
+
protocol: WATCH_QUERY_WIRE_VERSION,
|
|
74
|
+
requestId: nextRequestId(),
|
|
75
|
+
query: query.text,
|
|
76
|
+
modalities: query.modality === '' ? [] : [query.modality],
|
|
77
|
+
limit: query.limit,
|
|
78
|
+
cursor: null,
|
|
79
|
+
deadlineMs: query.deadlineMs,
|
|
80
|
+
}, signal);
|
|
81
|
+
// The transport envelope first. `ok: false` means the call never produced a
|
|
82
|
+
// domain answer at all — no Connection, a Gateway refusal, a codec that
|
|
83
|
+
// rejected the response — and reporting that as an empty library would be a
|
|
84
|
+
// lie about what the workspace contains.
|
|
85
|
+
if (!answer.ok) {
|
|
86
|
+
return nothing(`The Library host did not answer: ${answer.error.message}`, 'corrupt');
|
|
87
|
+
}
|
|
88
|
+
const value = answer.value;
|
|
89
|
+
switch (value.outcome) {
|
|
90
|
+
case 'page': {
|
|
91
|
+
const notes = value.records.length < value.total
|
|
92
|
+
? [`Showing ${String(value.records.length)} of ${String(value.total)} matches; `
|
|
93
|
+
+ 'the host answered with one page and offered no cursor.']
|
|
94
|
+
: [];
|
|
95
|
+
return {
|
|
96
|
+
rows: value.records.map(rowOf),
|
|
97
|
+
total: value.total,
|
|
98
|
+
health: healthOf(value.indexState),
|
|
99
|
+
generation: value.generation,
|
|
100
|
+
notes,
|
|
101
|
+
// `nextCursor` is the host's own statement about whether more remains.
|
|
102
|
+
// Deriving it from `total` instead would offer a Next control the host
|
|
103
|
+
// has no way to answer.
|
|
104
|
+
pageable: value.nextCursor !== null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case 'rejected':
|
|
108
|
+
return nothing(`The host refused the request (${value.reason}`
|
|
109
|
+
+ `${value.field === null ? '' : ` at ${value.field}`}).`);
|
|
110
|
+
case 'deadline_exceeded':
|
|
111
|
+
return nothing(`The host did not answer within ${String(value.deadlineMs)}ms. Try a narrower query.`);
|
|
112
|
+
case 'cursor_expired':
|
|
113
|
+
return nothing('That page is no longer held by the host. Search again.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** A local index answer as the same view model, so the surface renders one shape. */
|
|
117
|
+
export function fromIndex(result) {
|
|
118
|
+
return {
|
|
119
|
+
rows: result.results.map(entry => ({
|
|
120
|
+
key: entry.sourceId,
|
|
121
|
+
recordId: entry.sourceId,
|
|
122
|
+
title: entry.title,
|
|
123
|
+
kind: entry.kind,
|
|
124
|
+
snippets: entry.hits.map(hit => hit.text),
|
|
125
|
+
evidenceCount: entry.hits[0]?.evidenceIds.length ?? 0,
|
|
126
|
+
current: entry.current,
|
|
127
|
+
})),
|
|
128
|
+
total: result.total,
|
|
129
|
+
health: result.health,
|
|
130
|
+
// The local index is not a host generation and does not pretend to be one.
|
|
131
|
+
generation: 0,
|
|
132
|
+
notes: result.notes,
|
|
133
|
+
pageable: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Ask the host to read its roots again.
|
|
138
|
+
*
|
|
139
|
+
* Every outcome is rendered, and none of them is an exception. A refusal, an
|
|
140
|
+
* elapsed deadline, an abandoned rebuild and a failed one are four different
|
|
141
|
+
* facts; so is a rebuild that succeeded and found nothing new. Reporting any
|
|
142
|
+
* of them as "refreshed" would be a control that lies about what it did.
|
|
143
|
+
*/
|
|
144
|
+
export async function refreshLibrary(reads, deadlineMs, signal) {
|
|
145
|
+
const answer = await reads.libraryRefresh({
|
|
146
|
+
protocol: WATCH_QUERY_WIRE_VERSION,
|
|
147
|
+
requestId: nextRequestId(),
|
|
148
|
+
deadlineMs,
|
|
149
|
+
}, signal);
|
|
150
|
+
if (!answer.ok) {
|
|
151
|
+
return failedRefresh(`The Library host did not answer: ${answer.error.message}`);
|
|
152
|
+
}
|
|
153
|
+
const value = answer.value;
|
|
154
|
+
switch (value.outcome) {
|
|
155
|
+
case 'refreshed':
|
|
156
|
+
return {
|
|
157
|
+
refreshed: true,
|
|
158
|
+
generation: value.index.generation,
|
|
159
|
+
recordCount: value.index.recordCount,
|
|
160
|
+
note: value.skipped.length === 0
|
|
161
|
+
? ''
|
|
162
|
+
: `${String(value.skipped.length)} file(s) were not readable: `
|
|
163
|
+
+ value.skipped.slice(0, 3).join('; '),
|
|
164
|
+
failed: false,
|
|
165
|
+
};
|
|
166
|
+
case 'refresh_cancelled':
|
|
167
|
+
return {
|
|
168
|
+
refreshed: false,
|
|
169
|
+
generation: value.index.generation,
|
|
170
|
+
recordCount: value.index.recordCount,
|
|
171
|
+
note: 'The refresh was abandoned. The Library is unchanged.',
|
|
172
|
+
failed: false,
|
|
173
|
+
};
|
|
174
|
+
case 'refresh_failed':
|
|
175
|
+
return {
|
|
176
|
+
refreshed: false,
|
|
177
|
+
generation: value.index.generation,
|
|
178
|
+
recordCount: value.index.recordCount,
|
|
179
|
+
note: `The refresh failed: ${value.reason}. The previous index is still searchable.`,
|
|
180
|
+
failed: true,
|
|
181
|
+
};
|
|
182
|
+
case 'rejected':
|
|
183
|
+
return failedRefresh(`The host refused the refresh (${value.reason}).`);
|
|
184
|
+
case 'deadline_exceeded':
|
|
185
|
+
return failedRefresh(`The refresh did not finish within ${String(value.deadlineMs)}ms. `
|
|
186
|
+
+ 'It may still be running on the host.');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** A refresh that produced no generation, and why. */
|
|
190
|
+
function failedRefresh(note) {
|
|
191
|
+
return { refreshed: false, generation: 0, recordCount: 0, note, failed: true };
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=read-plane.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library, as a working search surface.
|
|
3
|
+
*
|
|
4
|
+
* It is backed by `LibraryIndex` — a local, derived, rebuildable inverted index
|
|
5
|
+
* — so search works offline, needs no service and needs no embedding model.
|
|
6
|
+
* Semantic retrieval stays a future optional plugin; this is what runs on any
|
|
7
|
+
* machine today.
|
|
8
|
+
*
|
|
9
|
+
* The records come from the evidence the workspace has actually seen. Where
|
|
10
|
+
* there are none the surface says so and offers a rebuild, rather than
|
|
11
|
+
* presenting an empty result set as though a search had run and found nothing —
|
|
12
|
+
* those are different facts and a person acts differently on each.
|
|
13
|
+
*
|
|
14
|
+
* Accessibility is not an afterthought here because a search box is where
|
|
15
|
+
* keyboard and screen-reader behaviour is most obviously felt: the field is
|
|
16
|
+
* labelled, results are a live region announcing their own count, every filter
|
|
17
|
+
* is a real control, and the index's condition is announced rather than only
|
|
18
|
+
* coloured.
|
|
19
|
+
*
|
|
20
|
+
* @module @deepwatch/dsh-library/client/search-view
|
|
21
|
+
*/
|
|
22
|
+
import type { ReactNode } from 'react';
|
|
23
|
+
import { LibraryIndex, MAX_LIMIT } from '../index-store.js';
|
|
24
|
+
import type { IndexableRecord } from '../index-store.js';
|
|
25
|
+
import type { WatchQueryRemote } from './read-plane.js';
|
|
26
|
+
export interface LibrarySearchProps {
|
|
27
|
+
/** The records to index. The store remains the source of truth. */
|
|
28
|
+
readonly records?: readonly IndexableRecord[];
|
|
29
|
+
/** Injected for tests; production builds its own. */
|
|
30
|
+
readonly index?: LibraryIndex;
|
|
31
|
+
/**
|
|
32
|
+
* The mounted `ctx.remote.watchQuery` namespace, when there is one.
|
|
33
|
+
*
|
|
34
|
+
* Present in a profile: the workspace's own host holds the library, and
|
|
35
|
+
* answering from a browser-side copy of it would be a second index with its
|
|
36
|
+
* own drift. Absent everywhere else — a test, a story, a build without the
|
|
37
|
+
* Host row — and the local index answers instead.
|
|
38
|
+
*/
|
|
39
|
+
readonly reads?: WatchQueryRemote | undefined;
|
|
40
|
+
}
|
|
41
|
+
/** The Library search workflow: query, filter, sort, page, rebuild. */
|
|
42
|
+
export declare function LibrarySearch({ records, index: injected, reads }: LibrarySearchProps): ReactNode;
|
|
43
|
+
export { MAX_LIMIT };
|
|
44
|
+
//# sourceMappingURL=search-view.d.ts.map
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { LibraryIndex, MAX_LIMIT, tokenize } from '../index-store.js';
|
|
4
|
+
import { fromIndex, readLibraryPage, refreshLibrary } from './read-plane.js';
|
|
5
|
+
const PAGE = 10;
|
|
6
|
+
/**
|
|
7
|
+
* How long the surface will wait for the host before it must show something.
|
|
8
|
+
*
|
|
9
|
+
* Shorter than the host's own ceiling on purpose: the host clamps to 30s, and a
|
|
10
|
+
* search box that can appear frozen for half a minute is a search box people
|
|
11
|
+
* stop trusting. The host is told this number, so an answer it cannot produce
|
|
12
|
+
* in time comes back as `deadline_exceeded` — a sentence on the screen — rather
|
|
13
|
+
* than as a request nobody ever cancelled.
|
|
14
|
+
*/
|
|
15
|
+
const DEADLINE_MS = 10_000;
|
|
16
|
+
/**
|
|
17
|
+
* How long the surface will wait for a rebuild.
|
|
18
|
+
*
|
|
19
|
+
* Longer than a search, because it is one: reading a corpus is work a person
|
|
20
|
+
* has asked for and expects to take a moment. Still bounded, and still the
|
|
21
|
+
* number the host is told, so an overrun comes back as an answer rather than
|
|
22
|
+
* as a control that never settles.
|
|
23
|
+
*/
|
|
24
|
+
const REFRESH_DEADLINE_MS = 60_000;
|
|
25
|
+
const S = {
|
|
26
|
+
root: {
|
|
27
|
+
display: 'flex', flexDirection: 'column', gap: '14px',
|
|
28
|
+
height: '100%', minHeight: 0,
|
|
29
|
+
},
|
|
30
|
+
bar: { display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'flex-end' },
|
|
31
|
+
field: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1 1 260px', minWidth: 0 },
|
|
32
|
+
label: {
|
|
33
|
+
fontSize: '11px', fontWeight: 600, letterSpacing: '.05em',
|
|
34
|
+
textTransform: 'uppercase', color: 'var(--dsw-alias-label-tertiary)',
|
|
35
|
+
},
|
|
36
|
+
input: {
|
|
37
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
38
|
+
border: '1px solid color-mix(in srgb, var(--watch-accent) 12%, var(--dsw-alias-border-l2))',
|
|
39
|
+
borderRadius: '10px', padding: '9px 11px', fontSize: '13px',
|
|
40
|
+
color: 'inherit', font: 'inherit', minWidth: 0, width: '100%',
|
|
41
|
+
},
|
|
42
|
+
select: {
|
|
43
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
44
|
+
border: '1px solid color-mix(in srgb, var(--watch-accent) 12%, var(--dsw-alias-border-l2))',
|
|
45
|
+
borderRadius: '10px', padding: '9px 11px', fontSize: '13px', color: 'inherit',
|
|
46
|
+
},
|
|
47
|
+
button: {
|
|
48
|
+
background: 'transparent', border: '1px solid var(--dsw-alias-border-l2)',
|
|
49
|
+
borderRadius: '10px', padding: '9px 13px', fontSize: '13px',
|
|
50
|
+
color: 'inherit', cursor: 'pointer',
|
|
51
|
+
},
|
|
52
|
+
status: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', margin: 0 },
|
|
53
|
+
list: { display: 'flex', flexDirection: 'column', gap: '8px', margin: 0, padding: 0, listStyle: 'none' },
|
|
54
|
+
hit: {
|
|
55
|
+
border: '1px solid color-mix(in srgb, var(--watch-accent) 9%, var(--dsw-alias-border-l2))', borderRadius: '14px',
|
|
56
|
+
padding: '14px 16px', background: 'linear-gradient(145deg, color-mix(in srgb, var(--watch-accent) 3%, var(--dsw-alias-bg-layer-2)), var(--dsw-alias-bg-base))',
|
|
57
|
+
boxShadow: '0 8px 24px color-mix(in srgb, black 7%, transparent)',
|
|
58
|
+
},
|
|
59
|
+
title: { fontSize: '13.5px', fontWeight: 600, margin: 0 },
|
|
60
|
+
snippet: {
|
|
61
|
+
fontSize: '12.5px', lineHeight: 1.6, margin: '6px 0 0',
|
|
62
|
+
color: 'var(--dsw-alias-label-secondary)', wordBreak: 'break-word',
|
|
63
|
+
},
|
|
64
|
+
meta: { fontSize: '11.5px', color: 'var(--dsw-alias-label-tertiary)', marginTop: '6px', display: 'flex', gap: '10px', flexWrap: 'wrap' },
|
|
65
|
+
};
|
|
66
|
+
/** What the index says about itself, in words a person can act on. */
|
|
67
|
+
const HEALTH = {
|
|
68
|
+
empty: { says: 'Nothing indexed yet.', tone: 'var(--watch-tone-neutral)' },
|
|
69
|
+
ready: { says: 'Index ready.', tone: 'var(--watch-tone-active)' },
|
|
70
|
+
indexing: { says: 'Indexing — results are partial.', tone: 'var(--watch-tone-caution)' },
|
|
71
|
+
stale: { says: 'Index is behind the store.', tone: 'var(--watch-tone-caution)' },
|
|
72
|
+
corrupt: { says: 'Index unreadable. Rebuild required.', tone: 'var(--watch-tone-error)' },
|
|
73
|
+
};
|
|
74
|
+
const KINDS = ['video', 'audio', 'page', 'stream', 'document', 'screen_capture'];
|
|
75
|
+
/**
|
|
76
|
+
* Highlight matches without building markup.
|
|
77
|
+
*
|
|
78
|
+
* The snippet is evidence, so it is never altered and never handed to a
|
|
79
|
+
* renderer as HTML. Splitting into plain segments and marking them with React
|
|
80
|
+
* elements keeps escaping the renderer's job, which is the only place it is
|
|
81
|
+
* reliably done.
|
|
82
|
+
*/
|
|
83
|
+
function Highlighted({ text, terms }) {
|
|
84
|
+
if (terms.length === 0 || text === '')
|
|
85
|
+
return _jsx(_Fragment, { children: text });
|
|
86
|
+
const pattern = terms
|
|
87
|
+
.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
88
|
+
.filter(term => term !== '')
|
|
89
|
+
.join('|');
|
|
90
|
+
if (pattern === '')
|
|
91
|
+
return _jsx(_Fragment, { children: text });
|
|
92
|
+
const parts = text.split(new RegExp(`(${pattern})`, 'giu'));
|
|
93
|
+
return (_jsx(_Fragment, { children: parts.map((part, index) => (terms.includes(part.toLowerCase())
|
|
94
|
+
? _jsx("mark", { style: { background: 'var(--watch-wash-active)', color: 'inherit' }, children: part }, `${part}-${String(index)}`)
|
|
95
|
+
: _jsx("span", { children: part }, `${part}-${String(index)}`))) }));
|
|
96
|
+
}
|
|
97
|
+
/** The Library search workflow: query, filter, sort, page, rebuild. */
|
|
98
|
+
export function LibrarySearch({ records = [], index: injected, reads }) {
|
|
99
|
+
const queryId = useId();
|
|
100
|
+
const kindId = useId();
|
|
101
|
+
const verdictId = useId();
|
|
102
|
+
const sortId = useId();
|
|
103
|
+
const [text, setText] = useState('');
|
|
104
|
+
const [kind, setKind] = useState('');
|
|
105
|
+
const [verdict, setVerdict] = useState('');
|
|
106
|
+
const [sort, setSort] = useState('relevance');
|
|
107
|
+
const [offset, setOffset] = useState(0);
|
|
108
|
+
const [generation, setGeneration] = useState(0);
|
|
109
|
+
const [state, setState] = useState(null);
|
|
110
|
+
// Bounded progress. `true` only while a rebuild the host accepted is
|
|
111
|
+
// outstanding, so the control cannot appear busy after the answer arrived.
|
|
112
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
113
|
+
const [refreshed, setRefreshed] = useState(null);
|
|
114
|
+
const index = useMemo(() => {
|
|
115
|
+
if (injected !== undefined)
|
|
116
|
+
return injected;
|
|
117
|
+
const built = new LibraryIndex();
|
|
118
|
+
built.addAll(records);
|
|
119
|
+
return built;
|
|
120
|
+
}, [injected, records, generation]);
|
|
121
|
+
// Every query supersedes the one before it. Without this, a slow search over
|
|
122
|
+
// a large corpus can land after a newer one and overwrite it with stale
|
|
123
|
+
// results — the classic race that makes a search box feel haunted. It is what
|
|
124
|
+
// carries cancellation to the host too: the Remote takes the same signal, so
|
|
125
|
+
// an abandoned query is abandoned on both sides rather than only on this one.
|
|
126
|
+
const inFlight = useRef(null);
|
|
127
|
+
const run = useCallback((nextOffset) => {
|
|
128
|
+
inFlight.current?.abort();
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
inFlight.current = controller;
|
|
131
|
+
setOffset(nextOffset);
|
|
132
|
+
if (reads === undefined) {
|
|
133
|
+
setState(fromIndex(index.search({
|
|
134
|
+
text,
|
|
135
|
+
...(kind === '' ? {} : { kinds: [kind] }),
|
|
136
|
+
...(verdict === '' ? {} : { verdicts: [verdict] }),
|
|
137
|
+
sort,
|
|
138
|
+
offset: nextOffset,
|
|
139
|
+
limit: PAGE,
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
})));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
void readLibraryPage(reads, { text, modality: kind, limit: PAGE, deadlineMs: DEADLINE_MS }, controller.signal).then(next => {
|
|
145
|
+
// A superseded answer is dropped rather than rendered: the newer query
|
|
146
|
+
// already owns the screen.
|
|
147
|
+
if (!controller.signal.aborted)
|
|
148
|
+
setState(next);
|
|
149
|
+
});
|
|
150
|
+
}, [reads, index, text, kind, verdict, sort]);
|
|
151
|
+
useEffect(() => { run(0); }, [run]);
|
|
152
|
+
useEffect(() => () => { inFlight.current?.abort(); }, []);
|
|
153
|
+
/**
|
|
154
|
+
* Ask the host to read its roots again, then search the result.
|
|
155
|
+
*
|
|
156
|
+
* Two steps rather than one, and deliberately in that order: the refresh
|
|
157
|
+
* reports what the host now holds, and the search that follows is what puts
|
|
158
|
+
* it on the screen. Collapsing them would leave the count and the rows
|
|
159
|
+
* describing two different generations.
|
|
160
|
+
*/
|
|
161
|
+
const refresh = useCallback(() => {
|
|
162
|
+
if (reads === undefined) {
|
|
163
|
+
// No host to ask. The local index is derived, so discarding it and
|
|
164
|
+
// building it again is the whole refresh.
|
|
165
|
+
setGeneration(value => value + 1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (refreshing)
|
|
169
|
+
return;
|
|
170
|
+
setRefreshing(true);
|
|
171
|
+
setRefreshed(null);
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
void refreshLibrary(reads, REFRESH_DEADLINE_MS, controller.signal)
|
|
174
|
+
.then(next => {
|
|
175
|
+
setRefreshed(next);
|
|
176
|
+
setRefreshing(false);
|
|
177
|
+
// Re-read only where the host actually swapped something in. A failed
|
|
178
|
+
// refresh leaves the previous index searchable and the rows on screen
|
|
179
|
+
// are still correct for it.
|
|
180
|
+
if (next.refreshed)
|
|
181
|
+
setGeneration(value => value + 1);
|
|
182
|
+
});
|
|
183
|
+
}, [reads, refreshing]);
|
|
184
|
+
const terms = useMemo(() => tokenize(text), [text]);
|
|
185
|
+
// `noUncheckedIndexedAccess` is on, so an index lookup is optional even
|
|
186
|
+
// with a total record type. Falling back keeps the surface renderable
|
|
187
|
+
// for a health value a future build adds before this one knows it.
|
|
188
|
+
const health = HEALTH[state?.health ?? (reads === undefined ? index.health : 'empty')]
|
|
189
|
+
?? { says: 'Index state unknown.', tone: 'var(--watch-tone-neutral)' };
|
|
190
|
+
const rows = state?.rows ?? [];
|
|
191
|
+
const total = state?.total ?? 0;
|
|
192
|
+
const shown = rows.length;
|
|
193
|
+
const page = Math.floor(offset / PAGE) + 1;
|
|
194
|
+
// The host answers one page and offers no cursor, so it never claims more
|
|
195
|
+
// pages than it can produce. Only the local index pages.
|
|
196
|
+
const pages = state?.pageable === true ? Math.max(1, Math.ceil(total / PAGE)) : 1;
|
|
197
|
+
return (_jsxs("div", { style: S.root, children: [_jsxs("form", { style: S.bar, role: "search", onSubmit: event => { event.preventDefault(); run(0); }, children: [_jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: queryId, style: S.label, children: "Search evidence" }), _jsx("input", { id: queryId, style: S.input, type: "search", value: text, placeholder: "Words in a transcript, a title, a run\u2026", onChange: event => { setText(event.target.value); } })] }), _jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: kindId, style: S.label, children: "Type" }), _jsxs("select", { id: kindId, style: S.select, value: kind, onChange: event => { setKind(event.target.value); }, children: [_jsx("option", { value: "", children: "Any type" }), KINDS.map(value => _jsx("option", { value: value, children: value.replace('_', ' ') }, value))] })] }), _jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: verdictId, style: S.label, children: "Verification" }), _jsxs("select", { id: verdictId, style: S.select, value: verdict, disabled: reads !== undefined, onChange: event => { setVerdict(event.target.value); }, children: [_jsx("option", { value: "", children: "Any state" }), ['VERIFIED', 'FAILED', 'UNVERIFIED', 'INCONCLUSIVE'].map(value => (_jsx("option", { value: value, children: value }, value)))] })] }), _jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: sortId, style: S.label, children: "Sort" }), _jsxs("select", { id: sortId, style: S.select, value: sort, disabled: reads !== undefined, onChange: event => { setSort(event.target.value); }, children: [_jsx("option", { value: "relevance", children: "Relevance" }), _jsx("option", { value: "newest", children: "Newest first" }), _jsx("option", { value: "oldest", children: "Oldest first" }), _jsx("option", { value: "title", children: "Title" })] })] }), _jsx("button", { type: "submit", style: S.button, children: "Search" }), _jsx("button", { type: "button", style: S.button, disabled: refreshing,
|
|
198
|
+
// Rebuilding is safe precisely because the index is derived: it can
|
|
199
|
+
// be thrown away and reconstructed from the records at any time.
|
|
200
|
+
//
|
|
201
|
+
// It says what it does in each mode rather than one word for two
|
|
202
|
+
// actions. Locally it discards the index and builds it again. Against
|
|
203
|
+
// a host it asks the host to read its roots again — a real operation
|
|
204
|
+
// with a real answer, which is why the label is the same verb and the
|
|
205
|
+
// subject is the Library rather than a local structure.
|
|
206
|
+
onClick: refresh, children: refreshing
|
|
207
|
+
? 'Refreshing…'
|
|
208
|
+
: (reads === undefined ? 'Rebuild index' : 'Refresh library') })] }), _jsxs("p", { style: { ...S.status, color: health.tone }, children: [health.says, ' ', _jsx("span", { style: { color: 'var(--dsw-alias-label-tertiary)' }, children: reads === undefined
|
|
209
|
+
? `${String(index.size)} record(s) indexed on this machine.`
|
|
210
|
+
: 'Answered by this workspace’s own host.' })] }), _jsx("p", { style: S.status, role: "status", "aria-live": "polite", children: total === 0
|
|
211
|
+
? (terms.length === 0 ? 'No records to list.' : `No matches for “${text}”.`)
|
|
212
|
+
: `${String(total)} match${total === 1 ? '' : 'es'}, showing ${String(shown)} (page ${String(page)} of ${String(pages)}).` }), refreshing
|
|
213
|
+
? (_jsx("p", { style: S.status, role: "status", "aria-live": "polite", children: "Reading the library again. The results below are the previous index until it finishes." }))
|
|
214
|
+
: null, refreshed === null || refreshing
|
|
215
|
+
? null
|
|
216
|
+
: (_jsxs("p", { style: {
|
|
217
|
+
...S.status,
|
|
218
|
+
color: refreshed.failed ? 'var(--watch-tone-error)' : 'var(--dsw-alias-label-tertiary)',
|
|
219
|
+
}, role: "status", "aria-live": "polite", children: [refreshed.refreshed
|
|
220
|
+
? `Library refreshed: ${String(refreshed.recordCount)} record(s), `
|
|
221
|
+
+ `generation ${String(refreshed.generation)}.`
|
|
222
|
+
: refreshed.note, refreshed.refreshed && refreshed.note !== '' ? ` ${refreshed.note}` : ''] })), (state?.notes ?? []).map(note => (_jsx("p", { style: S.status, children: note }, note))), total === 0
|
|
223
|
+
? (_jsx("div", { style: { ...S.hit, borderStyle: 'dashed' }, children: _jsx("p", { style: { ...S.snippet, margin: 0 }, children: reads === undefined && index.size === 0
|
|
224
|
+
? 'Nothing has been indexed yet. Evidence appears here once the workspace has recorded some — then this searches it locally, with no service and no model.'
|
|
225
|
+
: 'Nothing matched. Every word has to appear in a record; try fewer words, or clear the filters.' }) }))
|
|
226
|
+
: (_jsx("ul", { style: S.list, children: rows.map(entry => (_jsxs("li", { style: S.hit, children: [_jsx("h4", { style: S.title, children: _jsx(Highlighted, { text: entry.title, terms: terms }) }), entry.snippets.map((snippet, at) => (_jsx("p", { style: S.snippet, children: _jsx(Highlighted, { text: snippet, terms: terms }) }, `${entry.key}-${String(at)}`))), _jsxs("div", { style: S.meta, children: [_jsx("span", { children: entry.kind }), _jsx("span", { "data-watch-ltr": true, children: entry.recordId }), entry.evidenceCount > 0
|
|
227
|
+
? (_jsx("span", { "data-watch-ltr": true, children: `${String(entry.evidenceCount)} evidence ref(s)` }))
|
|
228
|
+
: null] })] }, entry.key))) })), pages > 1
|
|
229
|
+
? (_jsxs("nav", { style: { display: 'flex', gap: '8px' }, "aria-label": "Search results pages", children: [_jsx("button", { type: "button", style: S.button, disabled: offset === 0, onClick: () => { run(Math.max(0, offset - PAGE)); }, children: "Previous" }), _jsx("button", { type: "button", style: S.button, disabled: offset + PAGE >= total, onClick: () => { run(Math.min(offset + PAGE, Math.max(0, total - 1))); }, children: "Next" })] }))
|
|
230
|
+
: null] }));
|
|
231
|
+
}
|
|
232
|
+
export { MAX_LIMIT };
|
|
233
|
+
//# sourceMappingURL=search-view.js.map
|