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
@@ -1,137 +1,13 @@
1
- import { Database } from './sqlite'
2
- import * as path from 'path'
3
- import * as os from 'os'
4
- import * as fs from 'fs'
5
- import { logger } from '../logger'
6
- import { useStore } from '../state'
7
-
8
- const CACHE_TABLE_NAME = 'cache_entries'
9
- const DEFAULT_NAMESPACE = '__default__'
10
- const initializedDatabasePaths = new Set<string>()
11
- let logicalTimestamp = Date.now()
12
-
13
- function nextTimestamp(): number {
14
- logicalTimestamp += 1
15
- return logicalTimestamp
16
- }
17
-
18
- function getCurrentDatabasePath(): string {
19
- const { extensionPath } = useStore.getState()
20
- const dbSuffix = process.env.TERMCAST_DB_SUFFIX?.replace(/[^a-zA-Z0-9_-]/g, '_')
21
- const dbFileName = dbSuffix ? `data-${dbSuffix}.db` : 'data.db'
22
-
23
- if (extensionPath) {
24
- return path.join(extensionPath, '.termcast-bundle', dbFileName)
25
- }
26
-
27
- // Fallback for examples/tests that don't set extensionPath
28
- return path.join(os.homedir(), '.termcast', '.termcast-bundle', dbFileName)
29
- }
30
-
31
- function getCurrentCacheDir(namespace?: string): string {
32
- const { extensionPath } = useStore.getState()
33
-
34
- const baseDir = extensionPath
35
- ? path.join(extensionPath, '.termcast-bundle', 'cache')
36
- : path.join(os.homedir(), '.termcast', '.termcast-bundle', 'cache')
37
-
38
- return namespace ? path.join(baseDir, namespace) : baseDir
39
- }
40
-
41
- function getNamespace(namespace?: string): string {
42
- return namespace || DEFAULT_NAMESPACE
43
- }
44
-
45
- function initializeDatabaseOnce({ db, dbPath }: { db: Database; dbPath: string }): void {
46
- if (initializedDatabasePaths.has(dbPath)) {
47
- return
48
- }
49
-
50
- db.exec('PRAGMA journal_mode = WAL')
51
- db.exec('PRAGMA wal_autocheckpoint = 1000')
52
- db.exec('PRAGMA synchronous = NORMAL')
53
-
54
- db.exec(`
55
- CREATE TABLE IF NOT EXISTS ${CACHE_TABLE_NAME} (
56
- namespace TEXT NOT NULL,
57
- key TEXT NOT NULL,
58
- data TEXT NOT NULL,
59
- size INTEGER NOT NULL,
60
- last_accessed_at INTEGER NOT NULL,
61
- updated_at INTEGER NOT NULL,
62
- PRIMARY KEY(namespace, key)
63
- )
64
- `)
65
-
66
- db.exec(`
67
- CREATE INDEX IF NOT EXISTS idx_${CACHE_TABLE_NAME}_namespace_lru
68
- ON ${CACHE_TABLE_NAME}(namespace, last_accessed_at)
69
- `)
70
-
71
- cleanupLegacyCacheTables(db)
72
- initializedDatabasePaths.add(dbPath)
73
- }
74
-
75
- function cleanupLegacyCacheTables(db: Database): void {
76
- const rows = db
77
- .prepare(
78
- `SELECT name FROM sqlite_master
79
- WHERE type = 'table'
80
- AND (name = 'cache' OR name LIKE 'cache_%')
81
- AND name != ?`,
82
- )
83
- .all(CACHE_TABLE_NAME) as Array<{ name: string }>
84
-
85
- if (rows.length === 0) {
86
- return
87
- }
88
-
89
- const tx = db.transaction(() => {
90
- const upsert = db.prepare(
91
- `INSERT INTO ${CACHE_TABLE_NAME} (namespace, key, data, size, last_accessed_at, updated_at)
92
- VALUES (?, ?, ?, ?, ?, ?)
93
- ON CONFLICT(namespace, key)
94
- DO UPDATE SET
95
- data = excluded.data,
96
- size = excluded.size,
97
- last_accessed_at = excluded.last_accessed_at,
98
- updated_at = excluded.updated_at`,
99
- )
100
-
101
- for (const { name } of rows) {
102
- const namespace =
103
- name === 'cache'
104
- ? DEFAULT_NAMESPACE
105
- : name === 'cache_tanstack_query'
106
- ? 'tanstack-query'
107
- : `legacy:${name.slice('cache_'.length)}`
108
-
109
- try {
110
- const values = db
111
- .prepare(`SELECT key, data, size, rowid FROM ${name}`)
112
- .all() as Array<{ key: string; data: string; size: number; rowid: number }>
113
-
114
- values.forEach((entry) => {
115
- const timestamp = entry.rowid
116
- upsert.run(
117
- namespace,
118
- entry.key,
119
- entry.data,
120
- entry.size,
121
- timestamp,
122
- timestamp,
123
- )
124
- })
125
- } catch {
126
- // Ignore invalid legacy tables and continue cleanup.
127
- }
128
-
129
- db.exec(`DROP TABLE IF EXISTS ${name}`)
130
- }
131
- })
1
+ /**
2
+ * Cache API platform-agnostic facade.
3
+ *
4
+ * Re-exports the platform-specific Cache class from #platform/cache
5
+ * (SQLite on Node/Bun, IndexedDB on browser) and provides the shared
6
+ * `withCache` higher-order function on top.
7
+ */
132
8
 
133
- tx()
134
- }
9
+ export { Cache } from '#platform/cache'
10
+ import { Cache } from '#platform/cache'
135
11
 
