@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
@@ -5,17 +5,25 @@
5
5
 
6
6
  // Module: tcmd/core/session
7
7
  // A Session wires two panes plus cross-pane UI state (active pane, preview,
8
- // command palette, bookmarks) into one dispatchable model. Everything reactive
8
+ // command panel, bookmarks) into one dispatchable model. Everything reactive
9
9
  // is a cell or derived cell; views subscribe, commands mutate.
10
+ //
11
+ // On top of the two (source/destination) panes the session owns two auxiliary
12
+ // panes: `parent` mirrors the parent of the pane holding the current
13
+ // directory, `preview` mirrors the directory the inspector is showing. They
14
+ // are real panes — own listing, cursor, marks — so the explorer view and the
15
+ // directory preview are clickable and keyboard-navigable like any other pane,
16
+ // while path changes still land on the pane that owns the current directory
17
+ // (see `currentId` / `core/commands#currentPane`).
10
18
 
11
19
  import { cell, derived, effect } from "select/state/cells"
12
20
  import { createBookmarks } from "./bookmarks.js"
13
- import { commandRow, createDispatch, getCommandBarItems, getPaletteCommands } from "./commands.js"
21
+ import { createDispatch, getCommandBarItems } from "./commands.js"
14
22
  import { createNullOs } from "./os.js"
15
- import { PANEL_FOCUS, bindArgs, filterPanelOperations, isTextualRow, panelCommand, panelOperations, panelRowList, panelRunnable, rowAt, withArgValue } from "./panel.js"
16
- import { basename, join } from "./path.js"
17
- import { PANE_BUFFERS } from "./pane.js"
18
- import { completionToken, scriptItems } from "./script.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"
25
+ import { indexOfPath } from "./selection.js"
26
+ import { createPane, PANE_BUFFERS } from "./pane.js"
19
27
  import { hasMatch, score } from "../vendor/fzy.js"
20
28
 
21
29
  const PREVIEW_BYTES = 65536
@@ -29,30 +37,112 @@ const PREVIEW_BYTES = 65536
29
37
  // await session.load()
30
38
  // session.dispatch("pane.toggle")
31
39
  // ```
