@ssebastien/tcmd 0.1.4 → 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 (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. package/tinyjs.json +16 -0
@@ -14,6 +14,13 @@
14
14
  // - `edit(path)`: Promise<void> - $EDITOR (or vi)
15
15
  // - `run(command, options)`: Promise<void> - `$SHELL -c command` in
16
16
  // `options.cwd`
17
+ // - `copy(text)`: boolean - best-effort clipboard write. The TUI sends OSC 52
18
+ // through the renderer; the browser and desktop webview use the Clipboard
19
+ // API. False when the host has no clipboard.
20
+ // - `canEdit`: boolean - whether `edit` can launch an editor at all. A host
21
+ // without one (the web's null adapter) sets it false, so Enter on a file
22
+ // falls back to the fullscreen inspector instead of erroring (see
23
+ // `core/commands#pane.enter`).
17
24
 
18
25
  // `node:child_process` is imported lazily: only `createNodeOs` needs it, so
19
26
  // bundles that use the null adapter (the web view, tests) never pull in a Node
@@ -29,9 +36,14 @@ function loadChildProcess() {
29
36
 
30
37
  // Function: createNodeOs
31
38
  // Node/Bun-backed host adapter. `suspend`/`resume` are optional hooks the TUI
32
- // uses to release and restore the terminal around interactive commands.
33
- function createNodeOs({ suspend, resume } = {}) {
39
+ // uses to release and restore the terminal around interactive commands;
40
+ // `copy` is the TUI's OSC 52 hook (`renderer.copyToClipboardOSC52`).
41
+ function createNodeOs({ suspend, resume, copy } = {}) {
34
42
  return {
43
+ canEdit: true,
44
+ copy(text) {
45
+ return Boolean(copy?.(`${text ?? ""}`))
46
+ },
35
47
  async open(path) {
36
48
  await openWith(path, openerCandidates())
37
49
  },
@@ -48,14 +60,28 @@ function createNodeOs({ suspend, resume } = {}) {
48
60
  }
49
61
  }
50
62
 
63
+ // Function: copyToBrowserClipboard
64
+ // Best-effort clipboard write for a browser or desktop webview. The write is
65
+ // asynchronous but fire-and-forget; a refused write (no permission, no user
66
+ // gesture) leaves the clipboard untouched.
67
+ function copyToBrowserClipboard(text) {
68
+ const clipboard = globalThis.navigator?.clipboard
69
+ if (!clipboard?.writeText) return false
70
+ void clipboard.writeText(`${text ?? ""}`).catch(() => {})
71
+ return true
72
+ }
73
+
51
74
  // Function: createNullOs
52
75
  // Host adapter for environments without a shell (web): every operation fails
53
76
  // with a clear message, which the dispatch path reports in the command bar.
77
+ // Clipboard access is the exception: the browser has one.
54
78
  function createNullOs() {
55
79
  const unsupported = (name) => async () => {
56
80
  throw new Error(`${name} is not available in this environment`)
57
81
  }
58
82
  return {
83
+ canEdit: false,
84
+ copy: (text) => copyToBrowserClipboard(text),
59
85
  open: unsupported("open"),
60
86
  edit: unsupported("edit"),
61
87
  run: unsupported("run"),
@@ -123,6 +149,6 @@ async function runDetached(command, args) {
123
149
  })
124
150
  }
125
151
 
126
- export { createNodeOs, createNullOs }
152
+ export { copyToBrowserClipboard, createNodeOs, createNullOs }
127
153
 
128
154
  // EOF
@@ -51,10 +51,10 @@ function currentPane(session) {
51
51
  // await pane.refresh()
52
52
  // pane.entries.value // sorted, filtered FileEntry[]
53
53
  // ```
54
- function createPane({ fs, path: initialPath, context } = {}) {
54
+ function createPane({ fs, path: initialPath, context, watch } = {}) {
55
55
  const pane = {}
56
56
  pane.fs = fs
57
- pane.directory = createDirectory({ fs, path: initialPath, context })
57
+ pane.directory = createDirectory({ fs, path: initialPath, context, watch })
58
58
 
59
59
  // Proxy cells mirror the directory, so views address the pane uniformly.
60
60
  const dir = pane.directory
@@ -70,6 +70,7 @@ function createPane({ fs, path: initialPath, context } = {}) {
70
70
  pane.disk = dir.disk
71
71
  pane.segments = dir.segments
72
72
  pane.history = dir.history
73
+ pane.autoRefresh = dir.autoRefresh
73
74
 
74
75
  // Function: current
75
76
  // The pane's directory.
@@ -79,6 +80,7 @@ function createPane({ fs, path: initialPath, context } = {}) {
79
80
  // currentEntry / toggleMark / setFilter / clearFilter — delegate to the
80
81
  // directory.
81
82
  pane.refresh = () => pane.directory.refresh()
83
+ pane.setAutoRefresh = (enabled) => pane.directory.setAutoRefresh(enabled)
82
84
  pane.navigate = (path, options) => pane.directory.navigate(path, options)
83
85
  pane.up = () => pane.directory.up()
84
86
  pane.open = () => pane.directory.open()
@@ -5,30 +5,35 @@
5
5
 
6
6
  // Module: tcmd/core/panel
7
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.
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.
12
14
  //
13
15
  // For the selected operation, the panel binds its arguments from the live
14
16
  // context — a selection argument shows the SRC/DST pane's marked paths (falling
15
17
  // back to the cursor entry) and lets the user toggle individual items; a
16
18
  // destination argument resolves to the DST pane's directory; textual arguments
17
- // (a name, a buffer label, a shell command) are typed in place.
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`.
18
21
  //
19
22
  // The selection the user tweaks is *transient*: it lives in the draft bindings
20
23
  // (`session.panelDraft`), never in the real pane marks. When the draft runs,
21
24
  // each selection argument is encoded as a self-contained `$paths:[…]` token, so
22
- // the resolved paths survive an async confirm (see `expandArg`). The same draft
23
- // assembles the command line the panel previews (`panelCommand`), so the user
24
- // 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.
25
28
  // Everything here is pure: the caller owns the cells; the panel commands in
26
- // `core/commands` assemble and run.
29
+ // `core/commands` assemble and run. Picker path resolution and row shaping live
30
+ // in `core/picker`.
27
31
 
28
- import { hasMatch, score } from "../vendor/fzy.js"
32
+ import { hasMatch } from "../vendor/fzy.js"
29
33
  import { currentPaneId, PANE_BUFFERS, PANE_LABELS } from "./pane.js"
30
34
  import { basename, parent } from "./path.js"
31
- 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"
32
37
 
33
38
  // Constant: PANEL_FOCUS
34
39
  // The two regions the panel's focus ring moves through: "ops" (the filter and
@@ -36,89 +41,132 @@ import { getOperations, pathToken } from "./operations.js"
36
41
  // right pane, where a textual argument owns the keyboard and Space toggles a
37
42
  // selection item).
38
43
  const PANEL_FOCUS = { OPS: "ops", ARGS: "args" }
44
+ const PANEL_GROUPS = ["Command", "File", "Info", "Create", "Edit", "Delete"]
39
45
 
40
46
  // 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.
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.
43
50
  function panelEntries() {
44
- return getOperations()
45
- .filter((op) => !op.hidden)
51
+ const entries = getOperations()
52
+ .filter((op) => !op.hidden && op.group)
46
53
  .map((op) => ({
47
54
  id: `op:${op.name}`,
48
55
  name: op.name,
49
- summary: `${op.signature ?? ""}`.replace(/^\S+\s*/, ""),
50
- signature: op.signature ?? "",
56
+ group: op.group,
51
57
  risk: op.risk ?? null,
52
58
  matchNames: [op.name, ...(op.aliases ?? [])],
53
59
  op,
54
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
55
90
  }
56
91
 
57
92
  // Function: compareEntries
58
- // The panel's default ordering: alphabetically by name.
93
+ // The panel's fixed category order, then alphabetical command order.
59
94
  function compareEntries(a, b) {
60
- return `${a?.name ?? ""}`.localeCompare(`${b?.name ?? ""}`)
61
- }
62
-
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) {
67
- let best = -Infinity
68
- for (const name of entry.matchNames ?? []) {
69
- const haystack = `${name}`.toLowerCase()
70
- if (hasMatch(query, haystack)) best = Math.max(best, score(query, haystack))
71
- }
72
- return best
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 ?? ""}`)
73
100
  }
74
101
 
75
102
  // 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.
103
+ // Narrows the entry list by command name or alias and keeps the fixed category
104
+ // order and alphabetical order within each group.
81
105
  function filterPanelEntries(entries, query) {
82
106
  const list = entries ?? []
83
107
  const q = `${query ?? ""}`.trim().toLowerCase()
84
108
  if (!q) return [...list].sort(compareEntries)
85
- const ranked = []
86
- for (const entry of list) {
87
- const rank = entryScore(entry, q)
88
- const signatureMatch = `${entry.signature ?? ""}`.toLowerCase().includes(q)
89
- if (rank === -Infinity && !signatureMatch) continue
90
- ranked.push({ entry, score: rank })
91
- }
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)
109
+ return list
110
+ .filter((entry) => (entry.matchNames ?? []).some((name) => hasMatch(q, `${name}`.toLowerCase())))
111
+ .sort(compareEntries)
94
112
  }
95
113
 
96
114
  // 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
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
99
117
  // (what the cursor `panelOpIndex` addresses).
100
118
  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
- }))
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
+ })
136
+ }
137
+ return rows
138
+ }
139
+
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
156
+ }
157
+ return currentPaneId(session)
109
158
  }
