@ssebastien/tcmd 0.1.3 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +256 -62
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/desktop/dist/index.html +13 -0
  5. package/desktop/dist/main.js +20298 -0
  6. package/desktop/dist/style.css +1515 -0
  7. package/desktop/frontend/index.html +13 -0
  8. package/package.json +19 -6
  9. package/public/fonts/OFL.txt +93 -0
  10. package/public/index.html +14 -0
  11. package/public/licenses/LUCIDE.txt +43 -0
  12. package/public/main.js +20679 -0
  13. package/public/style.css +1515 -0
  14. package/scripts/build-desktop.js +31 -0
  15. package/scripts/serve-web.js +61 -0
  16. package/src/js/tcmd/components/App.html +80 -14
  17. package/src/js/tcmd/components/App.js +339 -114
  18. package/src/js/tcmd/components/App.xml +76 -22
  19. package/src/js/tcmd/components/BookmarkList.html +5 -5
  20. package/src/js/tcmd/components/BookmarkList.js +15 -41
  21. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  22. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  23. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  24. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  25. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  26. package/src/js/tcmd/components/CommandArgRow.html +28 -0
  27. package/src/js/tcmd/components/CommandArgRow.js +146 -30
  28. package/src/js/tcmd/components/CommandArgRow.xml +55 -4
  29. package/src/js/tcmd/components/CommandBar.html +2 -2
  30. package/src/js/tcmd/components/CommandBar.js +33 -27
  31. package/src/js/tcmd/components/CommandBar.xml +2 -2
  32. package/src/js/tcmd/components/CommandItem.html +1 -1
  33. package/src/js/tcmd/components/CommandItem.js +25 -25
  34. package/src/js/tcmd/components/CommandItem.xml +2 -2
  35. package/src/js/tcmd/components/CommandPanel.html +29 -0
  36. package/src/js/tcmd/components/CommandPanel.js +78 -52
  37. package/src/js/tcmd/components/CommandPanel.xml +49 -25
  38. package/src/js/tcmd/components/Confirm.html +2 -2
  39. package/src/js/tcmd/components/EntryDetail.html +7 -7
  40. package/src/js/tcmd/components/EntryDetail.js +22 -8
  41. package/src/js/tcmd/components/FileList.html +5 -1
  42. package/src/js/tcmd/components/FileList.js +2 -1
  43. package/src/js/tcmd/components/FilePicker.html +10 -0
  44. package/src/js/tcmd/components/FilePicker.js +43 -0
  45. package/src/js/tcmd/components/FilePicker.xml +13 -0
  46. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  47. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  48. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  49. package/src/js/tcmd/components/FilePreview.html +14 -4
  50. package/src/js/tcmd/components/FilePreview.js +111 -12
  51. package/src/js/tcmd/components/FilePreview.xml +28 -8
  52. package/src/js/tcmd/components/FileRow.html +2 -1
  53. package/src/js/tcmd/components/FileRow.js +70 -12
  54. package/src/js/tcmd/components/FilterBar.html +1 -1
  55. package/src/js/tcmd/components/FilterBar.js +14 -4
  56. package/src/js/tcmd/components/FilterBar.xml +1 -1
  57. package/src/js/tcmd/components/Frame.js +324 -0
  58. package/src/js/tcmd/components/Help.html +15 -0
  59. package/src/js/tcmd/components/Help.js +260 -0
  60. package/src/js/tcmd/components/Help.xml +13 -4
  61. package/src/js/tcmd/components/HelpRow.html +8 -0
  62. package/src/js/tcmd/components/HelpRow.xml +7 -0
  63. package/src/js/tcmd/components/OverlayList.html +8 -0
  64. package/src/js/tcmd/components/OverlayList.js +69 -0
  65. package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
  66. package/src/js/tcmd/components/OverlayRow.html +5 -0
  67. package/src/js/tcmd/components/OverlayRow.js +47 -0
  68. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  69. package/src/js/tcmd/components/Summary.html +1 -1
  70. package/src/js/tcmd/components/Summary.js +1 -6
  71. package/src/js/tcmd/components/format.js +4 -23
  72. package/src/js/tcmd/components/icons.js +176 -5
  73. package/src/js/tcmd/components/index.js +9 -5
  74. package/src/js/tcmd/components/rows.js +176 -48
  75. package/src/js/tcmd/core/base64.js +64 -0
  76. package/src/js/tcmd/core/bookmarks.js +45 -8
  77. package/src/js/tcmd/core/commands.js +978 -260
  78. package/src/js/tcmd/core/concurrency.js +59 -0
  79. package/src/js/tcmd/core/decompress.js +117 -0
  80. package/src/js/tcmd/core/directory.js +219 -93
  81. package/src/js/tcmd/core/format.js +36 -0
  82. package/src/js/tcmd/core/fs.js +438 -54
  83. package/src/js/tcmd/core/history.js +58 -0
  84. package/src/js/tcmd/core/index.js +3 -9
  85. package/src/js/tcmd/core/journal.js +93 -0
  86. package/src/js/tcmd/core/keymap.js +42 -6
  87. package/src/js/tcmd/core/operations.js +813 -48
  88. package/src/js/tcmd/core/os.js +29 -3
  89. package/src/js/tcmd/core/pane.js +33 -8
  90. package/src/js/tcmd/core/panel.js +280 -104
  91. package/src/js/tcmd/core/path.js +30 -3
  92. package/src/js/tcmd/core/permissions.js +107 -0
  93. package/src/js/tcmd/core/picker.js +156 -0
  94. package/src/js/tcmd/core/preview.js +102 -0
  95. package/src/js/tcmd/core/script.js +7 -95
  96. package/src/js/tcmd/core/selection.js +9 -1
  97. package/src/js/tcmd/core/session.js +498 -200
  98. package/src/js/tcmd/core/watch.js +153 -0
  99. package/src/js/tcmd/desktop/backend/api.js +165 -0
  100. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  101. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  102. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  103. package/src/js/tcmd/desktop/fs.js +91 -0
  104. package/src/js/tcmd/desktop/main.js +194 -0
  105. package/src/js/tcmd/desktop/os.js +33 -0
  106. package/src/js/tcmd/index.js +0 -1
  107. package/src/js/tcmd/tui/app.js +118 -60
  108. package/src/js/tcmd/tui/catalogue.js +238 -42
  109. package/src/js/tcmd/tui/index.js +18 -19
  110. package/src/js/tcmd/tui/opentui.js +33 -0
  111. package/src/js/tcmd/tui/syntax.js +91 -0
  112. package/src/js/tcmd/tui/theme.js +32 -2
  113. package/src/js/tcmd/vendor/bzip2.js +276 -0
  114. package/src/js/tcmd/web/app.js +225 -41
  115. package/src/js/tcmd/web/bookmarks.js +36 -0
  116. package/src/js/tcmd/web/catalogue.js +81 -38
  117. package/src/js/tcmd/web/effects.js +223 -0
  118. package/src/js/tcmd/web/header.html +1 -1
  119. package/src/js/tcmd/web/header.js +15 -11
  120. package/src/js/tcmd/web/http.js +13 -0
  121. package/src/js/tcmd/web/index.js +2 -2
  122. package/src/js/tcmd/web/main.js +6 -66
  123. package/src/js/tcmd/web/opfs.js +297 -30
  124. package/src/js/tcmd/web/seed.js +4 -4
  125. package/src/js/tcmd/web/server.js +124 -21
  126. package/src/js/tcmd/web/theme.js +4 -0
  127. package/tinyjs.json +16 -0
  128. package/src/js/tcmd/components/CommandPalette.html +0 -7
  129. package/src/js/tcmd/components/CommandPalette.js +0 -58
  130. package/src/js/tcmd/components/PaletteRow.html +0 -3
  131. package/src/js/tcmd/components/PaletteRow.js +0 -43
  132. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  133. package/src/js/tcmd/tui/frame.js +0 -269
  134. package/src/js/tcmd/tui/help.js +0 -32
