@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
@@ -18,15 +18,23 @@
18
18
 
19
19
  import { cell, derived, effect } from "select/state/cells"
20
20
  import { createBookmarks } from "./bookmarks.js"
21
+ import { createJournal } from "./journal.js"
21
22
  import { createDispatch, getCommandBarItems } from "./commands.js"
22
23
  import { createNullOs } from "./os.js"
23
- import { PANEL_FOCUS, argvFor, bindArgs, filterPanelEntries, isTextualRow, panelCommand, panelEntries, panelEntryRows, panelRowList, panelRunnable, pickerRowList, rowAt, withArgValue } from "./panel.js"
24
- import { basename, extname, join, parent as parentPath } from "./path.js"
24
+ import { PANEL_FOCUS, argvFor, bindArgs, filterPanelEntries, isTextualRow, panelCommand, panelEntries, panelEntryRows, panelRowList, panelRunnable, rowAt, withArgValue } from "./panel.js"
25
+ import { createPickerPathQueue, pickerRowList, pickerVisibleEntries } from "./picker.js"
26
+ import { basename, parent as parentPath } from "./path.js"
27
+ import { hasBinaryByte, PREVIEW_BYTES, previewKind, previewSource } from "./preview.js"
28
+ import { compressedBytes, decompressHead } from "./decompress.js"
25
29
  import { indexOfPath } from "./selection.js"
26
30
  import { createPane, PANE_BUFFERS } from "./pane.js"
27
31
  import { hasMatch, score } from "../vendor/fzy.js"
28
32
 
29
- const PREVIEW_BYTES = 65536
33
+ // Constant: BOOKMARK_ADD_KEY
34
+ // Key and display label of the synthetic "add current path" row. It contains
35
+ // spaces, which a bookmark name may not, so it can never collide with a stored
36
+ // row.
37
+ const BOOKMARK_ADD_KEY = "+ add current path"
30
38
 
31
39
  // Function: createSession
32
40
  // Creates the shared model for a two-pane browser.
@@ -54,8 +62,23 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
54
62
  session.pickerPane = createPane({ fs, path: left.path.value })
55
63
  // Shown in the header title; empty when the environment has no hostname.
56
64
  session.host = host ?? ""
65
+ // The renderer installs its quit hook at mount; the panel's Quit command
66
+ // calls it (core cannot exit a process or unmount a DOM app itself).
67
+ session.quit = null
57
68
  // Bookmark model (named paths). The store defaults to in-memory.
58
69
  session.bookmarks = bookmarks ?? createBookmarks()
70
+ // Undo/redo journal: reversible file operations (`mkdir`, `copy`, `move`,
71
+ // `rename`, `replace`, `trash`) record an inverse here; `^Z`/`^⇧Z` and the `u` history
72
+ // panel drive it. Session-only.
73
+ session.journal = createJournal()
74
+ // The shared list overlay (`u` history, `⇧T` trash): one modal list at a
75
+ // time. `overlay` is "history" | "trash" | null; `overlayItems` holds the
76
+ // rows the active overlay renders; `overlayIndex` is its cursor. A history
77
+ // row carries `index` (its position in the journal), a trash row carries
78
+ // `trashPath`.
79
+ session.overlay = cell(null)
80
+ session.overlayItems = cell([])
81
+ session.overlayIndex = cell(0)
59
82
  // View mode: the three-column "explorer" (parent · current · preview) the
60
83
  // workspace opens in, or the two-pane "commander". `view.toggleMode` (`t`)
61
84
  // flips it; the workspace chrome in `components/Frame` reads it to decide
@@ -85,11 +108,17 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
85
108
  // command bars in place. Independent of `showPreview`, so restoring the
86
109
  // layout returns the inline inspector to exactly what it was.
87
110
  session.previewFull = cell(false)
111
+ // The fullscreen inspector overlay: a framed preview covering the whole
112
+ // viewport. It opens from `v` on a narrow terminal and from Enter on a file
113
+ // that cannot be edited (see `core/commands#pane.enter`), and closes on Esc
114
+ // or `v`. Core-owned so a command can request it; the renderers only show
115
+ // it (`components/Frame#showViewer`). Independent of `showPreview` and
116
+ // `previewFull`, which drive the inline and the maximized inspector.
117
+ session.viewerOpen = cell(false)
88
118
  session.notice = cell(null)
