@ssebastien/tcmd 0.0.0-stage → 0.1.1
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 +234 -2
- package/bin/tcmd +17 -0
- package/package.json +37 -5
- package/src/js/tcmd/components/App.html +28 -0
- package/src/js/tcmd/components/App.js +355 -0
- package/src/js/tcmd/components/App.xml +98 -0
- package/src/js/tcmd/components/BookmarkList.html +12 -0
- package/src/js/tcmd/components/BookmarkList.js +73 -0
- package/src/js/tcmd/components/BookmarkList.xml +18 -0
- package/src/js/tcmd/components/BookmarkRow.html +5 -0
- package/src/js/tcmd/components/BookmarkRow.js +40 -0
- package/src/js/tcmd/components/BookmarkRow.xml +10 -0
- package/src/js/tcmd/components/Breadcrumbs.js +27 -0
- package/src/js/tcmd/components/CommandArgRow.js +90 -0
- package/src/js/tcmd/components/CommandArgRow.xml +5 -0
- package/src/js/tcmd/components/CommandBar.html +4 -0
- package/src/js/tcmd/components/CommandBar.js +126 -0
- package/src/js/tcmd/components/CommandBar.xml +4 -0
- package/src/js/tcmd/components/CommandItem.html +3 -0
- package/src/js/tcmd/components/CommandItem.js +78 -0
- package/src/js/tcmd/components/CommandItem.xml +6 -0
- package/src/js/tcmd/components/CommandPalette.html +7 -0
- package/src/js/tcmd/components/CommandPalette.js +58 -0
- package/src/js/tcmd/components/CommandPalette.xml +16 -0
- package/src/js/tcmd/components/CommandPanel.js +98 -0
- package/src/js/tcmd/components/CommandPanel.xml +35 -0
- package/src/js/tcmd/components/Confirm.html +3 -0
- package/src/js/tcmd/components/Confirm.js +36 -0
- package/src/js/tcmd/components/Confirm.xml +7 -0
- package/src/js/tcmd/components/Crumb.html +4 -0
- package/src/js/tcmd/components/Crumb.js +48 -0
- package/src/js/tcmd/components/Crumb.xml +4 -0
- package/src/js/tcmd/components/EntryDetail.html +15 -0
- package/src/js/tcmd/components/EntryDetail.js +194 -0
- package/src/js/tcmd/components/EntryDetail.xml +16 -0
- package/src/js/tcmd/components/FileList.html +1 -0
- package/src/js/tcmd/components/FileList.js +41 -0
- package/src/js/tcmd/components/FileList.xml +7 -0
- package/src/js/tcmd/components/FilePreview.html +5 -0
- package/src/js/tcmd/components/FilePreview.js +110 -0
- package/src/js/tcmd/components/FilePreview.xml +10 -0
- package/src/js/tcmd/components/FileRow.html +5 -0
- package/src/js/tcmd/components/FileRow.js +135 -0
- package/src/js/tcmd/components/FileRow.xml +10 -0
- package/src/js/tcmd/components/FilterBar.html +6 -0
- package/src/js/tcmd/components/FilterBar.js +50 -0
- package/src/js/tcmd/components/FilterBar.xml +6 -0
- package/src/js/tcmd/components/Help.xml +11 -0
- package/src/js/tcmd/components/PaletteRow.html +3 -0
- package/src/js/tcmd/components/PaletteRow.js +43 -0
- package/src/js/tcmd/components/PaletteRow.xml +4 -0
- package/src/js/tcmd/components/RowList.html +1 -0
- package/src/js/tcmd/components/RowList.xml +1 -0
- package/src/js/tcmd/components/Summary.html +3 -0
- package/src/js/tcmd/components/Summary.js +77 -0
- package/src/js/tcmd/components/Summary.xml +3 -0
- package/src/js/tcmd/components/bind.js +20 -0
- package/src/js/tcmd/components/format.js +155 -0
- package/src/js/tcmd/components/icons.js +185 -0
- package/src/js/tcmd/components/index.js +41 -0
- package/src/js/tcmd/components/rows.js +212 -0
- package/src/js/tcmd/core/bookmarks.js +175 -0
- package/src/js/tcmd/core/commands.js +930 -0
- package/src/js/tcmd/core/directory.js +194 -0
- package/src/js/tcmd/core/entries.js +88 -0
- package/src/js/tcmd/core/fs.js +266 -0
- package/src/js/tcmd/core/index.js +39 -0
- package/src/js/tcmd/core/keymap.js +60 -0
- package/src/js/tcmd/core/operations.js +453 -0
- package/src/js/tcmd/core/os.js +128 -0
- package/src/js/tcmd/core/pane.js +74 -0
- package/src/js/tcmd/core/panel.js +383 -0
- package/src/js/tcmd/core/path.js +57 -0
- package/src/js/tcmd/core/script.js +186 -0
- package/src/js/tcmd/core/selection.js +43 -0
- package/src/js/tcmd/core/session.js +405 -0
- package/src/js/tcmd/index.js +53 -0
- package/src/js/tcmd/tui/app.js +140 -0
- package/src/js/tcmd/tui/catalogue.js +300 -0
- package/src/js/tcmd/tui/frame.js +269 -0
- package/src/js/tcmd/tui/help.js +32 -0
- package/src/js/tcmd/tui/index.js +104 -0
- package/src/js/tcmd/tui/theme.js +177 -0
- package/src/js/tcmd/vendor/fzy.js +243 -0
- package/src/js/tcmd/web/app.js +154 -0
- package/src/js/tcmd/web/catalogue.js +159 -0
- package/src/js/tcmd/web/header.html +1 -0
- package/src/js/tcmd/web/header.js +38 -0
- package/src/js/tcmd/web/http.js +79 -0
- package/src/js/tcmd/web/index.js +22 -0
- package/src/js/tcmd/web/main.js +151 -0
- package/src/js/tcmd/web/opfs.js +282 -0
- package/src/js/tcmd/web/seed.js +90 -0
- package/src/js/tcmd/web/server.js +191 -0
- package/src/js/tcmd/web/theme.js +23 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-27
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/operations
|
|
7
|
+
// The scripting operations: the file-management verbs the command palette's
|
|
8
|
+
// scripting mode completes and runs. An operation receives the session and
|
|
9
|
+
// its raw argument tokens; path arguments resolve filesets ($A … $FILE), `~`
|
|
10
|
+
// and paths relative to the active pane. Operations own their side effects and
|
|
11
|
+
// return a one-line message (shown in the command bar) or undefined.
|
|
12
|
+
//
|
|
13
|
+
// Registry entries carry the presentation metadata the palette renders:
|
|
14
|
+
// `signature`, `description`, `aliases` and an optional `confirmText`. An
|
|
15
|
+
// optional `risk` ("caution" for a move, "destructive" for delete/erase) lets
|
|
16
|
+
// the command panel tint the operation by how dangerous it is.
|
|
17
|
+
//
|
|
18
|
+
// They also carry `args`, the structured signature the command panel renders:
|
|
19
|
+
// one descriptor per positional argument. A descriptor is
|
|
20
|
+
// `{ name, kind, role?, optional?, variadic? }` where `kind` is:
|
|
21
|
+
// - "selection": one or more paths from a pane's selection (marks, falling
|
|
22
|
+
// back to the cursor entry). `role` picks the pane: "source" (SRC), "dest"
|
|
23
|
+
// (DST) or "active" (default).
|
|
24
|
+
// - "dest": a single destination directory (the DST pane's path).
|
|
25
|
+
// - "buffer": a selection-buffer label (`+A`, `-B`, …).
|
|
26
|
+
// - "literal": free text (a name), edited in the panel's text field.
|
|
27
|
+
// - "raw": the rest of the line, unparsed (a shell command).
|
|
28
|
+
|
|
29
|
+
import { basename, extname, join, normalize } from "./path.js"
|
|
30
|
+
import { PANE_BUFFERS } from "./pane.js"
|
|
31
|
+
|
|
32
|
+
// Constant: PATHS_PREFIX
|
|
33
|
+
// The prefix of a self-contained path-list token the command panel encodes
|
|
34
|
+
// (`$paths:["/a","/b"]`), which `expandArg` decodes back to paths. It keeps a
|
|
35
|
+
// panel argument self-contained (no session state), so it still resolves after
|
|
36
|
+
// an async confirm re-runs it.
|
|
37
|
+
const PATHS_PREFIX = "$paths:"
|
|
38
|
+
|
|
39
|
+
// Function: pathToken
|
|
40
|
+
// Encodes a resolved path list as one `$paths:` token.
|
|
41
|
+
function pathToken(paths) {
|
|
42
|
+
return `${PATHS_PREFIX}${JSON.stringify(paths ?? [])}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Function: decodePathToken
|
|
46
|
+
// Decodes a `$paths:` token to normalized paths, or null when `text` is not one
|
|
47
|
+
// (so `expandArg` can fall through to the named filesets).
|
|
48
|
+
function decodePathToken(text) {
|
|
49
|
+
if (!`${text ?? ""}`.startsWith(PATHS_PREFIX)) return null
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(`${text}`.slice(PATHS_PREFIX.length))
|
|
52
|
+
return Array.isArray(parsed) ? parsed.map((path) => normalize(`${path}`)) : []
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const operations = [
|
|
59
|
+
{
|
|
60
|
+
name: "copy",
|
|
61
|
+
signature: "copy FROM TO",
|
|
62
|
+
description: "Copy files and directories",
|
|
63
|
+
arity: { min: 2, max: 2 },
|
|
64
|
+
args: [
|
|
65
|
+
{ name: "FROM", kind: "selection", role: "source" },
|
|
66
|
+
{ name: "TO", kind: "dest" },
|
|
67
|
+
],
|
|
68
|
+
async run(session, args) {
|
|
69
|
+
const sources = expandAll(session, [args[0]])
|
|
70
|
+
const targets = expandAll(session, [args[1]])
|
|
71
|
+
if (targets.length !== 1) throw new Error("copy: TO must be a single path")
|
|
72
|
+
const [target] = targets
|
|
73
|
+
if (sources.length > 1 && !(await isDir(session, target))) {
|
|
74
|
+
throw new Error("copy: TO must be an existing directory for multiple FROM")
|
|
75
|
+
}
|
|
76
|
+
const into = await isDir(session, target)
|
|
77
|
+
for (const source of sources) {
|
|
78
|
+
if (!(await session.fs.stat(source))) throw new Error(`not found: ${source}`)
|
|
79
|
+
await session.fs.copy(source, into ? join(target, basename(source)) : target)
|
|
80
|
+
}
|
|
81
|
+
await refreshPanes(session)
|
|
82
|
+
return `copied ${sources.length} path${sources.length === 1 ? "" : "s"} to ${target}`
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "move",
|
|
87
|
+
signature: "move FROM TO",
|
|
88
|
+
description: "Move files and directories",
|
|
89
|
+
risk: "caution",
|
|
90
|
+
arity: { min: 2, max: 2 },
|
|
91
|
+
args: [
|
|
92
|
+
{ name: "FROM", kind: "selection", role: "source" },
|
|
93
|
+
{ name: "TO", kind: "dest" },
|
|
94
|
+
],
|
|
95
|
+
async run(session, args) {
|
|
96
|
+
const sources = expandAll(session, [args[0]])
|
|
97
|
+
const targets = expandAll(session, [args[1]])
|
|
98
|
+
if (targets.length !== 1) throw new Error("move: TO must be a single path")
|
|
99
|
+
const [target] = targets
|
|
100
|
+
if (sources.length > 1 && !(await isDir(session, target))) {
|
|
101
|
+
throw new Error("move: TO must be an existing directory for multiple FROM")
|
|
102
|
+
}
|
|
103
|
+
const into = await isDir(session, target)
|
|
104
|
+
for (const source of sources) {
|
|
105
|
+
if (!(await session.fs.stat(source))) throw new Error(`not found: ${source}`)
|
|
106
|
+
await session.fs.move(source, into ? join(target, basename(source)) : target)
|
|
107
|
+
}
|
|
108
|
+
await refreshPanes(session)
|
|
109
|
+
return `moved ${sources.length} path${sources.length === 1 ? "" : "s"} to ${target}`
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "delete",
|
|
114
|
+
signature: "delete PATH...",
|
|
115
|
+
description: "Delete paths, moving them to the trash",
|
|
116
|
+
risk: "destructive",
|
|
117
|
+
arity: { min: 1 },
|
|
118
|
+
args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
|
|
119
|
+
async run(session, args) {
|
|
120
|
+
const paths = expandAll(session, args)
|
|
121
|
+
for (const path of paths) {
|
|
122
|
+
if (!(await session.fs.stat(path))) throw new Error(`not found: ${path}`)
|
|
123
|
+
}
|
|
124
|
+
await session.fs.trash(paths)
|
|
125
|
+
await refreshPanes(session)
|
|
126
|
+
return `trashed ${paths.length} path${paths.length === 1 ? "" : "s"}`
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "erase",
|
|
131
|
+
signature: "erase PATH...",
|
|
132
|
+
description: "Delete paths without moving them to the trash",
|
|
133
|
+
risk: "destructive",
|
|
134
|
+
arity: { min: 1 },
|
|
135
|
+
args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
|
|
136
|
+
confirmText: (args) => `erase ${args.join(" ")} without trash?`,
|
|
137
|
+
async run(session, args) {
|
|
138
|
+
const paths = expandAll(session, args)
|
|
139
|
+
await session.fs.erase(paths)
|
|
140
|
+
await refreshPanes(session)
|
|
141
|
+
return `erased ${paths.length} path${paths.length === 1 ? "" : "s"}`
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "stat",
|
|
146
|
+
signature: "stat PATH...",
|
|
147
|
+
description: "Show file information, including disk usage",
|
|
148
|
+
arity: { min: 1 },
|
|
149
|
+
args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
|
|
150
|
+
async run(session, args) {
|
|
151
|
+
const paths = expandAll(session, args)
|
|
152
|
+
const details = []
|
|
153
|
+
for (const path of paths) {
|
|
154
|
+
const stats = await session.fs.stat(path)
|
|
155
|
+
if (!stats) throw new Error(`not found: ${path}`)
|
|
156
|
+
const size = stats.isDir ? await session.fs.du(path) : stats.size
|
|
157
|
+
details.push(path)
|
|
158
|
+
details.push(
|
|
159
|
+
` ${stats.isDir ? "directory" : "file"} · ${humanSize(size)} · ${new Date(stats.mtimeMs || 0).toISOString()}`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
session.showPreview.set(true)
|
|
163
|
+
session.previewTarget.set(await previewTarget(session, paths[0], details))
|
|
164
|
+
return `stat ${paths.join(" ")}`
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "touch",
|
|
169
|
+
signature: "touch PATH...",
|
|
170
|
+
description: "Create files or bump their modification time",
|
|
171
|
+
arity: { min: 1 },
|
|
172
|
+
args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
|
|
173
|
+
async run(session, args) {
|
|
174
|
+
const paths = expandAll(session, args)
|
|
175
|
+
for (const path of paths) await session.fs.touch(path)
|
|
176
|
+
await refreshPanes(session)
|
|
177
|
+
return `touched ${paths.length} path${paths.length === 1 ? "" : "s"}`
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "sel",
|
|
182
|
+
signature: "sel [+|-][BUF] PATH...",
|
|
183
|
+
description: "Add or remove paths from a selection buffer",
|
|
184
|
+
arity: { min: 1 },
|
|
185
|
+
args: [
|
|
186
|
+
{ name: "BUF", kind: "buffer", optional: true },
|
|
187
|
+
{ name: "PATH", kind: "selection", role: "active", variadic: true },
|
|
188
|
+
],
|
|
189
|
+
async run(session, args) {
|
|
190
|
+
const { sign, buffer, tokens } = parseSelection(args)
|
|
191
|
+
if (!tokens.length) throw new Error("sel needs at least one path")
|
|
192
|
+
const paths = expandAll(session, tokens)
|
|
193
|
+
const target = findBuffer(session, buffer)
|
|
194
|
+
const label = buffer ? `${buffer}`.toUpperCase() : PANE_BUFFERS[session.active.value] ?? PANE_BUFFERS.left
|
|
195
|
+
const marks = new Set(target.marked.value)
|
|
196
|
+
for (const path of paths) {
|
|
197
|
+
if (sign === "-") marks.delete(path)
|
|
198
|
+
else marks.add(path)
|
|
199
|
+
}
|
|
200
|
+
target.marked.set(marks)
|
|
201
|
+
const verb = sign === "-" ? "removed" : "added"
|
|
202
|
+
const to = sign === "-" ? "from" : "to"
|
|
203
|
+
return `${verb} ${paths.length} path${paths.length === 1 ? "" : "s"} ${to} $${label}`
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "edit",
|
|
208
|
+
signature: "edit PATH",
|
|
209
|
+
description: "Edit a file with $EDITOR",
|
|
210
|
+
arity: { min: 1, max: 1 },
|
|
211
|
+
args: [{ name: "PATH", kind: "selection", role: "active" }],
|
|
212
|
+
async run(session, args) {
|
|
213
|
+
const paths = expandAll(session, args)
|
|
214
|
+
if (paths.length !== 1) throw new Error("edit expects a single path")
|
|
215
|
+
const [path] = paths
|
|
216
|
+
await session.os.edit(path)
|
|
217
|
+
await refreshPanes(session)
|
|
218
|
+
return `edited ${path}`
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "view",
|
|
223
|
+
signature: "view PATH",
|
|
224
|
+
description: "Preview a path",
|
|
225
|
+
arity: { min: 1, max: 1 },
|
|
226
|
+
args: [{ name: "PATH", kind: "selection", role: "active" }],
|
|
227
|
+
async run(session, args) {
|
|
228
|
+
const paths = expandAll(session, args)
|
|
229
|
+
if (paths.length !== 1) throw new Error("view expects a single path")
|
|
230
|
+
const [path] = paths
|
|
231
|
+
session.showPreview.set(true)
|
|
232
|
+
session.previewTarget.set(await previewTarget(session, path))
|
|
233
|
+
return `viewing ${path}`
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "open",
|
|
238
|
+
signature: "open PATH...",
|
|
239
|
+
description: "Open paths with the desktop opener",
|
|
240
|
+
arity: { min: 1 },
|
|
241
|
+
args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
|
|
242
|
+
async run(session, args) {
|
|
243
|
+
const paths = expandAll(session, args)
|
|
244
|
+
for (const path of paths) await session.os.open(path)
|
|
245
|
+
return `opened ${paths.length} path${paths.length === 1 ? "" : "s"}`
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: "bookmark",
|
|
250
|
+
signature: "bookmark [NAME] [PATH]",
|
|
251
|
+
description: "List, add or remove named bookmarks",
|
|
252
|
+
arity: { min: 0, max: 2 },
|
|
253
|
+
args: [
|
|
254
|
+
{ name: "NAME", kind: "literal", optional: true },
|
|
255
|
+
{ name: "PATH", kind: "selection", role: "active", optional: true },
|
|
256
|
+
],
|
|
257
|
+
async run(session, args) {
|
|
258
|
+
const bookmarks = session.bookmarks
|
|
259
|
+
if (args.length === 0) {
|
|
260
|
+
const rows = bookmarks.rows()
|
|
261
|
+
if (!rows.length) return "no bookmarks"
|
|
262
|
+
return `bookmarks:\n${rows
|
|
263
|
+
.map((row) => ` ${row.name} ${row.path}${row.isDir === true ? "/" : ""}`)
|
|
264
|
+
.join("\n")}`
|
|
265
|
+
}
|
|
266
|
+
const [name, path] = args
|
|
267
|
+
if (!path) {
|
|
268
|
+
if (!(await bookmarks.remove(name))) throw new Error(`no such bookmark: ${name}`)
|
|
269
|
+
return `removed bookmark ${name}`
|
|
270
|
+
}
|
|
271
|
+
const target = expandArg(session, path)
|
|
272
|
+
if (target.length !== 1) throw new Error("bookmark: PATH must be a single path")
|
|
273
|
+
const stats = await session.fs.stat(target[0])
|
|
274
|
+
await bookmarks.record(name, target[0], stats ? Boolean(stats.isDir) : null)
|
|
275
|
+
return `bookmarked ${name} → ${target[0]}`
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "run",
|
|
280
|
+
signature: "run CMD",
|
|
281
|
+
aliases: ["!"],
|
|
282
|
+
description: "Run a shell command in the active pane",
|
|
283
|
+
raw: true,
|
|
284
|
+
arity: { min: 1 },
|
|
285
|
+
args: [{ name: "CMD", kind: "raw" }],
|
|
286
|
+
async run(session, args) {
|
|
287
|
+
const command = `${args[0] ?? ""}`.trim()
|
|
288
|
+
if (!command) throw new Error("run needs a command")
|
|
289
|
+
const cwd = session.panes[session.active.value]?.path.value
|
|
290
|
+
await session.os.run(command, { cwd })
|
|
291
|
+
await refreshPanes(session)
|
|
292
|
+
return `ran ${command}`
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
// Function: getOperations
|
|
298
|
+
// The full operation registry.
|
|
299
|
+
function getOperations() {
|
|
300
|
+
return operations
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Function: getOperation
|
|
304
|
+
// Looks up an operation by name or alias (case-insensitive).
|
|
305
|
+
function getOperation(name) {
|
|
306
|
+
const needle = `${name ?? ""}`.toLowerCase()
|
|
307
|
+
for (const operation of operations) {
|
|
308
|
+
if (operation.name === needle) return operation
|
|
309
|
+
if ((operation.aliases ?? []).some((alias) => alias.toLowerCase() === needle)) return operation
|
|
310
|
+
}
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Function: runOperation
|
|
315
|
+
// Runs an operation directly with already-split argument tokens, shared by the
|
|
316
|
+
// script line runner and the command panel. `options.confirmed` skips the
|
|
317
|
+
// confirm prompt. Writes the result (or the failure) to the session notice and
|
|
318
|
+
// returns the status: "ran", "confirm", "empty" or "error". Delegated to by
|
|
319
|
+
// `runLine`; the panel passes `options.op` + `options.args` so `confirm.yes`
|
|
320
|
+
// can re-run without re-parsing a line.
|
|
321
|
+
async function runOperation(session, op, args, options = {}) {
|
|
322
|
+
const clean = (args ?? []).filter((arg) => `${arg ?? ""}` !== "")
|
|
323
|
+
if (op.arity) {
|
|
324
|
+
if (clean.length < op.arity.min || (op.arity.max !== undefined && clean.length > op.arity.max)) {
|
|
325
|
+
session.notice.set({ kind: "error", text: `usage: ${op.signature}` })
|
|
326
|
+
return "error"
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (op.confirmText && !options.confirmed) {
|
|
330
|
+
session.confirm.set({ text: op.confirmText(clean), line: options.line, op: op.name, args: clean })
|
|
331
|
+
return "confirm"
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const message = await op.run(session, clean, options)
|
|
335
|
+
if (message) session.notice.set({ kind: "tip", text: message })
|
|
336
|
+
return "ran"
|
|
337
|
+
} catch (error) {
|
|
338
|
+
session.notice.set({ kind: "error", text: `${error?.message ?? error}` })
|
|
339
|
+
return "error"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Function: expandArg
|
|
344
|
+
// Expands one argument token to paths: the command panel's self-contained
|
|
345
|
+
// `$paths:` lists, filesets ($A, $FILE), `~` and paths relative to the active
|
|
346
|
+
// pane.
|
|
347
|
+
function expandArg(session, token) {
|
|
348
|
+
const text = `${token ?? ""}`
|
|
349
|
+
if (text.startsWith("$")) {
|
|
350
|
+
const encoded = decodePathToken(text)
|
|
351
|
+
if (encoded) return encoded
|
|
352
|
+
const set = (session.filesets?.value ?? []).find(
|
|
353
|
+
(candidate) => candidate.id.toLowerCase() === text.toLowerCase(),
|
|
354
|
+
)
|
|
355
|
+
if (!set) throw new Error(`unknown fileset: ${text}`)
|
|
356
|
+
return set.paths.slice()
|
|
357
|
+
}
|
|
358
|
+
if (text.startsWith("~")) {
|
|
359
|
+
const rest = text.replace(/^~\/?/, "")
|
|
360
|
+
return [rest ? join(session.fs.home(), rest) : session.fs.home()]
|
|
361
|
+
}
|
|
362
|
+
if (text.startsWith("/")) return [normalize(text)]
|
|
363
|
+
const base = session.panes[session.active.value]?.path.value ?? session.fs.cwd()
|
|
364
|
+
return [join(base, text)]
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Function: expandAll
|
|
368
|
+
// Expands every token to paths, flattened in order.
|
|
369
|
+
function expandAll(session, tokens) {
|
|
370
|
+
const paths = []
|
|
371
|
+
for (const token of tokens) paths.push(...expandArg(session, token))
|
|
372
|
+
return paths
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function isDir(session, path) {
|
|
376
|
+
const stats = await session.fs.stat(path)
|
|
377
|
+
return Boolean(stats?.isDir)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function refreshPanes(session) {
|
|
381
|
+
await Promise.all([session.panes.left.refresh(), session.panes.right.refresh()])
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function previewTarget(session, path, details) {
|
|
385
|
+
const stats = await session.fs.stat(path)
|
|
386
|
+
if (!stats) throw new Error(`not found: ${path}`)
|
|
387
|
+
const target = {
|
|
388
|
+
path,
|
|
389
|
+
name: basename(path),
|
|
390
|
+
isDir: stats.isDir,
|
|
391
|
+
size: stats.size,
|
|
392
|
+
mtimeMs: stats.mtimeMs,
|
|
393
|
+
ext: extname(path),
|
|
394
|
+
}
|
|
395
|
+
if (details) target.details = details
|
|
396
|
+
return target
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Function: parseSelection
|
|
400
|
+
// Splits `sel` arguments into a sign, an optional buffer label and the path
|
|
401
|
+
// tokens: `+A paths` adds to buffer A (the source pane), `-B paths` removes
|
|
402
|
+
// from buffer B (the destination pane); a bare path list defaults to `+` on the
|
|
403
|
+
// active pane.
|
|
404
|
+
function parseSelection(args) {
|
|
405
|
+
const match = /^([+-])(\w*)$/.exec(args[0] ?? "")
|
|
406
|
+
if (match) {
|
|
407
|
+
return { sign: match[1], buffer: match[2] || null, tokens: args.slice(1) }
|
|
408
|
+
}
|
|
409
|
+
return { sign: "+", buffer: null, tokens: args }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Function: findBuffer
|
|
413
|
+
// Resolves a selection buffer to a pane's marks cell: the active pane by
|
|
414
|
+
// default, or the pane whose buffer label matches `name` (`A` = source/left,
|
|
415
|
+
// `B` = destination/right).
|
|
416
|
+
function findBuffer(session, name) {
|
|
417
|
+
const active = session.panes[session.active.value] ?? session.panes.left
|
|
418
|
+
if (!name) return active
|
|
419
|
+
const target = `${name}`.toUpperCase()
|
|
420
|
+
for (const paneId of Object.keys(session.panes)) {
|
|
421
|
+
if (PANE_BUFFERS[paneId] === target) return session.panes[paneId]
|
|
422
|
+
}
|
|
423
|
+
throw new Error(`unknown selection buffer: ${name}`)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Function: humanSize
|
|
427
|
+
// Compact byte size for stat output (core-local: components own presentation
|
|
428
|
+
// formatting).
|
|
429
|
+
function humanSize(bytes) {
|
|
430
|
+
const value = Number(bytes ?? 0)
|
|
431
|
+
if (value < 1024) return `${value}B`
|
|
432
|
+
const units = ["K", "M", "G", "T"]
|
|
433
|
+
let size = value / 1024
|
|
434
|
+
let unit = 0
|
|
435
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
436
|
+
size /= 1024
|
|
437
|
+
unit++
|
|
438
|
+
}
|
|
439
|
+
return `${size >= 100 ? Math.round(size) : size.toFixed(1)}${units[unit]}`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export {
|
|
443
|
+
expandAll,
|
|
444
|
+
expandArg,
|
|
445
|
+
decodePathToken,
|
|
446
|
+
getOperation,
|
|
447
|
+
getOperations,
|
|
448
|
+
humanSize,
|
|
449
|
+
pathToken,
|
|
450
|
+
runOperation,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// EOF
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-27
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/os
|
|
7
|
+
// Host adapter for the operations that leave the model: opening a path with
|
|
8
|
+
// the desktop opener, editing a file with $EDITOR, and running a shell
|
|
9
|
+
// command. Like `core/fs`, the adapter is injected into the session so the
|
|
10
|
+
// web build can provide a null implementation and tests a fake.
|
|
11
|
+
//
|
|
12
|
+
// A host adapter provides:
|
|
13
|
+
// - `open(path)`: Promise<void> - desktop open (xdg-open / open)
|
|
14
|
+
// - `edit(path)`: Promise<void> - $EDITOR (or vi)
|
|
15
|
+
// - `run(command, options)`: Promise<void> - `$SHELL -c command` in
|
|
16
|
+
// `options.cwd`
|
|
17
|
+
|
|
18
|
+
// `node:child_process` is imported lazily: only `createNodeOs` needs it, so
|
|
19
|
+
// bundles that use the null adapter (the web view, tests) never pull in a Node
|
|
20
|
+
// builtin. See `loadChildProcess`.
|
|
21
|
+
let childProcess = null
|
|
22
|
+
|
|
23
|
+
// Function: loadChildProcess
|
|
24
|
+
// Resolves the `node:child_process` module once, on first use.
|
|
25
|
+
function loadChildProcess() {
|
|
26
|
+
childProcess ??= import("node:child_process")
|
|
27
|
+
return childProcess
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Function: createNodeOs
|
|
31
|
+
// Node/Bun-backed host adapter. `suspend`/`resume` are optional hooks the TUI
|
|
32
|
+
// uses to release and restore the terminal around interactive commands.
|
|
33
|
+
function createNodeOs({ suspend, resume } = {}) {
|
|
34
|
+
return {
|
|
35
|
+
async open(path) {
|
|
36
|
+
await openWith(path, openerCandidates())
|
|
37
|
+
},
|
|
38
|
+
async edit(path) {
|
|
39
|
+
await withSuspended(suspend, resume, () =>
|
|
40
|
+
runCommand(editorCommand(), [path], {}),
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
async run(command, options = {}) {
|
|
44
|
+
await withSuspended(suspend, resume, () =>
|
|
45
|
+
runCommand(shellCommand(), ["-c", command], { cwd: options.cwd }),
|
|
46
|
+
)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Function: createNullOs
|
|
52
|
+
// Host adapter for environments without a shell (web): every operation fails
|
|
53
|
+
// with a clear message, which the dispatch path reports in the command bar.
|
|
54
|
+
function createNullOs() {
|
|
55
|
+
const unsupported = (name) => async () => {
|
|
56
|
+
throw new Error(`${name} is not available in this environment`)
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
open: unsupported("open"),
|
|
60
|
+
edit: unsupported("edit"),
|
|
61
|
+
run: unsupported("run"),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Function: openerCandidates
|
|
66
|
+
// Desktop openers in preference order: $TCMD_OPENER, then `open` on macOS and
|
|
67
|
+
// `gnome-open`/`xdg-open` on Linux. Missing commands fall through to the next
|
|
68
|
+
// candidate.
|
|
69
|
+
function openerCandidates() {
|
|
70
|
+
if (process.env.TCMD_OPENER) return [process.env.TCMD_OPENER]
|
|
71
|
+
return process.platform === "darwin" ? ["open"] : ["gnome-open", "xdg-open"]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function openWith(path, candidates) {
|
|
75
|
+
let lastError
|
|
76
|
+
for (const command of candidates) {
|
|
77
|
+
try {
|
|
78
|
+
await runDetached(command, [path])
|
|
79
|
+
return
|
|
80
|
+
} catch (error) {
|
|
81
|
+
lastError = error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw lastError ?? new Error("no desktop opener found")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function editorCommand() {
|
|
88
|
+
return process.env.EDITOR || process.env.VISUAL || "vi"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shellCommand() {
|
|
92
|
+
return process.env.SHELL || "/bin/sh"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function withSuspended(suspend, resume, fn) {
|
|
96
|
+
if (suspend) suspend()
|
|
97
|
+
try {
|
|
98
|
+
return await fn()
|
|
99
|
+
} finally {
|
|
100
|
+
if (resume) resume()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runCommand(command, args, options) {
|
|
105
|
+
const { spawn } = await loadChildProcess()
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const child = spawn(command, args, { stdio: "inherit", ...options })
|
|
108
|
+
child.on("error", reject)
|
|
109
|
+
child.on("exit", (code, signal) => {
|
|
110
|
+
if (code === 0) resolve()
|
|
111
|
+
else reject(new Error(`${command} exited with ${signal ?? code}`))
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runDetached(command, args) {
|
|
117
|
+
const { spawn } = await loadChildProcess()
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
|
120
|
+
child.on("error", reject)
|
|
121
|
+
child.unref()
|
|
122
|
+
resolve()
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { createNodeOs, createNullOs }
|
|
127
|
+
|
|
128
|
+
// EOF
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/pane
|
|
7
|
+
// A Pane is one column with a single directory: it owns that directory and
|
|
8
|
+
// proxies its cells (path, entries, cursor, marks, disk, …) as pane-level
|
|
9
|
+
// cells, so views and commands address the pane directly. Navigation is async
|
|
10
|
+
// and updates the directory's `rawEntries` in place.
|
|
11
|
+
|
|
12
|
+
import { cell } from "select/state/cells"
|
|
13
|
+
import { createDirectory } from "./directory.js"
|
|
14
|
+
|
|
15
|
+
// Constant: PANE_BUFFERS
|
|
16
|
+
// The selection-buffer label per pane: the left (source) pane is `$A`, the
|
|
17
|
+
// right (destination) pane is `$B`, so scripting keeps two named buffers.
|
|
18
|
+
const PANE_BUFFERS = { left: "A", right: "B" }
|
|
19
|
+
|
|
20
|
+
// Constant: PANE_LABELS
|
|
21
|
+
// The pane role labels: the left pane is the source (SRC), the right the
|
|
22
|
+
// destination (DST).
|
|
23
|
+
const PANE_LABELS = { left: "SRC", right: "DST" }
|
|
24
|
+
|
|
25
|
+
// Function: createPane
|
|
26
|
+
// Creates a pane bound to a filesystem adapter at `path`.
|
|
27
|
+
//
|
|
28
|
+
// Example:
|
|
29
|
+
// ```javascript
|
|
30
|
+
// const pane = createPane({ fs: createNodeFs(), path: "/home" })
|
|
31
|
+
// await pane.refresh()
|
|
32
|
+
// pane.entries.value // sorted, filtered FileEntry[]
|
|
33
|
+
// ```
|
|
34
|
+
function createPane({ fs, path: initialPath } = {}) {
|
|
35
|
+
const pane = {}
|
|
36
|
+
pane.fs = fs
|
|
37
|
+
pane.directory = createDirectory({ fs, path: initialPath })
|
|
38
|
+
|
|
39
|
+
// Proxy cells mirror the directory, so views address the pane uniformly.
|
|
40
|
+
const dir = pane.directory
|
|
41
|
+
pane.path = dir.path
|
|
42
|
+
pane.rawEntries = dir.rawEntries
|
|
43
|
+
pane.loading = dir.loading
|
|
44
|
+
pane.error = dir.error
|
|
45
|
+
pane.filter = dir.filter
|
|
46
|
+
pane.sort = dir.sort
|
|
47
|
+
pane.entries = dir.entries
|
|
48
|
+
pane.cursor = dir.cursor
|
|
49
|
+
pane.cursorStat = dir.cursorStat
|
|
50
|
+
pane.marked = dir.marked
|
|
51
|
+
pane.disk = dir.disk
|
|
52
|
+
pane.segments = dir.segments
|
|
53
|
+
|
|
54
|
+
// Function: current
|
|
55
|
+
// The pane's directory.
|
|
56
|
+
pane.current = () => pane.directory
|
|
57
|
+
|
|
58
|
+
// Function: refresh / navigate / up / open / back / forward /
|
|
59
|
+
// currentEntry / toggleMark / clearFilter — delegate to the directory.
|
|
60
|
+
pane.refresh = () => pane.directory.refresh()
|
|
61
|
+
pane.navigate = (path) => pane.directory.navigate(path)
|
|
62
|
+
pane.up = () => pane.directory.up()
|
|
63
|
+
pane.open = () => pane.directory.open()
|
|
64
|
+
pane.back = () => pane.directory.back()
|
|
65
|
+
pane.forward = () => pane.directory.forward()
|
|
66
|
+
pane.currentEntry = () => pane.directory.currentEntry()
|
|
67
|
+
pane.toggleMark = () => pane.directory.toggleMark()
|
|
68
|
+
pane.clearFilter = () => pane.directory.clearFilter()
|
|
69
|
+
pane.dispose = () => pane.directory.dispose()
|
|
70
|
+
|
|
71
|
+
return pane
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { PANE_BUFFERS, PANE_LABELS, createPane }
|