@ssebastien/tcmd 0.1.3 → 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 (134) hide show
  1. package/README.md +256 -62
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/desktop/dist/index.html +13 -0
  5. package/desktop/dist/main.js +20298 -0
  6. package/desktop/dist/style.css +1515 -0
  7. package/desktop/frontend/index.html +13 -0
  8. package/package.json +19 -6
  9. package/public/fonts/OFL.txt +93 -0
  10. package/public/index.html +14 -0
  11. package/public/licenses/LUCIDE.txt +43 -0
  12. package/public/main.js +20679 -0
  13. package/public/style.css +1515 -0
  14. package/scripts/build-desktop.js +31 -0
  15. package/scripts/serve-web.js +61 -0
  16. package/src/js/tcmd/components/App.html +80 -14
  17. package/src/js/tcmd/components/App.js +339 -114
  18. package/src/js/tcmd/components/App.xml +76 -22
  19. package/src/js/tcmd/components/BookmarkList.html +5 -5
  20. package/src/js/tcmd/components/BookmarkList.js +15 -41
  21. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  22. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  23. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  24. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  25. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  26. package/src/js/tcmd/components/CommandArgRow.html +28 -0
  27. package/src/js/tcmd/components/CommandArgRow.js +146 -30
  28. package/src/js/tcmd/components/CommandArgRow.xml +55 -4
  29. package/src/js/tcmd/components/CommandBar.html +2 -2
  30. package/src/js/tcmd/components/CommandBar.js +33 -27
  31. package/src/js/tcmd/components/CommandBar.xml +2 -2
  32. package/src/js/tcmd/components/CommandItem.html +1 -1
  33. package/src/js/tcmd/components/CommandItem.js +25 -25
  34. package/src/js/tcmd/components/CommandItem.xml +2 -2
  35. package/src/js/tcmd/components/CommandPanel.html +29 -0
  36. package/src/js/tcmd/components/CommandPanel.js +78 -52
  37. package/src/js/tcmd/components/CommandPanel.xml +49 -25
  38. package/src/js/tcmd/components/Confirm.html +2 -2
  39. package/src/js/tcmd/components/EntryDetail.html +7 -7
  40. package/src/js/tcmd/components/EntryDetail.js +22 -8
  41. package/src/js/tcmd/components/FileList.html +5 -1
  42. package/src/js/tcmd/components/FileList.js +2 -1
  43. package/src/js/tcmd/components/FilePicker.html +10 -0
  44. package/src/js/tcmd/components/FilePicker.js +43 -0
  45. package/src/js/tcmd/components/FilePicker.xml +13 -0
  46. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  47. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  48. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  49. package/src/js/tcmd/components/FilePreview.html +14 -4
  50. package/src/js/tcmd/components/FilePreview.js +111 -12
  51. package/src/js/tcmd/components/FilePreview.xml +28 -8
  52. package/src/js/tcmd/components/FileRow.html +2 -1
  53. package/src/js/tcmd/components/FileRow.js +70 -12
  54. package/src/js/tcmd/components/FilterBar.html +1 -1
  55. package/src/js/tcmd/components/FilterBar.js +14 -4
  56. package/src/js/tcmd/components/FilterBar.xml +1 -1
  57. package/src/js/tcmd/components/Frame.js +324 -0
  58. package/src/js/tcmd/components/Help.html +15 -0
  59. package/src/js/tcmd/components/Help.js +260 -0
  60. package/src/js/tcmd/components/Help.xml +13 -4
  61. package/src/js/tcmd/components/HelpRow.html +8 -0
  62. package/src/js/tcmd/components/HelpRow.xml +7 -0
  63. package/src/js/tcmd/components/OverlayList.html +8 -0
  64. package/src/js/tcmd/components/OverlayList.js +69 -0
  65. package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
  66. package/src/js/tcmd/components/OverlayRow.html +5 -0
  67. package/src/js/tcmd/components/OverlayRow.js +47 -0
  68. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  69. package/src/js/tcmd/components/Summary.html +1 -1
  70. package/src/js/tcmd/components/Summary.js +1 -6
  71. package/src/js/tcmd/components/format.js +4 -23
  72. package/src/js/tcmd/components/icons.js +176 -5
  73. package/src/js/tcmd/components/index.js +9 -5
  74. package/src/js/tcmd/components/rows.js +176 -48
  75. package/src/js/tcmd/core/base64.js +64 -0
  76. package/src/js/tcmd/core/bookmarks.js +45 -8
  77. package/src/js/tcmd/core/commands.js +978 -260
  78. package/src/js/tcmd/core/concurrency.js +59 -0
  79. package/src/js/tcmd/core/decompress.js +117 -0
  80. package/src/js/tcmd/core/directory.js +219 -93
  81. package/src/js/tcmd/core/format.js +36 -0
  82. package/src/js/tcmd/core/fs.js +438 -54
  83. package/src/js/tcmd/core/history.js +58 -0
  84. package/src/js/tcmd/core/index.js +3 -9
  85. package/src/js/tcmd/core/journal.js +93 -0
  86. package/src/js/tcmd/core/keymap.js +42 -6
  87. package/src/js/tcmd/core/operations.js +813 -48
  88. package/src/js/tcmd/core/os.js +29 -3
  89. package/src/js/tcmd/core/pane.js +33 -8
  90. package/src/js/tcmd/core/panel.js +280 -104
  91. package/src/js/tcmd/core/path.js +30 -3
  92. package/src/js/tcmd/core/permissions.js +107 -0
  93. package/src/js/tcmd/core/picker.js +156 -0
  94. package/src/js/tcmd/core/preview.js +102 -0
  95. package/src/js/tcmd/core/script.js +7 -95
  96. package/src/js/tcmd/core/selection.js +9 -1
  97. package/src/js/tcmd/core/session.js +498 -200
  98. package/src/js/tcmd/core/watch.js +153 -0
  99. package/src/js/tcmd/desktop/backend/api.js +165 -0
  100. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  101. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  102. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  103. package/src/js/tcmd/desktop/fs.js +91 -0
  104. package/src/js/tcmd/desktop/main.js +194 -0
  105. package/src/js/tcmd/desktop/os.js +33 -0
  106. package/src/js/tcmd/index.js +0 -1
  107. package/src/js/tcmd/tui/app.js +118 -60
  108. package/src/js/tcmd/tui/catalogue.js +238 -42
  109. package/src/js/tcmd/tui/index.js +18 -19
  110. package/src/js/tcmd/tui/opentui.js +33 -0
  111. package/src/js/tcmd/tui/syntax.js +91 -0
  112. package/src/js/tcmd/tui/theme.js +32 -2
  113. package/src/js/tcmd/vendor/bzip2.js +276 -0
  114. package/src/js/tcmd/web/app.js +225 -41
  115. package/src/js/tcmd/web/bookmarks.js +36 -0
  116. package/src/js/tcmd/web/catalogue.js +81 -38
  117. package/src/js/tcmd/web/effects.js +223 -0
  118. package/src/js/tcmd/web/header.html +1 -1
  119. package/src/js/tcmd/web/header.js +15 -11
  120. package/src/js/tcmd/web/http.js +13 -0
  121. package/src/js/tcmd/web/index.js +2 -2
  122. package/src/js/tcmd/web/main.js +6 -66
  123. package/src/js/tcmd/web/opfs.js +297 -30
  124. package/src/js/tcmd/web/seed.js +4 -4
  125. package/src/js/tcmd/web/server.js +124 -21
  126. package/src/js/tcmd/web/theme.js +4 -0
  127. package/tinyjs.json +16 -0
  128. package/src/js/tcmd/components/CommandPalette.html +0 -7
  129. package/src/js/tcmd/components/CommandPalette.js +0 -58
  130. package/src/js/tcmd/components/PaletteRow.html +0 -3
  131. package/src/js/tcmd/components/PaletteRow.js +0 -43
  132. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  133. package/src/js/tcmd/tui/frame.js +0 -269
  134. package/src/js/tcmd/tui/help.js +0 -32
