@ssebastien/tcmd 0.1.3 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +256 -62
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/desktop/dist/index.html +13 -0
  5. package/desktop/dist/main.js +20298 -0
  6. package/desktop/dist/style.css +1515 -0
  7. package/desktop/frontend/index.html +13 -0
  8. package/package.json +19 -6
  9. package/public/fonts/OFL.txt +93 -0
  10. package/public/index.html +14 -0
  11. package/public/licenses/LUCIDE.txt +43 -0
  12. package/public/main.js +20679 -0
  13. package/public/style.css +1515 -0
  14. package/scripts/build-desktop.js +31 -0
  15. package/scripts/serve-web.js +61 -0
  16. package/src/js/tcmd/components/App.html +80 -14
  17. package/src/js/tcmd/components/App.js +339 -114
  18. package/src/js/tcmd/components/App.xml +76 -22
  19. package/src/js/tcmd/components/BookmarkList.html +5 -5
  20. package/src/js/tcmd/components/BookmarkList.js +15 -41
  21. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  22. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  23. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  24. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  25. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  26. package/src/js/tcmd/components/CommandArgRow.html +28 -0
  27. package/src/js/tcmd/components/CommandArgRow.js +146 -30
  28. package/src/js/tcmd/components/CommandArgRow.xml +55 -4
  29. package/src/js/tcmd/components/CommandBar.html +2 -2
  30. package/src/js/tcmd/components/CommandBar.js +33 -27
  31. package/src/js/tcmd/components/CommandBar.xml +2 -2
  32. package/src/js/tcmd/components/CommandItem.html +1 -1
  33. package/src/js/tcmd/components/CommandItem.js +25 -25
  34. package/src/js/tcmd/components/CommandItem.xml +2 -2
  35. package/src/js/tcmd/components/CommandPanel.html +29 -0
  36. package/src/js/tcmd/components/CommandPanel.js +78 -52
  37. package/src/js/tcmd/components/CommandPanel.xml +49 -25
  38. package/src/js/tcmd/components/Confirm.html +2 -2
  39. package/src/js/tcmd/components/EntryDetail.html +7 -7
  40. package/src/js/tcmd/components/EntryDetail.js +22 -8
  41. package/src/js/tcmd/components/FileList.html +5 -1
  42. package/src/js/tcmd/components/FileList.js +2 -1
  43. package/src/js/tcmd/components/FilePicker.html +10 -0
  44. package/src/js/tcmd/components/FilePicker.js +43 -0
  45. package/src/js/tcmd/components/FilePicker.xml +13 -0
  46. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  47. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  48. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  49. package/src/js/tcmd/components/FilePreview.html +14 -4
  50. package/src/js/tcmd/components/FilePreview.js +111 -12
  51. package/src/js/tcmd/components/FilePreview.xml +28 -8
  52. package/src/js/tcmd/components/FileRow.html +2 -1
  53. package/src/js/tcmd/components/FileRow.js +70 -12
  54. package/src/js/tcmd/components/FilterBar.html +1 -1
  55. package/src/js/tcmd/components/FilterBar.js +14 -4
  56. package/src/js/tcmd/components/FilterBar.xml +1 -1
  57. package/src/js/tcmd/components/Frame.js +324 -0
  58. package/src/js/tcmd/components/Help.html +15 -0
  59. package/src/js/tcmd/components/Help.js +260 -0
  60. package/src/js/tcmd/components/Help.xml +13 -4
  61. package/src/js/tcmd/components/HelpRow.html +8 -0
  62. package/src/js/tcmd/components/HelpRow.xml +7 -0
  63. package/src/js/tcmd/components/OverlayList.html +8 -0
  64. package/src/js/tcmd/components/OverlayList.js +69 -0
  65. package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
  66. package/src/js/tcmd/components/OverlayRow.html +5 -0
  67. package/src/js/tcmd/components/OverlayRow.js +47 -0
  68. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  69. package/src/js/tcmd/components/Summary.html +1 -1
  70. package/src/js/tcmd/components/Summary.js +1 -6
  71. package/src/js/tcmd/components/format.js +4 -23
  72. package/src/js/tcmd/components/icons.js +176 -5
  73. package/src/js/tcmd/components/index.js +9 -5
  74. package/src/js/tcmd/components/rows.js +176 -48
  75. package/src/js/tcmd/core/base64.js +64 -0
  76. package/src/js/tcmd/core/bookmarks.js +45 -8
  77. package/src/js/tcmd/core/commands.js +978 -260
  78. package/src/js/tcmd/core/concurrency.js +59 -0
  79. package/src/js/tcmd/core/decompress.js +117 -0
  80. package/src/js/tcmd/core/directory.js +219 -93
  81. package/src/js/tcmd/core/format.js +36 -0
  82. package/src/js/tcmd/core/fs.js +438 -54
  83. package/src/js/tcmd/core/history.js +58 -0
  84. package/src/js/tcmd/core/index.js +3 -9
  85. package/src/js/tcmd/core/journal.js +93 -0
  86. package/src/js/tcmd/core/keymap.js +42 -6
  87. package/src/js/tcmd/core/operations.js +813 -48
  88. package/src/js/tcmd/core/os.js +29 -3
  89. package/src/js/tcmd/core/pane.js +33 -8
  90. package/src/js/tcmd/core/panel.js +280 -104
  91. package/src/js/tcmd/core/path.js +30 -3
  92. package/src/js/tcmd/core/permissions.js +107 -0
  93. package/src/js/tcmd/core/picker.js +156 -0
  94. package/src/js/tcmd/core/preview.js +102 -0
  95. package/src/js/tcmd/core/script.js +7 -95
  96. package/src/js/tcmd/core/selection.js +9 -1
  97. package/src/js/tcmd/core/session.js +498 -200
  98. package/src/js/tcmd/core/watch.js +153 -0
  99. package/src/js/tcmd/desktop/backend/api.js +165 -0
  100. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  101. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  102. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  103. package/src/js/tcmd/desktop/fs.js +91 -0
  104. package/src/js/tcmd/desktop/main.js +194 -0
  105. package/src/js/tcmd/desktop/os.js +33 -0
  106. package/src/js/tcmd/index.js +0 -1
  107. package/src/js/tcmd/tui/app.js +118 -60
  108. package/src/js/tcmd/tui/catalogue.js +238 -42
  109. package/src/js/tcmd/tui/index.js +18 -19
  110. package/src/js/tcmd/tui/opentui.js +33 -0
  111. package/src/js/tcmd/tui/syntax.js +91 -0
  112. package/src/js/tcmd/tui/theme.js +32 -2
  113. package/src/js/tcmd/vendor/bzip2.js +276 -0
  114. package/src/js/tcmd/web/app.js +225 -41
  115. package/src/js/tcmd/web/bookmarks.js +36 -0
  116. package/src/js/tcmd/web/catalogue.js +81 -38
  117. package/src/js/tcmd/web/effects.js +223 -0
  118. package/src/js/tcmd/web/header.html +1 -1
  119. package/src/js/tcmd/web/header.js +15 -11
  120. package/src/js/tcmd/web/http.js +13 -0
  121. package/src/js/tcmd/web/index.js +2 -2
  122. package/src/js/tcmd/web/main.js +6 -66
  123. package/src/js/tcmd/web/opfs.js +297 -30
  124. package/src/js/tcmd/web/seed.js +4 -4
  125. package/src/js/tcmd/web/server.js +124 -21
  126. package/src/js/tcmd/web/theme.js +4 -0
  127. package/tinyjs.json +16 -0
  128. package/src/js/tcmd/components/CommandPalette.html +0 -7
  129. package/src/js/tcmd/components/CommandPalette.js +0 -58
  130. package/src/js/tcmd/components/PaletteRow.html +0 -3
  131. package/src/js/tcmd/components/PaletteRow.js +0 -43
  132. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  133. package/src/js/tcmd/tui/frame.js +0 -269
  134. package/src/js/tcmd/tui/help.js +0 -32
