@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.
- package/README.md +169 -58
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/OverlayList.xml +13 -0
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +612 -136
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/session.js +286 -116
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- 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,
|
|
24
|
-
import {
|
|
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
|
-
|
|
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
|
|
147
|
-
//
|
|
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. `
|
|
175
|
-
// list offers and what Enter accepts; `
|
|
176
|
-
// chosen path is written back to; `
|
|
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.
|
|
179
|
-
session.
|
|
180
|
-
session.
|
|
181
|
-
session.
|
|
182
|
-
// The picker's
|
|
183
|
-
//
|
|
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
|
|
187
|
-
//
|
|
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(
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
302
|
-
// ranked by fzy score.
|
|
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.
|
|
305
|
-
([entries, query,
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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.
|
|
327
|
-
([filtering, bookmarkOpen,
|
|
328
|
-
getCommandBarItems({ filtering, bookmarkOpen,
|
|
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.
|
|
347
|
-
([entries, mode]) => (
|
|
447
|
+
[session.pickerPane.entries, session.pickerMode],
|
|
448
|
+
([entries, mode]) => pickerVisibleEntries(entries, mode),
|
|
348
449
|
)
|
|
349
|
-
session.pickerRows = derived(
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
358
|
-
//
|
|
359
|
-
session.
|
|
360
|
-
|
|
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
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
session.
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
424
|
-
//
|
|
425
|
-
//
|
|
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.
|
|
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.
|
|
434
|
-
session.
|
|
435
|
-
session.
|
|
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:
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
function prefillPanelDraft(entry, draft) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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 }
|