@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
@@ -10,16 +10,21 @@
10
10
  // source of truth for every presentation layer.
11
11
  //
12
12
  // Commands carry presentation metadata:
13
- // - `kind`: "ui" for UI-level actions; the command palette lists UI actions by
14
- // default (scripting operations live in core/operations.js).
15
- // - `bar`: true when the command appears in the command bar's key hints.
16
- // - `hidden`: true for internal commands that never show in the palette.
13
+ // - `kind`: "ui" for UI-level actions; the command panel lists the two it can
14
+ // reach (the workspace-view toggle and Quit) alongside the CLI operations
15
+ // (see core/panel.js).
16
+ // - `hidden`: true for internal commands that never show in the panel.
17
+ // The default command bar is the explicit `BAR_COMMANDS` list below, in that
18
+ // order.
17
19
 
18
- import { basename, parent as parentOf } from "./path.js"
19
- import { PANEL_FOCUS, argvFor, bindingAt, missingArg, rowAt, withToggledItem } from "./panel.js"
20
+ import { parent as parentOf } from "./path.js"
21
+ import { PANEL_FOCUS, argvFor, bindingAt, missingBinding, rowAt, withArgValue, withToggledItem } from "./panel.js"
22
+ import { pickerAbsolutePath, pickerDirectoryText } from "./picker.js"
23
+ import { hasBinaryByte, previewKind, previewSource, SNIFF_BYTES } from "./preview.js"
20
24
  import { clampCursor, moveCursor } from "./selection.js"
25
+ import { currentPane } from "./pane.js"
21
26
  import { runLine } from "./script.js"
22
- import { getOperation, runOperation } from "./operations.js"
27
+ import { getOperation, refreshPanes, runOperation } from "./operations.js"
23
28
 
24
29
  // Constant: PAGE / JUMP
25
30
  // Cursor movement steps: a page for PgUp/PgDn, a shorter jump for the
@@ -27,24 +32,6 @@ import { getOperation, runOperation } from "./operations.js"
27
32
  const PAGE = 10
28
33
  const JUMP = 5
29
34
 
30
- // Function: PALETTE_KEYS
31
- // Command-bar hints shown while the command palette is open.
32
- const PALETTE_KEYS = [
33
- { id: null, title: "Navigate", keys: "↑/↓" },
34
- { id: null, title: "Complete", keys: "Tab" },
35
- { id: null, title: "Run", keys: "Enter", primary: true },
36
- { id: null, title: "Close", keys: "Esc" },
37
- ]
38
-
39
- // Constant: SCRIPT_KEYS
40
- // Command-bar hints for scripting mode (no UI navigation, only the palette
41
- // itself).
42
- const SCRIPT_KEYS = [
43
- { id: null, title: "Complete", keys: "Tab" },
44
- { id: null, title: "Run", keys: "Enter", primary: true },
45
- { id: null, title: "Close", keys: "Esc" },
46
- ]
47
-
48
35
  // Constant: FILTER_KEYS
49
36
  // Command-bar hints shown while a pane filter is capturing input.
