@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.
- package/README.md +256 -62
- package/bin/tcmd +10 -2
- package/bin/tcmd.js +13 -2
- 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 +19 -6
- package/public/fonts/OFL.txt +93 -0
- package/public/index.html +14 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +20679 -0
- package/public/style.css +1515 -0
- package/scripts/build-desktop.js +31 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +80 -14
- package/src/js/tcmd/components/App.js +339 -114
- package/src/js/tcmd/components/App.xml +76 -22
- package/src/js/tcmd/components/BookmarkList.html +5 -5
- package/src/js/tcmd/components/BookmarkList.js +15 -41
- 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/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +28 -0
- package/src/js/tcmd/components/CommandArgRow.js +146 -30
- package/src/js/tcmd/components/CommandArgRow.xml +55 -4
- package/src/js/tcmd/components/CommandBar.html +2 -2
- package/src/js/tcmd/components/CommandBar.js +33 -27
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +25 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +78 -52
- package/src/js/tcmd/components/CommandPanel.xml +49 -25
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +22 -8
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- 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 +14 -4
- package/src/js/tcmd/components/FilePreview.js +111 -12
- package/src/js/tcmd/components/FilePreview.xml +28 -8
- package/src/js/tcmd/components/FileRow.html +2 -1
- package/src/js/tcmd/components/FileRow.js +70 -12
- 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/components/Frame.js +324 -0
- package/src/js/tcmd/components/Help.html +15 -0
- package/src/js/tcmd/components/Help.js +260 -0
- 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/{CommandPalette.xml → OverlayList.xml} +2 -5
- 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/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +176 -5
- package/src/js/tcmd/components/index.js +9 -5
- package/src/js/tcmd/components/rows.js +176 -48
- 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 +978 -260
- 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 +219 -93
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +438 -54
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +42 -6
- package/src/js/tcmd/core/operations.js +813 -48
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +33 -8
- package/src/js/tcmd/core/panel.js +280 -104
- 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/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +498 -200
- 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/index.js +0 -1
- package/src/js/tcmd/tui/app.js +118 -60
- package/src/js/tcmd/tui/catalogue.js +238 -42
- package/src/js/tcmd/tui/index.js +18 -19
- 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 +32 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +225 -41
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +81 -38
- package/src/js/tcmd/web/effects.js +223 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +4 -4
- package/src/js/tcmd/web/server.js +124 -21
- package/src/js/tcmd/web/theme.js +4 -0
- package/tinyjs.json +16 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- 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/frame.js +0 -269
- package/src/js/tcmd/tui/help.js +0 -32
|
@@ -10,16 +10,21 @@
|
|
|
10
10
|
// source of truth for every presentation layer.
|
|
11
11
|
//
|
|
12
12
|
// Commands carry presentation metadata:
|
|
13
|
-
// - `kind`: "ui" for UI-level actions; the command
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// - `hidden`: true for internal commands that never show in the
|
|
13
|
+
// - `kind`: "ui" for UI-level actions; the command panel lists the two it can
|
|
14
|
+
// reach (the workspace-view toggle and Quit) alongside the CLI operations
|
|
15
|
+
// (see core/panel.js).
|
|
16
|
+
// - `hidden`: true for internal commands that never show in the panel.
|
|
17
|
+
// The default command bar is the explicit `BAR_COMMANDS` list below, in that
|
|
18
|
+
// order.
|
|
17
19
|
|
|
18
|
-
import {
|
|
19
|
-
import { PANEL_FOCUS, argvFor, bindingAt,
|
|
20
|
+
import { parent as parentOf } from "./path.js"
|
|
21
|
+
import { PANEL_FOCUS, argvFor, bindingAt, missingBinding, rowAt, withArgValue, withToggledItem } from "./panel.js"
|
|
22
|
+
import { pickerAbsolutePath, pickerDirectoryText } from "./picker.js"
|
|
23
|
+
import { hasBinaryByte, previewKind, previewSource, SNIFF_BYTES } from "./preview.js"
|
|
20
24
|
import { clampCursor, moveCursor } from "./selection.js"
|
|
25
|
+
import { currentPane } from "./pane.js"
|
|
21
26
|
import { runLine } from "./script.js"
|
|
22
|
-
import { getOperation, runOperation } from "./operations.js"
|
|
27
|
+
import { getOperation, refreshPanes, runOperation } from "./operations.js"
|
|
23
28
|
|
|
24
29
|
// Constant: PAGE / JUMP
|
|
25
30
|
// Cursor movement steps: a page for PgUp/PgDn, a shorter jump for the
|
|
@@ -27,24 +32,6 @@ import { getOperation, runOperation } from "./operations.js"
|
|
|
27
32
|
const PAGE = 10
|
|
28
33
|
const JUMP = 5
|
|
29
34
|
|
|
30
|
-
// Function: PALETTE_KEYS
|
|
31
|
-
// Command-bar hints shown while the command palette is open.
|
|
32
|
-
const PALETTE_KEYS = [
|
|
33
|
-
{ id: null, title: "Navigate", keys: "↑/↓" },
|
|
34
|
-
{ id: null, title: "Complete", keys: "Tab" },
|
|
35
|
-
{ id: null, title: "Run", keys: "Enter", primary: true },
|
|
36
|
-
{ id: null, title: "Close", keys: "Esc" },
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
// Constant: SCRIPT_KEYS
|
|
40
|
-
// Command-bar hints for scripting mode (no UI navigation, only the palette
|
|
41
|
-
// itself).
|
|
42
|
-
const SCRIPT_KEYS = [
|
|
43
|
-
{ id: null, title: "Complete", keys: "Tab" },
|
|
44
|
-
{ id: null, title: "Run", keys: "Enter", primary: true },
|
|
45
|
-
{ id: null, title: "Close", keys: "Esc" },
|
|
46
|
-
]
|
|
47
|
-
|
|
48
35
|
// Constant: FILTER_KEYS
|
|
49
36
|
// Command-bar hints shown while a pane filter is capturing input.
|
|
50
37
|
const FILTER_KEYS = [
|
|
@@ -53,18 +40,12 @@ const FILTER_KEYS = [
|
|
|
53
40
|
{ id: null, title: "Clear", keys: "Esc" },
|
|
54
41
|
]
|
|
55
42
|
|
|
56
|
-
// Constant:
|
|
57
|
-
// Command-bar hints while
|
|
58
|
-
const
|
|
59
|
-
{ id: null, title: "Save", keys: "Enter", primary: true },
|
|
60
|
-
{ id: null, title: "Cancel", keys: "Esc" },
|
|
61
|
-
]
|
|
62
|
-
|
|
63
|
-
// Constant: BOOKMARK_LIST_KEYS
|
|
64
|
-
// Command-bar hints while picking a bookmark.
|
|
65
|
-
const BOOKMARK_LIST_KEYS = [
|
|
43
|
+
// Constant: BOOKMARK_KEYS
|
|
44
|
+
// Command-bar hints while the bookmarks panel owns the keyboard.
|
|
45
|
+
const BOOKMARK_KEYS = [
|
|
66
46
|
{ id: null, title: "Navigate", keys: "↑/↓" },
|
|
67
|
-
{ id: null, title: "
|
|
47
|
+
{ id: null, title: "Remove", keys: "⌫/Del" },
|
|
48
|
+
{ id: null, title: "Open", keys: "Enter", primary: true },
|
|
68
49
|
{ id: null, title: "Close", keys: "Esc" },
|
|
69
50
|
]
|
|
70
51
|
|
|
@@ -73,11 +54,58 @@ const BOOKMARK_LIST_KEYS = [
|
|
|
73
54
|
const PANEL_HINT_KEYS = [
|
|
74
55
|
{ id: null, title: "Command", keys: "↑/↓" },
|
|
75
56
|
{ id: null, title: "Arg", keys: "⇥" },
|
|
57
|
+
{ id: null, title: "Browse", keys: "^O" },
|
|
76
58
|
{ id: null, title: "Toggle", keys: "Spc" },
|
|
77
59
|
{ id: null, title: "Run", keys: "↵", primary: true },
|
|
78
60
|
{ id: null, title: "Close", keys: "Esc" },
|
|
79
61
|
]
|
|
80
62
|
|
|
63
|
+
// Constant: PANEL_CONFIRM_KEYS
|
|
64
|
+
// The same hints after a risky action is armed: Enter now confirms.
|
|
65
|
+
const PANEL_CONFIRM_KEYS = PANEL_HINT_KEYS.map((hint) =>
|
|
66
|
+
hint.title === "Run" ? { ...hint, title: "Confirm" } : hint,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// Constant: PICKER_KEYS
|
|
70
|
+
// Command-bar hints while the panel's file picker owns the keyboard.
|
|
71
|
+
const PICKER_KEYS = [
|
|
72
|
+
{ id: null, title: "Path", keys: "type" },
|
|
73
|
+
{ id: null, title: "Row", keys: "↑/↓" },
|
|
74
|
+
{ id: null, title: "Open", keys: "→" },
|
|
75
|
+
{ id: null, title: "Go", keys: "↵", primary: true },
|
|
76
|
+
{ id: null, title: "Here", keys: "^↵" },
|
|
77
|
+
{ id: null, title: "Focus", keys: "⇥" },
|
|
78
|
+
{ id: null, title: "Back", keys: "Esc" },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
// Constant: HISTORY_KEYS / TRASH_KEYS
|
|
82
|
+
// Command-bar hints while the shared list overlay owns the keyboard.
|
|
83
|
+
const HISTORY_KEYS = [
|
|
84
|
+
{ id: null, title: "Row", keys: "↑/↓" },
|
|
85
|
+
{ id: null, title: "Jump", keys: "↵", primary: true },
|
|
86
|
+
{ id: null, title: "Close", keys: "Esc" },
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const TRASH_KEYS = [
|
|
90
|
+
{ id: null, title: "Row", keys: "↑/↓" },
|
|
91
|
+
{ id: null, title: "Restore", keys: "↵", primary: true },
|
|
92
|
+
{ id: null, title: "Close", keys: "Esc" },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
// Constant: BAR_COMMANDS
|
|
96
|
+
// The command bar's default entries, in display order. Kept as an explicit
|
|
97
|
+
// list so the bar stays a short, stable set of primary actions instead of
|
|
98
|
+
// every command that happens to be context-relevant.
|
|
99
|
+
const BAR_COMMANDS = [
|
|
100
|
+
"panel.open",
|
|
101
|
+
"list.mark",
|
|
102
|
+
"filter.start",
|
|
103
|
+
"pane.toggle",
|
|
104
|
+
"bookmark.open",
|
|
105
|
+
"view.toggleMode",
|
|
106
|
+
"app.help",
|
|
107
|
+
]
|
|
108
|
+
|
|
81
109
|
const commands = [
|
|
82
110
|
{
|
|
83
111
|
id: "nav.up",
|
|
@@ -156,10 +184,26 @@ const commands = [
|
|
|
156
184
|
title: "Open",
|
|
157
185
|
keys: "Enter",
|
|
158
186
|
kind: "ui",
|
|
159
|
-
bar: true,
|
|
160
187
|
primary: true,
|
|
161
188
|
run(session) {
|
|
162
|
-
|
|
189
|
+
// The entry under the *focused* pane's cursor decides where the
|
|
190
|
+
// current directory goes: a directory descends into it. On an
|
|
191
|
+
// auxiliary column (parent/preview) that means the current pane
|
|
192
|
+
// follows the selection, which is what keeps the mirror in step.
|
|
193
|
+
const focused = pane(session)
|
|
194
|
+
const entry = focused.currentEntry()
|
|
195
|
+
if (!entry) return
|
|
196
|
+
const current = currentPane(session)
|
|
197
|
+
if (entry.isDir) return current.navigate(entry.path)
|
|
198
|
+
// A file outside the current pane pins the inspector on it before
|
|
199
|
+
// it is opened, so the preview follows the entry that was acted on.
|
|
200
|
+
if (focused !== current) {
|
|
201
|
+
session.previewTarget.set(entry)
|
|
202
|
+
session.showPreview.set(true)
|
|
203
|
+
}
|
|
204
|
+
// A file: edit it when it is a text file the host can edit,
|
|
205
|
+
// otherwise show it fullscreen (see `openFile`).
|
|
206
|
+
return openFile(session, current, entry)
|
|
163
207
|
},
|
|
164
208
|
},
|
|
165
209
|
{
|
|
@@ -167,9 +211,11 @@ const commands = [
|
|
|
167
211
|
title: "Parent",
|
|
168
212
|
keys: "←",
|
|
169
213
|
kind: "ui",
|
|
170
|
-
bar: true,
|
|
171
214
|
run(session) {
|
|
172
|
-
|
|
215
|
+
// Stepping up always moves the current directory: the parent
|
|
216
|
+
// column mirrors it, so navigating that column itself would
|
|
217
|
+
// break the mirror.
|
|
218
|
+
return currentPane(session).up()
|
|
173
219
|
},
|
|
174
220
|
},
|
|
175
221
|
{
|
|
@@ -178,7 +224,7 @@ const commands = [
|
|
|
178
224
|
keys: "Esc",
|
|
179
225
|
kind: "ui",
|
|
180
226
|
run(session) {
|
|
181
|
-
return
|
|
227
|
+
return currentPane(session).back()
|
|
182
228
|
},
|
|
183
229
|
},
|
|
184
230
|
{
|
|
@@ -187,15 +233,14 @@ const commands = [
|
|
|
187
233
|
keys: "⇧Esc",
|
|
188
234
|
kind: "ui",
|
|
189
235
|
run(session) {
|
|
190
|
-
return
|
|
236
|
+
return currentPane(session).forward()
|
|
191
237
|
},
|
|
192
238
|
},
|
|
193
239
|
{
|
|
194
240
|
id: "pane.reload",
|
|
195
241
|
title: "Reload",
|
|
196
|
-
keys: "
|
|
242
|
+
keys: "F5/r",
|
|
197
243
|
kind: "ui",
|
|
198
|
-
bar: true,
|
|
199
244
|
run(session) {
|
|
200
245
|
return pane(session).refresh()
|
|
201
246
|
},
|
|
@@ -205,9 +250,24 @@ const commands = [
|
|
|
205
250
|
title: "Switch",
|
|
206
251
|
keys: "Tab",
|
|
207
252
|
kind: "ui",
|
|
208
|
-
bar: true,
|
|
209
253
|
run(session) {
|
|
210
|
-
|
|
254
|
+
// Cycle through the columns the current view mode shows:
|
|
255
|
+
// source ⇄ destination in commander, parent → current → preview
|
|
256
|
+
// in explorer. Focus parked on a column that is not in the cycle
|
|
257
|
+
// (the inspector in commander) lands on the other side.
|
|
258
|
+
const current = session.currentId.value
|
|
259
|
+
const order =
|
|
260
|
+
session.viewMode.value === "explorer"
|
|
261
|
+
? session.showPreview.value
|
|
262
|
+
? ["parent", current, "preview"]
|
|
263
|
+
: ["parent", current]
|
|
264
|
+
: ["left", "right"]
|
|
265
|
+
const at = order.indexOf(session.active.value)
|
|
266
|
+
const next =
|
|
267
|
+
at >= 0
|
|
268
|
+
? order[(at + 1) % order.length]
|
|
269
|
+
: order.find((paneId) => paneId !== current) ?? order[0]
|
|
270
|
+
session.active.set(next)
|
|
211
271
|
},
|
|
212
272
|
},
|
|
213
273
|
{
|
|
@@ -215,46 +275,24 @@ const commands = [
|
|
|
215
275
|
title: "Mark",
|
|
216
276
|
keys: "Spc",
|
|
217
277
|
kind: "ui",
|
|
218
|
-
bar: true,
|
|
219
278
|
run(session) {
|
|
220
279
|
pane(session).toggleMark()
|
|
221
280
|
},
|
|
222
281
|
},
|
|
223
282
|
{
|
|
224
|
-
id: "bookmark.
|
|
225
|
-
title: "
|
|
283
|
+
id: "bookmark.open",
|
|
284
|
+
title: "Bookmarks",
|
|
226
285
|
keys: "b",
|
|
227
286
|
kind: "ui",
|
|
228
|
-
bar: true,
|
|
229
287
|
run(session) {
|
|
230
|
-
//
|
|
231
|
-
// directory when the listing is empty.
|
|
288
|
+
// The add row records the entry under the cursor; it falls back to
|
|
289
|
+
// the pane's directory when the listing is empty.
|
|
232
290
|
const entry = pane(session).currentEntry()
|
|
233
291
|
const path = entry?.path ?? pane(session).path.value
|
|
234
292
|
const isDir = entry ? Boolean(entry.isDir) : true
|
|
235
|
-
const existing = session.bookmarks.find(path)
|
|
236
|
-
if (existing) {
|
|
237
|
-
// Already bookmarked: no need to re-name it.
|
|
238
|
-
session.notice.set({ kind: "tip", text: `bookmark ${existing.name}` })
|
|
239
|
-
return
|
|
240
|
-
}
|
|
241
|
-
// The default name is the path's basename.
|
|
242
|
-
session.bookmarkTarget.set({ path, isDir })
|
|
243
|
-
session.bookmarkQuery.set(basename(path))
|
|
244
|
-
session.bookmarkMode.set("name")
|
|
245
|
-
session.bookmarkOpen.set(true)
|
|
246
|
-
},
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
id: "bookmark.jump",
|
|
250
|
-
title: "Bookmarks",
|
|
251
|
-
keys: "g",
|
|
252
|
-
kind: "ui",
|
|
253
|
-
bar: true,
|
|
254
|
-
run(session) {
|
|
255
|
-
session.bookmarkTarget.set(null)
|
|
256
293
|
session.bookmarkQuery.set("")
|
|
257
|
-
session.
|
|
294
|
+
session.bookmarkIndex.set(0)
|
|
295
|
+
session.bookmarkTarget.set({ path, isDir })
|
|
258
296
|
session.bookmarkOpen.set(true)
|
|
259
297
|
},
|
|
260
298
|
},
|
|
@@ -277,30 +315,19 @@ const commands = [
|
|
|
277
315
|
},
|
|
278
316
|
},
|
|
279
317
|
{
|
|
280
|
-
id: "bookmark.
|
|
281
|
-
title: "
|
|
318
|
+
id: "bookmark.remove",
|
|
319
|
+
title: "Remove bookmark",
|
|
282
320
|
kind: "ui",
|
|
283
321
|
hidden: true,
|
|
284
|
-
run(session) {
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return
|
|
294
|
-
}
|
|
295
|
-
// Close synchronously: the global key handler and the input's
|
|
296
|
-
// `on:enter` can both fire, and a second save while the overlay is
|
|
297
|
-
// still open would re-record and flash the notice twice. A rejected
|
|
298
|
-
// `record` (rejected name) still surfaces through the dispatch's
|
|
299
|
-
// error notice.
|
|
300
|
-
closeBookmark(session)
|
|
301
|
-
return session.bookmarks.record(name, target.path, target.isDir).then(() => {
|
|
302
|
-
session.notice.set({ kind: "tip", text: `bookmarked ${name}` })
|
|
303
|
-
})
|
|
322
|
+
async run(session) {
|
|
323
|
+
const items = session.bookmarkItems.value ?? []
|
|
324
|
+
const index = clampCursor(session.bookmarkIndex.value, items.length)
|
|
325
|
+
const item = items[index]
|
|
326
|
+
// The add row is an action, not a stored bookmark.
|
|
327
|
+
if (!item || item.add) return
|
|
328
|
+
if (!(await session.bookmarks.remove(item.name))) return
|
|
329
|
+
session.bookmarkIndex.set(clampCursor(index, session.bookmarkItems.value.length))
|
|
330
|
+
session.notice.set({ kind: "tip", text: `removed bookmark ${item.name}` })
|
|
304
331
|
},
|
|
305
332
|
},
|
|
306
333
|
{
|
|
@@ -321,7 +348,9 @@ const commands = [
|
|
|
321
348
|
const items = session.bookmarkItems.value
|
|
322
349
|
const item = items[clampCursor(session.bookmarkIndex.value, items.length)]
|
|
323
350
|
if (!item) return
|
|
324
|
-
|
|
351
|
+
// The add row records the current target instead of navigating.
|
|
352
|
+
if (item.add) return addCurrentBookmark(session)
|
|
353
|
+
const target = currentPane(session)
|
|
325
354
|
const path = item.path
|
|
326
355
|
// Resolve the kind when it is unknown (legacy path-only bookmark).
|
|
327
356
|
let isDir = item.isDir
|
|
@@ -351,15 +380,145 @@ const commands = [
|
|
|
351
380
|
closeBookmark(session)
|
|
352
381
|
},
|
|
353
382
|
},
|
|
383
|
+
// Undo/redo and the shared list overlay (`u` history, `⇧T` trash). The
|
|
384
|
+
// journal records reversible operations (see core/operations); the overlay
|
|
385
|
+
// is one modal list whose rows/actions depend on the active mode.
|
|
386
|
+
{
|
|
387
|
+
id: "history.undo",
|
|
388
|
+
title: "Undo",
|
|
389
|
+
keys: "^Z",
|
|
390
|
+
kind: "ui",
|
|
391
|
+
async run(session) {
|
|
392
|
+
const entry = await session.journal.undo(session)
|
|
393
|
+
if (!entry) {
|
|
394
|
+
session.notice.set({ kind: "warning", text: "nothing to undo" })
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
await refreshPanes(session)
|
|
398
|
+
session.notice.set({ kind: "tip", text: `undid ${entry.label}` })
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
id: "history.redo",
|
|
403
|
+
title: "Redo",
|
|
404
|
+
keys: "^⇧Z",
|
|
405
|
+
kind: "ui",
|
|
406
|
+
async run(session) {
|
|
407
|
+
const entry = await session.journal.redo(session)
|
|
408
|
+
if (!entry) {
|
|
409
|
+
session.notice.set({ kind: "warning", text: "nothing to redo" })
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
await refreshPanes(session)
|
|
413
|
+
session.notice.set({ kind: "tip", text: `redid ${entry.label}` })
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: "history.open",
|
|
418
|
+
title: "History",
|
|
419
|
+
keys: "u",
|
|
420
|
+
kind: "ui",
|
|
421
|
+
run(session) {
|
|
422
|
+
openHistory(session)
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: "trash.open",
|
|
427
|
+
title: "Trash",
|
|
428
|
+
keys: "⇧T",
|
|
429
|
+
kind: "ui",
|
|
430
|
+
async run(session) {
|
|
431
|
+
await openTrash(session)
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: "file.mkdir",
|
|
436
|
+
title: "New directory",
|
|
437
|
+
keys: "n",
|
|
438
|
+
kind: "ui",
|
|
439
|
+
run(session) {
|
|
440
|
+
// The panel opens on `mkdir` with its NAME field primed, the same
|
|
441
|
+
// way `!` lands on `run`; PATH is prefilled with the current path.
|
|
442
|
+
openPanel(session, { name: "mkdir", focus: PANEL_FOCUS.ARGS })
|
|
443
|
+
const nameIndex = session.panelRows.value.findIndex(
|
|
444
|
+
(row) => row.type === "arg" && row.name === "NAME",
|
|
445
|
+
)
|
|
446
|
+
session.panelRowIndex.set(nameIndex >= 0 ? nameIndex : 0)
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
id: "overlay.up",
|
|
451
|
+
title: "Previous row",
|
|
452
|
+
kind: "ui",
|
|
453
|
+
hidden: true,
|
|
454
|
+
run(session) {
|
|
455
|
+
const rows = session.overlayItems.value
|
|
456
|
+
session.overlayIndex.set(moveCursor(session.overlayIndex.value, -1, rows.length))
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
id: "overlay.down",
|
|
461
|
+
title: "Next row",
|
|
462
|
+
kind: "ui",
|
|
463
|
+
hidden: true,
|
|
464
|
+
run(session) {
|
|
465
|
+
const rows = session.overlayItems.value
|
|
466
|
+
session.overlayIndex.set(moveCursor(session.overlayIndex.value, 1, rows.length))
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
id: "overlay.setIndex",
|
|
471
|
+
title: "Highlight row",
|
|
472
|
+
kind: "ui",
|
|
473
|
+
hidden: true,
|
|
474
|
+
run(session, payload) {
|
|
475
|
+
if (payload?.index !== undefined) session.overlayIndex.set(Math.max(0, payload.index))
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
id: "overlay.activate",
|
|
480
|
+
title: "Activate row",
|
|
481
|
+
kind: "ui",
|
|
482
|
+
hidden: true,
|
|
483
|
+
async run(session) {
|
|
484
|
+
const mode = session.overlay.value
|
|
485
|
+
const rows = session.overlayItems.value
|
|
486
|
+
const row = rows[clampCursor(session.overlayIndex.value, rows.length)]
|
|
487
|
+
if (!row) return
|
|
488
|
+
if (mode === "history") return activateHistory(session, row)
|
|
489
|
+
if (mode === "trash") return activateTrash(session, row)
|
|
490
|
+
return undefined
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
id: "overlay.cancel",
|
|
495
|
+
title: "Close list",
|
|
496
|
+
kind: "ui",
|
|
497
|
+
hidden: true,
|
|
498
|
+
run(session) {
|
|
499
|
+
closeOverlay(session)
|
|
500
|
+
},
|
|
501
|
+
},
|
|
354
502
|
{
|
|
355
503
|
id: "list.cursor",
|
|
356
|
-
title: "
|
|
504
|
+
title: "Select row",
|
|
357
505
|
kind: "ui",
|
|
358
506
|
hidden: true,
|
|
359
507
|
run(session, payload) {
|
|
360
508
|
const target = payload?.paneId ? session.panes[payload.paneId] : pane(session)
|
|
361
509
|
activate(session, payload?.paneId)
|
|
362
|
-
|
|
510
|
+
const index = payload?.index ?? 0
|
|
511
|
+
target.cursor.set(clampCursor(index, target.entries.value.length))
|
|
512
|
+
// In the explorer the parent and preview columns are mirrors: a
|
|
513
|
+
// click on one of their rows retargets the current directory
|
|
514
|
+
// instead of only moving that column's cursor (see
|
|
515
|
+
// `retargetFromParent` / `retargetFromPreview`). Focus stays on the
|
|
516
|
+
// clicked column; the pane body's own `pane:activate` agrees.
|
|
517
|
+
if (session.viewMode.value !== "explorer") return
|
|
518
|
+
if (payload?.paneId === "parent") return retargetFromParent(session, target, index)
|
|
519
|
+
if (payload?.paneId === "preview" && isColumnPane(session, "preview")) {
|
|
520
|
+
return retargetFromPreview(session, target, index)
|
|
521
|
+
}
|
|
363
522
|
},
|
|
364
523
|
},
|
|
365
524
|
{
|
|
@@ -377,9 +536,11 @@ const commands = [
|
|
|
377
536
|
kind: "ui",
|
|
378
537
|
hidden: true,
|
|
379
538
|
run(session, payload) {
|
|
380
|
-
|
|
539
|
+
// A breadcrumb click focuses its column, then lands the current
|
|
540
|
+
// directory there: the auxiliary columns mirror it, so their
|
|
541
|
+
// crumbs navigate the pane they follow.
|
|
381
542
|
activate(session, payload?.paneId)
|
|
382
|
-
return
|
|
543
|
+
return currentPane(session).navigate(payload?.path)
|
|
383
544
|
},
|
|
384
545
|
},
|
|
385
546
|
{
|
|
@@ -387,11 +548,64 @@ const commands = [
|
|
|
387
548
|
title: "Preview",
|
|
388
549
|
keys: "v",
|
|
389
550
|
kind: "ui",
|
|
390
|
-
bar: true,
|
|
391
551
|
run(session) {
|
|
392
552
|
session.showPreview.set(!session.showPreview.value)
|
|
393
553
|
},
|
|
394
554
|
},
|
|
555
|
+
{
|
|
556
|
+
id: "view.toggleViewer",
|
|
557
|
+
title: "Fullscreen",
|
|
558
|
+
kind: "ui",
|
|
559
|
+
hidden: true,
|
|
560
|
+
run(session) {
|
|
561
|
+
// The fullscreen inspector overlay. `v` opens it on a narrow
|
|
562
|
+
// terminal and Enter opens it on a file that cannot be edited (see
|
|
563
|
+
// `openFile`); the shared App controller closes it on Esc or `v`
|
|
564
|
+
// (see `components/App#createBehaviors`).
|
|
565
|
+
session.viewerOpen.set(!session.viewerOpen.value)
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
id: "view.maximize",
|
|
570
|
+
title: "Maximize preview",
|
|
571
|
+
keys: "⇧V",
|
|
572
|
+
kind: "ui",
|
|
573
|
+
run(session) {
|
|
574
|
+
// The inspector takes the whole pane row, or gives it back. The
|
|
575
|
+
// preference cells are untouched, so the previous layout — pane
|
|
576
|
+
// widths, focus and whether the preview was even shown — returns
|
|
577
|
+
// untouched on the next press.
|
|
578
|
+
session.previewFull.set(!session.previewFull.value)
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
id: "preview.scroll",
|
|
583
|
+
title: "Scroll preview",
|
|
584
|
+
kind: "ui",
|
|
585
|
+
hidden: true,
|
|
586
|
+
run(session, payload) {
|
|
587
|
+
// The renderers own the body and its measured viewport, so the
|
|
588
|
+
// command publishes a one-shot request instead of an offset. The
|
|
589
|
+
// token keeps repeated presses of the same direction distinct.
|
|
590
|
+
const delta = Math.trunc(Number(payload?.delta ?? 0))
|
|
591
|
+
if (!Number.isFinite(delta) || !delta) return
|
|
592
|
+
const current = session.previewScroll?.value
|
|
593
|
+
session.previewScroll?.set({ delta, token: (current?.token ?? 0) + 1 })
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
id: "view.toggleMode",
|
|
598
|
+
title: "Toggle Mode",
|
|
599
|
+
keys: "t",
|
|
600
|
+
kind: "ui",
|
|
601
|
+
run(session) {
|
|
602
|
+
// Flip the workspace between the two-pane commander and the
|
|
603
|
+
// three-column explorer (parent · current · preview). Focus on a
|
|
604
|
+
// column that does not exist in the target mode is handed back to
|
|
605
|
+
// the pane it was following (see the guard effect in the session).
|
|
606
|
+
session.viewMode.set(session.viewMode.value === "explorer" ? "commander" : "explorer")
|
|
607
|
+
},
|
|
608
|
+
},
|
|
395
609
|
{
|
|
396
610
|
id: "file.open",
|
|
397
611
|
title: "Open file",
|
|
@@ -414,36 +628,38 @@ const commands = [
|
|
|
414
628
|
session.notice.set({ kind: "warning", text: "no file to edit" })
|
|
415
629
|
return
|
|
416
630
|
}
|
|
417
|
-
|
|
631
|
+
// A host without an editor (the web's null adapter) reports it, the
|
|
632
|
+
// same guard `openFile` uses, instead of tripping over the throw.
|
|
633
|
+
if (session.os?.canEdit === false) {
|
|
634
|
+
session.notice.set({ kind: "warning", text: "no editor available" })
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
return session.os.edit(entry.path).then(() => currentPane(session).refresh())
|
|
418
638
|
},
|
|
419
639
|
},
|
|
420
|
-
// Command panel (
|
|
421
|
-
//
|
|
640
|
+
// Command panel (`:`): the one entry point, over both the CLI operations
|
|
641
|
+
// and the TinyCmd actions. Lifecycle, then navigation, then draft editing,
|
|
642
|
+
// then run.
|
|
422
643
|
{
|
|
423
|
-
id: "panel.
|
|
644
|
+
id: "panel.open",
|
|
424
645
|
title: "Commands",
|
|
425
|
-
keys: "
|
|
646
|
+
keys: ":",
|
|
426
647
|
kind: "ui",
|
|
427
|
-
bar: true,
|
|
428
648
|
run(session) {
|
|
429
|
-
|
|
649
|
+
openPanel(session)
|
|
430
650
|
},
|
|
431
651
|
},
|
|
432
652
|
{
|
|
433
|
-
id: "panel.
|
|
434
|
-
title: "
|
|
435
|
-
keys: ":",
|
|
653
|
+
id: "panel.shell",
|
|
654
|
+
title: "Shell command",
|
|
436
655
|
kind: "ui",
|
|
437
656
|
hidden: true,
|
|
438
657
|
run(session) {
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return
|
|
445
|
-
}
|
|
446
|
-
openPanel(session)
|
|
658
|
+
// `!` is the "run a shell command" reflex: land on the `run`
|
|
659
|
+
// operation with its raw CMD field already owning the keyboard. The
|
|
660
|
+
// field starts empty — `!` is a script-line alias, not part of the
|
|
661
|
+
// command itself.
|
|
662
|
+
openPanel(session, { name: "run", focus: PANEL_FOCUS.ARGS })
|
|
447
663
|
},
|
|
448
664
|
},
|
|
449
665
|
{
|
|
@@ -492,26 +708,27 @@ const commands = [
|
|
|
492
708
|
kind: "ui",
|
|
493
709
|
hidden: true,
|
|
494
710
|
run(session, payload) {
|
|
495
|
-
// Tab cycles the focus ring: the command
|
|
496
|
-
//
|
|
711
|
+
// Tab cycles the focus ring: the command filter, each argument header,
|
|
712
|
+
// then the Run action, wrapping at both ends (an item row is reached
|
|
713
|
+
// with the arrows). Shift-Tab walks it backwards.
|
|
497
714
|
const delta = payload?.delta === -1 ? -1 : 1
|
|
498
715
|
const rows = session.panelRows.value
|
|
499
|
-
const
|
|
500
|
-
for (let i = 0; i < rows.length; i++) if (rows[i].type === "arg")
|
|
501
|
-
if (!
|
|
716
|
+
const stops = []
|
|
717
|
+
for (let i = 0; i < rows.length; i++) if (rows[i].type === "arg" || rows[i].type === "run") stops.push(i)
|
|
718
|
+
if (!stops.length) return
|
|
502
719
|
if (session.panelFocus.value === PANEL_FOCUS.OPS) {
|
|
503
|
-
session.panelRowIndex.set(delta > 0 ?
|
|
720
|
+
session.panelRowIndex.set(delta > 0 ? stops[0] : stops[stops.length - 1])
|
|
504
721
|
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
505
722
|
return
|
|
506
723
|
}
|
|
507
|
-
const at =
|
|
724
|
+
const at = stops.indexOf(session.panelRowIndex.value)
|
|
508
725
|
if (delta > 0) {
|
|
509
|
-
if (at === -1 || at >=
|
|
510
|
-
else session.panelRowIndex.set(
|
|
726
|
+
if (at === -1 || at >= stops.length - 1) session.panelFocus.set(PANEL_FOCUS.OPS)
|
|
727
|
+
else session.panelRowIndex.set(stops[at + 1])
|
|
511
728
|
} else if (at <= 0) {
|
|
512
729
|
session.panelFocus.set(PANEL_FOCUS.OPS)
|
|
513
730
|
} else {
|
|
514
|
-
session.panelRowIndex.set(
|
|
731
|
+
session.panelRowIndex.set(stops[at - 1])
|
|
515
732
|
}
|
|
516
733
|
},
|
|
517
734
|
},
|
|
@@ -567,6 +784,31 @@ const commands = [
|
|
|
567
784
|
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
568
785
|
},
|
|
569
786
|
},
|
|
787
|
+
{
|
|
788
|
+
id: "panel.moveBit",
|
|
789
|
+
title: "Move permission bit",
|
|
790
|
+
kind: "ui",
|
|
791
|
+
hidden: true,
|
|
792
|
+
run(session, payload) {
|
|
793
|
+
// Left/Right walk the r/w/x cells of a focused permission row.
|
|
794
|
+
const row = rowAt(session.panelRows.value, session.panelRowIndex.value)
|
|
795
|
+
if (row?.type !== "perm") return
|
|
796
|
+
const delta = payload?.delta === -1 ? -1 : 1
|
|
797
|
+
session.panelPermBit.set(Math.min(2, Math.max(0, session.panelPermBit.value + delta)))
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: "panel.togglePerm",
|
|
802
|
+
title: "Toggle permission bit",
|
|
803
|
+
kind: "ui",
|
|
804
|
+
hidden: true,
|
|
805
|
+
run(session, payload) {
|
|
806
|
+
// A click on one permission cell.
|
|
807
|
+
const { argIndex, itemIndex } = payload ?? {}
|
|
808
|
+
if (!Number.isInteger(argIndex) || !Number.isInteger(itemIndex)) return
|
|
809
|
+
session.panelDraft.set(withToggledItem(session.panelDraft.value, argIndex, itemIndex, true))
|
|
810
|
+
},
|
|
811
|
+
},
|
|
570
812
|
{
|
|
571
813
|
id: "panel.toggleItem",
|
|
572
814
|
title: "Toggle argument item",
|
|
@@ -577,165 +819,321 @@ const commands = [
|
|
|
577
819
|
if (!row) return
|
|
578
820
|
const draft = session.panelDraft.value
|
|
579
821
|
if (row.type === "item") {
|
|
580
|
-
session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex))
|
|
822
|
+
session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex, row.multi !== false))
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
if (row.type === "perm") {
|
|
826
|
+
const bit = row.bits?.[session.panelPermBit.value]
|
|
827
|
+
if (bit) session.panelDraft.set(withToggledItem(draft, row.argIndex, bit.itemIndex, true))
|
|
581
828
|
return
|
|
582
829
|
}
|
|
583
830
|
// An argument header toggles its whole selection: select all when
|
|
584
|
-
// some are off, clear when all are on.
|
|
831
|
+
// some are off, clear when all are on. A single-path argument is a
|
|
832
|
+
// radio list, so its header has nothing to toggle.
|
|
585
833
|
const binding = bindingAt(draft, row.argIndex)
|
|
586
|
-
|
|
834
|
+
// Only a selection argument mass-toggles from its header; a
|
|
835
|
+
// permission grid has no safe "all" state and stays per-bit.
|
|
836
|
+
if (!binding || binding.kind !== "selection" || binding.multi !== true) return
|
|
587
837
|
const all = (binding.items ?? []).length > 0 && (binding.items ?? []).every((item) => item.selected)
|
|
588
838
|
const items = (binding.items ?? []).map((item) => ({ ...item, selected: !all }))
|
|
589
839
|
session.panelDraft.set(draft.map((b) => (b.index === row.argIndex ? { ...b, items } : b)))
|
|
590
840
|
},
|
|
591
841
|
},
|
|
592
842
|
{
|
|
593
|
-
id: "panel.
|
|
594
|
-
title: "
|
|
843
|
+
id: "panel.copy",
|
|
844
|
+
title: "Copy argument value",
|
|
595
845
|
kind: "ui",
|
|
596
846
|
hidden: true,
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if (
|
|
605
|
-
session.notice
|
|
847
|
+
run(session, payload) {
|
|
848
|
+
// A click on a read-only argument value copies it through the host
|
|
849
|
+
// (OSC 52 in the TUI, the Clipboard API in a browser); the command
|
|
850
|
+
// bar confirms so the gesture is visible even though the clipboard
|
|
851
|
+
// itself is out of band.
|
|
852
|
+
const text = `${payload?.text ?? ""}`
|
|
853
|
+
if (!text) return
|
|
854
|
+
if (!session.os?.copy?.(text)) {
|
|
855
|
+
session.notice?.set({ kind: "warning", text: "clipboard is not available" })
|
|
606
856
|
return
|
|
607
857
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// closes it. Errors stay open so the draft can be fixed.
|
|
611
|
-
if (status === "ran") closePanel(session)
|
|
858
|
+
session.notice?.set({ kind: "tip", text: `copied ${text}` })
|
|
859
|
+
showBriefPanelResult(session)
|
|
612
860
|
},
|
|
613
861
|
},
|
|
862
|
+
// File picker: a sub-mode of the panel that browses the filesystem for a
|
|
863
|
+
// path/destination argument. Lifecycle then navigation then choose.
|
|
614
864
|
{
|
|
615
|
-
id: "panel.
|
|
616
|
-
title: "
|
|
865
|
+
id: "panel.browse",
|
|
866
|
+
title: "Browse for a path",
|
|
617
867
|
kind: "ui",
|
|
618
868
|
hidden: true,
|
|
619
869
|
run(session) {
|
|
620
|
-
|
|
870
|
+
// Browse the focused argument when it is pickable, else the first
|
|
871
|
+
// pickable argument of the operation; a no-op when there is none.
|
|
872
|
+
const focused = focusedBinding(session)
|
|
873
|
+
const binding = focused?.picker === true ? focused : firstPickableBinding(session)
|
|
874
|
+
if (!binding) return undefined
|
|
875
|
+
return openPicker(session, binding.index, binding)
|
|
621
876
|
},
|
|
622
877
|
},
|
|
623
878
|
{
|
|
624
|
-
id: "
|
|
625
|
-
title: "
|
|
626
|
-
keys: "^P",
|
|
879
|
+
id: "picker.up",
|
|
880
|
+
title: "Previous picker row",
|
|
627
881
|
kind: "ui",
|
|
628
|
-
|
|
882
|
+
hidden: true,
|
|
629
883
|
run(session) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
openPalette(session, "ui", "")
|
|
884
|
+
const length = session.pickerRows.value.length
|
|
885
|
+
session.pickerFocus.set("rows")
|
|
886
|
+
session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, -1, length))
|
|
635
887
|
},
|
|
636
888
|
},
|
|
637
889
|
{
|
|
638
|
-
id: "
|
|
639
|
-
title: "
|
|
890
|
+
id: "picker.down",
|
|
891
|
+
title: "Next picker row",
|
|
640
892
|
kind: "ui",
|
|
893
|
+
hidden: true,
|
|
641
894
|
run(session) {
|
|
642
|
-
|
|
895
|
+
const length = session.pickerRows.value.length
|
|
896
|
+
session.pickerFocus.set("rows")
|
|
897
|
+
session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, 1, length))
|
|
643
898
|
},
|
|
644
899
|
},
|
|
645
900
|
{
|
|
646
|
-
id: "
|
|
647
|
-
title: "
|
|
648
|
-
keys: "!",
|
|
901
|
+
id: "picker.setCursor",
|
|
902
|
+
title: "Highlight picker row",
|
|
649
903
|
kind: "ui",
|
|
650
|
-
|
|
651
|
-
|
|
904
|
+
hidden: true,
|
|
905
|
+
run(session, payload) {
|
|
906
|
+
if (payload?.index !== undefined) session.panelRowIndex.set(Math.max(0, payload.index))
|
|
907
|
+
session.pickerFocus.set("rows")
|
|
652
908
|
},
|
|
653
909
|
},
|
|
654
910
|
{
|
|
655
|
-
id: "
|
|
656
|
-
title: "
|
|
657
|
-
keys: "/",
|
|
911
|
+
id: "picker.focusPath",
|
|
912
|
+
title: "Edit the picker path",
|
|
658
913
|
kind: "ui",
|
|
659
|
-
|
|
914
|
+
hidden: true,
|
|
660
915
|
run(session) {
|
|
661
|
-
session.
|
|
916
|
+
session.pickerFocus.set("path")
|
|
662
917
|
},
|
|
663
918
|
},
|
|
664
919
|
{
|
|
665
|
-
id: "
|
|
666
|
-
title: "
|
|
920
|
+
id: "picker.focusRows",
|
|
921
|
+
title: "Focus the picker entries",
|
|
667
922
|
kind: "ui",
|
|
668
923
|
hidden: true,
|
|
669
924
|
run(session) {
|
|
670
|
-
session.
|
|
925
|
+
session.pickerFocus.set("rows")
|
|
671
926
|
},
|
|
672
927
|
},
|
|
673
928
|
{
|
|
674
|
-
id: "
|
|
675
|
-
title: "
|
|
929
|
+
id: "picker.type",
|
|
930
|
+
title: "Type into the picker path",
|
|
676
931
|
kind: "ui",
|
|
677
932
|
hidden: true,
|
|
678
|
-
run(session) {
|
|
679
|
-
|
|
680
|
-
|
|
933
|
+
run(session, payload) {
|
|
934
|
+
// A printable key pressed while the entry list owns the keyboard
|
|
935
|
+
// jumps back to the path field and extends it, so typing always
|
|
936
|
+
// means "narrow the path" no matter where the cursor sits.
|
|
937
|
+
const text = `${session.pickerPathText.value ?? ""}`
|
|
938
|
+
session.pickerPathText.set(`${text}${payload?.char ?? ""}`)
|
|
939
|
+
session.pickerFocus.set("path")
|
|
681
940
|
},
|
|
682
941
|
},
|
|
683
942
|
{
|
|
684
|
-
id: "
|
|
685
|
-
title: "
|
|
943
|
+
id: "picker.path",
|
|
944
|
+
title: "Go to the typed picker path",
|
|
686
945
|
kind: "ui",
|
|
687
946
|
hidden: true,
|
|
688
|
-
run(session) {
|
|
689
|
-
|
|
947
|
+
async run(session) {
|
|
948
|
+
// Enter in the path field commits it: an existing directory is
|
|
949
|
+
// chosen (destination) or opened (file/any picker), an existing
|
|
950
|
+
// file is chosen, and a path that does not exist is reported.
|
|
951
|
+
const text = `${session.pickerPathText.value ?? ""}`
|
|
952
|
+
const absolute = pickerAbsolutePath(
|
|
953
|
+
text,
|
|
954
|
+
session.pickerBasePath.value || session.pickerPane.path.value || "/",
|
|
955
|
+
session.fs.home?.() ?? "/",
|
|
956
|
+
)
|
|
957
|
+
const stats = await session.fs.stat(absolute)
|
|
958
|
+
if (!stats) {
|
|
959
|
+
// A partial path narrowed the listing: Enter takes the match
|
|
960
|
+
// under the cursor (the `..` parent row is not a match).
|
|
961
|
+
const row = pickerEntry(session)
|
|
962
|
+
if (row?.type === "entry") {
|
|
963
|
+
if (row.isDir && session.pickerMode.value !== "dir") return descendPicker(session, row.path)
|
|
964
|
+
if (row.isDir || session.pickerMode.value !== "dir") return choosePicker(session, row.path)
|
|
965
|
+
}
|
|
966
|
+
session.notice.set({ kind: "warning", text: `no such path: ${absolute}` })
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
if (stats.isDir) {
|
|
970
|
+
if (session.pickerMode.value === "dir") return choosePicker(session, absolute)
|
|
971
|
+
return descendPicker(session, absolute)
|
|
972
|
+
}
|
|
973
|
+
if (session.pickerMode.value === "dir") {
|
|
974
|
+
session.notice.set({ kind: "warning", text: `not a directory: ${absolute}` })
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
return choosePicker(session, absolute)
|
|
690
978
|
},
|
|
691
979
|
},
|
|
692
980
|
{
|
|
693
|
-
id: "
|
|
694
|
-
title: "
|
|
981
|
+
id: "picker.enter",
|
|
982
|
+
title: "Choose picker row",
|
|
695
983
|
kind: "ui",
|
|
696
984
|
hidden: true,
|
|
697
985
|
run(session) {
|
|
698
|
-
|
|
986
|
+
const entry = pickerEntry(session)
|
|
987
|
+
if (!entry) return undefined
|
|
988
|
+
if (entry.type === "parent") return session.dispatch("picker.parent")
|
|
989
|
+
// A directory descends unless this is a destination picker, where
|
|
990
|
+
// every row is already a valid choice.
|
|
991
|
+
if (entry.isDir && session.pickerMode.value !== "dir") {
|
|
992
|
+
return descendPicker(session, entry.path)
|
|
993
|
+
}
|
|
994
|
+
return choosePicker(session, entry.path)
|
|
699
995
|
},
|
|
700
996
|
},
|
|
701
997
|
{
|
|
702
|
-
id: "
|
|
703
|
-
title: "
|
|
998
|
+
id: "picker.descend",
|
|
999
|
+
title: "Open the picker's directory",
|
|
704
1000
|
kind: "ui",
|
|
705
1001
|
hidden: true,
|
|
706
1002
|
run(session) {
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
if (
|
|
1003
|
+
const entry = pickerEntry(session)
|
|
1004
|
+
if (entry?.type === "parent") return session.dispatch("picker.parent")
|
|
1005
|
+
if (entry?.isDir) return descendPicker(session, entry.path)
|
|
1006
|
+
return undefined
|
|
710
1007
|
},
|
|
711
1008
|
},
|
|
712
1009
|
{
|
|
713
|
-
id: "
|
|
714
|
-
title: "
|
|
1010
|
+
id: "picker.parent",
|
|
1011
|
+
title: "Picker parent directory",
|
|
715
1012
|
kind: "ui",
|
|
716
1013
|
hidden: true,
|
|
717
|
-
run(session
|
|
718
|
-
session.
|
|
1014
|
+
async run(session) {
|
|
1015
|
+
session.pickerPaths.invalidate()
|
|
1016
|
+
session.pickerQuery.set("")
|
|
1017
|
+
const cameFrom = session.pickerPane.path.value
|
|
1018
|
+
await session.pickerPane.up()
|
|
1019
|
+
session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
|
|
1020
|
+
const index = session.pickerRows.value.findIndex((row) => row.path === cameFrom)
|
|
1021
|
+
session.panelRowIndex.set(index >= 0 ? index : 0)
|
|
719
1022
|
},
|
|
720
1023
|
},
|
|
721
1024
|
{
|
|
722
|
-
id: "
|
|
723
|
-
title: "
|
|
1025
|
+
id: "picker.chooseDir",
|
|
1026
|
+
title: "Choose the picker's directory",
|
|
724
1027
|
kind: "ui",
|
|
725
1028
|
hidden: true,
|
|
726
1029
|
run(session) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1030
|
+
return choosePicker(session, session.pickerPane.path.value)
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
id: "picker.cancel",
|
|
1035
|
+
title: "Leave the picker",
|
|
1036
|
+
kind: "ui",
|
|
1037
|
+
hidden: true,
|
|
1038
|
+
run(session) {
|
|
1039
|
+
// Back to the argument column, panel still open.
|
|
1040
|
+
session.pickerPaths.invalidate()
|
|
1041
|
+
session.pickerOpen.set(false)
|
|
1042
|
+
session.pickerArg.set(-1)
|
|
1043
|
+
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1044
|
+
session.pickerFocus.set("path")
|
|
1045
|
+
session.panelRowIndex.set(0)
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
id: "panel.run",
|
|
1050
|
+
title: "Run command",
|
|
1051
|
+
kind: "ui",
|
|
1052
|
+
hidden: true,
|
|
1053
|
+
async run(session) {
|
|
1054
|
+
const entry = session.panelEntry.value
|
|
1055
|
+
if (!entry) return
|
|
1056
|
+
if (entry.commandId) {
|
|
1057
|
+
closePanel(session)
|
|
1058
|
+
return session.dispatch(entry.commandId)
|
|
1059
|
+
}
|
|
1060
|
+
const op = entry.op
|
|
1061
|
+
const draft = session.panelDraft.value
|
|
1062
|
+
// An incomplete command does not fail: a blank destination/path
|
|
1063
|
+
// asks where to (the picker opens and the run resumes on a choice —
|
|
1064
|
+
// explorer mode has no DST pane to default from), and any other
|
|
1065
|
+
// missing argument just takes the cursor, so Enter walks the
|
|
1066
|
+
// command field by field.
|
|
1067
|
+
const missing = missingBinding(draft)
|
|
1068
|
+
if (missing) {
|
|
1069
|
+
if (missing.kind === "dest" || missing.kind === "path") {
|
|
1070
|
+
session.pickerPendingRun.set(true)
|
|
1071
|
+
return openPicker(session, missing.index, missing)
|
|
1072
|
+
}
|
|
1073
|
+
focusBinding(session, missing)
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
session.panelRunError?.set(null)
|
|
1077
|
+
// Amber (caution) and red (destructive) actions are two-step: the
|
|
1078
|
+
// first Run prints the assembled command and arms `[Confirm]`; the
|
|
1079
|
+
// second executes it without the separate confirm overlay.
|
|
1080
|
+
const risk = entry.risk === "caution" || entry.risk === "destructive"
|
|
1081
|
+
if (risk && !session.panelArmed.value) {
|
|
1082
|
+
session.panelArmed.set(true)
|
|
733
1083
|
return
|
|
734
1084
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1085
|
+
const status = await runOperation(session, op, argvFor(draft), {
|
|
1086
|
+
panel: true,
|
|
1087
|
+
...(risk ? { confirmed: true } : {}),
|
|
738
1088
|
})
|
|
1089
|
+
// A confirm keeps the panel open until the user answers; a run
|
|
1090
|
+
// closes it. Errors stay open so the draft can be fixed.
|
|
1091
|
+
if (status === "error") keepPanelError(session)
|
|
1092
|
+
else if (status === "ran") {
|
|
1093
|
+
showBriefPanelResult(session)
|
|
1094
|
+
closePanel(session)
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
id: "panel.cancel",
|
|
1100
|
+
title: "Close command panel",
|
|
1101
|
+
kind: "ui",
|
|
1102
|
+
hidden: true,
|
|
1103
|
+
run(session) {
|
|
1104
|
+
closePanel(session)
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
id: "filter.start",
|
|
1109
|
+
title: "Filter",
|
|
1110
|
+
keys: "/",
|
|
1111
|
+
kind: "ui",
|
|
1112
|
+
run(session) {
|
|
1113
|
+
// A column with no filter field (the inspector, or a column the
|
|
1114
|
+
// current mode does not show) hands focus to the pane it follows
|
|
1115
|
+
// before the input starts capturing keystrokes.
|
|
1116
|
+
if (!hasFilterField(session, session.active.value)) activate(session, session.currentId.value)
|
|
1117
|
+
session.filtering.set(session.active.value)
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
id: "filter.accept",
|
|
1122
|
+
title: "Keep filter",
|
|
1123
|
+
kind: "ui",
|
|
1124
|
+
hidden: true,
|
|
1125
|
+
run(session) {
|
|
1126
|
+
session.filtering.set(null)
|
|
1127
|
+
},
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
id: "filter.clear",
|
|
1131
|
+
title: "Clear filter",
|
|
1132
|
+
kind: "ui",
|
|
1133
|
+
hidden: true,
|
|
1134
|
+
run(session) {
|
|
1135
|
+
pane(session).clearFilter()
|
|
1136
|
+
session.filtering.set(null)
|
|
739
1137
|
},
|
|
740
1138
|
},
|
|
741
1139
|
{
|
|
@@ -753,7 +1151,11 @@ const commands = [
|
|
|
753
1151
|
// A panel-originated confirm re-runs its draft and, on success,
|
|
754
1152
|
// closes the panel that was waiting on it.
|
|
755
1153
|
return runOperation(session, op, pending.args, { confirmed: true }).then((status) => {
|
|
756
|
-
if (status === "
|
|
1154
|
+
if (status === "error" && session.panelOpen?.value) keepPanelError(session)
|
|
1155
|
+
if (status === "ran" && session.panelOpen?.value) {
|
|
1156
|
+
showBriefPanelResult(session)
|
|
1157
|
+
closePanel(session)
|
|
1158
|
+
}
|
|
757
1159
|
return status
|
|
758
1160
|
})
|
|
759
1161
|
}
|
|
@@ -769,16 +1171,29 @@ const commands = [
|
|
|
769
1171
|
session.confirm.set(null)
|
|
770
1172
|
},
|
|
771
1173
|
},
|
|
772
|
-
// Quit:
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
1174
|
+
// Quit: the key path intercepts `app.quit` before dispatch, while the
|
|
1175
|
+
// panel's synthetic Quit entry dispatches it; both reach the renderer's
|
|
1176
|
+
// host hook (`session.quit`, installed at mount). Hidden from the `?`
|
|
1177
|
+
// keymap's command bar, not from the panel.
|
|
776
1178
|
{
|
|
777
1179
|
id: "app.quit",
|
|
778
1180
|
title: "Quit",
|
|
779
1181
|
keys: "q",
|
|
780
1182
|
kind: "ui",
|
|
781
|
-
|
|
1183
|
+
hidden: true,
|
|
1184
|
+
run(session) {
|
|
1185
|
+
return session.quit?.()
|
|
1186
|
+
},
|
|
1187
|
+
},
|
|
1188
|
+
// Help: presentation-only too. Each renderer intercepts `?` and owns its
|
|
1189
|
+
// own overlay cell, and routes the bar click the same way; the registry
|
|
1190
|
+
// entry exists so the bar and the `?` reference can advertise it. Hidden
|
|
1191
|
+
// from the panel, where it could only no-op.
|
|
1192
|
+
{
|
|
1193
|
+
id: "app.help",
|
|
1194
|
+
title: "Help",
|
|
1195
|
+
keys: "?",
|
|
1196
|
+
kind: "ui",
|
|
782
1197
|
hidden: true,
|
|
783
1198
|
run() {},
|
|
784
1199
|
},
|
|
@@ -799,40 +1214,35 @@ function getCommand(id) {
|
|
|
799
1214
|
return null
|
|
800
1215
|
}
|
|
801
1216
|
|
|
802
|
-
// Function: getPaletteCommands
|
|
803
|
-
// Commands the command palette lists in UI mode: user-visible UI actions.
|
|
804
|
-
function getPaletteCommands() {
|
|
805
|
-
return commands.filter((command) => !command.hidden && (command.kind ?? "ui") === "ui")
|
|
806
|
-
}
|
|
807
|
-
|
|
808
1217
|
// Function: getCommandBarItems
|
|
809
|
-
// Command-bar rows for the current context: the
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
if (
|
|
1218
|
+
// Command-bar rows for the current context: the panel's key hints while it is
|
|
1219
|
+
// open, the bookmark-panel hints while that panel owns the keyboard, the
|
|
1220
|
+
// filter hints while a pane filter is capturing input, or the fixed
|
|
1221
|
+
// `BAR_COMMANDS` set otherwise, in display order. Takes the context as named
|
|
1222
|
+
// fields — four adjacent flags are too easy to transpose.
|
|
1223
|
+
function getCommandBarItems({ filtering, bookmarkOpen, panelOpen, pickerOpen, panelArmed, overlay }) {
|
|
1224
|
+
if (panelOpen) return pickerOpen ? PICKER_KEYS : panelArmed ? PANEL_CONFIRM_KEYS : PANEL_HINT_KEYS
|
|
1225
|
+
if (bookmarkOpen) return BOOKMARK_KEYS
|
|
1226
|
+
if (overlay === "history") return HISTORY_KEYS
|
|
1227
|
+
if (overlay === "trash") return TRASH_KEYS
|
|
816
1228
|
if (filtering) return FILTER_KEYS
|
|
817
|
-
return
|
|
1229
|
+
return BAR_COMMANDS.map((id) => getCommand(id)).filter(Boolean).map(barRow)
|
|
818
1230
|
}
|
|
819
1231
|
|
|
820
|
-
// Function:
|
|
821
|
-
// Projects a command to the small row object
|
|
822
|
-
function
|
|
1232
|
+
// Function: barRow
|
|
1233
|
+
// Projects a command to the small row object the command bar renders.
|
|
1234
|
+
function barRow(command) {
|
|
823
1235
|
return {
|
|
824
1236
|
id: command.id,
|
|
825
1237
|
title: command.title,
|
|
826
1238
|
keys: command.keys,
|
|
827
|
-
kind: command.kind ?? "ui",
|
|
828
1239
|
primary: command.primary === true,
|
|
829
|
-
completion: command.completion ?? command.title,
|
|
830
1240
|
}
|
|
831
1241
|
}
|
|
832
1242
|
|
|
833
1243
|
// Function: createDispatch
|
|
834
|
-
// Returns `dispatch(id, payload)` bound to the session. Unknown
|
|
835
|
-
//
|
|
1244
|
+
// Returns `dispatch(id, payload)` bound to the session. Unknown commands are
|
|
1245
|
+
// ignored here; `app.*` commands run their host hook. Failures (sync throws and
|
|
836
1246
|
// rejections) land on the active pane's error cell and the session notice.
|
|
837
1247
|
function createDispatch(session) {
|
|
838
1248
|
return (id, payload) => {
|
|
@@ -854,8 +1264,33 @@ function fail(session, error) {
|
|
|
854
1264
|
return null
|
|
855
1265
|
}
|
|
856
1266
|
|
|
1267
|
+
// Function: isColumnPane
|
|
1268
|
+
// Whether the focused column is backed by a rendered pane. The parent column
|
|
1269
|
+
// only exists in explorer mode (focus is handed back on leaving it); the
|
|
1270
|
+
// inspector column only while it previews a directory — over a file it is a
|
|
1271
|
+
// read-only card with no listing to drive.
|
|
1272
|
+
function isColumnPane(session, paneId) {
|
|
1273
|
+
if (paneId !== "preview") return paneId !== "parent" || session.viewMode.value === "explorer"
|
|
1274
|
+
return Boolean(session.showPreview.value) && Boolean(session.previewEntry.value?.isDir) && !session.preview.value
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Function: hasFilterField
|
|
1278
|
+
// Whether the focused column renders a filter input. The inspector column
|
|
1279
|
+
// never does — its header is the preview title — so `/` hands focus to the
|
|
1280
|
+
// pane it shows before capturing keystrokes, the same way a nav key falls
|
|
1281
|
+
// back to it.
|
|
1282
|
+
function hasFilterField(session, paneId) {
|
|
1283
|
+
return paneId !== "preview" && isColumnPane(session, paneId)
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Function: pane
|
|
1287
|
+
// The pane cursor movement, marks and reload act on: the focused column's
|
|
1288
|
+
// pane when it has one, else the current pane (so the inspector over a file
|
|
1289
|
+
// keeps scrolling the directory it is showing).
|
|
857
1290
|
function pane(session) {
|
|
858
|
-
|
|
1291
|
+
const focused = session.panes[session.active.value]
|
|
1292
|
+
if (!focused || !isColumnPane(session, session.active.value)) return currentPane(session)
|
|
1293
|
+
return focused
|
|
859
1294
|
}
|
|
860
1295
|
|
|
861
1296
|
// Function: activate
|
|
@@ -866,16 +1301,83 @@ function activate(session, paneId) {
|
|
|
866
1301
|
if (session.active.value !== paneId) session.active.set(paneId)
|
|
867
1302
|
}
|
|
868
1303
|
|
|
1304
|
+
// Function: retargetFromParent
|
|
1305
|
+
// A click on a row of the explorer's parent column retargets the current
|
|
1306
|
+
// directory: a directory becomes it, a file opens its containing directory
|
|
1307
|
+
// with the file selected so the inspector previews it. Both moves are
|
|
1308
|
+
// recorded, so Esc steps back to where the click came from.
|
|
1309
|
+
function retargetFromParent(session, parent, index) {
|
|
1310
|
+
const entries = parent.entries.value
|
|
1311
|
+
const entry = entries[clampCursor(index, entries.length)]
|
|
1312
|
+
if (!entry) return
|
|
1313
|
+
const current = currentPane(session)
|
|
1314
|
+
if (entry.isDir) return current.navigate(entry.path)
|
|
1315
|
+
return current
|
|
1316
|
+
.navigate(parent.path.value, { cursorPath: entry.path })
|
|
1317
|
+
.then(() => pinPreview(session, current))
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Function: retargetFromPreview
|
|
1321
|
+
// A click on a row of the explorer's preview column promotes the previewed
|
|
1322
|
+
// directory to the current one, selecting the clicked entry: a directory then
|
|
1323
|
+
// previews its own contents (the next click descends further), a file previews
|
|
1324
|
+
// as a card.
|
|
1325
|
+
function retargetFromPreview(session, preview, index) {
|
|
1326
|
+
const entries = preview.entries.value
|
|
1327
|
+
const entry = entries[clampCursor(index, entries.length)]
|
|
1328
|
+
if (!entry) return
|
|
1329
|
+
const current = currentPane(session)
|
|
1330
|
+
return current.navigate(preview.path.value, { cursorPath: entry.path }).then(() => {
|
|
1331
|
+
if (!entry.isDir) pinPreview(session, current)
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Function: pinPreview
|
|
1336
|
+
// Points the inspector at the pane's cursor entry, so a file a click just
|
|
1337
|
+
// revealed previews even if the follow effect lags the navigation.
|
|
1338
|
+
function pinPreview(session, pane) {
|
|
1339
|
+
session.previewTarget.set(pane.currentEntry() ?? null)
|
|
1340
|
+
session.showPreview.set(true)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Function: openFile
|
|
1344
|
+
// Enter on a file. A text file the host can edit opens in `$EDITOR` and the
|
|
1345
|
+
// pane is refreshed afterwards (an editor may have rewritten it); anything else
|
|
1346
|
+
// — an image, media, a binary body, or any file on a host without an editor
|
|
1347
|
+
// (the web) — opens the fullscreen inspector instead.
|
|
1348
|
+
async function openFile(session, current, entry) {
|
|
1349
|
+
if (await isEditable(session, entry)) {
|
|
1350
|
+
await session.os.edit(entry.path)
|
|
1351
|
+
await current.refresh()
|
|
1352
|
+
return
|
|
1353
|
+
}
|
|
1354
|
+
session.previewTarget.set(entry)
|
|
1355
|
+
session.showPreview.set(true)
|
|
1356
|
+
session.viewerOpen.set(true)
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// Function: isEditable
|
|
1360
|
+
// Whether Enter edits `entry` rather than previewing it: the host must have an
|
|
1361
|
+
// editor, the entry must preview as text (not an image/media card) and its head
|
|
1362
|
+
// must not be binary. Compressed entries are never editable: the inspector
|
|
1363
|
+
// decompresses them, but `$EDITOR` would only see the compressed bytes. The
|
|
1364
|
+
// read follows symlinks, like the inspector's own, and needs only the sniff
|
|
1365
|
+
// window `hasBinaryByte` looks at.
|
|
1366
|
+
async function isEditable(session, entry) {
|
|
1367
|
+
if (entry?.isDir) return false
|
|
1368
|
+
if (session.os?.canEdit === false) return false
|
|
1369
|
+
const source = previewSource(entry)
|
|
1370
|
+
if (source.compression) return false
|
|
1371
|
+
if (previewKind(source.innerExt) !== "text") return false
|
|
1372
|
+
const head = await session.fs.readTextHead(entry.path, SNIFF_BYTES).catch(() => null)
|
|
1373
|
+
return head !== null && !hasBinaryByte(head)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
869
1376
|
function bump(session, delta) {
|
|
870
1377
|
const p = pane(session)
|
|
871
1378
|
p.cursor.set(moveCursor(p.cursor.value, delta, p.entries.value.length))
|
|
872
1379
|
}
|
|
873
1380
|
|
|
874
|
-
function movePalette(session, delta) {
|
|
875
|
-
const length = session.paletteItems.value.length
|
|
876
|
-
session.paletteIndex.set(moveCursor(session.paletteIndex.value, delta, length))
|
|
877
|
-
}
|
|
878
|
-
|
|
879
1381
|
function moveBookmark(session, delta) {
|
|
880
1382
|
const length = session.bookmarkItems.value.length
|
|
881
1383
|
session.bookmarkIndex.set(moveCursor(session.bookmarkIndex.value, delta, length))
|
|
@@ -885,46 +1387,262 @@ function closeBookmark(session) {
|
|
|
885
1387
|
session.bookmarkOpen.set(false)
|
|
886
1388
|
}
|
|
887
1389
|
|
|
888
|
-
|
|
1390
|
+
// Function: addCurrentBookmark
|
|
1391
|
+
// Records the panel's target under an automatic name and closes the panel.
|
|
1392
|
+
// Closing happens before the async save so a second Enter cannot add twice.
|
|
1393
|
+
function addCurrentBookmark(session) {
|
|
1394
|
+
const target = session.bookmarkTarget.value
|
|
1395
|
+
if (!target) return
|
|
1396
|
+
closeBookmark(session)
|
|
1397
|
+
return session.bookmarks.add(target.path, target.isDir).then(({ name, added }) => {
|
|
1398
|
+
session.notice.set({ kind: "tip", text: added ? `bookmarked ${name}` : `bookmark ${name}` })
|
|
1399
|
+
})
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Function: openHistory
|
|
1403
|
+
// Opens the history overlay: one row per journal entry, the cursor parked on
|
|
1404
|
+
// the current position. A row carries its journal `index` so activating it can
|
|
1405
|
+
// rewind/replay to that point.
|
|
1406
|
+
function openHistory(session) {
|
|
1407
|
+
const rows = session.journal.rows()
|
|
1408
|
+
session.overlayItems.set(
|
|
1409
|
+
rows.map((row) => ({
|
|
1410
|
+
key: `h:${row.id}`,
|
|
1411
|
+
text: row.label,
|
|
1412
|
+
meta: row.applied ? (row.current ? "current" : "applied") : "undone",
|
|
1413
|
+
index: row.index,
|
|
1414
|
+
current: row.current,
|
|
1415
|
+
dim: !row.applied,
|
|
1416
|
+
})),
|
|
1417
|
+
)
|
|
1418
|
+
const current = rows.findIndex((row) => row.current)
|
|
1419
|
+
session.overlayIndex.set(current >= 0 ? current : 0)
|
|
1420
|
+
session.overlay.set("history")
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Function: openTrash
|
|
1424
|
+
// Opens the trash overlay on the current trash listing.
|
|
1425
|
+
async function openTrash(session) {
|
|
1426
|
+
await loadTrash(session)
|
|
1427
|
+
session.overlayIndex.set(0)
|
|
1428
|
+
session.overlay.set("trash")
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// Function: loadTrash
|
|
1432
|
+
// Refreshes the overlay rows from the trash listing. A row's key is its trash
|
|
1433
|
+
// path (`fs.restore` addresses entries by it) and its meta the original path.
|
|
1434
|
+
async function loadTrash(session) {
|
|
1435
|
+
const rows = (await session.fs.trashList?.()) ?? []
|
|
1436
|
+
session.overlayItems.set(
|
|
1437
|
+
rows.map((row) => ({
|
|
1438
|
+
key: row.trashPath,
|
|
1439
|
+
text: row.name,
|
|
1440
|
+
meta: row.originalPath ?? "",
|
|
1441
|
+
trashPath: row.trashPath,
|
|
1442
|
+
dim: false,
|
|
1443
|
+
})),
|
|
1444
|
+
)
|
|
1445
|
+
return rows
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Function: activateHistory
|
|
1449
|
+
// Rewinds or replays the journal to `row.index`, refreshes the panes and closes
|
|
1450
|
+
// the overlay.
|
|
1451
|
+
async function activateHistory(session, row) {
|
|
1452
|
+
await session.journal.goTo(session, row.index)
|
|
1453
|
+
await refreshPanes(session)
|
|
1454
|
+
closeOverlay(session)
|
|
1455
|
+
session.notice.set({ kind: "tip", text: `history → ${row.text}` })
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Function: activateTrash
|
|
1459
|
+
// Restores the trashed entry under the cursor, refreshes the trash list (the
|
|
1460
|
+
// restored row is gone), the panes and closes nothing (the panel stays open so
|
|
1461
|
+
// several entries can be restored in a row).
|
|
1462
|
+
async function activateTrash(session, row) {
|
|
1463
|
+
await session.fs.restore([row.trashPath])
|
|
1464
|
+
await loadTrash(session)
|
|
1465
|
+
session.overlayIndex.set(clampCursor(session.overlayIndex.value, session.overlayItems.value.length))
|
|
1466
|
+
await refreshPanes(session)
|
|
1467
|
+
session.notice.set({ kind: "tip", text: `restored ${row.meta || row.text}` })
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Function: closeOverlay
|
|
1471
|
+
function closeOverlay(session) {
|
|
1472
|
+
session.overlay.set(null)
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Function: focusedBinding
|
|
1476
|
+
// The draft binding under the right pane's cursor (an argument header or one
|
|
1477
|
+
// of its item rows), or null on the command filter.
|
|
1478
|
+
function focusedBinding(session) {
|
|
1479
|
+
if (session.panelFocus.value !== PANEL_FOCUS.ARGS) return null
|
|
1480
|
+
const row = rowAt(session.panelRows.value, session.panelRowIndex.value)
|
|
1481
|
+
return row ? bindingAt(session.panelDraft.value, row.argIndex) : null
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Function: firstPickableBinding
|
|
1485
|
+
// The first browsable (`picker`) binding of the draft, so `^O` works from the
|
|
1486
|
+
// command filter too.
|
|
1487
|
+
function firstPickableBinding(session) {
|
|
1488
|
+
return (session.panelDraft.value ?? []).find((binding) => binding.picker === true) ?? null
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Function: focusBinding
|
|
1492
|
+
// Moves the cursor onto the argument header row of the binding Enter found
|
|
1493
|
+
// missing.
|
|
1494
|
+
function focusBinding(session, binding) {
|
|
1495
|
+
const rowIndex = (session.panelRows.value ?? []).findIndex(
|
|
1496
|
+
(row) => row.type === "arg" && row.argIndex === binding.index,
|
|
1497
|
+
)
|
|
1498
|
+
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1499
|
+
session.panelRowIndex.set(rowIndex >= 0 ? rowIndex : 0)
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Function: openPanel
|
|
1503
|
+
// Opens the command panel fresh: empty query, first entry selected, the entry
|
|
1504
|
+
// column focused. `options.name` selects a CLI operation by name — `!` lands on
|
|
1505
|
+
// `run` — and `options.focus` moves the focus ring to the argument column so a
|
|
1506
|
+
// raw or literal argument owns the keyboard right away.
|
|
1507
|
+
function openPanel(session, options = {}) {
|
|
889
1508
|
session.panelQuery.set("")
|
|
890
1509
|
session.panelOpIndex.set(0)
|
|
891
1510
|
session.panelRowIndex.set(0)
|
|
892
1511
|
session.panelFocus.set(PANEL_FOCUS.OPS)
|
|
1512
|
+
session.pickerOpen.set(false)
|
|
1513
|
+
session.pickerArg.set(-1)
|
|
1514
|
+
session.pickerPendingRun.set(false)
|
|
1515
|
+
session.panelRunError?.set(null)
|
|
893
1516
|
session.panelOpen.set(true)
|
|
1517
|
+
if (!options.name) return
|
|
1518
|
+
// Select the operation by index rather than by query, so the filter stays
|
|
1519
|
+
// empty and the list keeps its full ordering. A missing name is a bug in
|
|
1520
|
+
// the caller (a renamed operation), not something to silently paper over by
|
|
1521
|
+
// landing on the first entry.
|
|
1522
|
+
const index = session.panelFiltered.value.findIndex((entry) => entry.name === options.name)
|
|
1523
|
+
if (index < 0) throw new Error(`command panel has no entry named "${options.name}"`)
|
|
1524
|
+
session.panelOpIndex.set(index)
|
|
1525
|
+
if (options.focus === PANEL_FOCUS.ARGS) {
|
|
1526
|
+
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1527
|
+
session.panelRowIndex.set(0)
|
|
1528
|
+
}
|
|
894
1529
|
}
|
|
895
1530
|
|
|
896
|
-
// Function:
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1531
|
+
// Function: pickerEntry
|
|
1532
|
+
// The entry under the picker cursor, or null (empty listing / stale cursor).
|
|
1533
|
+
function pickerEntry(session) {
|
|
1534
|
+
const rows = session.pickerRows.value
|
|
1535
|
+
return rows[clampCursor(session.panelRowIndex.value, rows.length)] ?? null
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Function: openPicker
|
|
1539
|
+
// Opens the panel's file picker on a path argument: the argument column
|
|
1540
|
+
// switches to a browsable listing seated on the argument's current value (a
|
|
1541
|
+
// directory opens there, a file opens in its parent with the file under the
|
|
1542
|
+
// cursor), else on the current pane's directory. Focus moves to the argument
|
|
1543
|
+
// column so the picker's own input owns the keyboard.
|
|
1544
|
+
async function openPicker(session, argIndex, binding) {
|
|
1545
|
+
session.pickerPaths.invalidate()
|
|
1546
|
+
const mode = binding?.mode ?? "dir"
|
|
1547
|
+
session.pickerMode.set(mode)
|
|
1548
|
+
session.pickerArg.set(argIndex)
|
|
1549
|
+
session.pickerQuery.set("")
|
|
1550
|
+
session.pickerOpen.set(true)
|
|
1551
|
+
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1552
|
+
let startPath = currentPane(session)?.path?.value ?? session.fs.cwd?.() ?? "/"
|
|
1553
|
+
let cursorPath = null
|
|
1554
|
+
const value = `${binding?.value ?? ""}`.trim()
|
|
1555
|
+
if (value) {
|
|
1556
|
+
const stats = await session.fs.stat(value)
|
|
1557
|
+
if (stats?.isDir) startPath = value
|
|
1558
|
+
else {
|
|
1559
|
+
startPath = parentOf(value) ?? "/"
|
|
1560
|
+
cursorPath = value
|
|
1561
|
+
}
|
|
904
1562
|
}
|
|
905
|
-
|
|
1563
|
+
session.pickerBasePath.set(startPath)
|
|
1564
|
+
session.pickerFocus.set("path")
|
|
1565
|
+
await session.pickerPane.navigate(startPath, { record: false, cursorPath })
|
|
1566
|
+
session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
|
|
1567
|
+
const rows = session.pickerRows.value
|
|
1568
|
+
const index = cursorPath ? rows.findIndex((row) => row.path === cursorPath) : -1
|
|
1569
|
+
session.panelRowIndex.set(index >= 0 ? index : 0)
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Function: descendPicker
|
|
1573
|
+
// Moves the picker into `path`, clearing its filter and re-anchoring the
|
|
1574
|
+
// cursor at the top of the fresh listing.
|
|
1575
|
+
async function descendPicker(session, path) {
|
|
1576
|
+
if (!path) return undefined
|
|
1577
|
+
session.pickerPaths.invalidate()
|
|
1578
|
+
session.pickerQuery.set("")
|
|
1579
|
+
await session.pickerPane.navigate(path, { record: false })
|
|
1580
|
+
session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
|
|
1581
|
+
session.pickerFocus.set("rows")
|
|
1582
|
+
session.panelRowIndex.set(0)
|
|
1583
|
+
return undefined
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Function: choosePicker
|
|
1587
|
+
// Accepts `path` as the picked value: writes it into the argument, leaves the
|
|
1588
|
+
// picker (the panel stays open on the argument column) and, when the picker was
|
|
1589
|
+
// opened to satisfy a pending run, runs the operation now.
|
|
1590
|
+
function choosePicker(session, path) {
|
|
1591
|
+
if (path == null) return undefined
|
|
1592
|
+
session.pickerPaths.invalidate()
|
|
1593
|
+
const argIndex = session.pickerArg.value
|
|
1594
|
+
session.panelDraft.set(withArgValue(session.panelDraft.value, argIndex, path))
|
|
1595
|
+
session.pickerOpen.set(false)
|
|
1596
|
+
session.pickerArg.set(-1)
|
|
1597
|
+
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1598
|
+
session.pickerFocus.set("path")
|
|
1599
|
+
const rows = session.panelRows.value
|
|
1600
|
+
const index = rows.findIndex((row) => row.type === "arg" && row.argIndex === argIndex)
|
|
1601
|
+
session.panelRowIndex.set(index >= 0 ? index : 0)
|
|
1602
|
+
if (session.pickerPendingRun.value) {
|
|
1603
|
+
session.pickerPendingRun.set(false)
|
|
1604
|
+
return session.dispatch("panel.run")
|
|
1605
|
+
}
|
|
1606
|
+
return undefined
|
|
906
1607
|
}
|
|
907
1608
|
|
|
908
1609
|
function closePanel(session) {
|
|
1610
|
+
session.pickerPaths.invalidate()
|
|
909
1611
|
session.panelOpen.set(false)
|
|
1612
|
+
session.panelRunError?.set(null)
|
|
910
1613
|
}
|
|
911
1614
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
session.
|
|
916
|
-
session.
|
|
1615
|
+
// Keep an operation error in the panel action row rather than duplicating it
|
|
1616
|
+
// in the command bar underneath the modal.
|
|
1617
|
+
function keepPanelError(session) {
|
|
1618
|
+
const notice = session.notice?.value
|
|
1619
|
+
session.panelRunError?.set(notice?.text ?? "The command failed")
|
|
1620
|
+
session.notice?.set(null)
|
|
1621
|
+
const runIndex = session.panelRows?.value?.findIndex((row) => row.type === "run") ?? -1
|
|
1622
|
+
if (runIndex >= 0) {
|
|
1623
|
+
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1624
|
+
session.panelRowIndex.set(runIndex)
|
|
1625
|
+
}
|
|
917
1626
|
}
|
|
918
1627
|
|
|
919
|
-
|
|
920
|
-
|
|
1628
|
+
// Successful panel results briefly replace the command bar's normal hints.
|
|
1629
|
+
// A later dispatch can replace the notice first; the identity check prevents
|
|
1630
|
+
// this timer from clearing that newer status.
|
|
1631
|
+
function showBriefPanelResult(session) {
|
|
1632
|
+
const notice = session.notice?.value
|
|
1633
|
+
if (!notice || notice.kind === "error") return
|
|
1634
|
+
const timer = setTimeout(() => {
|
|
1635
|
+
if (session.notice?.value === notice) session.notice.set(null)
|
|
1636
|
+
}, 2500)
|
|
1637
|
+
timer?.unref?.()
|
|
921
1638
|
}
|
|
922
1639
|
|
|
923
1640
|
export {
|
|
924
|
-
|
|
1641
|
+
barRow,
|
|
1642
|
+
closePanel,
|
|
925
1643
|
createDispatch,
|
|
926
1644
|
getCommand,
|
|
927
1645
|
getCommandBarItems,
|
|
928
1646
|
getCommands,
|
|
929
|
-
|
|
1647
|
+
openPanel,
|
|
930
1648
|
}
|