@@ -4,90 +4,170 @@
4
4
  // Created: 2026-10-09
5
5
 
6
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.
7
+ // The command panel (Commando): the one entry point for the CLI operations
8
+ // (`copy`, `move`, `mkdir`, `new`, `chmod`, `hardlink`, `symlink`, `delete`,
9
+ // `run` — the `core/operations` registry). They are grouped by purpose
10
+ // (Command, File, Info, Create, Edit, Delete), using each operation's own
11
+ // `group` metadata, and alphabetized within each group (see
12
+ // `filterPanelEntries`). The workspace-view toggle and Quit are listed
13
+ // alongside the CLI operations; other TinyCmd actions keep their keys.
14
+ //
15
+ // For the selected operation, the panel binds its arguments from the live
16
+ // context — a selection argument shows the SRC/DST pane's marked paths (falling
17
+ // back to the cursor entry) and lets the user toggle individual items; a
18
+ // destination argument resolves to the DST pane's directory; textual arguments
19
+ // (a name, a buffer label, a shell command) are typed in place. `chmod` binds
20
+ // its MODE to the permission grid from `core/permissions`.
15
21
  //
16
22
  // The selection the user tweaks is *transient*: it lives in the draft bindings
17
23
  // (`session.panelDraft`), never in the real pane marks. When the draft runs,