50
37
  const FILTER_KEYS = [
@@ -53,18 +40,12 @@ const FILTER_KEYS = [
53
40
  { id: null, title: "Clear", keys: "Esc" },
54
41
  ]
55
42
 
56
- // Constant: BOOKMARK_NAME_KEYS
57
- // Command-bar hints while naming a bookmark.
58
- const BOOKMARK_NAME_KEYS = [
59
- { id: null, title: "Save", keys: "Enter", primary: true },
60
- { id: null, title: "Cancel", keys: "Esc" },
61
- ]
62
-
63
- // Constant: BOOKMARK_LIST_KEYS
64
- // Command-bar hints while picking a bookmark.
65
- const BOOKMARK_LIST_KEYS = [
43
+ // Constant: BOOKMARK_KEYS
44
+ // Command-bar hints while the bookmarks panel owns the keyboard.
45
+ const BOOKMARK_KEYS = [
66
46
  { id: null, title: "Navigate", keys: "↑/↓" },
67
- { id: null, title: "Jump", keys: "Enter", primary: true },
47
+ { id: null, title: "Remove", keys: "⌫/Del" },
48
+ { id: null, title: "Open", keys: "Enter", primary: true },
68
49
  { id: null, title: "Close", keys: "Esc" },
69
50
  ]
70
51
 
@@ -73,11 +54,58 @@ const BOOKMARK_LIST_KEYS = [
73
54
  const PANEL_HINT_KEYS = [
74
55
  { id: null, title: "Command", keys: "↑/↓" },
75
56
  { id: null, title: "Arg", keys: "⇥" },
57
+ { id: null, title: "Browse", keys: "^O" },
76
58
  { id: null, title: "Toggle", keys: "Spc" },
77
59
  { id: null, title: "Run", keys: "↵", primary: true },
78
60
  { id: null, title: "Close", keys: "Esc" },
79
61
  ]
80
62
 
63
+ // Constant: PANEL_CONFIRM_KEYS
64
+ // The same hints after a risky action is armed: Enter now confirms.
65
+ const PANEL_CONFIRM_KEYS = PANEL_HINT_KEYS.map((hint) =>
66
+ hint.title === "Run" ? { ...hint, title: "Confirm" } : hint,
67
+ )
68
+
69
+ // Constant: PICKER_KEYS
70
+ // Command-bar hints while the panel's file picker owns the keyboard.
71
+ const PICKER_KEYS = [
72
+ { id: null, title: "Path", keys: "type" },
73
+ { id: null, title: "Row", keys: "↑/↓" },
74
+ { id: null, title: "Open", keys: "→" },
75
+ { id: null, title: "Go", keys: "↵", primary: true },
76
+ { id: null, title: "Here", keys: "^↵" },
77
+ { id: null, title: "Focus", keys: "⇥" },
78
+ { id: null, title: "Back", keys: "Esc" },
79
+ ]
80
+
81
+ // Constant: HISTORY_KEYS / TRASH_KEYS
82
+ // Command-bar hints while the shared list overlay owns the keyboard.
83
+ const HISTORY_KEYS = [
84
+ { id: null, title: "Row", keys: "↑/↓" },
85
+ { id: null, title: "Jump", keys: "↵", primary: true },
86
+ { id: null, title: "Close", keys: "Esc" },
87
+ ]
88
+
89
+ const TRASH_KEYS = [
90
+ { id: null, title: "Row", keys: "↑/↓" },
91
+ { id: null, title: "Restore", keys: "↵", primary: true },
92
+ { id: null, title: "Close", keys: "Esc" },
93
+ ]
94
+
95
+ // Constant: BAR_COMMANDS
96
+ // The command bar's default entries, in display order. Kept as an explicit
97
+ // list so the bar stays a short, stable set of primary actions instead of
98
+ // every command that happens to be context-relevant.
99
+ const BAR_COMMANDS = [
100
+ "panel.open",
101
+ "list.mark",
102
+ "filter.start",
103
+ "pane.toggle",
104
+ "bookmark.open",
105
+ "view.toggleMode",
106
+ "app.help",
107
+ ]
108
+
81
109
  const commands = [
82
110
  {
83
111
  id: "nav.up",
@@ -156,10 +184,26 @@ const commands = [
156
184
  title: "Open",
157
185
  keys: "Enter",
158
186
  kind: "ui",
159
- bar: true,
160
187
  primary: true,
161
188
  run(session) {
162
- return pane(session).open()
189
+ // The entry under the *focused* pane's cursor decides where the
190
+ // current directory goes: a directory descends into it. On an
191
+ // auxiliary column (parent/preview) that means the current pane
192
+ // follows the selection, which is what keeps the mirror in step.
193
+ const focused = pane(session)
194
+ const entry = focused.currentEntry()
195
+ if (!entry) return
196
+ const current = currentPane(session)
197
+ if (entry.isDir) return current.navigate(entry.path)
198
+ // A file outside the current pane pins the inspector on it before
199
+ // it is opened, so the preview follows the entry that was acted on.
200
+ if (focused !== current) {
201
+ session.previewTarget.set(entry)
202
+ session.showPreview.set(true)
203
+ }
204
+ // A file: edit it when it is a text file the host can edit,
205
+ // otherwise show it fullscreen (see `openFile`).
206
+ return openFile(session, current, entry)
163
207
  },
164
208
  },
165
209
  {
@@ -167,9 +211,11 @@ const commands = [
167
211
  title: "Parent",
168
212
  keys: "←",
169
213
  kind: "ui",
170
- bar: true,
171
214
  run(session) {
172
- return pane(session).up()
215
+ // Stepping up always moves the current directory: the parent
216
+ // column mirrors it, so navigating that column itself would
217
+ // break the mirror.
218
+ return currentPane(session).up()
173
219
  },
174
220
  },
175
221
  {
@@ -178,7 +224,7 @@ const commands = [
178
224
  keys: "Esc",
179
225
  kind: "ui",
180
226
  run(session) {
181
- return pane(session).back()
227
+ return currentPane(session).back()
182
228
  },
183
229
  },
184
230
  {
@@ -187,15 +233,14 @@ const commands = [
187
233
  keys: "⇧Esc",
188
234
  kind: "ui",
189
235
  run(session) {
190
- return pane(session).forward()
236
+ return currentPane(session).forward()
191
237
  },
192
238
  },
193
239
  {
194
240
  id: "pane.reload",
195
241
  title: "Reload",
196
- keys: "^R",
242
+ keys: "F5/r",
197
243
  kind: "ui",
198
- bar: true,
199
244
  run(session) {
200
245
  return pane(session).refresh()
201
246
  },
@@ -205,9 +250,24 @@ const commands = [
205
250
  title: "Switch",
206
251
  keys: "Tab",
207
252
  kind: "ui",
208
- bar: true,
209
253
  run(session) {
210
- session.active.set(session.active.value === "left" ? "right" : "left")
254
+ // Cycle through the columns the current view mode shows:
255
+ // source ⇄ destination in commander, parent → current → preview
256
+ // in explorer. Focus parked on a column that is not in the cycle
257
+ // (the inspector in commander) lands on the other side.
258
+ const current = session.currentId.value
259
+ const order =
260
+ session.viewMode.value === "explorer"
261
+ ? session.showPreview.value
262
+ ? ["parent", current, "preview"]
263
+ : ["parent", current]
264
+ : ["left", "right"]
265
+ const at = order.indexOf(session.active.value)
266
+ const next =
267
+ at >= 0
268
+ ? order[(at + 1) % order.length]
269
+ : order.find((paneId) => paneId !== current) ?? order[0]
270
+ session.active.set(next)
211
271
  },
212
272
  },
213
273
  {
@@ -215,46 +275,24 @@ const commands = [
215
275
  title: "Mark",
216
276
  keys: "Spc",
217
277
  kind: "ui",
218
- bar: true,
219
278
  run(session) {
220
279
  pane(session).toggleMark()
221
280
  },
222
281
  },
223
282
  {
224
- id: "bookmark.record",
225
- title: "Bookmark",
283
+ id: "bookmark.open",
284
+ title: "Bookmarks",
226
285
  keys: "b",
227
286
  kind: "ui",
228
- bar: true,
229
287
  run(session) {
230
- // Record the entry under the cursor; fall back to the pane's
231
- // directory when the listing is empty.
288
+ // The add row records the entry under the cursor; it falls back to
289
+ // the pane's directory when the listing is empty.
232
290
  const entry = pane(session).currentEntry()
233
291
  const path = entry?.path ?? pane(session).path.value
234
292
  const isDir = entry ? Boolean(entry.isDir) : true
235
- const existing = session.bookmarks.find(path)
236
- if (existing) {
237
- // Already bookmarked: no need to re-name it.
238
- session.notice.set({ kind: "tip", text: `bookmark ${existing.name}` })
239
- return
240
- }
241
- // The default name is the path's basename.
242
- session.bookmarkTarget.set({ path, isDir })
243
- session.bookmarkQuery.set(basename(path))
244
- session.bookmarkMode.set("name")
245
- session.bookmarkOpen.set(true)
246
- },
247
- },
248
- {
249
- id: "bookmark.jump",
250
- title: "Bookmarks",
251
- keys: "g",
252
- kind: "ui",
253
- bar: true,
254
- run(session) {
255
- session.bookmarkTarget.set(null)
256
293
  session.bookmarkQuery.set("")
257
- session.bookmarkMode.set("list")
294
+ session.bookmarkIndex.set(0)
295
+ session.bookmarkTarget.set({ path, isDir })
258
296
  session.bookmarkOpen.set(true)
259
297
  },
260
298
  },
@@ -277,30 +315,19 @@ const commands = [
277
315
  },
278
316
  },
279
317
  {
280
- id: "bookmark.save",
281
- title: "Save bookmark",
318
+ id: "bookmark.remove",
319
+ title: "Remove bookmark",
282
320
  kind: "ui",
283
321
  hidden: true,
284
- run(session) {
285
- const name = `${session.bookmarkQuery.value ?? ""}`.trim()
286
- const target = session.bookmarkTarget.value
287
- if (!name) {
288
- session.notice.set({ kind: "warning", text: "bookmark needs a name" })
289
- return
290
- }
291
- if (!target) {
292
- closeBookmark(session)
293
- return
294
- }
295
- // Close synchronously: the global key handler and the input's
296
- // `on:enter` can both fire, and a second save while the overlay is
297
- // still open would re-record and flash the notice twice. A rejected
298
- // `record` (rejected name) still surfaces through the dispatch's
299
- // error notice.
300
- closeBookmark(session)
301
- return session.bookmarks.record(name, target.path, target.isDir).then(() => {
302
- session.notice.set({ kind: "tip", text: `bookmarked ${name}` })
303
- })
322
+ async run(session) {
323
+ const items = session.bookmarkItems.value ?? []
324
+ const index = clampCursor(session.bookmarkIndex.value, items.length)
325
+ const item = items[index]
326
+ // The add row is an action, not a stored bookmark.
327
+ if (!item || item.add) return
328
+ if (!(await session.bookmarks.remove(item.name))) return
329
+ session.bookmarkIndex.set(clampCursor(index, session.bookmarkItems.value.length))
330
+ session.notice.set({ kind: "tip", text: `removed bookmark ${item.name}` })
304
331
  },
305
332
  },
306
333
  {
@@ -321,7 +348,9 @@ const commands = [
321
348
  const items = session.bookmarkItems.value
322
349
  const item = items[clampCursor(session.bookmarkIndex.value, items.length)]
323
350
  if (!item) return
324
- const target = session.panes[session.active.value]
351
+ // The add row records the current target instead of navigating.
352
+ if (item.add) return addCurrentBookmark(session)
353
+ const target = currentPane(session)
325
354
  const path = item.path
326
355
  // Resolve the kind when it is unknown (legacy path-only bookmark).
327
356
  let isDir = item.isDir
@@ -351,15 +380,145 @@ const commands = [
351
380
  closeBookmark(session)
352
381
  },
353
382
  },
383
+ // Undo/redo and the shared list overlay (`u` history, `⇧T` trash). The
384
+ // journal records reversible operations (see core/operations); the overlay
385
+ // is one modal list whose rows/actions depend on the active mode.
386
+ {
387
+ id: "history.undo",
388
+ title: "Undo",
389
+ keys: "^Z",
390
+ kind: "ui",
391
+ async run(session) {
392
+ const entry = await session.journal.undo(session)
393
+ if (!entry) {
394
+ session.notice.set({ kind: "warning", text: "nothing to undo" })
395
+ return
396
+ }
397
+ await refreshPanes(session)
398
+ session.notice.set({ kind: "tip", text: `undid ${entry.label}` })
399
+ },
400
+ },
401
+ {
402
+ id: "history.redo",
403
+ title: "Redo",
404
+ keys: "^⇧Z",
405
+ kind: "ui",
406
+ async run(session) {
407
+ const entry = await session.journal.redo(session)
408
+ if (!entry) {
409
+ session.notice.set({ kind: "warning", text: "nothing to redo" })
410
+ return
411
+ }
412
+ await refreshPanes(session)
413
+ session.notice.set({ kind: "tip", text: `redid ${entry.label}` })
414
+ },
415
+ },
416
+ {
417
+ id: "history.open",
418
+ title: "History",
419
+ keys: "u",
420
+ kind: "ui",
421
+ run(session) {
422
+ openHistory(session)
423
+ },
424
+ },
425
+ {
426
+ id: "trash.open",
427
+ title: "Trash",
428
+ keys: "⇧T",
429
+ kind: "ui",
430
+ async run(session) {
431
+ await openTrash(session)
432
+ },
433
+ },
434
+ {
435
+ id: "file.mkdir",
436
+ title: "New directory",
437
+ keys: "n",
438
+ kind: "ui",
439
+ run(session) {
440
+ // The panel opens on `mkdir` with its NAME field primed, the same
441
+ // way `!` lands on `run`; PATH is prefilled with the current path.
442
+ openPanel(session, { name: "mkdir", focus: PANEL_FOCUS.ARGS })
443
+ const nameIndex = session.panelRows.value.findIndex(
444
+ (row) => row.type === "arg" && row.name === "NAME",
445
+ )
446
+ session.panelRowIndex.set(nameIndex >= 0 ? nameIndex : 0)
447
+ },
448
+ },
449
+ {
450
+ id: "overlay.up",
451
+ title: "Previous row",
452
+ kind: "ui",
453
+ hidden: true,
454
+ run(session) {
455
+ const rows = session.overlayItems.value
456
+ session.overlayIndex.set(moveCursor(session.overlayIndex.value, -1, rows.length))
457
+ },
458
+ },
459
+ {
460
+ id: "overlay.down",
461
+ title: "Next row",
462
+ kind: "ui",
463
+ hidden: true,
464
+ run(session) {
465
+ const rows = session.overlayItems.value
466
+ session.overlayIndex.set(moveCursor(session.overlayIndex.value, 1, rows.length))
467
+ },
468
+ },
469
+ {
470
+ id: "overlay.setIndex",
471
+ title: "Highlight row",
472
+ kind: "ui",
473
+ hidden: true,
474
+ run(session, payload) {
475
+ if (payload?.index !== undefined) session.overlayIndex.set(Math.max(0, payload.index))
476
+ },
477
+ },
478
+ {
479
+ id: "overlay.activate",
480
+ title: "Activate row",
481
+ kind: "ui",
482
+ hidden: true,
483
+ async run(session) {
484
+ const mode = session.overlay.value
485
+ const rows = session.overlayItems.value
486
+ const row = rows[clampCursor(session.overlayIndex.value, rows.length)]
487
+ if (!row) return
488
+ if (mode === "history") return activateHistory(session, row)
489
+ if (mode === "trash") return activateTrash(session, row)
490
+ return undefined
491
+ },
492
+ },
493
+ {
494
+ id: "overlay.cancel",
495
+ title: "Close list",
496
+ kind: "ui",
497
+ hidden: true,
498
+ run(session) {
499
+ closeOverlay(session)
500
+ },
501
+ },
354
502
  {
355
503
  id: "list.cursor",
356
- title: "Set cursor",
504
+ title: "Select row",
357
505
  kind: "ui",
358
506
  hidden: true,
359
507
  run(session, payload) {
360
508
  const target = payload?.paneId ? session.panes[payload.paneId] : pane(session)
361
509
  activate(session, payload?.paneId)
362
- target.cursor.set(clampCursor(payload?.index ?? 0, target.entries.value.length))
510
+ const index = payload?.index ?? 0
511
+ target.cursor.set(clampCursor(index, target.entries.value.length))
512
+ // In the explorer the parent and preview columns are mirrors: a
513
+ // click on one of their rows retargets the current directory
514
+ // instead of only moving that column's cursor (see
515
+ // `retargetFromParent` / `retargetFromPreview`). Focus stays on the
516
+ // clicked column; the pane body's own `pane:activate` agrees.
517
+ if (session.viewMode.value !== "explorer") return
518
+ if (payload?.paneId === "parent") return retargetFromParent(session, target, index)
519
+ if (payload?.paneId === "preview" && isColumnPane(session, "preview")) {
520
+ return retargetFromPreview(session, target, index)
521
+ }
363
522
  },
364
523
  },
365
524
  {
@@ -377,9 +536,11 @@ const commands = [
377
536
  kind: "ui",
378
537
  hidden: true,
379
538
  run(session, payload) {
380
- const target = payload?.paneId ? session.panes[payload.paneId] : pane(session)
539
+ // A breadcrumb click focuses its column, then lands the current
540
+ // directory there: the auxiliary columns mirror it, so their
541
+ // crumbs navigate the pane they follow.
381
542
  activate(session, payload?.paneId)
382
- return target.navigate(payload?.path)
543
+ return currentPane(session).navigate(payload?.path)
383
544
  },
384
545
  },
385
546
  {
@@ -387,11 +548,64 @@ const commands = [
387
548
  title: "Preview",
388
549
  keys: "v",
389
550
  kind: "ui",
390
- bar: true,
391
551
  run(session) {
392
552
  session.showPreview.set(!session.showPreview.value)
393
553
  },
394
554
  },
555
+ {
556
+ id: "view.toggleViewer",
557
+ title: "Fullscreen",
558
+ kind: "ui",
559
+ hidden: true,
560
+ run(session) {
561
+ // The fullscreen inspector overlay. `v` opens it on a narrow
562
+ // terminal and Enter opens it on a file that cannot be edited (see
563
+ // `openFile`); the shared App controller closes it on Esc or `v`
564
+ // (see `components/App#createBehaviors`).
565
+ session.viewerOpen.set(!session.viewerOpen.value)
566
+ },
567
+ },
568
+ {
569
+ id: "view.maximize",
570
+ title: "Maximize preview",
571
+ keys: "⇧V",
572
+ kind: "ui",
573
+ run(session) {
574
+ // The inspector takes the whole pane row, or gives it back. The
575
+ // preference cells are untouched, so the previous layout — pane
576
+ // widths, focus and whether the preview was even shown — returns
577
+ // untouched on the next press.
578
+ session.previewFull.set(!session.previewFull.value)
579
+ },
580
+ },
581
+ {
582
+ id: "preview.scroll",
583
+ title: "Scroll preview",
584
+ kind: "ui",
585
+ hidden: true,
586
+ run(session, payload) {
587
+ // The renderers own the body and its measured viewport, so the
588
+ // command publishes a one-shot request instead of an offset. The
589
+ // token keeps repeated presses of the same direction distinct.
590
+ const delta = Math.trunc(Number(payload?.delta ?? 0))
591
+ if (!Number.isFinite(delta) || !delta) return
592
+ const current = session.previewScroll?.value
593
+ session.previewScroll?.set({ delta, token: (current?.token ?? 0) + 1 })
594
+ },
595
+ },
596
+ {
597
+ id: "view.toggleMode",
598
+ title: "Toggle Mode",
599
+ keys: "t",
600
+ kind: "ui",
601
+ run(session) {
602
+ // Flip the workspace between the two-pane commander and the
603
+ // three-column explorer (parent · current · preview). Focus on a
604
+ // column that does not exist in the target mode is handed back to
605
+ // the pane it was following (see the guard effect in the session).
606
+ session.viewMode.set(session.viewMode.value === "explorer" ? "commander" : "explorer")
607
+ },
608
+ },
395
609
  {
396
610
  id: "file.open",
397
611
  title: "Open file",
@@ -414,36 +628,38 @@ const commands = [
414
628
  session.notice.set({ kind: "warning", text: "no file to edit" })
415
629
  return
416
630
  }
417
- return session.os.edit(entry.path).then(() => session.panes[session.active.value].refresh())
631
+ // A host without an editor (the web's null adapter) reports it, the
632
+ // same guard `openFile` uses, instead of tripping over the throw.
633
+ if (session.os?.canEdit === false) {
634
+ session.notice.set({ kind: "warning", text: "no editor available" })
635
+ return
636
+ }
637
+ return session.os.edit(entry.path).then(() => currentPane(session).refresh())
418
638
  },
419
639
  },
420
- // Command panel (`:`/`m`): the structured Commando view over the
421
- // operations. Lifecycle, then navigation, then draft editing, then run.
640
+ // Command panel (`:`): the one entry point, over both the CLI operations
641
+ // and the TinyCmd actions. Lifecycle, then navigation, then draft editing,
642
+ // then run.
422
643
  {
423
- id: "panel.toggle",
644
+ id: "panel.open",
424
645
  title: "Commands",
425
- keys: "m",
646
+ keys: ":",
426
647
  kind: "ui",
427
- bar: true,
428
648
  run(session) {
429
- togglePanel(session)
649
+ openPanel(session)
430
650
  },
431
651
  },
432
652
  {
433
- id: "panel.open",
434
- title: "Commands",
435
- keys: ":",
653
+ id: "panel.shell",
654
+ title: "Shell command",
436
655
  kind: "ui",
437
656
  hidden: true,
438
657
  run(session) {
439
- // `:` is the natural "command" key: open the structured panel on
440
- // renderers that have it, and fall back to the flat script palette
441
- // on those that do not (the web view).
442
- if (!session.panelEnabled?.value) {
443
- openPalette(session, "script", "")
444
- return
445
- }
446
- openPanel(session)
658
+ // `!` is the "run a shell command" reflex: land on the `run`
659
+ // operation with its raw CMD field already owning the keyboard. The
660
+ // field starts empty — `!` is a script-line alias, not part of the
661
+ // command itself.
662
+ openPanel(session, { name: "run", focus: PANEL_FOCUS.ARGS })
447
663
  },
448
664
  },
449
665
  {
@@ -492,26 +708,27 @@ const commands = [
492
708
  kind: "ui",
493
709
  hidden: true,
494
710
  run(session, payload) {
495
- // Tab cycles the focus ring: the command (query) then each argument
496
- // header, wrapping at both ends. Shift-Tab walks it backwards.
711
+ // Tab cycles the focus ring: the command filter, each argument header,
712
+ // then the Run action, wrapping at both ends (an item row is reached
713
+ // with the arrows). Shift-Tab walks it backwards.
497
714
  const delta = payload?.delta === -1 ? -1 : 1
498
715
  const rows = session.panelRows.value
499
- const headers = []
500
- for (let i = 0; i < rows.length; i++) if (rows[i].type === "arg") headers.push(i)
501
- if (!headers.length) return
716
+ const stops = []
717
+ for (let i = 0; i < rows.length; i++) if (rows[i].type === "arg" || rows[i].type === "run") stops.push(i)
718
+ if (!stops.length) return
502
719
  if (session.panelFocus.value === PANEL_FOCUS.OPS) {
503
- session.panelRowIndex.set(delta > 0 ? headers[0] : headers[headers.length - 1])
720
+ session.panelRowIndex.set(delta > 0 ? stops[0] : stops[stops.length - 1])
504
721
  session.panelFocus.set(PANEL_FOCUS.ARGS)
505
722
  return
506
723
  }
507
- const at = headers.indexOf(session.panelRowIndex.value)
724
+ const at = stops.indexOf(session.panelRowIndex.value)
508
725
  if (delta > 0) {
509
- if (at === -1 || at >= headers.length - 1) session.panelFocus.set(PANEL_FOCUS.OPS)
510
- else session.panelRowIndex.set(headers[at + 1])
726
+ if (at === -1 || at >= stops.length - 1) session.panelFocus.set(PANEL_FOCUS.OPS)
727
+ else session.panelRowIndex.set(stops[at + 1])
511
728
  } else if (at <= 0) {
512
729
  session.panelFocus.set(PANEL_FOCUS.OPS)
513
730
  } else {
514
- session.panelRowIndex.set(headers[at - 1])
731
+ session.panelRowIndex.set(stops[at - 1])
515
732
  }
516
733
  },
517
734
  },
@@ -567,6 +784,31 @@ const commands = [
567
784
  session.panelFocus.set(PANEL_FOCUS.ARGS)
568
785
  },
569
786
  },
787
+ {
788
+ id: "panel.moveBit",
789
+ title: "Move permission bit",
790
+ kind: "ui",
791
+ hidden: true,
792
+ run(session, payload) {
793
+ // Left/Right walk the r/w/x cells of a focused permission row.
794
+ const row = rowAt(session.panelRows.value, session.panelRowIndex.value)
795
+ if (row?.type !== "perm") return
796
+ const delta = payload?.delta === -1 ? -1 : 1
797
+ session.panelPermBit.set(Math.min(2, Math.max(0, session.panelPermBit.value + delta)))
798
+ },
799
+ },
800
+ {
801
+ id: "panel.togglePerm",
802
+ title: "Toggle permission bit",
803
+ kind: "ui",
804
+ hidden: true,
805
+ run(session, payload) {
806
+ // A click on one permission cell.
807
+ const { argIndex, itemIndex } = payload ?? {}
808
+ if (!Number.isInteger(argIndex) || !Number.isInteger(itemIndex)) return
809
+ session.panelDraft.set(withToggledItem(session.panelDraft.value, argIndex, itemIndex, true))
810
+ },
811
+ },
570
812
  {
571
813
  id: "panel.toggleItem",
572
814
  title: "Toggle argument item",
@@ -577,165 +819,321 @@ const commands = [
577
819
  if (!row) return
578
820
  const draft = session.panelDraft.value
579
821
  if (row.type === "item") {
580
- session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex))
822
+ session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex, row.multi !== false))
823
+ return
824
+ }
825
+ if (row.type === "perm") {
826
+ const bit = row.bits?.[session.panelPermBit.value]
827
+ if (bit) session.panelDraft.set(withToggledItem(draft, row.argIndex, bit.itemIndex, true))
581
828
  return
582
829
  }
583
830
  // An argument header toggles its whole selection: select all when
584
- // some are off, clear when all are on.
831
+ // some are off, clear when all are on. A single-path argument is a
832
+ // radio list, so its header has nothing to toggle.
585
833
  const binding = bindingAt(draft, row.argIndex)
586
- if (!binding || binding.kind !== "selection") return
834
+ // Only a selection argument mass-toggles from its header; a
835
+ // permission grid has no safe "all" state and stays per-bit.
836
+ if (!binding || binding.kind !== "selection" || binding.multi !== true) return
587
837
  const all = (binding.items ?? []).length > 0 && (binding.items ?? []).every((item) => item.selected)
588
838
  const items = (binding.items ?? []).map((item) => ({ ...item, selected: !all }))
589
839
  session.panelDraft.set(draft.map((b) => (b.index === row.argIndex ? { ...b, items } : b)))
590
840
  },
591
841
  },
592
842
  {
593
- id: "panel.run",
594
- title: "Run command",
843
+ id: "panel.copy",
844
+ title: "Copy argument value",
595
845
  kind: "ui",
596
846
  hidden: true,
597
- async run(session) {
598
- const op = session.panelOperation.value
599
- if (!op) return
600
- // An unrunnable draft is refused with the missing argument's name —
601
- // the `«NAME»` the preview already flags — instead of the generic
602
- // usage error `runOperation` would produce.
603
- const missing = missingArg(session.panelDraft.value)
604
- if (missing) {
605
- session.notice.set({ kind: "error", text: `${op.name}: fill in «${missing}»` })
847
+ run(session, payload) {
848
+ // A click on a read-only argument value copies it through the host
849
+ // (OSC 52 in the TUI, the Clipboard API in a browser); the command
850
+ // bar confirms so the gesture is visible even though the clipboard
851
+ // itself is out of band.
852
+ const text = `${payload?.text ?? ""}`
853
+ if (!text) return
854
+ if (!session.os?.copy?.(text)) {
855
+ session.notice?.set({ kind: "warning", text: "clipboard is not available" })
606
856
  return
607
857
  }
608
- const status = await runOperation(session, op, argvFor(session.panelDraft.value))
609
- // A confirm keeps the panel open until the user answers; a run
610
- // closes it. Errors stay open so the draft can be fixed.
611
- if (status === "ran") closePanel(session)
858
+ session.notice?.set({ kind: "tip", text: `copied ${text}` })
859
+ showBriefPanelResult(session)
612
860
  },
613
861
  },
862
+ // File picker: a sub-mode of the panel that browses the filesystem for a
863
+ // path/destination argument. Lifecycle then navigation then choose.
614
864
  {
615
- id: "panel.cancel",
616
- title: "Close command panel",
865
+ id: "panel.browse",
866
+ title: "Browse for a path",
617
867
  kind: "ui",
618
868
  hidden: true,
619
869
  run(session) {
620
- closePanel(session)
870
+ // Browse the focused argument when it is pickable, else the first
871
+ // pickable argument of the operation; a no-op when there is none.
872
+ const focused = focusedBinding(session)
873
+ const binding = focused?.picker === true ? focused : firstPickableBinding(session)
874
+ if (!binding) return undefined
875
+ return openPicker(session, binding.index, binding)
621
876
  },
622
877
  },
623
878
  {
624
- id: "palette.toggle",
625
- title: "Palette",
626
- keys: "^P",
879
+ id: "picker.up",
880
+ title: "Previous picker row",
627
881
  kind: "ui",
628
- bar: true,
882
+ hidden: true,
629
883
  run(session) {
630
- if (session.paletteOpen.value) {
631
- closePalette(session)
632
- return
633
- }
634
- openPalette(session, "ui", "")
884
+ const length = session.pickerRows.value.length
885
+ session.pickerFocus.set("rows")
886
+ session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, -1, length))
635
887
  },
636
888
  },
637
889
  {
638
- id: "palette.script",
639
- title: "Script",
890
+ id: "picker.down",
891
+ title: "Next picker row",
640
892
  kind: "ui",
893
+ hidden: true,
641
894
  run(session) {
642
- openPalette(session, "script", "")
895
+ const length = session.pickerRows.value.length
896
+ session.pickerFocus.set("rows")
897
+ session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, 1, length))
643
898
  },
644
899
  },
645
900
  {
646
- id: "palette.run",
647
- title: "Run command",
648
- keys: "!",
901
+ id: "picker.setCursor",
902
+ title: "Highlight picker row",
649
903
  kind: "ui",
650
- run(session) {
651
- openPalette(session, "script", "!")
904
+ hidden: true,
905
+ run(session, payload) {
906
+ if (payload?.index !== undefined) session.panelRowIndex.set(Math.max(0, payload.index))
907
+ session.pickerFocus.set("rows")
652
908
  },
653
909
  },
654
910
  {
655
- id: "filter.start",
656
- title: "Filter",
657
- keys: "/",
911
+ id: "picker.focusPath",
912
+ title: "Edit the picker path",
658
913
  kind: "ui",
659
- bar: true,
914
+ hidden: true,
660
915
  run(session) {
661
- session.filtering.set(session.active.value)
916
+ session.pickerFocus.set("path")
662
917
  },
663
918
  },
664
919
  {
665
- id: "filter.accept",
666
- title: "Keep filter",
920
+ id: "picker.focusRows",
921
+ title: "Focus the picker entries",
667
922
  kind: "ui",
668
923
  hidden: true,
669
924
  run(session) {
670
- session.filtering.set(null)
925
+ session.pickerFocus.set("rows")
671
926
  },
672
927
  },
673
928
  {
674
- id: "filter.clear",
675
- title: "Clear filter",
929
+ id: "picker.type",
930
+ title: "Type into the picker path",
676
931
  kind: "ui",
677
932
  hidden: true,
678
- run(session) {
679
- pane(session).clearFilter()
680
- session.filtering.set(null)
933
+ run(session, payload) {
934
+ // A printable key pressed while the entry list owns the keyboard
935
+ // jumps back to the path field and extends it, so typing always
936
+ // means "narrow the path" no matter where the cursor sits.
937
+ const text = `${session.pickerPathText.value ?? ""}`
938
+ session.pickerPathText.set(`${text}${payload?.char ?? ""}`)
939
+ session.pickerFocus.set("path")
681
940
  },
682
941
  },
683
942
  {
684
- id: "palette.up",
685
- title: "Previous command",
943
+ id: "picker.path",
944
+ title: "Go to the typed picker path",
686
945
  kind: "ui",
687
946
  hidden: true,
688
- run(session) {
689
- movePalette(session, -1)
947
+ async run(session) {
948
+ // Enter in the path field commits it: an existing directory is
949
+ // chosen (destination) or opened (file/any picker), an existing
950
+ // file is chosen, and a path that does not exist is reported.
951
+ const text = `${session.pickerPathText.value ?? ""}`
952
+ const absolute = pickerAbsolutePath(
953
+ text,
954
+ session.pickerBasePath.value || session.pickerPane.path.value || "/",
955
+ session.fs.home?.() ?? "/",
956
+ )
957
+ const stats = await session.fs.stat(absolute)
958
+ if (!stats) {
959
+ // A partial path narrowed the listing: Enter takes the match
960
+ // under the cursor (the `..` parent row is not a match).
961
+ const row = pickerEntry(session)
962
+ if (row?.type === "entry") {
963
+ if (row.isDir && session.pickerMode.value !== "dir") return descendPicker(session, row.path)
964
+ if (row.isDir || session.pickerMode.value !== "dir") return choosePicker(session, row.path)
965
+ }
966
+ session.notice.set({ kind: "warning", text: `no such path: ${absolute}` })
967
+ return
968
+ }
969
+ if (stats.isDir) {
970
+ if (session.pickerMode.value === "dir") return choosePicker(session, absolute)
971
+ return descendPicker(session, absolute)
972
+ }
973
+ if (session.pickerMode.value === "dir") {
974
+ session.notice.set({ kind: "warning", text: `not a directory: ${absolute}` })
975
+ return
976
+ }
977
+ return choosePicker(session, absolute)
690
978
  },
691
979
  },
692
980
  {
693
- id: "palette.down",
694
- title: "Next command",
981
+ id: "picker.enter",
982
+ title: "Choose picker row",
695
983
  kind: "ui",
696
984
  hidden: true,
697
985
  run(session) {
698
- movePalette(session, 1)
986
+ const entry = pickerEntry(session)
987
+ if (!entry) return undefined
988
+ if (entry.type === "parent") return session.dispatch("picker.parent")
989
+ // A directory descends unless this is a destination picker, where
990
+ // every row is already a valid choice.
991
+ if (entry.isDir && session.pickerMode.value !== "dir") {
992
+ return descendPicker(session, entry.path)
993
+ }
994
+ return choosePicker(session, entry.path)
699
995
  },
700
996
  },
701
997
  {
702
- id: "palette.complete",
703
- title: "Complete",
998
+ id: "picker.descend",
999
+ title: "Open the picker's directory",
704
1000
  kind: "ui",
705
1001
  hidden: true,
706
1002
  run(session) {
707
- const items = session.paletteItems.value
708
- const item = items[clampCursor(session.paletteIndex.value, items.length)]
709
- if (item?.completion) session.paletteQuery.set(item.completion)
1003
+ const entry = pickerEntry(session)
1004
+ if (entry?.type === "parent") return session.dispatch("picker.parent")
1005
+ if (entry?.isDir) return descendPicker(session, entry.path)
1006
+ return undefined
710
1007
  },
711
1008
  },
712
1009
  {
713
- id: "palette.setQuery",
714
- title: "Set palette query",
1010
+ id: "picker.parent",
1011
+ title: "Picker parent directory",
715
1012
  kind: "ui",
716
1013
  hidden: true,
717
- run(session, payload) {
718
- session.paletteQuery.set(`${payload?.query ?? ""}`)
1014
+ async run(session) {
1015
+ session.pickerPaths.invalidate()
1016
+ session.pickerQuery.set("")
1017
+ const cameFrom = session.pickerPane.path.value
1018
+ await session.pickerPane.up()
1019
+ session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
1020
+ const index = session.pickerRows.value.findIndex((row) => row.path === cameFrom)
1021
+ session.panelRowIndex.set(index >= 0 ? index : 0)
719
1022
  },
720
1023
  },
721
1024
  {
722
- id: "palette.submit",
723
- title: "Submit palette",
1025
+ id: "picker.chooseDir",
1026
+ title: "Choose the picker's directory",
724
1027
  kind: "ui",
725
1028
  hidden: true,
726
1029
  run(session) {
727
- const query = `${session.paletteQuery.value ?? ""}`.trim()
728
- if (!query) {
729
- // Nothing to run: complete the highlighted row instead.
730
- const items = session.paletteItems.value
731
- const item = items[clampCursor(session.paletteIndex.value, items.length)]
732
- if (item?.completion) session.paletteQuery.set(item.completion)
1030
+ return choosePicker(session, session.pickerPane.path.value)
1031
+ },
1032
+ },
1033
+ {
1034
+ id: "picker.cancel",
1035
+ title: "Leave the picker",
1036
+ kind: "ui",
1037
+ hidden: true,
1038
+ run(session) {
1039
+ // Back to the argument column, panel still open.
1040
+ session.pickerPaths.invalidate()
1041
+ session.pickerOpen.set(false)
1042
+ session.pickerArg.set(-1)
1043
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1044
+ session.pickerFocus.set("path")
1045
+ session.panelRowIndex.set(0)
1046
+ },
1047
+ },
1048
+ {
1049
+ id: "panel.run",
1050
+ title: "Run command",
1051
+ kind: "ui",
1052
+ hidden: true,
1053
+ async run(session) {
1054
+ const entry = session.panelEntry.value
1055
+ if (!entry) return
1056
+ if (entry.commandId) {
1057
+ closePanel(session)
1058
+ return session.dispatch(entry.commandId)
1059
+ }
1060
+ const op = entry.op
1061
+ const draft = session.panelDraft.value
1062
+ // An incomplete command does not fail: a blank destination/path
1063
+ // asks where to (the picker opens and the run resumes on a choice —
1064
+ // explorer mode has no DST pane to default from), and any other
1065
+ // missing argument just takes the cursor, so Enter walks the
1066
+ // command field by field.
1067
+ const missing = missingBinding(draft)
1068
+ if (missing) {
1069
+ if (missing.kind === "dest" || missing.kind === "path") {
1070
+ session.pickerPendingRun.set(true)
1071
+ return openPicker(session, missing.index, missing)
1072
+ }
1073
+ focusBinding(session, missing)
1074
+ return
1075
+ }
1076
+ session.panelRunError?.set(null)
1077
+ // Amber (caution) and red (destructive) actions are two-step: the
1078
+ // first Run prints the assembled command and arms `[Confirm]`; the
1079
+ // second executes it without the separate confirm overlay.
1080
+ const risk = entry.risk === "caution" || entry.risk === "destructive"
1081
+ if (risk && !session.panelArmed.value) {
1082
+ session.panelArmed.set(true)
733
1083
  return
734
1084
  }
735
- return runLine(session, query).then((status) => {
736
- if (status !== "empty") closePalette(session)
737
- return status
1085
+ const status = await runOperation(session, op, argvFor(draft), {
1086
+ panel: true,
1087
+ ...(risk ? { confirmed: true } : {}),
738
1088
  })
1089
+ // A confirm keeps the panel open until the user answers; a run
1090
+ // closes it. Errors stay open so the draft can be fixed.
1091
+ if (status === "error") keepPanelError(session)
1092
+ else if (status === "ran") {
1093
+ showBriefPanelResult(session)
1094
+ closePanel(session)
1095
+ }
1096
+ },
1097
+ },
1098
+ {
1099
+ id: "panel.cancel",
1100
+ title: "Close command panel",
1101
+ kind: "ui",
1102
+ hidden: true,
1103
+ run(session) {
1104
+ closePanel(session)
1105
+ },
1106
+ },
1107
+ {
1108
+ id: "filter.start",
1109
+ title: "Filter",
1110
+ keys: "/",
1111
+ kind: "ui",
1112
+ run(session) {
1113
+ // A column with no filter field (the inspector, or a column the
1114
+ // current mode does not show) hands focus to the pane it follows
1115
+ // before the input starts capturing keystrokes.
1116
+ if (!hasFilterField(session, session.active.value)) activate(session, session.currentId.value)
1117
+ session.filtering.set(session.active.value)
1118
+ },
1119
+ },
1120
+ {
1121
+ id: "filter.accept",
1122
+ title: "Keep filter",
1123
+ kind: "ui",
1124
+ hidden: true,
1125
+ run(session) {
1126
+ session.filtering.set(null)
1127
+ },
1128
+ },
1129
+ {
1130
+ id: "filter.clear",
1131
+ title: "Clear filter",
1132
+ kind: "ui",
1133
+ hidden: true,
1134
+ run(session) {
1135
+ pane(session).clearFilter()
1136
+ session.filtering.set(null)
739
1137
  },
740
1138
  },
741
1139
  {
@@ -753,7 +1151,11 @@ const commands = [
753
1151
  // A panel-originated confirm re-runs its draft and, on success,
754
1152
  // closes the panel that was waiting on it.
755
1153
  return runOperation(session, op, pending.args, { confirmed: true }).then((status) => {
756
- if (status === "ran" && session.panelOpen?.value) closePanel(session)
1154
+ if (status === "error" && session.panelOpen?.value) keepPanelError(session)
1155
+ if (status === "ran" && session.panelOpen?.value) {
1156
+ showBriefPanelResult(session)
1157
+ closePanel(session)
1158
+ }
757
1159
  return status
758
1160
  })
759
1161
  }
@@ -769,16 +1171,29 @@ const commands = [
769
1171
  session.confirm.set(null)
770
1172
  },
771
1173
  },
772
- // Quit: presentation-only. The key path intercepts `app.quit` before
773
- // dispatch, and the command-bar click does the same (the renderer owns
774
- // exiting); the registry entry exists so the bar and the `?` reference
775
- // can advertise it. Hidden from the palette, where it could only no-op.
1174
+ // Quit: the key path intercepts `app.quit` before dispatch, while the
1175
+ // panel's synthetic Quit entry dispatches it; both reach the renderer's
1176
+ // host hook (`session.quit`, installed at mount). Hidden from the `?`
1177
+ // keymap's command bar, not from the panel.
776
1178
  {
777
1179
  id: "app.quit",
778
1180
  title: "Quit",
779
1181
  keys: "q",
780
1182
  kind: "ui",
781
- bar: true,
1183
+ hidden: true,
1184
+ run(session) {
1185
+ return session.quit?.()
1186
+ },
1187
+ },
1188
+ // Help: presentation-only too. Each renderer intercepts `?` and owns its
1189
+ // own overlay cell, and routes the bar click the same way; the registry
1190
+ // entry exists so the bar and the `?` reference can advertise it. Hidden
1191
+ // from the panel, where it could only no-op.
1192
+ {
1193
+ id: "app.help",
1194
+ title: "Help",
1195
+ keys: "?",
1196
+ kind: "ui",
782
1197
  hidden: true,
783
1198
  run() {},
784
1199
  },
@@ -799,40 +1214,35 @@ function getCommand(id) {
799
1214
  return null
800
1215
  }
801
1216
 
802
- // Function: getPaletteCommands
803
- // Commands the command palette lists in UI mode: user-visible UI actions.
804
- function getPaletteCommands() {
805
- return commands.filter((command) => !command.hidden && (command.kind ?? "ui") === "ui")
806
- }
807
-
808
1217
  // Function: getCommandBarItems
809
- // Command-bar rows for the current context: the palette's key hints while it
810
- // is open (scripting mode has its own), the filter hints while a pane filter is
811
- // capturing input, the `bar`-flagged UI commands otherwise (keymap order).
812
- function getCommandBarItems(paletteOpen, paletteMode, filtering, bookmarkOpen, bookmarkMode, panelOpen) {
813
- if (panelOpen) return PANEL_HINT_KEYS
814
- if (bookmarkOpen) return bookmarkMode === "name" ? BOOKMARK_NAME_KEYS : BOOKMARK_LIST_KEYS
815
- if (paletteOpen) return paletteMode === "script" ? SCRIPT_KEYS : PALETTE_KEYS
1218
+ // Command-bar rows for the current context: the panel's key hints while it is
1219
+ // open, the bookmark-panel hints while that panel owns the keyboard, the
1220
+ // filter hints while a pane filter is capturing input, or the fixed
1221
+ // `BAR_COMMANDS` set otherwise, in display order. Takes the context as named
1222
+ // fields — four adjacent flags are too easy to transpose.
1223
+ function getCommandBarItems({ filtering, bookmarkOpen, panelOpen, pickerOpen, panelArmed, overlay }) {
1224
+ if (panelOpen) return pickerOpen ? PICKER_KEYS : panelArmed ? PANEL_CONFIRM_KEYS : PANEL_HINT_KEYS
1225
+ if (bookmarkOpen) return BOOKMARK_KEYS
1226
+ if (overlay === "history") return HISTORY_KEYS
1227
+ if (overlay === "trash") return TRASH_KEYS
816
1228
  if (filtering) return FILTER_KEYS
817
- return commands.filter((command) => command.bar).map(commandRow)
1229
+ return BAR_COMMANDS.map((id) => getCommand(id)).filter(Boolean).map(barRow)
818
1230
  }
819
1231
 
820
- // Function: commandRow
821
- // Projects a command to the small row object views render.
822
- function commandRow(command) {
1232
+ // Function: barRow
1233
+ // Projects a command to the small row object the command bar renders.
1234
+ function barRow(command) {
823
1235
  return {
824
1236
  id: command.id,
825
1237
  title: command.title,
826
1238
  keys: command.keys,
827
- kind: command.kind ?? "ui",
828
1239
  primary: command.primary === true,
829
- completion: command.completion ?? command.title,
830
1240
  }
831
1241
  }
832
1242
 
833
1243
  // Function: createDispatch
834
- // Returns `dispatch(id, payload)` bound to the session. Unknown and `app.*`
835
- // (presentation-only) commands are ignored here. Failures (sync throws and
1244
+ // Returns `dispatch(id, payload)` bound to the session. Unknown commands are
1245
+ // ignored here; `app.*` commands run their host hook. Failures (sync throws and
836
1246
  // rejections) land on the active pane's error cell and the session notice.
837
1247
  function createDispatch(session) {
838
1248
  return (id, payload) => {
@@ -854,8 +1264,33 @@ function fail(session, error) {
854
1264
  return null
855
1265
  }
856
1266
 
1267
+ // Function: isColumnPane
1268
+ // Whether the focused column is backed by a rendered pane. The parent column
1269
+ // only exists in explorer mode (focus is handed back on leaving it); the
1270
+ // inspector column only while it previews a directory — over a file it is a
1271
+ // read-only card with no listing to drive.
1272
+ function isColumnPane(session, paneId) {
1273
+ if (paneId !== "preview") return paneId !== "parent" || session.viewMode.value === "explorer"
1274
+ return Boolean(session.showPreview.value) && Boolean(session.previewEntry.value?.isDir) && !session.preview.value
1275
+ }
1276
+
1277
+ // Function: hasFilterField
1278
+ // Whether the focused column renders a filter input. The inspector column
1279
+ // never does — its header is the preview title — so `/` hands focus to the
1280
+ // pane it shows before capturing keystrokes, the same way a nav key falls
1281
+ // back to it.
1282
+ function hasFilterField(session, paneId) {
1283
+ return paneId !== "preview" && isColumnPane(session, paneId)
1284
+ }
1285
+
1286
+ // Function: pane
1287
+ // The pane cursor movement, marks and reload act on: the focused column's
1288
+ // pane when it has one, else the current pane (so the inspector over a file
1289
+ // keeps scrolling the directory it is showing).
857
1290
  function pane(session) {
858
- return session.panes[session.active.value]
1291
+ const focused = session.panes[session.active.value]
1292
+ if (!focused || !isColumnPane(session, session.active.value)) return currentPane(session)
1293
+ return focused
859
1294
  }
860
1295
 
861
1296
  // Function: activate
@@ -866,16 +1301,83 @@ function activate(session, paneId) {
866
1301
  if (session.active.value !== paneId) session.active.set(paneId)
867
1302
  }
868
1303
 
1304
+ // Function: retargetFromParent
1305
+ // A click on a row of the explorer's parent column retargets the current
1306
+ // directory: a directory becomes it, a file opens its containing directory
1307
+ // with the file selected so the inspector previews it. Both moves are
1308
+ // recorded, so Esc steps back to where the click came from.
1309
+ function retargetFromParent(session, parent, index) {
1310
+ const entries = parent.entries.value
1311
+ const entry = entries[clampCursor(index, entries.length)]
1312
+ if (!entry) return
1313
+ const current = currentPane(session)
1314
+ if (entry.isDir) return current.navigate(entry.path)
1315
+ return current
1316
+ .navigate(parent.path.value, { cursorPath: entry.path })
1317
+ .then(() => pinPreview(session, current))
1318
+ }
1319
+
1320
+ // Function: retargetFromPreview
1321
+ // A click on a row of the explorer's preview column promotes the previewed
1322
+ // directory to the current one, selecting the clicked entry: a directory then
1323
+ // previews its own contents (the next click descends further), a file previews
1324
+ // as a card.
1325
+ function retargetFromPreview(session, preview, index) {
1326
+ const entries = preview.entries.value
1327
+ const entry = entries[clampCursor(index, entries.length)]
1328
+ if (!entry) return
1329
+ const current = currentPane(session)
1330
+ return current.navigate(preview.path.value, { cursorPath: entry.path }).then(() => {
1331
+ if (!entry.isDir) pinPreview(session, current)
1332
+ })
1333
+ }
1334
+
1335
+ // Function: pinPreview
1336
+ // Points the inspector at the pane's cursor entry, so a file a click just
1337
+ // revealed previews even if the follow effect lags the navigation.
1338
+ function pinPreview(session, pane) {
1339
+ session.previewTarget.set(pane.currentEntry() ?? null)
1340
+ session.showPreview.set(true)
1341
+ }
1342
+
1343
+ // Function: openFile
1344
+ // Enter on a file. A text file the host can edit opens in `$EDITOR` and the
1345
+ // pane is refreshed afterwards (an editor may have rewritten it); anything else
1346
+ // — an image, media, a binary body, or any file on a host without an editor
1347
+ // (the web) — opens the fullscreen inspector instead.
1348
+ async function openFile(session, current, entry) {
1349
+ if (await isEditable(session, entry)) {
1350
+ await session.os.edit(entry.path)
1351
+ await current.refresh()
1352
+ return
1353
+ }
1354
+ session.previewTarget.set(entry)
1355
+ session.showPreview.set(true)
1356
+ session.viewerOpen.set(true)
1357
+ }
1358
+
1359
+ // Function: isEditable
1360
+ // Whether Enter edits `entry` rather than previewing it: the host must have an
1361
+ // editor, the entry must preview as text (not an image/media card) and its head
1362
+ // must not be binary. Compressed entries are never editable: the inspector
1363
+ // decompresses them, but `$EDITOR` would only see the compressed bytes. The
1364
+ // read follows symlinks, like the inspector's own, and needs only the sniff
1365
+ // window `hasBinaryByte` looks at.
1366
+ async function isEditable(session, entry) {
1367
+ if (entry?.isDir) return false
1368
+ if (session.os?.canEdit === false) return false
1369
+ const source = previewSource(entry)
1370
+ if (source.compression) return false
1371
+ if (previewKind(source.innerExt) !== "text") return false
1372
+ const head = await session.fs.readTextHead(entry.path, SNIFF_BYTES).catch(() => null)
1373
+ return head !== null && !hasBinaryByte(head)
1374
+ }
1375
+
869
1376
  function bump(session, delta) {
870
1377
  const p = pane(session)
871
1378
  p.cursor.set(moveCursor(p.cursor.value, delta, p.entries.value.length))
872
1379
  }
873
1380
 
874
- function movePalette(session, delta) {
875
- const length = session.paletteItems.value.length
876
- session.paletteIndex.set(moveCursor(session.paletteIndex.value, delta, length))
877
- }
878
-
879
1381
  function moveBookmark(session, delta) {
880
1382
  const length = session.bookmarkItems.value.length
881
1383
  session.bookmarkIndex.set(moveCursor(session.bookmarkIndex.value, delta, length))
@@ -885,46 +1387,262 @@ function closeBookmark(session) {
885
1387
  session.bookmarkOpen.set(false)
886
1388
  }
887
1389
 
888
- function openPanel(session) {
1390
+ // Function: addCurrentBookmark
1391
+ // Records the panel's target under an automatic name and closes the panel.
1392
+ // Closing happens before the async save so a second Enter cannot add twice.
1393
+ function addCurrentBookmark(session) {
1394
+ const target = session.bookmarkTarget.value
1395
+ if (!target) return
1396
+ closeBookmark(session)
1397
+ return session.bookmarks.add(target.path, target.isDir).then(({ name, added }) => {
1398
+ session.notice.set({ kind: "tip", text: added ? `bookmarked ${name}` : `bookmark ${name}` })
1399
+ })
1400
+ }
1401
+
1402
+ // Function: openHistory
1403
+ // Opens the history overlay: one row per journal entry, the cursor parked on
1404
+ // the current position. A row carries its journal `index` so activating it can
1405
+ // rewind/replay to that point.
1406
+ function openHistory(session) {
1407
+ const rows = session.journal.rows()
1408
+ session.overlayItems.set(
1409
+ rows.map((row) => ({
1410
+ key: `h:${row.id}`,
1411
+ text: row.label,
1412
+ meta: row.applied ? (row.current ? "current" : "applied") : "undone",
1413
+ index: row.index,
1414
+ current: row.current,
1415
+ dim: !row.applied,
1416
+ })),
1417
+ )
1418
+ const current = rows.findIndex((row) => row.current)
1419
+ session.overlayIndex.set(current >= 0 ? current : 0)
1420
+ session.overlay.set("history")
1421
+ }
1422
+
1423
+ // Function: openTrash
1424
+ // Opens the trash overlay on the current trash listing.
1425
+ async function openTrash(session) {
1426
+ await loadTrash(session)
1427
+ session.overlayIndex.set(0)
1428
+ session.overlay.set("trash")
1429
+ }
1430
+
1431
+ // Function: loadTrash
1432
+ // Refreshes the overlay rows from the trash listing. A row's key is its trash
1433
+ // path (`fs.restore` addresses entries by it) and its meta the original path.
1434
+ async function loadTrash(session) {
1435
+ const rows = (await session.fs.trashList?.()) ?? []
1436
+ session.overlayItems.set(
1437
+ rows.map((row) => ({
1438
+ key: row.trashPath,
1439
+ text: row.name,
1440
+ meta: row.originalPath ?? "",
1441
+ trashPath: row.trashPath,
1442
+ dim: false,
1443
+ })),
1444
+ )
1445
+ return rows
1446
+ }
1447
+
1448
+ // Function: activateHistory
1449
+ // Rewinds or replays the journal to `row.index`, refreshes the panes and closes
1450
+ // the overlay.
1451
+ async function activateHistory(session, row) {
1452
+ await session.journal.goTo(session, row.index)
1453
+ await refreshPanes(session)
1454
+ closeOverlay(session)
1455
+ session.notice.set({ kind: "tip", text: `history → ${row.text}` })
1456
+ }
1457
+
1458
+ // Function: activateTrash
1459
+ // Restores the trashed entry under the cursor, refreshes the trash list (the
1460
+ // restored row is gone), the panes and closes nothing (the panel stays open so
1461
+ // several entries can be restored in a row).
1462
+ async function activateTrash(session, row) {
1463
+ await session.fs.restore([row.trashPath])
1464
+ await loadTrash(session)
1465
+ session.overlayIndex.set(clampCursor(session.overlayIndex.value, session.overlayItems.value.length))
1466
+ await refreshPanes(session)
1467
+ session.notice.set({ kind: "tip", text: `restored ${row.meta || row.text}` })
1468
+ }
1469
+
1470
+ // Function: closeOverlay
1471
+ function closeOverlay(session) {
1472
+ session.overlay.set(null)
1473
+ }
1474
+
1475
+ // Function: focusedBinding
1476
+ // The draft binding under the right pane's cursor (an argument header or one
1477
+ // of its item rows), or null on the command filter.
1478
+ function focusedBinding(session) {
1479
+ if (session.panelFocus.value !== PANEL_FOCUS.ARGS) return null
1480
+ const row = rowAt(session.panelRows.value, session.panelRowIndex.value)
1481
+ return row ? bindingAt(session.panelDraft.value, row.argIndex) : null
1482
+ }
1483
+
1484
+ // Function: firstPickableBinding
1485
+ // The first browsable (`picker`) binding of the draft, so `^O` works from the
1486
+ // command filter too.
1487
+ function firstPickableBinding(session) {
1488
+ return (session.panelDraft.value ?? []).find((binding) => binding.picker === true) ?? null
1489
+ }
1490
+
1491
+ // Function: focusBinding
1492
+ // Moves the cursor onto the argument header row of the binding Enter found
1493
+ // missing.
1494
+ function focusBinding(session, binding) {
1495
+ const rowIndex = (session.panelRows.value ?? []).findIndex(
1496
+ (row) => row.type === "arg" && row.argIndex === binding.index,
1497
+ )
1498
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1499
+ session.panelRowIndex.set(rowIndex >= 0 ? rowIndex : 0)
1500
+ }
1501
+
1502
+ // Function: openPanel
1503
+ // Opens the command panel fresh: empty query, first entry selected, the entry
1504
+ // column focused. `options.name` selects a CLI operation by name — `!` lands on
1505
+ // `run` — and `options.focus` moves the focus ring to the argument column so a
1506
+ // raw or literal argument owns the keyboard right away.
1507
+ function openPanel(session, options = {}) {
889
1508
  session.panelQuery.set("")
890
1509
  session.panelOpIndex.set(0)
891
1510
  session.panelRowIndex.set(0)
892
1511
  session.panelFocus.set(PANEL_FOCUS.OPS)
1512
+ session.pickerOpen.set(false)
1513
+ session.pickerArg.set(-1)
1514
+ session.pickerPendingRun.set(false)
1515
+ session.panelRunError?.set(null)
893
1516
  session.panelOpen.set(true)
1517
+ if (!options.name) return
1518
+ // Select the operation by index rather than by query, so the filter stays
1519
+ // empty and the list keeps its full ordering. A missing name is a bug in
1520
+ // the caller (a renamed operation), not something to silently paper over by
1521
+ // landing on the first entry.
1522
+ const index = session.panelFiltered.value.findIndex((entry) => entry.name === options.name)
1523
+ if (index < 0) throw new Error(`command panel has no entry named "${options.name}"`)
1524
+ session.panelOpIndex.set(index)
1525
+ if (options.focus === PANEL_FOCUS.ARGS) {
1526
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1527
+ session.panelRowIndex.set(0)
1528
+ }
894
1529
  }
895
1530
 
896
- // Function: togglePanel
897
- // Opens the command panel, or closes it when already open. Disabled (TUI-only)
898
- // renderers ignore it.
899
- function togglePanel(session) {
900
- if (!session.panelEnabled?.value) return
901
- if (session.panelOpen.value) {
902
- closePanel(session)
903
- return
1531
+ // Function: pickerEntry
1532
+ // The entry under the picker cursor, or null (empty listing / stale cursor).
1533
+ function pickerEntry(session) {
1534
+ const rows = session.pickerRows.value
1535
+ return rows[clampCursor(session.panelRowIndex.value, rows.length)] ?? null
1536
+ }
1537
+
1538
+ // Function: openPicker
1539
+ // Opens the panel's file picker on a path argument: the argument column
1540
+ // switches to a browsable listing seated on the argument's current value (a
1541
+ // directory opens there, a file opens in its parent with the file under the
1542
+ // cursor), else on the current pane's directory. Focus moves to the argument
1543
+ // column so the picker's own input owns the keyboard.
1544
+ async function openPicker(session, argIndex, binding) {
1545
+ session.pickerPaths.invalidate()
1546
+ const mode = binding?.mode ?? "dir"
1547
+ session.pickerMode.set(mode)
1548
+ session.pickerArg.set(argIndex)
1549
+ session.pickerQuery.set("")
1550
+ session.pickerOpen.set(true)
1551
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1552
+ let startPath = currentPane(session)?.path?.value ?? session.fs.cwd?.() ?? "/"
1553
+ let cursorPath = null
1554
+ const value = `${binding?.value ?? ""}`.trim()
1555
+ if (value) {
1556
+ const stats = await session.fs.stat(value)
1557
+ if (stats?.isDir) startPath = value
1558
+ else {
1559
+ startPath = parentOf(value) ?? "/"
1560
+ cursorPath = value
1561
+ }
904
1562
  }
905
- openPanel(session)
1563
+ session.pickerBasePath.set(startPath)
1564
+ session.pickerFocus.set("path")
1565
+ await session.pickerPane.navigate(startPath, { record: false, cursorPath })
1566
+ session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
1567
+ const rows = session.pickerRows.value
1568
+ const index = cursorPath ? rows.findIndex((row) => row.path === cursorPath) : -1
1569
+ session.panelRowIndex.set(index >= 0 ? index : 0)
1570
+ }
1571
+
1572
+ // Function: descendPicker
1573
+ // Moves the picker into `path`, clearing its filter and re-anchoring the
1574
+ // cursor at the top of the fresh listing.
1575
+ async function descendPicker(session, path) {
1576
+ if (!path) return undefined
1577
+ session.pickerPaths.invalidate()
1578
+ session.pickerQuery.set("")
1579
+ await session.pickerPane.navigate(path, { record: false })
1580
+ session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
1581
+ session.pickerFocus.set("rows")
1582
+ session.panelRowIndex.set(0)
1583
+ return undefined
1584
+ }
1585
+
1586
+ // Function: choosePicker
1587
+ // Accepts `path` as the picked value: writes it into the argument, leaves the
1588
+ // picker (the panel stays open on the argument column) and, when the picker was
1589
+ // opened to satisfy a pending run, runs the operation now.
1590
+ function choosePicker(session, path) {
1591
+ if (path == null) return undefined
1592
+ session.pickerPaths.invalidate()
1593
+ const argIndex = session.pickerArg.value
1594
+ session.panelDraft.set(withArgValue(session.panelDraft.value, argIndex, path))
1595
+ session.pickerOpen.set(false)
1596
+ session.pickerArg.set(-1)
1597
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1598
+ session.pickerFocus.set("path")
1599
+ const rows = session.panelRows.value
1600
+ const index = rows.findIndex((row) => row.type === "arg" && row.argIndex === argIndex)
1601
+ session.panelRowIndex.set(index >= 0 ? index : 0)
1602
+ if (session.pickerPendingRun.value) {
1603
+ session.pickerPendingRun.set(false)
1604
+ return session.dispatch("panel.run")
1605
+ }
1606
+ return undefined
906
1607
  }
907
1608
 
908
1609
  function closePanel(session) {
1610
+ session.pickerPaths.invalidate()
909
1611
  session.panelOpen.set(false)
1612
+ session.panelRunError?.set(null)
910
1613
  }
911
1614
 
912
- function openPalette(session, mode, query) {
913
- session.paletteMode.set(mode)
914
- session.paletteQuery.set(query)
915
- session.paletteIndex.set(0)
916
- session.paletteOpen.set(true)
1615
+ // Keep an operation error in the panel action row rather than duplicating it
1616
+ // in the command bar underneath the modal.
1617
+ function keepPanelError(session) {
1618
+ const notice = session.notice?.value
1619
+ session.panelRunError?.set(notice?.text ?? "The command failed")
1620
+ session.notice?.set(null)
1621
+ const runIndex = session.panelRows?.value?.findIndex((row) => row.type === "run") ?? -1
1622
+ if (runIndex >= 0) {
1623
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1624
+ session.panelRowIndex.set(runIndex)
1625
+ }
917
1626
  }
918
1627
 
919
- function closePalette(session) {
920
- session.paletteOpen.set(false)
1628
+ // Successful panel results briefly replace the command bar's normal hints.
1629
+ // A later dispatch can replace the notice first; the identity check prevents
1630
+ // this timer from clearing that newer status.
1631
+ function showBriefPanelResult(session) {
1632
+ const notice = session.notice?.value
1633
+ if (!notice || notice.kind === "error") return
1634
+ const timer = setTimeout(() => {
1635
+ if (session.notice?.value === notice) session.notice.set(null)
1636
+ }, 2500)
1637
+ timer?.unref?.()
921
1638
  }
922
1639
 
923
1640
  export {
924
- commandRow,
1641
+ barRow,
1642
+ closePanel,
925
1643
  createDispatch,
926
1644
  getCommand,
927
1645
  getCommandBarItems,
928
1646
  getCommands,
929
- getPaletteCommands,
1647
+ openPanel,
930
1648
  }