@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
@@ -11,12 +11,37 @@
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
22
47
  //
@@ -30,27 +55,47 @@ import fs from "node:fs/promises"
30
55
  import nodeFs from "node:fs"
31
56
  import nodeOs from "node:os"
32
57
  import nodePath from "node:path"
33
- 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"
34
61
 
35
62
  // Owner-name caches, filled lazily from /etc/passwd and /etc/group. Browsers
36
63
  // and non-POSIX hosts never populate them (the adapters there omit `owner`).
37
64
  let passwdNames = null
38
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()
39
68
 
40
- // Function: resolveOwner
41
- // "user:group" for a uid/gid, resolving names from /etc/passwd and /etc/group
42
- // (cached once). Falls back to the numeric ids, or "" when both are absent.
43
- async function resolveOwner(uid, gid) {
44
- 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() {
45
74
  if (process.platform !== "win32") {
46
75
  passwdNames ??= await parseColonFile("/etc/passwd", 2)
47
76
  groupNames ??= await parseColonFile("/etc/group", 2)
48
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 ""
49
85
  const user = uid == null ? "" : passwdNames?.get(uid) ?? `${uid}`
50
86
  const group = gid == null ? "" : groupNames?.get(gid) ?? `${gid}`
51
87
  return user && group ? `${user}:${group}` : user || group
52
88
  }
53
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
+
54
99
  // Function: parseColonFile
55
100
  // Reads a passwd/group-style file into a `{ id -> name }` map; unreadable
56
101
  // files yield an empty map (names then fall back to the numeric id).
@@ -69,11 +114,38 @@ async function parseColonFile(path, nameColumn) {
69
114
  return map
70
115
  }
71
116
 
72
- 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) {
73
145
  const stat = stats || {}
74
146
  return {
75
147
  name,
76
- path: pathJoin(dirPath, name),
148
+ path: prefix + name,
77
149
  isDir,
78
150
  isSymlink: Boolean(stat.isSymlink),
79
151
  // The raw `readlink` text — relative when the link was written that
@@ -98,24 +170,41 @@ function isDeviceNode(node) {
98
170
  )
99
171
  }
100
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
+
101
185
  // Function: createNodeFs
102
186
  // Node/Bun-backed implementation of the filesystem adapter.
103
187
  function createNodeFs() {
104
188
  const adapter = {
105
189
  async readDir(path) {
106
190
  const dirents = await fs.readdir(path, { withFileTypes: true })
107
- const entries = new Array(dirents.length)
108
- for (let i = 0; i < dirents.length; i++) {
109
- const d = dirents[i]
110
- const full = pathJoin(path, d.name)
111
- const stats = await statSafe(full)
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)
112
201
  const isLink = d.isSymbolicLink()
113
202
  // The dirent type describes the link itself, not what it
114
203
  // points at: resolve through the followed stat so a link to a
115
204
  // directory is listed (sorted, opened, previewed) as one. A
116
205
  // broken link has no stats and stays a plain entry.
117
206
  const isDir = d.isDirectory() || (isLink && Boolean(stats?.isDir))
118
- entries[i] = toEntry(d.name, path, isDir, {
207
+ const entry = toEntry(d.name, prefix, isDir, {
119
208
  isSymlink: isLink,
120
209
  target: isLink ? await readLinkSafe(full) : null,
121
210
  isDevice: isDeviceNode(d),
@@ -126,18 +215,33 @@ function createNodeFs() {
126
215
  uid: stats?.uid ?? null,
127
216
  gid: stats?.gid ?? null,
128
217
  })
129
- entries[i].owner = await resolveOwner(stats?.uid, stats?.gid)
130
- }
131
- return entries
218
+ entry.owner = ownerOf(stats?.uid, stats?.gid)
219
+ return entry
220
+ })
132
221
  },
133
222
  async stat(path) {
134
223
  return statSafe(path)
135
224
  },
225
+ realpath: (path) => fs.realpath(path),
136
226
  async isDir(path) {
137
227
  const stats = await statSafe(path)
138
228
  return Boolean(stats?.isDir)
139
229
  },
140
- 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) {
141
245
  const file = nodeFs.openSync(path, "r")
142
246
  try {
143
247
  const buffer = Buffer.alloc(maxBytes)
@@ -147,6 +251,16 @@ function createNodeFs() {
147
251
  nodeFs.closeSync(file)
148
252
  }
149
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
+ },
150
264
  async du(path) {
151
265
  return duSafe(path)
152
266
  },
@@ -159,35 +273,139 @@ function createNodeFs() {
159
273
  }
160
274
  },
161
275
  async copy(from, to) {
162
- 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
+ })
163
280
  },
164
281
  async move(from, to) {
165
- try {
166
- await fs.rename(from, to)
167
- } catch (error) {
168
- if (error?.code !== "EXDEV") throw error
169
- await fs.cp(from, to, { recursive: true })
170
- await fs.rm(from, { recursive: true })
171
- }
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 })
172
310
  },