89
119
  session.confirm = cell(null)
90
120
  // The pane whose filter input currently owns the keyboard, or null.
91
121
  session.filtering = cell(null)
92
-
93
122
  // Back/forward restore the focused column alongside the listing: each
94
123
  // source/destination pane captures `active` into its history entries and
95
124
  // restores it on Esc/⇧Esc. A column the current mode does not show falls
@@ -143,13 +172,12 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
143
172
  else sync()
144
173
  })
145
174
 
146
- // Bookmark overlay: `bookmarkMode` is "name" while recording (an input for
147
- // the new bookmark's name) or "list" while jumping (a fuzzy picker).
175
+ // Bookmark panel: one modal list. `bookmarkQuery` filters the stored
176
+ // bookmarks by name or path; `bookmarkIndex` is its cursor; `bookmarkTarget`
177
+ // is the `{ path, isDir }` the add row would record (null while closed).
148
178
  session.bookmarkOpen = cell(false)
149
- session.bookmarkMode = cell("list")
150
179
  session.bookmarkQuery = cell("")
151
180
  session.bookmarkIndex = cell(0)
152
- // The path being recorded while in "name" mode.
153
181
  session.bookmarkTarget = cell(null)
154
182
 
155
183
  // Command panel (`:`): the one entry point, listing the CLI operations,
@@ -165,26 +193,69 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
165
193
  session.panelQuery = cell("")
166
194
  session.panelOpIndex = cell(0)
167
195
  session.panelRowIndex = cell(0)
196
+ // The bit cursor inside a permission row (0=r, 1=w, 2=x): Space toggles
197
+ // the cell it marks, Left/Right move it.
198
+ session.panelPermBit = cell(0)
168
199
  session.panelFocus = cell(PANEL_FOCUS.OPS)
169
200
  session.panelDraft = cell([])
170
201
  session.panelEntries = cell(panelEntries())
202
+ // Errors from a panel run stay beside the action so the user can retry
203
+ // without losing the command draft.
204
+ session.panelRunError = cell(null)
205
+ // Amber/red operations are two-step: the first Run prints the assembled
206
+ // command and arms `[Confirm]`; the second executes it. Any draft or
207
+ // operation change disarms it.
208
+ session.panelArmed = cell(false)
171
209
 
172
210
  // Picker sub-mode: while a `dest`/`path` argument is being browsed the
173
211
  // panel's argument column shows the picker's listing instead of the
174
- // argument rows. `panelPickerMode` ("dir"/"file"/"any") decides what the
175
- // list offers and what Enter accepts; `panelPickerArg` is the argument the
176
- // chosen path is written back to; `panelPendingRun` reruns the operation
212
+ // argument rows. `pickerMode` ("dir"/"file"/"any") decides what the
213
+ // list offers and what Enter accepts; `pickerArg` is the argument the
214
+ // chosen path is written back to; `pickerPendingRun` reruns the operation
177
215
  // once a pick that was holding up a run completes.
178
- session.panelPicking = cell(false)
179
- session.panelPickerMode = cell("dir")
180
- session.panelPickerArg = cell(-1)
181
- session.panelPendingRun = cell(false)
182
- // The picker's fuzzy filter, written by the picker input and routed to the
183
- // picker pane's own filter (see the effect below).
216
+ session.pickerOpen = cell(false)
217
+ session.pickerMode = cell("dir")
218
+ session.pickerArg = cell(-1)
219
+ session.pickerPendingRun = cell(false)
220
+ // The picker's editable path and the region that owns the keyboard: "path"
221
+ // (the text field) or "rows" (the entry list). `pickerQuery` is the fragment
222
+ // the path text resolves to and drives the pane's fuzzy filter.
223
+ session.pickerPathText = cell("")
224
+ session.pickerFocus = cell("path")
225
+ // The directory relative path text resolves against (set when the picker
226
+ // opens), so typing a relative path does not drift as the picker navigates.
227
+ session.pickerBasePath = cell("")
184
228
  session.pickerQuery = cell("")
