@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.
- package/README.md +169 -58
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/OverlayList.xml +13 -0
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +612 -136
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/session.js +286 -116
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- 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
|
|
14
|
-
// `signature`, `description`, `aliases` and an optional `confirmText`.
|
|
15
|
-
// optional `
|
|
16
|
-
//
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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 =
|
|
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("
|
|
149
|
-
if (!replace) throw new Error("
|
|
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("
|
|
152
|
-
|
|
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(`
|
|
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(`
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
await session.fs.move(
|
|
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 "
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
)
|
|
534
|
+
details.push(` ${summary}`)
|
|
535
|
+
results.push(`${path} · ${summary}`)
|
|
222
536
|
}
|
|
223
|
-
session
|
|
224
|
-
|
|
225
|
-
|
|
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:
|
|
490
|
-
// The literal find-and-replace mapping a `
|
|
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
|
|
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
|
|
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
|
-
|
|
513
|
-
seen.add(
|
|
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
|
-
|
|
1211
|
+
recordHistory,
|
|
1212
|
+
refreshPanes,
|
|
1213
|
+
replacePlan,
|
|
543
1214
|
runOperation,
|
|
1215
|
+
shellQuote,
|
|
544
1216
|
}
|
|
545
1217
|
|
|
546
1218
|
// EOF
|