32
- function createSession({ fs, os, left, right, host, bookmarks } = {}) {
40
+ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explorer" } = {}) {
33
41
  const session = {}
34
42
  session.fs = fs
35
43
  session.os = os ?? createNullOs()
36
44
  session.panes = { left, right }
45
+ // Auxiliary panes: the explorer's parent column and the inspector's
46
+ // directory column. Both start on a valid path so a refresh before their
47
+ // first sync can never read a blank one.
48
+ session.panes.parent = createPane({ fs, path: parentPath(left.path.value) ?? left.path.value })
49
+ session.panes.preview = createPane({ fs, path: left.path.value })
50
+ // File picker: a private pane the command panel browses while a path
51
+ // argument is being picked. Kept out of `session.panes` so `load`, the
52
+ // pane refresh loop and pane navigation leave it alone; it is (re)read
53
+ // when the picker opens (see `core/commands#openPicker`).
54
+ session.pickerPane = createPane({ fs, path: left.path.value })
37
55
  // Shown in the header title; empty when the environment has no hostname.
38
56
  session.host = host ?? ""
39
57
  // Bookmark model (named paths). The store defaults to in-memory.
40
58
  session.bookmarks = bookmarks ?? createBookmarks()
59
+ // View mode: the three-column "explorer" (parent · current · preview) the
60
+ // workspace opens in, or the two-pane "commander". `view.toggleMode` (`t`)
61
+ // flips it; the workspace chrome in `components/Frame` reads it to decide
62
+ // which columns exist. Callers that need the two-pane workspace (tests, a
63
+ // host that prefers it) pass `viewMode: "commander"`.
64
+ session.viewMode = cell(viewMode)
41
65
  session.active = cell("left")
42
- // The active pane's current directory, tracked by the header title.
43
- session.activePath = derived([session.active, left.path, right.path], ([active, l, r]) =>
44
- active === "left" ? l : r,
66
+ // The source/destination pane focus last was on. The auxiliary columns
67
+ // are attached to it, so `currentId` survives focus moving into them.
68
+ session.anchor = cell("left")
69
+ // The pane that owns the current directory: the focused pane when focus
70
+ // is on a source/destination pane, the anchor otherwise. Everything that
71
+ // changes a *path* (open, up, back, breadcrumbs, the command panel's
72
+ // relative arguments) targets it; cursor-only movement targets the
73
+ // focused pane.
74
+ session.currentId = derived([session.active, session.anchor], ([active, anchor]) =>
75
+ active === "left" || active === "right" ? active : anchor,
76
+ )
77
+ // The current pane's directory, tracked by the header title and by the
78
+ // parent column's mirror.
79
+ session.activePath = derived([session.currentId, left.path, right.path], ([current, l, r]) =>
80
+ current === "left" ? l : r,
45
81
  )
46
82
  session.showPreview = cell(true)
47
- session.paletteOpen = cell(false)
48
- session.paletteMode = cell("ui")
49
- session.paletteQuery = cell("")
50
- session.paletteIndex = cell(0)
83
+ // The inspector maximized over the workspace columns (`⇧V`): the preview
84
+ // takes the whole pane row until toggled back, leaving the identity and
85
+ // command bars in place. Independent of `showPreview`, so restoring the
86
+ // layout returns the inline inspector to exactly what it was.
87
+ session.previewFull = cell(false)
51
88
  session.notice = cell(null)
52
89
  session.confirm = cell(null)
53
90
  // The pane whose filter input currently owns the keyboard, or null.
54
91
  session.filtering = cell(null)
55
92
 
93
+ // Back/forward restore the focused column alongside the listing: each
94
+ // source/destination pane captures `active` into its history entries and
95
+ // restores it on Esc/⇧Esc. A column the current mode does not show falls
96
+ // back to the pane owning the current directory.
97
+ const focusContext = {
98
+ get: () => session.active.value,
99
+ set: (value) => {
100
+ if (value) session.active.set(visibleColumn(session, value))
101
+ },
102
+ }
103
+ left.setContext(focusContext)
104
+ right.setContext(focusContext)
105
+
106
+ // Focus remembers which source/destination pane it came from.
107
+ effect([session.active], ([active]) => {
108
+ if (active === "left" || active === "right") session.anchor.set(active)
109
+ })
110
+ // The parent column only exists in explorer mode: leaving the mode hands
111
+ // focus back to the pane it was following.
112
+ effect([session.viewMode], ([mode]) => {
113
+ if (mode !== "explorer" && session.active.value === "parent") {
114
+ session.active.set(session.currentId.value)
115
+ }
116
+ })
117
+ // The parent column mirrors the parent of the current directory. It never
118
+ // moves on its own: opening, stepping up or following a breadcrumb in it
119
+ // retargets the current pane instead (see `core/commands`). Its cursor
120
+ // follows the current directory, so the mirror always highlights the
121
+ // entry the current pane is showing. Mirroring never records history: the
122
+ // column's own back/forward stays on the paths the user navigated.
123
+ //
124
+ // `parent.entries` is a dependency so the cursor also lands when the
125
+ // mirror's listing arrives *after* the effect first runs — the explorer is
126
+ // the opening mode, so its parent column is populated by the initial
127
+ // `load` rather than by a later navigation.
128
+ effect([session.activePath, session.viewMode, session.panes.parent.entries], ([path, mode]) => {
129
+ if (mode !== "explorer" || !path) return
130
+ // The filesystem root has no parent; mirror itself rather than read a
131
+ // blank path.
132
+ const target = parentPath(path) ?? path
133
+ const parent = session.panes.parent
134
+ // Land the mirror's cursor on the current directory once its listing
135
+ // knows it. A sync left over from an older navigation yields: it
136
+ // checks the live path before writing the cursor.
137
+ const sync = () => {
138
+ if (session.activePath.value !== path) return
139
+ const index = indexOfPath(parent.entries.value, path)
140
+ if (index >= 0) parent.cursor.set(index)
141
+ }
142
+ if (parent.path.value !== target) void parent.navigate(target, { record: false }).then(sync)
143
+ else sync()
144
+ })
145
+
56
146
  // Bookmark overlay: `bookmarkMode` is "name" while recording (an input for
57
147
  // the new bookmark's name) or "list" while jumping (a fuzzy picker).
58
148
  session.bookmarkOpen = cell(false)
@@ -62,29 +152,39 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
62
152
  // The path being recorded while in "name" mode.
63
153
  session.bookmarkTarget = cell(null)
64
154
 
65
- // Command panel (`m` / `:`): a structured Commando-style view over the
66
- // operations and their argument bindings. `panelQuery` filters the
67
- // operation list by typed name; `panelOpIndex` selects the operation in
68
- // that filtered list; `panelRowIndex` is the cursor in the flattened
69
- // argument rows (headers + items); `panelFocus` is a `PANEL_FOCUS` token
70
- // ("ops": typing filters operations, "args": an argument owns the
71
- // keyboard). `panelDraft` holds the transient bindings the user tweaks —
72
- // it never mutates the real pane marks.
155
+ // Command panel (`:`): the one entry point, listing the CLI operations,
156
+ // with the selected operation's arguments bound from the live context.
157
+ // `panelQuery` filters the entry list by typed name; `panelOpIndex` selects
158
+ // an entry in that filtered list; `panelRowIndex` is the cursor in the
159
+ // flattened argument rows (headers + items); `panelFocus` is a
160
+ // `PANEL_FOCUS` token ("ops": typing
161
+ // filters entries, "args": an argument owns the keyboard). `panelDraft`
162
+ // holds the transient bindings the user tweaks — it never mutates the real
163
+ // pane marks.
73
164
  session.panelOpen = cell(false)
74
165
  session.panelQuery = cell("")
75
166
  session.panelOpIndex = cell(0)
76
167
  session.panelRowIndex = cell(0)
77
168
  session.panelFocus = cell(PANEL_FOCUS.OPS)
78
169
  session.panelDraft = cell([])
79
- session.panelOps = cell(panelOperations())
80
- // The command panel view is TUI-only; the web view disables it at startup
81
- // so no dispatch path (key, palette, command bar) opens an invisible modal.
82
- session.panelEnabled = cell(true)
170
+ session.panelEntries = cell(panelEntries())
171
+
172
+ // Picker sub-mode: while a `dest`/`path` argument is being browsed the
173
+ // 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
177
+ // 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).
184
+ session.pickerQuery = cell("")
83
185
 
84
- // The command palette keeps its cursor on the first match whenever the
186
+ // The bookmark picker keeps its cursor on the first match whenever the
85
187
  // query changes.
86
- effect([session.paletteQuery], () => session.paletteIndex.set(0))
87
- // The bookmark picker likewise.
88
188
  effect([session.bookmarkQuery], () => session.bookmarkIndex.set(0))
89
189
 
90
190
  // Filter editing is bound to the active pane: switching pane drops the
@@ -92,25 +192,42 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
92
192
  effect([session.active], () => session.filtering.set(null))
93
193
 
94
194
  // The entry under the cursor of each pane, as a small plain object. The
95
- // stat details (ctime, mode, uid, gid) come from the directory's lazy
96
- // `cursorStat`, so the pane detail line updates as the cursor moves.
97
- session.leftCursorEntry = derived([left.entries, left.cursor, left.cursorStat], ([entries, cursor, stat]) =>
98
- entrySummary(stat ?? entries[cursor]),
195
+ // listing carries the stat details (ctime, mode, uid, gid), so the detail
196
+ // line updates as the cursor moves without a separate stat pass.
197
+ session.leftCursorEntry = derived([left.entries, left.cursor], ([entries, cursor]) =>
198
+ entrySummary(entries[cursor]),
99
199
  )
100
- session.rightCursorEntry = derived([right.entries, right.cursor, right.cursorStat], ([entries, cursor, stat]) =>
101
- entrySummary(stat ?? entries[cursor]),
200
+ session.rightCursorEntry = derived([right.entries, right.cursor], ([entries, cursor]) =>
201
+ entrySummary(entries[cursor]),
202
+ )
203
+ // The same detail line for the explorer's parent column.
204
+ const parent = session.panes.parent
205
+ session.parentCursorEntry = derived([parent.entries, parent.cursor], ([entries, cursor]) =>
206
+ entrySummary(entries[cursor]),
102
207
  )
103
208
 
104
- // Preview target: a `view`/`stat` pin, or the active pane's cursor entry.
209
+ // Preview target: a `view`/`stat` pin, or the current pane's cursor entry.
210
+ // Focus on an auxiliary column does not move it: the preview keeps
211
+ // following the pane that owns the directory.
105
212
  session.previewTarget = cell(null)
106
213
  session.previewEntry = derived(
107
- [session.active, session.leftCursorEntry, session.rightCursorEntry, session.previewTarget],
108
- ([active, l, r, target]) => target ?? (active === "left" ? l : r),
214
+ [session.currentId, session.leftCursorEntry, session.rightCursorEntry, session.previewTarget],
215
+ ([current, l, r, target]) => target ?? (current === "left" ? l : r),
109
216
  )
110
217
 
111
218
  // Moving either cursor resumes following the cursor in the preview.
112
219
  effect([session.leftCursorEntry, session.rightCursorEntry], () => session.previewTarget.set(null))
113
220
 
221
+ // The preview column mirrors the directory being previewed, so a
222
+ // directory reads as a pane in every view mode. It never moves on its
223
+ // own for the same reason as the parent column.
224
+ effect([session.previewEntry], ([entry]) => {
225
+ if (!entry?.isDir) return
226
+ if (session.panes.preview.path.value !== entry.path) {
227
+ void session.panes.preview.navigate(entry.path, { record: false })
228
+ }
229
+ })
230
+
114
231
  session._previewToken = 0
115
232
 
116
233
  // Preview payload: text head for files, metadata for the rest; `stat`
@@ -121,13 +238,17 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
121
238
  session.preview.set(null)
122
239
  return
123
240
  }
124
- const kind = entry.isDir ? "directory" : previewKind(entry)
241
+ // The preview follows symlinks: its title names the traversal and its
242
+ // kind comes from what the link points at, not from the link's own
243
+ // name (a `notes -> README.md` previews as markdown).
244
+ const source = previewSource(entry)
245
+ const kind = entry.isDir ? "directory" : previewKind({ ext: source.ext })
125
246
  if (entry.details) {
126
247
  session.preview.set({
127
248
  kind,
128
- title: basename(entry.path),
249
+ title: source.title,
129
250
  size: entry.size,
130
- ext: entry.ext,
251
+ ext: source.ext,
131
252
  details: entry.details,
132
253
  text: null,
133
254
  })
@@ -143,9 +264,9 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
143
264
  if (token !== session._previewToken) return
144
265
  session.preview.set({
145
266
  kind,
146
- title: basename(entry.path),
267
+ title: source.title,
147
268
  size: entry.size,
148
- ext: entry.ext,
269
+ ext: source.ext,
149
270
  text: head === null || hasBinaryByte(head) ? null : head,
150
271
  })
151
272
  })
@@ -177,37 +298,6 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
177
298
  },
178
299
  )
