@ssebastien/tcmd 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +121 -38
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/package.json +4 -3
  5. package/public/index.html +14 -0
  6. package/public/main.js +17501 -0
  7. package/public/style.css +901 -0
  8. package/scripts/serve-web.js +61 -0
  9. package/src/js/tcmd/components/App.html +28 -12
  10. package/src/js/tcmd/components/App.js +135 -85
  11. package/src/js/tcmd/components/App.xml +64 -15
  12. package/src/js/tcmd/components/BookmarkList.html +3 -2
  13. package/src/js/tcmd/components/BookmarkList.js +1 -1
  14. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  15. package/src/js/tcmd/components/CommandArgRow.html +17 -0
  16. package/src/js/tcmd/components/CommandArgRow.js +86 -26
  17. package/src/js/tcmd/components/CommandArgRow.xml +31 -4
  18. package/src/js/tcmd/components/CommandBar.html +1 -1
  19. package/src/js/tcmd/components/CommandBar.js +3 -3
  20. package/src/js/tcmd/components/CommandItem.html +1 -1
  21. package/src/js/tcmd/components/CommandItem.js +1 -0
  22. package/src/js/tcmd/components/CommandPanel.html +29 -0
  23. package/src/js/tcmd/components/CommandPanel.js +54 -52
  24. package/src/js/tcmd/components/CommandPanel.xml +38 -24
  25. package/src/js/tcmd/components/Confirm.html +1 -1
  26. package/src/js/tcmd/components/EntryDetail.html +7 -7
  27. package/src/js/tcmd/components/EntryDetail.js +1 -1
  28. package/src/js/tcmd/components/FileList.html +5 -1
  29. package/src/js/tcmd/components/FileList.js +2 -1
  30. package/src/js/tcmd/components/FilePreview.html +14 -3
  31. package/src/js/tcmd/components/FilePreview.js +73 -10
  32. package/src/js/tcmd/components/FilePreview.xml +19 -6
  33. package/src/js/tcmd/components/FileRow.html +1 -1
  34. package/src/js/tcmd/components/FileRow.js +10 -3
  35. package/src/js/tcmd/components/FilterBar.html +1 -1
  36. package/src/js/tcmd/components/FilterBar.js +14 -4
  37. package/src/js/tcmd/components/FilterBar.xml +1 -1
  38. package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
  39. package/src/js/tcmd/components/Help.html +7 -0
  40. package/src/js/tcmd/components/Help.js +109 -0
  41. package/src/js/tcmd/components/Summary.html +1 -1
  42. package/src/js/tcmd/components/Summary.js +1 -6
  43. package/src/js/tcmd/components/icons.js +39 -5
  44. package/src/js/tcmd/components/index.js +3 -4
  45. package/src/js/tcmd/components/rows.js +37 -33
  46. package/src/js/tcmd/core/commands.js +422 -180
  47. package/src/js/tcmd/core/directory.js +145 -95
  48. package/src/js/tcmd/core/fs.js +29 -2
  49. package/src/js/tcmd/core/history.js +58 -0
  50. package/src/js/tcmd/core/index.js +3 -9
  51. package/src/js/tcmd/core/keymap.js +3 -3
  52. package/src/js/tcmd/core/operations.js +108 -15
  53. package/src/js/tcmd/core/pane.js +31 -8
  54. package/src/js/tcmd/core/panel.js +172 -82
  55. package/src/js/tcmd/core/script.js +7 -95
  56. package/src/js/tcmd/core/selection.js +9 -1
  57. package/src/js/tcmd/core/session.js +280 -152
  58. package/src/js/tcmd/index.js +0 -1
  59. package/src/js/tcmd/tui/app.js +26 -5
  60. package/src/js/tcmd/tui/catalogue.js +6 -19
  61. package/src/js/tcmd/tui/index.js +1 -16
  62. package/src/js/tcmd/tui/theme.js +5 -0
  63. package/src/js/tcmd/web/app.js +158 -32
  64. package/src/js/tcmd/web/bookmarks.js +36 -0
  65. package/src/js/tcmd/web/catalogue.js +45 -39
  66. package/src/js/tcmd/web/effects.js +143 -0
  67. package/src/js/tcmd/web/header.html +1 -1
  68. package/src/js/tcmd/web/header.js +15 -11
  69. package/src/js/tcmd/web/main.js +6 -66
  70. package/src/js/tcmd/web/seed.js +3 -3
  71. package/src/js/tcmd/web/server.js +6 -4
  72. package/src/js/tcmd/web/theme.js +1 -0
  73. package/src/js/tcmd/components/CommandPalette.html +0 -7
  74. package/src/js/tcmd/components/CommandPalette.js +0 -58
  75. package/src/js/tcmd/components/CommandPalette.xml +0 -16
  76. package/src/js/tcmd/components/PaletteRow.html +0 -3
  77. package/src/js/tcmd/components/PaletteRow.js +0 -43
  78. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  79. package/src/js/tcmd/tui/help.js +0 -32