18
24
  // 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.
25
+ // the resolved paths survive an async confirm (see `expandArg`). The draft can
26
+ // also be rendered as a shell-quoted command (`panelCommand`), while the panel
27
+ // itself presents the arguments and an explicit Run action.
22
28
  // Everything here is pure: the caller owns the cells; the panel commands in
23
- // `core/commands` assemble and run.
29
+ // `core/commands` assemble and run. Picker path resolution and row shaping live
30
+ // in `core/picker`.
24
31
 
25
- import { hasMatch, score } from "../vendor/fzy.js"
26
- import { PANE_BUFFERS, PANE_LABELS } from "./pane.js"
32
+ import { hasMatch } from "../vendor/fzy.js"
33
+ import { currentPaneId, PANE_BUFFERS, PANE_LABELS } from "./pane.js"
27
34
  import { basename, parent } from "./path.js"
28
- import { getOperations, pathToken } from "./operations.js"
35
+ import { getOperations, pathToken, shellQuote } from "./operations.js"
36
+ import { PERMISSION_BITS, PERMISSION_CLASSES, permissionItems, permissionOctal, permissionText } from "./permissions.js"
29
37
 
30
38
  // 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).
39
+ // The two regions the panel's focus ring moves through: "ops" (the filter and
40
+ // the command list on the left) and "args" (an argument row or item in the
41
+ // right pane, where a textual argument owns the keyboard and Space toggles a
42
+ // selection item).
34
43
  const PANEL_FOCUS = { OPS: "ops", ARGS: "args" }
44
+ const PANEL_GROUPS = ["Command", "File", "Info", "Create", "Edit", "Delete"]
35
45
 
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))
46
+ // Function: panelEntries
47
+ // The entries the panel lists: the categorized CLI operations (each carries its
48
+ // own `group`), the workspace-view toggle (named `view` like File's preview
49
+ // operation, but in the Command group) and Quit.
50
+ function panelEntries() {
51
+ const entries = getOperations()
52
+ .filter((op) => !op.hidden && op.group)
53
+ .map((op) => ({
54
+ id: `op:${op.name}`,
55
+ name: op.name,
56
+ group: op.group,
57
+ risk: op.risk ?? null,
58
+ matchNames: [op.name, ...(op.aliases ?? [])],
59
+ op,
60
+ }))
61
+ entries.push({
62
+ id: "command:view",
63
+ name: "view",
64
+ group: "Command",
65
+ risk: null,
66
+ matchNames: ["view"],
67
+ commandId: "view.toggleMode",
68
+ // A no-argument command still shows where it acts.
69
+ showPath: true,
70
+ })
71
+ entries.push({
72
+ id: "command:reload",
73
+ name: "reload",
74
+ group: "Command",
75
+ risk: null,
76
+ matchNames: ["reload", "refresh"],
77
+ commandId: "pane.reload",
78
+ // A no-argument command still shows where it acts.
79
+ showPath: true,
80
+ })
81
+ entries.push({
82
+ id: "command:quit",
83
+ name: "quit",
84
+ group: "Command",
85
+ risk: null,
86
+ matchNames: ["quit", "q"],
87
+ commandId: "app.quit",
88
+ })
89
+ return entries
41
90
  }
