@ssebastien/tcmd 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. package/tinyjs.json +16 -0
@@ -10,17 +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 panel lists the visible UI
14
- // actions alongside the CLI operations (see core/panel.js).
15
- // - `bar`: true when the command appears in the command bar's key hints.
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
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"
20
+ import { parent as parentOf } from "./path.js"
19
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"
21
25
  import { currentPane } from "./pane.js"
22
26
  import { runLine } from "./script.js"
23
- import { getOperation, runOperation } from "./operations.js"
27
+ import { getOperation, refreshPanes, runOperation } from "./operations.js"
24
28
 
25
29
  // Constant: PAGE / JUMP
26
30
  // Cursor movement steps: a page for PgUp/PgDn, a shorter jump for the
@@ -36,18 +40,12 @@ const FILTER_KEYS = [
36
40
  { id: null, title: "Clear", keys: "Esc" },
37
41
  ]
38
42
 
39
- // Constant: BOOKMARK_NAME_KEYS
40
- // Command-bar hints while naming a bookmark.
41
- const BOOKMARK_NAME_KEYS = [
42
- { id: null, title: "Save", keys: "Enter", primary: true },
43
- { id: null, title: "Cancel", keys: "Esc" },
44
- ]
45
-
46
- // Constant: BOOKMARK_LIST_KEYS
47
- // Command-bar hints while picking a bookmark.
48
- const BOOKMARK_LIST_KEYS = [
43
+ // Constant: BOOKMARK_KEYS
44
+ // Command-bar hints while the bookmarks panel owns the keyboard.
45
+ const BOOKMARK_KEYS = [
49
46
  { id: null, title: "Navigate", keys: "↑/↓" },
50
- { id: null, title: "Jump", keys: "Enter", primary: true },
47
+ { id: null, title: "Remove", keys: "⌫/Del" },
48
+ { id: null, title: "Open", keys: "Enter", primary: true },
51
49
  { id: null, title: "Close", keys: "Esc" },
52
50
  ]
53
51
 
@@ -62,18 +60,52 @@ const PANEL_HINT_KEYS = [
62
60
  { id: null, title: "Close", keys: "Esc" },
63
61
  ]
64
62
 
65
- // Constant: PANEL_PICK_KEYS
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
66
70
  // Command-bar hints while the panel's file picker owns the keyboard.
67
- const PANEL_PICK_KEYS = [
68
- { id: null, title: "Filter", keys: "type" },
71
+ const PICKER_KEYS = [
72
+ { id: null, title: "Path", keys: "type" },
69
73
  { id: null, title: "Row", keys: "↑/↓" },
70
74
  { id: null, title: "Open", keys: "→" },
71
- { id: null, title: "Choose", keys: "↵", primary: true },
75
+ { id: null, title: "Go", keys: "↵", primary: true },
72
76
  { id: null, title: "Here", keys: "^↵" },
73
- { id: null, title: "Up", keys: "⌫" },
77
+ { id: null, title: "Focus", keys: "⇥" },
74
78
  { id: null, title: "Back", keys: "Esc" },
75
79
  ]
76
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
+
77
109
  const commands = [
78
110
  {
79
111
  id: "nav.up",
@@ -152,7 +184,6 @@ const commands = [
152
184
  title: "Open",
153
185
  keys: "Enter",
154
186
  kind: "ui",
155
- bar: true,
156
187
  primary: true,
157
188
  run(session) {
158
189
  // The entry under the *focused* pane's cursor decides where the
@@ -164,12 +195,15 @@ const commands = [
164
195
  if (!entry) return
165
196
  const current = currentPane(session)
166
197
  if (entry.isDir) return current.navigate(entry.path)
167
- // A file outside the current pane pins the inspector on it.
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.
168
200
  if (focused !== current) {
169
201
  session.previewTarget.set(entry)
170
202
  session.showPreview.set(true)
171
203
  }
172
- return undefined
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)
173
207
  },
174
208
  },
175
209
  {
@@ -177,7 +211,6 @@ const commands = [
177
211
  title: "Parent",
178
212
  keys: "←",
179
213
  kind: "ui",
180
- bar: true,
181
214
  run(session) {
182
215
  // Stepping up always moves the current directory: the parent
183
216
  // column mirrors it, so navigating that column itself would
@@ -206,9 +239,8 @@ const commands = [
206
239
  {
207
240
  id: "pane.reload",
208
241
  title: "Reload",
209
- keys: "^R",
242
+ keys: "F5/r",
210
243
  kind: "ui",
211
- bar: true,
212
244
  run(session) {
213
245
  return pane(session).refresh()
214
246
  },
@@ -218,7 +250,6 @@ const commands = [
218
250
  title: "Switch",
219
251
  keys: "Tab",
220
252
  kind: "ui",
221
- bar: true,
222
253
  run(session) {
223
254
  // Cycle through the columns the current view mode shows:
224
255
  // source ⇄ destination in commander, parent → current → preview
@@ -244,46 +275,24 @@ const commands = [
244
275
  title: "Mark",
245
276
  keys: "Spc",
246
277
  kind: "ui",
247
- bar: true,
248
278
  run(session) {
249
279
  pane(session).toggleMark()
250
280
  },
251
281
  },
252
282
  {
253
- id: "bookmark.record",
254
- title: "Bookmark",
283
+ id: "bookmark.open",
284
+ title: "Bookmarks",
255
285
  keys: "b",
256
286
  kind: "ui",
257
- bar: true,
258
287
  run(session) {
259
- // Record the entry under the cursor; fall back to the pane's
260
- // 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.
261
290
  const entry = pane(session).currentEntry()
262
291
  const path = entry?.path ?? pane(session).path.value
263
292
  const isDir = entry ? Boolean(entry.isDir) : true
264
- const existing = session.bookmarks.find(path)
265
- if (existing) {
266
- // Already bookmarked: no need to re-name it.
267
- session.notice.set({ kind: "tip", text: `bookmark ${existing.name}` })
268
- return
269
- }
270
- // The default name is the path's basename.
271
- session.bookmarkTarget.set({ path, isDir })
272
- session.bookmarkQuery.set(basename(path))
273
- session.bookmarkMode.set("name")
274
- session.bookmarkOpen.set(true)
275
- },
276
- },
277
- {
278
- id: "bookmark.jump",
279
- title: "Bookmarks",
280
- keys: "g",
281
- kind: "ui",
282
- bar: true,
283
- run(session) {
284
- session.bookmarkTarget.set(null)
285
293
  session.bookmarkQuery.set("")
286
- session.bookmarkMode.set("list")
294
+ session.bookmarkIndex.set(0)
295
+ session.bookmarkTarget.set({ path, isDir })
287
296
  session.bookmarkOpen.set(true)
288
297
  },
289
298
  },
@@ -306,30 +315,19 @@ const commands = [
306
315
  },
307
316
  },
308
317
  {
309
- id: "bookmark.save",
310
- title: "Save bookmark",
318
+ id: "bookmark.remove",
319
+ title: "Remove bookmark",
311
320
  kind: "ui",
312
321
  hidden: true,
313
- run(session) {
314
- const name = `${session.bookmarkQuery.value ?? ""}`.trim()
315
- const target = session.bookmarkTarget.value
316
- if (!name) {
317
- session.notice.set({ kind: "warning", text: "bookmark needs a name" })
318
- return
319
- }
320
- if (!target) {
321
- closeBookmark(session)
322
- return
323
- }
324
- // Close synchronously: the global key handler and the input's
325
- // `on:enter` can both fire, and a second save while the overlay is
326
- // still open would re-record and flash the notice twice. A rejected
327
- // `record` (rejected name) still surfaces through the dispatch's
328
- // error notice.
329
- closeBookmark(session)
330
- return session.bookmarks.record(name, target.path, target.isDir).then(() => {
331
- session.notice.set({ kind: "tip", text: `bookmarked ${name}` })
332
- })
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}` })
333
331
  },
334
332
  },
335
333
  {
@@ -350,6 +348,8 @@ const commands = [
350
348
  const items = session.bookmarkItems.value
351
349
  const item = items[clampCursor(session.bookmarkIndex.value, items.length)]
352
350
  if (!item) return
351
+ // The add row records the current target instead of navigating.
352
+ if (item.add) return addCurrentBookmark(session)
353
353
  const target = currentPane(session)
354
354
  const path = item.path
355
355
  // Resolve the kind when it is unknown (legacy path-only bookmark).
@@ -380,6 +380,125 @@ const commands = [
380
380
  closeBookmark(session)
381
381
  },
382
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
+ },
383
502
  {
384
503
  id: "list.cursor",
385
504
  title: "Select row",
@@ -429,11 +548,23 @@ const commands = [
429
548
  title: "Preview",
430
549
  keys: "v",
431
550
  kind: "ui",
432
- bar: true,
433
551
  run(session) {
434
552
  session.showPreview.set(!session.showPreview.value)
435
553
  },
436
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
+ },
437
568
  {
438
569
  id: "view.maximize",
439
570
  title: "Maximize preview",
@@ -447,12 +578,26 @@ const commands = [
447
578
  session.previewFull.set(!session.previewFull.value)
448
579
  },
449
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
+ },
450
596
  {
451
597
  id: "view.toggleMode",
452
- title: "Explorer view",
598
+ title: "Toggle Mode",
453
599
  keys: "t",
454
600
  kind: "ui",
455
- bar: true,
456
601
  run(session) {
457
602
  // Flip the workspace between the two-pane commander and the
458
603
  // three-column explorer (parent · current · preview). Focus on a
@@ -483,6 +628,12 @@ const commands = [
483
628
  session.notice.set({ kind: "warning", text: "no file to edit" })
484
629
  return
485
630
  }
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
+ }
486
637
  return session.os.edit(entry.path).then(() => currentPane(session).refresh())
487
638
  },
488
639
  },
@@ -494,7 +645,6 @@ const commands = [
494
645
  title: "Commands",
495
646
  keys: ":",
496
647
  kind: "ui",
497
- bar: true,
498
648
  run(session) {
499
649
  openPanel(session)
500
650
  },
@@ -558,27 +708,27 @@ const commands = [
558
708
  kind: "ui",
559
709
  hidden: true,
560
710
  run(session, payload) {
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.
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.
564
714
  const delta = payload?.delta === -1 ? -1 : 1
565
715
  const rows = session.panelRows.value
566
- const headers = []
567
- for (let i = 0; i < rows.length; i++) if (rows[i].type === "arg") headers.push(i)
568
- 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
569
719
  if (session.panelFocus.value === PANEL_FOCUS.OPS) {
570
- session.panelRowIndex.set(delta > 0 ? headers[0] : headers[headers.length - 1])
720
+ session.panelRowIndex.set(delta > 0 ? stops[0] : stops[stops.length - 1])
571
721
  session.panelFocus.set(PANEL_FOCUS.ARGS)
572
722
  return
573
723
  }
574
- const at = headers.indexOf(session.panelRowIndex.value)
724
+ const at = stops.indexOf(session.panelRowIndex.value)
575
725
  if (delta > 0) {
576
- if (at === -1 || at >= headers.length - 1) session.panelFocus.set(PANEL_FOCUS.OPS)
577
- 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])
578
728
  } else if (at <= 0) {
579
729
  session.panelFocus.set(PANEL_FOCUS.OPS)
580
730
  } else {
581
- session.panelRowIndex.set(headers[at - 1])
731
+ session.panelRowIndex.set(stops[at - 1])
582
732
  }
583
733
  },
584
734
  },
@@ -634,6 +784,31 @@ const commands = [
634
784
  session.panelFocus.set(PANEL_FOCUS.ARGS)
635
785
  },
636
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
+ },
637
812
  {
638
813
  id: "panel.toggleItem",
639
814
  title: "Toggle argument item",
@@ -647,16 +822,43 @@ const commands = [
647
822
  session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex, row.multi !== false))
648
823
  return
649
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))
828
+ return
829
+ }
650
830
  // An argument header toggles its whole selection: select all when
651
831
  // some are off, clear when all are on. A single-path argument is a
652
832
  // radio list, so its header has nothing to toggle.
653
833
  const binding = bindingAt(draft, row.argIndex)
834
+ // Only a selection argument mass-toggles from its header; a
835
+ // permission grid has no safe "all" state and stays per-bit.
654
836
  if (!binding || binding.kind !== "selection" || binding.multi !== true) return
655
837
  const all = (binding.items ?? []).length > 0 && (binding.items ?? []).every((item) => item.selected)
656
838
  const items = (binding.items ?? []).map((item) => ({ ...item, selected: !all }))
657
839
  session.panelDraft.set(draft.map((b) => (b.index === row.argIndex ? { ...b, items } : b)))
658
840
  },
659
841
  },
842
+ {
843
+ id: "panel.copy",
844
+ title: "Copy argument value",
845
+ kind: "ui",
846
+ hidden: true,
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" })
856
+ return
857
+ }
858
+ session.notice?.set({ kind: "tip", text: `copied ${text}` })
859
+ showBriefPanelResult(session)
860
+ },
861
+ },
660
862
  // File picker: a sub-mode of the panel that browses the filesystem for a
661
863
  // path/destination argument. Lifecycle then navigation then choose.
662
864
  {
@@ -674,74 +876,153 @@ const commands = [
674
876
  },
675
877
  },
676
878
  {
677
- id: "panel.pick.up",
879
+ id: "picker.up",
678
880
  title: "Previous picker row",
679
881
  kind: "ui",
680
882
  hidden: true,
681
883
  run(session) {
682
884
  const length = session.pickerRows.value.length
885
+ session.pickerFocus.set("rows")
683
886
  session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, -1, length))
684
887
  },
685
888
  },
686
889
  {
687
- id: "panel.pick.down",
890
+ id: "picker.down",
688
891
  title: "Next picker row",
689
892
  kind: "ui",
690
893
  hidden: true,
691
894
  run(session) {
692
895
  const length = session.pickerRows.value.length
896
+ session.pickerFocus.set("rows")
693
897
  session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, 1, length))
694
898
  },
695
899
  },
696
900
  {
697
- id: "panel.pick.setCursor",
901
+ id: "picker.setCursor",
698
902
  title: "Highlight picker row",
699
903
  kind: "ui",
700
904
  hidden: true,
701
905
  run(session, payload) {
702
906
  if (payload?.index !== undefined) session.panelRowIndex.set(Math.max(0, payload.index))
907
+ session.pickerFocus.set("rows")
908
+ },
909
+ },
910
+ {
911
+ id: "picker.focusPath",
912
+ title: "Edit the picker path",
913
+ kind: "ui",
914
+ hidden: true,
915
+ run(session) {
916
+ session.pickerFocus.set("path")
917
+ },
918
+ },
919
+ {
920
+ id: "picker.focusRows",
921
+ title: "Focus the picker entries",
922
+ kind: "ui",
923
+ hidden: true,
924
+ run(session) {
925
+ session.pickerFocus.set("rows")
926
+ },
927
+ },
928
+ {
929
+ id: "picker.type",
930
+ title: "Type into the picker path",
931
+ kind: "ui",
932
+ hidden: true,
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")
940
+ },
941
+ },
942
+ {
943
+ id: "picker.path",
944
+ title: "Go to the typed picker path",
945
+ kind: "ui",
946
+ hidden: true,
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)
703
978
  },
704
979
  },
705
980
  {
706
- id: "panel.pick.enter",
981
+ id: "picker.enter",
707
982
  title: "Choose picker row",
708
983
  kind: "ui",
709
984
  hidden: true,
710
985
  run(session) {
711
986
  const entry = pickerEntry(session)
712
987
  if (!entry) return undefined
988
+ if (entry.type === "parent") return session.dispatch("picker.parent")
713
989
  // A directory descends unless this is a destination picker, where
714
990
  // every row is already a valid choice.
715
- if (entry.isDir && session.panelPickerMode.value !== "dir") {
991
+ if (entry.isDir && session.pickerMode.value !== "dir") {
716
992
  return descendPicker(session, entry.path)
717
993
  }
718
994
  return choosePicker(session, entry.path)
719
995
  },
720
996
  },
721
997
  {
722
- id: "panel.pick.descend",
998
+ id: "picker.descend",
723
999
  title: "Open the picker's directory",
724
1000
  kind: "ui",
725
1001
  hidden: true,
726
1002
  run(session) {
727
1003
  const entry = pickerEntry(session)
1004
+ if (entry?.type === "parent") return session.dispatch("picker.parent")
728
1005
  if (entry?.isDir) return descendPicker(session, entry.path)
729
1006
  return undefined
730
1007
  },
731
1008
  },
732
1009
  {
733
- id: "panel.pick.parent",
1010
+ id: "picker.parent",
734
1011
  title: "Picker parent directory",
735
1012
  kind: "ui",
736
1013
  hidden: true,
737
1014
  async run(session) {
1015
+ session.pickerPaths.invalidate()
738
1016
  session.pickerQuery.set("")
1017
+ const cameFrom = session.pickerPane.path.value
739
1018
  await session.pickerPane.up()
740
- session.panelRowIndex.set(0)
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)
741
1022
  },
742
1023
  },
743
1024
  {
744
- id: "panel.pick.chooseDir",
1025
+ id: "picker.chooseDir",
745
1026
  title: "Choose the picker's directory",
746
1027
  kind: "ui",
747
1028
  hidden: true,
@@ -750,15 +1031,17 @@ const commands = [
750
1031
  },
751
1032
  },
752
1033
  {
753
- id: "panel.pick.cancel",
1034
+ id: "picker.cancel",
754
1035
  title: "Leave the picker",
755
1036
  kind: "ui",
756
1037
  hidden: true,
757
1038
  run(session) {
758
1039
  // Back to the argument column, panel still open.
759
- session.panelPicking.set(false)
760
- session.panelPickerArg.set(-1)
1040
+ session.pickerPaths.invalidate()
1041
+ session.pickerOpen.set(false)
1042
+ session.pickerArg.set(-1)
761
1043
  session.panelFocus.set(PANEL_FOCUS.ARGS)
1044
+ session.pickerFocus.set("path")
762
1045
  session.panelRowIndex.set(0)
763
1046
  },
764
1047
  },
@@ -770,6 +1053,10 @@ const commands = [
770
1053
  async run(session) {
771
1054
  const entry = session.panelEntry.value
772
1055
  if (!entry) return
1056
+ if (entry.commandId) {
1057
+ closePanel(session)
1058
+ return session.dispatch(entry.commandId)
1059
+ }
773
1060
  const op = entry.op
774
1061
  const draft = session.panelDraft.value
775
1062
  // An incomplete command does not fail: a blank destination/path
@@ -780,16 +1067,32 @@ const commands = [
780
1067
  const missing = missingBinding(draft)
781
1068
  if (missing) {
782
1069
  if (missing.kind === "dest" || missing.kind === "path") {
783
- session.panelPendingRun.set(true)
1070
+ session.pickerPendingRun.set(true)
784
1071
  return openPicker(session, missing.index, missing)
785
1072
  }
786
1073
  focusBinding(session, missing)
787
1074
  return
788
1075
  }
789
- const status = await runOperation(session, op, argvFor(draft))
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)
1083
+ return
1084
+ }
1085
+ const status = await runOperation(session, op, argvFor(draft), {
1086
+ panel: true,
1087
+ ...(risk ? { confirmed: true } : {}),
1088
+ })
790
1089
  // A confirm keeps the panel open until the user answers; a run
791
1090
  // closes it. Errors stay open so the draft can be fixed.
792
- if (status === "ran") closePanel(session)
1091
+ if (status === "error") keepPanelError(session)
1092
+ else if (status === "ran") {
1093
+ showBriefPanelResult(session)
1094
+ closePanel(session)
1095
+ }
793
1096
  },
794
1097
  },
795
1098
  {
@@ -806,7 +1109,6 @@ const commands = [
806
1109
  title: "Filter",
807
1110
  keys: "/",
808
1111
  kind: "ui",
809
- bar: true,
810
1112
  run(session) {
811
1113
  // A column with no filter field (the inspector, or a column the
812
1114
  // current mode does not show) hands focus to the pane it follows
@@ -849,7 +1151,11 @@ const commands = [
849
1151
  // A panel-originated confirm re-runs its draft and, on success,
850
1152
  // closes the panel that was waiting on it.
851
1153
  return runOperation(session, op, pending.args, { confirmed: true }).then((status) => {
852
- 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
+ }
853
1159
  return status
854
1160
  })
855
1161
  }
@@ -865,16 +1171,29 @@ const commands = [
865
1171
  session.confirm.set(null)
866
1172
  },
867
1173
  },
868
- // Quit: presentation-only. The key path intercepts `app.quit` before
869
- // dispatch, and the command-bar click does the same (the renderer owns
870
- // exiting); the registry entry exists so the bar and the `?` reference
871
- // can advertise it. Hidden from the panel, 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.
872
1178
  {
873
1179
  id: "app.quit",
874
1180
  title: "Quit",
875
1181
  keys: "q",
876
1182
  kind: "ui",
877
- 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",
878
1197
  hidden: true,
879
1198
  run() {},
880
1199
  },
@@ -897,15 +1216,17 @@ function getCommand(id) {
897
1216
 
898
1217
  // Function: getCommandBarItems
899
1218
  // 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
906
- if (bookmarkOpen) return bookmarkMode === "name" ? BOOKMARK_NAME_KEYS : BOOKMARK_LIST_KEYS
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
907
1228
  if (filtering) return FILTER_KEYS
908
- return commands.filter((command) => command.bar).map(barRow)
1229
+ return BAR_COMMANDS.map((id) => getCommand(id)).filter(Boolean).map(barRow)
909
1230
  }
910
1231
 
911
1232
  // Function: barRow
@@ -920,8 +1241,8 @@ function barRow(command) {
920
1241
  }
921
1242
 
922
1243
  // Function: createDispatch
923
- // Returns `dispatch(id, payload)` bound to the session. Unknown and `app.*`
924
- // (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
925
1246
  // rejections) land on the active pane's error cell and the session notice.
926
1247
  function createDispatch(session) {
927
1248
  return (id, payload) => {
@@ -1019,6 +1340,39 @@ function pinPreview(session, pane) {
1019
1340
  session.showPreview.set(true)
1020
1341
  }
1021
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
+
1022
1376
  function bump(session, delta) {
1023
1377
  const p = pane(session)
1024
1378
  p.cursor.set(moveCursor(p.cursor.value, delta, p.entries.value.length))
@@ -1033,6 +1387,91 @@ function closeBookmark(session) {
1033
1387
  session.bookmarkOpen.set(false)
1034
1388
  }
1035
1389
 
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
+
1036
1475
  // Function: focusedBinding
1037
1476
  // The draft binding under the right pane's cursor (an argument header or one
1038
1477
  // of its item rows), or null on the command filter.
@@ -1070,9 +1509,10 @@ function openPanel(session, options = {}) {
1070
1509
  session.panelOpIndex.set(0)
1071
1510
  session.panelRowIndex.set(0)
1072
1511
  session.panelFocus.set(PANEL_FOCUS.OPS)
1073
- session.panelPicking.set(false)
1074
- session.panelPickerArg.set(-1)
1075
- session.panelPendingRun.set(false)
1512
+ session.pickerOpen.set(false)
1513
+ session.pickerArg.set(-1)
1514
+ session.pickerPendingRun.set(false)
1515
+ session.panelRunError?.set(null)
1076
1516
  session.panelOpen.set(true)
1077
1517
  if (!options.name) return
1078
1518
  // Select the operation by index rather than by query, so the filter stays
@@ -1102,11 +1542,12 @@ function pickerEntry(session) {
1102
1542
  // cursor), else on the current pane's directory. Focus moves to the argument
1103
1543
  // column so the picker's own input owns the keyboard.
1104
1544
  async function openPicker(session, argIndex, binding) {
1545
+ session.pickerPaths.invalidate()
1105
1546
  const mode = binding?.mode ?? "dir"
1106
- session.panelPickerMode.set(mode)
1107
- session.panelPickerArg.set(argIndex)
1547
+ session.pickerMode.set(mode)
1548
+ session.pickerArg.set(argIndex)
1108
1549
  session.pickerQuery.set("")
1109
- session.panelPicking.set(true)
1550
+ session.pickerOpen.set(true)
1110
1551
  session.panelFocus.set(PANEL_FOCUS.ARGS)
1111
1552
  let startPath = currentPane(session)?.path?.value ?? session.fs.cwd?.() ?? "/"
1112
1553
  let cursorPath = null
@@ -1119,7 +1560,10 @@ async function openPicker(session, argIndex, binding) {
1119
1560
  cursorPath = value
1120
1561
  }
1121
1562
  }
1563
+ session.pickerBasePath.set(startPath)
1564
+ session.pickerFocus.set("path")
1122
1565
  await session.pickerPane.navigate(startPath, { record: false, cursorPath })
1566
+ session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
1123
1567
  const rows = session.pickerRows.value
1124
1568
  const index = cursorPath ? rows.findIndex((row) => row.path === cursorPath) : -1
1125
1569
  session.panelRowIndex.set(index >= 0 ? index : 0)
@@ -1130,8 +1574,11 @@ async function openPicker(session, argIndex, binding) {
1130
1574
  // cursor at the top of the fresh listing.
1131
1575
  async function descendPicker(session, path) {
1132
1576
  if (!path) return undefined
1577
+ session.pickerPaths.invalidate()
1133
1578
  session.pickerQuery.set("")
1134
1579
  await session.pickerPane.navigate(path, { record: false })
1580
+ session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
1581
+ session.pickerFocus.set("rows")
1135
1582
  session.panelRowIndex.set(0)
1136
1583
  return undefined
1137
1584
  }
@@ -1142,23 +1589,52 @@ async function descendPicker(session, path) {
1142
1589
  // opened to satisfy a pending run, runs the operation now.
1143
1590
  function choosePicker(session, path) {
1144
1591
  if (path == null) return undefined
1145
- const argIndex = session.panelPickerArg.value
1592
+ session.pickerPaths.invalidate()
1593
+ const argIndex = session.pickerArg.value
1146
1594
  session.panelDraft.set(withArgValue(session.panelDraft.value, argIndex, path))
1147
- session.panelPicking.set(false)
1148
- session.panelPickerArg.set(-1)
1595
+ session.pickerOpen.set(false)
1596
+ session.pickerArg.set(-1)
1149
1597
  session.panelFocus.set(PANEL_FOCUS.ARGS)
1598
+ session.pickerFocus.set("path")
1150
1599
  const rows = session.panelRows.value
1151
1600
  const index = rows.findIndex((row) => row.type === "arg" && row.argIndex === argIndex)
1152
1601
  session.panelRowIndex.set(index >= 0 ? index : 0)
1153
- if (session.panelPendingRun.value) {
1154
- session.panelPendingRun.set(false)
1602
+ if (session.pickerPendingRun.value) {
1603
+ session.pickerPendingRun.set(false)
1155
1604
  return session.dispatch("panel.run")
1156
1605
  }
1157
1606
  return undefined
1158
1607
  }
1159
1608
 
1160
1609
  function closePanel(session) {
1610
+ session.pickerPaths.invalidate()
1161
1611
  session.panelOpen.set(false)
1612
+ session.panelRunError?.set(null)
1613
+ }
1614
+
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
+ }
1626
+ }
1627
+
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?.()
1162
1638
  }
1163
1639
 
1164
1640
  export {