@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.
- package/README.md +256 -62
- package/bin/tcmd +10 -2
- package/bin/tcmd.js +13 -2
- 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 +19 -6
- package/public/fonts/OFL.txt +93 -0
- package/public/index.html +14 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +20679 -0
- package/public/style.css +1515 -0
- package/scripts/build-desktop.js +31 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +80 -14
- package/src/js/tcmd/components/App.js +339 -114
- package/src/js/tcmd/components/App.xml +76 -22
- package/src/js/tcmd/components/BookmarkList.html +5 -5
- package/src/js/tcmd/components/BookmarkList.js +15 -41
- 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/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +28 -0
- package/src/js/tcmd/components/CommandArgRow.js +146 -30
- package/src/js/tcmd/components/CommandArgRow.xml +55 -4
- package/src/js/tcmd/components/CommandBar.html +2 -2
- package/src/js/tcmd/components/CommandBar.js +33 -27
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +25 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +78 -52
- package/src/js/tcmd/components/CommandPanel.xml +49 -25
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +22 -8
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- 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 +14 -4
- package/src/js/tcmd/components/FilePreview.js +111 -12
- package/src/js/tcmd/components/FilePreview.xml +28 -8
- package/src/js/tcmd/components/FileRow.html +2 -1
- package/src/js/tcmd/components/FileRow.js +70 -12
- package/src/js/tcmd/components/FilterBar.html +1 -1
- package/src/js/tcmd/components/FilterBar.js +14 -4
- package/src/js/tcmd/components/FilterBar.xml +1 -1
- package/src/js/tcmd/components/Frame.js +324 -0
- package/src/js/tcmd/components/Help.html +15 -0
- package/src/js/tcmd/components/Help.js +260 -0
- 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/{CommandPalette.xml → OverlayList.xml} +2 -5
- 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/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +176 -5
- package/src/js/tcmd/components/index.js +9 -5
- package/src/js/tcmd/components/rows.js +176 -48
- 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 +978 -260
- 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 +219 -93
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +438 -54
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +42 -6
- package/src/js/tcmd/core/operations.js +813 -48
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +33 -8
- package/src/js/tcmd/core/panel.js +280 -104
- 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/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +498 -200
- 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/index.js +0 -1
- package/src/js/tcmd/tui/app.js +118 -60
- package/src/js/tcmd/tui/catalogue.js +238 -42
- package/src/js/tcmd/tui/index.js +18 -19
- 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 +32 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +225 -41
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +81 -38
- package/src/js/tcmd/web/effects.js +223 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +4 -4
- package/src/js/tcmd/web/server.js +124 -21
- package/src/js/tcmd/web/theme.js +4 -0
- package/tinyjs.json +16 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/PaletteRow.html +0 -3
- package/src/js/tcmd/components/PaletteRow.js +0 -43
- package/src/js/tcmd/components/PaletteRow.xml +0 -4
- package/src/js/tcmd/tui/frame.js +0 -269
- 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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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 {
|
|
30
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|
|
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
|
-
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
)
|
|
534
|
+
details.push(` ${summary}`)
|
|
535
|
+
results.push(`${path} · ${summary}`)
|
|
161
536
|
}
|
|
162
|
-
session
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|