@@ -5,20 +5,36 @@
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 { createJournal } from "./journal.js"
22
+ import { createDispatch, getCommandBarItems } from "./commands.js"
14
23
  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"
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"
29
+ import { indexOfPath } from "./selection.js"
30
+ import { createPane, PANE_BUFFERS } from "./pane.js"
19
31
  import { hasMatch, score } from "../vendor/fzy.js"
20
32
 
21
- 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"
22
38
 
23
39
  // Function: createSession
24
40
  // Creates the shared model for a two-pane browser.
@@ -29,62 +45,217 @@ const PREVIEW_BYTES = 65536
29
45
  // await session.load()
30
46
  // session.dispatch("pane.toggle")
31
47
  // ```
32
- function createSession({ fs, os, left, right, host, bookmarks } = {}) {
48
+ function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explorer" } = {}) {
33
49
  const session = {}
34
50
  session.fs = fs
35
51
  session.os = os ?? createNullOs()
36
52
  session.panes = { left, right }
53
+ // Auxiliary panes: the explorer's parent column and the inspector's
54
+ // directory column. Both start on a valid path so a refresh before their
55
+ // first sync can never read a blank one.
56
+ session.panes.parent = createPane({ fs, path: parentPath(left.path.value) ?? left.path.value })
57
+ session.panes.preview = createPane({ fs, path: left.path.value })
58
+ // File picker: a private pane the command panel browses while a path
59
+ // argument is being picked. Kept out of `session.panes` so `load`, the
60
+ // pane refresh loop and pane navigation leave it alone; it is (re)read
61
+ // when the picker opens (see `core/commands#openPicker`).
62
+ session.pickerPane = createPane({ fs, path: left.path.value })
37
63
  // Shown in the header title; empty when the environment has no hostname.
38
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
39
68
  // Bookmark model (named paths). The store defaults to in-memory.
40
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)
82
+ // View mode: the three-column "explorer" (parent · current · preview) the
83
+ // workspace opens in, or the two-pane "commander". `view.toggleMode` (`t`)
84
+ // flips it; the workspace chrome in `components/Frame` reads it to decide
85
+ // which columns exist. Callers that need the two-pane workspace (tests, a
86
+ // host that prefers it) pass `viewMode: "commander"`.
87
+ session.viewMode = cell(viewMode)
41
88
  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,