@@ -11,40 +11,91 @@
11
11
  // - `readDir(path)`: Promise<FileEntry-like[]> - directory listing with stat info
12
12
  // - `stat(path)`: Promise<{ isDir, isSymlink, size, mtimeMs, ctimeMs, mode, uid,
13
13
  // gid } | null>
14
+ // - `realpath(path)`: Promise<string> - absolute path with symlinks resolved
14
15
  // - `isDir(path)`: Promise<boolean>
16
+ // - `watch(path, onChange)`: stop() | null, or a promise of either - optional
17
+ // change notification for `path` (async for RPC adapters); unsupported
18
+ // paths are polled by `core/watch`
15
19
  // - `readTextHead(path, maxBytes)`: Promise<string> - leading bytes as text
20
+ // - `readBytes(path, maxBytes)`: Promise<Uint8Array> - leading raw bytes (the
21
+ // compressed previews decompress these)
16
22
  // - `du(path)`: Promise<number> - recursive size in bytes
17
23
  // - `disk(path)`: Promise<{ free, total } | null> - filesystem capacity
18
- // - `copy(from, to)`, `move(from, to)`: Promise<void> - files or directories
19
- // - `trash(paths)`, `erase(paths)`: Promise<void> - delete, with/without trash
24
+ // - `copy(from, to)`, `move(from, to)`: Promise<void> - files or directories;
25
+ // copy can merge directories but never replaces an existing entry, and move
26
+ // requires an absent destination
27
+ // - `hardlink(from, to)`: Promise<void> - create a hard link, refusing an
28
+ // existing destination
29
+ // - `symlink(target, link)`: Promise<void> - create a symbolic link, refusing
30
+ // an existing destination
31
+ // - `chmod(path, mode)`: Promise<void> - set the permission bits (numeric)
32
+ // - `chown(path, owner)`: Promise<void> - set the owner from `USER[:GROUP]`
33
+ // (names or numeric ids)
34
+ // - `unlink(path)`: Promise<void> - remove one symbolic link, leaving its
35
+ // target (and regular files) alone
36
+ // - `mkdir(path)`: Promise<void> - create a directory (parents included)
37
+ // - `trash(paths)`: Promise<TrashedEntry[]> - move to the freedesktop trash,
38
+ // returning `{ from, to, name }` per path (`to` is the trash copy)
39
+ // - `trashList()`: Promise<TrashInfo[]> - the trash contents, newest first,
40
+ // as `{ name, originalPath, deletedAt, trashPath, isDir }`
41
+ // - `restore(trashPaths, options?)`: Promise<void> - move trashed entries back
42
+ // to their recorded original paths; `validateDestination(path)` is an
43
+ // optional host guard (used by the scoped web server)
44
+ // - `erase(paths)`: Promise<void> - delete without trash
20
45
  // - `touch(path)`: Promise<void> - create or bump mtime
