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
package/src/logger.tsx
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
joinPath,
|
|
3
|
+
cwd,
|
|
4
|
+
unlinkIfExists,
|
|
5
|
+
appendToFile,
|
|
6
|
+
inspectValue,
|
|
7
|
+
getEnv,
|
|
8
|
+
exit,
|
|
9
|
+
setupErrorHandlers,
|
|
10
|
+
} from '#platform/runtime'
|
|
4
11
|
import { useEffect } from 'react'
|
|
5
12
|
|
|
6
|
-
const LOG_FILE =
|
|
13
|
+
const LOG_FILE = joinPath(cwd(), 'app.log')
|
|
14
|
+
const isDebugLoggingEnabled = getEnv('TERMCAST_DEBUG') === '1'
|
|
7
15
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
if (isDebugLoggingEnabled) {
|
|
17
|
+
// Delete log file on process start
|
|
18
|
+
unlinkIfExists(LOG_FILE)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function appendLogEntry(logEntry: string): void {
|
|
22
|
+
if (!isDebugLoggingEnabled) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
appendToFile(LOG_FILE, logEntry)
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
function serialize(msg: any): string {
|
|
@@ -17,7 +32,7 @@ function serialize(msg: any): string {
|
|
|
17
32
|
if (typeof msg === 'string') {
|
|
18
33
|
return msg
|
|
19
34
|
}
|
|
20
|
-
return
|
|
35
|
+
return inspectValue(msg, 3)
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
export const logger = {
|
|
@@ -25,21 +40,21 @@ export const logger = {
|
|
|
25
40
|
const timestamp = new Date().toISOString()
|
|
26
41
|
const formattedMessages = messages.map(serialize).join(' ')
|
|
27
42
|
const logEntry = `[${timestamp}] ${formattedMessages}\n`
|
|
28
|
-
|
|
43
|
+
appendLogEntry(logEntry)
|
|
29
44
|
console.log(...messages)
|
|
30
45
|
},
|
|
31
46
|
error: (...messages: any[]) => {
|
|
32
47
|
const timestamp = new Date().toISOString()
|
|
33
48
|
const formattedMessages = messages.map(serialize).join(' ')
|
|
34
49
|
const logEntry = `[${timestamp}] ERROR: ${formattedMessages}\n`
|
|
35
|
-
|
|
50
|
+
appendLogEntry(logEntry)
|
|
36
51
|
console.error(...messages)
|
|
37
52
|
},
|
|
38
53
|
warn: (...messages: any[]) => {
|
|
39
54
|
const timestamp = new Date().toISOString()
|
|
40
55
|
const formattedMessages = messages.map(serialize).join(' ')
|
|
41
56
|
const logEntry = `[${timestamp}] WARN: ${formattedMessages}\n`
|
|
42
|
-
|
|
57
|
+
appendLogEntry(logEntry)
|
|
43
58
|
console.warn(...messages)
|
|
44
59
|
},
|
|
45
60
|
trace: (...messages: any[]) => {
|
|
@@ -54,41 +69,33 @@ export const logger = {
|
|
|
54
69
|
}
|
|
55
70
|
const formattedMessages = messages.map(serialize).join(' ')
|
|
56
71
|
const logEntry = `[${timestamp}] TRACE: ${formattedMessages}\n${stack}\n`
|
|
57
|
-
|
|
72
|
+
appendLogEntry(logEntry)
|
|
58
73
|
console.trace(...messages)
|
|
59
74
|
},
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
// Catch unhandled errors and exceptions
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
'Unhandled Rejection
|
|
81
|
-
|
|
82
|
-
'reason:',
|
|
83
|
-
reason.message,
|
|
84
|
-
reason.stack,
|
|
85
|
-
)
|
|
78
|
+
setupErrorHandlers((error, type) => {
|
|
79
|
+
if (type === 'uncaughtException') {
|
|
80
|
+
if (error instanceof Error) {
|
|
81
|
+
logger.error('Uncaught Exception:', error.message, error.stack)
|
|
82
|
+
} else {
|
|
83
|
+
logger.error('Uncaught Exception:', serialize(error))
|
|
84
|
+
}
|
|
85
|
+
// In app mode, don't exit on uncaught exceptions — the error boundary
|
|
86
|
+
// will catch React errors, and crashing the whole app is worse than
|
|
87
|
+
// a broken screen the user can recover from.
|
|
88
|
+
if (getEnv('TERMCAST_APP_MODE') !== '1') {
|
|
89
|
+
exit(1)
|
|
90
|
+
}
|
|
91
|
+
} else if (type === 'unhandledRejection') {
|
|
92
|
+
if (error instanceof Error) {
|
|
93
|
+
logger.error('Unhandled Rejection:', error.message, error.stack)
|
|
94
|
+
} else {
|
|
95
|
+
logger.error('Unhandled Rejection:', serialize(error))
|
|
96
|
+
}
|
|
86
97
|
} else {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
promise,
|
|
90
|
-
'reason:',
|
|
91
|
-
serialize(reason),
|
|
92
|
-
)
|
|
98
|
+
// uncaughtExceptionMonitor
|
|
99
|
+
logger.error(`Uncaught exception from ${type}:`, error)
|
|
93
100
|
}
|
|
94
101
|
})
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Cache implementation backed by IndexedDB with in-memory Map for sync reads.
|
|
3
|
+
*
|
|
4
|
+
* On construction, all entries for the namespace are loaded into a Map.
|
|
5
|
+
* Sync reads (get, has, isEmpty) hit the Map.
|
|
6
|
+
* Writes update both the Map and IndexedDB (fire-and-forget).
|
|
7
|
+
* LRU eviction is done in-memory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { byteLength } from '#platform/runtime'
|
|
11
|
+
|
|
12
|
+
const DB_NAME = 'termcast-cache'
|
|
13
|
+
const STORE_NAME = 'cache_entries'
|
|
14
|
+
const DB_VERSION = 1
|
|
15
|
+
const DEFAULT_NAMESPACE = '__default__'
|
|
16
|
+
|
|
17
|
+
interface CacheEntry {
|
|
18
|
+
namespace: string
|
|
19
|
+
key: string
|
|
20
|
+
data: string
|
|
21
|
+
size: number
|
|
22
|
+
last_accessed_at: number
|
|
23
|
+
updated_at: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Shared IDB connection — lazily opened, reused across Cache instances
|
|
27
|
+
let dbPromise: Promise<IDBDatabase> | null = null
|
|
28
|
+
|
|
29
|
+
function openDB(): Promise<IDBDatabase> {
|
|
30
|
+
if (dbPromise) return dbPromise
|
|
31
|
+
|
|
32
|
+
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
|
33
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
|
34
|
+
|
|
35
|
+
request.onupgradeneeded = () => {
|
|
36
|
+
const db = request.result
|
|
37
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
38
|
+
const store = db.createObjectStore(STORE_NAME, {
|
|
39
|
+
keyPath: ['namespace', 'key'],
|
|
40
|
+
})
|
|
41
|
+
store.createIndex('by_namespace', 'namespace', { unique: false })
|
|
42
|
+
store.createIndex('by_lru', ['namespace', 'last_accessed_at'], {
|
|
43
|
+
unique: false,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
request.onsuccess = () => {
|
|
49
|
+
resolve(request.result)
|
|
50
|
+
}
|
|
51
|
+
request.onerror = () => {
|
|
52
|
+
reject(request.error)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return dbPromise
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Load all entries for a namespace into memory (called once per Cache instance)
|
|
60
|
+
async function loadNamespace(namespace: string): Promise<Map<string, CacheEntry>> {
|
|
61
|
+
const db = await openDB()
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const tx = db.transaction(STORE_NAME, 'readonly')
|
|
64
|
+
const store = tx.objectStore(STORE_NAME)
|
|
65
|
+
const index = store.index('by_namespace')
|
|
66
|
+
const request = index.getAll(namespace)
|
|
67
|
+
|
|
68
|
+
request.onsuccess = () => {
|
|
69
|
+
const map = new Map<string, CacheEntry>()
|
|
70
|
+
for (const entry of request.result as CacheEntry[]) {
|
|
71
|
+
map.set(entry.key, entry)
|
|
72
|
+
}
|
|
73
|
+
resolve(map)
|
|
74
|
+
}
|
|
75
|
+
request.onerror = () => {
|
|
76
|
+
reject(request.error)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function idbPut(entry: CacheEntry): Promise<void> {
|
|
82
|
+
const db = await openDB()
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
85
|
+
const store = tx.objectStore(STORE_NAME)
|
|
86
|
+
const request = store.put(entry)
|
|
87
|
+
request.onsuccess = () => {
|
|
88
|
+
resolve()
|
|
89
|
+
}
|
|
90
|
+
request.onerror = () => {
|
|
91
|
+
reject(request.error)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function idbDelete(namespace: string, key: string): Promise<void> {
|
|
97
|
+
const db = await openDB()
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
100
|
+
const store = tx.objectStore(STORE_NAME)
|
|
101
|
+
const request = store.delete([namespace, key])
|
|
102
|
+
request.onsuccess = () => {
|
|
103
|
+
resolve()
|
|
104
|
+
}
|
|
105
|
+
request.onerror = () => {
|
|
106
|
+
reject(request.error)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function idbClearNamespace(namespace: string): Promise<void> {
|
|
112
|
+
const db = await openDB()
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
115
|
+
const store = tx.objectStore(STORE_NAME)
|
|
116
|
+
const index = store.index('by_namespace')
|
|
117
|
+
const request = index.openCursor(namespace)
|
|
118
|
+
|
|
119
|
+
request.onsuccess = () => {
|
|
120
|
+
const cursor = request.result
|
|
121
|
+
if (cursor) {
|
|
122
|
+
cursor.delete()
|
|
123
|
+
cursor.continue()
|
|
124
|
+
} else {
|
|
125
|
+
resolve()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
request.onerror = () => {
|
|
129
|
+
reject(request.error)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let logicalTimestamp = Date.now()
|
|
135
|
+
function nextTimestamp(): number {
|
|
136
|
+
logicalTimestamp += 1
|
|
137
|
+
return logicalTimestamp
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class Cache {
|
|
141
|
+
static get STORAGE_DIRECTORY_NAME(): string {
|
|
142
|
+
return 'cache'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
static get DEFAULT_CAPACITY(): number {
|
|
146
|
+
return 10 * 1024 * 1024 // 10 MB
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private entries: Map<string, CacheEntry> = new Map()
|
|
150
|
+
private capacity: number
|
|
151
|
+
private namespace: string
|
|
152
|
+
private subscribers: Cache.Subscriber[] = []
|
|
153
|
+
private currentSize: number = 0
|
|
154
|
+
private initialized: boolean = false
|
|
155
|
+
|
|
156
|
+
constructor(options?: Cache.Options) {
|
|
157
|
+
this.capacity = options?.capacity || Cache.DEFAULT_CAPACITY
|
|
158
|
+
this.namespace = options?.namespace || DEFAULT_NAMESPACE
|
|
159
|
+
|
|
160
|
+
// Kick off async load — sync reads return undefined until loaded
|
|
161
|
+
this.init()
|
|
162
|
+
|
|
163
|
+
// Bind all methods
|
|
164
|
+
this.get = this.get.bind(this)
|
|
165
|
+
this.has = this.has.bind(this)
|
|
166
|
+
this.set = this.set.bind(this)
|
|
167
|
+
this.remove = this.remove.bind(this)
|
|
168
|
+
this.clear = this.clear.bind(this)
|
|
169
|
+
this.subscribe = this.subscribe.bind(this)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async init(): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
const loaded = await loadNamespace(this.namespace)
|
|
175
|
+
// Merge: keep in-memory writes that happened during async load,
|
|
176
|
+
// preferring the newer entry (by updated_at) on key conflicts.
|
|
177
|
+
for (const [key, inMemoryEntry] of this.entries) {
|
|
178
|
+
const loadedEntry = loaded.get(key)
|
|
179
|
+
if (!loadedEntry || inMemoryEntry.updated_at >= loadedEntry.updated_at) {
|
|
180
|
+
loaded.set(key, inMemoryEntry)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.entries = loaded
|
|
184
|
+
this.currentSize = 0
|
|
185
|
+
for (const entry of this.entries.values()) {
|
|
186
|
+
this.currentSize += entry.size
|
|
187
|
+
}
|
|
188
|
+
this.initialized = true
|
|
189
|
+
} catch {
|
|
190
|
+
// IndexedDB might not be available (e.g. incognito with restrictions)
|
|
191
|
+
this.initialized = true
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get storageDirectory(): string {
|
|
196
|
+
return `/termcast/cache/${this.namespace}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get(key: string): string | undefined {
|
|
200
|
+
const entry = this.entries.get(key)
|
|
201
|
+
if (!entry) return undefined
|
|
202
|
+
|
|
203
|
+
// Update LRU timestamp in-memory and async in IDB
|
|
204
|
+
const now = nextTimestamp()
|
|
205
|
+
entry.last_accessed_at = now
|
|
206
|
+
idbPut(entry).catch(() => {})
|
|
207
|
+
|
|
208
|
+
return entry.data
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
has(key: string): boolean {
|
|
212
|
+
return this.entries.has(key)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
get isEmpty(): boolean {
|
|
216
|
+
return this.entries.size === 0
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
set(key: string, data: string): void {
|
|
220
|
+
const now = nextTimestamp()
|
|
221
|
+
const dataSize = byteLength(data)
|
|
222
|
+
|
|
223
|
+
const existing = this.entries.get(key)
|
|
224
|
+
const oldSize = existing?.size || 0
|
|
225
|
+
const newTotalSize = this.currentSize - oldSize + dataSize
|
|
226
|
+
|
|
227
|
+
if (newTotalSize > this.capacity) {
|
|
228
|
+
this.maintainCapacity(newTotalSize - this.capacity)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const entry: CacheEntry = {
|
|
232
|
+
namespace: this.namespace,
|
|
233
|
+
key,
|
|
234
|
+
data,
|
|
235
|
+
size: dataSize,
|
|
236
|
+
last_accessed_at: now,
|
|
237
|
+
updated_at: now,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.entries.set(key, entry)
|
|
241
|
+
this.currentSize = this.currentSize - oldSize + dataSize
|
|
242
|
+
|
|
243
|
+
// Persist async
|
|
244
|
+
idbPut(entry).catch(() => {})
|
|
245
|
+
|
|
246
|
+
this.notifySubscribers(key, data)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
remove(key: string): boolean {
|
|
250
|
+
const entry = this.entries.get(key)
|
|
251
|
+
if (!entry) return false
|
|
252
|
+
|
|
253
|
+
this.entries.delete(key)
|
|
254
|
+
this.currentSize -= entry.size
|
|
255
|
+
|
|
256
|
+
// Persist async
|
|
257
|
+
idbDelete(this.namespace, key).catch(() => {})
|
|
258
|
+
|
|
259
|
+
this.notifySubscribers(key, undefined)
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
clear(options?: { notifySubscribers: boolean }): void {
|
|
264
|
+
this.entries.clear()
|
|
265
|
+
this.currentSize = 0
|
|
266
|
+
|
|
267
|
+
// Persist async
|
|
268
|
+
idbClearNamespace(this.namespace).catch(() => {})
|
|
269
|
+
|
|
270
|
+
if (options?.notifySubscribers !== false) {
|
|
271
|
+
this.notifySubscribers(undefined, undefined)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
subscribe(subscriber: Cache.Subscriber): Cache.Subscription {
|
|
276
|
+
this.subscribers.push(subscriber)
|
|
277
|
+
return () => {
|
|
278
|
+
const index = this.subscribers.indexOf(subscriber)
|
|
279
|
+
if (index > -1) {
|
|
280
|
+
this.subscribers.splice(index, 1)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private maintainCapacity(bytesToFree: number): void {
|
|
286
|
+
// Sort by LRU — oldest first
|
|
287
|
+
const sorted = [...this.entries.values()].sort(
|
|
288
|
+
(a, b) => a.last_accessed_at - b.last_accessed_at,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
let freedBytes = 0
|
|
292
|
+
for (const entry of sorted) {
|
|
293
|
+
if (freedBytes >= bytesToFree) break
|
|
294
|
+
this.entries.delete(entry.key)
|
|
295
|
+
freedBytes += entry.size
|
|
296
|
+
// Persist async
|
|
297
|
+
idbDelete(this.namespace, entry.key).catch(() => {})
|
|
298
|
+
}
|
|
299
|
+
this.currentSize -= freedBytes
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private notifySubscribers(
|
|
303
|
+
key: string | undefined,
|
|
304
|
+
data: string | undefined,
|
|
305
|
+
): void {
|
|
306
|
+
for (const subscriber of this.subscribers) {
|
|
307
|
+
try {
|
|
308
|
+
subscriber(key, data)
|
|
309
|
+
} catch {
|
|
310
|
+
// Ignore subscriber errors in browser
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export namespace Cache {
|
|
317
|
+
export interface Options {
|
|
318
|
+
namespace?: string
|
|
319
|
+
capacity?: number
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export type Subscriber = (
|
|
323
|
+
key: string | undefined,
|
|
324
|
+
data: string | undefined,
|
|
325
|
+
) => void
|
|
326
|
+
export type Subscription = () => void
|
|
327
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser LocalStorage implementation backed by window.localStorage.
|
|
3
|
+
*
|
|
4
|
+
* Values are stored as JSON objects with type metadata to preserve
|
|
5
|
+
* number/boolean types across serialization, matching the SQLite
|
|
6
|
+
* implementation's behavior.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PREFIX = 'termcast:'
|
|
10
|
+
|
|
11
|
+
interface StoredEntry {
|
|
12
|
+
value: string
|
|
13
|
+
type: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export namespace LocalStorage {
|
|
17
|
+
export type Value = string | number | boolean
|
|
18
|
+
|
|
19
|
+
export interface Values {
|
|
20
|
+
[key: string]: any
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getItem<T extends Value = Value>(
|
|
24
|
+
key: string,
|
|
25
|
+
): Promise<T | undefined> {
|
|
26
|
+
return getItemSync<T>(key)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getItemSync<T extends Value = Value>(
|
|
30
|
+
key: string,
|
|
31
|
+
): T | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const raw = window.localStorage.getItem(PREFIX + key)
|
|
34
|
+
if (raw === null) return undefined
|
|
35
|
+
|
|
36
|
+
const entry: StoredEntry = JSON.parse(raw)
|
|
37
|
+
|
|
38
|
+
let value: Value
|
|
39
|
+
switch (entry.type) {
|
|
40
|
+
case 'number':
|
|
41
|
+
value = parseFloat(entry.value)
|
|
42
|
+
break
|
|
43
|
+
case 'boolean':
|
|
44
|
+
value = entry.value === 'true'
|
|
45
|
+
break
|
|
46
|
+
default:
|
|
47
|
+
value = entry.value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return value as T
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function setItem(key: string, value: Value): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
const entry: StoredEntry = {
|
|
59
|
+
value: String(value),
|
|
60
|
+
type: typeof value,
|
|
61
|
+
}
|
|
62
|
+
window.localStorage.setItem(PREFIX + key, JSON.stringify(entry))
|
|
63
|
+
} catch {
|
|
64
|
+
// localStorage might be full or disabled
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function removeItem(key: string): Promise<void> {
|
|
69
|
+
window.localStorage.removeItem(PREFIX + key)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function allItems<T extends Values = Values>(): Promise<T> {
|
|
73
|
+
const result: Values = {}
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
76
|
+
const fullKey = window.localStorage.key(i)
|
|
77
|
+
if (!fullKey?.startsWith(PREFIX)) continue
|
|
78
|
+
|
|
79
|
+
const key = fullKey.slice(PREFIX.length)
|
|
80
|
+
const raw = window.localStorage.getItem(fullKey)
|
|
81
|
+
if (raw === null) continue
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const entry: StoredEntry = JSON.parse(raw)
|
|
85
|
+
|
|
86
|
+
let value: Value
|
|
87
|
+
switch (entry.type) {
|
|
88
|
+
case 'number':
|
|
89
|
+
value = parseFloat(entry.value)
|
|
90
|
+
break
|
|
91
|
+
case 'boolean':
|
|
92
|
+
value = entry.value === 'true'
|
|
93
|
+
break
|
|
94
|
+
default:
|
|
95
|
+
value = entry.value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
result[key] = value
|
|
99
|
+
} catch {
|
|
100
|
+
// Skip corrupted entries
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result as T
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function clear(): Promise<void> {
|
|
108
|
+
const keysToRemove: string[] = []
|
|
109
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
110
|
+
const fullKey = window.localStorage.key(i)
|
|
111
|
+
if (fullKey?.startsWith(PREFIX)) {
|
|
112
|
+
keysToRemove.push(fullKey)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const key of keysToRemove) {
|
|
116
|
+
window.localStorage.removeItem(key)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|