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.
Files changed (170) hide show
  1. package/dist/action-utils.d.ts.map +1 -1
  2. package/dist/action-utils.js +17 -132
  3. package/dist/action-utils.js.map +1 -1
  4. package/dist/apis/cache.d.ts +8 -30
  5. package/dist/apis/cache.d.ts.map +1 -1
  6. package/dist/apis/cache.js +9 -271
  7. package/dist/apis/cache.js.map +1 -1
  8. package/dist/apis/clipboard.d.ts +4 -2
  9. package/dist/apis/clipboard.d.ts.map +1 -1
  10. package/dist/apis/clipboard.js +18 -31
  11. package/dist/apis/clipboard.js.map +1 -1
  12. package/dist/apis/environment.d.ts.map +1 -1
  13. package/dist/apis/environment.js +14 -49
  14. package/dist/apis/environment.js.map +1 -1
  15. package/dist/apis/localstorage.d.ts +7 -12
  16. package/dist/apis/localstorage.d.ts.map +1 -1
  17. package/dist/apis/localstorage.js +7 -184
  18. package/dist/apis/localstorage.js.map +1 -1
  19. package/dist/app.d.ts.map +1 -1
  20. package/dist/app.js +16 -15
  21. package/dist/app.js.map +1 -1
  22. package/dist/cli.js +7 -6
  23. package/dist/cli.js.map +1 -1
  24. package/dist/components/actions.d.ts.map +1 -1
  25. package/dist/components/actions.js +13 -2
  26. package/dist/components/actions.js.map +1 -1
  27. package/dist/components/extension-preferences.d.ts.map +1 -1
  28. package/dist/components/extension-preferences.js +7 -8
  29. package/dist/components/extension-preferences.js.map +1 -1
  30. package/dist/components/form/file-autocomplete.js +2 -2
  31. package/dist/components/form/file-autocomplete.js.map +1 -1
  32. package/dist/components/list.d.ts.map +1 -1
  33. package/dist/components/list.js +242 -14
  34. package/dist/components/list.js.map +1 -1
  35. package/dist/e2e-node.d.ts.map +1 -1
  36. package/dist/e2e-node.js +5 -4
  37. package/dist/e2e-node.js.map +1 -1
  38. package/dist/extensions/dev.d.ts.map +1 -1
  39. package/dist/extensions/dev.js +5 -2
  40. package/dist/extensions/dev.js.map +1 -1
  41. package/dist/globals.d.ts.map +1 -1
  42. package/dist/globals.js +2 -1
  43. package/dist/globals.js.map +1 -1
  44. package/dist/internal/error-handler.d.ts.map +1 -1
  45. package/dist/internal/error-handler.js +21 -19
  46. package/dist/internal/error-handler.js.map +1 -1
  47. package/dist/internal/providers.d.ts.map +1 -1
  48. package/dist/internal/providers.js +41 -1
  49. package/dist/internal/providers.js.map +1 -1
  50. package/dist/logger.d.ts.map +1 -1
  51. package/dist/logger.js +40 -29
  52. package/dist/logger.js.map +1 -1
  53. package/dist/platform/browser/cache.d.ts +41 -0
  54. package/dist/platform/browser/cache.d.ts.map +1 -0
  55. package/dist/platform/browser/cache.js +262 -0
  56. package/dist/platform/browser/cache.js.map +1 -0
  57. package/dist/platform/browser/localstorage.d.ts +20 -0
  58. package/dist/platform/browser/localstorage.d.ts.map +1 -0
  59. package/dist/platform/browser/localstorage.js +102 -0
  60. package/dist/platform/browser/localstorage.js.map +1 -0
  61. package/dist/platform/browser/runtime.d.ts +51 -0
  62. package/dist/platform/browser/runtime.d.ts.map +1 -0
  63. package/dist/platform/browser/runtime.js +164 -0
  64. package/dist/platform/browser/runtime.js.map +1 -0
  65. package/dist/platform/bun/sqlite.d.ts +17 -0
  66. package/dist/platform/bun/sqlite.d.ts.map +1 -0
  67. package/dist/platform/bun/sqlite.js +6 -0
  68. package/dist/platform/bun/sqlite.js.map +1 -0
  69. package/dist/platform/node/cache.d.ts +35 -0
  70. package/dist/platform/node/cache.d.ts.map +1 -0
  71. package/dist/platform/node/cache.js +269 -0
  72. package/dist/platform/node/cache.js.map +1 -0
  73. package/dist/platform/node/localstorage.d.ts +17 -0
  74. package/dist/platform/node/localstorage.d.ts.map +1 -0
  75. package/dist/platform/node/localstorage.js +186 -0
  76. package/dist/platform/node/localstorage.js.map +1 -0
  77. package/dist/platform/node/runtime.d.ts +52 -0
  78. package/dist/platform/node/runtime.d.ts.map +1 -0
  79. package/dist/platform/node/runtime.js +230 -0
  80. package/dist/platform/node/runtime.js.map +1 -0
  81. package/dist/platform/node/sqlite.d.ts +27 -0
  82. package/dist/platform/node/sqlite.d.ts.map +1 -0
  83. package/dist/platform/node/sqlite.js +21 -0
  84. package/dist/platform/node/sqlite.js.map +1 -0
  85. package/dist/state.d.ts +5 -0
  86. package/dist/state.d.ts.map +1 -1
  87. package/dist/state.js +6 -28
  88. package/dist/state.js.map +1 -1
  89. package/dist/utils/file-system.d.ts.map +1 -1
  90. package/dist/utils/file-system.js +17 -22
  91. package/dist/utils/file-system.js.map +1 -1
  92. package/dist/utils.d.ts +1 -1
  93. package/dist/utils.d.ts.map +1 -1
  94. package/dist/utils.js +42 -47
  95. package/dist/utils.js.map +1 -1
  96. package/dist/vim-mode.d.ts +40 -0
  97. package/dist/vim-mode.d.ts.map +1 -0
  98. package/dist/vim-mode.js +135 -0
  99. package/dist/vim-mode.js.map +1 -0
  100. package/fonts/Inconsolata.otf +0 -0
  101. package/fonts/SIL Open Font License.txt +41 -0
  102. package/package.json +62 -10
  103. package/src/action-utils.tsx +27 -124
  104. package/src/apis/cache.test.ts +1 -1
  105. package/src/apis/cache.tsx +9 -373
  106. package/src/apis/clipboard.tsx +29 -38
  107. package/src/apis/environment.tsx +25 -52
  108. package/src/apis/localstorage.tsx +8 -214
  109. package/src/app.tsx +16 -15
  110. package/src/cli.tsx +14 -15
  111. package/src/compile.vitest.tsx +2 -2
  112. package/src/components/actions.tsx +19 -1
  113. package/src/components/extension-preferences.tsx +7 -8
  114. package/src/components/form/file-autocomplete.tsx +2 -2
  115. package/src/components/list.tsx +279 -14
  116. package/src/e2e-node.tsx +7 -7
  117. package/src/examples/action-shortcut.vitest.tsx +2 -2
  118. package/src/examples/actions-context.vitest.tsx +1 -1
  119. package/src/examples/bar-graph-weekly.vitest.tsx +10 -36
  120. package/src/examples/detail-metadata-showcase.vitest.tsx +37 -42
  121. package/src/examples/form-basic.vitest.tsx +45 -41
  122. package/src/examples/github.vitest.tsx +4 -4
  123. package/src/examples/graph-bar-chart.vitest.tsx +13 -11
  124. package/src/examples/graph-polymarket.vitest.tsx +2 -2
  125. package/src/examples/graph-row.vitest.tsx +66 -66
  126. package/src/examples/graph-styles.vitest.tsx +12 -12
  127. package/src/examples/internal/simple-scrollbox.vitest.tsx +14 -48
  128. package/src/examples/list-detail-metadata.vitest.tsx +5 -5
  129. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  130. package/src/examples/list-item-accessories.vitest.tsx +2 -2
  131. package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
  132. package/src/examples/list-no-actions.vitest.tsx +2 -2
  133. package/src/examples/list-scrollbox.vitest.tsx +5 -5
  134. package/src/examples/list-spacing-mode.vitest.tsx +3 -3
  135. package/src/examples/list-with-detail.vitest.tsx +68 -68
  136. package/src/examples/list-with-dropdown.vitest.tsx +5 -5
  137. package/src/examples/list-with-sections.vitest.tsx +27 -27
  138. package/src/examples/simple-candle-chart.vitest.tsx +7 -7
  139. package/src/examples/simple-detail-markdown.vitest.tsx +8 -8
  140. package/src/examples/simple-detail-table.vitest.tsx +8 -8
  141. package/src/examples/simple-graph.vitest.tsx +3 -3
  142. package/src/examples/simple-grid.vitest.tsx +14 -14
  143. package/src/examples/simple-heatmap.vitest.tsx +10 -10
  144. package/src/examples/simple-navigation.vitest.tsx +17 -17
  145. package/src/examples/simple-progress-bar.vitest.tsx +1 -1
  146. package/src/examples/store.vitest.tsx +1 -1
  147. package/src/examples/swift-extension.vitest.tsx +2 -2
  148. package/src/examples/table-edge-cases.vitest.tsx +18 -18
  149. package/src/examples/toast-action.vitest.tsx +2 -2
  150. package/src/examples/toast-variations.vitest.tsx +5 -5
  151. package/src/extensions/dev.tsx +5 -2
  152. package/src/extensions/dev.vitest.tsx +3 -3
  153. package/src/globals.ts +2 -1
  154. package/src/internal/error-handler.tsx +19 -21
  155. package/src/internal/providers.tsx +39 -0
  156. package/src/logger.tsx +48 -41
  157. package/src/platform/browser/cache.ts +327 -0
  158. package/src/platform/browser/localstorage.ts +119 -0
  159. package/src/platform/browser/runtime.ts +209 -0
  160. package/src/platform/bun/sqlite.ts +19 -0
  161. package/src/platform/node/cache.ts +372 -0
  162. package/src/platform/node/localstorage.ts +214 -0
  163. package/src/platform/node/runtime.ts +264 -0
  164. package/src/platform/node/sqlite.ts +43 -0
  165. package/src/state.tsx +17 -28
  166. package/src/utils/file-system.ts +17 -22
  167. package/src/utils.test.tsx +1 -1
  168. package/src/utils.tsx +56 -47
  169. package/src/vim-mode.tsx +153 -0
  170. package/src/apis/sqlite.ts +0 -14