21
46
  // - `home()`, `cwd()`: string - well-known directories
47
+ //
48
+ // A listing entry additionally reports symlinks as what they point at:
49
+ // `isSymlink` keeps the entry's own identity, `target` is the raw `readlink`
50
+ // text (relative as written, null otherwise), and `isDir`/`size` follow the
51
+ // link — so a link to a directory sorts, opens and previews as a directory,
52
+ // and a link to a file carries the target's size.
22
53
 
23
54
  import fs from "node:fs/promises"
24
55
  import nodeFs from "node:fs"
25
56
  import nodeOs from "node:os"
26
57
  import nodePath from "node:path"
27
- import { extname, join as pathJoin } from "./path.js"
58
+ import { createLimiter, mapLimit } from "./concurrency.js"
59
+ import { SEP, extname, join as pathJoin, normalize } from "./path.js"
60
+ import { PREVIEW_BYTES } from "./preview.js"
28
61
 
29
62
  // Owner-name caches, filled lazily from /etc/passwd and /etc/group. Browsers
30
63
  // and non-POSIX hosts never populate them (the adapters there omit `owner`).
31
64
  let passwdNames = null
32
65
  let groupNames = null
66
+ // ponytail: process-local destination locks serialize callers here; cross-process no-replace moves need native support.
67
+ const pathLocks = new Map()
33
68
 