229
+ // Serializes path resolution and drops stale keystrokes (see
230
+ // `core/picker#createPickerPathQueue`).
231
+ session.pickerPaths = createPickerPathQueue(session)
232
+
233
+ // Auto-refresh follows the columns the view actually shows. A hidden pane
234
+ // stops watching — and, on adapters without a native watcher, stops
235
+ // polling the filesystem — until it is shown again; resuming re-reads it
236
+ // once so the column never comes back stale. The picker's private pane
237
+ // only watches while the picker is open.
238
+ effect(
239
+ [session.viewMode, session.showPreview, session.currentId, session.pickerOpen],
240
+ ([mode, showPreview, current, pickerOpen]) => {
241
+ const visible = new Set()
242
+ if (mode === "explorer") {
243
+ visible.add("parent")
244
+ visible.add(current)
245
+ } else {
246
+ visible.add("left")
247
+ visible.add("right")
248
+ }
249
+ if (showPreview) visible.add("preview")
250
+ for (const [id, pane] of Object.entries(session.panes)) {
251
+ pane.setAutoRefresh?.(visible.has(id))
252
+ }
253
+ session.pickerPane?.setAutoRefresh?.(Boolean(pickerOpen))
254
+ },
255
+ )
185
256
 
186
- // The bookmark picker keeps its cursor on the first match whenever the
187
- // query changes.
257
+ // The bookmark panel keeps its cursor on the first match whenever the query
258
+ // changes.
188
259
  effect([session.bookmarkQuery], () => session.bookmarkIndex.set(0))
189
260
 
190
261
  // Filter editing is bound to the active pane: switching pane drops the
@@ -233,6 +304,11 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
233
304
  // Preview payload: text head for files, metadata for the rest; `stat`
234
305
  // targets carry `details` and render as a card even for directories.
235
306
  session.preview = cell(null)
307
+ // Preview body scroll requests: a monotonic `{ delta, token }` event the
308
+ // renderers apply to their own body. Keeping it a signal rather than an
309
+ // offset lets each renderer clamp against its own measured viewport; the
310
+ // token makes two identical key presses distinct writes.
311
+ session.previewScroll = cell({ delta: 0, token: 0 })
236
312
  session._stopPreviewWatch = effect([session.previewEntry, session.showPreview], async ([entry, show]) => {
237
313
  if (!show || !entry) {
238
314
  session.preview.set(null)
@@ -240,15 +316,19 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
240
316
  }
241
317
  // The preview follows symlinks: its title names the traversal and its
242
318
  // kind comes from what the link points at, not from the link's own
243
- // name (a `notes -> README.md` previews as markdown).
319
+ // name (a `notes -> README.md` previews as markdown). A compressed
320
+ // entry is classified by the name inside it and read through its
321
+ // decompressor (`notes.txt.gz` previews as text).
244
322
  const source = previewSource(entry)
245
- const kind = entry.isDir ? "directory" : previewKind({ ext: source.ext })
323
+ const kind = entry.isDir ? "directory" : previewKind(source.innerExt)
246
324
  if (entry.details) {
247
325
  session.preview.set({
248
326
  kind,
249
327
  title: source.title,
250
328
  size: entry.size,
251
329
  ext: source.ext,
330
+ innerExt: source.innerExt,
331
+ compression: source.compression,
252
332
  details: entry.details,
253
333
  text: null,
254
334
  })
@@ -259,14 +339,27 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
259
339
  return
260
340
  }
261
341
  const token = ++session._previewToken
262
- const head =
263
- kind === "text" ? await fs.readTextHead(entry.path, PREVIEW_BYTES).catch(() => null) : null
342
+ let head = null
343
+ if (kind === "text") {
344
+ if (source.compression) {
345
+ const bytes = await fs
346
+ .readBytes(entry.path, compressedBytes(source.compression))
347
+ .catch(() => null)
348
+ head = bytes
349
+ ? await decompressHead(bytes, source.compression, PREVIEW_BYTES).catch(() => null)
350
+ : null
351
+ } else {
352
+ head = await fs.readTextHead(entry.path, PREVIEW_BYTES).catch(() => null)
353
+ }
354
+ }
264
355
  if (token !== session._previewToken) return
265
356
  session.preview.set({
266
357
  kind,
267
358
  title: source.title,
268
359
  size: entry.size,
269
360
  ext: source.ext,
361
+ innerExt: source.innerExt,
362
+ compression: source.compression,
270
363
  text: head === null || hasBinaryByte(head) ? null : head,
271
364
  })
272
365
  })