110
159
 
111
160
  // Function: resolvePaneId
112
161
  // 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).
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).
117
166
  function resolvePaneId(session, role) {
118
167
  if (role === "dest") return "right"
119
- if (role === "source") {
120
- return session.viewMode?.value === "explorer" ? currentPaneId(session) : "left"
121
- }
168
+ if (session.viewMode?.value === "explorer") return explorerColumnId(session)
169
+ if (role === "source") return "left"
122
170
  return currentPaneId(session)
123
171
  }
124
172
 
@@ -163,6 +211,9 @@ function bindArg(session, arg, index) {
163
211
  const role = arg.role ?? "active"
164
212
  const common = {
165
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,
166
217
  name: arg.name,
167
218
  kind: arg.kind,
168
219
  role,
@@ -185,6 +236,21 @@ function bindArg(session, arg, index) {
185
236
  value: "",
186
237
  }
187
238
  }
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
+ }
188
254
  if (arg.kind === "dest" || arg.kind === "path") {
189
255
  // A destination or a path argument is typeable in place, like a
190
256
  // literal, and additionally browsable through the panel's file picker
@@ -194,7 +260,12 @@ function bindArg(session, arg, index) {
194
260
  // empty for the picker (or the user) to fill.
195
261
  const mode = arg.kind === "dest" ? "dir" : arg.mode ?? "any"
196
262
  const commander = (session.viewMode?.value ?? "commander") !== "explorer"
197
- const value = mode === "dir" && commander ? session.panes?.right?.path?.value ?? "" : ""
263
+ const value =
264
+ arg.prefill === "current"
265
+ ? (session.activePath?.value ?? "")
266
+ : mode === "dir" && commander
267
+ ? (session.panes?.right?.path?.value ?? "")
268
+ : ""
198
269
  return { ...common, textual: true, picker: true, mode, items: [], value }
199
270
  }
200
271
  if (arg.kind === "buffer") {
@@ -228,6 +299,7 @@ function argTokens(bindings) {
228
299
  const paths = selectedPaths(binding)
229
300
  return paths.length ? pathToken(paths) : ""
230
301
  }
302
+ if (binding.kind === "permissions") return permissionOctal(binding)
231
303
  if (binding.kind === "dest") return binding.value ?? ""
232
304
  return `${binding.value ?? ""}`.trim()
233
305
  })