34
- // Function: resolveOwner
35
- // "user:group" for a uid/gid, resolving names from /etc/passwd and /etc/group
36
- // (cached once). Falls back to the numeric ids, or "" when both are absent.
37
- async function resolveOwner(uid, gid) {
38
- if (uid == null && gid == null) return ""
69
+ // Function: loadOwners
70
+ // Fills the passwd/group caches once, so `ownerOf` can resolve synchronously.
71
+ // A listing resolves its owners after a single `loadOwners()`, instead of one
72
+ // async round-trip per entry.
73
+ async function loadOwners() {
39
74
  if (process.platform !== "win32") {
40
75
  passwdNames ??= await parseColonFile("/etc/passwd", 2)
41
76
  groupNames ??= await parseColonFile("/etc/group", 2)
42
77
  }
78
+ }
79
+
80
+ // Function: ownerOf
81
+ // "user:group" for a uid/gid from the (already loaded) names maps. Falls back
82
+ // to the numeric ids, or "" when both are absent.
83
+ function ownerOf(uid, gid) {
84
+ if (uid == null && gid == null) return ""
43
85
  const user = uid == null ? "" : passwdNames?.get(uid) ?? `${uid}`
44
86
  const group = gid == null ? "" : groupNames?.get(gid) ?? `${gid}`
45
87
  return user && group ? `${user}:${group}` : user || group
46
88
  }
47
89
 
90
+ // Function: resolveOwner
91
+ // "user:group" for a uid/gid, loading the names maps on first use. Falls back
92
+ // to the numeric ids, or "" when both are absent.
93
+ async function resolveOwner(uid, gid) {
94
+ if (uid == null && gid == null) return ""
95
+ await loadOwners()
96
+ return ownerOf(uid, gid)
97
+ }
98
+
48
99
  // Function: parseColonFile
49
100
  // Reads a passwd/group-style file into a `{ id -> name }` map; unreadable
50
101
  // files yield an empty map (names then fall back to the numeric id).
@@ -63,13 +114,43 @@ async function parseColonFile(path, nameColumn) {
63
114
  return map
64
115
  }
65
116
 
66
- function toEntry(name, dirPath, isDir, stats) {
117
+ // Function: idForName
118
+ // Resolves a user or group name to its numeric id (numeric ids pass through).
119
+ function idForName(names, value, kind) {
120
+ const text = `${value ?? ""}`.trim()
121
+ if (/^\d+$/.test(text)) return Number(text)
122
+ for (const [id, name] of names ?? []) if (name === text) return id
123
+ throw new Error(`chown: unknown ${kind}: ${text}`)
124
+ }
125
+
126
+ // Function: resolveOwnerIds
127
+ // Splits `USER[:GROUP]` into `{ uid, gid }`; an omitted part is -1, which
128
+ // `chown` leaves unchanged.
129
+ async function resolveOwnerIds(owner) {
130
+ const text = `${owner ?? ""}`.trim()
131
+ if (!text) throw new Error("chown: OWNER must be USER[:GROUP]")
132
+ const [user, group] = text.split(":")
133
+ await loadOwners()
134
+ return {
135
+ uid: user ? idForName(passwdNames, user, "user") : -1,
136
+ gid: group ? idForName(groupNames, group, "group") : -1,
137
+ }
138
+ }
139
+
140
+ // Function: toEntry
141
+ // Builds a listing entry. `prefix` is the directory path already ending in the
142
+ // separator, so the listing joins it once instead of normalizing the directory
143
+ // path per entry.
144
+ function toEntry(name, prefix, isDir, stats) {
67
145
  const stat = stats || {}
68
146
  return {
69
147
  name,
70
- path: pathJoin(dirPath, name),
148
+ path: prefix + name,
71
149
  isDir,
72
150
  isSymlink: Boolean(stat.isSymlink),
151
+ // The raw `readlink` text — relative when the link was written that
152
+ // way — so the listing and the preview can show where it points.
153
+ target: stat.target ?? null,
73
154
  isDevice: Boolean(stat.isDevice),
74
155
  size: isDir ? 0 : stat.size ?? 0,
75
156
  mtimeMs: stat.mtimeMs ?? 0,
@@ -89,19 +170,43 @@ function isDeviceNode(node) {
89
170
  )
90
171
  }
91
172
 
173
+ // Constant: READDIR_CONCURRENCY
174
+ // Per-entry stats in a listing are issued concurrently, bounded so a huge
175
+ // directory does not queue tens of thousands of requests at once. The Node
176
+ // threadpool services a handful of filesystem calls at a time, so a modest
177
+ // bound keeps it saturated without the memory churn of one promise per entry.
178
+ const READDIR_CONCURRENCY = 64
179
+
180
+ // Constant: DU_CONCURRENCY
181
+ // Global in-flight bound for the recursive `du` walk, shared across every
182
+ // directory level (see `duSafe`).
183
+ const DU_CONCURRENCY = 64
184
+
92
185
  // Function: createNodeFs
93
186
  // Node/Bun-backed implementation of the filesystem adapter.
94
187
  function createNodeFs() {
95
188
  const adapter = {
96
189
  async readDir(path) {
97
190
  const dirents = await fs.readdir(path, { withFileTypes: true })
98
- const entries = new Array(dirents.length)
99
- for (let i = 0; i < dirents.length; i++) {
100
- const d = dirents[i]
101
- const full = pathJoin(path, d.name)
102
- const stats = await statSafe(full)
103
- entries[i] = toEntry(d.name, path, d.isDirectory(), {
104
- isSymlink: d.isSymbolicLink(),
191
+ // Normalize the directory once, not per entry: `toEntry` appends the
192
+ // name to this prefix.
193
+ const normalized = normalize(path)
194
+ const prefix = normalized === SEP ? SEP : normalized + SEP
195
+ // Resolve owners from the cached maps, once, up front: the per-entry
196
+ // work below then stays purely filesystem-bound and parallel.
197
+ await loadOwners()
198
+ return mapLimit(dirents, READDIR_CONCURRENCY, async (d) => {
199
+ const full = prefix + d.name
200
+ const stats = await statDetails(full)
201
+ const isLink = d.isSymbolicLink()
202
+ // The dirent type describes the link itself, not what it
203
+ // points at: resolve through the followed stat so a link to a
204
+ // directory is listed (sorted, opened, previewed) as one. A
205
+ // broken link has no stats and stays a plain entry.
206
+ const isDir = d.isDirectory() || (isLink && Boolean(stats?.isDir))
207
+ const entry = toEntry(d.name, prefix, isDir, {
208
+ isSymlink: isLink,
209
+ target: isLink ? await readLinkSafe(full) : null,
105
210
  isDevice: isDeviceNode(d),
106
211
  size: stats?.size ?? 0,
107
212
  mtimeMs: stats?.mtimeMs ?? 0,
@@ -110,18 +215,33 @@ function createNodeFs() {
110
215
  uid: stats?.uid ?? null,
111
216
  gid: stats?.gid ?? null,
112
217
  })
113
- entries[i].owner = await resolveOwner(stats?.uid, stats?.gid)
114
- }
115
- return entries
218
+ entry.owner = ownerOf(stats?.uid, stats?.gid)
219
+ return entry
220
+ })
116
221
  },
117
222
  async stat(path) {
118
223
  return statSafe(path)
119
224
  },
225
+ realpath: (path) => fs.realpath(path),
120
226
  async isDir(path) {
121
227
  const stats = await statSafe(path)
122
228
  return Boolean(stats?.isDir)
123
229
  },
124
- async readTextHead(path, maxBytes = 65536) {
230
+ // Function: watch (adapter method)
231
+ // Reports changes inside `path` through Node's directory watcher. The
232
+ // callback is coalesced by `core/watch`; the returned stop closes the
233
+ // watcher. A path this process cannot watch (watch limit, permissions,
234
+ // a deleted directory) yields null so the caller falls back to polling.
235
+ watch(path, onChange) {
236
+ try {
237
+ const watcher = nodeFs.watch(path, { persistent: false }, () => onChange?.())
238
+ watcher.on?.("error", () => watcher.close())
239
+ return () => watcher.close()
240
+ } catch (error) {
241
+ return null
242
+ }
243
+ },
244
+ async readTextHead(path, maxBytes = PREVIEW_BYTES) {
125
245
  const file = nodeFs.openSync(path, "r")
126
246
  try {
127
247
  const buffer = Buffer.alloc(maxBytes)
@@ -131,6 +251,16 @@ function createNodeFs() {
131
251
  nodeFs.closeSync(file)
132
252
  }
133
253
  },
254
+ async readBytes(path, maxBytes = PREVIEW_BYTES) {
255
+ const file = nodeFs.openSync(path, "r")
256
+ try {
257
+ const buffer = Buffer.alloc(maxBytes)
258
+ const bytes = nodeFs.readSync(file, buffer, 0, maxBytes, 0)
259
+ return new Uint8Array(buffer.subarray(0, bytes))
260
+ } finally {
261
+ nodeFs.closeSync(file)
262
+ }
263
+ },
134
264
  async du(path) {
135
265
  return duSafe(path)
136
266
  },
@@ -143,35 +273,139 @@ function createNodeFs() {
143
273
  }
144
274
  },
145
275
  async copy(from, to) {
146
- await fs.cp(from, to, { recursive: true })
276
+ return withPathLock(to, async () => {
277
+ await assertCopySafe(from, to)
278
+ await fs.cp(from, to, { recursive: true })
279
+ })
147
280
  },
148
281
  async move(from, to) {
149
- try {
150
- await fs.rename(from, to)
151
- } catch (error) {
152
- if (error?.code !== "EXDEV") throw error
153
- await fs.cp(from, to, { recursive: true })
154
- await fs.rm(from, { recursive: true })
155
- }
282
+ await moveSafe(from, to)
283
+ },
284
+ async hardlink(from, to) {
285
+ await withPathLock(to, async () => {
286
+ if (await pathExists(to)) throw pathExistsError(to)
287
+ await fs.link(from, to)
288
+ })
289
+ },
290
+ async symlink(target, link) {
291
+ await withPathLock(link, async () => {
292
+ if (await pathExists(link)) throw pathExistsError(link)
293
+ await fs.symlink(target, link)
294
+ })
295
+ },
296
+ async chmod(path, mode) {
297
+ await fs.chmod(path, mode)
298
+ },
299
+ async chown(path, owner) {
300
+ const { uid, gid } = await resolveOwnerIds(owner)
301
+ await fs.chown(path, uid, gid)
302
+ },
303
+ async unlink(path) {
304
+ const stats = await fs.lstat(path)
305
+ if (!stats.isSymbolicLink()) throw new Error(`not a symbolic link: ${path}`)
306
+ await fs.unlink(path)
307
+ },
308
+ async mkdir(path) {
309
+ await fs.mkdir(path, { recursive: true })
156
310
  },
157
311
  async trash(paths) {
158
312
  const filesDir = trashDir("files")
159
313
  const infoDir = trashDir("info")
160
314
  await fs.mkdir(filesDir, { recursive: true })
161
315
  await fs.mkdir(infoDir, { recursive: true })
316
+ const trashed = []
162
317
  for (const source of paths) {
163
318
  const absolute = nodePath.resolve(source)
164
- const name = await freeTrashName(filesDir, nodePath.basename(absolute))
319
+ const { name, infoFile, handle } = await reserveTrashName(
320
+ filesDir,
321
+ infoDir,
322
+ nodePath.basename(absolute),
323
+ )
165
324
  const target = nodePath.join(filesDir, name)
325
+ const info = `[Trash Info]\nPath=${encodeTrashPath(absolute)}\nDeletionDate=${new Date().toISOString()}\n`
326
+ try {
327
+ await handle.writeFile(info)
328
+ await handle.close()
329
+ } catch (error) {
330
+ await handle.close().catch(() => {})
331
+ await fs.rm(infoFile, { force: true })
332
+ throw error
333
+ }
334
+ await handle.close()
335
+ try {
336
+ await moveSafe(absolute, target)
337
+ } catch (error) {
338
+ await fs.rm(infoFile, { force: true })
339
+ throw error
340
+ }
341
+ trashed.push({ from: absolute, to: target, name })
342
+ }
343
+ return trashed
344
+ },
345
+ async trashList() {
346
+ const filesDir = trashDir("files")
347
+ const infoDir = trashDir("info")
348
+ let files
349
+ try {
350
+ files = await fs.readdir(infoDir)
351
+ } catch (error) {
352
+ return []
353
+ }
354
+ const rows = []
355
+ for (const file of files) {
356
+ if (!file.endsWith(".trashinfo")) continue
357
+ const name = file.slice(0, -".trashinfo".length)
358
+ const trashPath = nodePath.join(filesDir, name)
359
+ let info = {}
360
+ try {
361
+ info = parseTrashInfo(await fs.readFile(nodePath.join(infoDir, file), "utf8"))
362
+ } catch (error) {
363
+ // A missing or unreadable info file leaves the row without a
364
+ // recorded original path; it is still listed.
365
+ }
366
+ let stats
166
367
  try {
167
- await fs.rename(absolute, target)
368
+ stats = await fs.lstat(trashPath)
168
369
  } catch (error) {
169
- if (error?.code !== "EXDEV") throw error
170
- await fs.cp(absolute, target, { recursive: true })
171
- await fs.rm(absolute, { recursive: true })
370
+ if (error?.code === "ENOENT") continue
371
+ throw error
372
+ }
373
+ rows.push({
374
+ name,
375
+ originalPath: info.Path ?? null,
376
+ deletedAt: info.DeletionDate ?? null,
377
+ trashPath,
378
+ isDir: stats.isDirectory(),
379
+ })
380
+ }
381
+ return rows.sort((a, b) => `${b.deletedAt ?? ""}`.localeCompare(`${a.deletedAt ?? ""}`))
382
+ },
383
+ async restore(trashPaths, { validateDestination } = {}) {
384
+ const filesDir = nodePath.resolve(trashDir("files"))
385
+ const infoDir = trashDir("info")
386
+ const restores = []
387
+ const destinations = new Set()
388
+ for (const path of trashPaths ?? []) {
389
+ const trashPath = nodePath.resolve(path)
390
+ if (nodePath.dirname(trashPath) !== filesDir) throw new Error(`not a trash entry: ${path}`)
391
+ const name = nodePath.basename(trashPath)
392
+ const infoFile = nodePath.join(infoDir, `${name}.trashinfo`)
393
+ const info = parseTrashInfo(await fs.readFile(infoFile, "utf8"))
394
+ if (!info.Path) throw new Error(`no original path recorded for ${name}`)
395
+ if (!nodePath.isAbsolute(info.Path)) throw new Error(`invalid original path for ${name}`)
396
+ const destination = validateDestination ? await validateDestination(info.Path) : info.Path
397
+ if (destinations.has(destination) || (await pathExists(destination))) {
398
+ throw pathExistsError(destination)
172
399
  }
173
- const info = `[Trash Info]\nPath=${absolute}\nDeletionDate=${new Date().toISOString()}\n`
174
- await fs.writeFile(nodePath.join(infoDir, `${name}.trashinfo`), info)
400
+ destinations.add(destination)
401
+ restores.push({ trashPath, infoFile, destination })
402
+ }
403
+ assertNoNestedDestinations(restores.map((restore) => restore.destination))
404
+ for (const { trashPath, infoFile, destination } of restores) {
405
+ if (await pathExists(destination)) throw pathExistsError(destination)
406
+ await fs.mkdir(nodePath.dirname(destination), { recursive: true })
407
+ await moveSafe(trashPath, destination)
408
+ await fs.rm(infoFile, { force: true })
175
409
  }
176
410
  },
177
411
  async erase(paths) {
@@ -204,20 +438,151 @@ function trashDir(kind) {
204
438
  return nodePath.join(dataHome, "Trash", kind)
205
439
  }
206
440
 
207
- // Function: freeTrashName
208
- // First free `<name>`, `<name>.1`, … in the trash files directory.
209
- async function freeTrashName(filesDir, base) {
441
+ // Function: reserveTrashName
442
+ // Atomically reserves the `.trashinfo` name so concurrent trash operations do
443
+ // not choose the same files/info pair.
444
+ async function reserveTrashName(filesDir, infoDir, base) {
210
445
  for (let i = 0; ; i++) {
211
446
  const name = i === 0 ? base : `${base}.${i}`
447
+ const target = nodePath.join(filesDir, name)
448
+ const infoFile = nodePath.join(infoDir, `${name}.trashinfo`)
449
+ if (await pathExists(target)) continue
450
+ let handle
212
451
  try {
213
- await fs.access(nodePath.join(filesDir, name))
452
+ handle = await fs.open(infoFile, "wx")
214
453
  } catch (error) {
215
- return name
454
+ if (error?.code === "EEXIST") continue
455
+ throw error
216
456
  }
457
+ if (await pathExists(target)) {
458
+ await handle.close()
459
+ await fs.rm(infoFile, { force: true })
460
+ continue
461
+ }
462
+ return { name, infoFile, handle }
217
463
  }
218
464
  }
219
465
 
220
- async function statSafe(path) {
466
+ // Function: moveSafe
467
+ // Renames `from` to `to`, falling back to copy+remove across filesystems
468
+ // (EXDEV), where `rename` cannot cross devices.
469
+ async function moveSafe(from, to) {
470
+ return withPathLock(to, async () => {
471
+ if (await pathExists(to)) throw pathExistsError(to)
472
+ try {
473
+ await fs.rename(from, to)
474
+ } catch (error) {
475
+ if (error?.code !== "EXDEV") throw error
476
+ await fs.cp(from, to, { recursive: true })
477
+ await fs.rm(from, { recursive: true })
478
+ }
479
+ })
480
+ }
481
+
482
+ async function withPathLock(path, action) {
483
+ const key = nodePath.resolve(path)
484
+ const previous = pathLocks.get(key) ?? Promise.resolve()
485
+ let release
486
+ const gate = new Promise((resolve) => (release = resolve))
487
+ const tail = previous.then(() => gate)
488
+ pathLocks.set(key, tail)
489
+ await previous
490
+ try {
491
+ return await action()
492
+ } finally {
493
+ release()
494
+ if (pathLocks.get(key) === tail) pathLocks.delete(key)
495
+ }
496
+ }
497
+
498
+ async function assertCopySafe(from, to) {
499
+ const source = await fs.lstat(from)
500
+ const sourcePath = nodePath.resolve(from)
501
+ const targetPath = nodePath.resolve(to)
502
+ const relative = nodePath.relative(sourcePath, targetPath)
503
+ if (
504
+ sourcePath === targetPath ||
505
+ (source.isDirectory() && relative && relative !== ".." && !relative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(relative))
506
+ ) {
507
+ throw new Error(`cannot copy a path into itself: ${to}`)
508
+ }
509
+ let destination
510
+ try {
511
+ destination = await fs.lstat(to)
512
+ } catch (error) {
513
+ if (error?.code === "ENOENT") return
514
+ throw error
515
+ }
516
+ if (!source.isDirectory() || !destination.isDirectory()) throw pathExistsError(to)
517
+ for (const name of await fs.readdir(from)) {
518
+ await assertCopySafe(nodePath.join(from, name), nodePath.join(to, name))
519
+ }
520
+ }
521
+
522
+ function assertNoNestedDestinations(paths) {
523
+ for (let i = 0; i < paths.length; i++) {
524
+ for (let j = i + 1; j < paths.length; j++) {
525
+ const relative = nodePath.relative(paths[i], paths[j])
526
+ const reverse = nodePath.relative(paths[j], paths[i])
527
+ if (
528
+ (relative && relative !== ".." && !relative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(relative)) ||
529
+ (reverse && reverse !== ".." && !reverse.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(reverse))
530
+ ) {
531
+ throw pathExistsError(paths[j])
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ // Function: parseTrashInfo
538
+ // Parses a freedesktop `.trashinfo` file (`Key=Value` lines under a
539
+ // `[Trash Info]` header) into a `{ Path, DeletionDate }` map.
540
+ function parseTrashInfo(text) {
541
+ const info = {}
542
+ for (const line of `${text ?? ""}`.split("\n")) {
543
+ const eq = line.indexOf("=")
544
+ if (eq <= 0) continue
545
+ const key = line.slice(0, eq).trim()
546
+ const value = line.slice(eq + 1).trim()
547
+ if (key === "Path") {
548
+ try {
549
+ info[key] = decodeURIComponent(value)
550
+ } catch (error) {
551
+ info[key] = value
552
+ }
553
+ } else {
554
+ info[key] = value
555
+ }
556
+ }
557
+ return info
558
+ }
559
+
560
+ // Function: encodeTrashPath
561
+ // Percent-encodes path components for the freedesktop `.trashinfo` format.
562
+ function encodeTrashPath(path) {
563
+ return `${path}`.split(nodePath.sep).map(encodeURIComponent).join(nodePath.sep)
564
+ }
565
+
566
+ async function pathExists(path) {
567
+ try {
568
+ await fs.lstat(path)
569
+ return true
570
+ } catch (error) {
571
+ if (error?.code === "ENOENT") return false
572
+ throw error
573
+ }
574
+ }
575
+
576
+ function pathExistsError(path) {
577
+ const error = new Error(`destination already exists: ${path}`)
578
+ error.code = "EEXIST"
579
+ return error
580
+ }
581
+
582
+ // Function: statDetails
583
+ // The stat fields a listing needs, with no owner lookup — `readDir` resolves
584
+ // owners itself, from the cached maps, so the per-entry work stays parallel.
585
+ async function statDetails(path) {
221
586
  try {
222
587
  const stats = await fs.stat(path)
223
588
  return {
@@ -230,34 +595,53 @@ async function statSafe(path) {
230
595
  mode: stats.mode,
231
596
  uid: stats.uid,
232
597
  gid: stats.gid,
233
- owner: await resolveOwner(stats.uid, stats.gid),
234
598
  }
235
599
  } catch (error) {
236
600
  return null
237
601
  }
238
602
  }
239
603
 
604
+ async function statSafe(path) {
605
+ const details = await statDetails(path)
606
+ if (!details) return null
607
+ return { ...details, owner: await resolveOwner(details.uid, details.gid) }
608
+ }
609
+
610
+ // Function: readLinkSafe
611
+ // The raw `readlink` text of `path`, or null when it is not a link (or the
612
+ // link cannot be read). Relative targets stay relative, as written.
613
+ async function readLinkSafe(path) {
614
+ try {
615
+ return await fs.readlink(path)
616
+ } catch (error) {
617
+ return null
618
+ }
619
+ }
620
+
240
621
  // Function: duSafe
241
- // Recursive disk usage; unreadable entries contribute 0 and never throw.
622
+ // Recursive disk usage; unreadable entries contribute 0 and never throw. The
623
+ // walk fans out per directory but every filesystem call takes a slot from one
624
+ // global limiter, so a deep tree cannot multiply the concurrency per level.
625
+ const duLimit = createLimiter(DU_CONCURRENCY)
242
626
  async function duSafe(path) {
243
- const stats = await statSafe(path)
627
+ const stats = await duLimit(() => statDetails(path))
244
628
  if (!stats) return 0
245
629
  if (!stats.isDir) return stats.size
246
- let total = 0
247
630
  let dirents
248
631
  try {
249
- dirents = await fs.readdir(path, { withFileTypes: true })
632
+ dirents = await duLimit(() => fs.readdir(path, { withFileTypes: true }))
250
633
  } catch (error) {
251
634
  return 0
252
635
  }
253
- for (const d of dirents) {
636
+ const sizes = await mapLimit(dirents, DU_CONCURRENCY, (d) => {
637
+ // A symlink contributes nothing, matching the old link guard.
638
+ if (d.isSymbolicLink()) return 0
254
639
  const full = pathJoin(path, d.name)
255
- if (d.isDirectory()) total += await duSafe(full)
256
- else if (!d.isSymbolicLink()) {
257
- const child = await statSafe(full)
258
- total += child?.size ?? 0
259
- }
260
- }
640
+ if (d.isDirectory()) return duSafe(full)
641
+ return duLimit(() => statDetails(full)).then((child) => child?.size ?? 0)
642
+ })
643
+ let total = 0
644
+ for (const size of sizes) total += size
261
645
  return total
262
646
  }
263
647
 
@@ -0,0 +1,58 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-14
5
+
6
+ // Module: tcmd/core/history
7
+ // A browser-like history over snapshots: a stack with a cursor (`index`) into
8
+ // it. `push` truncates the forward branch, `back`/`forward` move the cursor and
9
+ // return the snapshot to restore, and `replace` updates the current entry in
10
+ // place. Pure bookkeeping — callers own what a snapshot contains and how to
11
+ // apply it.
12
+
13
+ // Function: createHistory
14
+ // Creates a history seeded with `initial` and the cursor on it.
15
+ function createHistory(initial) {
16
+ const stack = [initial]
17
+ let index = 0
18
+ return {
19
+ stack,
20
+ get index() {
21
+ return index
22
+ },
23
+ // Function: current
24
+ // The snapshot the cursor is on.
25
+ current: () => stack[index],
26
+ // Function: push
27
+ // Records `snapshot` after the cursor, dropping the forward branch.
28
+ push(snapshot) {
29
+ stack.length = index + 1
30
+ stack.push(snapshot)
31
+ index = stack.length - 1
32
+ },
33
+ // Function: replace
34
+ // Updates the current entry in place, so a later return restores the
35
+ // state as it was left (moved cursor, marks, filter).
36
+ replace(snapshot) {
37
+ stack[index] = snapshot
38
+ },
39
+ // Function: back
40
+ // Moves one entry back and returns its snapshot, or null at the start.
41
+ back() {
42
+ if (index <= 0) return null
43
+ index -= 1
44
+ return stack[index]
45
+ },
46
+ // Function: forward
47
+ // Moves one entry forward and returns its snapshot, or null at the end.
48
+ forward() {
49
+ if (index >= stack.length - 1) return null
50
+ index += 1
51
+ return stack[index]
52
+ },
53
+ }
54
+ }
55
+
56
+ export { createHistory }
57
+
58
+ // EOF