42
91
 
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 ?? ""}`)
92
+ // Function: compareEntries
93
+ // The panel's fixed category order, then alphabetical command order.
94
+ function compareEntries(a, b) {
95
+ const groupA = PANEL_GROUPS.indexOf(`${a?.group ?? "File"}`)
96
+ const groupB = PANEL_GROUPS.indexOf(`${b?.group ?? "File"}`)
97
+ const rankA = groupA < 0 ? PANEL_GROUPS.length : groupA
98
+ const rankB = groupB < 0 ? PANEL_GROUPS.length : groupB
99
+ return rankA - rankB || `${a?.name ?? ""}`.localeCompare(`${b?.name ?? ""}`)
100
+ }
101
+
102
+ // Function: filterPanelEntries
103
+ // Narrows the entry list by command name or alias and keeps the fixed category
104
+ // order and alphabetical order within each group.
105
+ function filterPanelEntries(entries, query) {
106
+ const list = entries ?? []
107
+ const q = `${query ?? ""}`.trim().toLowerCase()
108
+ if (!q) return [...list].sort(compareEntries)
109
+ return list
110
+ .filter((entry) => (entry.matchNames ?? []).some((name) => hasMatch(q, `${name}`.toLowerCase())))
111
+ .sort(compareEntries)
47
112
  }
48
113
 
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))
114
+ // Function: panelEntryRows
115
+ // Projects filtered entries into the command-list column: a group heading and
116
+ // one name-only `op` row per entry, tagged with its index in the *entries* list
117
+ // (what the cursor `panelOpIndex` addresses).
118
+ function panelEntryRows(entries) {
119
+ const rows = []
120
+ let previousGroup = null
121
+ for (let index = 0; index < (entries ?? []).length; index++) {
122
+ const entry = entries[index]
123
+ const group = entry.group ?? "File"
124
+ if (group !== previousGroup) {
125
+ rows.push({ type: "group", key: `group:${group.toLowerCase()}`, name: group, group })
126
+ previousGroup = group
127
+ }
128
+ rows.push({
129
+ type: "op",
130
+ key: entry.id,
131
+ entryIndex: index,
132
+ name: entry.name,
133
+ group,
134
+ risk: entry.risk,
135
+ })
58
136
  }
59
- return best
137
+ return rows
60
138
  }
61
139
 
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 })
140
+ // Function: explorerColumnId
141
+ // The column an explorer argument draws from: the *focused* one, so focusing
142
+ // the parent mirror selects from its own listing — the current directory, a
143
+ // directory — and focusing the preview selects from the directory it mirrors.
144
+ // A focused column without a listing (the inspector over a file) falls back to
145
+ // the pane that owns the current directory, matching `core/commands#pane`.
146
+ function explorerColumnId(session) {
147
+ const active = session.active?.value
148
+ if (active === "parent") return active
149
+ if (
150
+ active === "preview" &&
151
+ session.showPreview?.value &&
152
+ session.previewEntry?.value?.isDir &&
153
+ !session.preview?.value
154
+ ) {
155
+ return active
79
156
  }
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)
157
+ return currentPaneId(session)
82
158
  }
83
159
 
84
160
  // Function: resolvePaneId
