@ssebastien/tcmd 0.1.4 → 0.1.7

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 (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. package/tinyjs.json +16 -0
@@ -0,0 +1,626 @@
1
+ // Project: TinyCommand
2
+ // License: BSD
3
+ // Module: tcmd/desktop/backend/fs
4
+ // txiki.js implementation of the filesystem adapter contract. Keep the
5
+ // runtime injectable so these operations can be tested under Bun without
6
+ // importing Node builtins into the shipped backend.
7
+
8
+ import { extname } from "../../core/path.js"
9
+
10
+ const PREVIEW_LIMIT = 1024 * 1024
11
+ const encoder = new TextEncoder()
12
+ const decoder = new TextDecoder()
13
+
14
+ function normalizePath(value, base = "/") {
15
+ if (typeof value !== "string" || !value || value.includes("\0")) {
16
+ throw new TypeError("path must be a non-empty string without NUL bytes")
17
+ }
18
+ const source = value.startsWith("/") ? value : `${base}/${value}`
19
+ const parts = []
20
+ for (const part of source.split("/")) {
21
+ if (!part || part === ".") continue
22
+ if (part === "..") parts.pop()
23
+ else parts.push(part)
24
+ }
25
+ return `/${parts.join("/")}`
26
+ }
27
+
28
+ function joinPath(base, name) {
29
+ return `${base === "/" ? "" : base}/${name}`
30
+ }
31
+
32
+ function basename(path) {
33
+ const parts = path.split("/").filter(Boolean)
34
+ return parts.at(-1) ?? "/"
35
+ }
36
+
37
+ function dirname(path) {
38
+ const parts = path.split("/").filter(Boolean)
39
+ if (parts.length <= 1) return "/"
40
+ return `/${parts.slice(0, -1).join("/")}`
41
+ }
42
+
43
+ function isWithin(parent, child) {
44
+ return parent === child || (parent === "/" ? child.startsWith("/") : child.startsWith(`${parent}/`))
45
+ }
46
+
47
+ function isDevice(stats) {
48
+ return Boolean(stats?.isBlockDevice || stats?.isCharacterDevice || stats?.isFIFO || stats?.isSocket)
49
+ }
50
+
51
+ function timeMs(value) {
52
+ if (value instanceof Date) return value.getTime()
53
+ const number = Number(value)
54
+ return Number.isFinite(number) ? number : 0
55
+ }
56
+
57
+ function statEntry(stats, overrides = {}) {
58
+ return {
59
+ isDir: Boolean(stats?.isDirectory),
60
+ isSymlink: Boolean(overrides.isSymlink ?? stats?.isSymbolicLink),
61
+ isDevice: isDevice(stats),
62
+ size: Number(stats?.size ?? 0),
63
+ mtimeMs: timeMs(stats?.mtim ?? stats?.mtimeMs),
64
+ ctimeMs: timeMs(stats?.ctim ?? stats?.ctimeMs),
65
+ mode: Number(stats?.mode ?? 0),
66
+ uid: stats?.uid == null ? null : Number(stats.uid),
67
+ gid: stats?.gid == null ? null : Number(stats.gid),
68
+ owner: "",
69
+ }
70
+ }
71
+
72
+ function missing(error) {
73
+ return error?.code === "ENOENT" || error?.code === "ENOTDIR"
74
+ }
75
+
76
+ function pathExistsError(path) {
77
+ const error = new Error(`destination already exists: ${path}`)
78
+ error.code = "EEXIST"
79
+ return error
80
+ }
81
+
82
+ function parseTrashInfo(text) {
83
+ const info = {}
84
+ for (const line of `${text ?? ""}`.split("\n")) {
85
+ const at = line.indexOf("=")
86
+ if (at <= 0) continue
87
+ const key = line.slice(0, at).trim()
88
+ const value = line.slice(at + 1).trim()
89
+ if (key === "Path") {
90
+ try {
91
+ info[key] = decodeURIComponent(value)
92
+ } catch {
93
+ info[key] = value
94
+ }
95
+ } else if (key === "DeletionDate") info[key] = value
96
+ }
97
+ return info
98
+ }
99
+
100
+ function encodeTrashPath(path) {
101
+ return path.split("/").map((part) => encodeURIComponent(part)).join("/")
102
+ }
103
+
104
+ function assertNoNested(paths) {
105
+ for (let i = 0; i < paths.length; i++) {
106
+ for (let j = i + 1; j < paths.length; j++) {
107
+ if (isWithin(paths[i], paths[j]) || isWithin(paths[j], paths[i])) {
108
+ throw new Error(`overlapping paths: ${paths[i]} and ${paths[j]}`)
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ // Function: parseColonIds
115
+ // Reads a passwd/group-style file into `{ id -> name }`; an unreadable file
116
+ // yields an empty map (numeric ids still work).
117
+ async function parseColonIds(runtime, path) {
118
+ const map = new Map()
119
+ try {
120
+ const text = decoder.decode(await runtime.readFile(path))
121
+ for (const line of text.split("\n")) {
122
+ const parts = line.split(":")
123
+ const id = Number(parts[2])
124
+ if (parts.length > 2 && Number.isFinite(id)) map.set(id, parts[0])
125
+ }
126
+ } catch {
127
+ // No passwd/group file: names cannot be resolved.
128
+ }
129
+ return map
130
+ }
131
+
132
+ function idForName(names, value, kind) {
133
+ const text = `${value ?? ""}`.trim()
134
+ if (/^\d+$/.test(text)) return Number(text)
135
+ for (const [id, name] of names ?? []) if (name === text) return id
136
+ throw new Error(`chown: unknown ${kind}: ${text}`)
137
+ }
138
+
139
+ async function resolveOwnerIds(runtime, owner) {
140
+ const text = `${owner ?? ""}`.trim()
141
+ if (!text) throw new Error("chown: OWNER must be USER[:GROUP]")
142
+ const [user, group] = text.split(":")
143
+ const users = user ? await parseColonIds(runtime, "/etc/passwd") : null
144
+ const groups = group ? await parseColonIds(runtime, "/etc/group") : null
145
+ return {
146
+ uid: user ? idForName(users, user, "user") : -1,
147
+ gid: group ? idForName(groups, group, "group") : -1,
148
+ }
149
+ }
150
+
151
+ function createDesktopFs(options = {}) {
152
+ const runtime = options.runtime
153
+ if (!runtime) throw new TypeError("txiki.js runtime is required")
154
+ let { home = runtime.homeDir, cwd = runtime.cwd, env = runtime.env ?? {} } = options
155
+ if (env.OS === "Windows_NT") {
156
+ throw new Error("The tcmd desktop filesystem does not support Windows drive or UNC paths yet")
157
+ }
158
+ const root = "/"
159
+ home = normalizePath(home || "/")
160
+ cwd = normalizePath(cwd || home, home)
161
+ const dataHome = env.XDG_DATA_HOME?.startsWith("/")
162
+ ? normalizePath(env.XDG_DATA_HOME)
163
+ : joinPath(home, ".local/share")
164
+ const trashRoot = joinPath(dataHome, "Trash")
165
+ const trashFiles = joinPath(trashRoot, "files")
166
+ const trashInfo = joinPath(trashRoot, "info")
167
+ const pathLocks = new Map()
168
+
169
+ const pathArg = (path) => normalizePath(path, cwd)
170
+
171
+ async function lstatOrNull(path) {
172
+ try {
173
+ return await runtime.lstat(path)
174
+ } catch (error) {
175
+ if (missing(error)) return null
176
+ throw error
177
+ }
178
+ }
179
+
180
+ async function statSafe(path) {
181
+ try {
182
+ return statEntry(await runtime.stat(path))
183
+ } catch {
184
+ return null
185
+ }
186
+ }
187
+
188
+ // Function: readHead
189
+ // Leading `maxBytes` (bounded by `PREVIEW_LIMIT`) of a file, as raw bytes.
190
+ // `readTextHead` decodes it; `readBytes` hands it to the compressed-preview
191
+ // decompressor on the frontend.
192
+ async function readHead(pathValue, maxBytes = 65536) {
193
+ const path = pathArg(pathValue)
194
+ const requested = Number(maxBytes)
195
+ const length = Number.isFinite(requested)
196
+ ? Math.max(0, Math.min(PREVIEW_LIMIT, Math.floor(requested)))
197
+ : 65536
198
+ if (!length) return new Uint8Array(0)
199
+ const handle = await runtime.open(path, "r")
200
+ try {
201
+ const buffer = new Uint8Array(length)
202
+ let count = 0
203
+ while (count < length) {
204
+ const read = await handle.read(buffer.subarray(count), count)
205
+ if (read == null || read === 0) break
206
+ count += read
207
+ }
208
+ return buffer.subarray(0, count)
209
+ } finally {
210
+ await handle.close()
211
+ }
212
+ }
213
+
214
+ async function withPathLock(path, action) {
215
+ const key = pathArg(path)
216
+ const previous = pathLocks.get(key) ?? Promise.resolve()
217
+ let release
218
+ const gate = new Promise((resolve) => (release = resolve))
219
+ const tail = previous.then(() => gate)
220
+ pathLocks.set(key, tail)
221
+ await previous
222
+ try {
223
+ return await action()
224
+ } finally {
225
+ release()
226
+ if (pathLocks.get(key) === tail) pathLocks.delete(key)
227
+ }
228
+ }
229
+
230
+ function assertMutable(path) {
231
+ if (path === root) throw new Error("refusing to modify the filesystem root")
232
+ if (isWithin(trashRoot, path) || isWithin(path, trashRoot)) {
233
+ throw new Error("refusing to modify an internal trash path or its parent")
234
+ }
235
+ }
236
+
237
+ async function readDirNames(path) {
238
+ const handle = await runtime.readDir(path)
239
+ const names = []
240
+ try {
241
+ for await (const entry of handle) names.push(entry.name)
242
+ } finally {
243
+ await handle.close?.()
244
+ }
245
+ return names
246
+ }
247
+
248
+ async function physicalPath(path) {
249
+ try {
250
+ return await runtime.realPath(path)
251
+ } catch (error) {
252
+ // Resolve the closest existing ancestor when the destination is new.
253
+ if (!missing(error) || path === root) throw error
254
+ return joinPath(await physicalPath(dirname(path)), basename(path))
255
+ }
256
+ }
257
+
258
+ async function assertCopySafe(from, to) {
259
+ const source = await runtime.lstat(from)
260
+ if (source.isDirectory) {
261
+ if (isWithin(from, to) || isWithin(await runtime.realPath(from), await physicalPath(to))) {
262
+ throw new Error(`cannot copy a path into itself: ${to}`)
263
+ }
264
+ }
265
+ const destination = await lstatOrNull(to)
266
+ if (!destination) return
267
+ if (!source.isDirectory || !destination.isDirectory) throw pathExistsError(to)
268
+ for (const name of await readDirNames(from)) {
269
+ await assertCopySafe(joinPath(from, name), joinPath(to, name))
270
+ }
271
+ }
272
+
273
+ async function copyEntry(from, to) {
274
+ const source = await runtime.lstat(from)
275
+ const destination = await lstatOrNull(to)
276
+ if (source.isSymbolicLink) {
277
+ if (destination) throw pathExistsError(to)
278
+ const target = await runtime.readLink(from)
279
+ await runtime.symlink(target, to)
280
+ return
281
+ }
282
+ if (source.isDirectory) {
283
+ if (destination && !destination.isDirectory) throw pathExistsError(to)
284
+ // Keep new directories private and owner-writable while populating,
285
+ // including when the source is read-only. Apply its exact mode last
286
+ // (mkdir's mode is filtered by umask). Merges retain destination modes.
287
+ if (!destination) await runtime.makeDir(to, { mode: 0o700 })
288
+ for (const name of await readDirNames(from)) {
289
+ await copyEntry(joinPath(from, name), joinPath(to, name))
290
+ }
291
+ if (!destination) await runtime.chmod(to, Number(source.mode) & 0o7777)
292
+ return
293
+ }
294
+ if (!source.isFile) throw new Error(`cannot copy a special filesystem entry: ${from}`)
295
+ if (destination) throw pathExistsError(to)
296
+ await runtime.copyFile(from, to)
297
+ }
298
+
299
+ async function copy(fromValue, toValue) {
300
+ const from = pathArg(fromValue)
301
+ const to = pathArg(toValue)
302
+ assertMutable(to)
303
+ return withPathLock(to, async () => {
304
+ await assertCopySafe(from, to)
305
+ await copyEntry(from, to)
306
+ })
307
+ }
308
+
309
+ async function movePaths(fromValue, toValue, { internal = false } = {}) {
310
+ const from = pathArg(fromValue)
311
+ const to = pathArg(toValue)
312
+ if (!internal) {
313
+ assertMutable(from)
314
+ assertMutable(to)
315
+ }
316
+ if (from === to || isWithin(from, to)) throw new Error(`cannot move a path into itself: ${to}`)
317
+ return withPathLock(to, async () => {
318
+ const source = await lstatOrNull(from)
319
+ if (!source) throw Object.assign(new Error(`not found: ${from}`), { code: "ENOENT" })
320
+ if (await lstatOrNull(to)) throw pathExistsError(to)
321
+ try {
322
+ await runtime.rename(from, to)
323
+ } catch (error) {
324
+ if (error?.code !== "EXDEV") throw error
325
+ await assertCopySafe(from, to)
326
+ await copyEntry(from, to)
327
+ try {
328
+ await runtime.remove(from)
329
+ } catch (removeError) {
330
+ throw new Error(`copied to ${to}, but could not remove the original ${from}: ${removeError.message}`, {
331
+ cause: removeError,
332
+ })
333
+ }
334
+ }
335
+ })
336
+ }
337
+
338
+ const move = (from, to) => movePaths(from, to)
339
+
340
+ async function reserveTrashName(base) {
341
+ for (let index = 0; ; index++) {
342
+ const name = index ? `${base}.${index}` : base
343
+ const file = joinPath(trashFiles, name)
344
+ if (await lstatOrNull(file)) continue
345
+ const infoFile = joinPath(trashInfo, `${name}.trashinfo`)
346
+ let handle
347
+ try {
348
+ handle = await runtime.open(infoFile, "wx")
349
+ } catch (error) {
350
+ if (error?.code === "EEXIST") continue
351
+ throw error
352
+ }
353
+ if (await lstatOrNull(file)) {
354
+ await handle.close()
355
+ await runtime.remove(infoFile)
356
+ continue
357
+ }
358
+ return { name, file, infoFile, handle }
359
+ }
360
+ }
361
+
362
+ async function writeHandle(handle, text) {
363
+ const bytes = encoder.encode(text)
364
+ let offset = 0
365
+ while (offset < bytes.length) {
366
+ const written = await handle.write(bytes.subarray(offset), offset)
367
+ if (!written) throw new Error("could not write trash metadata")
368
+ offset += written
369
+ }
370
+ }
371
+
372
+ const adapter = {
373
+ async readDir(pathValue) {
374
+ const path = pathArg(pathValue)
375
+ const handle = await runtime.readDir(path)
376
+ const entries = []
377
+ try {
378
+ for await (const dirent of handle) {
379
+ const full = joinPath(path, dirent.name)
380
+ let linkStats = null
381
+ try {
382
+ linkStats = await runtime.lstat(full)
383
+ } catch {
384
+ // Keep the directory entry visible if stat races with removal.
385
+ }
386
+ let followed = null
387
+ try {
388
+ followed = await runtime.stat(full)
389
+ } catch {
390
+ // Broken symlinks remain visible as non-directories.
391
+ }
392
+ const symlink = Boolean(linkStats?.isSymbolicLink ?? dirent.isSymbolicLink)
393
+ let target = null
394
+ if (symlink) {
395
+ try {
396
+ target = await runtime.readLink(full)
397
+ } catch {
398
+ // Link target text is supplemental metadata.
399
+ }
400
+ }
401
+ const stat = statEntry(followed ?? linkStats, { isSymlink: symlink })
402
+ const isDir = Boolean(dirent.isDirectory || (symlink && followed?.isDirectory))
403
+ entries.push({
404
+ name: dirent.name,
405
+ path: full,
406
+ isDir,
407
+ isSymlink: symlink,
408
+ target,
409
+ isDevice: isDevice(linkStats ?? dirent),
410
+ size: isDir ? 0 : Number(followed?.size ?? (symlink ? 0 : stat.size)),
411
+ mtimeMs: stat.mtimeMs,
412
+ ctimeMs: stat.ctimeMs,
413
+ mode: stat.mode,
414
+ uid: stat.uid,
415
+ gid: stat.gid,
416
+ owner: "",
417
+ ext: isDir ? null : extname(dirent.name),
418
+ })
419
+ }
420
+ } finally {
421
+ await handle.close?.()
422
+ }
423
+ return entries
424
+ },
425
+ async stat(pathValue) {
426
+ return statSafe(pathArg(pathValue))
427
+ },
428
+ realpath: (pathValue) => runtime.realPath(pathArg(pathValue)),
429
+ async isDir(pathValue) {
430
+ return Boolean((await adapter.stat(pathValue))?.isDir)
431
+ },
432
+ async readTextHead(pathValue, maxBytes = 65536) {
433
+ return decoder.decode(await readHead(pathValue, maxBytes))
434
+ },
435
+ readBytes: (pathValue, maxBytes = 65536) => readHead(pathValue, maxBytes),
436
+ async du(pathValue) {
437
+ const path = pathArg(pathValue)
438
+ const rootStat = await statSafe(path)
439
+ if (!rootStat) return 0
440
+ if (!rootStat.isDir) return rootStat.size
441
+ let total = 0
442
+ const visit = async (directory) => {
443
+ for (const entry of await adapter.readDir(directory)) {
444
+ if (entry.isSymlink || entry.isDevice) continue
445
+ if (entry.isDir) await visit(entry.path)
446
+ else total += entry.size
447
+ }
448
+ }
449
+ await visit(path)
450
+ return total
451
+ },
452
+ async disk(pathValue) {
453
+ try {
454
+ const stats = await runtime.statFs(pathArg(pathValue))
455
+ return {
456
+ free: Number(stats.bfree * stats.bsize),
457
+ total: Number(stats.blocks * stats.bsize),
458
+ }
459
+ } catch {
460
+ return null
461
+ }
462
+ },
463
+ copy,
464
+ move,
465
+ async hardlink(fromValue, toValue) {
466
+ const from = pathArg(fromValue)
467
+ const to = pathArg(toValue)
468
+ assertMutable(to)
469
+ if (typeof runtime.link !== "function") throw new Error("hard links are not available in this runtime")
470
+ await withPathLock(to, async () => {
471
+ if (await lstatOrNull(to)) throw pathExistsError(to)
472
+ await runtime.link(from, to)
473
+ })
474
+ },
475
+ async symlink(targetValue, linkValue) {
476
+ const target = pathArg(targetValue)
477
+ const link = pathArg(linkValue)
478
+ assertMutable(link)
479
+ await withPathLock(link, async () => {
480
+ if (await lstatOrNull(link)) throw pathExistsError(link)
481
+ await runtime.symlink(target, link)
482
+ })
483
+ },
484
+ async chmod(pathValue, mode) {
485
+ const path = pathArg(pathValue)
486
+ assertMutable(path)
487
+ await runtime.chmod(path, mode)
488
+ },
489
+ async chown(pathValue, owner) {
490
+ const path = pathArg(pathValue)
491
+ assertMutable(path)
492
+ if (typeof runtime.chown !== "function") throw new Error("chown is not available in this runtime")
493
+ const { uid, gid } = await resolveOwnerIds(runtime, owner)
494
+ await runtime.chown(path, uid, gid)
495
+ },
496
+ async unlink(pathValue) {
497
+ const path = pathArg(pathValue)
498
+ assertMutable(path)
499
+ const stats = await lstatOrNull(path)
500
+ if (!stats?.isSymbolicLink) throw new Error(`not a symbolic link: ${path}`)
501
+ await runtime.remove(path)
502
+ },
503
+ async mkdir(pathValue) {
504
+ const path = pathArg(pathValue)
505
+ assertMutable(path)
506
+ await runtime.makeDir(path, { recursive: true })
507
+ },
508
+ async trash(paths) {
509
+ const sources = (paths ?? []).map(pathArg)
510
+ assertNoNested(sources)
511
+ for (const path of sources) {
512
+ assertMutable(path)
513
+ if (!(await lstatOrNull(path))) throw Object.assign(new Error(`not found: ${path}`), { code: "ENOENT" })
514
+ }
515
+ await runtime.makeDir(trashFiles, { recursive: true })
516
+ await runtime.makeDir(trashInfo, { recursive: true })
517
+ const trashed = []
518
+ for (const from of sources) {
519
+ const { name, file, infoFile, handle } = await reserveTrashName(basename(from))
520
+ try {
521
+ await writeHandle(
522
+ handle,
523
+ `[Trash Info]\nPath=${encodeTrashPath(from)}\nDeletionDate=${new Date().toISOString()}\n`,
524
+ )
525
+ } catch (error) {
526
+ await runtime.remove(infoFile).catch(() => {})
527
+ throw error
528
+ } finally {
529
+ await handle.close()
530
+ }
531
+ try {
532
+ await movePaths(from, file, { internal: true })
533
+ } catch (error) {
534
+ await runtime.remove(infoFile).catch(() => {})
535
+ throw error
536
+ }
537
+ trashed.push({ from, to: file, name })
538
+ }
539
+ return trashed
540
+ },
541
+ async trashList() {
542
+ let names
543
+ try {
544
+ names = await readDirNames(trashInfo)
545
+ } catch (error) {
546
+ if (missing(error)) return []
547
+ throw error
548
+ }
549
+ const rows = []
550
+ for (const filename of names) {
551
+ if (!filename.endsWith(".trashinfo")) continue
552
+ const name = filename.slice(0, -".trashinfo".length)
553
+ const trashPath = joinPath(trashFiles, name)
554
+ let info = {}
555
+ try {
556
+ info = parseTrashInfo(decoder.decode(await runtime.readFile(joinPath(trashInfo, filename))))
557
+ } catch {
558
+ // Show the item even if its metadata has gone missing.
559
+ }
560
+ const stats = await lstatOrNull(trashPath)
561
+ if (!stats) continue
562
+ rows.push({
563
+ name,
564
+ originalPath: info.Path ?? null,
565
+ deletedAt: info.DeletionDate ?? null,
566
+ trashPath,
567
+ isDir: Boolean(stats.isDirectory),
568
+ })
569
+ }
570
+ return rows.sort((a, b) => `${b.deletedAt ?? ""}`.localeCompare(`${a.deletedAt ?? ""}`))
571
+ },
572
+ async restore(paths, { validateDestination } = {}) {
573
+ const restores = []
574
+ const destinations = new Set()
575
+ for (const value of paths ?? []) {
576
+ const trashPath = pathArg(value)
577
+ if (dirname(trashPath) !== trashFiles) throw new Error(`not a trash entry: ${value}`)
578
+ const name = basename(trashPath)
579
+ const infoFile = joinPath(trashInfo, `${name}.trashinfo`)
580
+ const info = parseTrashInfo(decoder.decode(await runtime.readFile(infoFile)))
581
+ if (!info.Path?.startsWith("/")) throw new Error(`no valid original path recorded for ${name}`)
582
+ const original = pathArg(info.Path)
583
+ assertMutable(original)
584
+ const destination = validateDestination ? pathArg(await validateDestination(original)) : original
585
+ if (destinations.has(destination) || (await lstatOrNull(destination))) {
586
+ throw pathExistsError(destination)
587
+ }
588
+ destinations.add(destination)
589
+ restores.push({ trashPath, infoFile, destination })
590
+ }
591
+ assertNoNested(restores.map((restore) => restore.destination))
592
+ for (const { trashPath, infoFile, destination } of restores) {
593
+ if (await lstatOrNull(destination)) throw pathExistsError(destination)
594
+ await runtime.makeDir(dirname(destination), { recursive: true })
595
+ await movePaths(trashPath, destination, { internal: true })
596
+ await runtime.remove(infoFile)
597
+ }
598
+ },
599
+ async erase(paths) {
600
+ const targets = (paths ?? []).map(pathArg)
601
+ assertNoNested(targets)
602
+ for (const path of targets) assertMutable(path)
603
+ for (const path of targets) await runtime.remove(path)
604
+ },
605
+ async touch(pathValue) {
606
+ const path = pathArg(pathValue)
607
+ assertMutable(path)
608
+ const now = new Date()
609
+ try {
610
+ await runtime.utime(path, now, now)
611
+ } catch (error) {
612
+ if (!missing(error)) throw error
613
+ const handle = await runtime.open(path, "a")
614
+ await handle.close()
615
+ }
616
+ },
617
+ home: () => home,
618
+ cwd: () => cwd,
619
+ root: () => root,
620
+ }
621
+ return adapter
622
+ }
623
+
624
+ export { createDesktopFs, dirname, isWithin, normalizePath }
625
+
626
+ // EOF
@@ -0,0 +1,9 @@
1
+ // tinyjs backend entry point. The app bridge is the only path from the webview
2
+ // to filesystem and process access.
3
+
4
+ import { createDesktopBackend } from "./api.js"
5
+
6
+ const desktop = createDesktopBackend({ runtime: tjs })
7
+
8
+ export const api = desktop.api
9
+ export const onOpenFiles = desktop.onOpenFiles
@@ -0,0 +1,23 @@
1
+ // Project: TinyCommand
2
+ // License: BSD
3
+ // Module: tcmd/desktop/bookmarks
4
+ // Persist the shared bookmark model in tinyjs's per-application settings store
5
+ // rather than browser localStorage or the TUI's config file.
6
+
7
+ function createDesktopBookmarkStore({ store, key = "tcmd.bookmarks.v1" } = {}) {
8
+ if (!store?.get || !store?.set) throw new TypeError("tinyjs store API is required")
9
+ return {
10
+ async load() {
11
+ const value = await store.get(key)
12
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {}
13
+ },
14
+ async save(map) {
15
+ const saved = await store.set(key, map)
16
+ if (saved === false) throw new Error("Could not save bookmarks to application storage")
17
+ },
18
+ }
19
+ }
20
+
21
+ export { createDesktopBookmarkStore }
22
+
23
+ // EOF