89
+ // The source/destination pane focus last was on. The auxiliary columns
90
+ // are attached to it, so `currentId` survives focus moving into them.
91
+ session.anchor = cell("left")
92
+ // The pane that owns the current directory: the focused pane when focus
93
+ // is on a source/destination pane, the anchor otherwise. Everything that
94
+ // changes a *path* (open, up, back, breadcrumbs, the command panel's
95
+ // relative arguments) targets it; cursor-only movement targets the
96
+ // focused pane.
97
+ session.currentId = derived([session.active, session.anchor], ([active, anchor]) =>
98
+ active === "left" || active === "right" ? active : anchor,
99
+ )
100
+ // The current pane's directory, tracked by the header title and by the
101
+ // parent column's mirror.
102
+ session.activePath = derived([session.currentId, left.path, right.path], ([current, l, r]) =>
103
+ current === "left" ? l : r,
45
104
  )
46
105
  session.showPreview = cell(true)
47
- session.paletteOpen = cell(false)
48
- session.paletteMode = cell("ui")
49
- session.paletteQuery = cell("")
50
- session.paletteIndex = cell(0)
106
+ // The inspector maximized over the workspace columns (`⇧V`): the preview
107
+ // takes the whole pane row until toggled back, leaving the identity and
108
+ // command bars in place. Independent of `showPreview`, so restoring the
109
+ // layout returns the inline inspector to exactly what it was.
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)
51
118
  session.notice = cell(null)
52
119
  session.confirm = cell(null)
53
120
  // The pane whose filter input currently owns the keyboard, or null.
54
121
  session.filtering = cell(null)
122
+ // Back/forward restore the focused column alongside the listing: each
123
+ // source/destination pane captures `active` into its history entries and
124
+ // restores it on Esc/⇧Esc. A column the current mode does not show falls
125
+ // back to the pane owning the current directory.
126
+ const focusContext = {
127
+ get: () => session.active.value,
128
+ set: (value) => {
129
+ if (value) session.active.set(visibleColumn(session, value))
130
+ },
131
+ }
132
+ left.setContext(focusContext)
133
+ right.setContext(focusContext)
55
134
 