85
- // Maps an argument role to a pane id: "source" -> SRC (left), "dest" -> DST
86
- // (right), anything else -> the active pane.
161
+ // Maps an argument role to a pane id: "source" -> the working pane (SRC in
162
+ // commander; in explorer the focused column, which may be either side of the
163
+ // chain), "dest" -> DST (right), anything else -> the pane holding the current
164
+ // directory (the focused column in explorer, never an auxiliary column with no
165
+ // listing, so operations run against what the cursor shows).
87
166
  function resolvePaneId(session, role) {
88
- if (role === "source") return "left"
89
167
  if (role === "dest") return "right"
90
- return session.active.value
168
+ if (session.viewMode?.value === "explorer") return explorerColumnId(session)
169
+ if (role === "source") return "left"
170
+ return currentPaneId(session)
91
171
  }
92
172
 
93
173
  // Function: paneSelectionPaths
@@ -131,12 +211,16 @@ function bindArg(session, arg, index) {
131
211
  const role = arg.role ?? "active"
132
212
  const common = {
133
213
  index,
214
+ // Display order in the argument pane; the argv order stays the
215
+ // operation's own (see `panelRowList` and `argTokens`).
216
+ panelOrder: arg.panelOrder ?? index,
134
217
  name: arg.name,
135
218
  kind: arg.kind,
136
219
  role,
137
220
  optional: arg.optional === true,
138
221
  variadic: arg.variadic === true,
139
- textual: arg.kind === "literal" || arg.kind === "raw" || arg.kind === "buffer",
222
+ textual:
223
+ arg.kind === "literal" || arg.kind === "raw" || arg.kind === "buffer" || arg.kind === "path",
140
224
  }
141
225
  if (arg.kind === "selection") {
142
226
  const paneId = resolvePaneId(session, role)
@@ -145,26 +229,57 @@ function bindArg(session, arg, index) {
145
229
  return {
146
230
  ...common,
147
231
  paneId,
232
+ // A variadic selection accepts many marked paths (checkboxes); a
233
+ // single-path argument accepts one (a radio list).
234
+ multi: arg.variadic === true,
148
235
  items: paneSelectionPaths(pane).map((path) => itemFor(entries, path)),
149
236
  value: "",
150
237
  }
151
238
  }
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 }
239
+ if (arg.kind === "permissions") {
240
+ // Seed the checkboxes from the first selected path's current mode, so
241
+ // opening chmod starts on the file's real permissions rather than a
242
+ // generic 644. A path outside the listing falls back by kind.
243
+ const pane = session.panes[resolvePaneId(session, role)]
244
+ const entries = entryIndex(pane)
245
+ const path = paneSelectionPaths(pane)[0]
246
+ const entry = path ? entries.get(path) : null
247
+ const mode = Number.isInteger(entry?.mode)
248
+ ? entry.mode & 0o777
249
+ : entry?.isDir
250
+ ? 0o755
251
+ : 0o644
252
+ return { ...common, multi: true, items: permissionItems(mode) }
253
+ }
254
+ if (arg.kind === "dest" || arg.kind === "path") {
255
+ // A destination or a path argument is typeable in place, like a
256
+ // literal, and additionally browsable through the panel's file picker
257
+ // (`picker: true`, opened with `panel.browse`). In commander mode the
258
+ // DST pane is the natural destination, so a directory argument
259
+ // pre-fills from it; explorer mode has no destination pane, so it opens
260
+ // empty for the picker (or the user) to fill.
261
+ const mode = arg.kind === "dest" ? "dir" : arg.mode ?? "any"
262
+ const commander = (session.viewMode?.value ?? "commander") !== "explorer"
263
+ const value =
264
+ arg.prefill === "current"
265
+ ? (session.activePath?.value ?? "")
266
+ : mode === "dir" && commander
267
+ ? (session.panes?.right?.path?.value ?? "")
268
+ : ""
269
+ return { ...common, textual: true, picker: true, mode, items: [], value }
157
270
  }
158
271
  if (arg.kind === "buffer") {
159
- return { ...common, items: [], value: `+${PANE_BUFFERS[session.active.value] ?? "A"}` }
272
+ const label = PANE_BUFFERS[session.currentId?.value ?? session.active.value] ?? "A"
273
+ return { ...common, items: [], value: `+${label}` }
160
274
  }
161
275
  return { ...common, items: [], value: "" }
162
276
  }
