@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
@@ -10,23 +10,33 @@
10
10
  // Operations own their side effects and return a one-line message (shown in
11
11
  // the command bar) or undefined.
12
12
  //
13
- // Registry entries carry the presentation metadata the panel renders:
14
- // `signature`, `description`, `aliases` and an optional `confirmText`. An
15
- // optional `risk` ("caution" for a move, "destructive" for delete/erase) lets
16
- // the command panel tint the operation by how dangerous it is.
13
+ // Registry entries carry presentation metadata used by the panel and help:
14
+ // `signature`, `description`, `aliases`, `group` and an optional `confirmText`.
15
+ // An optional `displayCommand(session, argv)` overrides the `[Confirm]` line,
16
+ // and `preview(session, argv, draft)` supplies operation-specific preflight
17
+ // output. `group` places the operation in the command panel (Command, File,
18
+ // Info, Create, Edit, Delete); `sel` is script-only and has none. An optional
19
+ // `risk` ("caution" or "destructive") is the single source for the
20
+ // operation's colour treatment and the panel's two-step `[Confirm]` step.
17
21
  //
18
22
  // They also carry `args`, the structured signature the command panel renders:
19
23
  // one descriptor per positional argument. A descriptor is
20
- // `{ name, kind, role?, optional?, variadic? }` where `kind` is:
24
+ // `{ name, kind, role?, optional?, variadic?, panelOrder?, prefill? }` where
25
+ // `kind` is:
21
26
  // - "selection": one or more paths from a pane's selection (marks, falling
22
27
  // back to the cursor entry). `role` picks the pane: "source" (SRC), "dest"
23
28
  // (DST) or "active" (default).
24
29
  // - "dest": a single destination directory (the DST pane's path).
30
+ // - "path": a typeable/browsable path; `prefill: "current"` seeds it with the
31
+ // active pane's path.
32
+ // - "permissions": the chmod R/W/X grid for u/g/o.
25
33
  // - "buffer": a selection-buffer label (`+A`, `-B`, …).
26
34
  // - "literal": free text (a name), edited in the panel's text field.
27
35
  // - "raw": the rest of the line, unparsed (a shell command).
36
+ // `panelOrder` reorders only the panel rows, leaving the argv order intact.
28
37
 
29
- import { basename, extname, join, normalize, parent as parentOf } from "./path.js"
38
+ import { formatDisk, formatSize } from "./format.js"
39
+ import { basename, extname, join, normalize, parent as parentOf, relative } from "./path.js"
30
40
  import { currentPane, currentPaneId, PANE_BUFFERS } from "./pane.js"
31
41
 
32
42
  // Constant: PATHS_PREFIX
@@ -36,6 +46,27 @@ import { currentPane, currentPaneId, PANE_BUFFERS } from "./pane.js"
36
46
  // an async confirm re-runs it.
37
47
  const PATHS_PREFIX = "$paths:"
38
48
 