@@ -10,14 +10,15 @@
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).
13
+ // - `kind`: "ui" for UI-level actions; the command panel lists the visible UI
14
+ // actions alongside the CLI operations (see core/panel.js).
15
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.
16
+ // - `hidden`: true for internal commands that never show in the panel.
17
17
 
18
18
  import { basename, parent as parentOf } from "./path.js"
19
- import { PANEL_FOCUS, argvFor, bindingAt, missingArg, rowAt, withToggledItem } from "./panel.js"
19
+ import { PANEL_FOCUS, argvFor, bindingAt, missingBinding, rowAt, withArgValue, withToggledItem } from "./panel.js"
20
20
  import { clampCursor, moveCursor } from "./selection.js"
21
+ import { currentPane } from "./pane.js"
21
22
  import { runLine } from "./script.js"
22
23
  import { getOperation, runOperation } from "./operations.js"
23
24
 
@@ -27,24 +28,6 @@ import { getOperation, runOperation } from "./operations.js"
27
28
  const PAGE = 10
28
29
  const JUMP = 5
29
30
 
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
31
  // Constant: FILTER_KEYS
49
32
  // Command-bar hints shown while a pane filter is capturing input.
50
33
  const FILTER_KEYS = [
@@ -73,11 +56,24 @@ const BOOKMARK_LIST_KEYS = [
73
56
  const PANEL_HINT_KEYS = [
74
57
  { id: null, title: "Command", keys: "↑/↓" },
75
58
  { id: null, title: "Arg", keys: "⇥" },
59
+ { id: null, title: "Browse", keys: "^O" },
76
60
  { id: null, title: "Toggle", keys: "Spc" },
77
61
  { id: null, title: "Run", keys: "↵", primary: true },
78
62
  { id: null, title: "Close", keys: "Esc" },
79
63
  ]
80
64
 
65
+ // Constant: PANEL_PICK_KEYS
66
+ // Command-bar hints while the panel's file picker owns the keyboard.
67
+ const PANEL_PICK_KEYS = [
68
+ { id: null, title: "Filter", keys: "type" },
69
+ { id: null, title: "Row", keys: "↑/↓" },
70
+ { id: null, title: "Open", keys: "→" },
71
+ { id: null, title: "Choose", keys: "↵", primary: true },
72
+ { id: null, title: "Here", keys: "^↵" },
73
+ { id: null, title: "Up", keys: "⌫" },
74
+ { id: null, title: "Back", keys: "Esc" },
75
+ ]
76
+
81
77
  const commands = [
82
78
  {
83
79
  id: "nav.up",
@@ -159,7 +155,21 @@ const commands = [
159
155
  bar: true,
160
156
  primary: true,
161
157
  run(session) {
162
- return pane(session).open()
158
+ // The entry under the *focused* pane's cursor decides where the
159
+ // current directory goes: a directory descends into it. On an
160
+ // auxiliary column (parent/preview) that means the current pane
161
+ // follows the selection, which is what keeps the mirror in step.
162
+ const focused = pane(session)
163
+ const entry = focused.currentEntry()
164
+ if (!entry) return
165
+ const current = currentPane(session)
166
+ if (entry.isDir) return current.navigate(entry.path)
167
+ // A file outside the current pane pins the inspector on it.
168
+ if (focused !== current) {
169
+ session.previewTarget.set(entry)
170
+ session.showPreview.set(true)
171
+ }
172
+ return undefined
163
173
  },
164
174
  },
165
175
  {
@@ -169,7 +179,10 @@ const commands = [
169
179
  kind: "ui",
170
180
  bar: true,
171
181
  run(session) {
172
- return pane(session).up()
182
+ // Stepping up always moves the current directory: the parent
183
+ // column mirrors it, so navigating that column itself would
184
+ // break the mirror.
185
+ return currentPane(session).up()
173
186
  },
174
187
  },
175
188
  {
@@ -178,7 +191,7 @@ const commands = [
178
191
  keys: "Esc",
179
192
  kind: "ui",
180
193
  run(session) {
181
- return pane(session).back()
194
+ return currentPane(session).back()
182
195
  },
183
196
  },
184
197
  {
@@ -187,7 +200,7 @@ const commands = [
187
200
  keys: "⇧Esc",
188
201
  kind: "ui",
189
202
  run(session) {
190
- return pane(session).forward()
203
+ return currentPane(session).forward()
191
204
  },
192
205
  },
193
206
  {
@@ -207,7 +220,23 @@ const commands = [
207
220
  kind: "ui",
208
221
  bar: true,
209
222
  run(session) {
210
- session.active.set(session.active.value === "left" ? "right" : "left")
223
+ // Cycle through the columns the current view mode shows:
224
+ // source ⇄ destination in commander, parent → current → preview
225
+ // in explorer. Focus parked on a column that is not in the cycle
226
+ // (the inspector in commander) lands on the other side.
227
+ const current = session.currentId.value
228
+ const order =
229
+ session.viewMode.value === "explorer"
230
+ ? session.showPreview.value
231
+ ? ["parent", current, "preview"]
232
+ : ["parent", current]
233
+ : ["left", "right"]
234
+ const at = order.indexOf(session.active.value)
235
+ const next =
236
+ at >= 0
237
+ ? order[(at + 1) % order.length]
238
+ : order.find((paneId) => paneId !== current) ?? order[0]
239
+ session.active.set(next)
211
240
  },
212
241
  },
213
242
  {
@@ -321,7 +350,7 @@ const commands = [
321
350
  const items = session.bookmarkItems.value
322
351
  const item = items[clampCursor(session.bookmarkIndex.value, items.length)]
323
352
  if (!item) return
324
- const target = session.panes[session.active.value]
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
@@ -353,13 +382,24 @@ const commands = [
353
382
  },
354
383
  {
355
384
  id: "list.cursor",
356
- title: "Set cursor",
385
+ title: "Select row",
357
386
  kind: "ui",
358
387
  hidden: true,
359
388
  run(session, payload) {
360
389
  const target = payload?.paneId ? session.panes[payload.paneId] : pane(session)
361
390
  activate(session, payload?.paneId)
362
- target.cursor.set(clampCursor(payload?.index ?? 0, target.entries.value.length))
391
+ const index = payload?.index ?? 0
392
+ target.cursor.set(clampCursor(index, target.entries.value.length))
393
+ // In the explorer the parent and preview columns are mirrors: a
394
+ // click on one of their rows retargets the current directory
395
+ // instead of only moving that column's cursor (see
396
+ // `retargetFromParent` / `retargetFromPreview`). Focus stays on the
397
+ // clicked column; the pane body's own `pane:activate` agrees.
398
+ if (session.viewMode.value !== "explorer") return
399
+ if (payload?.paneId === "parent") return retargetFromParent(session, target, index)
400
+ if (payload?.paneId === "preview" && isColumnPane(session, "preview")) {
401
+ return retargetFromPreview(session, target, index)
402
+ }
363
403
  },
364
404
  },
365
405
  {
@@ -377,9 +417,11 @@ const commands = [
377
417
  kind: "ui",
378
418
  hidden: true,
379
419
  run(session, payload) {
380
- const target = payload?.paneId ? session.panes[payload.paneId] : pane(session)
420
+ // A breadcrumb click focuses its column, then lands the current
421
+ // directory there: the auxiliary columns mirror it, so their
422
+ // crumbs navigate the pane they follow.
381
423
  activate(session, payload?.paneId)
382
- return target.navigate(payload?.path)
424
+ return currentPane(session).navigate(payload?.path)
383
425
  },
384
426
  },
385
427
  {
@@ -392,6 +434,33 @@ const commands = [
392
434
  session.showPreview.set(!session.showPreview.value)
393
435
  },
394
436
  },
437
+ {
438
+ id: "view.maximize",
439
+ title: "Maximize preview",
440
+ keys: "⇧V",
441
+ kind: "ui",
442
+ run(session) {
443
+ // The inspector takes the whole pane row, or gives it back. The
444
+ // preference cells are untouched, so the previous layout — pane
445
+ // widths, focus and whether the preview was even shown — returns
446
+ // untouched on the next press.
447
+ session.previewFull.set(!session.previewFull.value)
448
+ },
449
+ },
450
+ {
451
+ id: "view.toggleMode",
452
+ title: "Explorer view",
453
+ keys: "t",
454
+ kind: "ui",
455
+ bar: true,
456
+ run(session) {
457
+ // Flip the workspace between the two-pane commander and the
458
+ // three-column explorer (parent · current · preview). Focus on a
459
+ // column that does not exist in the target mode is handed back to
460
+ // the pane it was following (see the guard effect in the session).
461
+ session.viewMode.set(session.viewMode.value === "explorer" ? "commander" : "explorer")
462
+ },
463
+ },
395
464
  {
396
465
  id: "file.open",
397
466
  title: "Open file",
@@ -414,36 +483,33 @@ const commands = [
414
483
  session.notice.set({ kind: "warning", text: "no file to edit" })
415
484
  return
416
485
  }
417
- return session.os.edit(entry.path).then(() => session.panes[session.active.value].refresh())
486
+ return session.os.edit(entry.path).then(() => currentPane(session).refresh())
418
487
  },
419
488
  },
420
- // Command panel (`:`/`m`): the structured Commando view over the
421
- // operations. Lifecycle, then navigation, then draft editing, then run.
489
+ // Command panel (`:`): the one entry point, over both the CLI operations
490
+ // and the TinyCmd actions. Lifecycle, then navigation, then draft editing,
491
+ // then run.
422
492
  {
423
- id: "panel.toggle",
493
+ id: "panel.open",
424
494
  title: "Commands",
425
- keys: "m",
495
+ keys: ":",
426
496
  kind: "ui",
427
497
  bar: true,
428
498
  run(session) {
429
- togglePanel(session)
499
+ openPanel(session)
430
500
  },
431
501
  },
432
502
  {
433
- id: "panel.open",
434
- title: "Commands",
435
- keys: ":",
503
+ id: "panel.shell",
504
+ title: "Shell command",
436
505
  kind: "ui",
437
506
  hidden: true,
438
507
  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)
508
+ // `!` is the "run a shell command" reflex: land on the `run`
509
+ // operation with its raw CMD field already owning the keyboard. The
510
+ // field starts empty — `!` is a script-line alias, not part of the
511
+ // command itself.
512
+ openPanel(session, { name: "run", focus: PANEL_FOCUS.ARGS })
447
513
  },
448
514
  },
449
515
  {
@@ -492,8 +558,9 @@ const commands = [
492
558
  kind: "ui",
493
559
  hidden: true,
494
560
  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.
561
+ // Tab cycles the focus ring: the command filter then each argument
562
+ // header, wrapping at both ends (an item row is reached with the
563
+ // arrows). Shift-Tab walks it backwards.
497
564
  const delta = payload?.delta === -1 ? -1 : 1
498
565
  const rows = session.panelRows.value
499
566
  const headers = []
@@ -577,165 +644,194 @@ const commands = [
577
644
  if (!row) return
578
645
  const draft = session.panelDraft.value
579
646
  if (row.type === "item") {
580
- session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex))
647
+ session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex, row.multi !== false))
581
648
  return
582
649
  }
583
650
  // An argument header toggles its whole selection: select all when
584
- // some are off, clear when all are on.
651
+ // some are off, clear when all are on. A single-path argument is a
652
+ // radio list, so its header has nothing to toggle.
585
653
  const binding = bindingAt(draft, row.argIndex)
586
- if (!binding || binding.kind !== "selection") return
654
+ if (!binding || binding.kind !== "selection" || binding.multi !== true) return
587
655
  const all = (binding.items ?? []).length > 0 && (binding.items ?? []).every((item) => item.selected)
588
656
  const items = (binding.items ?? []).map((item) => ({ ...item, selected: !all }))
589
657
  session.panelDraft.set(draft.map((b) => (b.index === row.argIndex ? { ...b, items } : b)))
590
658
  },
591
659
  },
660
+ // File picker: a sub-mode of the panel that browses the filesystem for a
661
+ // path/destination argument. Lifecycle then navigation then choose.
592
662
  {
593
- id: "panel.run",
594
- title: "Run command",
663
+ id: "panel.browse",
664
+ title: "Browse for a path",
595
665
  kind: "ui",
596
666
  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}»` })
606
- return
607
- }
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)
667
+ run(session) {
668
+ // Browse the focused argument when it is pickable, else the first
669
+ // pickable argument of the operation; a no-op when there is none.
670
+ const focused = focusedBinding(session)
671
+ const binding = focused?.picker === true ? focused : firstPickableBinding(session)
672
+ if (!binding) return undefined
673
+ return openPicker(session, binding.index, binding)
612
674
  },
613
675
  },
614
676
  {
615
- id: "panel.cancel",
616
- title: "Close command panel",
677
+ id: "panel.pick.up",
678
+ title: "Previous picker row",
617
679
  kind: "ui",
618
680
  hidden: true,
619
681
  run(session) {
620
- closePanel(session)
682
+ const length = session.pickerRows.value.length
683
+ session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, -1, length))
621
684
  },
622
685
  },
623
686
  {
624
- id: "palette.toggle",
625
- title: "Palette",
626
- keys: "^P",
687
+ id: "panel.pick.down",
688
+ title: "Next picker row",
627
689
  kind: "ui",
628
- bar: true,
690
+ hidden: true,
629
691
  run(session) {
630
- if (session.paletteOpen.value) {
631
- closePalette(session)
632
- return
633
- }
634
- openPalette(session, "ui", "")
692
+ const length = session.pickerRows.value.length
693
+ session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, 1, length))
635
694
  },
636
695
  },
637
696
  {
638
- id: "palette.script",
639
- title: "Script",
697
+ id: "panel.pick.setCursor",
698
+ title: "Highlight picker row",
640
699
  kind: "ui",
641
- run(session) {
642
- openPalette(session, "script", "")
700
+ hidden: true,
701
+ run(session, payload) {
702
+ if (payload?.index !== undefined) session.panelRowIndex.set(Math.max(0, payload.index))
643
703
  },
644
704
  },
645
705
  {
646
- id: "palette.run",
647
- title: "Run command",
648
- keys: "!",
706
+ id: "panel.pick.enter",
707
+ title: "Choose picker row",
649
708
  kind: "ui",
709
+ hidden: true,
650
710
  run(session) {
651
- openPalette(session, "script", "!")
711
+ const entry = pickerEntry(session)
712
+ if (!entry) return undefined
713
+ // A directory descends unless this is a destination picker, where
714
+ // every row is already a valid choice.
715
+ if (entry.isDir && session.panelPickerMode.value !== "dir") {
716
+ return descendPicker(session, entry.path)
717
+ }
718
+ return choosePicker(session, entry.path)
652
719
  },
653
720
  },
654
721
  {
655
- id: "filter.start",
656
- title: "Filter",
657
- keys: "/",
722
+ id: "panel.pick.descend",
723
+ title: "Open the picker's directory",
658
724
  kind: "ui",
659
- bar: true,
725
+ hidden: true,
660
726
  run(session) {
661
- session.filtering.set(session.active.value)
727
+ const entry = pickerEntry(session)
728
+ if (entry?.isDir) return descendPicker(session, entry.path)
729
+ return undefined
662
730
  },
663
731
  },
664
732
  {
665
- id: "filter.accept",
666
- title: "Keep filter",
733
+ id: "panel.pick.parent",
734
+ title: "Picker parent directory",
667
735
  kind: "ui",
668
736
  hidden: true,
669
- run(session) {
670
- session.filtering.set(null)
737
+ async run(session) {
738
+ session.pickerQuery.set("")
739
+ await session.pickerPane.up()
740
+ session.panelRowIndex.set(0)
671
741
  },
672
742
  },
673
743
  {
674
- id: "filter.clear",
675
- title: "Clear filter",
744
+ id: "panel.pick.chooseDir",
745
+ title: "Choose the picker's directory",
676
746
  kind: "ui",
677
747
  hidden: true,
678
748
  run(session) {
679
- pane(session).clearFilter()
680
- session.filtering.set(null)
749
+ return choosePicker(session, session.pickerPane.path.value)
681
750
  },
682
751
  },
683
752
  {
684
- id: "palette.up",
685
- title: "Previous command",
753
+ id: "panel.pick.cancel",
754
+ title: "Leave the picker",
686
755
  kind: "ui",
687
756
  hidden: true,
688
757
  run(session) {
689
- movePalette(session, -1)
758
+ // Back to the argument column, panel still open.
759
+ session.panelPicking.set(false)
760
+ session.panelPickerArg.set(-1)
761
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
762
+ session.panelRowIndex.set(0)
690
763
  },
691
764
  },
692
765
  {
693
- id: "palette.down",
694
- title: "Next command",
766
+ id: "panel.run",
767
+ title: "Run command",
695
768
  kind: "ui",
696
769
  hidden: true,
697
- run(session) {
698
- movePalette(session, 1)
770
+ async run(session) {
771
+ const entry = session.panelEntry.value
772
+ if (!entry) return
773
+ const op = entry.op
774
+ const draft = session.panelDraft.value
775
+ // An incomplete command does not fail: a blank destination/path
776
+ // asks where to (the picker opens and the run resumes on a choice —
777
+ // explorer mode has no DST pane to default from), and any other
778
+ // missing argument just takes the cursor, so Enter walks the
779
+ // command field by field.
780
+ const missing = missingBinding(draft)
781
+ if (missing) {
782
+ if (missing.kind === "dest" || missing.kind === "path") {
783
+ session.panelPendingRun.set(true)
784
+ return openPicker(session, missing.index, missing)
785
+ }
786
+ focusBinding(session, missing)
787
+ return
788
+ }
789
+ const status = await runOperation(session, op, argvFor(draft))
790
+ // A confirm keeps the panel open until the user answers; a run
791
+ // closes it. Errors stay open so the draft can be fixed.
792
+ if (status === "ran") closePanel(session)
699
793
  },
700
794
  },
701
795
  {
702
- id: "palette.complete",
703
- title: "Complete",
796
+ id: "panel.cancel",
797
+ title: "Close command panel",
704
798
  kind: "ui",
705
799
  hidden: true,
706
800
  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)
801
+ closePanel(session)
802
+ },
803
+ },
804
+ {
805
+ id: "filter.start",
806
+ title: "Filter",
807
+ keys: "/",
808
+ kind: "ui",
809
+ bar: true,
810
+ run(session) {
811
+ // A column with no filter field (the inspector, or a column the
812
+ // current mode does not show) hands focus to the pane it follows
813
+ // before the input starts capturing keystrokes.
814
+ if (!hasFilterField(session, session.active.value)) activate(session, session.currentId.value)
815
+ session.filtering.set(session.active.value)
710
816
  },
711
817
  },
712
818
  {
713
- id: "palette.setQuery",
714
- title: "Set palette query",
819
+ id: "filter.accept",
820
+ title: "Keep filter",
715
821
  kind: "ui",
716
822
  hidden: true,
717
- run(session, payload) {
718
- session.paletteQuery.set(`${payload?.query ?? ""}`)
823
+ run(session) {
824
+ session.filtering.set(null)
719
825
  },
720
826
  },
721
827
  {
722
- id: "palette.submit",
723
- title: "Submit palette",
828
+ id: "filter.clear",
829
+ title: "Clear filter",
724
830
  kind: "ui",
725
831
  hidden: true,
726
832
  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)
733
- return
734
- }
735
- return runLine(session, query).then((status) => {
736
- if (status !== "empty") closePalette(session)
737
- return status
738
- })
833
+ pane(session).clearFilter()
834
+ session.filtering.set(null)
739
835
  },
740
836
  },
741
837
  {
@@ -772,7 +868,7 @@ const commands = [
772
868
  // Quit: presentation-only. The key path intercepts `app.quit` before
773
869
  // dispatch, and the command-bar click does the same (the renderer owns
774
870
  // 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.
871
+ // can advertise it. Hidden from the panel, where it could only no-op.
776
872
  {
777
873
  id: "app.quit",
778
874
  title: "Quit",
@@ -799,34 +895,27 @@ function getCommand(id) {
799
895
  return null
800
896
  }
801
897
 
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
898
  // 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
899
+ // Command-bar rows for the current context: the panel's key hints while it is
900
+ // open, the filter hints while a pane filter is capturing input, the bookmark
901
+ // hints while the picker owns the keyboard, the `bar`-flagged UI commands
902
+ // otherwise (keymap order). Takes the context as named fields — four adjacent
903
+ // flags are too easy to transpose.
904
+ function getCommandBarItems({ filtering, bookmarkOpen, bookmarkMode, panelOpen, panelPicking }) {
905
+ if (panelOpen) return panelPicking ? PANEL_PICK_KEYS : PANEL_HINT_KEYS
814
906
  if (bookmarkOpen) return bookmarkMode === "name" ? BOOKMARK_NAME_KEYS : BOOKMARK_LIST_KEYS
815
- if (paletteOpen) return paletteMode === "script" ? SCRIPT_KEYS : PALETTE_KEYS
816
907
  if (filtering) return FILTER_KEYS
817
- return commands.filter((command) => command.bar).map(commandRow)
908
+ return commands.filter((command) => command.bar).map(barRow)
818
909
  }
819
910
 
820
- // Function: commandRow
821
- // Projects a command to the small row object views render.
822
- function commandRow(command) {
911
+ // Function: barRow
912
+ // Projects a command to the small row object the command bar renders.
913
+ function barRow(command) {
823
914
  return {
824
915
  id: command.id,
825
916
  title: command.title,
826
917
  keys: command.keys,
827
- kind: command.kind ?? "ui",
828
918
  primary: command.primary === true,
829
- completion: command.completion ?? command.title,
830
919
  }
831
920
  }
832
921
 
@@ -854,8 +943,33 @@ function fail(session, error) {
854
943
  return null
855
944
  }
856
945
 
946
+ // Function: isColumnPane
947
+ // Whether the focused column is backed by a rendered pane. The parent column
948
+ // only exists in explorer mode (focus is handed back on leaving it); the
949
+ // inspector column only while it previews a directory — over a file it is a
950
+ // read-only card with no listing to drive.
951
+ function isColumnPane(session, paneId) {
952
+ if (paneId !== "preview") return paneId !== "parent" || session.viewMode.value === "explorer"
953
+ return Boolean(session.showPreview.value) && Boolean(session.previewEntry.value?.isDir) && !session.preview.value
954
+ }
955
+
956
+ // Function: hasFilterField
957
+ // Whether the focused column renders a filter input. The inspector column
958
+ // never does — its header is the preview title — so `/` hands focus to the
959
+ // pane it shows before capturing keystrokes, the same way a nav key falls
960
+ // back to it.
961
+ function hasFilterField(session, paneId) {
962
+ return paneId !== "preview" && isColumnPane(session, paneId)
963
+ }
964
+
965
+ // Function: pane
966
+ // The pane cursor movement, marks and reload act on: the focused column's
967
+ // pane when it has one, else the current pane (so the inspector over a file
968
+ // keeps scrolling the directory it is showing).
857
969
  function pane(session) {
858
- return session.panes[session.active.value]
970
+ const focused = session.panes[session.active.value]
971
+ if (!focused || !isColumnPane(session, session.active.value)) return currentPane(session)
972
+ return focused
859
973
  }
860
974
 
861
975
  // Function: activate
@@ -866,16 +980,50 @@ function activate(session, paneId) {
866
980
  if (session.active.value !== paneId) session.active.set(paneId)
867
981
  }
868
982
 
983
+ // Function: retargetFromParent
984
+ // A click on a row of the explorer's parent column retargets the current
985
+ // directory: a directory becomes it, a file opens its containing directory
986
+ // with the file selected so the inspector previews it. Both moves are
987
+ // recorded, so Esc steps back to where the click came from.
988
+ function retargetFromParent(session, parent, index) {
989
+ const entries = parent.entries.value
990
+ const entry = entries[clampCursor(index, entries.length)]
991
+ if (!entry) return
992
+ const current = currentPane(session)
993
+ if (entry.isDir) return current.navigate(entry.path)
994
+ return current
995
+ .navigate(parent.path.value, { cursorPath: entry.path })
996
+ .then(() => pinPreview(session, current))
997
+ }
998
+
999
+ // Function: retargetFromPreview
1000
+ // A click on a row of the explorer's preview column promotes the previewed
1001
+ // directory to the current one, selecting the clicked entry: a directory then
1002
+ // previews its own contents (the next click descends further), a file previews
1003
+ // as a card.
1004
+ function retargetFromPreview(session, preview, index) {
1005
+ const entries = preview.entries.value
1006
+ const entry = entries[clampCursor(index, entries.length)]
1007
+ if (!entry) return
1008
+ const current = currentPane(session)
1009
+ return current.navigate(preview.path.value, { cursorPath: entry.path }).then(() => {
1010
+ if (!entry.isDir) pinPreview(session, current)
1011
+ })
1012
+ }
1013
+
1014
+ // Function: pinPreview
1015
+ // Points the inspector at the pane's cursor entry, so a file a click just
1016
+ // revealed previews even if the follow effect lags the navigation.
1017
+ function pinPreview(session, pane) {
1018
+ session.previewTarget.set(pane.currentEntry() ?? null)
1019
+ session.showPreview.set(true)
1020
+ }
1021
+
869
1022
  function bump(session, delta) {
870
1023
  const p = pane(session)
871
1024
  p.cursor.set(moveCursor(p.cursor.value, delta, p.entries.value.length))
872
1025
  }
873
1026
 
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
1027
  function moveBookmark(session, delta) {
880
1028
  const length = session.bookmarkItems.value.length
881
1029
  session.bookmarkIndex.set(moveCursor(session.bookmarkIndex.value, delta, length))
@@ -885,46 +1033,140 @@ function closeBookmark(session) {
885
1033
  session.bookmarkOpen.set(false)
886
1034
  }
887
1035
 
888
- function openPanel(session) {
1036
+ // Function: focusedBinding
1037
+ // The draft binding under the right pane's cursor (an argument header or one
1038
+ // of its item rows), or null on the command filter.
1039
+ function focusedBinding(session) {
1040
+ if (session.panelFocus.value !== PANEL_FOCUS.ARGS) return null
1041
+ const row = rowAt(session.panelRows.value, session.panelRowIndex.value)
1042
+ return row ? bindingAt(session.panelDraft.value, row.argIndex) : null
1043
+ }
1044
+
1045
+ // Function: firstPickableBinding
1046
+ // The first browsable (`picker`) binding of the draft, so `^O` works from the
1047
+ // command filter too.
1048
+ function firstPickableBinding(session) {
1049
+ return (session.panelDraft.value ?? []).find((binding) => binding.picker === true) ?? null
1050
+ }
1051
+
1052
+ // Function: focusBinding
1053
+ // Moves the cursor onto the argument header row of the binding Enter found
1054
+ // missing.
1055
+ function focusBinding(session, binding) {
1056
+ const rowIndex = (session.panelRows.value ?? []).findIndex(
1057
+ (row) => row.type === "arg" && row.argIndex === binding.index,
1058
+ )
1059
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1060
+ session.panelRowIndex.set(rowIndex >= 0 ? rowIndex : 0)
1061
+ }
1062
+
1063
+ // Function: openPanel
1064
+ // Opens the command panel fresh: empty query, first entry selected, the entry
1065
+ // column focused. `options.name` selects a CLI operation by name — `!` lands on
1066
+ // `run` — and `options.focus` moves the focus ring to the argument column so a
1067
+ // raw or literal argument owns the keyboard right away.
1068
+ function openPanel(session, options = {}) {
889
1069
  session.panelQuery.set("")
890
1070
  session.panelOpIndex.set(0)
891
1071
  session.panelRowIndex.set(0)
892
1072
  session.panelFocus.set(PANEL_FOCUS.OPS)
1073
+ session.panelPicking.set(false)
1074
+ session.panelPickerArg.set(-1)
1075
+ session.panelPendingRun.set(false)
893
1076
  session.panelOpen.set(true)
1077
+ if (!options.name) return
1078
+ // Select the operation by index rather than by query, so the filter stays
1079
+ // empty and the list keeps its full ordering. A missing name is a bug in
1080
+ // the caller (a renamed operation), not something to silently paper over by
1081
+ // landing on the first entry.
1082
+ const index = session.panelFiltered.value.findIndex((entry) => entry.name === options.name)
1083
+ if (index < 0) throw new Error(`command panel has no entry named "${options.name}"`)
1084
+ session.panelOpIndex.set(index)
1085
+ if (options.focus === PANEL_FOCUS.ARGS) {
1086
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1087
+ session.panelRowIndex.set(0)
1088
+ }
894
1089
  }
895
1090
 
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
1091
+ // Function: pickerEntry
1092
+ // The entry under the picker cursor, or null (empty listing / stale cursor).
1093
+ function pickerEntry(session) {
1094
+ const rows = session.pickerRows.value
1095
+ return rows[clampCursor(session.panelRowIndex.value, rows.length)] ?? null
1096
+ }
1097
+
1098
+ // Function: openPicker
1099
+ // Opens the panel's file picker on a path argument: the argument column
1100
+ // switches to a browsable listing seated on the argument's current value (a
1101
+ // directory opens there, a file opens in its parent with the file under the
1102
+ // cursor), else on the current pane's directory. Focus moves to the argument
1103
+ // column so the picker's own input owns the keyboard.
1104
+ async function openPicker(session, argIndex, binding) {
1105
+ const mode = binding?.mode ?? "dir"
1106
+ session.panelPickerMode.set(mode)
1107
+ session.panelPickerArg.set(argIndex)
1108
+ session.pickerQuery.set("")
1109
+ session.panelPicking.set(true)
1110
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1111
+ let startPath = currentPane(session)?.path?.value ?? session.fs.cwd?.() ?? "/"
1112
+ let cursorPath = null
1113
+ const value = `${binding?.value ?? ""}`.trim()
1114
+ if (value) {
1115
+ const stats = await session.fs.stat(value)
1116
+ if (stats?.isDir) startPath = value
1117
+ else {
1118
+ startPath = parentOf(value) ?? "/"
1119
+ cursorPath = value
1120
+ }
904
1121
  }
905
- openPanel(session)
1122
+ await session.pickerPane.navigate(startPath, { record: false, cursorPath })
1123
+ const rows = session.pickerRows.value
1124
+ const index = cursorPath ? rows.findIndex((row) => row.path === cursorPath) : -1
1125
+ session.panelRowIndex.set(index >= 0 ? index : 0)
906
1126
  }
907
1127
 
908
- function closePanel(session) {
909
- session.panelOpen.set(false)
1128
+ // Function: descendPicker
1129
+ // Moves the picker into `path`, clearing its filter and re-anchoring the
1130
+ // cursor at the top of the fresh listing.
1131
+ async function descendPicker(session, path) {
1132
+ if (!path) return undefined
1133
+ session.pickerQuery.set("")
1134
+ await session.pickerPane.navigate(path, { record: false })
1135
+ session.panelRowIndex.set(0)
1136
+ return undefined
910
1137
  }
911
1138
 
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)
1139
+ // Function: choosePicker
1140
+ // Accepts `path` as the picked value: writes it into the argument, leaves the
1141
+ // picker (the panel stays open on the argument column) and, when the picker was
1142
+ // opened to satisfy a pending run, runs the operation now.
1143
+ function choosePicker(session, path) {
1144
+ if (path == null) return undefined
1145
+ const argIndex = session.panelPickerArg.value
1146
+ session.panelDraft.set(withArgValue(session.panelDraft.value, argIndex, path))
1147
+ session.panelPicking.set(false)
1148
+ session.panelPickerArg.set(-1)
1149
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
1150
+ const rows = session.panelRows.value
1151
+ const index = rows.findIndex((row) => row.type === "arg" && row.argIndex === argIndex)
1152
+ session.panelRowIndex.set(index >= 0 ? index : 0)
1153
+ if (session.panelPendingRun.value) {
1154
+ session.panelPendingRun.set(false)
1155
+ return session.dispatch("panel.run")
1156
+ }
1157
+ return undefined
917
1158
  }
918
1159
 
919
- function closePalette(session) {
920
- session.paletteOpen.set(false)
1160
+ function closePanel(session) {
1161
+ session.panelOpen.set(false)
921
1162
  }
922
1163
 
923
1164
  export {
924
- commandRow,
1165
+ barRow,
1166
+ closePanel,
925
1167
  createDispatch,
926
1168
  getCommand,
927
1169
  getCommandBarItems,
928
1170
  getCommands,
929
- getPaletteCommands,
1171
+ openPanel,
930
1172
  }