179
300
 
180
- // The active pane's entries, used for script path completion.
181
- session.activeEntries = derived(
182
- [session.active, left.entries, right.entries],
183
- ([active, l, r]) => (active === "left" ? l : r),
184
- )
185
-
186
- // Script path completion: the entries of the directory being typed (a
187
- // token containing "/"), or the active pane's entries otherwise.
188
- session.completionEntries = cell([])
189
- session._completionToken = 0
190
- effect(
191
- [session.paletteMode, session.paletteQuery, session.activeEntries, session.active],
192
- async ([mode, query, activeEntries, active]) => {
193
- if (mode !== "script") {
194
- session.completionEntries.set([])
195
- return
196
- }
197
- const token = completionToken(query)
198
- if (!token) {
199
- session.completionEntries.set(activeEntries ?? [])
200
- return
201
- }
202
- const base = session.panes[active]?.path.value ?? session.fs.cwd()
203
- const dir = token.dir.startsWith("/") ? token.dir : join(base, token.dir)
204
- const mine = ++session._completionToken
205
- const entries = await session.fs.readDir(dir).catch(() => [])
206
- if (mine !== session._completionToken) return
207
- session.completionEntries.set(entries)
208
- },
209
- )
210
-
211
301
  // Bookmark picker rows: the bookmarks fuzzily matched by name or path,