56
- // Bookmark overlay: `bookmarkMode` is "name" while recording (an input for
57
- // the new bookmark's name) or "list" while jumping (a fuzzy picker).
135
+ // Focus remembers which source/destination pane it came from.
136
+ effect([session.active], ([active]) => {
137
+ if (active === "left" || active === "right") session.anchor.set(active)
138
+ })
139
+ // The parent column only exists in explorer mode: leaving the mode hands
140
+ // focus back to the pane it was following.
141
+ effect([session.viewMode], ([mode]) => {
142
+ if (mode !== "explorer" && session.active.value === "parent") {
143
+ session.active.set(session.currentId.value)
144
+ }
145
+ })
146
+ // The parent column mirrors the parent of the current directory. It never
147
+ // moves on its own: opening, stepping up or following a breadcrumb in it
148
+ // retargets the current pane instead (see `core/commands`). Its cursor
149
+ // follows the current directory, so the mirror always highlights the
150
+ // entry the current pane is showing. Mirroring never records history: the
151
+ // column's own back/forward stays on the paths the user navigated.
152
+ //
153
+ // `parent.entries` is a dependency so the cursor also lands when the
154
+ // mirror's listing arrives *after* the effect first runs — the explorer is
155
+ // the opening mode, so its parent column is populated by the initial
156
+ // `load` rather than by a later navigation.
157
+ effect([session.activePath, session.viewMode, session.panes.parent.entries], ([path, mode]) => {
158
+ if (mode !== "explorer" || !path) return
159
+ // The filesystem root has no parent; mirror itself rather than read a
160
+ // blank path.
161
+ const target = parentPath(path) ?? path
162
+ const parent = session.panes.parent
163
+ // Land the mirror's cursor on the current directory once its listing
164
+ // knows it. A sync left over from an older navigation yields: it
165
+ // checks the live path before writing the cursor.
166
+ const sync = () => {
167
+ if (session.activePath.value !== path) return
168
+ const index = indexOfPath(parent.entries.value, path)
169
+ if (index >= 0) parent.cursor.set(index)
170
+ }
171
+ if (parent.path.value !== target) void parent.navigate(target, { record: false }).then(sync)
172
+ else sync()
173
+ })
174
+
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).
58
178
  session.bookmarkOpen = cell(false)
59
- session.bookmarkMode = cell("list")
60
179
  session.bookmarkQuery = cell("")
61
180
  session.bookmarkIndex = cell(0)
62
- // The path being recorded while in "name" mode.
63
181
  session.bookmarkTarget = cell(null)
64
182
 
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.
183
+ // Command panel (`:`): the one entry point, listing the CLI operations,
184
+ // with the selected operation's arguments bound from the live context.
185
+ // `panelQuery` filters the entry list by typed name; `panelOpIndex` selects
186
+ // an entry in that filtered list; `panelRowIndex` is the cursor in the
187
+ // flattened argument rows (headers + items); `panelFocus` is a
188
+ // `PANEL_FOCUS` token ("ops": typing
189
+ // filters entries, "args": an argument owns the keyboard). `panelDraft`
190
+ // holds the transient bindings the user tweaks — it never mutates the real
191
+ // pane marks.
73
192
  session.panelOpen = cell(false)
74
193
  session.panelQuery = cell("")
75
194
  session.panelOpIndex = cell(0)
76
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)
77
199
  session.panelFocus = cell(PANEL_FOCUS.OPS)
78
200
  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)
83
-
84
- // The command palette keeps its cursor on the first match whenever the
85
- // query changes.
86
- effect([session.paletteQuery], () => session.paletteIndex.set(0))
87
- // The bookmark picker likewise.
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)
209
+
210
+ // Picker sub-mode: while a `dest`/`path` argument is being browsed the
211
+ // panel's argument column shows the picker's listing instead of the
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
215
+ // once a pick that was holding up a run completes.
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("")
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
+ )
256
+
257
+ // The bookmark panel keeps its cursor on the first match whenever the query
258
+ // changes.
88
259
  effect([session.bookmarkQuery], () => session.bookmarkIndex.set(0))
89
260
 
90
261
  // Filter editing is bound to the active pane: switching pane drops the
@@ -92,42 +263,72 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
92
263
  effect([session.active], () => session.filtering.set(null))
93
264
 
94
265
  // 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]),
266
+ // listing carries the stat details (ctime, mode, uid, gid), so the detail
267
+ // line updates as the cursor moves without a separate stat pass.
268
+ session.leftCursorEntry = derived([left.entries, left.cursor], ([entries, cursor]) =>
269
+ entrySummary(entries[cursor]),
270
+ )
271
+ session.rightCursorEntry = derived([right.entries, right.cursor], ([entries, cursor]) =>
272
+ entrySummary(entries[cursor]),
99
273
  )
