@ssebastien/tcmd 0.0.0-stage → 0.1.0
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 +7 -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,383 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-09
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/panel
|
|
7
|
+
// The command panel (Commando): a structured view over the operation registry.
|
|
8
|
+
// It lists the operations alphabetically — ranked by match importance once a
|
|
9
|
+
// query filters them (see `filterPanelOperations`) — and, for the selected
|
|
10
|
+
// one, its arguments bound from
|
|
11
|
+
// the live context — a selection argument shows the SRC/DST pane's marked paths
|
|
12
|
+
// (falling back to the cursor entry) and lets the user toggle individual items;
|
|
13
|
+
// a destination argument resolves to the DST pane's directory; textual
|
|
14
|
+
// arguments (a name, a buffer label, a shell command) are typed in place.
|
|
15
|
+
//
|
|
16
|
+
// The selection the user tweaks is *transient*: it lives in the draft bindings
|
|
17
|
+
// (`session.panelDraft`), never in the real pane marks. When the draft runs,
|
|
18
|
+
// each selection argument is encoded as a self-contained `$paths:[…]` token, so
|
|
19
|
+
// the resolved paths survive an async confirm (see `expandArg`). The same draft
|
|
20
|
+
// assembles the command line the panel previews (`panelCommand`), so the user
|
|
21
|
+
// sees how the current selection maps to the operation before running it.
|
|
22
|
+
// Everything here is pure: the caller owns the cells; the panel commands in
|
|
23
|
+
// `core/commands` assemble and run.
|
|
24
|
+
|
|
25
|
+
import { hasMatch, score } from "../vendor/fzy.js"
|
|
26
|
+
import { PANE_BUFFERS, PANE_LABELS } from "./pane.js"
|
|
27
|
+
import { basename, parent } from "./path.js"
|
|
28
|
+
import { getOperations, pathToken } from "./operations.js"
|
|
29
|
+
|
|
30
|
+
// Constant: PANEL_FOCUS
|
|
31
|
+
// The two columns the panel's focus ring moves through: "ops" (the operation
|
|
32
|
+
// list, where typing filters) and "args" (an argument row, where Space toggles
|
|
33
|
+
// and a textual argument owns the keyboard).
|
|
34
|
+
const PANEL_FOCUS = { OPS: "ops", ARGS: "args" }
|
|
35
|
+
|
|
36
|
+
// Function: panelOperations
|
|
37
|
+
// The operations the panel lists: every visible registry entry that carries a
|
|
38
|
+
// structured `args` signature (the ones that take arguments).
|
|
39
|
+
function panelOperations() {
|
|
40
|
+
return getOperations().filter((op) => !op.hidden && Array.isArray(op.args))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Function: compareOperations
|
|
44
|
+
// Alphabetical order by name, the panel's default ordering.
|
|
45
|
+
function compareOperations(a, b) {
|
|
46
|
+
return `${a?.name ?? ""}`.localeCompare(`${b?.name ?? ""}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Function: operationScore
|
|
50
|
+
// The match importance of an operation for a query: the best fzy score across
|
|
51
|
+
// its name and aliases, or `-Infinity` when neither matches (only the
|
|
52
|
+
// signature does). Signature hits stay listable but rank below any name
|
|
53
|
+
// match.
|
|
54
|
+
function operationScore(op, query) {
|
|
55
|
+
let best = -Infinity
|
|
56
|
+
for (const name of [op.name, ...(op.aliases ?? [])]) {
|
|
57
|
+
if (hasMatch(query, name)) best = Math.max(best, score(query, name))
|
|
58
|
+
}
|
|
59
|
+
return best
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Function: filterPanelOperations
|
|
63
|
+
// Narrows the operation list by a typed query and orders it: no query lists
|
|
64
|
+
// every operation alphabetically; a query ranks the matches by importance
|
|
65
|
+
// (the fzy score of the best name/alias match, so `open` outranks `copy` for
|
|
66
|
+
// `op`), ties broken alphabetically. A query that only matches an operation's
|
|
67
|
+
// signature (`buf` -> `sel [+|-][BUF] PATH...`) keeps it listable, at the
|
|
68
|
+
// bottom.
|
|
69
|
+
function filterPanelOperations(operations, query) {
|
|
70
|
+
const list = operations ?? []
|
|
71
|
+
const q = `${query ?? ""}`.trim().toLowerCase()
|
|
72
|
+
if (!q) return [...list].sort(compareOperations)
|
|
73
|
+
const ranked = []
|
|
74
|
+
for (const op of list) {
|
|
75
|
+
const rank = operationScore(op, q)
|
|
76
|
+
const signatureMatch = `${op.signature ?? ""}`.toLowerCase().includes(q)
|
|
77
|
+
if (rank === -Infinity && !signatureMatch) continue
|
|
78
|
+
ranked.push({ op, score: rank })
|
|
79
|
+
}
|
|
80
|
+
ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : compareOperations(a.op, b.op)))
|
|
81
|
+
return ranked.map((match) => match.op)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Function: resolvePaneId
|
|
85
|
+
// Maps an argument role to a pane id: "source" -> SRC (left), "dest" -> DST
|
|
86
|
+
// (right), anything else -> the active pane.
|
|
87
|
+
function resolvePaneId(session, role) {
|
|
88
|
+
if (role === "source") return "left"
|
|
89
|
+
if (role === "dest") return "right"
|
|
90
|
+
return session.active.value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Function: paneSelectionPaths
|
|
94
|
+
// The paths a selection argument draws from a pane: its marked set, or the
|
|
95
|
+
// cursor entry when nothing is marked. An empty listing yields nothing.
|
|
96
|
+
function paneSelectionPaths(pane) {
|
|
97
|
+
const marked = [...(pane?.marked?.value ?? [])]
|
|
98
|
+
if (marked.length) return marked
|
|
99
|
+
const entry = pane?.currentEntry?.()
|
|
100
|
+
return entry ? [entry.path] : []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Function: itemFor
|
|
104
|
+
// One toggleable selection item: the path plus the listing name/kind when the
|
|
105
|
+
// pane lists it (filesets can name paths outside the current listing). The
|
|
106
|
+
// parent directory rides along so the row can show where the path lives.
|
|
107
|
+
function itemFor(index, path) {
|
|
108
|
+
const entry = index?.get?.(path)
|
|
109
|
+
return {
|
|
110
|
+
path,
|
|
111
|
+
name: entry?.name ?? basename(path),
|
|
112
|
+
parent: parent(path) ?? "",
|
|
113
|
+
isDir: Boolean(entry?.isDir),
|
|
114
|
+
selected: true,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Function: entryIndex
|
|
119
|
+
// A `path -> entry` map for a pane's current listing, so binding many marked
|
|
120
|
+
// paths does not rescan the listing per path.
|
|
121
|
+
function entryIndex(pane) {
|
|
122
|
+
const index = new Map()
|
|
123
|
+
for (const entry of pane?.entries?.value ?? []) index.set(entry.path, entry)
|
|
124
|
+
return index
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Function: bindArg
|
|
128
|
+
// Binds one argument descriptor to the live context, producing the draft
|
|
129
|
+
// binding the panel renders and toggles.
|
|
130
|
+
function bindArg(session, arg, index) {
|
|
131
|
+
const role = arg.role ?? "active"
|
|
132
|
+
const common = {
|
|
133
|
+
index,
|
|
134
|
+
name: arg.name,
|
|
135
|
+
kind: arg.kind,
|
|
136
|
+
role,
|
|
137
|
+
optional: arg.optional === true,
|
|
138
|
+
variadic: arg.variadic === true,
|
|
139
|
+
textual: arg.kind === "literal" || arg.kind === "raw" || arg.kind === "buffer",
|
|
140
|
+
}
|
|
141
|
+
if (arg.kind === "selection") {
|
|
142
|
+
const paneId = resolvePaneId(session, role)
|
|
143
|
+
const pane = session.panes[paneId]
|
|
144
|
+
const entries = entryIndex(pane)
|
|
145
|
+
return {
|
|
146
|
+
...common,
|
|
147
|
+
paneId,
|
|
148
|
+
items: paneSelectionPaths(pane).map((path) => itemFor(entries, path)),
|
|
149
|
+
value: "",
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (arg.kind === "dest") {
|
|
153
|
+
const pane = session.panes.right
|
|
154
|
+
const path = pane?.path?.value ?? ""
|
|
155
|
+
// The destination is editable in place, like a literal (no toggles).
|
|
156
|
+
return { ...common, paneId: "right", textual: true, items: [], value: path }
|
|
157
|
+
}
|
|
158
|
+
if (arg.kind === "buffer") {
|
|
159
|
+
return { ...common, items: [], value: `+${PANE_BUFFERS[session.active.value] ?? "A"}` }
|
|
160
|
+
}
|
|
161
|
+
return { ...common, items: [], value: "" }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Function: bindArgs
|
|
165
|
+
// Binds every argument of `op` to the live context.
|
|
166
|
+
function bindArgs(session, op) {
|
|
167
|
+
return (op?.args ?? []).map((arg, index) => bindArg(session, arg, index))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Function: selectedPaths
|
|
171
|
+
// The paths a binding currently contributes (selected items).
|
|
172
|
+
function selectedPaths(binding) {
|
|
173
|
+
return (binding?.items ?? []).filter((item) => item.selected).map((item) => item.path)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Function: argTokens
|
|
177
|
+
// The untrimmed token each binding contributes: a selection its encoded
|
|
178
|
+
// `$paths:` token, a destination its path, a textual arg its value. Shared by
|
|
179
|
+
// `argvFor` (which trims trailing placeholders) and `missingArg` (which needs
|
|
180
|
+
// them to spot gaps).
|
|
181
|
+
function argTokens(bindings) {
|
|
182
|
+
return (bindings ?? []).map((binding) => {
|
|
183
|
+
if (binding.kind === "selection") {
|
|
184
|
+
const paths = selectedPaths(binding)
|
|
185
|
+
return paths.length ? pathToken(paths) : ""
|
|
186
|
+
}
|
|
187
|
+
if (binding.kind === "dest") return binding.value ?? ""
|
|
188
|
+
return `${binding.value ?? ""}`.trim()
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Function: argvFor
|
|
193
|
+
// The token array `runOperation` consumes for the draft (see `argTokens`). An
|
|
194
|
+
// empty textual optional keeps a `""` placeholder so later positional args do
|
|
195
|
+
// not shift; trailing placeholders are trimmed (arity counts non-empty tokens,
|
|
196
|
+
// so `bookmark NAME` with no PATH still means "remove NAME").
|
|
197
|
+
function argvFor(bindings) {
|
|
198
|
+
const args = argTokens(bindings)
|
|
199
|
+
while (args.length && args[args.length - 1] === "") args.pop()
|
|
200
|
+
return args
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Function: missingArg
|
|
204
|
+
// The name of the first argument that is not runnable: a required argument left
|
|
205
|
+
// blank, an optional one before a filled one (a positional gap would shift the
|
|
206
|
+
// following tokens), or a selection with nothing selected. Returns null when the
|
|
207
|
+
// argv is runnable.
|
|
208
|
+
function missingArg(bindings) {
|
|
209
|
+
const list = bindings ?? []
|
|
210
|
+
const args = argTokens(list)
|
|
211
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
212
|
+
if (args[i] === "") return list[i].name
|
|
213
|
+
}
|
|
214
|
+
// A trailing required argument must still be filled.
|
|
215
|
+
const trailing = list[list.length - 1]
|
|
216
|
+
if (trailing && args[args.length - 1] === "" && trailing.optional !== true) return trailing.name
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Function: shellQuote
|
|
221
|
+
// Quotes a token for display as it would appear in a shell: bare when it is
|
|
222
|
+
// already safe (`/home/a`), single-quoted when it contains spaces or shell
|
|
223
|
+
// metacharacters (`'my notes.txt'`). Only the last path segment matters in
|
|
224
|
+
// practice, but quoting the whole token keeps the preview copy-pasteable.
|
|
225
|
+
function shellQuote(token) {
|
|
226
|
+
const text = `${token ?? ""}`
|
|
227
|
+
if (text === "") return "''"
|
|
228
|
+
return /^[A-Za-z0-9_@%+=:,.\/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Function: panelCommand
|
|
232
|
+
// The command line the draft maps to: each argument rendered as the value it
|
|
233
|
+
// contributes — a selection as its selected paths, a destination/textual arg
|
|
234
|
+
// as its value — quoted as it would appear in a shell. A required argument left
|
|
235
|
+
// empty shows a `«NAME»` placeholder so the mapping stays legible, and an empty
|
|
236
|
+
// optional argument is omitted (it contributes no token). This is the "resolved
|
|
237
|
+
// command" the panel previews in full.
|
|
238
|
+
function panelCommand(bindings) {
|
|
239
|
+
const parts = []
|
|
240
|
+
for (const binding of bindings ?? []) {
|
|
241
|
+
if (binding.kind === "selection") {
|
|
242
|
+
const paths = selectedPaths(binding)
|
|
243
|
+
if (paths.length) parts.push(...paths.map(shellQuote))
|
|
244
|
+
else if (binding.optional !== true) parts.push(`«${binding.name}»`)
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
const value = `${binding.value ?? ""}`.trim()
|
|
248
|
+
if (value) parts.push(shellQuote(value))
|
|
249
|
+
else if (binding.optional !== true) parts.push(`«${binding.name}»`)
|
|
250
|
+
}
|
|
251
|
+
return parts.join(" ")
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Function: panelRunnable
|
|
255
|
+
// True when the draft has no missing required argument (see `missingArg`).
|
|
256
|
+
function panelRunnable(bindings) {
|
|
257
|
+
return missingArg(bindings) === null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Function: panelSummary
|
|
261
|
+
// One-line description of a binding for the panel: the pane and selected count
|
|
262
|
+
// for a selection, the value for a destination/textual arg.
|
|
263
|
+
function panelSummary(binding) {
|
|
264
|
+
if (!binding) return ""
|
|
265
|
+
if (binding.kind === "selection") {
|
|
266
|
+
const pane =
|
|
267
|
+
PANE_LABELS[binding.paneId] ??
|
|
268
|
+
(binding.role === "source" ? "SRC" : binding.role === "dest" ? "DST" : "PANE")
|
|
269
|
+
const count = selectedPaths(binding).length
|
|
270
|
+
return `${pane} · ${count} selected`
|
|
271
|
+
}
|
|
272
|
+
if (binding.kind === "dest") return binding.value || "(none)"
|
|
273
|
+
if (binding.kind === "buffer") return binding.value || "(active)"
|
|
274
|
+
return binding.value ? binding.value : "(empty)"
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Function: panelRowList
|
|
278
|
+
// Flattens the draft into the rows the panel's argument column renders: one
|
|
279
|
+
// header row per argument, followed by one item row per item of a
|
|
280
|
+
// selection/destination argument (so individual paths can be toggled). Row
|
|
281
|
+
// focus is assigned by the view (comparing the row index to the cursor), the
|
|
282
|
+
// same way list rows are; this stays pure and index-stable.
|
|
283
|
+
function panelRowList(bindings) {
|
|
284
|
+
const rows = []
|
|
285
|
+
for (const binding of bindings ?? []) {
|
|
286
|
+
rows.push({
|
|
287
|
+
type: "arg",
|
|
288
|
+
key: `arg:${binding.index}`,
|
|
289
|
+
argIndex: binding.index,
|
|
290
|
+
itemIndex: -1,
|
|
291
|
+
name: binding.name,
|
|
292
|
+
summary: panelSummary(binding),
|
|
293
|
+
kind: binding.kind,
|
|
294
|
+
role: binding.role,
|
|
295
|
+
optional: binding.optional,
|
|
296
|
+
variadic: binding.variadic,
|
|
297
|
+
textual: binding.textual,
|
|
298
|
+
value: binding.value,
|
|
299
|
+
count: (binding.items ?? []).length,
|
|
300
|
+
})
|
|
301
|
+
for (let i = 0; i < (binding.items ?? []).length; i++) {
|
|
302
|
+
const item = binding.items[i]
|
|
303
|
+
const selected = item.selected
|
|
304
|
+
rows.push({
|
|
305
|
+
type: "item",
|
|
306
|
+
key: `item:${binding.index}:${item.path}`,
|
|
307
|
+
argIndex: binding.index,
|
|
308
|
+
itemIndex: i,
|
|
309
|
+
name: item.name,
|
|
310
|
+
path: item.path,
|
|
311
|
+
parent: item.parent ?? "",
|
|
312
|
+
isDir: item.isDir,
|
|
313
|
+
selected,
|
|
314
|
+
marker: selected ? "[x]" : "[ ]",
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return rows
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Function: rowAt
|
|
322
|
+
// The argument row at the cursor `index`, or null.
|
|
323
|
+
function rowAt(rows, index) {
|
|
324
|
+
return (rows ?? [])[index] ?? null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Function: isTextualRow
|
|
328
|
+
// True when the cursor row is an argument header that takes free text, so the
|
|
329
|
+
// panel focuses its edit field.
|
|
330
|
+
function isTextualRow(row) {
|
|
331
|
+
return Boolean(row && row.type === "arg" && row.textual)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Function: withToggledItem
|
|
335
|
+
// Returns a new draft with item `itemIndex` of argument `argIndex` toggled,
|
|
336
|
+
// leaving the other bindings untouched.
|
|
337
|
+
function withToggledItem(draft, argIndex, itemIndex) {
|
|
338
|
+
return (draft ?? []).map((binding) => {
|
|
339
|
+
if (binding.index !== argIndex) return binding
|
|
340
|
+
const items = (binding.items ?? []).map((item, i) =>
|
|
341
|
+
i === itemIndex ? { ...item, selected: !item.selected } : item,
|
|
342
|
+
)
|
|
343
|
+
return { ...binding, items }
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Function: withArgValue
|
|
348
|
+
// Returns a new draft with the value of argument `argIndex` set.
|
|
349
|
+
function withArgValue(draft, argIndex, value) {
|
|
350
|
+
return (draft ?? []).map((binding) =>
|
|
351
|
+
binding.index === argIndex ? { ...binding, value } : binding,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Function: bindingAt
|
|
356
|
+
// The draft binding for an argument index, or null.
|
|
357
|
+
function bindingAt(draft, argIndex) {
|
|
358
|
+
return (draft ?? []).find((binding) => binding.index === argIndex) ?? null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Convenience re-exports: the `$paths:` token helpers live in `core/operations`
|
|
362
|
+
// (where `expandArg` decodes them); the panel is their only producer.
|
|
363
|
+
export { decodePathToken, pathToken } from "./operations.js"
|
|
364
|
+
|
|
365
|
+
export {
|
|
366
|
+
PANEL_FOCUS,
|
|
367
|
+
argvFor,
|
|
368
|
+
bindArgs,
|
|
369
|
+
bindingAt,
|
|
370
|
+
filterPanelOperations,
|
|
371
|
+
isTextualRow,
|
|
372
|
+
missingArg,
|
|
373
|
+
panelCommand,
|
|
374
|
+
panelOperations,
|
|
375
|
+
panelRowList,
|
|
376
|
+
panelRunnable,
|
|
377
|
+
panelSummary,
|
|
378
|
+
paneSelectionPaths,
|
|
379
|
+
rowAt,
|
|
380
|
+
selectedPaths,
|
|
381
|
+
withArgValue,
|
|
382
|
+
withToggledItem,
|
|
383
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/path
|
|
7
|
+
// Pure string path operations. No filesystem or platform imports, so the same
|
|
8
|
+
// helpers can back the TUI now and a web UI later.
|
|
9
|
+
|
|
10
|
+
const SEP = "/"
|
|
11
|
+
|
|
12
|
+
function normalize(p) {
|
|
13
|
+
return `${p ?? ""}`.replace(/\/+/g, SEP).replace(/(.)\/$/, "$1") || SEP
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function join(base, name) {
|
|
17
|
+
if (name === undefined || name === null || name === "") return normalize(base)
|
|
18
|
+
if (name.startsWith(SEP)) return normalize(name)
|
|
19
|
+
return normalize(`${normalize(base)}${SEP}${name}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function basename(p) {
|
|
23
|
+
const parts = split(p)
|
|
24
|
+
return parts.length ? parts[parts.length - 1] : SEP
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parent(p) {
|
|
28
|
+
const parts = split(p)
|
|
29
|
+
if (parts.length === 0) return null
|
|
30
|
+
if (parts.length === 1) return SEP
|
|
31
|
+
return SEP + parts.slice(0, parts.length - 1).join(SEP)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function split(p) {
|
|
35
|
+
return normalize(p).split(SEP).filter((part) => part.length > 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Function: extname
|
|
39
|
+
// Lowercased extension of the last segment, without the dot; null when the
|
|
40
|
+
// name has no extension (or is a dotfile).
|
|
41
|
+
function extname(p) {
|
|
42
|
+
const name = basename(p)
|
|
43
|
+
const at = name.lastIndexOf(".")
|
|
44
|
+
return at > 0 ? name.slice(at + 1).toLowerCase() : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Function: prefix
|
|
48
|
+
// Returns the path of the first `count` non-root segments of `p`, so
|
|
49
|
+
// `prefix("/a/b/c", 2)` is `"/a/b"`; count 0 is the root itself.
|
|
50
|
+
function prefix(p, count) {
|
|
51
|
+
const parts = split(p)
|
|
52
|
+
return SEP + parts.slice(0, Math.max(0, count)).join(SEP)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { SEP, basename, extname, join, normalize, parent, prefix, split }
|
|
56
|
+
|
|
57
|
+
// EOF
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-27
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/script
|
|
7
|
+
// The scripting language behind the command palette's scripting mode (`:`):
|
|
8
|
+
// line tokenizer, operation parser and runner, plus the completion rows the
|
|
9
|
+
// palette lists. A line starts with an operation name (or the `!` alias for
|
|
10
|
+
// `run`); arguments may be filesets ($A, $B, $FILE), quoted paths, `~` or
|
|
11
|
+
// paths relative to the active pane.
|
|
12
|
+
|
|
13
|
+
import { getOperation, getOperations, humanSize, runOperation } from "./operations.js"
|
|
14
|
+
|
|
15
|
+
// Function: tokenize
|
|
16
|
+
// Whitespace tokenizer with single/double quotes and backslash escapes.
|
|
17
|
+
function tokenize(line) {
|
|
18
|
+
const tokens = []
|
|
19
|
+
let current = ""
|
|
20
|
+
let quote = null
|
|
21
|
+
let escaping = false
|
|
22
|
+
for (const char of `${line ?? ""}`) {
|
|
23
|
+
if (escaping) {
|
|
24
|
+
current += char
|
|
25
|
+
escaping = false
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if (char === "\\") {
|
|
29
|
+
escaping = true
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (quote) {
|
|
33
|
+
if (char === quote) quote = null
|
|
34
|
+
else current += char
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (char === '"' || char === "'") {
|
|
38
|
+
quote = char
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (/\s/.test(char)) {
|
|
42
|
+
if (current !== "") {
|
|
43
|
+
tokens.push(current)
|
|
44
|
+
current = ""
|
|
45
|
+
}
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
current += char
|
|
49
|
+
}
|
|
50
|
+
if (current !== "") tokens.push(current)
|
|
51
|
+
return tokens
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Function: parseLine
|
|
55
|
+
// Parses a script line into `{ status, op, args, error }`:
|
|
56
|
+
// - "ok": an operation with positional args (raw string for `run`)
|
|
57
|
+
// - "empty": nothing to run
|
|
58
|
+
// - "error": unknown operation or wrong arity
|
|
59
|
+
function parseLine(line) {
|
|
60
|
+
const text = `${line ?? ""}`.trim()
|
|
61
|
+
if (!text) return { status: "empty", error: "empty command" }
|
|
62
|
+
if (text.startsWith("!")) {
|
|
63
|
+
const command = text.slice(1).trim()
|
|
64
|
+
if (!command) return { status: "empty", error: "empty command" }
|
|
65
|
+
return { status: "ok", op: getOperation("run"), args: [command] }
|
|
66
|
+
}
|
|
67
|
+
const tokens = tokenize(text)
|
|
68
|
+
const op = getOperation(tokens[0])
|
|
69
|
+
if (!op) return { status: "error", error: `unknown operation: ${tokens[0]}` }
|
|
70
|
+
const args = op.raw
|
|
71
|
+
? [text.slice(tokens[0].length).trim()]
|
|
72
|
+
: tokens.slice(1)
|
|
73
|
+
if (op.arity) {
|
|
74
|
+
const count = args.filter((arg) => arg !== "").length
|
|
75
|
+
if (count < op.arity.min || (op.arity.max !== undefined && count > op.arity.max)) {
|
|
76
|
+
return { status: "error", error: `usage: ${op.signature}` }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { status: "ok", op, args }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Function: runLine
|
|
83
|
+
// Parses and runs a script line. Returns "ran", "confirm" (the session
|
|
84
|
+
// confirm cell is filled), "empty" or "error"; failures and results land on
|
|
85
|
+
// the session notice. Delegates execution to `runOperation`, shared with the
|
|
86
|
+
// command panel.
|
|
87
|
+
async function runLine(session, line, options = {}) {
|
|
88
|
+
const parsed = parseLine(line)
|
|
89
|
+
if (parsed.status !== "ok") {
|
|
90
|
+
if (parsed.error) session.notice.set({ kind: "error", text: parsed.error })
|
|
91
|
+
return parsed.status
|
|
92
|
+
}
|
|
93
|
+
return runOperation(session, parsed.op, parsed.args, { ...options, line })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Function: scriptItems
|
|
97
|
+
// Palette rows for scripting mode: the session's available filesets first,
|
|
98
|
+
// then every operation, then matching entries of the active pane (path
|
|
99
|
+
// completion). Rows carry the full query replacement `completion` for Tab.
|
|
100
|
+
function scriptItems(query, filesets, context = {}) {
|
|
101
|
+
const text = `${query ?? ""}`
|
|
102
|
+
const q = text.trim().toLowerCase()
|
|
103
|
+
const head = q.split(/\s+/)[0] ?? ""
|
|
104
|
+
const rows = []
|
|
105
|
+
const typingFileset = lastToken(q).startsWith("$")
|
|
106
|
+
for (const set of filesets ?? []) {
|
|
107
|
+
const id = set.id.toLowerCase()
|
|
108
|
+
if (head && !id.includes(head) && !q.includes(id) && !typingFileset) continue
|
|
109
|
+
rows.push({
|
|
110
|
+
id: set.id,
|
|
111
|
+
title: set.id,
|
|
112
|
+
kind: "fileset",
|
|
113
|
+
detail: `${set.count} file${set.count === 1 ? "" : "s"}`,
|
|
114
|
+
completion: completeFileset(text, set.id),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
for (const op of getOperations()) {
|
|
118
|
+
if (op.hidden) continue
|
|
119
|
+
const names = [op.name, ...(op.aliases ?? [])]
|
|
120
|
+
const matchesName = !head || names.some((name) => name.toLowerCase().includes(head))
|
|
121
|
+
if (!matchesName && !op.signature.toLowerCase().includes(q)) continue
|
|
122
|
+
rows.push({
|
|
123
|
+
id: `op:${op.name}`,
|
|
124
|
+
title: op.name,
|
|
125
|
+
kind: "op",
|
|
126
|
+
detail: op.signature,
|
|
127
|
+
description: op.description,
|
|
128
|
+
completion: q.includes(" ") ? text.replace(/^\S+/, op.name) : `${op.name} `,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
const last = lastToken(q)
|
|
132
|
+
const token = lastToken(text)
|
|
133
|
+
if (token && !token.startsWith("$")) {
|
|
134
|
+
const at = token.lastIndexOf("/")
|
|
135
|
+
const dirPart = at >= 0 ? token.slice(0, at + 1) : ""
|
|
136
|
+
const prefix = (at >= 0 ? token.slice(at + 1) : token).toLowerCase()
|
|
137
|
+
for (const entry of context.entries ?? []) {
|
|
138
|
+
if (!entry.name.toLowerCase().startsWith(prefix)) continue
|
|
139
|
+
const name = entry.isDir ? `${entry.name}/` : entry.name
|
|
140
|
+
rows.push({
|
|
141
|
+
id: `path:${entry.path}`,
|
|
142
|
+
title: name,
|
|
143
|
+
kind: "path",
|
|
144
|
+
detail: entry.isDir ? "dir" : humanSize(entry.size),
|
|
145
|
+
completion: replaceLastToken(text, `${dirPart}${name}`),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return rows
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Function: lastToken
|
|
153
|
+
// The last whitespace-separated token, case preserved.
|
|
154
|
+
function lastToken(text) {
|
|
155
|
+
const match = /(\S+)\s*$/.exec(`${text ?? ""}`)
|
|
156
|
+
return match ? match[1] : ""
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Function: completionToken
|
|
160
|
+
// For a query whose last token contains "/", the directory part to read and
|
|
161
|
+
// the name prefix being completed; null when the token is not a path.
|
|
162
|
+
function completionToken(query) {
|
|
163
|
+
const token = lastToken(query)
|
|
164
|
+
if (!token || token.startsWith("$")) return null
|
|
165
|
+
const at = token.lastIndexOf("/")
|
|
166
|
+
if (at < 0) return null
|
|
167
|
+
return { dir: token.slice(0, at) || "/", prefix: token.slice(at + 1) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Function: replaceLastToken
|
|
171
|
+
// Replaces the (possibly empty) last whitespace-separated token.
|
|
172
|
+
function replaceLastToken(text, replacement) {
|
|
173
|
+
const match = /(\S*)\s*$/.exec(text)
|
|
174
|
+
return `${text.slice(0, match.index)}${replacement}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Function: completeFileset
|
|
178
|
+
// Replaces the `$…` token being typed, or appends the fileset.
|
|
179
|
+
function completeFileset(text, id) {
|
|
180
|
+
if (/\$\w*$/.test(text.trimEnd())) return replaceLastToken(text, id)
|
|
181
|
+
return `${text} ${id}`.trim()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { completionToken, parseLine, runLine, scriptItems, tokenize }
|
|
185
|
+
|
|
186
|
+
// EOF
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/selection
|
|
7
|
+
// Cursor and mark arithmetic over an entry list. Pure functions: callers own
|
|
8
|
+
// the cells, these only compute next values.
|
|
9
|
+
|
|
10
|
+
// Function: clampCursor
|
|
11
|
+
// Keeps `cursor` within `0..max` where `max = length - 1`; empty lists yield 0.
|
|
12
|
+
function clampCursor(cursor, length) {
|
|
13
|
+
if (!length) return 0
|
|
14
|
+
return Math.max(0, Math.min(cursor, length - 1))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Function: moveCursor
|
|
18
|
+
// Returns the cursor moved by `delta` entries, clamped to the list bounds.
|
|
19
|
+
function moveCursor(cursor, delta, length) {
|
|
20
|
+
return clampCursor(cursor + delta, length)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Function: currentEntry
|
|
24
|
+
// Returns the entry under `cursor`, or `undefined` for empty lists.
|
|
25
|
+
function currentEntry(entries, cursor) {
|
|
26
|
+
return entries[cursor]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Function: toggleMark
|
|
30
|
+
// Returns a new Set with `path` toggled in `marks`.
|
|
31
|
+
function toggleMark(marks, path) {
|
|
32
|
+
const next = new Set(marks)
|
|
33
|
+
if (next.has(path)) {
|
|
34
|
+
next.delete(path)
|
|
35
|
+
} else {
|
|
36
|
+
next.add(path)
|
|
37
|
+
}
|
|
38
|
+
return next
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { clampCursor, currentEntry, moveCursor, toggleMark }
|
|
42
|
+
|
|
43
|
+
// EOF
|