212
302
  // ranked by fzy score. Only meaningful in "list" mode.
213
303
  session.bookmarkItems = derived(
@@ -231,59 +321,72 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
231
321
  )
232
322
 
233
323
  // Command bar rows: context-dependent key hints (see core/commands). The
234
- // TUI-only panel button is dropped when the renderer disables the panel.
324
+ // panel button is dropped when a host disables the panel.
235
325
  session.commandBarItems = derived(
236
- [
237
- session.paletteOpen,
238
- session.paletteMode,
239
- session.filtering,
240
- session.bookmarkOpen,
241
- session.bookmarkMode,
242
- session.panelOpen,
243
- session.panelEnabled,
244
- ],
245
- ([open, mode, filtering, bookmarkOpen, bookmarkMode, panelOpen, panelEnabled]) => {
246
- const items = getCommandBarItems(open, mode, filtering, bookmarkOpen, bookmarkMode, panelOpen)
247
- if (panelEnabled) return items
248
- return items.filter((item) => item.id !== "panel.toggle")
249
- },
326
+ [session.filtering, session.bookmarkOpen, session.bookmarkMode, session.panelOpen, session.panelPicking],
327
+ ([filtering, bookmarkOpen, bookmarkMode, panelOpen, panelPicking]) =>
328
+ getCommandBarItems({ filtering, bookmarkOpen, bookmarkMode, panelOpen, panelPicking }),
250
329
  )
