@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
@@ -4,30 +4,40 @@
4
4
  // Created: 2026-09-27
5
5
 
6
6
  // Module: tcmd/core/operations
7
- // The scripting operations: the file-management verbs the command palette's
8
- // scripting mode completes and runs. An operation receives the session and
9
- // its raw argument tokens; path arguments resolve filesets ($A … $FILE), `~`
10
- // and paths relative to the active pane. Operations own their side effects and
11
- // return a one-line message (shown in the command bar) or undefined.
7
+ // The CLI operations: the file-management verbs the command panel runs. An
8
+ // operation receives the session and its raw argument tokens; path arguments
9
+ // resolve filesets ($A … $FILE), `~` and paths relative to the active pane.
10
+ // Operations own their side effects and return a one-line message (shown in
11
+ // the command bar) or undefined.
12
12
  //
13
- // Registry entries carry the presentation metadata the palette 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 } from "./path.js"
30
- import { PANE_BUFFERS } from "./pane.js"
38
+ import { formatDisk, formatSize } from "./format.js"
39
+ import { basename, extname, join, normalize, parent as parentOf, relative } from "./path.js"
40
+ import { currentPane, currentPaneId, PANE_BUFFERS } from "./pane.js"
31
41
 
32
42
  // Constant: PATHS_PREFIX
33
43
  // The prefix of a self-contained path-list token the command panel encodes
@@ -36,6 +46,27 @@ import { 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,15 +89,18 @@ 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
- { name: "FROM", kind: "selection", role: "source" },
98
+ { name: "FROM", kind: "selection", role: "source", variadic: true },
66
99
  { name: "TO", kind: "dest" },
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,26 +108,150 @@ 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",
90
247
  arity: { min: 2, max: 2 },
91
248
  args: [
92
- { name: "FROM", kind: "selection", role: "source" },
249
+ { name: "FROM", kind: "selection", role: "source", variadic: true },
93
250
  { name: "TO", kind: "dest" },
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,16 +259,169 @@ 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
  },
288
+ {
289
+ name: "rename",
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",
352
+ risk: "caution",
353
+ arity: { min: 3 },
354
+ args: [
355
+ { name: "FIND", kind: "literal" },
356
+ { name: "REPLACE", kind: "literal" },
357
+ { name: "PATH", kind: "selection", role: "active", variadic: true },
358
+ ],
359
+ // The live mapping the panel previews instead of the command line: one
360
+ // `old → new` line per path, an unchanged path marked as such, and an
361
+ // invalid name or a duplicate target flagged before anything runs.
362
+ preview(session, argv) {
363
+ const [find, replace] = argv
364
+ if (!find || !replace) return null
365
+ let paths
366
+ try {
367
+ paths = expandAll(session, argv.slice(2))
368
+ } catch (error) {
369
+ return null
370
+ }
371
+ if (!paths.length) return null
372
+ const plan = replacePlan(find, replace, paths)
373
+ const lines = plan.map((row) => {
374
+ if (row.error) return `${row.name} → ⚠ ${row.error}`
375
+ if (!row.changed) return `${row.name} (unchanged)`
376
+ return `${row.name} → ${row.newName}`
377
+ })
378
+ for (const name of duplicateTargets(plan)) lines.push(`⚠ duplicate target: ${name}`)
379
+ return lines.join("\n")
380
+ },
381
+ async run(session, args) {
382
+ const find = `${args[0] ?? ""}`
383
+ const replace = `${args[1] ?? ""}`
384
+ if (!find) throw new Error("replace needs FIND")
385
+ if (!replace) throw new Error("replace needs REPLACE")
386
+ const paths = expandAll(session, args.slice(2))
387
+ if (!paths.length) throw new Error("replace needs at least one path")
388
+ assertNoOverlappingPaths(paths, "replace")
389
+ const plan = replacePlan(find, replace, paths)
390
+ const bad = plan.find((row) => row.error)
391
+ if (bad) throw new Error(`replace ${basename(bad.path)}: ${bad.error}`)
392
+ const duplicates = duplicateTargets(plan)
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}`)
399
+ }
400
+ await assertPathsAbsent(session.fs, replaceMoves.map((move) => move.to))
401
+ let count = 0
402
+ const moves = []
403
+ for (const move of replaceMoves) {
404
+ await session.fs.move(move.from, move.to)
405
+ moves.push(move)
406
+ count += 1
407
+ }
408
+ await refreshPanes(session)
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"}`
420
+ },
421
+ },
112
422
  {
113
423
  name: "delete",
424
+ group: "Delete",
114
425
  signature: "delete PATH...",
115
426
  description: "Delete paths, moving them to the trash",
116
427
  risk: "destructive",
@@ -118,16 +429,44 @@ const operations = [
118
429
  args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
119
430
  async run(session, args) {
120
431
  const paths = expandAll(session, args)
432
+ assertNoOverlappingPaths(paths, "delete")
121
433
  for (const path of paths) {
122
434
  if (!(await session.fs.stat(path))) throw new Error(`not found: ${path}`)
123
435
  }
124
- await session.fs.trash(paths)
436
+ const trashed = (await session.fs.trash(paths)) ?? []
125
437
  await refreshPanes(session)
126
- 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}`
127
465
  },
128
466
  },
129
467
  {
130
468
  name: "erase",
469
+ group: "Delete",
131
470
  signature: "erase PATH...",
132
471
  description: "Delete paths without moving them to the trash",
133
472
  risk: "destructive",
@@ -136,13 +475,50 @@ const operations = [
136
475
  confirmText: (args) => `erase ${args.join(" ")} without trash?`,
137
476
  async run(session, args) {
138
477
  const paths = expandAll(session, args)
478
+ assertNoOverlappingPaths(paths, "erase")
139
479
  await session.fs.erase(paths)
140
480
  await refreshPanes(session)
141
481
  return `erased ${paths.length} path${paths.length === 1 ? "" : "s"}`
142
482
  },
143
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
+ },
144
519
  {
145
520
  name: "stat",
521
+ group: "Info",
146
522
  signature: "stat PATH...",
147
523
  description: "Show file information, including disk usage",
148
524
  arity: { min: 1 },
@@ -150,24 +526,104 @@ const operations = [
150
526
  async run(session, args) {
151
527
  const paths = expandAll(session, args)
152
528
  const details = []
529
+ const results = []
153
530
  for (const path of paths) {
154
- const stats = await session.fs.stat(path)
155
- if (!stats) throw new Error(`not found: ${path}`)
156
- 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()}`
157
533
  details.push(path)
158
- details.push(
159
- ` ${stats.isDir ? "directory" : "file"} · ${humanSize(size)} · ${new Date(stats.mtimeMs || 0).toISOString()}`,
160
- )
534
+ details.push(` ${summary}`)
535
+ results.push(`${path} · ${summary}`)
161
536
  }
162
- session.showPreview.set(true)
163
- session.previewTarget.set(await previewTarget(session, paths[0], details))
164
- 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
165
619
  },