@@ -298,34 +391,42 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
298
391
  },
299
392
  )
300
393
 
301
- // Bookmark picker rows: the bookmarks fuzzily matched by name or path,
302
- // ranked by fzy score. Only meaningful in "list" mode.
394
+ // Bookmark rows: the stored bookmarks fuzzily matched by name or path,
395
+ // ranked by fzy score. With no query, a first row offers to record
396
+ // `bookmarkTarget` when that path is not bookmarked yet.
303
397
  session.bookmarkItems = derived(
304
- [session.bookmarks.entries, session.bookmarkQuery, session.bookmarkMode],
305
- ([entries, query, mode]) => {
306
- if (mode !== "list") return []
307
- const rows = Object.entries(entries ?? {}).map(([name, entry]) => ({ name, ...entry }))
398
+ [session.bookmarks.entries, session.bookmarkQuery, session.bookmarkTarget],
399
+ ([entries, query, target]) => {
400
+ const rows = Object.entries(entries ?? {}).map(([name, entry]) => ({ key: name, name, ...entry }))
308
401
  const q = `${query ?? ""}`.trim()
309
- if (!q) return rows.sort((a, b) => a.name.localeCompare(b.name))
310
- const ranked = []
311
- for (const row of rows) {
312
- const best = Math.max(
313
- hasMatch(q, row.name) ? score(q, row.name) : -Infinity,
314
- hasMatch(q, row.path) ? score(q, row.path) : -Infinity,
315
- )
316
- if (best > -Infinity) ranked.push({ row, score: best })
402
+ let items
403
+ if (!q) {
404
+ items = rows.sort((a, b) => a.name.localeCompare(b.name))
405
+ } else {
406
+ const ranked = []
407
+ for (const row of rows) {
408
+ const best = Math.max(
409
+ hasMatch(q, row.name) ? score(q, row.name) : -Infinity,
410
+ hasMatch(q, row.path) ? score(q, row.path) : -Infinity,
411
+ )
412
+ if (best > -Infinity) ranked.push({ row, score: best })
413
+ }
414
+ ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : a.row.name.localeCompare(b.row.name)))
415
+ items = ranked.map((match) => match.row)
317
416
  }
318
- ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : a.row.name.localeCompare(b.row.name)))
319
- return ranked.map((match) => match.row)
417
+ if (!q && target && !items.some((row) => row.path === target.path)) {
418
+ items = [{ key: BOOKMARK_ADD_KEY, add: true, name: BOOKMARK_ADD_KEY, path: target.path, isDir: target.isDir }, ...items]
419
+ }
420
+ return items
320
421
  },
321
422
  )
322
423
 
323
424
  // Command bar rows: context-dependent key hints (see core/commands). The
324
425
  // panel button is dropped when a host disables the panel.
325
426
  session.commandBarItems = derived(
326
- [session.filtering, session.bookmarkOpen, session.bookmarkMode, session.panelOpen, session.panelPicking],
327
- ([filtering, bookmarkOpen, bookmarkMode, panelOpen, panelPicking]) =>
328
- getCommandBarItems({ filtering, bookmarkOpen, bookmarkMode, panelOpen, panelPicking }),
427
+ [session.filtering, session.bookmarkOpen, session.panelOpen, session.pickerOpen, session.panelArmed, session.overlay],
428
+ ([filtering, bookmarkOpen, panelOpen, pickerOpen, panelArmed, overlay]) =>
429
+ getCommandBarItems({ filtering, bookmarkOpen, panelOpen, pickerOpen, panelArmed, overlay }),
329
430
  )
330
431
 
331
432
  // The entry list filtered by the typed query; `panelOpIndex` indexes into
@@ -343,37 +444,80 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
343
444
  // lists directories only, so every row is a valid choice; a file/any
344
445
  // picker keeps the listing intact, where a directory row descends.
345
446
  session.pickerVisible = derived(
346
- [session.pickerPane.entries, session.panelPickerMode],
347
- ([entries, mode]) => (mode === "dir" ? (entries ?? []).filter((entry) => entry.isDir) : entries ?? []),
447
+ [session.pickerPane.entries, session.pickerMode],
448
+ ([entries, mode]) => pickerVisibleEntries(entries, mode),
348
449
  )