251
330
 
252
- // The operation list filtered by the typed query; `panelOpIndex` indexes
253
- // into this filtered list.
331
+ // The entry list filtered by the typed query; `panelOpIndex` indexes into
332
+ // this filtered list.
254
333
  session.panelFiltered = derived(
255
- [session.panelOps, session.panelQuery],
256
- ([operations, query]) => filterPanelOperations(operations, query),
334
+ [session.panelEntries, session.panelQuery],
335
+ ([entries, query]) => filterPanelEntries(entries, query),
257
336
  )
258
- // The operation the command panel is editing, and its bound arguments.
259
- session.panelOperation = derived(
337
+ // The entry the command panel is editing, and its bound arguments.
338
+ session.panelEntry = derived(
260
339
  [session.panelFiltered, session.panelOpIndex],
261
- ([operations, index]) => operations[Math.max(0, Math.min(index, operations.length - 1))] ?? null,
340
+ ([entries, index]) => entries[Math.max(0, Math.min(index, entries.length - 1))] ?? null,
341
+ )
342
+ // The picker's visible listing for the current mode: a destination picker
343
+ // lists directories only, so every row is a valid choice; a file/any
344
+ // picker keeps the listing intact, where a directory row descends.
345
+ session.pickerVisible = derived(
346
+ [session.pickerPane.entries, session.panelPickerMode],
347
+ ([entries, mode]) => (mode === "dir" ? (entries ?? []).filter((entry) => entry.isDir) : entries ?? []),
348
+ )
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)),
356
+ )
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)),
262
361
  )