package/src/logger.tsx CHANGED
@@ -1,13 +1,28 @@
1
- import * as fs from 'fs'
2
- import * as path from 'path'
3
- import util from 'node:util'
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 = path.join(process.cwd(), 'app.log')
13
+ const LOG_FILE = joinPath(cwd(), 'app.log')
14
+ const isDebugLoggingEnabled = getEnv('TERMCAST_DEBUG') === '1'
7
15
 
8
- // Delete log file on process start
9
- if (fs.existsSync(LOG_FILE)) {
10
- fs.unlinkSync(LOG_FILE)
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 util.inspect(msg, { depth: 3 })
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
- fs.appendFileSync(LOG_FILE, logEntry)
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
- fs.appendFileSync(LOG_FILE, logEntry)
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
- fs.appendFileSync(LOG_FILE, logEntry)
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
- fs.appendFileSync(LOG_FILE, logEntry)
72
+ appendLogEntry(logEntry)
58
73
  console.trace(...messages)
59
74
  },
60
75
  }
61
76
 
62
77
  // Catch unhandled errors and exceptions
63
- process.on('uncaughtException', (error: Error) => {
64
- if (error instanceof Error) {
65
- logger.error('Uncaught Exception:', error.message, error.stack)
66
- } else {
67
- logger.error('Uncaught Exception:', serialize(error))
68
- }
69
- // In app mode, don't exit on uncaught exceptions — the error boundary
70
- // will catch React errors, and crashing the whole app is worse than
71
- // a broken screen the user can recover from.
72
- if (process.env.TERMCAST_APP_MODE !== '1') {
73
- process.exit(1)
74
- }
75
- })
76
-
77
- process.on('unhandledRejection', async (reason: any, promise: Promise<any>) => {
78
- if (reason instanceof Error) {
79
- logger.error(
80
- 'Unhandled Rejection at:',
81
- promise,
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
- logger.error(
88
- 'Unhandled Rejection at:',
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
+ }