49
+ // Function: absolutePath
50
+ // Resolves dot segments in an already absolute path using POSIX path rules.
51
+ function absolutePath(path) {
52
+ const parts = []
53
+ for (const part of `${path ?? ""}`.split("/")) {
54
+ if (!part || part === ".") continue
55
+ if (part === "..") parts.pop()
56
+ else parts.push(part)
57
+ }
58
+ return `/${parts.join("/")}`
59
+ }
60
+
61
+ // Function: shellQuote
62
+ // Quotes a token only when needed, so displayed path results and panel previews
63
+ // can be pasted as shell arguments (including spaces or apostrophes).
64
+ function shellQuote(token) {
65
+ const text = `${token ?? ""}`
66
+ if (text === "") return "''"
67
+ return /^[A-Za-z0-9_@%+=:,.\/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
68
+ }
69
+
39
70
  // Function: pathToken
40
71
  // Encodes a resolved path list as one `$paths:` token.
41
72
  function pathToken(paths) {
@@ -58,8 +89,10 @@ function decodePathToken(text) {
58
89
  const operations = [
59
90
  {
60
91
  name: "copy",
92
+ group: "Edit",
61
93
  signature: "copy FROM TO",
62
94
  description: "Copy files and directories",
95
+ risk: "caution",
63
96
  arity: { min: 2, max: 2 },
64
97
  args: [
65
98
  { name: "FROM", kind: "selection", role: "source", variadic: true },
@@ -67,6 +100,7 @@ const operations = [
67
100
  ],
68
101
  async run(session, args) {
69
102
  const sources = expandAll(session, [args[0]])
103
+ assertNoOverlappingPaths(sources, "copy")
70
104
  const targets = expandAll(session, [args[1]])
71
105
  if (targets.length !== 1) throw new Error("copy: TO must be a single path")
72
106
  const [target] = targets
@@ -74,16 +108,139 @@ const operations = [
74
108
  throw new Error("copy: TO must be an existing directory for multiple FROM")
75
109
  }
76
110
  const into = await isDir(session, target)
111
+ const copies = []
77
112
  for (const source of sources) {
78
113
  if (!(await session.fs.stat(source))) throw new Error(`not found: ${source}`)
79
- await session.fs.copy(source, into ? join(target, basename(source)) : target)
114
+ const destination = into ? join(target, basename(source)) : target
115
+ const existed = Boolean(await session.fs.stat(destination))
116
+ await session.fs.copy(source, destination)
117
+ copies.push({ from: source, to: destination, existed })
80
118
  }
81
119
  await refreshPanes(session)
120
+ // Undo removes what was copied, but only when nothing pre-existed:
121
+ // erasing a target that was merged into would drop earlier content.
122
+ if (copies.every((copy) => !copy.existed)) {
123
+ let snapshots = await Promise.all(copies.map((copy) => pathSnapshot(session.fs, copy.to)))
124
+ if (snapshots.some((snapshot) => snapshot === null)) throw new Error("copy target disappeared")
125
+ recordHistory(session, {
126
+ label: `copy ${sources.length} path${sources.length === 1 ? "" : "s"} → ${target}`,
127
+ undo: async () => {
128
+ for (let i = 0; i < copies.length; i++) {
129
+ if (JSON.stringify(await pathSnapshot(session.fs, copies[i].to)) !== JSON.stringify(snapshots[i])) {
130
+ throw new Error(`copy target changed: ${copies[i].to}`)
131
+ }
132
+ }
133
+ await session.fs.erase(copies.map((copy) => copy.to))
134
+ },
135
+ redo: async () => {
136
+ await assertPathsAbsent(session.fs, copies.map((copy) => copy.to))
137
+ for (const copy of copies) await session.fs.copy(copy.from, copy.to)
138
+ snapshots = await Promise.all(copies.map((copy) => pathSnapshot(session.fs, copy.to)))
139
+ },
140
+ })
141
+ }
82
142
  return `copied ${sources.length} path${sources.length === 1 ? "" : "s"} to ${target}`
83
143
  },
84
144
  },
145
+ {
146
+ name: "mkdir",
147
+ group: "Create",
148
+ signature: "mkdir PATH...",
149
+ description: "Create directories",
150
+ arity: { min: 1 },
151
+ args: [
152
+ { name: "PATH", kind: "path", mode: "dir", prefill: "current" },
153
+ { name: "NAME", kind: "literal" },
154
+ ],
155
+ async run(session, args, options) {
156
+ const paths = creationPaths(session, args, options)
157
+ const requested = new Set()
158
+ const created = new Set()
159
+ for (const path of paths) {
160
+ const normalized = normalize(path)
161
+ if (requested.has(normalized)) throw new Error(`duplicate path: ${normalized}`)
162
+ requested.add(normalized)
163
+ if (await pathExists(session.fs, normalized)) throw new Error(`already exists: ${normalized}`)
164
+ let current = normalized
165
+ while (current !== "/") {
166
+ const stats = await session.fs.stat(current)
167
+ if (stats) {
168
+ if (!stats.isDir) throw new Error(`not a directory: ${current}`)
169
+ break
170
+ }
171
+ created.add(current)
172
+ const parent = parentOf(current)
173
+ if (!parent || parent === current) break
174
+ current = parent
175
+ }
176
+ }
177
+ const createdPaths = [...created]
178
+ try {
179
+ for (const path of paths) await session.fs.mkdir(path)
180
+ } catch (error) {
181
+ await removeCreatedDirs(session.fs, createdPaths)
182
+ throw error
183
+ }
184
+ await refreshPanes(session)
185
+ recordHistory(session, {
186
+ label: `mkdir ${paths.map((path) => basename(path)).join(", ")}`,
187
+ undo: async () => {
188
+ await removeCreatedDirs(session.fs, createdPaths)
189
+ },
190
+ redo: async () => {
191
+ await assertPathsAbsent(session.fs, createdPaths)
192
+ for (const path of paths) await session.fs.mkdir(path)
193
+ },
194
+ })
195
+ return `created ${createdPaths.length} director${createdPaths.length === 1 ? "y" : "ies"}`
196
+ },
197
+ },
198
+ {
199
+ name: "new",
200
+ group: "Create",
201
+ signature: "new PATH...",
202
+ description: "Create empty files",
203
+ arity: { min: 1 },
204
+ args: [
205
+ { name: "PATH", kind: "path", mode: "dir", prefill: "current" },
206
+ { name: "NAME", kind: "literal" },
207
+ ],
208
+ async run(session, args, options) {
209
+ const paths = creationPaths(session, args, options)
210
+ const requested = new Set()
211
+ for (const path of paths) {
212
+ const normalized = normalize(path)
213
+ if (requested.has(normalized)) throw new Error(`duplicate path: ${normalized}`)
214
+ requested.add(normalized)
215
+ if (await pathExists(session.fs, normalized)) throw new Error(`already exists: ${normalized}`)
216
+ const parent = parentOf(normalized)
217
+ if (parent && !(await session.fs.stat(parent))?.isDir) {
218
+ throw new Error(`no such directory: ${parent}`)
219
+ }
220
+ }
221
+ for (const path of paths) await session.fs.touch(path)
222
+ await refreshPanes(session)
223
+ recordHistory(session, {
224
+ label: `new ${paths.map((path) => basename(path)).join(", ")}`,
225
+ undo: async () => {
226
+ for (const path of paths) {
227
+ const stats = await session.fs.stat(path)
228
+ if (!stats) continue
229
+ if (stats.size > 0) throw new Error(`file changed since new: ${path}`)
230
+ }
231
+ await session.fs.erase(paths)
232
+ },
233
+ redo: async () => {
234
+ await assertPathsAbsent(session.fs, paths)
235
+ for (const path of paths) await session.fs.touch(path)
236
+ },
237
+ })
238
+ return `created ${paths.length} file${paths.length === 1 ? "" : "s"}`
239
+ },
240
+ },
85
241
  {
86
242
  name: "move",
243
+ group: "Edit",
87
244
  signature: "move FROM TO",
88
245
  description: "Move files and directories",
89
246
  risk: "caution",
@@ -94,6 +251,7 @@ const operations = [
94
251
  ],
95
252
  async run(session, args) {
96
253
  const sources = expandAll(session, [args[0]])
254
+ assertNoOverlappingPaths(sources, "move")
97
255
  const targets = expandAll(session, [args[1]])
98
256
  if (targets.length !== 1) throw new Error("move: TO must be a single path")
99
257
  const [target] = targets
@@ -101,18 +259,96 @@ const operations = [
101
259
  throw new Error("move: TO must be an existing directory for multiple FROM")
102
260
  }
103
261
  const into = await isDir(session, target)
104
- for (const source of sources) {
105
- if (!(await session.fs.stat(source))) throw new Error(`not found: ${source}`)
106
- await session.fs.move(source, into ? join(target, basename(source)) : target)
262
+ const plan = sources.map((source) => ({
263
+ from: source,
264
+ to: into ? join(target, basename(source)) : target,
265
+ }))
266
+ for (const move of plan) {
267
+ if (!(await pathExists(session.fs, move.from))) throw new Error(`not found: ${move.from}`)
268
+ }
269
+ await assertPathsAbsent(session.fs, plan.map((move) => move.to))
270
+ const moves = []
271
+ for (const move of plan) {
272
+ await session.fs.move(move.from, move.to)
273
+ moves.push(move)
107
274
  }
108
275
  await refreshPanes(session)
276
+ recordHistory(session, {
277
+ label: `move ${sources.length} path${sources.length === 1 ? "" : "s"} → ${target}`,
278
+ undo: async () => {
279
+ await moveHistory(session, moves.map(({ from, to }) => ({ from: to, to: from })))
280
+ },
281
+ redo: async () => {
282
+ await moveHistory(session, moves)
283
+ },
284
+ })
109
285
  return `moved ${sources.length} path${sources.length === 1 ? "" : "s"} to ${target}`
110
286
  },
111
287
  },
112
288
  {
113
289
  name: "rename",
114
- signature: "rename FIND REPLACE PATH...",
115
- description: "Rename paths with a literal find-and-replace",
290
+ group: "Edit",
291
+ signature: "rename FROM TO PATH",
292
+ description: "Rename a child path from one name to another",
293
+ risk: "caution",
294
+ arity: { min: 3, max: 3 },
295
+ args: [
296
+ { name: "FROM", kind: "literal" },
297
+ { name: "TO", kind: "literal" },
298
+ { name: "PATH", kind: "path", mode: "dir" },
299
+ ],
300
+ // The `[Confirm]` line names the two resolved paths relative to the
301
+ // working directory, rather than the `FROM TO PATH` argument triple the
302
+ // panel would assemble: `rename docs/a.txt docs/b.txt`.
303
+ displayCommand(session, argv) {
304
+ const [fromName, toName, pathToken] = argv
305
+ if (!fromName || !toName || !pathToken) return null
306
+ let directory
307
+ try {
308
+ directory = expandArg(session, pathToken)[0]
309
+ } catch {
310
+ return null
311
+ }
312
+ if (!directory) return null
313
+ const cwd = session.fs.cwd?.() ?? "/"
314
+ const source = relative(cwd, join(directory, fromName))
315
+ const target = relative(cwd, join(directory, toName))
316
+ return `rename ${shellQuote(source)} ${shellQuote(target)}`
317
+ },
318
+ async run(session, args) {
319
+ const fromName = `${args[0] ?? ""}`
320
+ const toName = `${args[1] ?? ""}`
321
+ if (!fromName) throw new Error("rename needs FROM")
322
+ if (!toName) throw new Error("rename needs TO")
323
+ assertRenameName(fromName, "FROM")
324
+ assertRenameName(toName, "TO")
325
+ const paths = expandAll(session, [args[2]])
326
+ if (paths.length !== 1) throw new Error("rename: PATH must be a single directory")
327
+ const [directory] = paths
328
+ if (!(await isDir(session, directory))) throw new Error(`rename: PATH is not a directory: ${directory}`)
329
+ const move = { from: join(directory, fromName), to: join(directory, toName) }
330
+ if (move.from === move.to) return "rename: nothing changed"
331
+ if (!(await pathExists(session.fs, move.from))) throw new Error(`rename: not found: ${move.from}`)
332
+ await assertPathsAbsent(session.fs, [move.to])
333
+ await session.fs.move(move.from, move.to)
334
+ await refreshPanes(session)
335
+ recordHistory(session, {
336
+ label: `rename ${fromName} → ${toName}`,
337
+ undo: async () => {
338
+ await moveHistory(session, [{ from: move.to, to: move.from }])
339
+ },
340
+ redo: async () => {
341
+ await moveHistory(session, [move])
342
+ },
343
+ })
344
+ return `renamed ${fromName} to ${toName}`
345
+ },
346
+ },
347
+ {
348
+ name: "replace",
349
+ group: "Edit",
350
+ signature: "replace FIND REPLACE PATH...",
351
+ description: "Replace a literal substring in path names",
116
352
  risk: "caution",
117
353
  arity: { min: 3 },
118
354
  args: [
@@ -133,7 +369,7 @@ const operations = [
133
369
  return null
134
370
  }
135
371
  if (!paths.length) return null
136
- const plan = renamePlan(find, replace, paths)
372
+ const plan = replacePlan(find, replace, paths)
137
373
  const lines = plan.map((row) => {
138
374
  if (row.error) return `${row.name} → ⚠ ${row.error}`
139
375
  if (!row.changed) return `${row.name} (unchanged)`
@@ -145,33 +381,47 @@ const operations = [
145
381
  async run(session, args) {
146
382
  const find = `${args[0] ?? ""}`
147
383
  const replace = `${args[1] ?? ""}`
148
- if (!find) throw new Error("rename needs FIND")
149
- if (!replace) throw new Error("rename needs REPLACE")
384
+ if (!find) throw new Error("replace needs FIND")
385
+ if (!replace) throw new Error("replace needs REPLACE")
150
386
  const paths = expandAll(session, args.slice(2))
151
- if (!paths.length) throw new Error("rename needs at least one path")
152
- const plan = renamePlan(find, replace, paths)
387
+ if (!paths.length) throw new Error("replace needs at least one path")
388
+ assertNoOverlappingPaths(paths, "replace")
389
+ const plan = replacePlan(find, replace, paths)
153
390
  const bad = plan.find((row) => row.error)
154
- if (bad) throw new Error(`rename ${basename(bad.path)}: ${bad.error}`)
391
+ if (bad) throw new Error(`replace ${basename(bad.path)}: ${bad.error}`)
155
392
  const duplicates = duplicateTargets(plan)
156
- if (duplicates.length) throw new Error(`rename: duplicate target ${duplicates[0]}`)
157
- for (const row of plan) {
158
- if (!row.changed) continue
159
- const target = join(parentOf(row.path), row.newName)
160
- if (await session.fs.stat(target)) throw new Error(`rename: ${row.newName} already exists`)
393
+ if (duplicates.length) throw new Error(`replace: duplicate target ${duplicates[0]}`)
394
+ const replaceMoves = plan
395
+ .filter((row) => row.changed)
396
+ .map((row) => ({ from: row.path, to: join(parentOf(row.path), row.newName) }))
397
+ for (const move of replaceMoves) {
398
+ if (!(await pathExists(session.fs, move.from))) throw new Error(`replace: not found: ${move.from}`)
161
399
  }
400
+ await assertPathsAbsent(session.fs, replaceMoves.map((move) => move.to))
162
401
  let count = 0
163
- for (const row of plan) {
164
- if (!row.changed) continue
165
- await session.fs.move(row.path, join(parentOf(row.path), row.newName))
402
+ const moves = []
403
+ for (const move of replaceMoves) {
404
+ await session.fs.move(move.from, move.to)
405
+ moves.push(move)
166
406
  count += 1
167
407
  }
168
408
  await refreshPanes(session)
169
- if (!count) return "rename: nothing changed"
170
- return `renamed ${count} path${count === 1 ? "" : "s"}`
409
+ if (!count) return "replace: nothing changed"
410
+ recordHistory(session, {
411
+ label: `replace ${count} path${count === 1 ? "" : "s"}`,
412
+ undo: async () => {
413
+ await moveHistory(session, moves.map(({ from, to }) => ({ from: to, to: from })))
414
+ },
415
+ redo: async () => {
416
+ await moveHistory(session, moves)
417
+ },
418
+ })
419
+ return `replaced ${count} path${count === 1 ? "" : "s"}`
171
420
  },
172
421
  },
173
422
  {
174
423
  name: "delete",
424
+ group: "Delete",
175
425
  signature: "delete PATH...",
176
426
  description: "Delete paths, moving them to the trash",
177
427
  risk: "destructive",
@@ -179,16 +429,44 @@ const operations = [
179
429
  args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
180
430
  async run(session, args) {
181
431
  const paths = expandAll(session, args)
432
+ assertNoOverlappingPaths(paths, "delete")
182
433
  for (const path of paths) {
183
434
  if (!(await session.fs.stat(path))) throw new Error(`not found: ${path}`)
184
435
  }
185
- await session.fs.trash(paths)
436
+ const trashed = (await session.fs.trash(paths)) ?? []
186
437
  await refreshPanes(session)
187
- return `trashed ${paths.length} path${paths.length === 1 ? "" : "s"}`
438
+ // The trash copies the delete can be undone from. Mutable because a
439
+ // redo re-trashes under fresh names.
440
+ let items = trashed.map((entry) => entry?.to ?? entry).filter(Boolean)
441
+ let restoredSnapshots = null
442
+ if (items.length) {
443
+ recordHistory(session, {
444
+ label: `trash ${paths.length} path${paths.length === 1 ? "" : "s"}`,
445
+ undo: async () => {
446
+ await session.fs.restore(items)
447
+ restoredSnapshots = await Promise.all(paths.map((path) => pathSnapshot(session.fs, path)))
448
+ },
449
+ redo: async () => {
450
+ for (let i = 0; i < paths.length; i++) {
451
+ if (JSON.stringify(await pathSnapshot(session.fs, paths[i])) !== JSON.stringify(restoredSnapshots?.[i])) {
452
+ throw new Error(`path changed since restore: ${paths[i]}`)
453
+ }
454
+ }
455
+ const again = (await session.fs.trash(paths)) ?? []
456
+ items = again.map((entry) => entry?.to ?? entry).filter(Boolean)
457
+ },
458
+ })
459
+ }
460
+ // Name where the entries went (the trash directory), so the delete is
461
+ // not a black hole: `u` history or `⇧T` trash bring them back.
462
+ const where = items.length ? parentOf(items[0]) : null
463
+ const count = `path${paths.length === 1 ? "" : "s"}`
464
+ return where ? `trashed ${paths.length} ${count} to ${where}` : `trashed ${paths.length} ${count}`
188
465
  },
189
466
  },
190
467
  {
191
468
  name: "erase",
469
+ group: "Delete",
192
470
  signature: "erase PATH...",
193
471
  description: "Delete paths without moving them to the trash",
194
472
  risk: "destructive",
@@ -197,13 +475,50 @@ const operations = [
197
475
  confirmText: (args) => `erase ${args.join(" ")} without trash?`,
198
476
  async run(session, args) {
199
477
  const paths = expandAll(session, args)
478
+ assertNoOverlappingPaths(paths, "erase")
200
479
  await session.fs.erase(paths)
201
480
  await refreshPanes(session)
202
481
  return `erased ${paths.length} path${paths.length === 1 ? "" : "s"}`
203
482
  },
204
483
  },
484
+ {
485
+ name: "unlink",
486
+ group: "Delete",
487
+ signature: "unlink PATH...",
488
+ description: "Remove symbolic links, leaving their targets alone",
489
+ risk: "destructive",
490
+ arity: { min: 1 },
491
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
492
+ async run(session, args) {
493
+ const paths = expandAll(session, args)
494
+ const links = []
495
+ for (const path of paths) {
496
+ const entry = await linkEntry(session.fs, path)
497
+ // Only links are unlinked: a typo on a regular file must not
498
+ // remove it. The whole batch is checked before anything runs.
499
+ if (!entry) throw new Error(`not a symbolic link: ${path}`)
500
+ links.push({ path, target: entry.target ?? "" })
501
+ }
502
+ for (const link of links) await session.fs.unlink(link.path)
503
+ await refreshPanes(session)
504
+ if (links.length) {
505
+ recordHistory(session, {
506
+ label: `unlink ${links.length} link${links.length === 1 ? "" : "s"}`,
507
+ undo: async () => {
508
+ await assertPathsAbsent(session.fs, links.map((link) => link.path))
509
+ for (const link of links) await session.fs.symlink(link.target, link.path)
510
+ },
511
+ redo: async () => {
512
+ for (const link of links) await session.fs.unlink(link.path)
513
+ },
514
+ })
515
+ }
516
+ return `unlinked ${links.length} link${links.length === 1 ? "" : "s"}`
517
+ },
518
+ },
205
519
  {
206
520
  name: "stat",
521
+ group: "Info",
207
522
  signature: "stat PATH...",
208
523
  description: "Show file information, including disk usage",
209
524
  arity: { min: 1 },
@@ -211,24 +526,104 @@ const operations = [
211
526
  async run(session, args) {
212
527
  const paths = expandAll(session, args)
213
528
  const details = []
529
+ const results = []
214
530
  for (const path of paths) {
215
- const stats = await session.fs.stat(path)
216
- if (!stats) throw new Error(`not found: ${path}`)
217
- const size = stats.isDir ? await session.fs.du(path) : stats.size
531
+ const { stats, size } = await pathUsage(session.fs, path)
532
+ const summary = `${stats.isDir ? "directory" : "file"} · ${formatSize(size)} · ${new Date(stats.mtimeMs || 0).toISOString()}`
218
533
  details.push(path)
219
- details.push(
220
- ` ${stats.isDir ? "directory" : "file"} · ${humanSize(size)} · ${new Date(stats.mtimeMs || 0).toISOString()}`,
221
- )
534
+ details.push(` ${summary}`)
535
+ results.push(`${path} · ${summary}`)
222
536
  }
223
- session.showPreview.set(true)
224
- session.previewTarget.set(await previewTarget(session, paths[0], details))
225
- return `stat ${paths.join(" ")}`
537
+ await showDetails(session, paths[0], details)
538
+ return results.join("; ")
539
+ },
540
+ },
541
+ {
542
+ name: "du",
543
+ group: "Info",
544
+ signature: "du PATH...",
545
+ aliases: ["usage"],
546
+ description: "Show disk usage per path (recursive for directories)",
547
+ arity: { min: 1 },
548
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
549
+ async run(session, args) {
550
+ const paths = expandAll(session, args)
551
+ const details = []
552
+ let total = 0
553
+ for (const path of paths) {
554
+ const { stats, size } = await pathUsage(session.fs, path)
555
+ total += size
556
+ details.push(path)
557
+ details.push(` ${formatSize(size)}${stats.isDir ? " recursive" : ""}`)
558
+ }
559
+ if (paths.length > 1) details.push(` total: ${formatSize(total)}`)
560
+ await showDetails(session, paths[0], details)
561
+ return paths.length === 1
562
+ ? `${formatSize(total)} in ${paths[0]}`
563
+ : `${formatSize(total)} in ${paths.length} paths`
564
+ },
565
+ },
566
+ {
567
+ name: "df",
568
+ group: "Info",
569
+ signature: "df PATH...",
570
+ aliases: ["free"],
571
+ description: "Show filesystem free and total space",
572
+ arity: { min: 0 },
573
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
574
+ async run(session, args) {
575
+ const paths = args.length ? expandAll(session, args) : [currentPane(session).path.value]
576
+ const details = []
577
+ const results = []
578
+ for (const path of paths) {
579
+ const disk = await session.fs.disk(path)
580
+ if (!disk) throw new Error(`no filesystem information for ${path}`)
581
+ const used = Math.max(0, disk.total - disk.free)
582
+ const percent = disk.total > 0 ? Math.round((used / disk.total) * 100) : 0
583
+ const summary = `${formatDisk(disk)} (${percent}% used)`
584
+ details.push(path)
585
+ details.push(` ${summary}`)
586
+ results.push(`${path} · ${summary}`)
587
+ }
588
+ await showDetails(session, paths[0], details)
589
+ return results.join("; ")
590
+ },
591
+ },
592
+ {
593
+ name: "abspath",
594
+ group: "File",
595
+ signature: "abspath PATH...",
596
+ description: "Show absolute paths",
597
+ arity: { min: 1 },
598
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
599
+ async run(session, args) {
600
+ const text = expandAll(session, args).map((path) => shellQuote(absolutePath(path))).join(" ")
601
+ copyText(session, text)
602
+ return text
603
+ },
604
+ },
605
+ {
606
+ name: "realpath",
607
+ group: "File",
608
+ signature: "realpath PATH...",
609
+ description: "Show resolved paths, following symlinks",
610
+ arity: { min: 1 },
611
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
612
+ async run(session, args) {
613
+ const paths = expandAll(session, args)
614
+ const resolved = []
615
+ for (const path of paths) resolved.push(await session.fs.realpath(absolutePath(path)))
616
+ const text = resolved.map(shellQuote).join(" ")
617
+ copyText(session, text)
618
+ return text
226
619
  },
227
620
  },
228
621
  {
229
622
  name: "touch",
623
+ group: "Edit",
230
624
  signature: "touch PATH...",
231
625
  description: "Create files or bump their modification time",
626
+ risk: "caution",
232
627
  arity: { min: 1 },
233
628
  args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
234
629
  async run(session, args) {
@@ -238,6 +633,53 @@ const operations = [
238
633
  return `touched ${paths.length} path${paths.length === 1 ? "" : "s"}`
239
634
  },
240
635
  },
636
+ {
637
+ name: "chmod",
638
+ group: "Edit",
639
+ signature: "chmod MODE PATH...",
640
+ description: "Set read/write/execute permissions for user, group and other",
641
+ risk: "caution",
642
+ arity: { min: 2 },
643
+ args: [
644
+ { name: "MODE", kind: "permissions", panelOrder: 1 },
645
+ { name: "PATH", kind: "selection", role: "active", variadic: true, panelOrder: 0 },
646
+ ],
647
+ async run(session, args) {
648
+ const modeText = `${args[0] ?? ""}`.trim()
649
+ if (!/^0?[0-7]{1,4}$/.test(modeText)) throw new Error("chmod: MODE must be octal (e.g. 644)")
650
+ const paths = expandAll(session, args.slice(1))
651
+ for (const path of paths) {
652
+ if (!(await session.fs.stat(path))) throw new Error(`not found: ${path}`)
653
+ }
654
+ const mode = Number.parseInt(modeText, 8)
655
+ for (const path of paths) await session.fs.chmod(path, mode)
656
+ await refreshPanes(session)
657
+ return `chmod ${modeText} ${paths.length} path${paths.length === 1 ? "" : "s"}`
658
+ },
659
+ },
660
+ {
661
+ name: "chown",
662
+ group: "Edit",
663
+ signature: "chown OWNER PATH...",
664
+ description: "Set path ownership (OWNER is USER[:GROUP])",
665
+ risk: "caution",
666
+ arity: { min: 2 },
667
+ args: [
668
+ { name: "OWNER", kind: "literal", panelOrder: 1 },
669
+ { name: "PATH", kind: "selection", role: "active", variadic: true, panelOrder: 0 },
670
+ ],
671
+ async run(session, args) {
672
+ const owner = `${args[0] ?? ""}`.trim()
673
+ if (!owner) throw new Error("chown: OWNER must be USER[:GROUP]")
674
+ const paths = expandAll(session, args.slice(1))
675
+ for (const path of paths) {
676
+ if (!(await session.fs.stat(path))) throw new Error(`not found: ${path}`)
677
+ }
678
+ for (const path of paths) await session.fs.chown(path, owner)
679
+ await refreshPanes(session)
680
+ return `chowned ${paths.length} path${paths.length === 1 ? "" : "s"} to ${owner}`
681
+ },
682
+ },
241
683
  {
242
684
  name: "sel",
243
685
  signature: "sel [+|-][BUF] PATH...",
@@ -266,8 +708,10 @@ const operations = [
266
708
  },
267
709
  {
268
710
  name: "edit",
711
+ group: "Edit",
269
712
  signature: "edit PATH",
270
713
  description: "Edit a file with $EDITOR",
714
+ risk: "caution",
271
715
  arity: { min: 1, max: 1 },
272
716
  args: [{ name: "PATH", kind: "selection", role: "active" }],
273
717
  async run(session, args) {
@@ -279,8 +723,52 @@ const operations = [
279
723
  return `edited ${path}`
280
724
  },
281
725
  },
726
+ {
727
+ name: "hardlink",
728
+ group: "Edit",
729
+ signature: "hardlink FROM TO",
730
+ description: "Create hard links to paths",
731
+ risk: "caution",
732
+ arity: { min: 2, max: 2 },
733
+ args: [
734
+ { name: "FROM", kind: "selection", role: "source", variadic: true },
735
+ { name: "TO", kind: "dest" },
736
+ ],
737
+ async run(session, args) {
738
+ const links = await linkPlan(session, args, "hardlink")
739
+ for (const link of links) {
740
+ const stats = await session.fs.stat(link.from)
741
+ if (!stats) throw new Error(`not found: ${link.from}`)
742
+ if (stats.isDir) throw new Error(`hardlink: cannot link a directory: ${link.from}`)
743
+ }
744
+ for (const link of links) await session.fs.hardlink(link.from, link.to)
745
+ await refreshPanes(session)
746
+ recordLinkHistory(session, "hardlink", links, (link) => session.fs.hardlink(link.from, link.to))
747
+ return `linked ${links.length} path${links.length === 1 ? "" : "s"} to ${links[0].to}`
748
+ },
749
+ },
750
+ {
751
+ name: "symlink",
752
+ group: "Edit",
753
+ signature: "symlink FROM TO",
754
+ description: "Create symbolic links to paths",
755
+ risk: "caution",
756
+ arity: { min: 2, max: 2 },
757
+ args: [
758
+ { name: "FROM", kind: "selection", role: "source", variadic: true },
759
+ { name: "TO", kind: "dest" },
760
+ ],
761
+ async run(session, args) {
762
+ const links = await linkPlan(session, args, "symlink")
763
+ for (const link of links) await session.fs.symlink(link.from, link.to)
764
+ await refreshPanes(session)
765
+ recordLinkHistory(session, "symlink", links, (link) => session.fs.symlink(link.from, link.to))
766
+ return `symlinked ${links.length} path${links.length === 1 ? "" : "s"} to ${links[0].to}`
767
+ },
768
+ },
282
769
  {
283
770
  name: "view",
771
+ group: "File",
284
772
  signature: "view PATH",
285
773
  description: "Preview a path",
286
774
  arity: { min: 1, max: 1 },
@@ -296,6 +784,7 @@ const operations = [
296
784
  },
297
785
  {
298
786
  name: "open",
787
+ group: "File",
299
788
  signature: "open PATH...",
300
789
  description: "Open paths with the desktop opener",
301
790
  arity: { min: 1 },
@@ -308,6 +797,7 @@ const operations = [
308
797
  },
309
798
  {
310
799
  name: "bookmark",
800
+ group: "Command",
311
801
  signature: "bookmark [NAME] [PATH]",
312
802
  description: "List, add or remove named bookmarks",
313
803
  arity: { min: 0, max: 2 },
@@ -338,6 +828,7 @@ const operations = [
338
828
  },
339
829
  {
340
830
  name: "run",
831
+ group: "File",
341
832
  signature: "run CMD",
342
833
  aliases: ["!"],
343
834
  description: "Run a shell command in the active pane",
@@ -433,17 +924,195 @@ function expandAll(session, tokens) {
433
924
  return paths
434
925
  }
435
926
 
927
+ // Function: creationPaths
928
+ // The paths `new`/`mkdir` create. Scripts keep the one-token-per-path syntax
929
+ // (`new a b`); the command panel supplies a PATH directory plus a NAME and
930
+ // opts into composing them here.
931
+ function creationPaths(session, args, options) {
932
+ if (!options?.panel) return expandAll(session, args)
933
+ const directory = `${args[0] ?? ""}`.trim()
934
+ const names = args.slice(1).map((name) => `${name ?? ""}`.trim()).filter(Boolean)
935
+ if (!directory || !names.length) return []
936
+ return expandAll(session, names.map((name) => join(directory, name)))
937
+ }
938
+
436
939
  async function isDir(session, path) {
437
940
  const stats = await session.fs.stat(path)
438
941
  return Boolean(stats?.isDir)
439
942
  }
440
943
 
944
+ // Function: pathUsage
945
+ // Stats and reported size for an information operation. Directories are
946
+ // measured recursively, so `stat` and `du` share one calculation and one
947
+ // missing-path error.
948
+ async function pathUsage(fs, path) {
949
+ const stats = await fs.stat(path)
950
+ if (!stats) throw new Error(`not found: ${path}`)
951
+ return { stats, size: stats.isDir ? await fs.du(path) : stats.size }
952
+ }
953
+
441
954
  async function refreshPanes(session) {
442
955
  // Every rendered column: the two panes, plus the explorer's parent mirror
443
956
  // and the directory the inspector is showing.
444
957
  await Promise.all(Object.values(session.panes).map((pane) => pane.refresh()))
445
958
  }
446
959
 
960
+ // Function: copyText
961
+ // Best-effort clipboard write through the host adapter (OSC 52 in the TUI, the
962
+ // Clipboard API in a browser). A host without a clipboard returns false; the
963
+ // operation still reports its result normally.
964
+ function copyText(session, text) {
965
+ try {
966
+ return Boolean(session.os?.copy?.(text))
967
+ } catch (error) {
968
+ return false
969
+ }
970
+ }
971
+
972
+ // Function: linkPlan
973
+ // The source/destination pairs a hardlink or symlink creates: the destination
974
+ // is the TO path, or `TO/basename(source)` when TO is an existing directory —
975
+ // the same rule copy and move use. Every destination must be absent.
976
+ async function linkPlan(session, args, label) {
977
+ const sources = expandAll(session, [args[0]])
978
+ const targets = expandAll(session, [args[1]])
979
+ if (targets.length !== 1) throw new Error(`${label}: TO must be a single path`)
980
+ const [target] = targets
981
+ if (sources.length > 1 && !(await isDir(session, target))) {
982
+ throw new Error(`${label}: TO must be an existing directory for multiple FROM`)
983
+ }
984
+ const into = await isDir(session, target)
985
+ const links = sources.map((source) => ({
986
+ from: source,
987
+ to: into ? join(target, basename(source)) : target,
988
+ }))
989
+ await assertPathsAbsent(session.fs, links.map((link) => link.to))
990
+ return links
991
+ }
992
+
993
+ // Function: recordLinkHistory
994
+ // Journals a batch of links: undo erases them, redo recreates them through the
995
+ // operation that made them (`hardlink` or `symlink`).
996
+ function recordLinkHistory(session, label, links, create) {
997
+ recordHistory(session, {
998
+ label: `${label} ${links.length} path${links.length === 1 ? "" : "s"}`,
999
+ undo: async () => {
1000
+ await session.fs.erase(links.map((link) => link.to))
1001
+ },
1002
+ redo: async () => {
1003
+ await assertPathsAbsent(session.fs, links.map((link) => link.to))
1004
+ for (const link of links) await create(link)
1005
+ },
1006
+ })
1007
+ }
1008
+
1009
+ // Function: linkEntry
1010
+ // The listing entry for `path` when it is a symbolic link (its own identity,
1011
+ // not what it points at). Null for a regular file or directory.
1012
+ async function linkEntry(fs, path) {
1013
+ const parent = parentOf(path)
1014
+ if (!parent) return null
1015
+ try {
1016
+ const entry = (await fs.readDir(parent)).find((candidate) => candidate.name === basename(path))
1017
+ return entry?.isSymlink ? entry : null
1018
+ } catch (error) {
1019
+ return null
1020
+ }
1021
+ }
1022
+
1023
+ // Function: recordHistory
1024
+ // Journals a reversible operation when the session has a journal (headless
1025
+ // hosts and tests may omit it). `entry` is `{ label, undo, redo }`.
1026
+ function recordHistory(session, entry) {
1027
+ session.journal?.record?.(entry)
1028
+ }
1029
+
1030
+ async function pathExists(fs, path) {
1031
+ if (await fs.stat(path)) return true
1032
+ const parent = parentOf(path)
1033
+ if (!parent) return false
1034
+ try {
1035
+ return (await fs.readDir(parent)).some((entry) => entry.name === basename(path))
1036
+ } catch (error) {
1037
+ if (error?.code === "ENOENT" || error?.name === "NotFoundError" || error?.name === "TypeMismatchError") return false
1038
+ throw error
1039
+ }
1040
+ }
1041
+
1042
+ async function assertPathsAbsent(fs, paths) {
1043
+ const seen = new Set()
1044
+ for (const path of paths) {
1045
+ if (seen.has(path) || (await pathExists(fs, path))) {
1046
+ const error = new Error(`destination already exists: ${path}`)
1047
+ error.code = "EEXIST"
1048
+ throw error
1049
+ }
1050
+ seen.add(path)
1051
+ }
1052
+ }
1053
+
1054
+ function assertNoOverlappingPaths(paths, label) {
1055
+ for (let i = 0; i < paths.length; i++) {
1056
+ for (let j = i + 1; j < paths.length; j++) {
1057
+ const a = paths[i]
1058
+ const b = paths[j]
1059
+ if (a === b || isAncestorPath(a, b) || isAncestorPath(b, a)) {
1060
+ throw new Error(`${label}: overlapping paths: ${a} and ${b}`)
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ function isAncestorPath(parent, child) {
1067
+ return child.startsWith(parent === "/" ? "/" : `${parent}/`)
1068
+ }
1069
+
1070
+ async function moveHistory(session, moves) {
1071
+ await assertPathsAbsent(session.fs, moves.map((move) => move.to))
1072
+ for (const move of moves) await session.fs.move(move.from, move.to)
1073
+ }
1074
+
1075
+ async function removeCreatedDirs(fs, paths) {
1076
+ const existing = []
1077
+ for (const path of paths) if (await pathExists(fs, path)) existing.push(path)
1078
+ const existingSet = new Set(existing)
1079
+ for (const path of existing) {
1080
+ const expected = new Set(existing.filter((child) => parentOf(child) === path).map(basename))
1081
+ const actual = new Set((await fs.readDir(path)).map((entry) => entry.name))
1082
+ if (actual.size !== expected.size || [...actual].some((name) => !expected.has(name))) {
1083
+ throw new Error(`directory changed since mkdir: ${path}`)
1084
+ }
1085
+ }
1086
+ const deepestFirst = [...existingSet].sort((a, b) => b.split("/").length - a.split("/").length)
1087
+ for (const path of deepestFirst) await fs.erase([path])
1088
+ }
1089
+
1090
+ async function treeSnapshot(fs, path, entry = null) {
1091
+ const stats = await fs.stat(path)
1092
+ if (!stats) return null
1093
+ const isSymlink = Boolean(entry?.isSymlink || stats.isSymlink)
1094
+ const snapshot = {
1095
+ isDir: Boolean(stats.isDir),
1096
+ isSymlink,
1097
+ size: stats.size ?? 0,
1098
+ mtimeMs: stats.mtimeMs ?? 0,
1099
+ ctimeMs: stats.ctimeMs ?? 0,
1100
+ mode: stats.mode ?? 0,
1101
+ target: isSymlink ? entry?.target ?? null : null,
1102
+ }
1103
+ if (snapshot.isDir && !isSymlink) {
1104
+ const children = (await fs.readDir(path)).sort((a, b) => a.name.localeCompare(b.name))
1105
+ snapshot.children = await Promise.all(children.map((child) => treeSnapshot(fs, child.path, child)))
1106
+ }
1107
+ return snapshot
1108
+ }
1109
+
1110
+ async function pathSnapshot(fs, path) {
1111
+ const parent = parentOf(path)
1112
+ const entry = parent ? (await fs.readDir(parent)).find((candidate) => candidate.name === basename(path)) : null
1113
+ return treeSnapshot(fs, path, entry)
1114
+ }
1115
+
447
1116
  async function previewTarget(session, path, details) {
448
1117
  const stats = await session.fs.stat(path)
449
1118
  if (!stats) throw new Error(`not found: ${path}`)
@@ -459,6 +1128,14 @@ async function previewTarget(session, path, details) {
459
1128
  return target
460
1129
  }
461
1130
 
1131
+ // Function: showDetails
1132
+ // Opens the preview on `path` with the detail lines an information operation
1133
+ // produced, so `stat`, `du` and `df` share the same presentation.
1134
+ async function showDetails(session, path, details) {
1135
+ session.showPreview.set(true)
1136
+ session.previewTarget.set(await previewTarget(session, path, details))
1137
+ }
1138
+
462
1139
  // Function: parseSelection
463
1140
  // Splits `sel` arguments into a sign, an optional buffer label and the path
464
1141
  // tokens: `+A paths` adds to buffer A (the source pane), `-B paths` removes
@@ -486,12 +1163,12 @@ function findBuffer(session, name) {
486
1163
  throw new Error(`unknown selection buffer: ${name}`)
487
1164
  }
488
1165
 
489
- // Function: renamePlan
490
- // The literal find-and-replace mapping a `rename` runs: one row per path with
1166
+ // Function: replacePlan
1167
+ // The literal find-and-replace mapping a `replace` runs: one row per path with
491
1168
  // its source basename, its new basename (every occurrence of FIND replaced by
492
1169
  // REPLACE), whether it changed, and an error when the result is empty or
493
1170
  // contains a path separator. Pure, so the panel preview and the run agree.
494
- function renamePlan(find, replace, paths) {
1171
+ function replacePlan(find, replace, paths) {
495
1172
  return (paths ?? []).map((path) => {
496
1173
  const name = basename(path)
497
1174
  const newName = name.split(find).join(replace)
@@ -502,45 +1179,40 @@ function renamePlan(find, replace, paths) {
502
1179
  })
503
1180
  }
504
1181
 
1182
+ function assertRenameName(name, argument) {
1183
+ if (name === "." || name === ".." || name.includes("/") || name.includes("\0")) {
1184
+ throw new Error(`rename: ${argument} must be a single name`)
1185
+ }
1186
+ }
1187
+
505
1188
  // Function: duplicateTargets
506
- // The new names two or more different paths would collide on.
1189
+ // The destination paths two or more changed paths would collide on. Basenames
1190
+ // may repeat safely when their files live in different directories.
507
1191
  function duplicateTargets(plan) {
508
1192
  const seen = new Set()
509
1193
  const duplicates = new Set()
510
1194
  for (const row of plan ?? []) {
511
1195
  if (!row.changed) continue
512
- if (seen.has(row.newName)) duplicates.add(row.newName)
513
- seen.add(row.newName)
1196
+ const target = join(parentOf(row.path), row.newName)
1197
+ if (seen.has(target)) duplicates.add(target)
1198
+ seen.add(target)
514
1199
  }
515
1200
  return [...duplicates]
516
1201
  }
517
1202
 
518
- // Function: humanSize
519
- // Compact byte size for stat output (core-local: components own presentation
520
- // formatting).
521
- function humanSize(bytes) {
522
- const value = Number(bytes ?? 0)
523
- if (value < 1024) return `${value}B`
524
- const units = ["K", "M", "G", "T"]
525
- let size = value / 1024
526
- let unit = 0
527
- while (size >= 1024 && unit < units.length - 1) {
528
- size /= 1024
529
- unit++
530
- }
531
- return `${size >= 100 ? Math.round(size) : size.toFixed(1)}${units[unit]}`
532
- }
533
-
534
1203
  export {
1204
+ absolutePath,
535
1205
  expandAll,
536
1206
  expandArg,
537
1207
  decodePathToken,
538
1208
  getOperation,
539
1209
  getOperations,
540
- humanSize,
541
1210
  pathToken,
542
- renamePlan,
1211
+ recordHistory,
1212
+ refreshPanes,
1213
+ replacePlan,
543
1214
  runOperation,
1215
+ shellQuote,
544
1216
  }
545
1217
 
546
1218
  // EOF