@ssebastien/tcmd 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -58
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/OverlayList.xml +13 -0
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +612 -136
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/session.js +286 -116
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- package/tinyjs.json +16 -0
|
@@ -10,17 +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 panel lists the
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
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 {
|
|
20
|
+
import { parent as parentOf } from "./path.js"
|
|
19
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"
|
|
21
25
|
import { currentPane } from "./pane.js"
|
|
22
26
|
import { runLine } from "./script.js"
|
|
23
|
-
import { getOperation, runOperation } from "./operations.js"
|
|
27
|
+
import { getOperation, refreshPanes, runOperation } from "./operations.js"
|
|
24
28
|
|
|
25
29
|
// Constant: PAGE / JUMP
|
|
26
30
|
// Cursor movement steps: a page for PgUp/PgDn, a shorter jump for the
|
|
@@ -36,18 +40,12 @@ const FILTER_KEYS = [
|
|
|
36
40
|
{ id: null, title: "Clear", keys: "Esc" },
|
|
37
41
|
]
|
|
38
42
|
|
|
39
|
-
// Constant:
|
|
40
|
-
// Command-bar hints while
|
|
41
|
-
const
|
|
42
|
-
{ id: null, title: "Save", keys: "Enter", primary: true },
|
|
43
|
-
{ id: null, title: "Cancel", keys: "Esc" },
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
// Constant: BOOKMARK_LIST_KEYS
|
|
47
|
-
// Command-bar hints while picking a bookmark.
|
|
48
|
-
const BOOKMARK_LIST_KEYS = [
|
|
43
|
+
// Constant: BOOKMARK_KEYS
|
|
44
|
+
// Command-bar hints while the bookmarks panel owns the keyboard.
|
|
45
|
+
const BOOKMARK_KEYS = [
|
|
49
46
|
{ id: null, title: "Navigate", keys: "↑/↓" },
|
|
50
|
-
{ id: null, title: "
|
|
47
|
+
{ id: null, title: "Remove", keys: "⌫/Del" },
|
|
48
|
+
{ id: null, title: "Open", keys: "Enter", primary: true },
|
|
51
49
|
{ id: null, title: "Close", keys: "Esc" },
|
|
52
50
|
]
|
|
53
51
|
|
|
@@ -62,18 +60,52 @@ const PANEL_HINT_KEYS = [
|
|
|
62
60
|
{ id: null, title: "Close", keys: "Esc" },
|
|
63
61
|
]
|
|
64
62
|
|
|
65
|
-
// Constant:
|
|
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
|
|
66
70
|
// Command-bar hints while the panel's file picker owns the keyboard.
|
|
67
|
-
const
|
|
68
|
-
{ id: null, title: "
|
|
71
|
+
const PICKER_KEYS = [
|
|
72
|
+
{ id: null, title: "Path", keys: "type" },
|
|
69
73
|
{ id: null, title: "Row", keys: "↑/↓" },
|
|
70
74
|
{ id: null, title: "Open", keys: "→" },
|
|
71
|
-
{ id: null, title: "
|
|
75
|
+
{ id: null, title: "Go", keys: "↵", primary: true },
|
|
72
76
|
{ id: null, title: "Here", keys: "^↵" },
|
|
73
|
-
{ id: null, title: "
|
|
77
|
+
{ id: null, title: "Focus", keys: "⇥" },
|
|
74
78
|
{ id: null, title: "Back", keys: "Esc" },
|
|
75
79
|
]
|
|
76
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
|
+
|
|
77
109
|
const commands = [
|
|
78
110
|
{
|
|
79
111
|
id: "nav.up",
|
|
@@ -152,7 +184,6 @@ const commands = [
|
|
|
152
184
|
title: "Open",
|
|
153
185
|
keys: "Enter",
|
|
154
186
|
kind: "ui",
|
|
155
|
-
bar: true,
|
|
156
187
|
primary: true,
|
|
157
188
|
run(session) {
|
|
158
189
|
// The entry under the *focused* pane's cursor decides where the
|
|
@@ -164,12 +195,15 @@ const commands = [
|
|
|
164
195
|
if (!entry) return
|
|
165
196
|
const current = currentPane(session)
|
|
166
197
|
if (entry.isDir) return current.navigate(entry.path)
|
|
167
|
-
// A file outside the current pane pins the inspector on it
|
|
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.
|
|
168
200
|
if (focused !== current) {
|
|
169
201
|
session.previewTarget.set(entry)
|
|
170
202
|
session.showPreview.set(true)
|
|
171
203
|
}
|
|
172
|
-
|
|
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)
|
|
173
207
|
},
|
|
174
208
|
},
|
|
175
209
|
{
|
|
@@ -177,7 +211,6 @@ const commands = [
|
|
|
177
211
|
title: "Parent",
|
|
178
212
|
keys: "←",
|
|
179
213
|
kind: "ui",
|
|
180
|
-
bar: true,
|
|
181
214
|
run(session) {
|
|
182
215
|
// Stepping up always moves the current directory: the parent
|
|
183
216
|
// column mirrors it, so navigating that column itself would
|
|
@@ -206,9 +239,8 @@ const commands = [
|
|
|
206
239
|
{
|
|
207
240
|
id: "pane.reload",
|
|
208
241
|
title: "Reload",
|
|
209
|
-
keys: "
|
|
242
|
+
keys: "F5/r",
|
|
210
243
|
kind: "ui",
|
|
211
|
-
bar: true,
|
|
212
244
|
run(session) {
|
|
213
245
|
return pane(session).refresh()
|
|
214
246
|
},
|
|
@@ -218,7 +250,6 @@ const commands = [
|
|
|
218
250
|
title: "Switch",
|
|
219
251
|
keys: "Tab",
|
|
220
252
|
kind: "ui",
|
|
221
|
-
bar: true,
|
|
222
253
|
run(session) {
|
|
223
254
|
// Cycle through the columns the current view mode shows:
|
|
224
255
|
// source ⇄ destination in commander, parent → current → preview
|
|
@@ -244,46 +275,24 @@ const commands = [
|
|
|
244
275
|
title: "Mark",
|
|
245
276
|
keys: "Spc",
|
|
246
277
|
kind: "ui",
|
|
247
|
-
bar: true,
|
|
248
278
|
run(session) {
|
|
249
279
|
pane(session).toggleMark()
|
|
250
280
|
},
|
|
251
281
|
},
|
|
252
282
|
{
|
|
253
|
-
id: "bookmark.
|
|
254
|
-
title: "
|
|
283
|
+
id: "bookmark.open",
|
|
284
|
+
title: "Bookmarks",
|
|
255
285
|
keys: "b",
|
|
256
286
|
kind: "ui",
|
|
257
|
-
bar: true,
|
|
258
287
|
run(session) {
|
|
259
|
-
//
|
|
260
|
-
// 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.
|
|
261
290
|
const entry = pane(session).currentEntry()
|
|
262
291
|
const path = entry?.path ?? pane(session).path.value
|
|
263
292
|
const isDir = entry ? Boolean(entry.isDir) : true
|
|
264
|
-
const existing = session.bookmarks.find(path)
|
|
265
|
-
if (existing) {
|
|
266
|
-
// Already bookmarked: no need to re-name it.
|
|
267
|
-
session.notice.set({ kind: "tip", text: `bookmark ${existing.name}` })
|
|
268
|
-
return
|
|
269
|
-
}
|
|
270
|
-
// The default name is the path's basename.
|
|
271
|
-
session.bookmarkTarget.set({ path, isDir })
|
|
272
|
-
session.bookmarkQuery.set(basename(path))
|
|
273
|
-
session.bookmarkMode.set("name")
|
|
274
|
-
session.bookmarkOpen.set(true)
|
|
275
|
-
},
|
|
276
|
-
},
|
|
277
|
-
{
|
|
278
|
-
id: "bookmark.jump",
|
|
279
|
-
title: "Bookmarks",
|
|
280
|
-
keys: "g",
|
|
281
|
-
kind: "ui",
|
|
282
|
-
bar: true,
|
|
283
|
-
run(session) {
|
|
284
|
-
session.bookmarkTarget.set(null)
|
|
285
293
|
session.bookmarkQuery.set("")
|
|
286
|
-
session.
|
|
294
|
+
session.bookmarkIndex.set(0)
|
|
295
|
+
session.bookmarkTarget.set({ path, isDir })
|
|
287
296
|
session.bookmarkOpen.set(true)
|
|
288
297
|
},
|
|
289
298
|
},
|
|
@@ -306,30 +315,19 @@ const commands = [
|
|
|
306
315
|
},
|
|
307
316
|
},
|
|
308
317
|
{
|
|
309
|
-
id: "bookmark.
|
|
310
|
-
title: "
|
|
318
|
+
id: "bookmark.remove",
|
|
319
|
+
title: "Remove bookmark",
|
|
311
320
|
kind: "ui",
|
|
312
321
|
hidden: true,
|
|
313
|
-
run(session) {
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
// Close synchronously: the global key handler and the input's
|
|
325
|
-
// `on:enter` can both fire, and a second save while the overlay is
|
|
326
|
-
// still open would re-record and flash the notice twice. A rejected
|
|
327
|
-
// `record` (rejected name) still surfaces through the dispatch's
|
|
328
|
-
// error notice.
|
|
329
|
-
closeBookmark(session)
|
|
330
|
-
return session.bookmarks.record(name, target.path, target.isDir).then(() => {
|
|
331
|
-
session.notice.set({ kind: "tip", text: `bookmarked ${name}` })
|
|
332
|
-
})
|
|
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}` })
|
|
333
331
|
},
|
|
334
332
|
},
|
|
335
333
|
{
|
|
@@ -350,6 +348,8 @@ const commands = [
|
|
|
350
348
|
const items = session.bookmarkItems.value
|
|
351
349
|
const item = items[clampCursor(session.bookmarkIndex.value, items.length)]
|
|
352
350
|
if (!item) return
|
|
351
|
+
// The add row records the current target instead of navigating.
|
|
352
|
+
if (item.add) return addCurrentBookmark(session)
|
|
353
353
|
const target = currentPane(session)
|
|
354
354
|
const path = item.path
|
|
355
355
|
// Resolve the kind when it is unknown (legacy path-only bookmark).
|
|
@@ -380,6 +380,125 @@ const commands = [
|
|
|
380
380
|
closeBookmark(session)
|
|
381
381
|
},
|
|
382
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
|
+
},
|
|
383
502
|
{
|
|
384
503
|
id: "list.cursor",
|
|
385
504
|
title: "Select row",
|
|
@@ -429,11 +548,23 @@ const commands = [
|
|
|
429
548
|
title: "Preview",
|
|
430
549
|
keys: "v",
|
|
431
550
|
kind: "ui",
|
|
432
|
-
bar: true,
|
|
433
551
|
run(session) {
|
|
434
552
|
session.showPreview.set(!session.showPreview.value)
|
|
435
553
|
},
|
|
436
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
|
+
},
|
|
437
568
|
{
|
|
438
569
|
id: "view.maximize",
|
|
439
570
|
title: "Maximize preview",
|
|
@@ -447,12 +578,26 @@ const commands = [
|
|
|
447
578
|
session.previewFull.set(!session.previewFull.value)
|
|
448
579
|
},
|
|
449
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
|
+
},
|
|
450
596
|
{
|
|
451
597
|
id: "view.toggleMode",
|
|
452
|
-
title: "
|
|
598
|
+
title: "Toggle Mode",
|
|
453
599
|
keys: "t",
|
|
454
600
|
kind: "ui",
|
|
455
|
-
bar: true,
|
|
456
601
|
run(session) {
|
|
457
602
|
// Flip the workspace between the two-pane commander and the
|
|
458
603
|
// three-column explorer (parent · current · preview). Focus on a
|
|
@@ -483,6 +628,12 @@ const commands = [
|
|
|
483
628
|
session.notice.set({ kind: "warning", text: "no file to edit" })
|
|
484
629
|
return
|
|
485
630
|
}
|
|
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
|
+
}
|
|
486
637
|
return session.os.edit(entry.path).then(() => currentPane(session).refresh())
|
|
487
638
|
},
|
|
488
639
|
},
|
|
@@ -494,7 +645,6 @@ const commands = [
|
|
|
494
645
|
title: "Commands",
|
|
495
646
|
keys: ":",
|
|
496
647
|
kind: "ui",
|
|
497
|
-
bar: true,
|
|
498
648
|
run(session) {
|
|
499
649
|
openPanel(session)
|
|
500
650
|
},
|
|
@@ -558,27 +708,27 @@ const commands = [
|
|
|
558
708
|
kind: "ui",
|
|
559
709
|
hidden: true,
|
|
560
710
|
run(session, payload) {
|
|
561
|
-
// Tab cycles the focus ring: the command filter
|
|
562
|
-
//
|
|
563
|
-
// arrows). Shift-Tab walks it backwards.
|
|
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.
|
|
564
714
|
const delta = payload?.delta === -1 ? -1 : 1
|
|
565
715
|
const rows = session.panelRows.value
|
|
566
|
-
const
|
|
567
|
-
for (let i = 0; i < rows.length; i++) if (rows[i].type === "arg")
|
|
568
|
-
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
|
|
569
719
|
if (session.panelFocus.value === PANEL_FOCUS.OPS) {
|
|
570
|
-
session.panelRowIndex.set(delta > 0 ?
|
|
720
|
+
session.panelRowIndex.set(delta > 0 ? stops[0] : stops[stops.length - 1])
|
|
571
721
|
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
572
722
|
return
|
|
573
723
|
}
|
|
574
|
-
const at =
|
|
724
|
+
const at = stops.indexOf(session.panelRowIndex.value)
|
|
575
725
|
if (delta > 0) {
|
|
576
|
-
if (at === -1 || at >=
|
|
577
|
-
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])
|
|
578
728
|
} else if (at <= 0) {
|
|
579
729
|
session.panelFocus.set(PANEL_FOCUS.OPS)
|
|
580
730
|
} else {
|
|
581
|
-
session.panelRowIndex.set(
|
|
731
|
+
session.panelRowIndex.set(stops[at - 1])
|
|
582
732
|
}
|
|
583
733
|
},
|
|
584
734
|
},
|
|
@@ -634,6 +784,31 @@ const commands = [
|
|
|
634
784
|
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
635
785
|
},
|
|
636
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
|
+
},
|
|
637
812
|
{
|
|
638
813
|
id: "panel.toggleItem",
|
|
639
814
|
title: "Toggle argument item",
|
|
@@ -647,16 +822,43 @@ const commands = [
|
|
|
647
822
|
session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex, row.multi !== false))
|
|
648
823
|
return
|
|
649
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))
|
|
828
|
+
return
|
|
829
|
+
}
|
|
650
830
|
// An argument header toggles its whole selection: select all when
|
|
651
831
|
// some are off, clear when all are on. A single-path argument is a
|
|
652
832
|
// radio list, so its header has nothing to toggle.
|
|
653
833
|
const binding = bindingAt(draft, row.argIndex)
|
|
834
|
+
// Only a selection argument mass-toggles from its header; a
|
|
835
|
+
// permission grid has no safe "all" state and stays per-bit.
|
|
654
836
|
if (!binding || binding.kind !== "selection" || binding.multi !== true) return
|
|
655
837
|
const all = (binding.items ?? []).length > 0 && (binding.items ?? []).every((item) => item.selected)
|
|
656
838
|
const items = (binding.items ?? []).map((item) => ({ ...item, selected: !all }))
|
|
657
839
|
session.panelDraft.set(draft.map((b) => (b.index === row.argIndex ? { ...b, items } : b)))
|
|
658
840
|
},
|
|
659
841
|
},
|
|
842
|
+
{
|
|
843
|
+
id: "panel.copy",
|
|
844
|
+
title: "Copy argument value",
|
|
845
|
+
kind: "ui",
|
|
846
|
+
hidden: true,
|
|
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" })
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
session.notice?.set({ kind: "tip", text: `copied ${text}` })
|
|
859
|
+
showBriefPanelResult(session)
|
|
860
|
+
},
|
|
861
|
+
},
|
|
660
862
|
// File picker: a sub-mode of the panel that browses the filesystem for a
|
|
661
863
|
// path/destination argument. Lifecycle then navigation then choose.
|
|
662
864
|
{
|
|
@@ -674,74 +876,153 @@ const commands = [
|
|
|
674
876
|
},
|
|
675
877
|
},
|
|
676
878
|
{
|
|
677
|
-
id: "
|
|
879
|
+
id: "picker.up",
|
|
678
880
|
title: "Previous picker row",
|
|
679
881
|
kind: "ui",
|
|
680
882
|
hidden: true,
|
|
681
883
|
run(session) {
|
|
682
884
|
const length = session.pickerRows.value.length
|
|
885
|
+
session.pickerFocus.set("rows")
|
|
683
886
|
session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, -1, length))
|
|
684
887
|
},
|
|
685
888
|
},
|
|
686
889
|
{
|
|
687
|
-
id: "
|
|
890
|
+
id: "picker.down",
|
|
688
891
|
title: "Next picker row",
|
|
689
892
|
kind: "ui",
|
|
690
893
|
hidden: true,
|
|
691
894
|
run(session) {
|
|
692
895
|
const length = session.pickerRows.value.length
|
|
896
|
+
session.pickerFocus.set("rows")
|
|
693
897
|
session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, 1, length))
|
|
694
898
|
},
|
|
695
899
|
},
|
|
696
900
|
{
|
|
697
|
-
id: "
|
|
901
|
+
id: "picker.setCursor",
|
|
698
902
|
title: "Highlight picker row",
|
|
699
903
|
kind: "ui",
|
|
700
904
|
hidden: true,
|
|
701
905
|
run(session, payload) {
|
|
702
906
|
if (payload?.index !== undefined) session.panelRowIndex.set(Math.max(0, payload.index))
|
|
907
|
+
session.pickerFocus.set("rows")
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
id: "picker.focusPath",
|
|
912
|
+
title: "Edit the picker path",
|
|
913
|
+
kind: "ui",
|
|
914
|
+
hidden: true,
|
|
915
|
+
run(session) {
|
|
916
|
+
session.pickerFocus.set("path")
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
id: "picker.focusRows",
|
|
921
|
+
title: "Focus the picker entries",
|
|
922
|
+
kind: "ui",
|
|
923
|
+
hidden: true,
|
|
924
|
+
run(session) {
|
|
925
|
+
session.pickerFocus.set("rows")
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
id: "picker.type",
|
|
930
|
+
title: "Type into the picker path",
|
|
931
|
+
kind: "ui",
|
|
932
|
+
hidden: true,
|
|
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")
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
id: "picker.path",
|
|
944
|
+
title: "Go to the typed picker path",
|
|
945
|
+
kind: "ui",
|
|
946
|
+
hidden: true,
|
|
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)
|
|
703
978
|
},
|
|
704
979
|
},
|
|
705
980
|
{
|
|
706
|
-
id: "
|
|
981
|
+
id: "picker.enter",
|
|
707
982
|
title: "Choose picker row",
|
|
708
983
|
kind: "ui",
|
|
709
984
|
hidden: true,
|
|
710
985
|
run(session) {
|
|
711
986
|
const entry = pickerEntry(session)
|
|
712
987
|
if (!entry) return undefined
|
|
988
|
+
if (entry.type === "parent") return session.dispatch("picker.parent")
|
|
713
989
|
// A directory descends unless this is a destination picker, where
|
|
714
990
|
// every row is already a valid choice.
|
|
715
|
-
if (entry.isDir && session.
|
|
991
|
+
if (entry.isDir && session.pickerMode.value !== "dir") {
|
|
716
992
|
return descendPicker(session, entry.path)
|
|
717
993
|
}
|
|
718
994
|
return choosePicker(session, entry.path)
|
|
719
995
|
},
|
|
720
996
|
},
|
|
721
997
|
{
|
|
722
|
-
id: "
|
|
998
|
+
id: "picker.descend",
|
|
723
999
|
title: "Open the picker's directory",
|
|
724
1000
|
kind: "ui",
|
|
725
1001
|
hidden: true,
|
|
726
1002
|
run(session) {
|
|
727
1003
|
const entry = pickerEntry(session)
|
|
1004
|
+
if (entry?.type === "parent") return session.dispatch("picker.parent")
|
|
728
1005
|
if (entry?.isDir) return descendPicker(session, entry.path)
|
|
729
1006
|
return undefined
|
|
730
1007
|
},
|
|
731
1008
|
},
|
|
732
1009
|
{
|
|
733
|
-
id: "
|
|
1010
|
+
id: "picker.parent",
|
|
734
1011
|
title: "Picker parent directory",
|
|
735
1012
|
kind: "ui",
|
|
736
1013
|
hidden: true,
|
|
737
1014
|
async run(session) {
|
|
1015
|
+
session.pickerPaths.invalidate()
|
|
738
1016
|
session.pickerQuery.set("")
|
|
1017
|
+
const cameFrom = session.pickerPane.path.value
|
|
739
1018
|
await session.pickerPane.up()
|
|
740
|
-
session.
|
|
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)
|
|
741
1022
|
},
|
|
742
1023
|
},
|
|
743
1024
|
{
|
|
744
|
-
id: "
|
|
1025
|
+
id: "picker.chooseDir",
|
|
745
1026
|
title: "Choose the picker's directory",
|
|
746
1027
|
kind: "ui",
|
|
747
1028
|
hidden: true,
|
|
@@ -750,15 +1031,17 @@ const commands = [
|
|
|
750
1031
|
},
|
|
751
1032
|
},
|
|
752
1033
|
{
|
|
753
|
-
id: "
|
|
1034
|
+
id: "picker.cancel",
|
|
754
1035
|
title: "Leave the picker",
|
|
755
1036
|
kind: "ui",
|
|
756
1037
|
hidden: true,
|
|
757
1038
|
run(session) {
|
|
758
1039
|
// Back to the argument column, panel still open.
|
|
759
|
-
session.
|
|
760
|
-
session.
|
|
1040
|
+
session.pickerPaths.invalidate()
|
|
1041
|
+
session.pickerOpen.set(false)
|
|
1042
|
+
session.pickerArg.set(-1)
|
|
761
1043
|
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1044
|
+
session.pickerFocus.set("path")
|
|
762
1045
|
session.panelRowIndex.set(0)
|
|
763
1046
|
},
|
|
764
1047
|
},
|
|
@@ -770,6 +1053,10 @@ const commands = [
|
|
|
770
1053
|
async run(session) {
|
|
771
1054
|
const entry = session.panelEntry.value
|
|
772
1055
|
if (!entry) return
|
|
1056
|
+
if (entry.commandId) {
|
|
1057
|
+
closePanel(session)
|
|
1058
|
+
return session.dispatch(entry.commandId)
|
|
1059
|
+
}
|
|
773
1060
|
const op = entry.op
|
|
774
1061
|
const draft = session.panelDraft.value
|
|
775
1062
|
// An incomplete command does not fail: a blank destination/path
|
|
@@ -780,16 +1067,32 @@ const commands = [
|
|
|
780
1067
|
const missing = missingBinding(draft)
|
|
781
1068
|
if (missing) {
|
|
782
1069
|
if (missing.kind === "dest" || missing.kind === "path") {
|
|
783
|
-
session.
|
|
1070
|
+
session.pickerPendingRun.set(true)
|
|
784
1071
|
return openPicker(session, missing.index, missing)
|
|
785
1072
|
}
|
|
786
1073
|
focusBinding(session, missing)
|
|
787
1074
|
return
|
|
788
1075
|
}
|
|
789
|
-
|
|
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)
|
|
1083
|
+
return
|
|
1084
|
+
}
|
|
1085
|
+
const status = await runOperation(session, op, argvFor(draft), {
|
|
1086
|
+
panel: true,
|
|
1087
|
+
...(risk ? { confirmed: true } : {}),
|
|
1088
|
+
})
|
|
790
1089
|
// A confirm keeps the panel open until the user answers; a run
|
|
791
1090
|
// closes it. Errors stay open so the draft can be fixed.
|
|
792
|
-
if (status === "
|
|
1091
|
+
if (status === "error") keepPanelError(session)
|
|
1092
|
+
else if (status === "ran") {
|
|
1093
|
+
showBriefPanelResult(session)
|
|
1094
|
+
closePanel(session)
|
|
1095
|
+
}
|
|
793
1096
|
},
|
|
794
1097
|
},
|
|
795
1098
|
{
|
|
@@ -806,7 +1109,6 @@ const commands = [
|
|
|
806
1109
|
title: "Filter",
|
|
807
1110
|
keys: "/",
|
|
808
1111
|
kind: "ui",
|
|
809
|
-
bar: true,
|
|
810
1112
|
run(session) {
|
|
811
1113
|
// A column with no filter field (the inspector, or a column the
|
|
812
1114
|
// current mode does not show) hands focus to the pane it follows
|
|
@@ -849,7 +1151,11 @@ const commands = [
|
|
|
849
1151
|
// A panel-originated confirm re-runs its draft and, on success,
|
|
850
1152
|
// closes the panel that was waiting on it.
|
|
851
1153
|
return runOperation(session, op, pending.args, { confirmed: true }).then((status) => {
|
|
852
|
-
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
|
+
}
|
|
853
1159
|
return status
|
|
854
1160
|
})
|
|
855
1161
|
}
|
|
@@ -865,16 +1171,29 @@ const commands = [
|
|
|
865
1171
|
session.confirm.set(null)
|
|
866
1172
|
},
|
|
867
1173
|
},
|
|
868
|
-
// Quit:
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
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.
|
|
872
1178
|
{
|
|
873
1179
|
id: "app.quit",
|
|
874
1180
|
title: "Quit",
|
|
875
1181
|
keys: "q",
|
|
876
1182
|
kind: "ui",
|
|
877
|
-
|
|
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",
|
|
878
1197
|
hidden: true,
|
|
879
1198
|
run() {},
|
|
880
1199
|
},
|
|
@@ -897,15 +1216,17 @@ function getCommand(id) {
|
|
|
897
1216
|
|
|
898
1217
|
// Function: getCommandBarItems
|
|
899
1218
|
// Command-bar rows for the current context: the panel's key hints while it is
|
|
900
|
-
// open, the
|
|
901
|
-
// hints while
|
|
902
|
-
// otherwise
|
|
903
|
-
// flags are too easy to transpose.
|
|
904
|
-
function getCommandBarItems({ filtering, bookmarkOpen,
|
|
905
|
-
if (panelOpen) return
|
|
906
|
-
if (bookmarkOpen) return
|
|
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
|
|
907
1228
|
if (filtering) return FILTER_KEYS
|
|
908
|
-
return
|
|
1229
|
+
return BAR_COMMANDS.map((id) => getCommand(id)).filter(Boolean).map(barRow)
|
|
909
1230
|
}
|
|
910
1231
|
|
|
911
1232
|
// Function: barRow
|
|
@@ -920,8 +1241,8 @@ function barRow(command) {
|
|
|
920
1241
|
}
|
|
921
1242
|
|
|
922
1243
|
// Function: createDispatch
|
|
923
|
-
// Returns `dispatch(id, payload)` bound to the session. Unknown
|
|
924
|
-
//
|
|
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
|
|
925
1246
|
// rejections) land on the active pane's error cell and the session notice.
|
|
926
1247
|
function createDispatch(session) {
|
|
927
1248
|
return (id, payload) => {
|
|
@@ -1019,6 +1340,39 @@ function pinPreview(session, pane) {
|
|
|
1019
1340
|
session.showPreview.set(true)
|
|
1020
1341
|
}
|
|
1021
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
|
+
|
|
1022
1376
|
function bump(session, delta) {
|
|
1023
1377
|
const p = pane(session)
|
|
1024
1378
|
p.cursor.set(moveCursor(p.cursor.value, delta, p.entries.value.length))
|
|
@@ -1033,6 +1387,91 @@ function closeBookmark(session) {
|
|
|
1033
1387
|
session.bookmarkOpen.set(false)
|
|
1034
1388
|
}
|
|
1035
1389
|
|
|
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
|
+
|
|
1036
1475
|
// Function: focusedBinding
|
|
1037
1476
|
// The draft binding under the right pane's cursor (an argument header or one
|
|
1038
1477
|
// of its item rows), or null on the command filter.
|
|
@@ -1070,9 +1509,10 @@ function openPanel(session, options = {}) {
|
|
|
1070
1509
|
session.panelOpIndex.set(0)
|
|
1071
1510
|
session.panelRowIndex.set(0)
|
|
1072
1511
|
session.panelFocus.set(PANEL_FOCUS.OPS)
|
|
1073
|
-
session.
|
|
1074
|
-
session.
|
|
1075
|
-
session.
|
|
1512
|
+
session.pickerOpen.set(false)
|
|
1513
|
+
session.pickerArg.set(-1)
|
|
1514
|
+
session.pickerPendingRun.set(false)
|
|
1515
|
+
session.panelRunError?.set(null)
|
|
1076
1516
|
session.panelOpen.set(true)
|
|
1077
1517
|
if (!options.name) return
|
|
1078
1518
|
// Select the operation by index rather than by query, so the filter stays
|
|
@@ -1102,11 +1542,12 @@ function pickerEntry(session) {
|
|
|
1102
1542
|
// cursor), else on the current pane's directory. Focus moves to the argument
|
|
1103
1543
|
// column so the picker's own input owns the keyboard.
|
|
1104
1544
|
async function openPicker(session, argIndex, binding) {
|
|
1545
|
+
session.pickerPaths.invalidate()
|
|
1105
1546
|
const mode = binding?.mode ?? "dir"
|
|
1106
|
-
session.
|
|
1107
|
-
session.
|
|
1547
|
+
session.pickerMode.set(mode)
|
|
1548
|
+
session.pickerArg.set(argIndex)
|
|
1108
1549
|
session.pickerQuery.set("")
|
|
1109
|
-
session.
|
|
1550
|
+
session.pickerOpen.set(true)
|
|
1110
1551
|
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1111
1552
|
let startPath = currentPane(session)?.path?.value ?? session.fs.cwd?.() ?? "/"
|
|
1112
1553
|
let cursorPath = null
|
|
@@ -1119,7 +1560,10 @@ async function openPicker(session, argIndex, binding) {
|
|
|
1119
1560
|
cursorPath = value
|
|
1120
1561
|
}
|
|
1121
1562
|
}
|
|
1563
|
+
session.pickerBasePath.set(startPath)
|
|
1564
|
+
session.pickerFocus.set("path")
|
|
1122
1565
|
await session.pickerPane.navigate(startPath, { record: false, cursorPath })
|
|
1566
|
+
session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
|
|
1123
1567
|
const rows = session.pickerRows.value
|
|
1124
1568
|
const index = cursorPath ? rows.findIndex((row) => row.path === cursorPath) : -1
|
|
1125
1569
|
session.panelRowIndex.set(index >= 0 ? index : 0)
|
|
@@ -1130,8 +1574,11 @@ async function openPicker(session, argIndex, binding) {
|
|
|
1130
1574
|
// cursor at the top of the fresh listing.
|
|
1131
1575
|
async function descendPicker(session, path) {
|
|
1132
1576
|
if (!path) return undefined
|
|
1577
|
+
session.pickerPaths.invalidate()
|
|
1133
1578
|
session.pickerQuery.set("")
|
|
1134
1579
|
await session.pickerPane.navigate(path, { record: false })
|
|
1580
|
+
session.pickerPathText.set(pickerDirectoryText(session.pickerPane.path.value))
|
|
1581
|
+
session.pickerFocus.set("rows")
|
|
1135
1582
|
session.panelRowIndex.set(0)
|
|
1136
1583
|
return undefined
|
|
1137
1584
|
}
|
|
@@ -1142,23 +1589,52 @@ async function descendPicker(session, path) {
|
|
|
1142
1589
|
// opened to satisfy a pending run, runs the operation now.
|
|
1143
1590
|
function choosePicker(session, path) {
|
|
1144
1591
|
if (path == null) return undefined
|
|
1145
|
-
|
|
1592
|
+
session.pickerPaths.invalidate()
|
|
1593
|
+
const argIndex = session.pickerArg.value
|
|
1146
1594
|
session.panelDraft.set(withArgValue(session.panelDraft.value, argIndex, path))
|
|
1147
|
-
session.
|
|
1148
|
-
session.
|
|
1595
|
+
session.pickerOpen.set(false)
|
|
1596
|
+
session.pickerArg.set(-1)
|
|
1149
1597
|
session.panelFocus.set(PANEL_FOCUS.ARGS)
|
|
1598
|
+
session.pickerFocus.set("path")
|
|
1150
1599
|
const rows = session.panelRows.value
|
|
1151
1600
|
const index = rows.findIndex((row) => row.type === "arg" && row.argIndex === argIndex)
|
|
1152
1601
|
session.panelRowIndex.set(index >= 0 ? index : 0)
|
|
1153
|
-
if (session.
|
|
1154
|
-
session.
|
|
1602
|
+
if (session.pickerPendingRun.value) {
|
|
1603
|
+
session.pickerPendingRun.set(false)
|
|
1155
1604
|
return session.dispatch("panel.run")
|
|
1156
1605
|
}
|
|
1157
1606
|
return undefined
|
|
1158
1607
|
}
|
|
1159
1608
|
|
|
1160
1609
|
function closePanel(session) {
|
|
1610
|
+
session.pickerPaths.invalidate()
|
|
1161
1611
|
session.panelOpen.set(false)
|
|
1612
|
+
session.panelRunError?.set(null)
|
|
1613
|
+
}
|
|
1614
|
+
|
|
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
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
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?.()
|
|
1162
1638
|
}
|
|
1163
1639
|
|
|
1164
1640
|
export {
|