@bojackduy/opencode-telescope 0.1.17 → 0.1.19
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/components/preview.tsx +115 -39
- package/package.json +1 -1
- package/search.ts +161 -5
- package/telescope.tsx +304 -24
- package/ui/render-target.ts +32 -5
package/components/preview.tsx
CHANGED
|
@@ -51,7 +51,9 @@ export const ConversationPreview = (props: { item: SearchResult; parts: Conversa
|
|
|
51
51
|
<Show when={props.parts.length > 0} fallback={<ConversationFallback item={props.item} syntax={props.syntax} theme={props.theme} />}>
|
|
52
52
|
<For each={props.parts}>
|
|
53
53
|
{(part) => (
|
|
54
|
-
<
|
|
54
|
+
<box id={`preview-part-${part.id}`} flexDirection="column" flexShrink={0}>
|
|
55
|
+
<PreviewConversationPart part={part} item={props.item} syntax={props.syntax} theme={props.theme} />
|
|
56
|
+
</box>
|
|
55
57
|
)}
|
|
56
58
|
</For>
|
|
57
59
|
</Show>
|
|
@@ -143,8 +145,8 @@ const PreviewToolPart = (props: { part: ConversationPreviewPart; item: SearchRes
|
|
|
143
145
|
<Show when={props.part.target}>
|
|
144
146
|
<TargetMarker part={props.part} item={props.item} role={props.part.tool ?? "tool"} time={props.part.timeCreated} theme={props.theme} />
|
|
145
147
|
</Show>
|
|
146
|
-
<Show when={codeTool()} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
|
|
147
|
-
<CodeToolPreview part={props.part} syntax={props.syntax} theme={props.theme} />
|
|
148
|
+
<Show when={codeTool() && props.part.target} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
|
|
149
|
+
<CodeToolPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
|
|
148
150
|
</Show>
|
|
149
151
|
<Show when={props.part.state?.error}>
|
|
150
152
|
{(error) => <text fg={props.theme.error}>{error()}</text>}
|
|
@@ -167,29 +169,37 @@ const CompactToolRow = (props: { part: ConversationPreviewPart; color: any; fail
|
|
|
167
169
|
</>
|
|
168
170
|
)
|
|
169
171
|
|
|
170
|
-
const CodeToolPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
171
|
-
if (props.part.tool === "apply_patch") return <ApplyPatchPreview part={props.part} syntax={props.syntax} theme={props.theme} />
|
|
172
|
-
if (props.part.tool === "edit") return <EditPreview part={props.part} syntax={props.syntax} theme={props.theme} />
|
|
173
|
-
if (props.part.tool === "write") return <WritePreview part={props.part} syntax={props.syntax} theme={props.theme} />
|
|
172
|
+
const CodeToolPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
173
|
+
if (props.part.tool === "apply_patch") return <ApplyPatchPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
|
|
174
|
+
if (props.part.tool === "edit") return <EditPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
|
|
175
|
+
if (props.part.tool === "write") return <WritePreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
|
|
174
176
|
return <CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
const ApplyPatchPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
179
|
+
const ApplyPatchPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
178
180
|
const files = createMemo(() => parseApplyPatchFiles(props.part.state?.metadata))
|
|
181
|
+
const matched = createMemo(() => {
|
|
182
|
+
const query = props.item.match || props.item.previewMatch
|
|
183
|
+
return files().find((file) => containsOrderedTokens(file.patch, query)) ?? files()[0]
|
|
184
|
+
})
|
|
179
185
|
return (
|
|
180
|
-
<Show when={
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
<Show when={matched()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
|
|
187
|
+
{(file) => {
|
|
188
|
+
const view = createMemo(() => clippedText(file().patch, props.item.match || props.item.previewMatch, 24))
|
|
189
|
+
return (
|
|
190
|
+
<ToolBlock title={patchTitle(file())} theme={props.theme}>
|
|
191
|
+
<Show when={view().clipped}>
|
|
192
|
+
<text fg={props.theme.textMuted}>showing matched excerpt from large patch</text>
|
|
193
|
+
</Show>
|
|
194
|
+
<DiffBlock diff={view().text} filePath={file().filePath} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
|
|
185
195
|
</ToolBlock>
|
|
186
|
-
)
|
|
187
|
-
|
|
196
|
+
)
|
|
197
|
+
}}
|
|
188
198
|
</Show>
|
|
189
199
|
)
|
|
190
200
|
}
|
|
191
201
|
|
|
192
|
-
const EditPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
202
|
+
const EditPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
193
203
|
const input = createMemo(() => recordValue(props.part.state?.input))
|
|
194
204
|
const metadata = createMemo(() => recordValue(props.part.state?.metadata))
|
|
195
205
|
const diff = createMemo(() => stringValue(metadata()?.diff) ?? stringValue(recordValue(metadata()?.filediff)?.patch) ?? "")
|
|
@@ -198,14 +208,24 @@ const EditPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle
|
|
|
198
208
|
<Show when={diff()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
|
|
199
209
|
{(value) => (
|
|
200
210
|
<ToolBlock title={`← Edit ${shortPath(filePath())}`} theme={props.theme}>
|
|
201
|
-
|
|
211
|
+
{(() => {
|
|
212
|
+
const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 24))
|
|
213
|
+
return (
|
|
214
|
+
<>
|
|
215
|
+
<Show when={view().clipped}>
|
|
216
|
+
<text fg={props.theme.textMuted}>showing matched excerpt from large diff</text>
|
|
217
|
+
</Show>
|
|
218
|
+
<DiffBlock diff={view().text} filePath={filePath()} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
|
|
219
|
+
</>
|
|
220
|
+
)
|
|
221
|
+
})()}
|
|
202
222
|
</ToolBlock>
|
|
203
223
|
)}
|
|
204
224
|
</Show>
|
|
205
225
|
)
|
|
206
226
|
}
|
|
207
227
|
|
|
208
|
-
const WritePreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
228
|
+
const WritePreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
|
|
209
229
|
const input = createMemo(() => recordValue(props.part.state?.input))
|
|
210
230
|
const filePath = createMemo(() => stringValue(input()?.filePath) ?? "")
|
|
211
231
|
const content = createMemo(() => stringValue(input()?.content) ?? "")
|
|
@@ -213,15 +233,25 @@ const WritePreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyl
|
|
|
213
233
|
<Show when={content()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
|
|
214
234
|
{(value) => (
|
|
215
235
|
<ToolBlock title={`# Wrote ${shortPath(filePath())}`} theme={props.theme}>
|
|
236
|
+
{(() => {
|
|
237
|
+
const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 32))
|
|
238
|
+
return (
|
|
239
|
+
<>
|
|
240
|
+
<Show when={view().clipped}>
|
|
241
|
+
<text fg={props.theme.textMuted}>showing matched excerpt from large file</text>
|
|
242
|
+
</Show>
|
|
216
243
|
<line_number fg={props.theme.textMuted} minWidth={3} paddingRight={1}>
|
|
217
244
|
<code
|
|
218
245
|
conceal={false}
|
|
219
246
|
fg={props.theme.text}
|
|
220
247
|
filetype={filetype(filePath())}
|
|
221
248
|
syntaxStyle={props.syntax}
|
|
222
|
-
content={
|
|
249
|
+
content={view().text}
|
|
223
250
|
/>
|
|
224
251
|
</line_number>
|
|
252
|
+
</>
|
|
253
|
+
)
|
|
254
|
+
})()}
|
|
225
255
|
</ToolBlock>
|
|
226
256
|
)}
|
|
227
257
|
</Show>
|
|
@@ -246,27 +276,29 @@ const ToolBlock = (props: { title: string; children: any; theme: TuiThemeCurrent
|
|
|
246
276
|
</box>
|
|
247
277
|
)
|
|
248
278
|
|
|
249
|
-
const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => (
|
|
279
|
+
const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent; clipped?: boolean }) => (
|
|
250
280
|
<box paddingLeft={1}>
|
|
251
|
-
<diff
|
|
252
|
-
diff
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
281
|
+
<Show when={!props.clipped} fallback={<code filetype="diff" syntaxStyle={props.syntax} content={props.diff} fg={props.theme.text} />}>
|
|
282
|
+
<diff
|
|
283
|
+
diff={props.diff}
|
|
284
|
+
view="unified"
|
|
285
|
+
filetype={filetype(props.filePath)}
|
|
286
|
+
syntaxStyle={props.syntax}
|
|
287
|
+
showLineNumbers={true}
|
|
288
|
+
width="100%"
|
|
289
|
+
wrapMode="word"
|
|
290
|
+
fg={props.theme.text}
|
|
291
|
+
addedBg={props.theme.diffAddedBg}
|
|
292
|
+
removedBg={props.theme.diffRemovedBg}
|
|
293
|
+
contextBg={props.theme.diffContextBg}
|
|
294
|
+
addedSignColor={props.theme.diffHighlightAdded}
|
|
295
|
+
removedSignColor={props.theme.diffHighlightRemoved}
|
|
296
|
+
lineNumberFg={props.theme.diffLineNumber}
|
|
297
|
+
lineNumberBg={props.theme.diffContextBg}
|
|
298
|
+
addedLineNumberBg={props.theme.diffAddedLineNumberBg}
|
|
299
|
+
removedLineNumberBg={props.theme.diffRemovedLineNumberBg}
|
|
300
|
+
/>
|
|
301
|
+
</Show>
|
|
270
302
|
</box>
|
|
271
303
|
)
|
|
272
304
|
|
|
@@ -386,6 +418,50 @@ function matchExcerpt(text: string, query: string, radius = 80) {
|
|
|
386
418
|
}
|
|
387
419
|
}
|
|
388
420
|
|
|
421
|
+
function clippedText(text: string, query: string, radiusLines: number) {
|
|
422
|
+
const lines = text.split("\n")
|
|
423
|
+
const tooLarge = text.length > 30000 || lines.length > 420
|
|
424
|
+
if (!tooLarge) return { text, clipped: false }
|
|
425
|
+
|
|
426
|
+
const matchLine = findOrderedTokenLine(lines, query)
|
|
427
|
+
if (matchLine === -1) {
|
|
428
|
+
return { text: lines.slice(0, radiusLines * 2).join("\n"), clipped: true }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const start = Math.max(0, matchLine - radiusLines)
|
|
432
|
+
const end = Math.min(lines.length, matchLine + radiusLines + 1)
|
|
433
|
+
return {
|
|
434
|
+
text: [
|
|
435
|
+
start > 0 ? `... ${start} lines omitted ...` : undefined,
|
|
436
|
+
...lines.slice(start, end),
|
|
437
|
+
end < lines.length ? `... ${lines.length - end} lines omitted ...` : undefined,
|
|
438
|
+
].filter(Boolean).join("\n"),
|
|
439
|
+
clipped: true,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function findOrderedTokenLine(lines: string[], query: string) {
|
|
444
|
+
const tokens = query.trim().split(/\s+/).filter(Boolean)
|
|
445
|
+
if (tokens.length === 0) return -1
|
|
446
|
+
for (let index = 0; index < lines.length; index++) {
|
|
447
|
+
if (containsOrderedTokens(lines[index]!, query)) return index
|
|
448
|
+
}
|
|
449
|
+
return -1
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function containsOrderedTokens(text: string, query: string) {
|
|
453
|
+
const tokens = query.trim().split(/\s+/).filter(Boolean)
|
|
454
|
+
if (tokens.length === 0) return false
|
|
455
|
+
const lower = text.toLowerCase()
|
|
456
|
+
let searchPos = 0
|
|
457
|
+
for (const token of tokens) {
|
|
458
|
+
const index = lower.indexOf(token.toLowerCase(), searchPos)
|
|
459
|
+
if (index === -1) return false
|
|
460
|
+
searchPos = index + token.length
|
|
461
|
+
}
|
|
462
|
+
return true
|
|
463
|
+
}
|
|
464
|
+
|
|
389
465
|
function parseApplyPatchFiles(metadata: unknown) {
|
|
390
466
|
const files = recordValue(metadata)?.files
|
|
391
467
|
if (!Array.isArray(files)) return []
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-telescope",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.19",
|
|
5
5
|
"description": "Fuzzy search across all OpenCode conversations — grep session and chat history, find code snippets, and jump to any chat instantly",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/search.ts
CHANGED
|
@@ -95,6 +95,7 @@ let _db: Database | undefined
|
|
|
95
95
|
let _dbPath: string | undefined
|
|
96
96
|
let _indexDb: Database | undefined
|
|
97
97
|
let _indexDbPath: string | undefined
|
|
98
|
+
const backgroundIndexRebuilds = new Set<string>()
|
|
98
99
|
|
|
99
100
|
function getDb(dbPath?: string): Database {
|
|
100
101
|
const resolved = dbPath ?? resolveDatabasePath()
|
|
@@ -148,8 +149,13 @@ export function searchSessionMessages(query: string, options?: { limit?: number;
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
|
|
151
|
-
const
|
|
152
|
-
|
|
152
|
+
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
153
|
+
const db = getDb(dbPath)
|
|
154
|
+
const limit = options?.limit ?? 40
|
|
155
|
+
const indexed = dbPath === ":memory:" ? undefined : indexedRecentRows(db, dbPath, limit, options?.directory, options?.offset, options?.role)
|
|
156
|
+
if (indexed) return indexed.flatMap((row) => rowToSearchResult(row, "") ?? [])
|
|
157
|
+
debug.log("query:recent:source-fallback", { limit, offset: options?.offset ?? 0, directory: options?.directory, role: options?.role })
|
|
158
|
+
return visibleTextRows(db, limit, undefined, options?.directory, options?.offset, options?.role).flatMap(
|
|
153
159
|
(row) => rowToSearchResult(row, "") ?? [],
|
|
154
160
|
)
|
|
155
161
|
}
|
|
@@ -231,7 +237,11 @@ export function loadConversationAround(result: SearchResult, options?: { before?
|
|
|
231
237
|
fetchBefore,
|
|
232
238
|
fetchAfter,
|
|
233
239
|
beforeRows: beforeRows.length,
|
|
240
|
+
validBefore: validBefore.length,
|
|
241
|
+
invalidBefore: previewRowBreakdown(beforeRows, validBefore),
|
|
234
242
|
afterRows: afterRows.length,
|
|
243
|
+
validAfter: validAfter.length,
|
|
244
|
+
invalidAfter: previewRowBreakdown(afterRows, validAfter),
|
|
235
245
|
parts: parts.length,
|
|
236
246
|
hasMoreBefore: page.hasMoreBefore,
|
|
237
247
|
hasMoreAfter: page.hasMoreAfter,
|
|
@@ -262,6 +272,7 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
|
|
|
262
272
|
`).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
|
|
263
273
|
const valid = rows.filter(isPreviewRow)
|
|
264
274
|
const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
|
|
275
|
+
const parsedIds = new Set(parts.map((part) => part.id))
|
|
265
276
|
const page = { parts, hasMoreBefore: valid.length > limit }
|
|
266
277
|
debug.log("preview:window", {
|
|
267
278
|
item: result.id,
|
|
@@ -270,7 +281,10 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
|
|
|
270
281
|
cursor,
|
|
271
282
|
limit,
|
|
272
283
|
rows: rows.length,
|
|
284
|
+
valid: valid.length,
|
|
285
|
+
invalid: previewRowBreakdown(rows, valid),
|
|
273
286
|
parts: parts.length,
|
|
287
|
+
unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
|
|
274
288
|
hasMoreBefore: page.hasMoreBefore,
|
|
275
289
|
first: parts[0]?.id,
|
|
276
290
|
last: parts.at(-1)?.id,
|
|
@@ -298,6 +312,7 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
|
|
|
298
312
|
`).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
|
|
299
313
|
const valid = rows.filter(isPreviewRow)
|
|
300
314
|
const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
|
|
315
|
+
const parsedIds = new Set(parts.map((part) => part.id))
|
|
301
316
|
const page = { parts, hasMoreAfter: valid.length > limit }
|
|
302
317
|
debug.log("preview:window", {
|
|
303
318
|
item: result.id,
|
|
@@ -306,7 +321,10 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
|
|
|
306
321
|
cursor,
|
|
307
322
|
limit,
|
|
308
323
|
rows: rows.length,
|
|
324
|
+
valid: valid.length,
|
|
325
|
+
invalid: previewRowBreakdown(rows, valid),
|
|
309
326
|
parts: parts.length,
|
|
327
|
+
unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
|
|
310
328
|
hasMoreAfter: page.hasMoreAfter,
|
|
311
329
|
first: parts[0]?.id,
|
|
312
330
|
last: parts.at(-1)?.id,
|
|
@@ -501,7 +519,7 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
|
|
|
501
519
|
role: row.role,
|
|
502
520
|
type: row.type,
|
|
503
521
|
timeCreated: row.time_created,
|
|
504
|
-
text: extractToolIndexText(data).trim(),
|
|
522
|
+
text: target ? extractToolIndexText(data).trim() : "",
|
|
505
523
|
tool: typeof data.tool === "string" ? data.tool : "tool",
|
|
506
524
|
state: parseToolState(data.state),
|
|
507
525
|
target,
|
|
@@ -526,6 +544,21 @@ function isPreviewRow(row: ConversationRow) {
|
|
|
526
544
|
(row.type === "text" || row.type === "reasoning" || row.type === "tool")
|
|
527
545
|
}
|
|
528
546
|
|
|
547
|
+
function previewRowBreakdown(rows: ConversationRow[], validRows: ConversationRow[]) {
|
|
548
|
+
const valid = new Set(validRows.map((row) => row.id))
|
|
549
|
+
const invalidRows = rows.filter((row) => !valid.has(row.id))
|
|
550
|
+
if (invalidRows.length === 0) return undefined
|
|
551
|
+
const byRole = countBy(invalidRows, (row) => String(row.role ?? "unknown"))
|
|
552
|
+
const byType = countBy(invalidRows, (row) => String(row.type ?? "unknown"))
|
|
553
|
+
return { count: invalidRows.length, byRole, byType }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function countBy<T>(items: T[], key: (item: T) => string) {
|
|
557
|
+
const counts: Record<string, number> = {}
|
|
558
|
+
for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1
|
|
559
|
+
return counts
|
|
560
|
+
}
|
|
561
|
+
|
|
529
562
|
function parsePartData(data: string) {
|
|
530
563
|
try {
|
|
531
564
|
const value = JSON.parse(data) as unknown
|
|
@@ -565,6 +598,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
|
|
|
565
598
|
if (!match) return []
|
|
566
599
|
try {
|
|
567
600
|
const index = ensureSearchIndex(db, dbPath)
|
|
601
|
+
if (!index) return []
|
|
568
602
|
const conditions = ["document_fts MATCH ?"]
|
|
569
603
|
const params: (string | number)[] = [match]
|
|
570
604
|
if (role) {
|
|
@@ -595,6 +629,46 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
|
|
|
595
629
|
}
|
|
596
630
|
}
|
|
597
631
|
|
|
632
|
+
function indexedRecentRows(db: Database, dbPath: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
|
|
633
|
+
try {
|
|
634
|
+
const index = ensureSearchIndex(db, dbPath, { rebuild: false })
|
|
635
|
+
if (!index) {
|
|
636
|
+
scheduleBackgroundIndexRebuild(dbPath)
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const conditions: string[] = ["part_type = 'text'"]
|
|
641
|
+
const params: (string | number)[] = []
|
|
642
|
+
if (role) {
|
|
643
|
+
conditions.push("role = ?")
|
|
644
|
+
params.push(role)
|
|
645
|
+
}
|
|
646
|
+
if (directory) {
|
|
647
|
+
conditions.push("directory = ?")
|
|
648
|
+
params.push(directory)
|
|
649
|
+
}
|
|
650
|
+
params.push(limit)
|
|
651
|
+
if (offset) params.push(offset)
|
|
652
|
+
|
|
653
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
654
|
+
const offsetClause = offset ? "OFFSET ?" : ""
|
|
655
|
+
debug.time("query:recent:index:exec")
|
|
656
|
+
const rows = index.query<Row, (string | number)[]>(`
|
|
657
|
+
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
658
|
+
CAST(time_created AS INTEGER) AS time_created, text
|
|
659
|
+
FROM document_index
|
|
660
|
+
${where}
|
|
661
|
+
ORDER BY CAST(time_created AS INTEGER) DESC
|
|
662
|
+
LIMIT ? ${offsetClause}
|
|
663
|
+
`).all(...params as any[])
|
|
664
|
+
debug.timeEnd("query:recent:index:exec")
|
|
665
|
+
return rows
|
|
666
|
+
} catch (err) {
|
|
667
|
+
debug.log("recent:index:fallback", err instanceof Error ? err.message : String(err))
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
598
672
|
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
|
|
599
673
|
const offsetClause = offset ? "OFFSET ?" : ""
|
|
600
674
|
const conditions: string[] = [
|
|
@@ -637,7 +711,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
|
|
|
637
711
|
return rows
|
|
638
712
|
}
|
|
639
713
|
|
|
640
|
-
function ensureSearchIndex(source: Database, sourcePath: string) {
|
|
714
|
+
function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean }) {
|
|
641
715
|
const indexPath = searchIndexPath(sourcePath)
|
|
642
716
|
if (!_indexDb || _indexDbPath !== indexPath) {
|
|
643
717
|
_indexDb?.close()
|
|
@@ -652,12 +726,32 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
|
|
|
652
726
|
const currentPath = getMeta(_indexDb, "source_path")
|
|
653
727
|
const currentIndexVersion = getMeta(_indexDb, "index_version")
|
|
654
728
|
if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
|
|
729
|
+
if (options?.rebuild === false) return
|
|
655
730
|
rebuildSearchIndex(source, _indexDb, sourcePath, state)
|
|
656
731
|
}
|
|
657
732
|
return _indexDb
|
|
658
733
|
}
|
|
659
734
|
|
|
660
|
-
|
|
735
|
+
function scheduleBackgroundIndexRebuild(dbPath: string) {
|
|
736
|
+
if (backgroundIndexRebuilds.has(dbPath)) return
|
|
737
|
+
backgroundIndexRebuilds.add(dbPath)
|
|
738
|
+
debug.log("fts:rebuild:background-scheduled", { dbPath })
|
|
739
|
+
const timer = setTimeout(() => {
|
|
740
|
+
debug.time("fts:rebuild:background")
|
|
741
|
+
try {
|
|
742
|
+
const db = getDb(dbPath)
|
|
743
|
+
ensureSearchIndex(db, dbPath)
|
|
744
|
+
} catch (err) {
|
|
745
|
+
debug.log("fts:rebuild:background:error", err instanceof Error ? err.message : String(err))
|
|
746
|
+
} finally {
|
|
747
|
+
backgroundIndexRebuilds.delete(dbPath)
|
|
748
|
+
debug.timeEnd("fts:rebuild:background")
|
|
749
|
+
}
|
|
750
|
+
}, 250)
|
|
751
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const SEARCH_INDEX_VERSION = "6"
|
|
661
755
|
|
|
662
756
|
function searchIndexPath(sourcePath: string) {
|
|
663
757
|
const parsed = path.parse(sourcePath)
|
|
@@ -683,6 +777,24 @@ function migrateSearchIndex(db: Database) {
|
|
|
683
777
|
text,
|
|
684
778
|
tokenize='unicode61'
|
|
685
779
|
);
|
|
780
|
+
CREATE TABLE IF NOT EXISTS document_index(
|
|
781
|
+
id TEXT PRIMARY KEY,
|
|
782
|
+
message_id TEXT NOT NULL,
|
|
783
|
+
session_id TEXT NOT NULL,
|
|
784
|
+
session_title TEXT NOT NULL,
|
|
785
|
+
directory TEXT NOT NULL,
|
|
786
|
+
role TEXT NOT NULL,
|
|
787
|
+
part_type TEXT NOT NULL,
|
|
788
|
+
tool TEXT,
|
|
789
|
+
time_created INTEGER NOT NULL,
|
|
790
|
+
text TEXT NOT NULL
|
|
791
|
+
);
|
|
792
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_idx
|
|
793
|
+
ON document_index(directory, role, time_created DESC);
|
|
794
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
|
|
795
|
+
ON document_index(directory, role, part_type, time_created DESC);
|
|
796
|
+
CREATE INDEX IF NOT EXISTS document_index_time_idx
|
|
797
|
+
ON document_index(time_created DESC);
|
|
686
798
|
`)
|
|
687
799
|
|
|
688
800
|
const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
|
|
@@ -704,6 +816,33 @@ function migrateSearchIndex(db: Database) {
|
|
|
704
816
|
);
|
|
705
817
|
`)
|
|
706
818
|
}
|
|
819
|
+
|
|
820
|
+
const indexColumns = db.query<{ name: string }, []>("PRAGMA table_info(document_index)").all().map((column) => column.name)
|
|
821
|
+
if (!["part_type", "tool", "time_created", "text"].every((name) => indexColumns.includes(name))) {
|
|
822
|
+
db.exec("DROP TABLE IF EXISTS document_index")
|
|
823
|
+
db.exec(`
|
|
824
|
+
CREATE TABLE document_index(
|
|
825
|
+
id TEXT PRIMARY KEY,
|
|
826
|
+
message_id TEXT NOT NULL,
|
|
827
|
+
session_id TEXT NOT NULL,
|
|
828
|
+
session_title TEXT NOT NULL,
|
|
829
|
+
directory TEXT NOT NULL,
|
|
830
|
+
role TEXT NOT NULL,
|
|
831
|
+
part_type TEXT NOT NULL,
|
|
832
|
+
tool TEXT,
|
|
833
|
+
time_created INTEGER NOT NULL,
|
|
834
|
+
text TEXT NOT NULL
|
|
835
|
+
);
|
|
836
|
+
`)
|
|
837
|
+
}
|
|
838
|
+
db.exec(`
|
|
839
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_idx
|
|
840
|
+
ON document_index(directory, role, time_created DESC);
|
|
841
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
|
|
842
|
+
ON document_index(directory, role, part_type, time_created DESC);
|
|
843
|
+
CREATE INDEX IF NOT EXISTS document_index_time_idx
|
|
844
|
+
ON document_index(time_created DESC);
|
|
845
|
+
`)
|
|
707
846
|
}
|
|
708
847
|
|
|
709
848
|
function sourceState(db: Database, sourcePath: string) {
|
|
@@ -738,9 +877,14 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
738
877
|
INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
|
|
739
878
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
740
879
|
`)
|
|
880
|
+
const insertIndex = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
|
|
881
|
+
INSERT INTO document_index(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
|
|
882
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
883
|
+
`)
|
|
741
884
|
index.exec("BEGIN IMMEDIATE")
|
|
742
885
|
try {
|
|
743
886
|
index.exec("DELETE FROM document_fts")
|
|
887
|
+
index.exec("DELETE FROM document_index")
|
|
744
888
|
for (const row of rows.flatMap(indexSourceRowToRows)) {
|
|
745
889
|
insert.run(
|
|
746
890
|
row.id,
|
|
@@ -754,6 +898,18 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
754
898
|
row.time_created,
|
|
755
899
|
row.text,
|
|
756
900
|
)
|
|
901
|
+
insertIndex.run(
|
|
902
|
+
row.id,
|
|
903
|
+
row.message_id,
|
|
904
|
+
row.session_id,
|
|
905
|
+
row.session_title ?? "Untitled session",
|
|
906
|
+
row.directory,
|
|
907
|
+
row.role,
|
|
908
|
+
row.part_type ?? "text",
|
|
909
|
+
row.tool ?? null,
|
|
910
|
+
row.time_created,
|
|
911
|
+
row.text,
|
|
912
|
+
)
|
|
757
913
|
}
|
|
758
914
|
setMeta(index, "source_path", sourcePath)
|
|
759
915
|
setMeta(index, "source_data_version", String(state.dataVersion))
|
package/telescope.tsx
CHANGED
|
@@ -20,7 +20,7 @@ import { debug } from "./ui/debug.ts"
|
|
|
20
20
|
import { syntaxStyle } from "./ui/format.ts"
|
|
21
21
|
import type { TelescopeConfig } from "./ui/config.ts"
|
|
22
22
|
import { inputSafeKeys, keyListLabel, matchesKey, prevent } from "./ui/keyboard.ts"
|
|
23
|
-
import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
|
|
23
|
+
import { findRenderableByID, jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
|
|
24
24
|
|
|
25
25
|
export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; onClose: () => void }) => {
|
|
26
26
|
type OwnerFilter = "all" | SearchRole
|
|
@@ -46,11 +46,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
46
46
|
const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
|
|
47
47
|
const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
|
|
48
48
|
const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
|
|
49
|
+
const [previewWindow, setPreviewWindow] = createSignal({ start: 0, end: 0 })
|
|
50
|
+
const [previewHeightVersion, setPreviewHeightVersion] = createSignal(0)
|
|
49
51
|
const MIN_SEARCH_BATCH_SIZE = 25
|
|
50
52
|
const MIN_RECENT_BATCH_SIZE = 15
|
|
51
53
|
const RESULT_OVERSCAN_MULTIPLIER = 2
|
|
52
54
|
const RESULT_BATCH_VIEWPORTS = 4
|
|
53
|
-
const RESULT_PREFETCH_VIEWPORTS =
|
|
55
|
+
const RESULT_PREFETCH_VIEWPORTS = 3
|
|
54
56
|
const RESULT_CACHE_BEHIND_VIEWPORTS = 6
|
|
55
57
|
const INITIAL_PREVIEW_BEFORE = 20
|
|
56
58
|
const INITIAL_PREVIEW_AFTER = 30
|
|
@@ -87,12 +89,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
87
89
|
const directory = props.api.state.path.directory
|
|
88
90
|
let advanceSelectionAfterLoad = false
|
|
89
91
|
let advanceSelectionBeforeLoad = false
|
|
92
|
+
let resultNavigationStarted = false
|
|
90
93
|
let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
|
|
91
94
|
let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
|
|
92
95
|
let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
|
|
93
96
|
let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
|
|
94
97
|
let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
|
|
95
98
|
let pendingPreviewAfterVisible = false
|
|
99
|
+
const previewMeasuredHeights = new Map<string, number>()
|
|
96
100
|
const cancelPreviewPrefetch = () => {
|
|
97
101
|
if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
|
|
98
102
|
if (previewAfterTimer) clearTimeout(previewAfterTimer)
|
|
@@ -177,6 +181,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
177
181
|
resultPreviousTimer = undefined
|
|
178
182
|
advanceSelectionAfterLoad = false
|
|
179
183
|
advanceSelectionBeforeLoad = false
|
|
184
|
+
resultNavigationStarted = false
|
|
180
185
|
setLoadingMore(false)
|
|
181
186
|
setLoadingPreviousResults(false)
|
|
182
187
|
setPrefetchingResults(false)
|
|
@@ -187,9 +192,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
187
192
|
setLoading(true)
|
|
188
193
|
const limit = recentBatchSize()
|
|
189
194
|
const timer = setTimeout(() => {
|
|
195
|
+
debug.log("bootstrap:recent:start", { limit, directory: dir, role })
|
|
190
196
|
debug.time("query:recent")
|
|
191
197
|
try {
|
|
192
198
|
const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
|
|
199
|
+
debug.log("bootstrap:recent:done", { rows: batch.length, limit })
|
|
193
200
|
solidBatch(() => {
|
|
194
201
|
setResults(batch)
|
|
195
202
|
setResultBaseOffset(0)
|
|
@@ -199,6 +206,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
199
206
|
setSelected(0)
|
|
200
207
|
})
|
|
201
208
|
} catch (err) {
|
|
209
|
+
debug.log("bootstrap:recent:error", err instanceof Error ? err.message : String(err))
|
|
202
210
|
solidBatch(() => {
|
|
203
211
|
setResults([])
|
|
204
212
|
setResultBaseOffset(0)
|
|
@@ -220,7 +228,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
220
228
|
const timer = setTimeout(() => {
|
|
221
229
|
debug.time("query:search")
|
|
222
230
|
try {
|
|
231
|
+
debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q })
|
|
223
232
|
const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
|
|
233
|
+
debug.log("bootstrap:search:done", { rows: batch.length, limit })
|
|
224
234
|
solidBatch(() => {
|
|
225
235
|
setResults(batch)
|
|
226
236
|
setResultBaseOffset(0)
|
|
@@ -370,6 +380,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
370
380
|
|
|
371
381
|
const move = (delta: number) => {
|
|
372
382
|
if (results().length === 0) return
|
|
383
|
+
resultNavigationStarted = true
|
|
373
384
|
setSelected((index) => {
|
|
374
385
|
const base = resultBaseOffset()
|
|
375
386
|
const cachedEnd = base + results().length
|
|
@@ -409,6 +420,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
409
420
|
const state = resultPrefetchState()
|
|
410
421
|
const blockedBy = !hasMore()
|
|
411
422
|
? "no-more"
|
|
423
|
+
: !resultNavigationStarted
|
|
424
|
+
? "waiting-for-navigation"
|
|
412
425
|
: loadingMore()
|
|
413
426
|
? "loading-more"
|
|
414
427
|
: loadingPreviousResults()
|
|
@@ -431,19 +444,161 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
431
444
|
onCleanup(() => clearTimeout(timer))
|
|
432
445
|
})
|
|
433
446
|
|
|
434
|
-
const
|
|
435
|
-
|
|
447
|
+
const estimatePreviewPartHeight = (part: ConversationPreviewPart) => {
|
|
448
|
+
if (part.type === "tool") {
|
|
449
|
+
if (!part.target) return 2
|
|
450
|
+
if (part.tool === "write") return 40
|
|
451
|
+
if (part.tool === "edit" || part.tool === "apply_patch") return 35
|
|
452
|
+
return 2
|
|
453
|
+
}
|
|
454
|
+
if (part.type === "reasoning") return Math.min(24, Math.max(2, Math.ceil(part.text.length / 120)))
|
|
455
|
+
return Math.min(90, Math.max(3, Math.ceil(part.text.length / 90)))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const previewPartHeight = (part: ConversationPreviewPart) => previewMeasuredHeights.get(part.id) ?? estimatePreviewPartHeight(part)
|
|
459
|
+
|
|
460
|
+
const previewVirtualLayout = createMemo(() => {
|
|
461
|
+
previewHeightVersion()
|
|
462
|
+
let offset = 0
|
|
463
|
+
return previewParts().map((part) => {
|
|
464
|
+
const height = previewPartHeight(part)
|
|
465
|
+
const row = { part, top: offset, bottom: offset + height, height }
|
|
466
|
+
offset += height
|
|
467
|
+
return row
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const previewContentHeight = () => previewVirtualLayout().at(-1)?.bottom ?? 0
|
|
472
|
+
|
|
473
|
+
const updatePreviewWindow = () => {
|
|
474
|
+
const scroll = previewScroll
|
|
475
|
+
const layout = previewVirtualLayout()
|
|
476
|
+
if (layout.length === 0) {
|
|
477
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
if (!scroll) {
|
|
481
|
+
const next = { start: 0, end: Math.min(layout.length, 20) }
|
|
482
|
+
const current = previewWindow()
|
|
483
|
+
if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const overscan = Math.max(scroll.height * 2, 40)
|
|
488
|
+
const from = Math.max(0, scroll.scrollTop - overscan)
|
|
489
|
+
const to = scroll.scrollTop + scroll.height + overscan
|
|
490
|
+
const foundStart = layout.findIndex((row) => row.bottom >= from)
|
|
491
|
+
const start = foundStart === -1 ? Math.max(0, layout.length - 1) : Math.max(0, foundStart)
|
|
492
|
+
const foundEnd = layout.findIndex((row) => row.top > to)
|
|
493
|
+
const end = foundEnd === -1 ? layout.length : foundEnd
|
|
494
|
+
const next = { start, end: Math.min(layout.length, Math.max(start + 1, end)) }
|
|
495
|
+
const current = previewWindow()
|
|
496
|
+
if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const previewWindowParts = createMemo(() => {
|
|
500
|
+
const { start, end } = previewWindow()
|
|
501
|
+
return previewParts().slice(start, end)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
const previewTopSpacerHeight = createMemo(() => {
|
|
505
|
+
const { start } = previewWindow()
|
|
506
|
+
return previewVirtualLayout()[start]?.top ?? 0
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
const previewBottomSpacerHeight = createMemo(() => {
|
|
510
|
+
const { end } = previewWindow()
|
|
511
|
+
const layout = previewVirtualLayout()
|
|
512
|
+
const total = layout.at(-1)?.bottom ?? 0
|
|
513
|
+
const renderedBottom = end > 0 ? layout[end - 1]?.bottom ?? 0 : 0
|
|
514
|
+
return Math.max(0, total - renderedBottom)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
const previewScrollState = () => {
|
|
518
|
+
const scroll = previewScroll
|
|
519
|
+
const children = scroll?.getChildren()
|
|
436
520
|
const lastChild = children?.[children.length - 1] as { y: number; height: number } | undefined
|
|
437
|
-
return
|
|
521
|
+
return {
|
|
522
|
+
y: scroll?.y,
|
|
523
|
+
height: scroll?.height,
|
|
524
|
+
scrollTop: scroll?.scrollTop,
|
|
525
|
+
scrollHeight: scroll?.scrollHeight,
|
|
526
|
+
childContentHeight: lastChild ? lastChild.y + lastChild.height : 0,
|
|
527
|
+
children: children?.length ?? 0,
|
|
528
|
+
hasMoreBefore: hasMorePreviewBefore(),
|
|
529
|
+
hasMoreAfter: hasMorePreviewAfter(),
|
|
530
|
+
loadingMore: loadingPreviewMore(),
|
|
531
|
+
prefetchingBefore: prefetchingPreviewBefore(),
|
|
532
|
+
prefetchingAfter: prefetchingPreviewAfter(),
|
|
533
|
+
timerBefore: Boolean(previewBeforeTimer),
|
|
534
|
+
timerAfter: Boolean(previewAfterTimer),
|
|
535
|
+
}
|
|
438
536
|
}
|
|
439
537
|
|
|
538
|
+
const measurePreviewWindow = () => {
|
|
539
|
+
const scroll = previewScroll
|
|
540
|
+
if (!scroll) return
|
|
541
|
+
|
|
542
|
+
let changed = false
|
|
543
|
+
for (const part of previewWindowParts()) {
|
|
544
|
+
const node = findRenderableByID(scroll, `preview-part-${part.id}`)
|
|
545
|
+
const height = node?.height
|
|
546
|
+
if (!height || height <= 0) continue
|
|
547
|
+
if (previewMeasuredHeights.get(part.id) !== height) {
|
|
548
|
+
previewMeasuredHeights.set(part.id, height)
|
|
549
|
+
changed = true
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (changed) {
|
|
554
|
+
debug.log("preview:measure", {
|
|
555
|
+
measured: previewWindowParts().length,
|
|
556
|
+
totalMeasured: previewMeasuredHeights.size,
|
|
557
|
+
window: previewWindow(),
|
|
558
|
+
})
|
|
559
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
560
|
+
setTimeout(updatePreviewWindow, 1)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
createEffect(() => {
|
|
565
|
+
previewParts()
|
|
566
|
+
previewHeightVersion()
|
|
567
|
+
const timer = setTimeout(updatePreviewWindow, 1)
|
|
568
|
+
onCleanup(() => clearTimeout(timer))
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
createEffect(() => {
|
|
572
|
+
previewWindowParts()
|
|
573
|
+
const timer = setTimeout(measurePreviewWindow, 1)
|
|
574
|
+
onCleanup(() => clearTimeout(timer))
|
|
575
|
+
})
|
|
576
|
+
|
|
440
577
|
const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
|
|
441
578
|
const item = selectedResult()
|
|
442
579
|
const first = previewParts()[0]
|
|
443
|
-
if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore())
|
|
580
|
+
if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) {
|
|
581
|
+
debug.log("preview:load-before:skip", {
|
|
582
|
+
reason: !item ? "no-item" : !first ? "no-first-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
|
|
583
|
+
previousContentHeight,
|
|
584
|
+
preserveScroll,
|
|
585
|
+
visibleLoad,
|
|
586
|
+
state: previewScrollState(),
|
|
587
|
+
})
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
const beforeState = previewScrollState()
|
|
444
591
|
visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
|
|
445
592
|
debug.time("preview:load-before")
|
|
446
593
|
try {
|
|
594
|
+
debug.log("preview:load-before:start", {
|
|
595
|
+
item: item.id,
|
|
596
|
+
cursor: { id: first.id, timeCreated: first.timeCreated },
|
|
597
|
+
previousContentHeight,
|
|
598
|
+
preserveScroll,
|
|
599
|
+
visibleLoad,
|
|
600
|
+
state: beforeState,
|
|
601
|
+
})
|
|
447
602
|
const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
|
|
448
603
|
debug.log("preview:load-before", {
|
|
449
604
|
item: item.id,
|
|
@@ -458,8 +613,27 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
458
613
|
setPreviewParts((prev) => [...page.parts, ...prev])
|
|
459
614
|
if (preserveScroll) {
|
|
460
615
|
setTimeout(() => {
|
|
616
|
+
const beforeAdjust = previewScrollState()
|
|
461
617
|
const delta = previewContentHeight() - previousContentHeight
|
|
462
618
|
if (delta > 0) previewScroll?.scrollBy(delta)
|
|
619
|
+
updatePreviewWindow()
|
|
620
|
+
debug.log("preview:load-before:adjust", {
|
|
621
|
+
delta,
|
|
622
|
+
previousContentHeight,
|
|
623
|
+
newContentHeight: previewContentHeight(),
|
|
624
|
+
scrolled: delta > 0,
|
|
625
|
+
beforeAdjust,
|
|
626
|
+
afterAdjust: previewScrollState(),
|
|
627
|
+
})
|
|
628
|
+
}, 1)
|
|
629
|
+
} else {
|
|
630
|
+
setTimeout(() => {
|
|
631
|
+
updatePreviewWindow()
|
|
632
|
+
debug.log("preview:load-before:no-adjust", {
|
|
633
|
+
previousContentHeight,
|
|
634
|
+
newContentHeight: previewContentHeight(),
|
|
635
|
+
state: previewScrollState(),
|
|
636
|
+
})
|
|
463
637
|
}, 1)
|
|
464
638
|
}
|
|
465
639
|
}
|
|
@@ -475,7 +649,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
475
649
|
const loadPreviewAfter = (visibleLoad = false) => {
|
|
476
650
|
const item = selectedResult()
|
|
477
651
|
const last = previewParts().at(-1)
|
|
478
|
-
if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter())
|
|
652
|
+
if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) {
|
|
653
|
+
debug.log("preview:load-after:skip", {
|
|
654
|
+
reason: !item ? "no-item" : !last ? "no-last-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
|
|
655
|
+
visibleLoad,
|
|
656
|
+
state: previewScrollState(),
|
|
657
|
+
})
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
debug.log("preview:load-after:start", {
|
|
661
|
+
item: item.id,
|
|
662
|
+
cursor: { id: last.id, timeCreated: last.timeCreated },
|
|
663
|
+
visibleLoad,
|
|
664
|
+
state: previewScrollState(),
|
|
665
|
+
})
|
|
479
666
|
visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
|
|
480
667
|
debug.time("preview:load-after")
|
|
481
668
|
try {
|
|
@@ -488,7 +675,10 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
488
675
|
first: page.parts[0]?.id,
|
|
489
676
|
last: page.parts.at(-1)?.id,
|
|
490
677
|
})
|
|
491
|
-
if (page.parts.length > 0)
|
|
678
|
+
if (page.parts.length > 0) {
|
|
679
|
+
setPreviewParts((prev) => [...prev, ...page.parts])
|
|
680
|
+
setTimeout(updatePreviewWindow, 1)
|
|
681
|
+
}
|
|
492
682
|
setHasMorePreviewAfter(page.hasMoreAfter)
|
|
493
683
|
} catch (err) {
|
|
494
684
|
debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
|
|
@@ -499,16 +689,40 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
499
689
|
}
|
|
500
690
|
|
|
501
691
|
const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
|
|
502
|
-
if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore())
|
|
692
|
+
if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) {
|
|
693
|
+
debug.log("preview:prefetch-before:skip", {
|
|
694
|
+
reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
|
|
695
|
+
previousContentHeight,
|
|
696
|
+
preserveScroll,
|
|
697
|
+
visibleLoad,
|
|
698
|
+
state: previewScrollState(),
|
|
699
|
+
})
|
|
700
|
+
return
|
|
701
|
+
}
|
|
503
702
|
if (previewBeforeTimer) {
|
|
504
703
|
if (pendingPreviewBefore) {
|
|
704
|
+
const previousPending = { ...pendingPreviewBefore }
|
|
505
705
|
pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
|
|
506
706
|
pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
|
|
707
|
+
debug.log("preview:prefetch-before:merge", {
|
|
708
|
+
previousPending,
|
|
709
|
+
nextPending: pendingPreviewBefore,
|
|
710
|
+
requested: { previousContentHeight, preserveScroll, visibleLoad },
|
|
711
|
+
state: previewScrollState(),
|
|
712
|
+
})
|
|
713
|
+
} else {
|
|
714
|
+
debug.log("preview:prefetch-before:skip", {
|
|
715
|
+
reason: "timer-already-set",
|
|
716
|
+
previousContentHeight,
|
|
717
|
+
preserveScroll,
|
|
718
|
+
visibleLoad,
|
|
719
|
+
state: previewScrollState(),
|
|
720
|
+
})
|
|
507
721
|
}
|
|
508
722
|
return
|
|
509
723
|
}
|
|
510
724
|
pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
|
|
511
|
-
debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
|
|
725
|
+
debug.log("preview:prefetch-before-scheduled", { previousContentHeight, preserveScroll, visibleLoad, state: previewScrollState() })
|
|
512
726
|
previewBeforeTimer = setTimeout(() => {
|
|
513
727
|
const pending = pendingPreviewBefore
|
|
514
728
|
previewBeforeTimer = undefined
|
|
@@ -518,10 +732,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
518
732
|
}
|
|
519
733
|
|
|
520
734
|
const schedulePreviewAfter = (visibleLoad = false) => {
|
|
521
|
-
if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter())
|
|
735
|
+
if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) {
|
|
736
|
+
debug.log("preview:prefetch-after:skip", {
|
|
737
|
+
reason: !hasMorePreviewAfter() ? "no-more-after" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
|
|
738
|
+
visibleLoad,
|
|
739
|
+
state: previewScrollState(),
|
|
740
|
+
})
|
|
741
|
+
return
|
|
742
|
+
}
|
|
522
743
|
pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
|
|
523
|
-
if (previewAfterTimer)
|
|
524
|
-
|
|
744
|
+
if (previewAfterTimer) {
|
|
745
|
+
debug.log("preview:prefetch-after:skip", { reason: "timer-already-set", visibleLoad, pendingVisibleLoad: pendingPreviewAfterVisible, state: previewScrollState() })
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
debug.log("preview:prefetch-after-scheduled", { visibleLoad, state: previewScrollState() })
|
|
525
749
|
previewAfterTimer = setTimeout(() => {
|
|
526
750
|
const pendingVisibleLoad = pendingPreviewAfterVisible
|
|
527
751
|
previewAfterTimer = undefined
|
|
@@ -530,12 +754,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
530
754
|
}, 1)
|
|
531
755
|
}
|
|
532
756
|
|
|
757
|
+
const scrollPreviewToEstimatedTarget = (item: SearchResult) => {
|
|
758
|
+
const scroll = previewScroll
|
|
759
|
+
if (!scroll) return
|
|
760
|
+
const targetID = messageTargetID(item)
|
|
761
|
+
const row = previewVirtualLayout().find((entry) => entry.part.id === item.id)
|
|
762
|
+
if (!row) {
|
|
763
|
+
scrollPreviewToTarget(scroll, targetID)
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const top = Math.max(0, row.top - Math.max(1, Math.floor(scroll.height / 3)))
|
|
768
|
+
debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
|
|
769
|
+
scroll.scrollTo(top)
|
|
770
|
+
updatePreviewWindow()
|
|
771
|
+
setTimeout(() => scrollPreviewToTarget(scroll, targetID), 1)
|
|
772
|
+
}
|
|
773
|
+
|
|
533
774
|
let lastPreviewItemId = ""
|
|
534
775
|
createEffect(() => {
|
|
535
776
|
const item = selectedResult()
|
|
536
777
|
if (!item) {
|
|
537
778
|
cancelPreviewPrefetch()
|
|
538
779
|
setPreviewParts([])
|
|
780
|
+
previewMeasuredHeights.clear()
|
|
781
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
782
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
539
783
|
setHasMorePreviewBefore(false)
|
|
540
784
|
setHasMorePreviewAfter(false)
|
|
541
785
|
return
|
|
@@ -543,6 +787,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
543
787
|
if (item.id === lastPreviewItemId) return
|
|
544
788
|
lastPreviewItemId = item.id
|
|
545
789
|
cancelPreviewPrefetch()
|
|
790
|
+
previewMeasuredHeights.clear()
|
|
791
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
792
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
546
793
|
debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
|
|
547
794
|
const db = dbPath()
|
|
548
795
|
debug.time("preview:load")
|
|
@@ -560,6 +807,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
560
807
|
setPreviewParts(page.parts)
|
|
561
808
|
setHasMorePreviewBefore(page.hasMoreBefore)
|
|
562
809
|
setHasMorePreviewAfter(page.hasMoreAfter)
|
|
810
|
+
setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
|
|
563
811
|
} catch {}
|
|
564
812
|
debug.timeEnd("nav:total")
|
|
565
813
|
debug.timeEnd("preview:load")
|
|
@@ -573,18 +821,19 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
573
821
|
const scroll = previewScroll
|
|
574
822
|
const children = scroll?.getChildren()
|
|
575
823
|
if (!scroll || !children || children.length === 0) return
|
|
576
|
-
const
|
|
577
|
-
const
|
|
578
|
-
const atTop = scroll.y <= 0
|
|
824
|
+
const totalContentHeight = previewContentHeight()
|
|
825
|
+
const atTop = scroll.scrollTop <= 0
|
|
579
826
|
const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
|
|
580
|
-
const nearTop = scroll.
|
|
581
|
-
const atBottom = scroll.
|
|
582
|
-
const nearBottom = scroll.
|
|
827
|
+
const nearTop = scroll.scrollTop <= prefetchDistance
|
|
828
|
+
const atBottom = scroll.scrollTop + scroll.height >= totalContentHeight - 1
|
|
829
|
+
const nearBottom = scroll.scrollTop + scroll.height >= totalContentHeight - prefetchDistance
|
|
583
830
|
if (nearTop || nearBottom) {
|
|
584
831
|
debug.log("preview:scroll-edge", {
|
|
585
832
|
y: scroll.y,
|
|
833
|
+
scrollTop: scroll.scrollTop,
|
|
586
834
|
height: scroll.height,
|
|
587
835
|
contentHeight: totalContentHeight,
|
|
836
|
+
childContentHeight: previewScrollState().childContentHeight,
|
|
588
837
|
prefetchDistance,
|
|
589
838
|
atTop,
|
|
590
839
|
nearTop,
|
|
@@ -597,7 +846,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
597
846
|
children: children.length,
|
|
598
847
|
})
|
|
599
848
|
}
|
|
600
|
-
if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight,
|
|
849
|
+
if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, true, false)
|
|
601
850
|
if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
|
|
602
851
|
}, 400)
|
|
603
852
|
onCleanup(() => clearInterval(interval))
|
|
@@ -606,11 +855,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
606
855
|
let scrolledItem = ""
|
|
607
856
|
createEffect(() => {
|
|
608
857
|
const item = selectedResult()
|
|
609
|
-
|
|
858
|
+
previewVirtualLayout()
|
|
610
859
|
if (!item) return
|
|
611
860
|
if (item.id === scrolledItem) return
|
|
612
861
|
scrolledItem = item.id
|
|
613
|
-
const timer = setTimeout(() =>
|
|
862
|
+
const timer = setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
|
|
614
863
|
onCleanup(() => clearTimeout(timer))
|
|
615
864
|
})
|
|
616
865
|
|
|
@@ -625,7 +874,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
625
874
|
|
|
626
875
|
const scrollPreview = (direction: 1 | -1, evt: ParsedKey) => {
|
|
627
876
|
prevent(evt)
|
|
628
|
-
|
|
877
|
+
const scroll = previewScroll
|
|
878
|
+
if (!scroll) {
|
|
879
|
+
debug.log("preview:scroll-key:skip", { reason: "no-scroll", direction })
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const beforeState = previewScrollState()
|
|
884
|
+
debug.log("preview:scroll-key", { direction, state: beforeState })
|
|
885
|
+
|
|
886
|
+
if (direction < 0 && scroll.scrollTop <= 0 && hasMorePreviewBefore()) {
|
|
887
|
+
debug.log("preview:scroll-key:load-before", { direction, state: beforeState })
|
|
888
|
+
schedulePreviewBefore(previewContentHeight(), true, true)
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const totalContentHeight = previewContentHeight()
|
|
893
|
+
if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
|
|
894
|
+
debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
|
|
895
|
+
schedulePreviewAfter(true)
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const amount = direction * previewScrollAmount(scroll)
|
|
900
|
+
scroll.scrollBy(amount)
|
|
901
|
+
updatePreviewWindow()
|
|
902
|
+
debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
|
|
629
903
|
}
|
|
630
904
|
|
|
631
905
|
const focusInput = () => {
|
|
@@ -793,7 +1067,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
793
1067
|
<PreviewHeader item={selectedResult()} query={query()} theme={theme()} />
|
|
794
1068
|
<scrollbox ref={(element: ScrollBoxRenderable) => (previewScroll = element)} flexGrow={1} minHeight={0} paddingLeft={1} paddingRight={1} verticalScrollbarOptions={{ visible: true }}>
|
|
795
1069
|
<Show when={selectedResult()} fallback={<text fg={theme().textMuted}>Select a hit to preview the exact matched message.</text>}>
|
|
796
|
-
{(item) =>
|
|
1070
|
+
{(item) => (
|
|
1071
|
+
<box flexDirection="column" flexShrink={0}>
|
|
1072
|
+
<box height={previewTopSpacerHeight()} flexShrink={0} />
|
|
1073
|
+
<ConversationPreview item={item()} parts={previewWindowParts()} syntax={syntax()} theme={theme()} />
|
|
1074
|
+
<box height={previewBottomSpacerHeight()} flexShrink={0} />
|
|
1075
|
+
</box>
|
|
1076
|
+
)}
|
|
797
1077
|
</Show>
|
|
798
1078
|
</scrollbox>
|
|
799
1079
|
</box>
|
package/ui/render-target.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
2
|
import type { SearchResult } from "../search.ts"
|
|
3
|
+
import { debug } from "./debug.ts"
|
|
3
4
|
|
|
4
5
|
export function previewScrollAmount(scroll: ScrollBoxRenderable | undefined) {
|
|
5
6
|
return Math.max(1, Math.floor((scroll?.height || 10) / 8))
|
|
@@ -13,10 +14,35 @@ export function messageTargetID(item: SearchResult) {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function scrollPreviewToTarget(scroll: ScrollBoxRenderable | undefined, targetID: string) {
|
|
16
|
-
if (!scroll)
|
|
17
|
+
if (!scroll) {
|
|
18
|
+
debug.log("preview:target-scroll:skip", { reason: "no-scroll", targetID })
|
|
19
|
+
return
|
|
20
|
+
}
|
|
17
21
|
const target = findRenderableByID(scroll, targetID)
|
|
18
|
-
if (!target)
|
|
19
|
-
|
|
22
|
+
if (!target) {
|
|
23
|
+
debug.log("preview:target-scroll:skip", {
|
|
24
|
+
reason: "target-not-found",
|
|
25
|
+
targetID,
|
|
26
|
+
y: scroll.y,
|
|
27
|
+
scrollTop: scroll.scrollTop,
|
|
28
|
+
scrollHeight: scroll.scrollHeight,
|
|
29
|
+
height: scroll.height,
|
|
30
|
+
children: scroll.getChildren().length,
|
|
31
|
+
})
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const delta = target.y - scroll.y - Math.max(1, Math.floor(scroll.height / 3))
|
|
35
|
+
debug.log("preview:target-scroll", {
|
|
36
|
+
targetID,
|
|
37
|
+
targetY: target.y,
|
|
38
|
+
scrollY: scroll.y,
|
|
39
|
+
scrollTop: scroll.scrollTop,
|
|
40
|
+
scrollHeight: scroll.height,
|
|
41
|
+
contentHeight: scroll.scrollHeight,
|
|
42
|
+
delta,
|
|
43
|
+
})
|
|
44
|
+
scroll.scrollBy(delta)
|
|
45
|
+
debug.log("preview:target-scroll:after", { targetID, scrollY: scroll.y, scrollTop: scroll.scrollTop })
|
|
20
46
|
}
|
|
21
47
|
|
|
22
48
|
export function jumpToRenderedTarget(root: unknown, targetID: string) {
|
|
@@ -33,9 +59,10 @@ export function jumpToRenderedTarget(root: unknown, targetID: string) {
|
|
|
33
59
|
setTimeout(tick, 50)
|
|
34
60
|
}
|
|
35
61
|
|
|
36
|
-
type RenderNode = {
|
|
62
|
+
export type RenderNode = {
|
|
37
63
|
id?: string
|
|
38
64
|
y: number
|
|
65
|
+
height?: number
|
|
39
66
|
getChildren(): unknown[]
|
|
40
67
|
}
|
|
41
68
|
|
|
@@ -68,7 +95,7 @@ function isScrollNode(value: RenderNode): value is ScrollNode {
|
|
|
68
95
|
return "scrollBy" in value && typeof value.scrollBy === "function"
|
|
69
96
|
}
|
|
70
97
|
|
|
71
|
-
function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
|
|
98
|
+
export function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
|
|
72
99
|
if (!isRenderNode(node)) return
|
|
73
100
|
if (node.id === targetID) return node
|
|
74
101
|
for (const child of node.getChildren()) {
|