166
620
  },
167
621
  {
168
622
  name: "touch",
623
+ group: "Edit",
169
624
  signature: "touch PATH...",
170
625
  description: "Create files or bump their modification time",
626
+ risk: "caution",
171
627
  arity: { min: 1 },
172
628
  args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
173
629
  async run(session, args) {
@@ -177,6 +633,53 @@ const operations = [
177
633
  return `touched ${paths.length} path${paths.length === 1 ? "" : "s"}`
178
634
  },
179
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
+ },
180
683
  {
181
684
  name: "sel",
182
685
  signature: "sel [+|-][BUF] PATH...",
@@ -191,7 +694,7 @@ const operations = [
191
694
  if (!tokens.length) throw new Error("sel needs at least one path")
192
695
  const paths = expandAll(session, tokens)
193
696
  const target = findBuffer(session, buffer)
194
- const label = buffer ? `${buffer}`.toUpperCase() : PANE_BUFFERS[session.active.value] ?? PANE_BUFFERS.left
697
+ const label = buffer ? `${buffer}`.toUpperCase() : PANE_BUFFERS[currentPaneId(session)] ?? PANE_BUFFERS.left
195
698
  const marks = new Set(target.marked.value)
196
699
  for (const path of paths) {
197
700
  if (sign === "-") marks.delete(path)
@@ -205,8 +708,10 @@ const operations = [
205
708
  },
206
709
  {
207
710
  name: "edit",
711
+ group: "Edit",
208
712
  signature: "edit PATH",
209
713
  description: "Edit a file with $EDITOR",
714
+ risk: "caution",
210
715
  arity: { min: 1, max: 1 },
211
716
  args: [{ name: "PATH", kind: "selection", role: "active" }],
212
717
  async run(session, args) {
@@ -218,8 +723,52 @@ const operations = [
218
723
  return `edited ${path}`
219
724
  },
220
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
+ },
221
769
  {
222
770
  name: "view",
771
+ group: "File",
223
772
  signature: "view PATH",
224
773
  description: "Preview a path",
225
774
  arity: { min: 1, max: 1 },
@@ -235,6 +784,7 @@ const operations = [
235
784
  },
236
785
  {
237
786
  name: "open",
787
+ group: "File",
238
788
  signature: "open PATH...",
239
789
  description: "Open paths with the desktop opener",
240
790
  arity: { min: 1 },
@@ -247,6 +797,7 @@ const operations = [
247
797
  },
248
798
  {
249
799
  name: "bookmark",
800
+ group: "Command",
250
801
  signature: "bookmark [NAME] [PATH]",
251
802
  description: "List, add or remove named bookmarks",
252
803
  arity: { min: 0, max: 2 },
@@ -277,6 +828,7 @@ const operations = [
277
828
  },
278
829
  {
279
830
  name: "run",
831
+ group: "File",
280
832
  signature: "run CMD",
281
833
  aliases: ["!"],
282
834
  description: "Run a shell command in the active pane",
@@ -286,7 +838,7 @@ const operations = [
286
838
  async run(session, args) {
287
839
  const command = `${args[0] ?? ""}`.trim()
288
840
  if (!command) throw new Error("run needs a command")
289
- const cwd = session.panes[session.active.value]?.path.value
841
+ const cwd = currentPane(session).path.value
290
842
  await session.os.run(command, { cwd })
291
843
  await refreshPanes(session)
292
844
  return `ran ${command}`
@@ -360,7 +912,7 @@ function expandArg(session, token) {
360
912
  return [rest ? join(session.fs.home(), rest) : session.fs.home()]
361
913
  }
362
914
  if (text.startsWith("/")) return [normalize(text)]
363
- const base = session.panes[session.active.value]?.path.value ?? session.fs.cwd()
915
+ const base = currentPane(session).path.value ?? session.fs.cwd()
364
916
  return [join(base, text)]
365
917
  }
366
918
 
@@ -372,13 +924,193 @@ function expandAll(session, tokens) {
372
924
  return paths
373
925
  }
374
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
+
375
939
  async function isDir(session, path) {
376
940
  const stats = await session.fs.stat(path)
377
941
  return Boolean(stats?.isDir)
378
942
  }
379
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
+
380
954
  async function refreshPanes(session) {
381
- await Promise.all([session.panes.left.refresh(), session.panes.right.refresh()])
955
+ // Every rendered column: the two panes, plus the explorer's parent mirror
956
+ // and the directory the inspector is showing.
957
+ await Promise.all(Object.values(session.panes).map((pane) => pane.refresh()))
958
+ }
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)
382
1114
  }
383
1115
 
384
1116
  async function previewTarget(session, path, details) {
@@ -396,6 +1128,14 @@ async function previewTarget(session, path, details) {
396
1128
  return target
397
1129
  }
398
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
+
399
1139
  // Function: parseSelection
400
1140
  // Splits `sel` arguments into a sign, an optional buffer label and the path
401
1141
  // tokens: `+A paths` adds to buffer A (the source pane), `-B paths` removes
@@ -414,7 +1154,7 @@ function parseSelection(args) {
414
1154
  // default, or the pane whose buffer label matches `name` (`A` = source/left,
415
1155
  // `B` = destination/right).
416
1156
  function findBuffer(session, name) {
417
- const active = session.panes[session.active.value] ?? session.panes.left
1157
+ const active = currentPane(session)
418
1158
  if (!name) return active
419
1159
  const target = `${name}`.toUpperCase()
420
1160
  for (const paneId of Object.keys(session.panes)) {
@@ -423,31 +1163,56 @@ function findBuffer(session, name) {
423
1163
  throw new Error(`unknown selection buffer: ${name}`)
424
1164
  }
425
1165
 
426
- // Function: humanSize
427
- // Compact byte size for stat output (core-local: components own presentation
428
- // formatting).
429
- function humanSize(bytes) {
430
- const value = Number(bytes ?? 0)
431
- if (value < 1024) return `${value}B`
432
- const units = ["K", "M", "G", "T"]
433
- let size = value / 1024
434
- let unit = 0
435
- while (size >= 1024 && unit < units.length - 1) {
436
- size /= 1024
437
- unit++
1166
+ // Function: replacePlan
1167
+ // The literal find-and-replace mapping a `replace` runs: one row per path with
1168
+ // its source basename, its new basename (every occurrence of FIND replaced by
1169
+ // REPLACE), whether it changed, and an error when the result is empty or
1170
+ // contains a path separator. Pure, so the panel preview and the run agree.
1171
+ function replacePlan(find, replace, paths) {
1172
+ return (paths ?? []).map((path) => {
1173
+ const name = basename(path)
1174
+ const newName = name.split(find).join(replace)
1175
+ let error = null
1176
+ if (!newName) error = "empty name"
1177
+ else if (newName.includes("/")) error = "name has a slash"
1178
+ return { path, name, newName, changed: error === null && newName !== name, error }
1179
+ })
1180
+ }
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
+
1188
+ // Function: duplicateTargets
1189
+ // The destination paths two or more changed paths would collide on. Basenames
1190
+ // may repeat safely when their files live in different directories.
1191
+ function duplicateTargets(plan) {
1192
+ const seen = new Set()
1193
+ const duplicates = new Set()
1194
+ for (const row of plan ?? []) {
1195
+ if (!row.changed) continue
1196
+ const target = join(parentOf(row.path), row.newName)
1197
+ if (seen.has(target)) duplicates.add(target)
1198
+ seen.add(target)
438
1199
  }
439
- return `${size >= 100 ? Math.round(size) : size.toFixed(1)}${units[unit]}`
1200
+ return [...duplicates]
440
1201
  }
441
1202
 
442
1203
  export {
1204
+ absolutePath,
443
1205
  expandAll,
444
1206
  expandArg,
445
1207
  decodePathToken,
446
1208
  getOperation,
447
1209
  getOperations,
448
- humanSize,
449
1210
  pathToken,
1211
+ recordHistory,
1212
+ refreshPanes,
1213
+ replacePlan,
450
1214
  runOperation,
1215
+ shellQuote,
451
1216
  }
452
1217
 
453
1218
  // EOF