@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.
- package/README.md +121 -38
- package/bin/tcmd +10 -2
- package/bin/tcmd.js +13 -2
- package/package.json +4 -3
- package/public/index.html +14 -0
- package/public/main.js +17501 -0
- package/public/style.css +901 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +28 -12
- package/src/js/tcmd/components/App.js +135 -85
- package/src/js/tcmd/components/App.xml +64 -15
- package/src/js/tcmd/components/BookmarkList.html +3 -2
- package/src/js/tcmd/components/BookmarkList.js +1 -1
- package/src/js/tcmd/components/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +17 -0
- package/src/js/tcmd/components/CommandArgRow.js +86 -26
- package/src/js/tcmd/components/CommandArgRow.xml +31 -4
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +3 -3
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +1 -0
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +54 -52
- package/src/js/tcmd/components/CommandPanel.xml +38 -24
- package/src/js/tcmd/components/Confirm.html +1 -1
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +1 -1
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- package/src/js/tcmd/components/FilePreview.html +14 -3
- package/src/js/tcmd/components/FilePreview.js +73 -10
- package/src/js/tcmd/components/FilePreview.xml +19 -6
- package/src/js/tcmd/components/FileRow.html +1 -1
- package/src/js/tcmd/components/FileRow.js +10 -3
- package/src/js/tcmd/components/FilterBar.html +1 -1
- package/src/js/tcmd/components/FilterBar.js +14 -4
- package/src/js/tcmd/components/FilterBar.xml +1 -1
- package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
- package/src/js/tcmd/components/Help.html +7 -0
- package/src/js/tcmd/components/Help.js +109 -0
- package/src/js/tcmd/components/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/icons.js +39 -5
- package/src/js/tcmd/components/index.js +3 -4
- package/src/js/tcmd/components/rows.js +37 -33
- package/src/js/tcmd/core/commands.js +422 -180
- package/src/js/tcmd/core/directory.js +145 -95
- package/src/js/tcmd/core/fs.js +29 -2
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/keymap.js +3 -3
- package/src/js/tcmd/core/operations.js +108 -15
- package/src/js/tcmd/core/pane.js +31 -8
- package/src/js/tcmd/core/panel.js +172 -82
- package/src/js/tcmd/core/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +280 -152
- package/src/js/tcmd/index.js +0 -1
- package/src/js/tcmd/tui/app.js +26 -5
- package/src/js/tcmd/tui/catalogue.js +6 -19
- package/src/js/tcmd/tui/index.js +1 -16
- package/src/js/tcmd/tui/theme.js +5 -0
- package/src/js/tcmd/web/app.js +158 -32
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +45 -39
- package/src/js/tcmd/web/effects.js +143 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/seed.js +3 -3
- package/src/js/tcmd/web/server.js +6 -4
- package/src/js/tcmd/web/theme.js +1 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/CommandPalette.xml +0 -16
- package/src/js/tcmd/components/PaletteRow.html +0 -3
- package/src/js/tcmd/components/PaletteRow.js +0 -43
- package/src/js/tcmd/components/PaletteRow.xml +0 -4
- 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
|
|
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 {
|
|
21
|
+
import { createDispatch, getCommandBarItems } from "./commands.js"
|
|
14
22
|
import { createNullOs } from "./os.js"
|
|
15
|
-
import { PANEL_FOCUS, bindArgs,
|
|
16
|
-
import { basename, join } from "./path.js"
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 (
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// that filtered list; `panelRowIndex` is the cursor in the
|
|
69
|
-
// argument rows (headers + items); `panelFocus` is a
|
|
70
|
-
// ("ops": typing
|
|
71
|
-
//
|
|
72
|
-
// it never mutates the real
|
|
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.
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
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
|
|
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)
|
|
96
|
-
//
|
|
97
|
-
session.leftCursorEntry = derived([left.entries, left.cursor
|
|
98
|
-
entrySummary(
|
|
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
|
|
101
|
-
entrySummary(
|
|
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
|
|
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.
|
|
108
|
-
([
|
|
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
|
-
|
|
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:
|
|
249
|
+
title: source.title,
|
|
129
250
|
size: entry.size,
|
|
130
|
-
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:
|
|
267
|
+
title: source.title,
|
|
147
268
|
size: entry.size,
|
|
148
|
-
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
|
-
//
|
|
324
|
+
// panel button is dropped when a host disables the panel.
|
|
235
325
|
session.commandBarItems = derived(
|
|
236
|
-
[
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
253
|
-
//
|
|
331
|
+
// The entry list filtered by the typed query; `panelOpIndex` indexes into
|
|
332
|
+
// this filtered list.
|
|
254
333
|
session.panelFiltered = derived(
|
|
255
|
-
[session.
|
|
256
|
-
([
|
|
334
|
+
[session.panelEntries, session.panelQuery],
|
|
335
|
+
([entries, query]) => filterPanelEntries(entries, query),
|
|
257
336
|
)
|
|
258
|
-
// The
|
|
259
|
-
session.
|
|
337
|
+
// The entry the command panel is editing, and its bound arguments.
|
|
338
|
+
session.panelEntry = derived(
|
|
260
339
|
[session.panelFiltered, session.panelOpIndex],
|
|
261
|
-
([
|
|
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
|
|
267
|
-
//
|
|
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.
|
|
270
|
-
([draft,
|
|
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 =
|
|
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
|
|
278
|
-
// its
|
|
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
|
|
285
|
-
// effect copies the focused
|
|
286
|
-
// a textual
|
|
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
|
-
//
|
|
300
|
-
|
|
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
|
|
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;
|
|
315
|
-
effect([session.panelOpen, session.
|
|
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
|
-
|
|
410
|
+
const draft = bindArgs(session, entry)
|
|
411
|
+
prefillPanelDraft(entry, draft)
|
|
412
|
+
session.panelDraft.set(draft)
|
|
318
413
|
})
|
|
319
|
-
// Selecting another
|
|
320
|
-
effect([session.
|
|
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
|
|
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
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
session.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
(
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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.
|
|
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:
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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 }
|