349
- session.pickerRows = derived([session.pickerVisible], ([entries]) => pickerRowList(entries))
350
- // The argument column: the picker's entry rows while picking, the draft's
351
- // argument rows otherwise. Focus is read from `panelRowIndex` by the row
352
- // builder, so it works for both row shapes.
353
- session.panelRows = derived(
354
- [session.panelDraft, session.panelPicking, session.pickerRows],
355
- ([draft, picking, picker]) => (picking ? picker : panelRowList(draft)),
450
+ session.pickerRows = derived(
451
+ [session.pickerVisible, session.pickerPane.path],
452
+ ([entries, path]) => pickerRowList(entries, path),
356
453
  )
357
- // The right pane's argument blocks (label + value/field, and a bordered
358
- // item list per selection), and the label column width that aligns them.
359
- session.panelLabelWidth = derived([session.panelDraft], ([draft]) =>
360
- Math.max(4, ...(draft ?? []).map((binding) => `${binding.name ?? ""}`.length)),
454
+ // The argument column: the picker's entry rows while picking; otherwise the
455
+ // selected operation's arguments followed by its explicit Run action.
456
+ session.panelRows = derived(
457
+ [session.panelDraft, session.panelEntry, session.pickerOpen, session.pickerRows, session.panelRunError, session.panelArmed, session.activePath],
458
+ ([draft, entry, open, picker, runError, armed, activePath]) => {
459
+ if (open) return picker
460
+ if (!entry) return []
461
+ const rows = panelRowList(draft)
462
+ // A command with no arguments still shows the context it would act
463
+ // on: a read-only PATH row over the active pane's path.
464
+ if (!rows.length && entry.showPath) {
465
+ rows.push({
466
+ type: "arg",
467
+ key: "arg:path",
468
+ rowIndex: 0,
469
+ argIndex: -1,
470
+ itemIndex: -1,
471
+ name: "PATH",
472
+ summary: activePath ?? "",
473
+ kind: "path",
474
+ role: "active",
475
+ optional: false,
476
+ variadic: false,
477
+ textual: false,
478
+ editable: false,
479
+ picker: false,
480
+ mode: "dir",
481
+ multi: false,
482
+ readonly: true,
483
+ value: activePath ?? "",
484
+ count: 0,
485
+ })
486
+ }
487
+ rows.push({
488
+ type: "run",
489
+ key: "panel:run",
490
+ rowIndex: rows.length,
491
+ name: runError ? "Retry" : armed ? "Confirm" : "Run",
492
+ risk: entry.risk ?? null,
493
+ group: entry.group ?? "File",
494
+ error: runError,
495
+ armed,
496
+ // The command printed below `[Confirm]`.
497
+ command: armed ? panelCommandLine(session, entry, draft) : null,
498
+ })
499
+ return rows
500
+ },
361
501
  )
362
- // The assembled command line the draft maps to, and whether it is runnable.
363
- // The preview answers "what will this run" as the shell would see it,
364
- // prefixed with the entry name; a `«NAME»` marks a required argument still
365
- // to fill.
366
- session.panelCommand = derived(
502
+ // The right pane's argument blocks (a label line over its value/field, and
503
+ // a bordered item list per selection).
504
+ // Operation-specific preflight output (currently `replace`'s old → new
505
+ // mapping) is distinct from the assembled shell command.
506
+ session.panelPreview = derived(
367
507
  [session.panelDraft, session.panelEntry],
368
508
  ([draft, entry]) => {
369
- // An operation may preview its own mapping instead of the command
370
- // line (`rename` shows `old → new` per path). Anything else falls
371
- // back to the assembled command line.
372
- const custom = entry?.op?.preview?.(session, argvFor(draft), draft)
373
- if (typeof custom === "string") return custom
374
- const args = panelCommand(draft)
375
- const name = entry?.name ?? ""
376
- return args ? `${name} ${args}` : name
509
+ const preview = entry?.op?.preview?.(session, argvFor(draft), draft)
510
+ return typeof preview === "string" ? preview : null
511
+ },
512
+ )
513
+ // Preserve the existing `panelCommand` projection for consumers: operations
514
+ // with a custom preview return it; all others return the assembled command.
515
+ // The panel renders custom preflight output separately from its Run action.
516
+ session.panelCommand = derived(
517
+ [session.panelDraft, session.panelEntry, session.panelPreview],
518
+ ([draft, entry, preview]) => {
519
+ if (typeof preview === "string") return preview
520
+ return panelCommandLine(session, entry, draft)
377
521
  },
378
522
  )
379
523
  session.panelRunnable = derived([session.panelDraft], ([draft]) => panelRunnable(draft))
@@ -408,31 +552,51 @@ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explo
408
552
  effect([session.panelOpen, session.panelEntry], ([open, entry]) => {
409
553
  if (!open) return
410
554
  const draft = bindArgs(session, entry)
411
- prefillPanelDraft(entry, draft)
555
+ prefillPanelDraft(session, entry, draft)
412
556
  session.panelDraft.set(draft)
413
557
  })
414
558
  // Selecting another entry resets the argument cursor.
415
559
  effect([session.panelEntry], () => {
416
560
  session.panelRowIndex.set(0)
561
+ session.panelRunError.set(null)
562
+ session.panelArmed.set(false)
563
+ })
564
+ // Editing the draft after an error gives the next attempt a clean action
565
+ // row, and disarms a pending Confirm (the command changed under it).
566
+ effect([session.panelDraft], () => {
567
+ if (session.panelRunError.value) session.panelRunError.set(null)
568
+ if (session.panelArmed.value) session.panelArmed.set(false)
417
569
  })
418
570
  // Typing a query re-anchors the entry cursor on the first match.
419
571
  effect([session.panelQuery], () => {
420
572
  session.panelOpIndex.set(0)
421
573
  })
422
574
 
423
- // The picker's query drives the picker pane's fuzzy filter and re-anchors
424
- // the picker cursor on the first match. Only while picking, so the effect
425
- // can never reset the argument cursor outside the picker.
575
+ // The editable path resolves to a directory plus a filter fragment; the
576
+ // queue drops stale keystrokes and keeps fast typing from landing an older
577
+ // navigation after a newer one (see `core/picker#createPickerPathQueue`).
578
+ effect([session.pickerPathText, session.pickerOpen], ([text, open]) => {
579
+ if (open) session.pickerPaths.schedule(text)
580
+ })
581
+
582
+ // The picker's resolved fragment drives the picker pane's fuzzy filter and
583
+ // re-anchors the picker cursor on the first match. Only while picking, so
584
+ // the effect can never reset the argument cursor outside the picker.
426
585
  effect([session.pickerQuery], ([query]) => {
427
586
  session.pickerPane.setFilter(`${query ?? ""}`)
428
- if (session.panelPicking.value) session.panelRowIndex.set(0)
587
+ if (session.pickerOpen.value) {
588
+ const rows = session.pickerRows.value
589
+ const firstMatch = `${query ?? ""}` ? rows.findIndex((row) => row.type === "entry") : 0
590
+ session.panelRowIndex.set(firstMatch >= 0 ? firstMatch : 0)
591
+ }
429
592
  })
430
593
  // Closing the panel leaves the picker (it lives inside the panel).
431
594
  effect([session.panelOpen], ([open]) => {
432
595
  if (open) return
433
- session.panelPicking.set(false)
434
- session.panelPickerArg.set(-1)
435
- session.panelPendingRun.set(false)
596
+ session.pickerOpen.set(false)
597
+ session.pickerArg.set(-1)
598
+ session.pickerPendingRun.set(false)
599
+ session.panelArmed.set(false)
436
600
  })
437
601
 
438
602
  session.dispatch = createDispatch(session)
@@ -467,15 +631,51 @@ async function load(session) {
467
631
  }
468
632
 
469
633
  // Function: prefillPanelDraft
470
- // Seeds a freshly bound draft: a single-path `rename` prefills FIND with the
471
- // whole name, so typing REPLACE renames it in one step; a multi-path rename
472
- // keeps FIND empty for a shared substring.
473
- function prefillPanelDraft(entry, draft) {
474
- if (entry?.op?.name !== "rename") return
475
- const find = (draft ?? []).find((binding) => binding.name === "FIND")
476
- const selection = (draft ?? []).find((binding) => binding.kind === "selection")
477
- const selected = (selection?.items ?? []).filter((item) => item.selected)
478
- if (find && selected.length === 1) find.value = basename(selected[0].path)
634
+ // Seeds a freshly bound draft: `replace` prefills FIND with a single selected
635
+ // basename; exact `rename` prefills FROM and the containing directory from the
636
+ // active entry (or uses the pane's current directory when there is none).
637
+ function prefillPanelDraft(session, entry, draft) {
638
+ const name = entry?.op?.name
639
+ if (name === "replace") {
640
+ const find = (draft ?? []).find((binding) => binding.name === "FIND")
641
+ const selection = (draft ?? []).find((binding) => binding.kind === "selection")
642
+ const selected = (selection?.items ?? []).filter((item) => item.selected)
643
+ if (find && selected.length === 1) find.value = basename(selected[0].path)
644
+ return
645
+ }
646
+ if (name !== "rename") return
647
+ const active = session.active?.value
648
+ const auxiliaryPane =
649
+ (active === "parent" && session.panes.parent) ||
650
+ (active === "preview" &&
651
+ session.showPreview?.value &&
652
+ session.previewEntry?.value?.isDir &&
653
+ !session.preview?.value &&
654
+ session.panes.preview)
655
+ const pane = auxiliaryPane || session.panes[session.currentId?.value ?? active] || session.panes.left
656
+ const from = (draft ?? []).find((binding) => binding.name === "FROM")
657
+ const path = (draft ?? []).find((binding) => binding.name === "PATH")
658
+ const marked = [...(pane.marked?.value ?? [])]
659
+ const selectedPath =
660
+ marked.length === 1 ? marked[0] : marked.length === 0 ? pane.currentEntry?.()?.path : null
661
+ if (selectedPath) {
662
+ if (from) from.value = basename(selectedPath)
663
+ if (path) path.value = parentPath(selectedPath) ?? pane.path.value ?? ""
664
+ } else if (path) {
665
+ path.value = pane.path.value ?? ""
666
+ }
667
+ }
668
+
669
+ // Function: panelCommandLine
670
+ // The command the panel prints below `[Confirm]`: an operation's own
671
+ // `displayCommand` when it has one (rename spells out the two resolved paths),
672
+ // otherwise the operation name followed by its assembled argument tokens.
673
+ function panelCommandLine(session, entry, draft) {
674
+ const display = entry?.op?.displayCommand?.(session, argvFor(draft), draft)
675
+ if (display) return display
676
+ const name = entry?.name ?? ""
677
+ const args = panelCommand(draft)
678
+ return args ? `${name} ${args}` : name
479
679
  }
480
680
 
481
681
  function entrySummary(entry) {
@@ -500,34 +700,4 @@ function entrySummary(entry) {
500
700
  }
501
701
  }
502
702
 
503
- // Function: previewSource
504
- // What the preview renders *through* a symlink: `title` names the traversal
505
- // (`notes -> ../real/notes`) so it is visible in the header, and `ext` is the
506
- // target's extension, so a link previews with the kind of what it points at
507
- // rather than the kind of its own name. Plain entries pass through unchanged.
508
- function previewSource(entry) {
509
- const target = entry?.isSymlink && entry.target ? `${entry.target}` : null
510
- if (!target || !entry?.path) return { title: basename(entry?.path ?? ""), ext: entry?.ext ?? null }
511
- const ext = extname(target) ?? entry.ext ?? null
512
- return { title: `${basename(entry.path)} -> ${target}`, ext }
513
- }
514
-
515
- // Function: previewKind
516
- // Preview classification for file entries; directories are not previewed.
517
- function previewKind(entry) {
518
- const image = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]
519
- const media = ["mp3", "wav", "ogg", "flac", "mp4", "webm", "mkv", "mov", "pdf"]
520
- if (entry.ext && image.includes(entry.ext)) return "image"
521
- if (entry.ext && media.includes(entry.ext)) return "media"
522
- return "text"
523
- }
524
-
525
- function hasBinaryByte(text) {
526
- for (let i = 0; i < 2048 && i < text.length; i++) {
527
- const code = text.charCodeAt(i)
528
- if (code < 9 || (code > 13 && code < 32)) return true
529
- }
530
- return false
531
- }
532
-
533
- export { createSession, load, previewSource }
703
+ export { createSession, load }