163
277
 
164
278
  // 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))
279
+ // Binds every argument of the selected entry to the live context. Every
280
+ // operation carries an `args` signature.
281
+ function bindArgs(session, entry) {
282
+ return (entry?.op?.args ?? []).map((arg, index) => bindArg(session, arg, index))
168
283
  }
169
284
 
170
285
  // Function: selectedPaths
@@ -184,6 +299,7 @@ function argTokens(bindings) {
184
299
  const paths = selectedPaths(binding)
185
300
  return paths.length ? pathToken(paths) : ""
186
301
  }
302
+ if (binding.kind === "permissions") return permissionOctal(binding)
187
303
  if (binding.kind === "dest") return binding.value ?? ""
188
304
  return `${binding.value ?? ""}`.trim()
189
305
  })
@@ -200,32 +316,29 @@ function argvFor(bindings) {
200
316
  return args
201
317
  }
202
318
 
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) {
319
+ // Function: missingBinding
320
+ // The first binding that is not runnable: a required argument left blank, an
321
+ // optional one before a filled one (a positional gap would shift the following
322
+ // tokens), or a selection with nothing selected. Returns null when the argv is
323
+ // runnable. The panel uses it both to flag an unrunnable draft and to focus the
324
+ // next thing to fill when Enter lands on an incomplete command.
325
+ function missingBinding(bindings) {
209
326
  const list = bindings ?? []
210
327
  const args = argTokens(list)
211
328
  for (let i = 0; i < args.length - 1; i++) {
212
- if (args[i] === "") return list[i].name
329
+ if (args[i] === "") return list[i]
213
330
  }
214
331
  // A trailing required argument must still be filled.
215
332
  const trailing = list[list.length - 1]
216
- if (trailing && args[args.length - 1] === "" && trailing.optional !== true) return trailing.name
333
+ if (trailing && args[args.length - 1] === "" && trailing.optional !== true) return trailing
217
334
  return null
218
335
  }
219
336
 
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, `'\\''`)}'`
337
+ // Function: missingArg
338
+ // The name of the first argument that is not runnable (see `missingBinding`),
339
+ // or null when the argv is runnable.
340
+ function missingArg(bindings) {
341
+ return missingBinding(bindings)?.name ?? null
229
342
  }
230
343
 
231
344
  // Function: panelCommand
@@ -244,6 +357,10 @@ function panelCommand(bindings) {
244
357
  else if (binding.optional !== true) parts.push(`«${binding.name}»`)
245
358
  continue
246
359
  }
360
+ if (binding.kind === "permissions") {
361
+ parts.push(permissionOctal(binding))
362
+ continue
363
+ }
247
364
  const value = `${binding.value ?? ""}`.trim()
248
365
  if (value) parts.push(shellQuote(value))
249
366
  else if (binding.optional !== true) parts.push(`«${binding.name}»`)
@@ -269,41 +386,94 @@ function panelSummary(binding) {
269
386
  const count = selectedPaths(binding).length
270
387
  return `${pane} · ${count} selected`
271
388
  }
272
- if (binding.kind === "dest") return binding.value || "(none)"
389
+ if (binding.kind === "permissions") return permissionText(binding)
390
+ if (binding.kind === "dest" || binding.kind === "path") return binding.value || "(none)"
273
391
  if (binding.kind === "buffer") return binding.value || "(active)"
274
392
  return binding.value ? binding.value : "(empty)"
275
393
  }
276
394
 
277
395
  // 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.