263
- session.panelRows = derived([session.panelDraft], ([draft]) => panelRowList(draft))
264
362
  // The assembled command line the draft maps to, and whether it is runnable.
265
363
  // The preview answers "what will this run" as the shell would see it,
266
- // prefixed with the operation name; a `«NAME»` marks a required argument
267
- // still to fill.
364
+ // prefixed with the entry name; a `«NAME»` marks a required argument still
365
+ // to fill.
268
366
  session.panelCommand = derived(
269
- [session.panelDraft, session.panelOperation],
270
- ([draft, op]) => {
367
+ [session.panelDraft, session.panelEntry],
368
+ ([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
271
374
  const args = panelCommand(draft)
272
- const name = op?.name ?? ""
375
+ const name = entry?.name ?? ""
273
376
  return args ? `${name} ${args}` : name
274
377
  },
275
378
  )
276
379
  session.panelRunnable = derived([session.panelDraft], ([draft]) => panelRunnable(draft))
277
- // The cursor row and whether it is a textual argument (so the panel focuses
278
- // its text field).
380
+ // The argument row under the right pane's cursor, and whether it is a
381
+ // textual argument (so the panel focuses its value field).
279
382
  session.panelCursorRow = derived(
280
383
  [session.panelRows, session.panelRowIndex],
281
384
  ([rows, row]) => rowAt(rows, row),
282
385
  )
283
386
  session.panelTextual = derived([session.panelCursorRow], ([row]) => isTextualRow(row))
284
- // The text the input edits. A plain cell (the input writes through it); an
285
- // effect copies the focused binding's value in whenever the cursor lands on
286
- // a textual argument, and another folds typing back into the transient
387
+ // The text the value field edits. A plain cell (the input writes through
388
+ // it); an effect copies the focused argument's value in whenever the cursor
389
+ // lands on a textual row, and another folds typing back into the transient
287
390
  // draft so a value survives moving to another argument.
288
391
  session.panelValue = cell("")
289
392
  effect([session.panelCursorRow], ([row]) => {
@@ -296,67 +399,85 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
296
399
  }
297
400
  })
298
401
 
299
- // A picker-row projection for the operation list (left column), over the
300
- // query-filtered operations. `summary` is the argument list the row shows
301
- // — the signature with the leading operation name stripped, so the name is
302
- // not repeated next to the title.
303
- session.panelItems = derived([session.panelFiltered], ([operations]) =>
304
- operations.map((op) => ({
305
- id: `op:${op.name}`,
306
- title: op.name,
307
- summary: `${op.signature ?? ""}`.replace(/^\S+\s*/, ""),
308
- risk: op.risk ?? null,
309
- })),
310
- )
402
+ // The command-list column's rows: the filtered operations.
403
+ session.panelItems = derived([session.panelFiltered], ([entries]) => panelEntryRows(entries))
311
404
 
312
- // Re-bind the panel draft when the panel opens or the selected operation
405
+ // Re-bind the panel draft when the panel opens or the selected entry
313
406
  // changes. The panel is modal, so the pane context does not change
314
- // underneath it; an explicit toggle owns the draft while it is open.
315
- effect([session.panelOpen, session.panelOperation], ([open, op]) => {
407
+ // underneath it; opening the panel owns the draft while it is open.
408
+ effect([session.panelOpen, session.panelEntry], ([open, entry]) => {
316
409
  if (!open) return
317
- session.panelDraft.set(bindArgs(session, op))
410
+ const draft = bindArgs(session, entry)
411
+ prefillPanelDraft(entry, draft)
412
+ session.panelDraft.set(draft)
318
413
  })
319
- // Selecting another operation resets the argument cursor.
320
- effect([session.panelOperation], () => {
414
+ // Selecting another entry resets the argument cursor.
415
+ effect([session.panelEntry], () => {
321
416
  session.panelRowIndex.set(0)
322
417
  })
323
- // Typing a query re-anchors the operation cursor on the first match.
418
+ // Typing a query re-anchors the entry cursor on the first match.
324
419
  effect([session.panelQuery], () => {
325
420
  session.panelOpIndex.set(0)
326
421
  })
327
422
 
328
- // Command palette rows: UI actions by default; in scripting mode, the
329
- // operations plus the available filesets. Both are filtered by the query.
330
- session.paletteCommands = cell(getPaletteCommands().map(commandRow))
331
- session.paletteItems = derived(
332
- [
333
- session.paletteMode,
334
- session.paletteQuery,
335
- session.paletteCommands,
336
- session.filesets,
337
- session.completionEntries,
338
- ],
339
- ([mode, query, commands, filesets, entries]) =>
340
- mode === "script"
341
- ? scriptItems(query, filesets, { entries })
342
- : filterCommandRows(commands, query),
343
- )
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.
426
+ effect([session.pickerQuery], ([query]) => {
427
+ session.pickerPane.setFilter(`${query ?? ""}`)
428
+ if (session.panelPicking.value) session.panelRowIndex.set(0)
429
+ })
430
+ // Closing the panel leaves the picker (it lives inside the panel).
431
+ effect([session.panelOpen], ([open]) => {
432
+ if (open) return
433
+ session.panelPicking.set(false)
434
+ session.panelPickerArg.set(-1)
435
+ session.panelPendingRun.set(false)
436
+ })
344
437
 
345
438
  session.dispatch = createDispatch(session)
346
439
  session.load = () => load(session)
347
440
  return session
348
441
  }
349
442
 
443
+ // Function: visibleColumn
444
+ // `id` when the current view mode shows that column, else the pane that owns
445
+ // the current directory. Used to keep a restored focus on a column that
446
+ // exists.
447
+ function visibleColumn(session, id) {
448
+ const current = session.currentId.value
449
+ const columns =
450
+ session.viewMode.value === "explorer"
451
+ ? session.showPreview.value
452
+ ? ["parent", current, "preview"]
453
+ : ["parent", current]
454
+ : ["left", "right"]
455
+ return columns.includes(id) ? id : current
456
+ }
457
+
350
458
  // Function: load
351
- // Reads the initial listing for both panes and the persisted bookmarks.
459
+ // Reads the initial listing for every column — the two panes plus the two
460
+ // mirrors, which start on a valid path and so may never navigate — and the
461
+ // persisted bookmarks.
352
462
  async function load(session) {
353
463
  await Promise.all([
354
- session.panes.left.refresh(),
355
- session.panes.right.refresh(),
464
+ ...Object.values(session.panes).map((pane) => pane.refresh()),
356
465
  session.bookmarks.load?.(),
357
466
  ])
358
467
  }
359
468
 
469
+ // 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)
479
+ }
480
+
360
481
  function entrySummary(entry) {
361
482
  if (!entry) return null
362
483
  return {
@@ -373,15 +494,22 @@ function entrySummary(entry) {
373
494
  gid: entry.gid,
374
495
  owner: entry.owner,
375
496
  ext: entry.ext,
497
+ // The symlink's raw target, so the preview and the header can name
498
+ // where the link goes.
499
+ target: entry.target ?? null,
376
500
  }
377
501
  }
378
502
 
379
- // Function: filterCommandRows
380
- // Case-insensitive title filter; an empty query keeps every row.
381
- function filterCommandRows(rows, query) {
382
- const q = `${query ?? ""}`.trim().toLowerCase()
383
- if (!q) return rows
384
- return rows.filter((row) => row.title.toLowerCase().includes(q))
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 }
385
513
  }
386
514
 
387
515
  // Function: previewKind
@@ -402,4 +530,4 @@ function hasBinaryByte(text) {
402
530
  return false
403
531
  }
404
532
 
405
- export { createSession, load }
533
+ export { createSession, load, previewSource }
@@ -44,7 +44,6 @@ export {
44
44
  prefix,
45
45
  rankEntries,
46
46
  runLine,
47
- scriptItems,
48
47
  sortEntries,
49
48
  split,
50
49
  toggleMark,