100
- session.rightCursorEntry = derived([right.entries, right.cursor, right.cursorStat], ([entries, cursor, stat]) =>
101
- entrySummary(stat ?? entries[cursor]),
274
+ // The same detail line for the explorer's parent column.
275
+ const parent = session.panes.parent
276
+ session.parentCursorEntry = derived([parent.entries, parent.cursor], ([entries, cursor]) =>
277
+ entrySummary(entries[cursor]),
102
278
  )
103
279
 
104
- // Preview target: a `view`/`stat` pin, or the active pane's cursor entry.
280
+ // Preview target: a `view`/`stat` pin, or the current pane's cursor entry.
281
+ // Focus on an auxiliary column does not move it: the preview keeps
282
+ // following the pane that owns the directory.
105
283
  session.previewTarget = cell(null)
106
284
  session.previewEntry = derived(
107
- [session.active, session.leftCursorEntry, session.rightCursorEntry, session.previewTarget],
108
- ([active, l, r, target]) => target ?? (active === "left" ? l : r),
285
+ [session.currentId, session.leftCursorEntry, session.rightCursorEntry, session.previewTarget],
286
+ ([current, l, r, target]) => target ?? (current === "left" ? l : r),
109
287
  )
110
288
 
111
289
  // Moving either cursor resumes following the cursor in the preview.
112
290
  effect([session.leftCursorEntry, session.rightCursorEntry], () => session.previewTarget.set(null))
113
291
 
292
+ // The preview column mirrors the directory being previewed, so a
293
+ // directory reads as a pane in every view mode. It never moves on its
294
+ // own for the same reason as the parent column.
295
+ effect([session.previewEntry], ([entry]) => {
296
+ if (!entry?.isDir) return
297
+ if (session.panes.preview.path.value !== entry.path) {
298
+ void session.panes.preview.navigate(entry.path, { record: false })
299
+ }
300
+ })
301
+
114
302
  session._previewToken = 0
115
303
 
116
304
  // Preview payload: text head for files, metadata for the rest; `stat`
117
305
  // targets carry `details` and render as a card even for directories.
118
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 })
119
312
  session._stopPreviewWatch = effect([session.previewEntry, session.showPreview], async ([entry, show]) => {
120
313
  if (!show || !entry) {
121
314
  session.preview.set(null)
122
315
  return
123
316
  }
124
- const kind = entry.isDir ? "directory" : previewKind(entry)
317
+ // The preview follows symlinks: its title names the traversal and its
318
+ // kind comes from what the link points at, not from the link's own
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).
322
+ const source = previewSource(entry)
323
+ const kind = entry.isDir ? "directory" : previewKind(source.innerExt)
125
324
  if (entry.details) {
126
325
  session.preview.set({
127
326
  kind,
128
- title: basename(entry.path),
327
+ title: source.title,
129
328
  size: entry.size,
130
- ext: entry.ext,
329
+ ext: source.ext,
330
+ innerExt: source.innerExt,
331
+ compression: source.compression,
131
332
  details: entry.details,
132
333
  text: null,
133
334
  })
@@ -138,14 +339,27 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
138
339
  return
139
340
  }
140
341
  const token = ++session._previewToken
141
- const head =
142
- 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
+ }
143
355
  if (token !== session._previewToken) return
144
356
  session.preview.set({
145
357
  kind,
146
- title: basename(entry.path),
358
+ title: source.title,
147
359
  size: entry.size,
148
- ext: entry.ext,
360
+ ext: source.ext,
361
+ innerExt: source.innerExt,
362
+ compression: source.compression,
149
363
  text: head === null || hasBinaryByte(head) ? null : head,
150
364
  })
151
365
  })
@@ -177,113 +391,146 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
177
391
  },
178
392
  )
179
393
 
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
- // Bookmark picker rows: the bookmarks fuzzily matched by name or path,
212
- // 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.
213
397
  session.bookmarkItems = derived(
214
- [session.bookmarks.entries, session.bookmarkQuery, session.bookmarkMode],
215
- ([entries, query, mode]) => {
216
- if (mode !== "list") return []
217
- 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 }))
218
401
  const q = `${query ?? ""}`.trim()