173
311
  async trash(paths) {
174
312
  const filesDir = trashDir("files")
175
313
  const infoDir = trashDir("info")
176
314
  await fs.mkdir(filesDir, { recursive: true })
177
315
  await fs.mkdir(infoDir, { recursive: true })
316
+ const trashed = []
178
317
  for (const source of paths) {
179
318
  const absolute = nodePath.resolve(source)
180
- const name = await freeTrashName(filesDir, nodePath.basename(absolute))
319
+ const { name, infoFile, handle } = await reserveTrashName(
320
+ filesDir,
321
+ infoDir,
322
+ nodePath.basename(absolute),
323
+ )
181
324
  const target = nodePath.join(filesDir, name)
325
+ const info = `[Trash Info]\nPath=${encodeTrashPath(absolute)}\nDeletionDate=${new Date().toISOString()}\n`
182
326
  try {
183
- await fs.rename(absolute, target)
327
+ await handle.writeFile(info)
328
+ await handle.close()
184
329
  } catch (error) {
185
- if (error?.code !== "EXDEV") throw error
186
- await fs.cp(absolute, target, { recursive: true })
187
- await fs.rm(absolute, { recursive: true })
330
+ await handle.close().catch(() => {})
331
+ await fs.rm(infoFile, { force: true })
332
+ throw error
188
333
  }
189
- const info = `[Trash Info]\nPath=${absolute}\nDeletionDate=${new Date().toISOString()}\n`
190
- await fs.writeFile(nodePath.join(infoDir, `${name}.trashinfo`), info)
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
367
+ try {
368
+ stats = await fs.lstat(trashPath)
369
+ } catch (error) {
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)
399
+ }
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 })
191
409
  }
192
410
  },
193
411
  async erase(paths) {
@@ -220,20 +438,151 @@ function trashDir(kind) {
220
438
  return nodePath.join(dataHome, "Trash", kind)
221
439
  }
222
440
 
223
- // Function: freeTrashName
224
- // First free `<name>`, `<name>.1`, … in the trash files directory.
225
- 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) {
226
445
  for (let i = 0; ; i++) {
227
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
228
451
  try {
229
- await fs.access(nodePath.join(filesDir, name))
452
+ handle = await fs.open(infoFile, "wx")
230
453
  } catch (error) {
231
- return name
454
+ if (error?.code === "EEXIST") continue
455
+ throw error
232
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 }
233
463
  }
234
464
  }
235
465
 
236
- 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) {
237
586
  try {
238
587
  const stats = await fs.stat(path)
239
588
  return {
@@ -246,13 +595,18 @@ async function statSafe(path) {
246
595
  mode: stats.mode,
247
596
  uid: stats.uid,
248
597
  gid: stats.gid,
249
- owner: await resolveOwner(stats.uid, stats.gid),
250
598
  }
251
599
  } catch (error) {
252
600
  return null
253
601
  }
254
602
  }
255
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
+
256
610
  // Function: readLinkSafe
257
611
  // The raw `readlink` text of `path`, or null when it is not a link (or the
258
612
  // link cannot be read). Relative targets stay relative, as written.
@@ -265,26 +619,29 @@ async function readLinkSafe(path) {
265
619
  }
266
620
 
267
621
  // Function: duSafe
268
- // 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)
269
626
  async function duSafe(path) {
270
- const stats = await statSafe(path)
627
+ const stats = await duLimit(() => statDetails(path))
271
628
  if (!stats) return 0
272
629
  if (!stats.isDir) return stats.size
273
- let total = 0
274
630
  let dirents
275
631
  try {
276
- dirents = await fs.readdir(path, { withFileTypes: true })
632
+ dirents = await duLimit(() => fs.readdir(path, { withFileTypes: true }))
277
633
  } catch (error) {
278
634
  return 0
279
635
  }
280
- 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
281
639
  const full = pathJoin(path, d.name)
282
- if (d.isDirectory()) total += await duSafe(full)
283
- else if (!d.isSymbolicLink()) {
284
- const child = await statSafe(full)
285
- total += child?.size ?? 0
286
- }
287
- }
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
288
645
  return total
289
646
  }
290
647