396
+ // Flattens the draft into the rows the panel's right-hand argument pane
397
+ // renders: one header row per argument (its label and value/summary),
398
+ // followed by one item row per selected path of a selection argument (so
399
+ // individual paths can be toggled). A variadic selection shows checkboxes
400
+ // (`[x]`/`[ ]`); a single-path one shows radios (`(•)`/`( )`). Row focus is
401
+ // assigned by the view (comparing the row index to the cursor), the same way
402
+ // list rows are; this stays pure and index-stable.
283
403
  function panelRowList(bindings) {
284
404
  const rows = []
285
- for (const binding of bindings ?? []) {
405
+ // The pane may show arguments in a different order from the argv (PATH
406
+ // before MODE/OWNER); the binding `index` still keys the draft.
407
+ const ordered = [...(bindings ?? [])].sort(
408
+ (a, b) => (a.panelOrder ?? a.index) - (b.panelOrder ?? b.index),
409
+ )
410
+ for (const binding of ordered) {
411
+ const multi = binding.multi === true
412
+ const items = binding.items ?? []
413
+ // A selection that resolves to a single path is not a list: it renders
414
+ // as a pre-filled, read-only path (no checkbox/radio to pick from).
415
+ const single = items.length === 1
286
416
  rows.push({
287
417
  type: "arg",
288
418
  key: `arg:${binding.index}`,
419
+ rowIndex: rows.length,
289
420
  argIndex: binding.index,
290
421
  itemIndex: -1,
291
422
  name: binding.name,
292
- summary: panelSummary(binding),
423
+ summary: single ? items[0].path : panelSummary(binding),
293
424
  kind: binding.kind,
294
425
  role: binding.role,
295
426
  optional: binding.optional,
296
427
  variadic: binding.variadic,
297
428
  textual: binding.textual,
429
+ editable: binding.textual === true,
430
+ picker: binding.picker === true,
431
+ mode: binding.mode ?? null,
432
+ multi,
433
+ readonly: single,
298
434
  value: binding.value,
299
- count: (binding.items ?? []).length,
435
+ count: items.length,
300
436
  })
301
- for (let i = 0; i < (binding.items ?? []).length; i++) {
302
- const item = binding.items[i]
437
+ if (single) continue
438
+ // A permission argument renders as one row per class (u/g/o) with its
439
+ // three bits as clickable cells, rather than nine checkbox rows. The
440
+ // bit cursor (`panelPermBit`) selects the cell Space toggles.
441
+ if (binding.kind === "permissions") {
442
+ for (let c = 0; c < PERMISSION_CLASSES.length; c++) {
443
+ const permissionClass = PERMISSION_CLASSES[c]
444
+ const rowIndex = rows.length
445
+ const bits = PERMISSION_BITS.map((bit, i) => {
446
+ const itemIndex = c * PERMISSION_BITS.length + i
447
+ return {
448
+ id: bit.id,
449
+ selected: items[itemIndex]?.selected === true,
450
+ itemIndex,
451
+ }
452
+ })
453
+ rows.push({
454
+ type: "perm",
455
+ key: `perm:${binding.index}:${permissionClass.id}`,
456
+ rowIndex,
457
+ argIndex: binding.index,
458
+ classId: permissionClass.id,
459
+ label: permissionClass.label,
460
+ bits,
461
+ // Click payloads: arg index, item index, row index.
462
+ keyR: `${binding.index}:${bits[0].itemIndex}:${rowIndex}`,
463
+ keyW: `${binding.index}:${bits[1].itemIndex}:${rowIndex}`,
464
+ keyX: `${binding.index}:${bits[2].itemIndex}:${rowIndex}`,
465
+ })
466
+ }
467
+ continue
468
+ }
469
+ for (let i = 0; i < items.length; i++) {
470
+ const item = items[i]
303
471
  const selected = item.selected
472
+ const marker = multi ? (selected ? "[x]" : "[ ]") : selected ? "(•)" : "( )"
304
473
  rows.push({
305
474
  type: "item",
306
475
  key: `item:${binding.index}:${item.path}`,
476
+ rowIndex: rows.length,
307
477
  argIndex: binding.index,
308
478
  itemIndex: i,
309
479
  name: item.name,
@@ -311,7 +481,8 @@ function panelRowList(bindings) {
311
481
  parent: item.parent ?? "",
312
482
  isDir: item.isDir,
313
483
  selected,
314
- marker: selected ? "[x]" : "[ ]",
484
+ multi,
485
+ marker,
315
486
  })
316
487
  }
317
488
  }
@@ -333,12 +504,13 @@ function isTextualRow(row) {
333
504
 
334
505
  // Function: withToggledItem
335
506
  // Returns a new draft with item `itemIndex` of argument `argIndex` toggled,
336
- // leaving the other bindings untouched.
337
- function withToggledItem(draft, argIndex, itemIndex) {
507
+ // leaving the other bindings untouched. A single-path argument (`multi` false)
508
+ // is a radio list: choosing one selects it alone.
509
+ function withToggledItem(draft, argIndex, itemIndex, multi = true) {
338
510
  return (draft ?? []).map((binding) => {
339
511
  if (binding.index !== argIndex) return binding
340
512
  const items = (binding.items ?? []).map((item, i) =>
341
- i === itemIndex ? { ...item, selected: !item.selected } : item,
513
+ multi ? (i === itemIndex ? { ...item, selected: !item.selected } : item) : { ...item, selected: i === itemIndex },
342
514
  )
343
515
  return { ...binding, items }
344
516
  })
@@ -364,14 +536,18 @@ export { decodePathToken, pathToken } from "./operations.js"
364
536
 
365
537
  export {
366
538
  PANEL_FOCUS,
539
+ PANEL_GROUPS,
367
540
  argvFor,
541
+ bindArg,
368
542
  bindArgs,
369
543
  bindingAt,
370
- filterPanelOperations,
544
+ filterPanelEntries,
371
545
  isTextualRow,
372
546
  missingArg,
547
+ missingBinding,
373
548
  panelCommand,
374
- panelOperations,
549
+ panelEntries,
550
+ panelEntryRows,
375
551
  panelRowList,
376
552
  panelRunnable,
377
553
  panelSummary,
@@ -37,9 +37,11 @@ function split(p) {
37
37
 
38
38
  // Function: extname
39
39
  // Lowercased extension of the last segment, without the dot; null when the
40
- // name has no extension (or is a dotfile).
40
+ // name has no extension (or is a dotfile). A bare filename (no separator), the
41
+ // common case when building a listing, skips the `basename` split.
41
42
  function extname(p) {
42
- const name = basename(p)
43
+ const text = `${p ?? ""}`
44
+ const name = text.indexOf(SEP) < 0 ? text : basename(text)
43
45
  const at = name.lastIndexOf(".")
44
46
  return at > 0 ? name.slice(at + 1).toLowerCase() : null
45
47
  }
@@ -52,6 +54,31 @@ function prefix(p, count) {
52
54
  return SEP + parts.slice(0, Math.max(0, count)).join(SEP)
53
55
  }
54
56
 
55
- export { SEP, basename, extname, join, normalize, parent, prefix, split }
57
+ // Function: relative
58
+ // Expresses `target` relative to the absolute directory `base`, using `..`
59
+ // segments when it sits outside: `relative("/a", "/a/b/c")` is `"b/c"` and
60
+ // `relative("/a/b", "/a/c")` is `"../c"`. Equal paths collapse to `"."`. A
61
+ // non-absolute input comes back unchanged, since there is nothing meaningful
62
+ // to relativize against.
63
+ function relative(base, target) {
64
+ const from = `${base ?? ""}`
65
+ const to = `${target ?? ""}`
66
+ if (!from.startsWith(SEP) || !to.startsWith(SEP)) return to
67
+ const baseParts = split(from)
68
+ const targetParts = split(to)
69
+ let common = 0
70
+ while (
71
+ common < baseParts.length &&
72
+ common < targetParts.length &&
73
+ baseParts[common] === targetParts[common]
74
+ ) {
75
+ common += 1
76
+ }
77
+ const up = Array.from({ length: baseParts.length - common }, () => "..")
78
+ const down = targetParts.slice(common)
79
+ return [...up, ...down].join(SEP) || "."
80
+ }
81
+
82
+ export { SEP, basename, extname, join, normalize, parent, prefix, relative, split }
56
83
 
57
84
  // EOF