termcast 1.3.54 → 1.4.1
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/dist/action-utils.d.ts.map +1 -1
- package/dist/action-utils.js +17 -132
- package/dist/action-utils.js.map +1 -1
- package/dist/apis/cache.d.ts +8 -30
- package/dist/apis/cache.d.ts.map +1 -1
- package/dist/apis/cache.js +9 -271
- package/dist/apis/cache.js.map +1 -1
- package/dist/apis/clipboard.d.ts +4 -2
- package/dist/apis/clipboard.d.ts.map +1 -1
- package/dist/apis/clipboard.js +18 -31
- package/dist/apis/clipboard.js.map +1 -1
- package/dist/apis/environment.d.ts.map +1 -1
- package/dist/apis/environment.js +14 -49
- package/dist/apis/environment.js.map +1 -1
- package/dist/apis/localstorage.d.ts +7 -12
- package/dist/apis/localstorage.d.ts.map +1 -1
- package/dist/apis/localstorage.js +7 -184
- package/dist/apis/localstorage.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +16 -15
- package/dist/app.js.map +1 -1
- package/dist/cli.js +7 -6
- package/dist/cli.js.map +1 -1
- package/dist/components/actions.d.ts.map +1 -1
- package/dist/components/actions.js +13 -2
- package/dist/components/actions.js.map +1 -1
- package/dist/components/extension-preferences.d.ts.map +1 -1
- package/dist/components/extension-preferences.js +7 -8
- package/dist/components/extension-preferences.js.map +1 -1
- package/dist/components/form/file-autocomplete.js +2 -2
- package/dist/components/form/file-autocomplete.js.map +1 -1
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +242 -14
- package/dist/components/list.js.map +1 -1
- package/dist/e2e-node.d.ts.map +1 -1
- package/dist/e2e-node.js +5 -4
- package/dist/e2e-node.js.map +1 -1
- package/dist/extensions/dev.d.ts.map +1 -1
- package/dist/extensions/dev.js +5 -2
- package/dist/extensions/dev.js.map +1 -1
- package/dist/globals.d.ts.map +1 -1
- package/dist/globals.js +2 -1
- package/dist/globals.js.map +1 -1
- package/dist/internal/error-handler.d.ts.map +1 -1
- package/dist/internal/error-handler.js +21 -19
- package/dist/internal/error-handler.js.map +1 -1
- package/dist/internal/providers.d.ts.map +1 -1
- package/dist/internal/providers.js +41 -1
- package/dist/internal/providers.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +40 -29
- package/dist/logger.js.map +1 -1
- package/dist/platform/browser/cache.d.ts +41 -0
- package/dist/platform/browser/cache.d.ts.map +1 -0
- package/dist/platform/browser/cache.js +262 -0
- package/dist/platform/browser/cache.js.map +1 -0
- package/dist/platform/browser/localstorage.d.ts +20 -0
- package/dist/platform/browser/localstorage.d.ts.map +1 -0
- package/dist/platform/browser/localstorage.js +102 -0
- package/dist/platform/browser/localstorage.js.map +1 -0
- package/dist/platform/browser/runtime.d.ts +51 -0
- package/dist/platform/browser/runtime.d.ts.map +1 -0
- package/dist/platform/browser/runtime.js +164 -0
- package/dist/platform/browser/runtime.js.map +1 -0
- package/dist/platform/bun/sqlite.d.ts +17 -0
- package/dist/platform/bun/sqlite.d.ts.map +1 -0
- package/dist/platform/bun/sqlite.js +6 -0
- package/dist/platform/bun/sqlite.js.map +1 -0
- package/dist/platform/node/cache.d.ts +35 -0
- package/dist/platform/node/cache.d.ts.map +1 -0
- package/dist/platform/node/cache.js +269 -0
- package/dist/platform/node/cache.js.map +1 -0
- package/dist/platform/node/localstorage.d.ts +17 -0
- package/dist/platform/node/localstorage.d.ts.map +1 -0
- package/dist/platform/node/localstorage.js +186 -0
- package/dist/platform/node/localstorage.js.map +1 -0
- package/dist/platform/node/runtime.d.ts +52 -0
- package/dist/platform/node/runtime.d.ts.map +1 -0
- package/dist/platform/node/runtime.js +230 -0
- package/dist/platform/node/runtime.js.map +1 -0
- package/dist/platform/node/sqlite.d.ts +27 -0
- package/dist/platform/node/sqlite.d.ts.map +1 -0
- package/dist/platform/node/sqlite.js +21 -0
- package/dist/platform/node/sqlite.js.map +1 -0
- package/dist/state.d.ts +5 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +6 -28
- package/dist/state.js.map +1 -1
- package/dist/utils/file-system.d.ts.map +1 -1
- package/dist/utils/file-system.js +17 -22
- package/dist/utils/file-system.js.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +42 -47
- package/dist/utils.js.map +1 -1
- package/dist/vim-mode.d.ts +40 -0
- package/dist/vim-mode.d.ts.map +1 -0
- package/dist/vim-mode.js +135 -0
- package/dist/vim-mode.js.map +1 -0
- package/fonts/Inconsolata.otf +0 -0
- package/fonts/SIL Open Font License.txt +41 -0
- package/package.json +62 -10
- package/src/action-utils.tsx +27 -124
- package/src/apis/cache.test.ts +1 -1
- package/src/apis/cache.tsx +9 -373
- package/src/apis/clipboard.tsx +29 -38
- package/src/apis/environment.tsx +25 -52
- package/src/apis/localstorage.tsx +8 -214
- package/src/app.tsx +16 -15
- package/src/cli.tsx +14 -15
- package/src/compile.vitest.tsx +2 -2
- package/src/components/actions.tsx +19 -1
- package/src/components/extension-preferences.tsx +7 -8
- package/src/components/form/file-autocomplete.tsx +2 -2
- package/src/components/list.tsx +279 -14
- package/src/e2e-node.tsx +7 -7
- package/src/examples/action-shortcut.vitest.tsx +2 -2
- package/src/examples/actions-context.vitest.tsx +1 -1
- package/src/examples/bar-graph-weekly.vitest.tsx +10 -36
- package/src/examples/detail-metadata-showcase.vitest.tsx +37 -42
- package/src/examples/form-basic.vitest.tsx +45 -41
- package/src/examples/github.vitest.tsx +4 -4
- package/src/examples/graph-bar-chart.vitest.tsx +13 -11
- package/src/examples/graph-polymarket.vitest.tsx +2 -2
- package/src/examples/graph-row.vitest.tsx +66 -66
- package/src/examples/graph-styles.vitest.tsx +12 -12
- package/src/examples/internal/simple-scrollbox.vitest.tsx +14 -48
- package/src/examples/list-detail-metadata.vitest.tsx +5 -5
- package/src/examples/list-fetch-data.vitest.tsx +3 -3
- package/src/examples/list-item-accessories.vitest.tsx +2 -2
- package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
- package/src/examples/list-no-actions.vitest.tsx +2 -2
- package/src/examples/list-scrollbox.vitest.tsx +5 -5
- package/src/examples/list-spacing-mode.vitest.tsx +3 -3
- package/src/examples/list-with-detail.vitest.tsx +68 -68
- package/src/examples/list-with-dropdown.vitest.tsx +5 -5
- package/src/examples/list-with-sections.vitest.tsx +27 -27
- package/src/examples/simple-candle-chart.vitest.tsx +7 -7
- package/src/examples/simple-detail-markdown.vitest.tsx +8 -8
- package/src/examples/simple-detail-table.vitest.tsx +8 -8
- package/src/examples/simple-graph.vitest.tsx +3 -3
- package/src/examples/simple-grid.vitest.tsx +14 -14
- package/src/examples/simple-heatmap.vitest.tsx +10 -10
- package/src/examples/simple-navigation.vitest.tsx +17 -17
- package/src/examples/simple-progress-bar.vitest.tsx +1 -1
- package/src/examples/store.vitest.tsx +1 -1
- package/src/examples/swift-extension.vitest.tsx +2 -2
- package/src/examples/table-edge-cases.vitest.tsx +18 -18
- package/src/examples/toast-action.vitest.tsx +2 -2
- package/src/examples/toast-variations.vitest.tsx +5 -5
- package/src/extensions/dev.tsx +5 -2
- package/src/extensions/dev.vitest.tsx +3 -3
- package/src/globals.ts +2 -1
- package/src/internal/error-handler.tsx +19 -21
- package/src/internal/providers.tsx +39 -0
- package/src/logger.tsx +48 -41
- package/src/platform/browser/cache.ts +327 -0
- package/src/platform/browser/localstorage.ts +119 -0
- package/src/platform/browser/runtime.ts +209 -0
- package/src/platform/bun/sqlite.ts +19 -0
- package/src/platform/node/cache.ts +372 -0
- package/src/platform/node/localstorage.ts +214 -0
- package/src/platform/node/runtime.ts +264 -0
- package/src/platform/node/sqlite.ts +43 -0
- package/src/state.tsx +17 -28
- package/src/utils/file-system.ts +17 -22
- package/src/utils.test.tsx +1 -1
- package/src/utils.tsx +56 -47
- package/src/vim-mode.tsx +153 -0
- package/src/apis/sqlite.ts +0 -14
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser platform runtime.
|
|
3
|
+
*
|
|
4
|
+
* Provides the same exports as the Node runtime but backed by browser APIs
|
|
5
|
+
* or sensible no-ops for features that don't exist in the browser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── filesystem (no-ops / stubs) ─────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export function ensureDir(_dir: string): void {
|
|
11
|
+
// no-op: browser storage (IndexedDB) doesn't need directory creation
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function fileExists(_p: string): boolean {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readFileSync(_p: string): string {
|
|
19
|
+
throw new Error('readFileSync is not available in the browser')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function appendToFile(_p: string, _data: string): void {
|
|
23
|
+
// no-op: browser logger uses console only
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function unlinkIfExists(_p: string): void {
|
|
27
|
+
// no-op
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readdirSync(_dir: string): Array<{ name: string; isDirectory(): boolean }> {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function rmSync(_p: string): void {
|
|
35
|
+
// no-op
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mkdirSync(_dir: string): void {
|
|
39
|
+
// no-op
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cpSync(_src: string, _dest: string): void {
|
|
43
|
+
throw new Error('cpSync is not available in the browser')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── async filesystem ────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export async function readdirAsync(_dir: string): Promise<Array<{ name: string; isDirectory(): boolean }>> {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function accessAsync(_p: string): Promise<boolean> {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── path (pure string ops) ──────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export function joinPath(...parts: string[]): string {
|
|
59
|
+
return parts
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join('/')
|
|
62
|
+
.replace(/\/+/g, '/')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function dirname(p: string): string {
|
|
66
|
+
const parts = p.split('/')
|
|
67
|
+
parts.pop()
|
|
68
|
+
return parts.join('/') || '/'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function basename(p: string): string {
|
|
72
|
+
return p.split('/').pop() || ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolvePath(...parts: string[]): string {
|
|
76
|
+
return joinPath(...parts)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isAbsolute(p: string): boolean {
|
|
80
|
+
return p.startsWith('/')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function relativePath(from: string, to: string): string {
|
|
84
|
+
const fromParts = from.split('/').filter(Boolean)
|
|
85
|
+
const toParts = to.split('/').filter(Boolean)
|
|
86
|
+
let common = 0
|
|
87
|
+
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
|
|
88
|
+
common++
|
|
89
|
+
}
|
|
90
|
+
const ups = fromParts.length - common
|
|
91
|
+
return [...Array(ups).fill('..'), ...toParts.slice(common)].join('/')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── os ──────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function homedir(): string {
|
|
97
|
+
return '/home'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── process ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export const platform = 'browser'
|
|
103
|
+
|
|
104
|
+
export function exit(_code?: number): void {
|
|
105
|
+
// no-op: can't exit the browser
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getEnv(key: string): string | undefined {
|
|
109
|
+
// Browser env can be populated by the host app before boot
|
|
110
|
+
return (globalThis as any).__termcast_env?.[key]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function setEnv(key: string, value: string): void {
|
|
114
|
+
const g = globalThis as any
|
|
115
|
+
if (!g.__termcast_env) {
|
|
116
|
+
g.__termcast_env = {}
|
|
117
|
+
}
|
|
118
|
+
g.__termcast_env[key] = value
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function cwd(): string {
|
|
122
|
+
return '/'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function stdoutWrite(_data: string): void {
|
|
126
|
+
// no-op: browser renderer handles output
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isTTY(): boolean {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getArgv(): string[] {
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── appearance ──────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export function getSystemAppearance(): 'dark' | 'light' {
|
|
140
|
+
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
|
|
141
|
+
return 'dark'
|
|
142
|
+
}
|
|
143
|
+
return 'light'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── util ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export function byteLength(str: string): number {
|
|
149
|
+
return new TextEncoder().encode(str).length
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function inspectValue(val: unknown, _depth = 3): string {
|
|
153
|
+
if (typeof val === 'string') {
|
|
154
|
+
return val
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return JSON.stringify(val, null, 2)
|
|
158
|
+
} catch {
|
|
159
|
+
return String(val)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── error handling ──────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export function setupErrorHandlers(handler: (err: Error, type: string) => void): void {
|
|
166
|
+
window.addEventListener('error', (event) => {
|
|
167
|
+
handler(event.error ?? new Error(event.message), 'uncaughtException')
|
|
168
|
+
})
|
|
169
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
170
|
+
const err = event.reason instanceof Error
|
|
171
|
+
? event.reason
|
|
172
|
+
: new Error(String(event.reason))
|
|
173
|
+
handler(err, 'unhandledRejection')
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── shell / clipboard ───────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export function execWithInput(_command: string, _input: string): Promise<void> {
|
|
180
|
+
return Promise.reject(new Error('execWithInput is not available in the browser'))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function execCommand(_command: string): Promise<string> {
|
|
184
|
+
throw new Error('execCommand is not available in the browser')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function copyToClipboard(text: string): Promise<void> {
|
|
188
|
+
await navigator.clipboard.writeText(text)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function readClipboard(): Promise<string> {
|
|
192
|
+
return navigator.clipboard.readText()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function openUrl(url: string): Promise<void> {
|
|
196
|
+
window.open(url, '_blank')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function openFile(_target: string, _app?: string): Promise<void> {
|
|
200
|
+
throw new Error('openFile is not supported in the browser')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function showInFileManager(_filePath: string): Promise<void> {
|
|
204
|
+
throw new Error('showInFileManager is not supported in the browser')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function moveToTrash(_filePath: string): Promise<void> {
|
|
208
|
+
throw new Error('moveToTrash is not supported in the browser')
|
|
209
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite Database for Bun — re-exports the built-in bun:sqlite.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Database as BunDatabase } from 'bun:sqlite'
|
|
6
|
+
|
|
7
|
+
export const Database = BunDatabase
|
|
8
|
+
|
|
9
|
+
/** Instance type returned by `new Database(...)` */
|
|
10
|
+
export type DatabaseInstance = {
|
|
11
|
+
exec(sql: string): void
|
|
12
|
+
prepare(sql: string): {
|
|
13
|
+
get(...params: any[]): any
|
|
14
|
+
all(...params: any[]): any[]
|
|
15
|
+
run(...params: any[]): any
|
|
16
|
+
}
|
|
17
|
+
transaction<T>(fn: () => T): () => T
|
|
18
|
+
close(): void
|
|
19
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node/Bun Cache implementation backed by SQLite.
|
|
3
|
+
* Moved from apis/cache.tsx — this is the platform-specific storage layer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Database, type DatabaseInstance } from '#sqlite'
|
|
7
|
+
import { joinPath, dirname, homedir, ensureDir, byteLength } from '#platform/runtime'
|
|
8
|
+
import { logger } from '../../logger'
|
|
9
|
+
import { useStore } from '../../state'
|
|
10
|
+
|
|
11
|
+
const CACHE_TABLE_NAME = 'cache_entries'
|
|
12
|
+
const DEFAULT_NAMESPACE = '__default__'
|
|
13
|
+
const initializedDatabasePaths = new Set<string>()
|
|
14
|
+
let logicalTimestamp = Date.now()
|
|
15
|
+
|
|
16
|
+
function nextTimestamp(): number {
|
|
17
|
+
logicalTimestamp += 1
|
|
18
|
+
return logicalTimestamp
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getCurrentDatabasePath(): string {
|
|
22
|
+
const { extensionPath } = useStore.getState()
|
|
23
|
+
const dbSuffix = (globalThis as any).process?.env?.TERMCAST_DB_SUFFIX?.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
24
|
+
const dbFileName = dbSuffix ? `data-${dbSuffix}.db` : 'data.db'
|
|
25
|
+
|
|
26
|
+
if (extensionPath) {
|
|
27
|
+
return joinPath(extensionPath, '.termcast-bundle', dbFileName)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fallback for examples/tests that don't set extensionPath
|
|
31
|
+
return joinPath(homedir(), '.termcast', '.termcast-bundle', dbFileName)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getCurrentCacheDir(namespace?: string): string {
|
|
35
|
+
const { extensionPath } = useStore.getState()
|
|
36
|
+
|
|
37
|
+
const baseDir = extensionPath
|
|
38
|
+
? joinPath(extensionPath, '.termcast-bundle', 'cache')
|
|
39
|
+
: joinPath(homedir(), '.termcast', '.termcast-bundle', 'cache')
|
|
40
|
+
|
|
41
|
+
return namespace ? joinPath(baseDir, namespace) : baseDir
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getNamespace(namespace?: string): string {
|
|
45
|
+
return namespace || DEFAULT_NAMESPACE
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function initializeDatabaseOnce({ db, dbPath }: { db: DatabaseInstance; dbPath: string }): void {
|
|
49
|
+
if (initializedDatabasePaths.has(dbPath)) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
54
|
+
db.exec('PRAGMA wal_autocheckpoint = 1000')
|
|
55
|
+
db.exec('PRAGMA synchronous = NORMAL')
|
|
56
|
+
|
|
57
|
+
db.exec(`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS ${CACHE_TABLE_NAME} (
|
|
59
|
+
namespace TEXT NOT NULL,
|
|
60
|
+
key TEXT NOT NULL,
|
|
61
|
+
data TEXT NOT NULL,
|
|
62
|
+
size INTEGER NOT NULL,
|
|
63
|
+
last_accessed_at INTEGER NOT NULL,
|
|
64
|
+
updated_at INTEGER NOT NULL,
|
|
65
|
+
PRIMARY KEY(namespace, key)
|
|
66
|
+
)
|
|
67
|
+
`)
|
|
68
|
+
|
|
69
|
+
db.exec(`
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_${CACHE_TABLE_NAME}_namespace_lru
|
|
71
|
+
ON ${CACHE_TABLE_NAME}(namespace, last_accessed_at)
|
|
72
|
+
`)
|
|
73
|
+
|
|
74
|
+
cleanupLegacyCacheTables(db)
|
|
75
|
+
initializedDatabasePaths.add(dbPath)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cleanupLegacyCacheTables(db: DatabaseInstance): void {
|
|
79
|
+
const rows = db
|
|
80
|
+
.prepare(
|
|
81
|
+
`SELECT name FROM sqlite_master
|
|
82
|
+
WHERE type = 'table'
|
|
83
|
+
AND (name = 'cache' OR name LIKE 'cache_%')
|
|
84
|
+
AND name != ?`,
|
|
85
|
+
)
|
|
86
|
+
.all(CACHE_TABLE_NAME) as Array<{ name: string }>
|
|
87
|
+
|
|
88
|
+
if (rows.length === 0) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tx = db.transaction(() => {
|
|
93
|
+
const upsert = db.prepare(
|
|
94
|
+
`INSERT INTO ${CACHE_TABLE_NAME} (namespace, key, data, size, last_accessed_at, updated_at)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
96
|
+
ON CONFLICT(namespace, key)
|
|
97
|
+
DO UPDATE SET
|
|
98
|
+
data = excluded.data,
|
|
99
|
+
size = excluded.size,
|
|
100
|
+
last_accessed_at = excluded.last_accessed_at,
|
|
101
|
+
updated_at = excluded.updated_at`,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for (const { name } of rows) {
|
|
105
|
+
const namespace =
|
|
106
|
+
name === 'cache'
|
|
107
|
+
? DEFAULT_NAMESPACE
|
|
108
|
+
: name === 'cache_tanstack_query'
|
|
109
|
+
? 'tanstack-query'
|
|
110
|
+
: `legacy:${name.slice('cache_'.length)}`
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const values = db
|
|
114
|
+
.prepare(`SELECT key, data, size, rowid FROM ${name}`)
|
|
115
|
+
.all() as Array<{ key: string; data: string; size: number; rowid: number }>
|
|
116
|
+
|
|
117
|
+
values.forEach((entry) => {
|
|
118
|
+
const timestamp = entry.rowid
|
|
119
|
+
upsert.run(
|
|
120
|
+
namespace,
|
|
121
|
+
entry.key,
|
|
122
|
+
entry.data,
|
|
123
|
+
entry.size,
|
|
124
|
+
timestamp,
|
|
125
|
+
timestamp,
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
} catch {
|
|
129
|
+
// Ignore invalid legacy tables and continue cleanup.
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
db.exec(`DROP TABLE IF EXISTS ${name}`)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
tx()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class Cache {
|
|
140
|
+
static get STORAGE_DIRECTORY_NAME(): string {
|
|
141
|
+
const extensionPath = useStore.getState().extensionPath
|
|
142
|
+
return extensionPath ? 'cache' : '.termcast-cache'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
static get DEFAULT_CAPACITY(): number {
|
|
146
|
+
return 10 * 1024 * 1024 // 10 MB
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private db: DatabaseInstance
|
|
150
|
+
private capacity: number
|
|
151
|
+
private namespace: string
|
|
152
|
+
private subscribers: Cache.Subscriber[] = []
|
|
153
|
+
private currentSize: number = 0
|
|
154
|
+
|
|
155
|
+
constructor(options?: Cache.Options) {
|
|
156
|
+
const sqliteLoadStart = Date.now()
|
|
157
|
+
this.capacity = options?.capacity || Cache.DEFAULT_CAPACITY
|
|
158
|
+
this.namespace = getNamespace(options?.namespace)
|
|
159
|
+
|
|
160
|
+
const dbPath = getCurrentDatabasePath()
|
|
161
|
+
|
|
162
|
+
// Ensure parent directory exists
|
|
163
|
+
const dbDir = dirname(dbPath)
|
|
164
|
+
ensureDir(dbDir)
|
|
165
|
+
|
|
166
|
+
this.db = new Database(dbPath, {
|
|
167
|
+
create: true,
|
|
168
|
+
readwrite: true,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
initializeDatabaseOnce({ db: this.db, dbPath })
|
|
172
|
+
|
|
173
|
+
// Calculate initial size
|
|
174
|
+
const row = this.db
|
|
175
|
+
.prepare(
|
|
176
|
+
`SELECT COALESCE(SUM(size), 0) as total FROM ${CACHE_TABLE_NAME} WHERE namespace = ?`,
|
|
177
|
+
)
|
|
178
|
+
.get(this.namespace) as { total: number | null } | undefined
|
|
179
|
+
this.currentSize = row?.total || 0
|
|
180
|
+
|
|
181
|
+
const sqliteLoadMs = Date.now() - sqliteLoadStart
|
|
182
|
+
if (sqliteLoadMs > 500) {
|
|
183
|
+
logger.log(
|
|
184
|
+
`[perf] sqlite cache init took ${sqliteLoadMs}ms (namespace=${this.namespace})`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Bind all methods to this instance
|
|
189
|
+
this.get = this.get.bind(this)
|
|
190
|
+
this.has = this.has.bind(this)
|
|
191
|
+
this.set = this.set.bind(this)
|
|
192
|
+
this.remove = this.remove.bind(this)
|
|
193
|
+
this.clear = this.clear.bind(this)
|
|
194
|
+
this.subscribe = this.subscribe.bind(this)
|
|
195
|
+
this.maintainCapacity = this.maintainCapacity.bind(this)
|
|
196
|
+
this.notifySubscribers = this.notifySubscribers.bind(this)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get storageDirectory(): string {
|
|
200
|
+
return getCurrentCacheDir(this.namespace)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
get(key: string): string | undefined {
|
|
204
|
+
const now = nextTimestamp()
|
|
205
|
+
const row = this.db
|
|
206
|
+
.prepare(
|
|
207
|
+
`SELECT data, size FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`,
|
|
208
|
+
)
|
|
209
|
+
.get(this.namespace, key) as { data: string; size: number } | undefined
|
|
210
|
+
|
|
211
|
+
if (row) {
|
|
212
|
+
this.db
|
|
213
|
+
.prepare(
|
|
214
|
+
`UPDATE ${CACHE_TABLE_NAME} SET last_accessed_at = ? WHERE namespace = ? AND key = ?`,
|
|
215
|
+
)
|
|
216
|
+
.run(now, this.namespace, key)
|
|
217
|
+
|
|
218
|
+
return row.data
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
has(key: string): boolean {
|
|
225
|
+
const row = this.db
|
|
226
|
+
.prepare(`SELECT 1 FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`)
|
|
227
|
+
.get(this.namespace, key)
|
|
228
|
+
return !!row
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get isEmpty(): boolean {
|
|
232
|
+
const row = this.db
|
|
233
|
+
.prepare(
|
|
234
|
+
`SELECT COUNT(*) as count FROM ${CACHE_TABLE_NAME} WHERE namespace = ?`,
|
|
235
|
+
)
|
|
236
|
+
.get(this.namespace) as { count: number }
|
|
237
|
+
return row.count === 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
set(key: string, data: string): void {
|
|
241
|
+
const now = nextTimestamp()
|
|
242
|
+
const dataSize = byteLength(data)
|
|
243
|
+
|
|
244
|
+
// Get existing size if any
|
|
245
|
+
const existingRow = this.db
|
|
246
|
+
.prepare(
|
|
247
|
+
`SELECT size FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`,
|
|
248
|
+
)
|
|
249
|
+
.get(this.namespace, key) as { size: number } | undefined
|
|
250
|
+
const oldSize = existingRow?.size || 0
|
|
251
|
+
const newTotalSize = this.currentSize - oldSize + dataSize
|
|
252
|
+
|
|
253
|
+
if (newTotalSize > this.capacity) {
|
|
254
|
+
this.maintainCapacity(newTotalSize - this.capacity)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Insert or update the cache entry
|
|
258
|
+
this.db
|
|
259
|
+
.prepare(
|
|
260
|
+
`INSERT INTO ${CACHE_TABLE_NAME} (namespace, key, data, size, last_accessed_at, updated_at)
|
|
261
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
262
|
+
ON CONFLICT(namespace, key)
|
|
263
|
+
DO UPDATE SET
|
|
264
|
+
data = excluded.data,
|
|
265
|
+
size = excluded.size,
|
|
266
|
+
last_accessed_at = excluded.last_accessed_at,
|
|
267
|
+
updated_at = excluded.updated_at`,
|
|
268
|
+
)
|
|
269
|
+
.run(this.namespace, key, data, dataSize, now, now)
|
|
270
|
+
|
|
271
|
+
this.currentSize = this.currentSize - oldSize + dataSize
|
|
272
|
+
this.notifySubscribers(key, data)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
remove(key: string): boolean {
|
|
276
|
+
const row = this.db
|
|
277
|
+
.prepare(
|
|
278
|
+
`SELECT size FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`,
|
|
279
|
+
)
|
|
280
|
+
.get(this.namespace, key) as { size: number } | undefined
|
|
281
|
+
|
|
282
|
+
if (row) {
|
|
283
|
+
this.db
|
|
284
|
+
.prepare(`DELETE FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`)
|
|
285
|
+
.run(this.namespace, key)
|
|
286
|
+
|
|
287
|
+
this.currentSize -= row.size
|
|
288
|
+
this.notifySubscribers(key, undefined)
|
|
289
|
+
return true
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
clear(options?: { notifySubscribers: boolean }): void {
|
|
296
|
+
this.db
|
|
297
|
+
.prepare(`DELETE FROM ${CACHE_TABLE_NAME} WHERE namespace = ?`)
|
|
298
|
+
.run(this.namespace)
|
|
299
|
+
this.currentSize = 0
|
|
300
|
+
|
|
301
|
+
if (options?.notifySubscribers !== false) {
|
|
302
|
+
this.notifySubscribers(undefined, undefined)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
subscribe(subscriber: Cache.Subscriber): Cache.Subscription {
|
|
307
|
+
this.subscribers.push(subscriber)
|
|
308
|
+
return () => {
|
|
309
|
+
const index = this.subscribers.indexOf(subscriber)
|
|
310
|
+
if (index > -1) {
|
|
311
|
+
this.subscribers.splice(index, 1)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private maintainCapacity(bytesToFree: number): void {
|
|
317
|
+
const rows = this.db
|
|
318
|
+
.prepare(
|
|
319
|
+
`SELECT key, size FROM ${CACHE_TABLE_NAME}
|
|
320
|
+
WHERE namespace = ?
|
|
321
|
+
ORDER BY last_accessed_at ASC`,
|
|
322
|
+
)
|
|
323
|
+
.all(this.namespace) as Array<{ key: string; size: number }>
|
|
324
|
+
|
|
325
|
+
let freedBytes = 0
|
|
326
|
+
const keysToRemove: string[] = []
|
|
327
|
+
|
|
328
|
+
for (const row of rows) {
|
|
329
|
+
if (freedBytes >= bytesToFree) {
|
|
330
|
+
break
|
|
331
|
+
}
|
|
332
|
+
keysToRemove.push(row.key)
|
|
333
|
+
freedBytes += row.size
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (keysToRemove.length > 0) {
|
|
337
|
+
const placeholders = keysToRemove.map(() => '?').join(',')
|
|
338
|
+
const stmt = this.db.prepare(
|
|
339
|
+
`DELETE FROM ${CACHE_TABLE_NAME}
|
|
340
|
+
WHERE namespace = ? AND key IN (${placeholders})`,
|
|
341
|
+
)
|
|
342
|
+
stmt.run(this.namespace, ...(keysToRemove as [string, ...string[]]))
|
|
343
|
+
this.currentSize -= freedBytes
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private notifySubscribers(
|
|
348
|
+
key: string | undefined,
|
|
349
|
+
data: string | undefined,
|
|
350
|
+
): void {
|
|
351
|
+
for (const subscriber of this.subscribers) {
|
|
352
|
+
try {
|
|
353
|
+
subscriber(key, data)
|
|
354
|
+
} catch (error) {
|
|
355
|
+
logger.error('Cache subscriber error:', error)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export namespace Cache {
|
|
362
|
+
export interface Options {
|
|
363
|
+
namespace?: string
|
|
364
|
+
capacity?: number
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export type Subscriber = (
|
|
368
|
+
key: string | undefined,
|
|
369
|
+
data: string | undefined,
|
|
370
|
+
) => void
|
|
371
|
+
export type Subscription = () => void
|
|
372
|
+
}
|