@bojackduy/opencode-telescope 0.1.9 → 0.1.12
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/README.md +8 -8
- package/assets/demo.png +0 -0
- package/package.json +7 -7
- package/search.ts +255 -93
- package/telescope.tsx +13 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Fuzzy search across all your OpenCode conversations — grep through session and
|
|
|
4
4
|
|
|
5
5
|
> Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) — a fuzzy finder for your conversation history.
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
9
|
## Use cases
|
|
10
10
|
|
|
@@ -38,13 +38,13 @@ Add the plugin to your `tui.json`:
|
|
|
38
38
|
|
|
39
39
|
## Usage
|
|
40
40
|
|
|
41
|
-
| Action
|
|
42
|
-
|
|
43
|
-
| Open search
|
|
44
|
-
| Type to filter
|
|
45
|
-
| Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k`
|
|
46
|
-
| Preview
|
|
47
|
-
| Open
|
|
41
|
+
| Action | Key / Command |
|
|
42
|
+
| ---------------- | ----------------------------------------------- |
|
|
43
|
+
| Open search | `<leader>f` or `/telescope` |
|
|
44
|
+
| Type to filter | Fuzzy match against conversation text |
|
|
45
|
+
| Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k` |
|
|
46
|
+
| Preview | Select a result to see the conversation preview |
|
|
47
|
+
| Open | Press `Enter` to jump to the selected session |
|
|
48
48
|
|
|
49
49
|
## How it works
|
|
50
50
|
|
package/assets/demo.png
ADDED
|
Binary file
|
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.12",
|
|
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",
|
|
@@ -63,11 +63,11 @@
|
|
|
63
63
|
"solid-js": "*"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@opencode-ai/plugin": "^1.
|
|
67
|
-
"@opentui/core": "^0.1
|
|
68
|
-
"@opentui/solid": "^0.1
|
|
69
|
-
"@types/bun": "^1.3.
|
|
70
|
-
"solid-js": "^1.9.
|
|
71
|
-
"typescript": "^
|
|
66
|
+
"@opencode-ai/plugin": "^1.17.9",
|
|
67
|
+
"@opentui/core": "^0.4.1",
|
|
68
|
+
"@opentui/solid": "^0.4.1",
|
|
69
|
+
"@types/bun": "^1.3.14",
|
|
70
|
+
"solid-js": "^1.9.13",
|
|
71
|
+
"typescript": "^6.0.3"
|
|
72
72
|
}
|
|
73
73
|
}
|
package/search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite"
|
|
2
|
-
import { existsSync, readdirSync } from "node:fs"
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
5
5
|
import { debug } from "./ui/debug.ts"
|
|
@@ -69,6 +69,21 @@ type ConversationRow = {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
let cachedDbPath: string | undefined
|
|
72
|
+
let _db: Database | undefined
|
|
73
|
+
let _dbPath: string | undefined
|
|
74
|
+
let _indexDb: Database | undefined
|
|
75
|
+
let _indexDbPath: string | undefined
|
|
76
|
+
|
|
77
|
+
function getDb(dbPath?: string): Database {
|
|
78
|
+
const resolved = dbPath ?? resolveDatabasePath()
|
|
79
|
+
if (!_db || resolved !== _dbPath) {
|
|
80
|
+
_db?.close()
|
|
81
|
+
_db = new Database(resolved, { readonly: true })
|
|
82
|
+
_dbPath = resolved
|
|
83
|
+
tableCache.clear()
|
|
84
|
+
}
|
|
85
|
+
return _db
|
|
86
|
+
}
|
|
72
87
|
|
|
73
88
|
export function resolveDatabasePath() {
|
|
74
89
|
if (cachedDbPath) return cachedDbPath
|
|
@@ -105,90 +120,79 @@ export function searchSessionMessages(query: string, options?: { limit?: number;
|
|
|
105
120
|
const term = query.trim()
|
|
106
121
|
if (!term) return []
|
|
107
122
|
if (options?.dbPath === ":memory:") return []
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
} finally {
|
|
112
|
-
db.close()
|
|
113
|
-
}
|
|
123
|
+
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
124
|
+
const db = getDb(dbPath)
|
|
125
|
+
return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset)
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
|
|
117
|
-
const db =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
} finally {
|
|
123
|
-
db.close()
|
|
124
|
-
}
|
|
129
|
+
const db = getDb(options?.dbPath)
|
|
130
|
+
return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
|
|
131
|
+
(row) => rowToSearchResult(row, "") ?? [],
|
|
132
|
+
)
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
|
|
128
136
|
debug.time("preview:query:total")
|
|
129
|
-
const db =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (!hit) {
|
|
140
|
-
debug.timeEnd("preview:query:total")
|
|
141
|
-
return []
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const fetchBefore = Math.max(before * 4, 30)
|
|
145
|
-
const fetchAfter = Math.max(after * 4, 50)
|
|
146
|
-
|
|
147
|
-
debug.time("preview:query:before")
|
|
148
|
-
const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
149
|
-
SELECT p.id, p.message_id, p.session_id,
|
|
150
|
-
json_extract(m.data, '$.role') AS role,
|
|
151
|
-
json_extract(p.data, '$.type') AS type,
|
|
152
|
-
p.time_created, p.data
|
|
153
|
-
FROM part p
|
|
154
|
-
JOIN message m ON m.id = p.message_id
|
|
155
|
-
WHERE p.session_id = ?
|
|
156
|
-
AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
|
|
157
|
-
ORDER BY p.time_created DESC, p.id DESC
|
|
158
|
-
LIMIT ?
|
|
159
|
-
`).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
|
|
160
|
-
debug.timeEnd("preview:query:before")
|
|
161
|
-
|
|
162
|
-
debug.time("preview:query:after")
|
|
163
|
-
const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
164
|
-
SELECT p.id, p.message_id, p.session_id,
|
|
165
|
-
json_extract(m.data, '$.role') AS role,
|
|
166
|
-
json_extract(p.data, '$.type') AS type,
|
|
167
|
-
p.time_created, p.data
|
|
168
|
-
FROM part p
|
|
169
|
-
JOIN message m ON m.id = p.message_id
|
|
170
|
-
WHERE p.session_id = ?
|
|
171
|
-
AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
|
|
172
|
-
ORDER BY p.time_created ASC, p.id ASC
|
|
173
|
-
LIMIT ?
|
|
174
|
-
`).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
|
|
175
|
-
debug.timeEnd("preview:query:after")
|
|
176
|
-
|
|
177
|
-
debug.time("preview:query:parse")
|
|
178
|
-
const isValid = (row: ConversationRow) =>
|
|
179
|
-
(row.role === "user" || row.role === "assistant") &&
|
|
180
|
-
(row.type === "text" || row.type === "reasoning" || row.type === "tool")
|
|
181
|
-
|
|
182
|
-
const parts = [
|
|
183
|
-
...beforeRows.filter(isValid).slice(0, before).reverse(),
|
|
184
|
-
...afterRows.filter(isValid).slice(0, after + 1),
|
|
185
|
-
].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
|
|
186
|
-
debug.timeEnd("preview:query:parse")
|
|
137
|
+
const db = getDb(options?.dbPath)
|
|
138
|
+
const before = options?.before ?? 3
|
|
139
|
+
const after = options?.after ?? 6
|
|
140
|
+
|
|
141
|
+
debug.time("preview:query:hit")
|
|
142
|
+
const hit = db.query<{ time_created: number }, [string]>(
|
|
143
|
+
"SELECT time_created FROM part WHERE id = ?",
|
|
144
|
+
).get(result.id)
|
|
145
|
+
debug.timeEnd("preview:query:hit")
|
|
146
|
+
if (!hit) {
|
|
187
147
|
debug.timeEnd("preview:query:total")
|
|
188
|
-
return
|
|
189
|
-
} finally {
|
|
190
|
-
db.close()
|
|
148
|
+
return []
|
|
191
149
|
}
|
|
150
|
+
|
|
151
|
+
const fetchBefore = Math.max(before * 4, 30)
|
|
152
|
+
const fetchAfter = Math.max(after * 4, 50)
|
|
153
|
+
|
|
154
|
+
debug.time("preview:query:before")
|
|
155
|
+
const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
156
|
+
SELECT p.id, p.message_id, p.session_id,
|
|
157
|
+
json_extract(m.data, '$.role') AS role,
|
|
158
|
+
json_extract(p.data, '$.type') AS type,
|
|
159
|
+
p.time_created, p.data
|
|
160
|
+
FROM part p
|
|
161
|
+
JOIN message m ON m.id = p.message_id
|
|
162
|
+
WHERE p.session_id = ?
|
|
163
|
+
AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
|
|
164
|
+
ORDER BY p.time_created DESC, p.id DESC
|
|
165
|
+
LIMIT ?
|
|
166
|
+
`).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
|
|
167
|
+
debug.timeEnd("preview:query:before")
|
|
168
|
+
|
|
169
|
+
debug.time("preview:query:after")
|
|
170
|
+
const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
171
|
+
SELECT p.id, p.message_id, p.session_id,
|
|
172
|
+
json_extract(m.data, '$.role') AS role,
|
|
173
|
+
json_extract(p.data, '$.type') AS type,
|
|
174
|
+
p.time_created, p.data
|
|
175
|
+
FROM part p
|
|
176
|
+
JOIN message m ON m.id = p.message_id
|
|
177
|
+
WHERE p.session_id = ?
|
|
178
|
+
AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
|
|
179
|
+
ORDER BY p.time_created ASC, p.id ASC
|
|
180
|
+
LIMIT ?
|
|
181
|
+
`).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
|
|
182
|
+
debug.timeEnd("preview:query:after")
|
|
183
|
+
|
|
184
|
+
debug.time("preview:query:parse")
|
|
185
|
+
const isValid = (row: ConversationRow) =>
|
|
186
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
187
|
+
(row.type === "text" || row.type === "reasoning" || row.type === "tool")
|
|
188
|
+
|
|
189
|
+
const parts = [
|
|
190
|
+
...beforeRows.filter(isValid).slice(0, before).reverse(),
|
|
191
|
+
...afterRows.filter(isValid).slice(0, after + 1),
|
|
192
|
+
].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
|
|
193
|
+
debug.timeEnd("preview:query:parse")
|
|
194
|
+
debug.timeEnd("preview:query:total")
|
|
195
|
+
return parts
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
|
|
@@ -417,9 +421,46 @@ function parseToolState(value: unknown): ToolState | undefined {
|
|
|
417
421
|
}
|
|
418
422
|
}
|
|
419
423
|
|
|
420
|
-
function searchRows(db: Database, query: string, limit: number, directory?: string, offset?: number) {
|
|
424
|
+
function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number) {
|
|
421
425
|
if (!tableExists(db, "part") || !tableExists(db, "message")) return []
|
|
422
|
-
|
|
426
|
+
debug.time("query:sql")
|
|
427
|
+
const rows = indexedTextRows(db, dbPath, limit, query, directory, offset) ?? visibleTextRows(db, limit, query, directory, offset)
|
|
428
|
+
debug.timeEnd("query:sql")
|
|
429
|
+
debug.time("query:map")
|
|
430
|
+
const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
|
|
431
|
+
debug.timeEnd("query:map")
|
|
432
|
+
return results
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number) {
|
|
436
|
+
const match = ftsQuery(query)
|
|
437
|
+
if (!match) return []
|
|
438
|
+
try {
|
|
439
|
+
const index = ensureSearchIndex(db, dbPath)
|
|
440
|
+
const conditions = ["document_fts MATCH ?"]
|
|
441
|
+
const params: (string | number)[] = [match]
|
|
442
|
+
if (directory) {
|
|
443
|
+
conditions.push("directory = ?")
|
|
444
|
+
params.push(directory)
|
|
445
|
+
}
|
|
446
|
+
params.push(limit)
|
|
447
|
+
if (offset) params.push(offset)
|
|
448
|
+
const offsetClause = offset ? "OFFSET ?" : ""
|
|
449
|
+
debug.time("query:fts:exec")
|
|
450
|
+
const rows = index.query<Row, (string | number)[]>(`
|
|
451
|
+
SELECT id, message_id, session_id, session_title, directory, role,
|
|
452
|
+
CAST(time_created AS INTEGER) AS time_created, text
|
|
453
|
+
FROM document_fts
|
|
454
|
+
WHERE ${conditions.join(" AND ")}
|
|
455
|
+
ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
|
|
456
|
+
LIMIT ? ${offsetClause}
|
|
457
|
+
`).all(...params as any[])
|
|
458
|
+
debug.timeEnd("query:fts:exec")
|
|
459
|
+
return rows
|
|
460
|
+
} catch (err) {
|
|
461
|
+
debug.log("fts:fallback", err instanceof Error ? err.message : String(err))
|
|
462
|
+
return
|
|
463
|
+
}
|
|
423
464
|
}
|
|
424
465
|
|
|
425
466
|
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
|
|
@@ -444,24 +485,145 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
|
|
|
444
485
|
params.push(limit)
|
|
445
486
|
if (offset) params.push(offset)
|
|
446
487
|
|
|
447
|
-
|
|
448
|
-
.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
488
|
+
const sql = `
|
|
489
|
+
SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
|
|
490
|
+
json_extract(m.data, '$.role') AS role,
|
|
491
|
+
p.time_created,
|
|
492
|
+
json_extract(p.data, '$.text') AS text
|
|
493
|
+
FROM part p
|
|
494
|
+
JOIN message m ON m.id = p.message_id
|
|
495
|
+
JOIN session s ON s.id = p.session_id
|
|
496
|
+
WHERE ${conditions.join(" AND ")}
|
|
497
|
+
ORDER BY p.time_created DESC
|
|
498
|
+
LIMIT ? ${offsetClause}
|
|
499
|
+
`
|
|
500
|
+
debug.time("query:sql:exec")
|
|
501
|
+
const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
|
|
502
|
+
debug.timeEnd("query:sql:exec")
|
|
503
|
+
return rows
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function ensureSearchIndex(source: Database, sourcePath: string) {
|
|
507
|
+
const indexPath = searchIndexPath(sourcePath)
|
|
508
|
+
if (!_indexDb || _indexDbPath !== indexPath) {
|
|
509
|
+
_indexDb?.close()
|
|
510
|
+
_indexDb = new Database(indexPath)
|
|
511
|
+
_indexDbPath = indexPath
|
|
512
|
+
migrateSearchIndex(_indexDb)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const state = sourceState(source, sourcePath)
|
|
516
|
+
const currentDataVersion = getMeta(_indexDb, "source_data_version")
|
|
517
|
+
const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
|
|
518
|
+
const currentPath = getMeta(_indexDb, "source_path")
|
|
519
|
+
if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs)) {
|
|
520
|
+
rebuildSearchIndex(source, _indexDb, sourcePath, state)
|
|
521
|
+
}
|
|
522
|
+
return _indexDb
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function searchIndexPath(sourcePath: string) {
|
|
526
|
+
const parsed = path.parse(sourcePath)
|
|
527
|
+
return path.join(parsed.dir, `${parsed.name}-search.db`)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function migrateSearchIndex(db: Database) {
|
|
531
|
+
db.exec(`
|
|
532
|
+
CREATE TABLE IF NOT EXISTS index_meta(
|
|
533
|
+
key TEXT PRIMARY KEY,
|
|
534
|
+
value TEXT NOT NULL
|
|
535
|
+
);
|
|
536
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS document_fts USING fts5(
|
|
537
|
+
id UNINDEXED,
|
|
538
|
+
message_id UNINDEXED,
|
|
539
|
+
session_id UNINDEXED,
|
|
540
|
+
session_title,
|
|
541
|
+
directory UNINDEXED,
|
|
542
|
+
role UNINDEXED,
|
|
543
|
+
time_created UNINDEXED,
|
|
544
|
+
text,
|
|
545
|
+
tokenize='unicode61'
|
|
546
|
+
);
|
|
547
|
+
`)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function sourceState(db: Database, sourcePath: string) {
|
|
551
|
+
const stat = statSync(sourcePath)
|
|
552
|
+
const dataVersion = db.query<{ data_version: number }, []>("PRAGMA data_version").get()?.data_version ?? 0
|
|
553
|
+
return { dataVersion, mtimeMs: stat.mtimeMs }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
|
|
557
|
+
debug.time("fts:rebuild")
|
|
558
|
+
const rows = source.query<Row, []>(`
|
|
559
|
+
SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
|
|
560
|
+
json_extract(m.data, '$.role') AS role,
|
|
561
|
+
p.time_created,
|
|
562
|
+
json_extract(p.data, '$.text') AS text
|
|
563
|
+
FROM part p
|
|
564
|
+
JOIN message m ON m.id = p.message_id
|
|
565
|
+
JOIN session s ON s.id = p.session_id
|
|
566
|
+
WHERE json_extract(p.data, '$.type') = 'text'
|
|
567
|
+
AND json_extract(m.data, '$.role') IN ('user', 'assistant')
|
|
568
|
+
AND json_extract(p.data, '$.text') IS NOT NULL
|
|
569
|
+
ORDER BY p.time_created DESC
|
|
570
|
+
`).all()
|
|
571
|
+
const insert = index.query<Row, [string, string, string, string, string, string, number, string]>(`
|
|
572
|
+
INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, time_created, text)
|
|
573
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
574
|
+
`)
|
|
575
|
+
index.exec("BEGIN IMMEDIATE")
|
|
576
|
+
try {
|
|
577
|
+
index.exec("DELETE FROM document_fts")
|
|
578
|
+
for (const row of rows) {
|
|
579
|
+
insert.run(
|
|
580
|
+
row.id,
|
|
581
|
+
row.message_id,
|
|
582
|
+
row.session_id,
|
|
583
|
+
row.session_title ?? "Untitled session",
|
|
584
|
+
row.directory,
|
|
585
|
+
row.role,
|
|
586
|
+
row.time_created,
|
|
587
|
+
row.text,
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
setMeta(index, "source_path", sourcePath)
|
|
591
|
+
setMeta(index, "source_data_version", String(state.dataVersion))
|
|
592
|
+
setMeta(index, "source_mtime_ms", String(state.mtimeMs))
|
|
593
|
+
index.exec("COMMIT")
|
|
594
|
+
} catch (err) {
|
|
595
|
+
index.exec("ROLLBACK")
|
|
596
|
+
throw err
|
|
597
|
+
} finally {
|
|
598
|
+
debug.timeEnd("fts:rebuild")
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function ftsQuery(query: string) {
|
|
603
|
+
const tokens = query.trim().split(/\s+/)
|
|
604
|
+
.map((token) => token.replace(/["*^:()]/g, " ").trim())
|
|
605
|
+
.filter(Boolean)
|
|
606
|
+
if (!tokens.length) return ""
|
|
607
|
+
return tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" AND ")
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function getMeta(db: Database, key: string) {
|
|
611
|
+
return db.query<{ value: string }, [string]>("SELECT value FROM index_meta WHERE key = ?").get(key)?.value
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function setMeta(db: Database, key: string, value: string) {
|
|
615
|
+
db.query<unknown, [string, string]>(`
|
|
616
|
+
INSERT INTO index_meta(key, value) VALUES (?, ?)
|
|
617
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
618
|
+
`).run(key, value)
|
|
461
619
|
}
|
|
462
620
|
|
|
621
|
+
const tableCache = new Map<string, boolean>()
|
|
463
622
|
function tableExists(db: Database, name: string) {
|
|
464
|
-
|
|
623
|
+
if (tableCache.has(name)) return tableCache.get(name)!
|
|
624
|
+
const exists = Boolean(db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
|
|
625
|
+
tableCache.set(name, exists)
|
|
626
|
+
return exists
|
|
465
627
|
}
|
|
466
628
|
|
|
467
629
|
function defaultDataDir() {
|
package/telescope.tsx
CHANGED
|
@@ -100,8 +100,15 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
100
100
|
|
|
101
101
|
const move = (delta: number) => {
|
|
102
102
|
if (results().length === 0) return
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
setSelected((index) => {
|
|
104
|
+
const next = index + delta
|
|
105
|
+
let finalIndex = next
|
|
106
|
+
if (next < 0) finalIndex = results().length - 1
|
|
107
|
+
else if (next >= results().length) finalIndex = results().length - 1
|
|
108
|
+
|
|
109
|
+
if (finalIndex !== index) debug.time("nav:total")
|
|
110
|
+
return finalIndex
|
|
111
|
+
})
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
createEffect(() => {
|
|
@@ -144,7 +151,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
144
151
|
setLoadingMore(false)
|
|
145
152
|
}
|
|
146
153
|
}, 100)
|
|
147
|
-
onCleanup(() => clearTimeout(timer))
|
|
154
|
+
onCleanup(() => { clearTimeout(timer); setLoadingMore(false) })
|
|
148
155
|
})
|
|
149
156
|
|
|
150
157
|
let lastPreviewItemId = ""
|
|
@@ -188,10 +195,13 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
188
195
|
onCleanup(() => clearInterval(interval))
|
|
189
196
|
})
|
|
190
197
|
|
|
198
|
+
let scrolledItem = ""
|
|
191
199
|
createEffect(() => {
|
|
192
200
|
const item = selectedResult()
|
|
193
201
|
previewParts()
|
|
194
202
|
if (!item) return
|
|
203
|
+
if (item.id === scrolledItem) return
|
|
204
|
+
scrolledItem = item.id
|
|
195
205
|
const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
|
|
196
206
|
onCleanup(() => clearTimeout(timer))
|
|
197
207
|
})
|