136
12
  function hashString(value: string): string {
137
13
  let hash = 0
@@ -141,246 +17,6 @@ function hashString(value: string): string {
141
17
  return Math.abs(hash).toString(36)
142
18
  }
143
19
 
144
- export class Cache {
145
- static get STORAGE_DIRECTORY_NAME(): string {
146
- const extensionPath = useStore.getState().extensionPath
147
- return extensionPath ? 'cache' : '.termcast-cache'
148
- }
149
-
150
- static get DEFAULT_CAPACITY(): number {
151
- return 10 * 1024 * 1024 // 10 MB
152
- }
153
-
154
- private db: Database
155
- private capacity: number
156
- private namespace: string
157
- private subscribers: Cache.Subscriber[] = []
158
- private currentSize: number = 0
159
-
160
- constructor(options?: Cache.Options) {
161
- const sqliteLoadStart = Date.now()
162
- this.capacity = options?.capacity || Cache.DEFAULT_CAPACITY
163
- this.namespace = getNamespace(options?.namespace)
164
-
165
- const dbPath = getCurrentDatabasePath()
166
-
167
- // Ensure parent directory exists
168
- const dbDir = path.dirname(dbPath)
169
- if (!fs.existsSync(dbDir)) {
170
- fs.mkdirSync(dbDir, { recursive: true })
171
- }
172
-
173
- this.db = new Database(dbPath, {
174
- create: true,
175
- readwrite: true,
176
- })
177
-
178
- initializeDatabaseOnce({ db: this.db, dbPath })
179
-
180
- // Calculate initial size
181
- const row = this.db
182
- .prepare(
183
- `SELECT COALESCE(SUM(size), 0) as total FROM ${CACHE_TABLE_NAME} WHERE namespace = ?`,
184
- )
185
- .get(this.namespace) as { total: number | null } | undefined
186
- this.currentSize = row?.total || 0
187
-
188
- const sqliteLoadMs = Date.now() - sqliteLoadStart
189
- if (sqliteLoadMs > 500) {
190
- logger.log(
191
- `[perf] sqlite cache init took ${sqliteLoadMs}ms (namespace=${this.namespace})`,
192
- )
193
- }
194
-
195
- // Bind all methods to this instance
196
- this.get = this.get.bind(this)
197
- this.has = this.has.bind(this)
198
- this.set = this.set.bind(this)
199
- this.remove = this.remove.bind(this)
200
- this.clear = this.clear.bind(this)
201
- this.subscribe = this.subscribe.bind(this)
202
- this.maintainCapacity = this.maintainCapacity.bind(this)
203
- this.notifySubscribers = this.notifySubscribers.bind(this)
204
- }
205
-
206
- get storageDirectory(): string {
207
- return getCurrentCacheDir(this.namespace)
208
- }
209
-
210
- get(key: string): string | undefined {
211
- const now = nextTimestamp()
212
- const row = this.db
213
- .prepare(
214
- `SELECT data, size FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`,
215
- )
216
- .get(this.namespace, key) as { data: string; size: number } | undefined
217
-
218
- if (row) {
219
- this.db
220
- .prepare(
221
- `UPDATE ${CACHE_TABLE_NAME} SET last_accessed_at = ? WHERE namespace = ? AND key = ?`,
222
- )
223
- .run(now, this.namespace, key)
224
-
225
- return row.data
226
- }
227
-
228
- return undefined
229
- }
230
-
231
- has(key: string): boolean {
232
- const row = this.db
233
- .prepare(`SELECT 1 FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`)
234
- .get(this.namespace, key)
235
- return !!row
236
- }
237
-
238
- get isEmpty(): boolean {
239
- const row = this.db
240
- .prepare(
241
- `SELECT COUNT(*) as count FROM ${CACHE_TABLE_NAME} WHERE namespace = ?`,
242
- )
243
- .get(this.namespace) as { count: number }
244
- return row.count === 0
245
- }
246
-
247
- set(key: string, data: string): void {
248
- const now = nextTimestamp()
249
- const dataSize = Buffer.byteLength(data, 'utf8')
250
-
251
- // Get existing size if any
252
- const existingRow = this.db
253
- .prepare(
254
- `SELECT size FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`,
255
- )
256
- .get(this.namespace, key) as { size: number } | undefined
257
- const oldSize = existingRow?.size || 0
258
- const newTotalSize = this.currentSize - oldSize + dataSize
259
-
260
- if (newTotalSize > this.capacity) {
261
- this.maintainCapacity(newTotalSize - this.capacity)
262
- }
263
-
264
- // Insert or update the cache entry
265
- this.db
266
- .prepare(
267
- `INSERT INTO ${CACHE_TABLE_NAME} (namespace, key, data, size, last_accessed_at, updated_at)
268
- VALUES (?, ?, ?, ?, ?, ?)
269
- ON CONFLICT(namespace, key)
270
- DO UPDATE SET
271
- data = excluded.data,
272
- size = excluded.size,
273
- last_accessed_at = excluded.last_accessed_at,
274
- updated_at = excluded.updated_at`,
275
- )
276
- .run(this.namespace, key, data, dataSize, now, now)
277
-
278
- this.currentSize = this.currentSize - oldSize + dataSize
279
- this.notifySubscribers(key, data)
280
- }
281
-
282
- remove(key: string): boolean {
283
- // Check if key exists and get its size
284
- const row = this.db
285
- .prepare(
286
- `SELECT size FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`,
287
- )
288
- .get(this.namespace, key) as { size: number } | undefined
289
-
290
- if (row) {
291
- // Delete the key
292
- this.db
293
- .prepare(`DELETE FROM ${CACHE_TABLE_NAME} WHERE namespace = ? AND key = ?`)
294
- .run(this.namespace, key)
295
-
296
- this.currentSize -= row.size
297
- this.notifySubscribers(key, undefined)
298
- return true
299
- }
300
-
301
- return false
302
- }
303
-
304
- clear(options?: { notifySubscribers: boolean }): void {
305
- this.db
306
- .prepare(`DELETE FROM ${CACHE_TABLE_NAME} WHERE namespace = ?`)
307
- .run(this.namespace)
308
- this.currentSize = 0
309
-
310
- if (options?.notifySubscribers !== false) {
311
- this.notifySubscribers(undefined, undefined)
312
- }
313
- }
314
-
315
- subscribe(subscriber: Cache.Subscriber): Cache.Subscription {
316
- this.subscribers.push(subscriber)
317
- return () => {
318
- const index = this.subscribers.indexOf(subscriber)
319
- if (index > -1) {
320
- this.subscribers.splice(index, 1)
321
- }
322
- }
323
- }
324
-
325
- private maintainCapacity(bytesToFree: number): void {
326
- // Order by oldest last-access time first to evict least-recently-used rows.
327
- const rows = this.db
328
- .prepare(
329
- `SELECT key, size FROM ${CACHE_TABLE_NAME}
330
- WHERE namespace = ?
331
- ORDER BY last_accessed_at ASC`,
332
- )
333
- .all(this.namespace) as Array<{ key: string; size: number }>
334
-
335
- let freedBytes = 0
336
- const keysToRemove: string[] = []
337
-
338
- for (const row of rows) {
339
- if (freedBytes >= bytesToFree) {
340
- break
341
- }
342
- keysToRemove.push(row.key)
343
- freedBytes += row.size
344
- }
345
-
346
- if (keysToRemove.length > 0) {
347
- const placeholders = keysToRemove.map(() => '?').join(',')
348
- const stmt = this.db.prepare(
349
- `DELETE FROM ${CACHE_TABLE_NAME}
350
- WHERE namespace = ? AND key IN (${placeholders})`,
351
- )
352
- stmt.run(this.namespace, ...(keysToRemove as [string, ...string[]]))
353
- this.currentSize -= freedBytes
354
- }
355
- }
356
-
357
- private notifySubscribers(
358
- key: string | undefined,
359
- data: string | undefined,
360
- ): void {
361
- for (const subscriber of this.subscribers) {
362
- try {
363
- subscriber(key, data)
364
- } catch (error) {
365
- logger.error('Cache subscriber error:', error)
366
- }
367
- }
368
- }
369
- }
370
-
371
- export namespace Cache {
372
- export interface Options {
373
- namespace?: string
374
- capacity?: number
375
- }
376
-
377
- export type Subscriber = (
378
- key: string | undefined,
379
- data: string | undefined,
380
- ) => void
381
- export type Subscription = () => void
382
- }
383
-
384
20
  interface CacheMetadata {
385
21
  timestamp: number
386
22
  value: any
@@ -1,43 +1,43 @@
1
- import type { PathLike } from 'node:fs'
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
- import { exec } from 'child_process'
5
- import { promisify } from 'util'
1
+ import {
2
+ platform,
3
+ getEnv,
4
+ fileExists,
5
+ resolvePath,
6
+ copyToClipboard as platformCopy,
7
+ readClipboard as platformRead,
8
+ execCommand,
9
+ execWithInput,
10
+ } from '#platform/runtime'
6
11
  import { copyToClipboard, pasteContent } from 'termcast/src/action-utils'
7
12
  import { logger } from 'termcast/src/logger'
8
13
 
9
- const execAsync = promisify(exec)
10
- const platform = process.platform
11
-
12
14
  async function copyFileToClipboard(filePath: string): Promise<void> {
13
- const absolutePath = path.resolve(filePath)
15
+ const absolutePath = resolvePath(filePath)
14
16
 
15
- if (process.env.VITEST) {
17
+ if (getEnv('VITEST')) {
16
18
  logger.log(`📋 [VITEST] Skipping copy file to clipboard: ${filePath}`)
17
19
  return
18
20
  }
19
21
 
20
- if (!fs.existsSync(absolutePath)) {
22
+ if (!fileExists(absolutePath)) {
21
23
  throw new Error(`File not found: ${absolutePath}`)
22
24
  }
23
25
 
24
26
  try {
25
27
  if (platform === 'darwin') {
26
- // macOS: Use osascript to copy file to clipboard
27
28
  const script = `osascript -e 'set the clipboard to (POSIX file "${absolutePath}")'`
28
- await execAsync(script)
29
+ await execCommand(script)
29
30
  logger.log(`📋 Copied file to clipboard: ${filePath}`)
30
31
  } else if (platform === 'linux') {
31
- // Linux: Copy file path as text and file URI
32
32
  const fileUri = `file://${absolutePath}`
33
- await execAsync(
34
- `echo '${fileUri}' | xclip -selection clipboard -t text/uri-list`,
33
+ await execWithInput(
34
+ 'xclip -selection clipboard -t text/uri-list',
35
+ fileUri,
35
36
  )
36
37
  logger.log(`📋 Copied file to clipboard: ${filePath}`)
37
38
  } else if (platform === 'win32') {
38
- // Windows: Use PowerShell to copy file to clipboard
39
39
  const script = `powershell -command "Set-Clipboard -Path '${absolutePath}'"`
40
- await execAsync(script)
40
+ await execCommand(script)
41
41
  logger.log(`📋 Copied file to clipboard: ${filePath}`)
42
42
  } else {
43
43
  logger.log(`📋 File copy not supported on ${platform}: ${filePath}`)
@@ -116,24 +116,20 @@ export const Clipboard: ClipboardType = {
116
116
  let file: string | undefined
117
117
 
118
118
  if (platform === 'darwin') {
119
- // Try to get file first
120
119
  try {
121
120
  const fileCheckScript = `osascript -e 'try' -e 'get the clipboard as «class furl»' -e 'POSIX path of result' -e 'end try'`
122
- const { stdout: filePath } = await execAsync(fileCheckScript)
121
+ const filePath = await execCommand(fileCheckScript)
123
122
  if (filePath && filePath.trim()) {
124
123
  file = filePath.trim()
125
124
  }
126
125
  } catch {
127
- // No file in clipboard, try text
126
+ // No file in clipboard
128
127
  }
129
128
 
130
- // Get text content
131
- const { stdout } = await execAsync('pbpaste')
132
- text = stdout
129
+ text = await platformRead()
133
130
  } else if (platform === 'linux') {
134
- // Check for file URIs
135
131
  try {
136
- const { stdout: fileUri } = await execAsync(
132
+ const fileUri = await execCommand(
137
133
  'xclip -selection clipboard -t text/uri-list -o',
138
134
  )
139
135
  if (fileUri && fileUri.startsWith('file://')) {
@@ -143,24 +139,19 @@ export const Clipboard: ClipboardType = {
143
139
  // No file in clipboard
144
140
  }
145
141
 
146
- // Get text content
147
142
  try {
148
- const { stdout } = await execAsync('xclip -selection clipboard -o')
149
- text = stdout
143
+ text = await platformRead()
150
144
  } catch {
151
145
  // No text in clipboard
152
146
  }
153
147
  } else if (platform === 'win32') {
154
- // Windows: Get clipboard content
155
- const { stdout } = await execAsync(
156
- 'powershell -command "Get-Clipboard"',
157
- )
158
- text = stdout
159
-
160
- // Check if it's a file path
161
- if (text && fs.existsSync(text.trim())) {
148
+ text = await platformRead()
149
+ if (text && fileExists(text.trim())) {
162
150
  file = text.trim()
163
151
  }
152
+ } else {
153
+ // browser or other
154
+ text = await platformRead()
164
155
  }
165
156
 
166
157
  return { text, file }
@@ -188,7 +179,7 @@ export namespace Clipboard {
188
179
  text: string
189
180
  }
190
181
  | {
191
- file: PathLike
182
+ file: string | { href: string; toString(): string }
192
183
  }
193
184
  | {
194
185
  html: string
@@ -15,10 +15,17 @@
15
15
  * - System integration (selected Finder items, selected text)
16
16
  */
17
17
 
18
- import os from 'node:os'
19
- import path from 'node:path'
20
- import fs from 'node:fs'
21
- import { execSync } from 'node:child_process'
18
+ import {
19
+ homedir,
20
+ joinPath,
21
+ basename,
22
+ platform,
23
+ getEnv,
24
+ ensureDir,
25
+ fileExists,
26
+ execCommand,
27
+ getSystemAppearance,
28
+ } from '#platform/runtime'
22
29
  import { useStore } from '../state'
23
30
 
24
31
  export interface Environment {
@@ -57,28 +64,16 @@ class EnvironmentImpl implements Environment {
57
64
  }
58
65
 
59
66
  get appearance(): 'dark' | 'light' {
60
- // Try to detect system theme on macOS
61
- if (process.platform === 'darwin') {
62
- try {
63
- const result = execSync(
64
- 'defaults read -g AppleInterfaceStyle 2>/dev/null',
65
- { encoding: 'utf8' },
66
- )
67
- return result.includes('Dark') ? 'dark' : 'light'
68
- } catch {
69
- return 'light'
70
- }
71
- }
72
- return 'light'
67
+ return getSystemAppearance()
73
68
  }
74
69
 
75
70
  get assetsPath(): string {
76
71
  const state = useStore.getState()
77
72
  if (state.extensionPath) {
78
- return path.join(state.extensionPath, 'assets')
73
+ return joinPath(state.extensionPath, 'assets')
79
74
  }
80
75
  // TODO: Fallback for non-dev mode extensions
81
- return path.join(os.homedir(), '.termcast', 'assets')
76
+ return joinPath(homedir(), '.termcast', 'assets')
82
77
  }
83
78
 
84
79
  get commandMode(): 'view' | 'no-view' | 'menu-bar' {
@@ -108,7 +103,7 @@ class EnvironmentImpl implements Environment {
108
103
  return state.extensionPackageJson.name
109
104
  }
110
105
  if (state.extensionPath) {
111
- return path.basename(state.extensionPath)
106
+ return basename(state.extensionPath)
112
107
  }
113
108
  return ''
114
109
  }
@@ -143,17 +138,14 @@ class EnvironmentImpl implements Environment {
143
138
 
144
139
  get supportPath(): string {
145
140
  // Create a support directory in the user's data directory
146
- const baseDir = path.join(
147
- os.homedir(),
141
+ const baseDir = joinPath(
142
+ homedir(),
148
143
  '.termcast',
149
144
  'support',
150
145
  this.extensionName,
151
146
  )
152
147
 
153
- // Ensure the directory exists
154
- if (!fs.existsSync(baseDir)) {
155
- fs.mkdirSync(baseDir, { recursive: true })
156
- }
148
+ ensureDir(baseDir)
157
149
 
158
150
  return baseDir
159
151
  }
@@ -180,18 +172,13 @@ export const environment = new EnvironmentImpl()
180
172
  // Whether the TUI is running inside a standalone desktop app built with `termcast app build`.
181
173
  // In app mode, ESC at root level does not exit the process.
182
174
  export function isAppMode(): boolean {
183
- return process.env.TERMCAST_APP_MODE === '1'
175
+ return getEnv('TERMCAST_APP_MODE') === '1'
184
176
  }
185
177
 
186
178
  export async function getSelectedFinderItems(): Promise<string[]> {
187
179
  // TODO: Improve cross-platform support
188
- // Currently only works on macOS using AppleScript
189
- // Should add support for:
190
- // 1. Windows Explorer selection (via PowerShell or COM)
191
- // 2. Linux file managers (Nautilus, Dolphin, etc.)
192
- if (process.platform === 'darwin') {
180
+ if (platform === 'darwin') {
193
181
  try {
194
- // Use AppleScript to get selected Finder items
195
182
  const script = `
196
183
  tell application "Finder"
197
184
  set theSelection to selection
@@ -202,15 +189,12 @@ export async function getSelectedFinderItems(): Promise<string[]> {
202
189
  return thePaths
203
190
  end tell
204
191
  `
205
- const result = execSync(`osascript -e '${script}'`, {
206
- encoding: 'utf8',
207
- })
192
+ const result = await execCommand(`osascript -e '${script}'`)
208
193
  return result.trim().split(', ').filter(Boolean)
209
194
  } catch {
210
195
  return []
211
196
  }
212
197
  }
213
- // TODO: Implement for other platforms
214
198
  return []
215
199
  }
216
200
 
@@ -243,9 +227,9 @@ export async function launchCommand(options: LaunchOptions): Promise<void> {
243
227
  throw new Error(`Command '${options.name}' not found in extension`)
244
228
  }
245
229
 
246
- const bundledPath = path.join(extensionPath, '.termcast-bundle', `${options.name}.js`)
230
+ const bundledPath = joinPath(extensionPath, '.termcast-bundle', `${options.name}.js`)
247
231
 
248
- if (!fs.existsSync(bundledPath)) {
232
+ if (!fileExists(bundledPath)) {
249
233
  throw new Error(`Command '${options.name}' has not been built`)
250
234
  }
251
235
 
@@ -281,16 +265,8 @@ export async function launchCommand(options: LaunchOptions): Promise<void> {
281
265
  }
282
266
 
283
267
  export async function getSelectedText(): Promise<string> {
284
- // TODO: Improve implementation and cross-platform support
285
- // Current implementation has issues:
286
- // 1. Modifies the clipboard (should preserve original content)
287
- // 2. Uses delay which may not be reliable
288
- // 3. Only works on macOS
289
- // Should add support for Windows and Linux
290
- if (process.platform === 'darwin') {
268
+ if (platform === 'darwin') {
291
269
  try {
292
- // TODO: Save and restore clipboard contents to avoid side effects
293
- // Use AppleScript to get selected text from frontmost application
294
270
  const script = `
295
271
  tell application "System Events"
296
272
  keystroke "c" using command down
@@ -298,14 +274,11 @@ export async function getSelectedText(): Promise<string> {
298
274
  return (the clipboard)
299
275
  end tell
300
276
  `
301
- const result = execSync(`osascript -e '${script}'`, {
302
- encoding: 'utf8',
303
- })
277
+ const result = await execCommand(`osascript -e '${script}'`)
304
278
  return result.trim()
305
279
  } catch {
306
280
  return ''
307
281
  }
308
282
  }
309
- // TODO: Implement for Windows (via PowerShell) and Linux (xclip/xsel)
310
283
  return ''
311
284
  }