219
- if (!q) return rows.sort((a, b) => a.name.localeCompare(b.name))
220
- const ranked = []
221
- for (const row of rows) {
222
- const best = Math.max(
223
- hasMatch(q, row.name) ? score(q, row.name) : -Infinity,
224
- hasMatch(q, row.path) ? score(q, row.path) : -Infinity,
225
- )
226
- 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)
416
+ }
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]
227
419
  }
228
- ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : a.row.name.localeCompare(b.row.name)))
229
- return ranked.map((match) => match.row)
420
+ return items
230
421
  },
231
422
  )
232
423
 
233
424
  // Command bar rows: context-dependent key hints (see core/commands). The
234
- // TUI-only panel button is dropped when the renderer disables the panel.
425
+ // panel button is dropped when a host disables the panel.
235
426
  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
- },
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 }),
250
430
  )
251
431
 
252
- // The operation list filtered by the typed query; `panelOpIndex` indexes
253
- // into this filtered list.
432
+ // The entry list filtered by the typed query; `panelOpIndex` indexes into
433
+ // this filtered list.
254
434
  session.panelFiltered = derived(
255
- [session.panelOps, session.panelQuery],
256
- ([operations, query]) => filterPanelOperations(operations, query),
435
+ [session.panelEntries, session.panelQuery],
436
+ ([entries, query]) => filterPanelEntries(entries, query),
257
437
  )
258
- // The operation the command panel is editing, and its bound arguments.
259
- session.panelOperation = derived(
438
+ // The entry the command panel is editing, and its bound arguments.
439
+ session.panelEntry = derived(
260
440
  [session.panelFiltered, session.panelOpIndex],
261
- ([operations, index]) => operations[Math.max(0, Math.min(index, operations.length - 1))] ?? null,
441
+ ([entries, index]) => entries[Math.max(0, Math.min(index, entries.length - 1))] ?? null,
442
+ )
443
+ // The picker's visible listing for the current mode: a destination picker
444
+ // lists directories only, so every row is a valid choice; a file/any
445
+ // picker keeps the listing intact, where a directory row descends.
446
+ session.pickerVisible = derived(
447
+ [session.pickerPane.entries, session.pickerMode],
448
+ ([entries, mode]) => pickerVisibleEntries(entries, mode),
449
+ )
450
+ session.pickerRows = derived(
451
+ [session.pickerVisible, session.pickerPane.path],
452
+ ([entries, path]) => pickerRowList(entries, path),
262
453
  )
263
- session.panelRows = derived([session.panelDraft], ([draft]) => panelRowList(draft))
264
- // The assembled command line the draft maps to, and whether it is runnable.
265
- // 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.
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
+ },
501
+ )
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(
507
+ [session.panelDraft, session.panelEntry],
508
+ ([draft, entry]) => {
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.
268
516
  session.panelCommand = derived(
269
- [session.panelDraft, session.panelOperation],
270
- ([draft, op]) => {
271
- const args = panelCommand(draft)
272
- const name = op?.name ?? ""
273
- return args ? `${name} ${args}` : name
517
+ [session.panelDraft, session.panelEntry, session.panelPreview],
518
+ ([draft, entry, preview]) => {
519
+ if (typeof preview === "string") return preview
520
+ return panelCommandLine(session, entry, draft)
274
521
  },
275
522
  )
276
523
  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).
524
+ // The argument row under the right pane's cursor, and whether it is a
525
+ // textual argument (so the panel focuses its value field).
279
526
  session.panelCursorRow = derived(
280
527
  [session.panelRows, session.panelRowIndex],
281
528
  ([rows, row]) => rowAt(rows, row),
282
529
  )
283
530
  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
531
+ // The text the value field edits. A plain cell (the input writes through
532
+ // it); an effect copies the focused argument's value in whenever the cursor
533
+ // lands on a textual row, and another folds typing back into the transient
287
534
  // draft so a value survives moving to another argument.
288
535
  session.panelValue = cell("")
289
536
  effect([session.panelCursorRow], ([row]) => {
@@ -296,67 +543,141 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
296
543
  }
297
544
  })
298
545
 
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
- )
546
+ // The command-list column's rows: the filtered operations.
547
+ session.panelItems = derived([session.panelFiltered], ([entries]) => panelEntryRows(entries))
311
548
 
312
- // Re-bind the panel draft when the panel opens or the selected operation
549
+ // Re-bind the panel draft when the panel opens or the selected entry
313
550
  // 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]) => {
