@bojackduy/opencode-telescope 0.1.18 → 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 +3 -1
- package/package.json +1 -1
- package/search.ts +133 -4
- package/telescope.tsx +164 -10
- package/ui/render-target.ts +3 -2
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>
|
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
|
}
|
|
@@ -592,6 +598,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
|
|
|
592
598
|
if (!match) return []
|
|
593
599
|
try {
|
|
594
600
|
const index = ensureSearchIndex(db, dbPath)
|
|
601
|
+
if (!index) return []
|
|
595
602
|
const conditions = ["document_fts MATCH ?"]
|
|
596
603
|
const params: (string | number)[] = [match]
|
|
597
604
|
if (role) {
|
|
@@ -622,6 +629,46 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
|
|
|
622
629
|
}
|
|
623
630
|
}
|
|
624
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
|
+
|
|
625
672
|
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
|
|
626
673
|
const offsetClause = offset ? "OFFSET ?" : ""
|
|
627
674
|
const conditions: string[] = [
|
|
@@ -664,7 +711,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
|
|
|
664
711
|
return rows
|
|
665
712
|
}
|
|
666
713
|
|
|
667
|
-
function ensureSearchIndex(source: Database, sourcePath: string) {
|
|
714
|
+
function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean }) {
|
|
668
715
|
const indexPath = searchIndexPath(sourcePath)
|
|
669
716
|
if (!_indexDb || _indexDbPath !== indexPath) {
|
|
670
717
|
_indexDb?.close()
|
|
@@ -679,12 +726,32 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
|
|
|
679
726
|
const currentPath = getMeta(_indexDb, "source_path")
|
|
680
727
|
const currentIndexVersion = getMeta(_indexDb, "index_version")
|
|
681
728
|
if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
|
|
729
|
+
if (options?.rebuild === false) return
|
|
682
730
|
rebuildSearchIndex(source, _indexDb, sourcePath, state)
|
|
683
731
|
}
|
|
684
732
|
return _indexDb
|
|
685
733
|
}
|
|
686
734
|
|
|
687
|
-
|
|
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"
|
|
688
755
|
|
|
689
756
|
function searchIndexPath(sourcePath: string) {
|
|
690
757
|
const parsed = path.parse(sourcePath)
|
|
@@ -710,6 +777,24 @@ function migrateSearchIndex(db: Database) {
|
|
|
710
777
|
text,
|
|
711
778
|
tokenize='unicode61'
|
|
712
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);
|
|
713
798
|
`)
|
|
714
799
|
|
|
715
800
|
const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
|
|
@@ -731,6 +816,33 @@ function migrateSearchIndex(db: Database) {
|
|
|
731
816
|
);
|
|
732
817
|
`)
|
|
733
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
|
+
`)
|
|
734
846
|
}
|
|
735
847
|
|
|
736
848
|
function sourceState(db: Database, sourcePath: string) {
|
|
@@ -765,9 +877,14 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
765
877
|
INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
|
|
766
878
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
767
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
|
+
`)
|
|
768
884
|
index.exec("BEGIN IMMEDIATE")
|
|
769
885
|
try {
|
|
770
886
|
index.exec("DELETE FROM document_fts")
|
|
887
|
+
index.exec("DELETE FROM document_index")
|
|
771
888
|
for (const row of rows.flatMap(indexSourceRowToRows)) {
|
|
772
889
|
insert.run(
|
|
773
890
|
row.id,
|
|
@@ -781,6 +898,18 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
781
898
|
row.time_created,
|
|
782
899
|
row.text,
|
|
783
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
|
+
)
|
|
784
913
|
}
|
|
785
914
|
setMeta(index, "source_path", sourcePath)
|
|
786
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,10 +444,76 @@ 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)))
|
|
436
456
|
}
|
|
437
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
|
+
|
|
438
517
|
const previewScrollState = () => {
|
|
439
518
|
const scroll = previewScroll
|
|
440
519
|
const children = scroll?.getChildren()
|
|
@@ -456,6 +535,45 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
456
535
|
}
|
|
457
536
|
}
|
|
458
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
|
+
|
|
459
577
|
const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
|
|
460
578
|
const item = selectedResult()
|
|
461
579
|
const first = previewParts()[0]
|
|
@@ -498,6 +616,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
498
616
|
const beforeAdjust = previewScrollState()
|
|
499
617
|
const delta = previewContentHeight() - previousContentHeight
|
|
500
618
|
if (delta > 0) previewScroll?.scrollBy(delta)
|
|
619
|
+
updatePreviewWindow()
|
|
501
620
|
debug.log("preview:load-before:adjust", {
|
|
502
621
|
delta,
|
|
503
622
|
previousContentHeight,
|
|
@@ -509,6 +628,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
509
628
|
}, 1)
|
|
510
629
|
} else {
|
|
511
630
|
setTimeout(() => {
|
|
631
|
+
updatePreviewWindow()
|
|
512
632
|
debug.log("preview:load-before:no-adjust", {
|
|
513
633
|
previousContentHeight,
|
|
514
634
|
newContentHeight: previewContentHeight(),
|
|
@@ -555,7 +675,10 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
555
675
|
first: page.parts[0]?.id,
|
|
556
676
|
last: page.parts.at(-1)?.id,
|
|
557
677
|
})
|
|
558
|
-
if (page.parts.length > 0)
|
|
678
|
+
if (page.parts.length > 0) {
|
|
679
|
+
setPreviewParts((prev) => [...prev, ...page.parts])
|
|
680
|
+
setTimeout(updatePreviewWindow, 1)
|
|
681
|
+
}
|
|
559
682
|
setHasMorePreviewAfter(page.hasMoreAfter)
|
|
560
683
|
} catch (err) {
|
|
561
684
|
debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
|
|
@@ -631,12 +754,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
631
754
|
}, 1)
|
|
632
755
|
}
|
|
633
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
|
+
|
|
634
774
|
let lastPreviewItemId = ""
|
|
635
775
|
createEffect(() => {
|
|
636
776
|
const item = selectedResult()
|
|
637
777
|
if (!item) {
|
|
638
778
|
cancelPreviewPrefetch()
|
|
639
779
|
setPreviewParts([])
|
|
780
|
+
previewMeasuredHeights.clear()
|
|
781
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
782
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
640
783
|
setHasMorePreviewBefore(false)
|
|
641
784
|
setHasMorePreviewAfter(false)
|
|
642
785
|
return
|
|
@@ -644,6 +787,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
644
787
|
if (item.id === lastPreviewItemId) return
|
|
645
788
|
lastPreviewItemId = item.id
|
|
646
789
|
cancelPreviewPrefetch()
|
|
790
|
+
previewMeasuredHeights.clear()
|
|
791
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
792
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
647
793
|
debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
|
|
648
794
|
const db = dbPath()
|
|
649
795
|
debug.time("preview:load")
|
|
@@ -661,6 +807,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
661
807
|
setPreviewParts(page.parts)
|
|
662
808
|
setHasMorePreviewBefore(page.hasMoreBefore)
|
|
663
809
|
setHasMorePreviewAfter(page.hasMoreAfter)
|
|
810
|
+
setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
|
|
664
811
|
} catch {}
|
|
665
812
|
debug.timeEnd("nav:total")
|
|
666
813
|
debug.timeEnd("preview:load")
|
|
@@ -674,7 +821,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
674
821
|
const scroll = previewScroll
|
|
675
822
|
const children = scroll?.getChildren()
|
|
676
823
|
if (!scroll || !children || children.length === 0) return
|
|
677
|
-
const totalContentHeight =
|
|
824
|
+
const totalContentHeight = previewContentHeight()
|
|
678
825
|
const atTop = scroll.scrollTop <= 0
|
|
679
826
|
const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
|
|
680
827
|
const nearTop = scroll.scrollTop <= prefetchDistance
|
|
@@ -708,11 +855,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
708
855
|
let scrolledItem = ""
|
|
709
856
|
createEffect(() => {
|
|
710
857
|
const item = selectedResult()
|
|
711
|
-
|
|
858
|
+
previewVirtualLayout()
|
|
712
859
|
if (!item) return
|
|
713
860
|
if (item.id === scrolledItem) return
|
|
714
861
|
scrolledItem = item.id
|
|
715
|
-
const timer = setTimeout(() =>
|
|
862
|
+
const timer = setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
|
|
716
863
|
onCleanup(() => clearTimeout(timer))
|
|
717
864
|
})
|
|
718
865
|
|
|
@@ -742,7 +889,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
742
889
|
return
|
|
743
890
|
}
|
|
744
891
|
|
|
745
|
-
const totalContentHeight =
|
|
892
|
+
const totalContentHeight = previewContentHeight()
|
|
746
893
|
if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
|
|
747
894
|
debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
|
|
748
895
|
schedulePreviewAfter(true)
|
|
@@ -751,6 +898,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
751
898
|
|
|
752
899
|
const amount = direction * previewScrollAmount(scroll)
|
|
753
900
|
scroll.scrollBy(amount)
|
|
901
|
+
updatePreviewWindow()
|
|
754
902
|
debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
|
|
755
903
|
}
|
|
756
904
|
|
|
@@ -919,7 +1067,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
919
1067
|
<PreviewHeader item={selectedResult()} query={query()} theme={theme()} />
|
|
920
1068
|
<scrollbox ref={(element: ScrollBoxRenderable) => (previewScroll = element)} flexGrow={1} minHeight={0} paddingLeft={1} paddingRight={1} verticalScrollbarOptions={{ visible: true }}>
|
|
921
1069
|
<Show when={selectedResult()} fallback={<text fg={theme().textMuted}>Select a hit to preview the exact matched message.</text>}>
|
|
922
|
-
{(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
|
+
)}
|
|
923
1077
|
</Show>
|
|
924
1078
|
</scrollbox>
|
|
925
1079
|
</box>
|
package/ui/render-target.ts
CHANGED
|
@@ -59,9 +59,10 @@ export function jumpToRenderedTarget(root: unknown, targetID: string) {
|
|
|
59
59
|
setTimeout(tick, 50)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
type RenderNode = {
|
|
62
|
+
export type RenderNode = {
|
|
63
63
|
id?: string
|
|
64
64
|
y: number
|
|
65
|
+
height?: number
|
|
65
66
|
getChildren(): unknown[]
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -94,7 +95,7 @@ function isScrollNode(value: RenderNode): value is ScrollNode {
|
|
|
94
95
|
return "scrollBy" in value && typeof value.scrollBy === "function"
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
|
|
98
|
+
export function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
|
|
98
99
|
if (!isRenderNode(node)) return
|
|
99
100
|
if (node.id === targetID) return node
|
|
100
101
|
for (const child of node.getChildren()) {
|