@@ -269,17 +341,6 @@ function missingArg(bindings) {
269
341
  return missingBinding(bindings)?.name ?? null
270
342
  }
271
343
 
272
- // Function: shellQuote
273
- // Quotes a token for display as it would appear in a shell: bare when it is
274
- // already safe (`/home/a`), single-quoted when it contains spaces or shell
275
- // metacharacters (`'my notes.txt'`). Only the last path segment matters in
276
- // practice, but quoting the whole token keeps the preview copy-pasteable.
277
- function shellQuote(token) {
278
- const text = `${token ?? ""}`
279
- if (text === "") return "''"
280
- return /^[A-Za-z0-9_@%+=:,.\/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
281
- }
282
-
283
344
  // Function: panelCommand
284
345
  // The command line the draft maps to: each argument rendered as the value it
285
346
  // contributes — a selection as its selected paths, a destination/textual arg
@@ -296,6 +357,10 @@ function panelCommand(bindings) {
296
357
  else if (binding.optional !== true) parts.push(`«${binding.name}»`)
297
358
  continue
298
359
  }
360
+ if (binding.kind === "permissions") {
361
+ parts.push(permissionOctal(binding))
362
+ continue
363
+ }
299
364
  const value = `${binding.value ?? ""}`.trim()
300
365
  if (value) parts.push(shellQuote(value))
301
366
  else if (binding.optional !== true) parts.push(`«${binding.name}»`)
@@ -321,6 +386,7 @@ function panelSummary(binding) {
321
386
  const count = selectedPaths(binding).length
322
387
  return `${pane} · ${count} selected`
323
388
  }
389
+ if (binding.kind === "permissions") return permissionText(binding)
324
390
  if (binding.kind === "dest" || binding.kind === "path") return binding.value || "(none)"
325
391
  if (binding.kind === "buffer") return binding.value || "(active)"
326
392
  return binding.value ? binding.value : "(empty)"
@@ -336,7 +402,12 @@ function panelSummary(binding) {
336
402
  // list rows are; this stays pure and index-stable.
337
403
  function panelRowList(bindings) {
338
404
  const rows = []
339
- 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) {
340
411
  const multi = binding.multi === true
341
412
  const items = binding.items ?? []
342
413
  // A selection that resolves to a single path is not a list: it renders
@@ -355,6 +426,7 @@ function panelRowList(bindings) {
355
426
  optional: binding.optional,
356
427
  variadic: binding.variadic,
357
428
  textual: binding.textual,
429
+ editable: binding.textual === true,
358
430
  picker: binding.picker === true,
359
431
  mode: binding.mode ?? null,
360
432
  multi,
@@ -363,6 +435,37 @@ function panelRowList(bindings) {
363
435
  count: items.length,
364
436
  })
365
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
+ }
366
469
  for (let i = 0; i < items.length; i++) {
367
470
  const item = items[i]
368
471
  const selected = item.selected
@@ -392,23 +495,6 @@ function rowAt(rows, index) {
392
495
  return (rows ?? [])[index] ?? null
393
496
  }
394
497
 
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
-
412
498
  // Function: isTextualRow
413
499
  // True when the cursor row is an argument header that takes free text, so the
414
500
  // panel focuses its edit field.
@@ -450,6 +536,7 @@ export { decodePathToken, pathToken } from "./operations.js"
450
536
 
451
537
  export {
452
538
  PANEL_FOCUS,
539
+ PANEL_GROUPS,
453
540
  argvFor,
454
541
  bindArg,
455
542
  bindArgs,
@@ -465,7 +552,6 @@ export {
465
552
  panelRunnable,
466
553
  panelSummary,
467
554
  paneSelectionPaths,
468
- pickerRowList,
469
555
  rowAt,
470
556
  selectedPaths,
471
557
  withArgValue,
@@ -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
@@ -0,0 +1,107 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-22
5
+
6
+ // Module: tcmd/core/permissions
7
+ // POSIX permission bits as the command panel edits them: the nine `u/g/o` ×
8
+ // `r/w/x` toggles `chmod` binds its MODE argument to. The tables are the single
9
+ // source of truth for the display order, the octal values and the `rwx`
10
+ // rendering; the panel projects them into rows and the argument row component
11
+ // renders the three cells of each class.
12
+ //
13
+ // Everything here is pure: the binding is the caller's, and the numeric/octal
14
+ // conversions do not mutate it.
15
+
16
+ // Constant: PERMISSION_CLASSES
17
+ // The permission classes in display order (owner, group, other).
18
+ const PERMISSION_CLASSES = [
19
+ { id: "u", label: "user" },
20
+ { id: "g", label: "group" },
21
+ { id: "o", label: "other" },
22
+ ]
23
+
24
+ // Constant: PERMISSION_BITS
25
+ // The bits of each class in display order (read, write, execute).
26
+ const PERMISSION_BITS = [
27
+ { id: "r", label: "read" },
28
+ { id: "w", label: "write" },
29
+ { id: "x", label: "execute" },
30
+ ]
31
+
32
+ // Constant: PERMISSION_VALUES
33
+ // The octal value of each `class:bit` key.
34
+ const PERMISSION_VALUES = {
35
+ "u:r": 0o400, "u:w": 0o200, "u:x": 0o100,
36
+ "g:r": 0o040, "g:w": 0o020, "g:x": 0o010,
37
+ "o:r": 0o004, "o:w": 0o002, "o:x": 0o001,
38
+ }
39
+
40
+ // Function: permissionItems
41
+ // The nine `u/g/o` × `r/w/x` toggles for a permission argument, seeded from an
42
+ // existing file mode. Each item is a checkbox in the panel's item list: its
43
+ // `key` is the stable `class:bit` key the token builder reads back.
44
+ function permissionItems(mode) {
45
+ const bits = Number(mode) & 0o777
46
+ const items = []
47
+ for (const permissionClass of PERMISSION_CLASSES) {
48
+ for (const bit of PERMISSION_BITS) {
49
+ const key = `${permissionClass.id}:${bit.id}`
50
+ items.push({
51
+ key,
52
+ name: bit.label,
53
+ parent: permissionClass.label,
54
+ isDir: false,
55
+ selected: (bits & PERMISSION_VALUES[key]) !== 0,
56
+ })
57
+ }
58
+ }
59
+ return items
60
+ }
61
+
62
+ // Function: permissionMode
63
+ // The numeric mode a permission binding currently selects.
64
+ function permissionMode(binding) {
65
+ let mode = 0
66
+ for (const item of binding?.items ?? []) {
67
+ if (item.selected) mode |= PERMISSION_VALUES[item.key] ?? 0
68
+ }
69
+ return mode
70
+ }
71
+
72
+ // Function: permissionOctal
73
+ // The three-digit octal token a permission binding contributes to argv.
74
+ function permissionOctal(binding) {
75
+ return permissionMode(binding).toString(8).padStart(3, "0")
76
+ }
77
+
78
+ // Function: permissionSymbolic
79
+ // The `rwx`-style rendering of a numeric mode.
80
+ function permissionSymbolic(mode) {
81
+ return PERMISSION_CLASSES.map((permissionClass) =>
82
+ PERMISSION_BITS.map((bit) =>
83
+ mode & PERMISSION_VALUES[`${permissionClass.id}:${bit.id}`] ? bit.id : "-",
84
+ ).join(""),
85
+ ).join("")
86
+ }
87
+
88
+ // Function: permissionText
89
+ // The binding's value as the panel shows it: the octal mode and its symbolic
90
+ // form (`644 · rw-r--r--`).
91
+ function permissionText(binding) {
92
+ const mode = permissionMode(binding)
93
+ return `${permissionOctal(binding)} · ${permissionSymbolic(mode)}`
94
+ }
95
+
96
+ export {
97
+ PERMISSION_BITS,
98
+ PERMISSION_CLASSES,
99
+ PERMISSION_VALUES,
100
+ permissionItems,
101
+ permissionMode,
102
+ permissionOctal,
103
+ permissionSymbolic,
104
+ permissionText,
105
+ }
106
+
107
+ // EOF