551
+ // underneath it; opening the panel owns the draft while it is open.
552
+ effect([session.panelOpen, session.panelEntry], ([open, entry]) => {
316
553
  if (!open) return
317
- session.panelDraft.set(bindArgs(session, op))
554
+ const draft = bindArgs(session, entry)
555
+ prefillPanelDraft(session, entry, draft)
556
+ session.panelDraft.set(draft)
318
557
  })
319
- // Selecting another operation resets the argument cursor.
320
- effect([session.panelOperation], () => {
558
+ // Selecting another entry resets the argument cursor.
559
+ effect([session.panelEntry], () => {
321
560
  session.panelRowIndex.set(0)
561
+ session.panelRunError.set(null)
562
+ session.panelArmed.set(false)
322
563
  })
323
- // Typing a query re-anchors the operation cursor on the first match.
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)
569
+ })
570
+ // Typing a query re-anchors the entry cursor on the first match.
324
571
  effect([session.panelQuery], () => {
325
572
  session.panelOpIndex.set(0)
326
573
  })
327
574
 
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
- )
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.
585
+ effect([session.pickerQuery], ([query]) => {
586
+ session.pickerPane.setFilter(`${query ?? ""}`)
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
+ }
592
+ })
593
+ // Closing the panel leaves the picker (it lives inside the panel).
594
+ effect([session.panelOpen], ([open]) => {
595
+ if (open) return
596
+ session.pickerOpen.set(false)
597
+ session.pickerArg.set(-1)
598
+ session.pickerPendingRun.set(false)
599
+ session.panelArmed.set(false)
600
+ })
344
601
 
345
602
  session.dispatch = createDispatch(session)
346
603
  session.load = () => load(session)
347
604
  return session
348
605
  }
349
606
 
607
+ // Function: visibleColumn
608
+ // `id` when the current view mode shows that column, else the pane that owns
609
+ // the current directory. Used to keep a restored focus on a column that
610
+ // exists.
611
+ function visibleColumn(session, id) {
612
+ const current = session.currentId.value
613
+ const columns =
614
+ session.viewMode.value === "explorer"
615
+ ? session.showPreview.value
616
+ ? ["parent", current, "preview"]
617
+ : ["parent", current]
618
+ : ["left", "right"]
619
+ return columns.includes(id) ? id : current
620
+ }
621
+
350
622
  // Function: load
351
- // Reads the initial listing for both panes and the persisted bookmarks.
623
+ // Reads the initial listing for every column — the two panes plus the two
624
+ // mirrors, which start on a valid path and so may never navigate — and the
625
+ // persisted bookmarks.
352
626
  async function load(session) {
353
627
  await Promise.all([
354
- session.panes.left.refresh(),
355
- session.panes.right.refresh(),
628
+ ...Object.values(session.panes).map((pane) => pane.refresh()),
356
629
  session.bookmarks.load?.(),
357
630
  ])
358
631
  }
359
632
 
633
+ // Function: prefillPanelDraft
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
679
+ }
680
+
360
681
  function entrySummary(entry) {
361
682
  if (!entry) return null
362
683
  return {
@@ -373,33 +694,10 @@ function entrySummary(entry) {
373
694
  gid: entry.gid,
374
695
  owner: entry.owner,
375
696
  ext: entry.ext,
697
+ // The symlink's raw target, so the preview and the header can name
698
+ // where the link goes.
699
+ target: entry.target ?? null,
376
700
  }
377
701
  }
378
702
 
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))
385
- }
386
-
387
- // Function: previewKind
388
- // Preview classification for file entries; directories are not previewed.
389
- function previewKind(entry) {
390
- const image = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]
391
- const media = ["mp3", "wav", "ogg", "flac", "mp4", "webm", "mkv", "mov", "pdf"]
392
- if (entry.ext && image.includes(entry.ext)) return "image"
393
- if (entry.ext && media.includes(entry.ext)) return "media"
394
- return "text"
395
- }
396
-
397
- function hasBinaryByte(text) {
398
- for (let i = 0; i < 2048 && i < text.length; i++) {
399
- const code = text.charCodeAt(i)
400
- if (code < 9 || (code > 13 && code < 32)) return true
401
- }
402
- return false
403
- }
404
-
405
703
  export { createSession, load }