@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.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +17 -0
  3. package/package.json +37 -5
  4. package/src/js/tcmd/components/App.html +28 -0
  5. package/src/js/tcmd/components/App.js +355 -0
  6. package/src/js/tcmd/components/App.xml +98 -0
  7. package/src/js/tcmd/components/BookmarkList.html +12 -0
  8. package/src/js/tcmd/components/BookmarkList.js +73 -0
  9. package/src/js/tcmd/components/BookmarkList.xml +18 -0
  10. package/src/js/tcmd/components/BookmarkRow.html +5 -0
  11. package/src/js/tcmd/components/BookmarkRow.js +40 -0
  12. package/src/js/tcmd/components/BookmarkRow.xml +10 -0
  13. package/src/js/tcmd/components/Breadcrumbs.js +27 -0
  14. package/src/js/tcmd/components/CommandArgRow.js +90 -0
  15. package/src/js/tcmd/components/CommandArgRow.xml +5 -0
  16. package/src/js/tcmd/components/CommandBar.html +4 -0
  17. package/src/js/tcmd/components/CommandBar.js +126 -0
  18. package/src/js/tcmd/components/CommandBar.xml +4 -0
  19. package/src/js/tcmd/components/CommandItem.html +3 -0
  20. package/src/js/tcmd/components/CommandItem.js +78 -0
  21. package/src/js/tcmd/components/CommandItem.xml +6 -0
  22. package/src/js/tcmd/components/CommandPalette.html +7 -0
  23. package/src/js/tcmd/components/CommandPalette.js +58 -0
  24. package/src/js/tcmd/components/CommandPalette.xml +16 -0
  25. package/src/js/tcmd/components/CommandPanel.js +98 -0
  26. package/src/js/tcmd/components/CommandPanel.xml +35 -0
  27. package/src/js/tcmd/components/Confirm.html +3 -0
  28. package/src/js/tcmd/components/Confirm.js +36 -0
  29. package/src/js/tcmd/components/Confirm.xml +7 -0
  30. package/src/js/tcmd/components/Crumb.html +4 -0
  31. package/src/js/tcmd/components/Crumb.js +48 -0
  32. package/src/js/tcmd/components/Crumb.xml +4 -0
  33. package/src/js/tcmd/components/EntryDetail.html +15 -0
  34. package/src/js/tcmd/components/EntryDetail.js +194 -0
  35. package/src/js/tcmd/components/EntryDetail.xml +16 -0
  36. package/src/js/tcmd/components/FileList.html +1 -0
  37. package/src/js/tcmd/components/FileList.js +41 -0
  38. package/src/js/tcmd/components/FileList.xml +7 -0
  39. package/src/js/tcmd/components/FilePreview.html +5 -0
  40. package/src/js/tcmd/components/FilePreview.js +110 -0
  41. package/src/js/tcmd/components/FilePreview.xml +10 -0
  42. package/src/js/tcmd/components/FileRow.html +5 -0
  43. package/src/js/tcmd/components/FileRow.js +135 -0
  44. package/src/js/tcmd/components/FileRow.xml +10 -0
  45. package/src/js/tcmd/components/FilterBar.html +6 -0
  46. package/src/js/tcmd/components/FilterBar.js +50 -0
  47. package/src/js/tcmd/components/FilterBar.xml +6 -0
  48. package/src/js/tcmd/components/Help.xml +11 -0
  49. package/src/js/tcmd/components/PaletteRow.html +3 -0
  50. package/src/js/tcmd/components/PaletteRow.js +43 -0
  51. package/src/js/tcmd/components/PaletteRow.xml +4 -0
  52. package/src/js/tcmd/components/RowList.html +1 -0
  53. package/src/js/tcmd/components/RowList.xml +1 -0
  54. package/src/js/tcmd/components/Summary.html +3 -0
  55. package/src/js/tcmd/components/Summary.js +77 -0
  56. package/src/js/tcmd/components/Summary.xml +3 -0
  57. package/src/js/tcmd/components/bind.js +20 -0
  58. package/src/js/tcmd/components/format.js +155 -0
  59. package/src/js/tcmd/components/icons.js +185 -0
  60. package/src/js/tcmd/components/index.js +41 -0
  61. package/src/js/tcmd/components/rows.js +212 -0
  62. package/src/js/tcmd/core/bookmarks.js +175 -0
  63. package/src/js/tcmd/core/commands.js +930 -0
  64. package/src/js/tcmd/core/directory.js +194 -0
  65. package/src/js/tcmd/core/entries.js +88 -0
  66. package/src/js/tcmd/core/fs.js +266 -0
  67. package/src/js/tcmd/core/index.js +39 -0
  68. package/src/js/tcmd/core/keymap.js +60 -0
  69. package/src/js/tcmd/core/operations.js +453 -0
  70. package/src/js/tcmd/core/os.js +128 -0
  71. package/src/js/tcmd/core/pane.js +74 -0
  72. package/src/js/tcmd/core/panel.js +383 -0
  73. package/src/js/tcmd/core/path.js +57 -0
  74. package/src/js/tcmd/core/script.js +186 -0
  75. package/src/js/tcmd/core/selection.js +43 -0
  76. package/src/js/tcmd/core/session.js +405 -0
  77. package/src/js/tcmd/index.js +53 -0
  78. package/src/js/tcmd/tui/app.js +140 -0
  79. package/src/js/tcmd/tui/catalogue.js +300 -0
  80. package/src/js/tcmd/tui/frame.js +269 -0
  81. package/src/js/tcmd/tui/help.js +32 -0
  82. package/src/js/tcmd/tui/index.js +104 -0
  83. package/src/js/tcmd/tui/theme.js +177 -0
  84. package/src/js/tcmd/vendor/fzy.js +243 -0
  85. package/src/js/tcmd/web/app.js +154 -0
  86. package/src/js/tcmd/web/catalogue.js +159 -0
  87. package/src/js/tcmd/web/header.html +1 -0
  88. package/src/js/tcmd/web/header.js +38 -0
  89. package/src/js/tcmd/web/http.js +79 -0
  90. package/src/js/tcmd/web/index.js +22 -0
  91. package/src/js/tcmd/web/main.js +151 -0
  92. package/src/js/tcmd/web/opfs.js +282 -0
  93. package/src/js/tcmd/web/seed.js +90 -0
  94. package/src/js/tcmd/web/server.js +191 -0
  95. 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