@ssebastien/tcmd 0.1.2 → 0.1.4
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 +121 -38
- package/bin/tcmd +11 -3
- package/bin/tcmd.js +15 -0
- package/package.json +4 -3
- package/public/index.html +14 -0
- package/public/main.js +17501 -0
- package/public/style.css +901 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +28 -12
- package/src/js/tcmd/components/App.js +135 -85
- package/src/js/tcmd/components/App.xml +64 -15
- package/src/js/tcmd/components/BookmarkList.html +3 -2
- package/src/js/tcmd/components/BookmarkList.js +1 -1
- package/src/js/tcmd/components/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +17 -0
- package/src/js/tcmd/components/CommandArgRow.js +86 -26
- package/src/js/tcmd/components/CommandArgRow.xml +31 -4
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +3 -3
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +1 -0
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +54 -52
- package/src/js/tcmd/components/CommandPanel.xml +38 -24
- package/src/js/tcmd/components/Confirm.html +1 -1
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +1 -1
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- package/src/js/tcmd/components/FilePreview.html +14 -3
- package/src/js/tcmd/components/FilePreview.js +73 -10
- package/src/js/tcmd/components/FilePreview.xml +19 -6
- package/src/js/tcmd/components/FileRow.html +1 -1
- package/src/js/tcmd/components/FileRow.js +10 -3
- 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/{tui/frame.js → components/Frame.js} +120 -91
- package/src/js/tcmd/components/Help.html +7 -0
- package/src/js/tcmd/components/Help.js +109 -0
- package/src/js/tcmd/components/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/icons.js +39 -5
- package/src/js/tcmd/components/index.js +3 -4
- package/src/js/tcmd/components/rows.js +37 -33
- package/src/js/tcmd/core/commands.js +422 -180
- package/src/js/tcmd/core/directory.js +145 -95
- package/src/js/tcmd/core/fs.js +29 -2
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/keymap.js +3 -3
- package/src/js/tcmd/core/operations.js +108 -15
- package/src/js/tcmd/core/pane.js +31 -8
- package/src/js/tcmd/core/panel.js +172 -82
- 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 +280 -152
- package/src/js/tcmd/index.js +0 -1
- package/src/js/tcmd/tui/app.js +26 -5
- package/src/js/tcmd/tui/catalogue.js +6 -19
- package/src/js/tcmd/tui/index.js +1 -16
- package/src/js/tcmd/tui/theme.js +5 -0
- package/src/js/tcmd/web/app.js +158 -32
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +45 -39
- package/src/js/tcmd/web/effects.js +143 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/seed.js +3 -3
- package/src/js/tcmd/web/server.js +6 -4
- package/src/js/tcmd/web/theme.js +1 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/CommandPalette.xml +0 -16
- 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/help.js +0 -32
package/src/js/tcmd/core/pane.js
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
// Module: tcmd/core/pane
|
|
7
7
|
// A Pane is one column with a single directory: it owns that directory and
|
|
8
8
|
// proxies its cells (path, entries, cursor, marks, disk, …) as pane-level
|
|
9
|
-
// cells, so views and commands address the pane directly. Navigation
|
|
10
|
-
// and
|
|
9
|
+
// cells, so views and commands address the pane directly. Navigation reads the
|
|
10
|
+
// target first and commits atomically (see `core/directory#read`), so the pane
|
|
11
|
+
// keeps showing its current listing until the replacement is ready. The pane
|
|
12
|
+
// also owns its navigation history (back/forward snapshots of the view), which
|
|
13
|
+
// the session drives to restore position, selection and focus.
|
|
11
14
|
|
|
12
15
|
import { cell } from "select/state/cells"
|
|
13
16
|
import { createDirectory } from "./directory.js"
|
|
@@ -22,6 +25,23 @@ const PANE_BUFFERS = { left: "A", right: "B" }
|
|
|
22
25
|
// destination (DST).
|
|
23
26
|
const PANE_LABELS = { left: "SRC", right: "DST" }
|
|
24
27
|
|
|
28
|
+
// Function: currentPaneId
|
|
29
|
+
// The id of the pane that owns the current directory: the focused pane while
|
|
30
|
+
// focus is on source/destination, the anchor pane otherwise.
|
|
31
|
+
function currentPaneId(session) {
|
|
32
|
+
return session.currentId?.value ?? session.active?.value ?? "left"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Function: currentPane
|
|
36
|
+
// The pane that owns the current directory: the focused pane while focus is
|
|
37
|
+
// on source/destination, the anchor pane while it is on an auxiliary column
|
|
38
|
+
// (see `core/session`). Path-changing commands — open, up, history,
|
|
39
|
+
// breadcrumbs, relative arguments — target it; only cursor movement follows
|
|
40
|
+
// the focused column. `core/commands` keeps `pane()` for the latter.
|
|
41
|
+
function currentPane(session) {
|
|
42
|
+
return session.panes[currentPaneId(session)] ?? session.panes.left
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
// Function: createPane
|
|
26
46
|
// Creates a pane bound to a filesystem adapter at `path`.
|
|
27
47
|
//
|
|
@@ -31,10 +51,10 @@ const PANE_LABELS = { left: "SRC", right: "DST" }
|
|
|
31
51
|
// await pane.refresh()
|
|
32
52
|
// pane.entries.value // sorted, filtered FileEntry[]
|
|
33
53
|
// ```
|
|
34
|
-
function createPane({ fs, path: initialPath } = {}) {
|
|
54
|
+
function createPane({ fs, path: initialPath, context } = {}) {
|
|
35
55
|
const pane = {}
|
|
36
56
|
pane.fs = fs
|
|
37
|
-
pane.directory = createDirectory({ fs, path: initialPath })
|
|
57
|
+
pane.directory = createDirectory({ fs, path: initialPath, context })
|
|
38
58
|
|
|
39
59
|
// Proxy cells mirror the directory, so views address the pane uniformly.
|
|
40
60
|
const dir = pane.directory
|
|
@@ -46,29 +66,32 @@ function createPane({ fs, path: initialPath } = {}) {
|
|
|
46
66
|
pane.sort = dir.sort
|
|
47
67
|
pane.entries = dir.entries
|
|
48
68
|
pane.cursor = dir.cursor
|
|
49
|
-
pane.cursorStat = dir.cursorStat
|
|
50
69
|
pane.marked = dir.marked
|
|
51
70
|
pane.disk = dir.disk
|
|
52
71
|
pane.segments = dir.segments
|
|
72
|
+
pane.history = dir.history
|
|
53
73
|
|
|
54
74
|
// Function: current
|
|
55
75
|
// The pane's directory.
|
|
56
76
|
pane.current = () => pane.directory
|
|
57
77
|
|
|
58
78
|
// Function: refresh / navigate / up / open / back / forward /
|
|
59
|
-
// currentEntry / toggleMark / clearFilter — delegate to the
|
|
79
|
+
// currentEntry / toggleMark / setFilter / clearFilter — delegate to the
|
|
80
|
+
// directory.
|
|
60
81
|
pane.refresh = () => pane.directory.refresh()
|
|
61
|
-
pane.navigate = (path) => pane.directory.navigate(path)
|
|
82
|
+
pane.navigate = (path, options) => pane.directory.navigate(path, options)
|
|
62
83
|
pane.up = () => pane.directory.up()
|
|
63
84
|
pane.open = () => pane.directory.open()
|
|
64
85
|
pane.back = () => pane.directory.back()
|
|
65
86
|
pane.forward = () => pane.directory.forward()
|
|
66
87
|
pane.currentEntry = () => pane.directory.currentEntry()
|
|
67
88
|
pane.toggleMark = () => pane.directory.toggleMark()
|
|
89
|
+
pane.setFilter = (query) => pane.directory.setFilter(query)
|
|
68
90
|
pane.clearFilter = () => pane.directory.clearFilter()
|
|
91
|
+
pane.setContext = (context) => pane.directory.setContext(context)
|
|
69
92
|
pane.dispose = () => pane.directory.dispose()
|
|
70
93
|
|
|
71
94
|
return pane
|
|
72
95
|
}
|
|
73
96
|
|
|
74
|
-
export { PANE_BUFFERS, PANE_LABELS, createPane }
|
|
97
|
+
export { PANE_BUFFERS, PANE_LABELS, createPane, currentPane, currentPaneId }
|
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
// Created: 2026-10-09
|
|
5
5
|
|
|
6
6
|
// Module: tcmd/core/panel
|
|
7
|
-
// The command panel (Commando):
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
7
|
+
// The command panel (Commando): the one entry point for the CLI operations
|
|
8
|
+
// (`copy`, `move`, `rename`, `delete`, `run` — the `core/operations` registry).
|
|
9
|
+
// They are listed flat in one column, ranked by match importance once a query
|
|
10
|
+
// filters them (see `filterPanelEntries`). The internal TinyCmd actions are not
|
|
11
|
+
// listed here — they live on their own keys.
|
|
12
|
+
//
|
|
13
|
+
// For the selected operation, the panel binds its arguments from the live
|
|
14
|
+
// context — a selection argument shows the SRC/DST pane's marked paths (falling
|
|
15
|
+
// back to the cursor entry) and lets the user toggle individual items; a
|
|
16
|
+
// destination argument resolves to the DST pane's directory; textual arguments
|
|
17
|
+
// (a name, a buffer label, a shell command) are typed in place.
|
|
15
18
|
//
|
|
16
19
|
// The selection the user tweaks is *transient*: it lives in the draft bindings
|
|
17
20
|
// (`session.panelDraft`), never in the real pane marks. When the draft runs,
|
|
@@ -23,71 +26,100 @@
|
|
|
23
26
|
// `core/commands` assemble and run.
|
|
24
27
|
|
|
25
28
|
import { hasMatch, score } from "../vendor/fzy.js"
|
|
26
|
-
import { PANE_BUFFERS, PANE_LABELS } from "./pane.js"
|
|
29
|
+
import { currentPaneId, PANE_BUFFERS, PANE_LABELS } from "./pane.js"
|
|
27
30
|
import { basename, parent } from "./path.js"
|
|
28
31
|
import { getOperations, pathToken } from "./operations.js"
|
|
29
32
|
|
|
30
33
|
// Constant: PANEL_FOCUS
|
|
31
|
-
// The two
|
|
32
|
-
// list
|
|
33
|
-
//
|
|
34
|
+
// The two regions the panel's focus ring moves through: "ops" (the filter and
|
|
35
|
+
// the command list on the left) and "args" (an argument row or item in the
|
|
36
|
+
// right pane, where a textual argument owns the keyboard and Space toggles a
|
|
37
|
+
// selection item).
|
|
34
38
|
const PANEL_FOCUS = { OPS: "ops", ARGS: "args" }
|
|
35
39
|
|
|
36
|
-
// Function:
|
|
37
|
-
// The
|
|
38
|
-
//
|
|
39
|
-
function
|
|
40
|
-
return getOperations()
|
|
40
|
+
// Function: panelEntries
|
|
41
|
+
// The entries the panel lists: the CLI operations. They are the only namespace
|
|
42
|
+
// — the internal TinyCmd actions are reached by their own keys, not the panel.
|
|
43
|
+
function panelEntries() {
|
|
44
|
+
return getOperations()
|
|
45
|
+
.filter((op) => !op.hidden)
|
|
46
|
+
.map((op) => ({
|
|
47
|
+
id: `op:${op.name}`,
|
|
48
|
+
name: op.name,
|
|
49
|
+
summary: `${op.signature ?? ""}`.replace(/^\S+\s*/, ""),
|
|
50
|
+
signature: op.signature ?? "",
|
|
51
|
+
risk: op.risk ?? null,
|
|
52
|
+
matchNames: [op.name, ...(op.aliases ?? [])],
|
|
53
|
+
op,
|
|
54
|
+
}))
|
|
41
55
|
}
|
|
42
56
|
|
|
43
|
-
// Function:
|
|
44
|
-
//
|
|
45
|
-
function
|
|
57
|
+
// Function: compareEntries
|
|
58
|
+
// The panel's default ordering: alphabetically by name.
|
|
59
|
+
function compareEntries(a, b) {
|
|
46
60
|
return `${a?.name ?? ""}`.localeCompare(`${b?.name ?? ""}`)
|
|
47
61
|
}
|
|
48
62
|
|
|
49
|
-
// Function:
|
|
50
|
-
// The match importance of an
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
// match.
|
|
54
|
-
function operationScore(op, query) {
|
|
63
|
+
// Function: entryScore
|
|
64
|
+
// The match importance of an entry for a query: the best fzy score across its
|
|
65
|
+
// names, or `-Infinity` when none matches (only the signature does).
|
|
66
|
+
function entryScore(entry, query) {
|
|
55
67
|
let best = -Infinity
|
|
56
|
-
for (const name of
|
|
57
|
-
|
|
68
|
+
for (const name of entry.matchNames ?? []) {
|
|
69
|
+
const haystack = `${name}`.toLowerCase()
|
|
70
|
+
if (hasMatch(query, haystack)) best = Math.max(best, score(query, haystack))
|
|
58
71
|
}
|
|
59
72
|
return best
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
// Function:
|
|
63
|
-
// Narrows the
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const list = operations ?? []
|
|
75
|
+
// Function: filterPanelEntries
|
|
76
|
+
// Narrows the entry list by a typed query and orders it: no query lists the
|
|
77
|
+
// operations alphabetically; a query ranks the matches by importance (the fzy
|
|
78
|
+
// score of the best name match, so `open` outranks `copy` for `op`), ties
|
|
79
|
+
// alphabetical. A query that only matches an operation's signature (`buf` ->
|
|
80
|
+
// `sel [+|-][BUF] PATH...`) keeps it listable, at the bottom.
|
|
81
|
+
function filterPanelEntries(entries, query) {
|
|
82
|
+
const list = entries ?? []
|
|
71
83
|
const q = `${query ?? ""}`.trim().toLowerCase()
|
|
72
|
-
if (!q) return [...list].sort(
|
|
84
|
+
if (!q) return [...list].sort(compareEntries)
|
|
73
85
|
const ranked = []
|
|
74
|
-
for (const
|
|
75
|
-
const rank =
|
|
76
|
-
const signatureMatch = `${
|
|
86
|
+
for (const entry of list) {
|
|
87
|
+
const rank = entryScore(entry, q)
|
|
88
|
+
const signatureMatch = `${entry.signature ?? ""}`.toLowerCase().includes(q)
|
|
77
89
|
if (rank === -Infinity && !signatureMatch) continue
|
|
78
|
-
ranked.push({
|
|
90
|
+
ranked.push({ entry, score: rank })
|
|
79
91
|
}
|
|
80
|
-
ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score :
|
|
81
|
-
return ranked.map((match) => match.
|
|
92
|
+
ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : compareEntries(a.entry, b.entry)))
|
|
93
|
+
return ranked.map((match) => match.entry)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Function: panelEntryRows
|
|
97
|
+
// Projects the filtered entries into the flat list the command-list column
|
|
98
|
+
// renders: one `op` row per entry, tagged with its index in the *entries* list
|
|
99
|
+
// (what the cursor `panelOpIndex` addresses).
|
|
100
|
+
function panelEntryRows(entries) {
|
|
101
|
+
return (entries ?? []).map((entry, index) => ({
|
|
102
|
+
type: "op",
|
|
103
|
+
id: entry.id,
|
|
104
|
+
entryIndex: index,
|
|
105
|
+
name: entry.name,
|
|
106
|
+
summary: entry.summary,
|
|
107
|
+
risk: entry.risk,
|
|
108
|
+
}))
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
// Function: resolvePaneId
|
|
85
|
-
// Maps an argument role to a pane id: "source" ->
|
|
86
|
-
//
|
|
112
|
+
// Maps an argument role to a pane id: "source" -> the working pane (SRC in
|
|
113
|
+
// commander; the pane the explorer is actually browsing, which may be either
|
|
114
|
+
// side), "dest" -> DST (right), anything else -> the pane holding the current
|
|
115
|
+
// directory (never an auxiliary column: operations run against the directory
|
|
116
|
+
// being browsed).
|
|
87
117
|
function resolvePaneId(session, role) {
|
|
88
|
-
if (role === "source") return "left"
|
|
89
118
|
if (role === "dest") return "right"
|
|
90
|
-
|
|
119
|
+
if (role === "source") {
|
|
120
|
+
return session.viewMode?.value === "explorer" ? currentPaneId(session) : "left"
|
|
121
|
+
}
|
|
122
|
+
return currentPaneId(session)
|
|
91
123
|
}
|
|
92
124
|
|
|
93
125
|
// Function: paneSelectionPaths
|
|
@@ -136,7 +168,8 @@ function bindArg(session, arg, index) {
|
|
|
136
168
|
role,
|
|
137
169
|
optional: arg.optional === true,
|
|
138
170
|
variadic: arg.variadic === true,
|
|
139
|
-
textual:
|
|
171
|
+
textual:
|
|
172
|
+
arg.kind === "literal" || arg.kind === "raw" || arg.kind === "buffer" || arg.kind === "path",
|
|
140
173
|
}
|
|
141
174
|
if (arg.kind === "selection") {
|
|
142
175
|
const paneId = resolvePaneId(session, role)
|
|
@@ -145,26 +178,37 @@ function bindArg(session, arg, index) {
|
|
|
145
178
|
return {
|
|
146
179
|
...common,
|
|
147
180
|
paneId,
|
|
181
|
+
// A variadic selection accepts many marked paths (checkboxes); a
|
|
182
|
+
// single-path argument accepts one (a radio list).
|
|
183
|
+
multi: arg.variadic === true,
|
|
148
184
|
items: paneSelectionPaths(pane).map((path) => itemFor(entries, path)),
|
|
149
185
|
value: "",
|
|
150
186
|
}
|
|
151
187
|
}
|
|
152
|
-
if (arg.kind === "dest") {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
188
|
+
if (arg.kind === "dest" || arg.kind === "path") {
|
|
189
|
+
// A destination or a path argument is typeable in place, like a
|
|
190
|
+
// literal, and additionally browsable through the panel's file picker
|
|
191
|
+
// (`picker: true`, opened with `panel.browse`). In commander mode the
|
|
192
|
+
// DST pane is the natural destination, so a directory argument
|
|
193
|
+
// pre-fills from it; explorer mode has no destination pane, so it opens
|
|
194
|
+
// empty for the picker (or the user) to fill.
|
|
195
|
+
const mode = arg.kind === "dest" ? "dir" : arg.mode ?? "any"
|
|
196
|
+
const commander = (session.viewMode?.value ?? "commander") !== "explorer"
|
|
197
|
+
const value = mode === "dir" && commander ? session.panes?.right?.path?.value ?? "" : ""
|
|
198
|
+
return { ...common, textual: true, picker: true, mode, items: [], value }
|
|
157
199
|
}
|
|
158
200
|
if (arg.kind === "buffer") {
|
|
159
|
-
|
|
201
|
+
const label = PANE_BUFFERS[session.currentId?.value ?? session.active.value] ?? "A"
|
|
202
|
+
return { ...common, items: [], value: `+${label}` }
|
|
160
203
|
}
|
|
161
204
|
return { ...common, items: [], value: "" }
|
|
162
205
|
}
|
|
163
206
|
|
|
164
207
|
// Function: bindArgs
|
|
165
|
-
// Binds every argument of
|
|
166
|
-
|
|
167
|
-
|
|
208
|
+
// Binds every argument of the selected entry to the live context. Every
|
|
209
|
+
// operation carries an `args` signature.
|
|
210
|
+
function bindArgs(session, entry) {
|
|
211
|
+
return (entry?.op?.args ?? []).map((arg, index) => bindArg(session, arg, index))
|
|
168
212
|
}
|
|
169
213
|
|
|
170
214
|
// Function: selectedPaths
|
|
@@ -200,23 +244,31 @@ function argvFor(bindings) {
|
|
|
200
244
|
return args
|
|
201
245
|
}
|
|
202
246
|
|
|
203
|
-
// Function:
|
|
204
|
-
// The
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
247
|
+
// Function: missingBinding
|
|
248
|
+
// The first binding that is not runnable: a required argument left blank, an
|
|
249
|
+
// optional one before a filled one (a positional gap would shift the following
|
|
250
|
+
// tokens), or a selection with nothing selected. Returns null when the argv is
|
|
251
|
+
// runnable. The panel uses it both to flag an unrunnable draft and to focus the
|
|
252
|
+
// next thing to fill when Enter lands on an incomplete command.
|
|
253
|
+
function missingBinding(bindings) {
|
|
209
254
|
const list = bindings ?? []
|
|
210
255
|
const args = argTokens(list)
|
|
211
256
|
for (let i = 0; i < args.length - 1; i++) {
|
|
212
|
-
if (args[i] === "") return list[i]
|
|
257
|
+
if (args[i] === "") return list[i]
|
|
213
258
|
}
|
|
214
259
|
// A trailing required argument must still be filled.
|
|
215
260
|
const trailing = list[list.length - 1]
|
|
216
|
-
if (trailing && args[args.length - 1] === "" && trailing.optional !== true) return trailing
|
|
261
|
+
if (trailing && args[args.length - 1] === "" && trailing.optional !== true) return trailing
|
|
217
262
|
return null
|
|
218
263
|
}
|
|
219
264
|
|
|
265
|
+
// Function: missingArg
|
|
266
|
+
// The name of the first argument that is not runnable (see `missingBinding`),
|
|
267
|
+
// or null when the argv is runnable.
|
|
268
|
+
function missingArg(bindings) {
|
|
269
|
+
return missingBinding(bindings)?.name ?? null
|
|
270
|
+
}
|
|
271
|
+
|
|
220
272
|
// Function: shellQuote
|
|
221
273
|
// Quotes a token for display as it would appear in a shell: bare when it is
|
|
222
274
|
// already safe (`/home/a`), single-quoted when it contains spaces or shell
|
|
@@ -269,41 +321,56 @@ function panelSummary(binding) {
|
|
|
269
321
|
const count = selectedPaths(binding).length
|
|
270
322
|
return `${pane} · ${count} selected`
|
|
271
323
|
}
|
|
272
|
-
if (binding.kind === "dest") return binding.value || "(none)"
|
|
324
|
+
if (binding.kind === "dest" || binding.kind === "path") return binding.value || "(none)"
|
|
273
325
|
if (binding.kind === "buffer") return binding.value || "(active)"
|
|
274
326
|
return binding.value ? binding.value : "(empty)"
|
|
275
327
|
}
|
|
276
328
|
|
|
277
329
|
// Function: panelRowList
|
|
278
|
-
// Flattens the draft into the rows the panel's argument
|
|
279
|
-
// header row per argument
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
330
|
+
// Flattens the draft into the rows the panel's right-hand argument pane
|
|
331
|
+
// renders: one header row per argument (its label and value/summary),
|
|
332
|
+
// followed by one item row per selected path of a selection argument (so
|
|
333
|
+
// individual paths can be toggled). A variadic selection shows checkboxes
|
|
334
|
+
// (`[x]`/`[ ]`); a single-path one shows radios (`(•)`/`( )`). Row focus is
|
|
335
|
+
// assigned by the view (comparing the row index to the cursor), the same way
|
|
336
|
+
// list rows are; this stays pure and index-stable.
|
|
283
337
|
function panelRowList(bindings) {
|
|
284
338
|
const rows = []
|
|
285
339
|
for (const binding of bindings ?? []) {
|
|
340
|
+
const multi = binding.multi === true
|
|
341
|
+
const items = binding.items ?? []
|
|
342
|
+
// A selection that resolves to a single path is not a list: it renders
|
|
343
|
+
// as a pre-filled, read-only path (no checkbox/radio to pick from).
|
|
344
|
+
const single = items.length === 1
|
|
286
345
|
rows.push({
|
|
287
346
|
type: "arg",
|
|
288
347
|
key: `arg:${binding.index}`,
|
|
348
|
+
rowIndex: rows.length,
|
|
289
349
|
argIndex: binding.index,
|
|
290
350
|
itemIndex: -1,
|
|
291
351
|
name: binding.name,
|
|
292
|
-
summary: panelSummary(binding),
|
|
352
|
+
summary: single ? items[0].path : panelSummary(binding),
|
|
293
353
|
kind: binding.kind,
|
|
294
354
|
role: binding.role,
|
|
295
355
|
optional: binding.optional,
|
|
296
356
|
variadic: binding.variadic,
|
|
297
357
|
textual: binding.textual,
|
|
358
|
+
picker: binding.picker === true,
|
|
359
|
+
mode: binding.mode ?? null,
|
|
360
|
+
multi,
|
|
361
|
+
readonly: single,
|
|
298
362
|
value: binding.value,
|
|
299
|
-
count:
|
|
363
|
+
count: items.length,
|
|
300
364
|
})
|
|
301
|
-
|
|
302
|
-
|
|
365
|
+
if (single) continue
|
|
366
|
+
for (let i = 0; i < items.length; i++) {
|
|
367
|
+
const item = items[i]
|
|
303
368
|
const selected = item.selected
|
|
369
|
+
const marker = multi ? (selected ? "[x]" : "[ ]") : selected ? "(•)" : "( )"
|
|
304
370
|
rows.push({
|
|
305
371
|
type: "item",
|
|
306
372
|
key: `item:${binding.index}:${item.path}`,
|
|
373
|
+
rowIndex: rows.length,
|
|
307
374
|
argIndex: binding.index,
|
|
308
375
|
itemIndex: i,
|
|
309
376
|
name: item.name,
|
|
@@ -311,7 +378,8 @@ function panelRowList(bindings) {
|
|
|
311
378
|
parent: item.parent ?? "",
|
|
312
379
|
isDir: item.isDir,
|
|
313
380
|
selected,
|
|
314
|
-
|
|
381
|
+
multi,
|
|
382
|
+
marker,
|
|
315
383
|
})
|
|
316
384
|
}
|
|
317
385
|
}
|
|
@@ -324,6 +392,23 @@ function rowAt(rows, index) {
|
|
|
324
392
|
return (rows ?? [])[index] ?? null
|
|
325
393
|
}
|
|
326
394
|
|
|
395
|
+
// Function: pickerRowList
|
|
396
|
+
// Flattens the picker's visible listing into the rows the panel's argument
|
|
397
|
+
// column renders while a path is being picked. The shape mirrors a
|
|
398
|
+
// file-listing row (`name`, `path`, `isDir`) so the shared row component can
|
|
399
|
+
// render both; a symlink carries its raw target for the summary.
|
|
400
|
+
function pickerRowList(entries) {
|
|
401
|
+
return (entries ?? []).map((entry) => ({
|
|
402
|
+
type: "entry",
|
|
403
|
+
key: entry.path,
|
|
404
|
+
name: entry.name,
|
|
405
|
+
path: entry.path,
|
|
406
|
+
isDir: entry.isDir === true,
|
|
407
|
+
isSymlink: entry.isSymlink === true,
|
|
408
|
+
target: entry.target ?? null,
|
|
409
|
+
}))
|
|
410
|
+
}
|
|
411
|
+
|
|
327
412
|
// Function: isTextualRow
|
|
328
413
|
// True when the cursor row is an argument header that takes free text, so the
|
|
329
414
|
// panel focuses its edit field.
|
|
@@ -333,12 +418,13 @@ function isTextualRow(row) {
|
|
|
333
418
|
|
|
334
419
|
// Function: withToggledItem
|
|
335
420
|
// Returns a new draft with item `itemIndex` of argument `argIndex` toggled,
|
|
336
|
-
// leaving the other bindings untouched.
|
|
337
|
-
|
|
421
|
+
// leaving the other bindings untouched. A single-path argument (`multi` false)
|
|
422
|
+
// is a radio list: choosing one selects it alone.
|
|
423
|
+
function withToggledItem(draft, argIndex, itemIndex, multi = true) {
|
|
338
424
|
return (draft ?? []).map((binding) => {
|
|
339
425
|
if (binding.index !== argIndex) return binding
|
|
340
426
|
const items = (binding.items ?? []).map((item, i) =>
|
|
341
|
-
i === itemIndex ? { ...item, selected: !item.selected } : item,
|
|
427
|
+
multi ? (i === itemIndex ? { ...item, selected: !item.selected } : item) : { ...item, selected: i === itemIndex },
|
|
342
428
|
)
|
|
343
429
|
return { ...binding, items }
|
|
344
430
|
})
|
|
@@ -365,17 +451,21 @@ export { decodePathToken, pathToken } from "./operations.js"
|
|
|
365
451
|
export {
|
|
366
452
|
PANEL_FOCUS,
|
|
367
453
|
argvFor,
|
|
454
|
+
bindArg,
|
|
368
455
|
bindArgs,
|
|
369
456
|
bindingAt,
|
|
370
|
-
|
|
457
|
+
filterPanelEntries,
|
|
371
458
|
isTextualRow,
|
|
372
459
|
missingArg,
|
|
460
|
+
missingBinding,
|
|
373
461
|
panelCommand,
|
|
374
|
-
|
|
462
|
+
panelEntries,
|
|
463
|
+
panelEntryRows,
|
|
375
464
|
panelRowList,
|
|
376
465
|
panelRunnable,
|
|
377
466
|
panelSummary,
|
|
378
467
|
paneSelectionPaths,
|
|
468
|
+
pickerRowList,
|
|
379
469
|
rowAt,
|
|
380
470
|
selectedPaths,
|
|
381
471
|
withArgValue,
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
// Created: 2026-09-27
|
|
5
5
|
|
|
6
6
|
// Module: tcmd/core/script
|
|
7
|
-
// The scripting language
|
|
8
|
-
// line
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// The TinyCmd scripting language: line tokenizer, operation parser and runner.
|
|
8
|
+
// A line starts with an operation name (or the `!` alias for `run`); arguments
|
|
9
|
+
// may be filesets ($A, $B, $FILE), quoted paths, `~` or paths relative to the
|
|
10
|
+
// active pane. The command panel runs operations structurally and delegates to
|
|
11
|
+
// the same `runLine` through `runOperation`.
|
|
12
12
|
|
|
13
|
-
import { getOperation,
|
|
13
|
+
import { getOperation, runOperation } from "./operations.js"
|
|
14
14
|
|
|
15
15
|
// Function: tokenize
|
|
16
16
|
// Whitespace tokenizer with single/double quotes and backslash escapes.
|
|
@@ -93,94 +93,6 @@ async function runLine(session, line, options = {}) {
|
|
|
93
93
|
return runOperation(session, parsed.op, parsed.args, { ...options, line })
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
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 }
|
|
96
|
+
export { parseLine, runLine, tokenize }
|
|
185
97
|
|
|
186
98
|
// EOF
|
|
@@ -26,6 +26,14 @@ function currentEntry(entries, cursor) {
|
|
|
26
26
|
return entries[cursor]
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Function: indexOfPath
|
|
30
|
+
// The index of the entry whose path is `path`, or -1. Navigations that must
|
|
31
|
+
// land on a known element use it: the directory `up` stepped out of, the
|
|
32
|
+
// current directory in a mirror's listing.
|
|
33
|
+
function indexOfPath(entries, path) {
|
|
34
|
+
return entries.findIndex((entry) => entry.path === path)
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
// Function: toggleMark
|
|
30
38
|
// Returns a new Set with `path` toggled in `marks`.
|
|
31
39
|
function toggleMark(marks, path) {
|
|
@@ -38,6 +46,6 @@ function toggleMark(marks, path) {
|
|
|
38
46
|
return next
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
export { clampCursor, currentEntry, moveCursor, toggleMark }
|
|
49
|
+
export { clampCursor, currentEntry, indexOfPath, moveCursor, toggleMark }
|
|
42
50
|
|
|
43
51
|
// EOF
|