@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
|
@@ -5,20 +5,36 @@
|
|
|
5
5
|
|
|
6
6
|
// Module: tcmd/core/session
|
|
7
7
|
// A Session wires two panes plus cross-pane UI state (active pane, preview,
|
|
8
|
-
// command
|
|
8
|
+
// command panel, bookmarks) into one dispatchable model. Everything reactive
|
|
9
9
|
// is a cell or derived cell; views subscribe, commands mutate.
|
|
10
|
+
//
|
|
11
|
+
// On top of the two (source/destination) panes the session owns two auxiliary
|
|
12
|
+
// panes: `parent` mirrors the parent of the pane holding the current
|
|
13
|
+
// directory, `preview` mirrors the directory the inspector is showing. They
|
|
14
|
+
// are real panes — own listing, cursor, marks — so the explorer view and the
|
|
15
|
+
// directory preview are clickable and keyboard-navigable like any other pane,
|
|
16
|
+
// while path changes still land on the pane that owns the current directory
|
|
17
|
+
// (see `currentId` / `core/commands#currentPane`).
|
|
10
18
|
|
|
11
19
|
import { cell, derived, effect } from "select/state/cells"
|
|
12
20
|
import { createBookmarks } from "./bookmarks.js"
|
|
13
|
-
import {
|
|
21
|
+
import { createJournal } from "./journal.js"
|
|
22
|
+
import { createDispatch, getCommandBarItems } from "./commands.js"
|
|
14
23
|
import { createNullOs } from "./os.js"
|
|
15
|
-
import { PANEL_FOCUS, bindArgs,
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
24
|
+
import { PANEL_FOCUS, argvFor, bindArgs, filterPanelEntries, isTextualRow, panelCommand, panelEntries, panelEntryRows, panelRowList, panelRunnable, rowAt, withArgValue } from "./panel.js"
|
|
25
|
+
import { createPickerPathQueue, pickerRowList, pickerVisibleEntries } from "./picker.js"
|
|
26
|
+
import { basename, parent as parentPath } from "./path.js"
|
|
27
|
+
import { hasBinaryByte, PREVIEW_BYTES, previewKind, previewSource } from "./preview.js"
|
|
28
|
+
import { compressedBytes, decompressHead } from "./decompress.js"
|
|
29
|
+
import { indexOfPath } from "./selection.js"
|
|
30
|
+
import { createPane, PANE_BUFFERS } from "./pane.js"
|
|
19
31
|
import { hasMatch, score } from "../vendor/fzy.js"
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
// Constant: BOOKMARK_ADD_KEY
|
|
34
|
+
// Key and display label of the synthetic "add current path" row. It contains
|
|
35
|
+
// spaces, which a bookmark name may not, so it can never collide with a stored
|
|
36
|
+
// row.
|
|
37
|
+
const BOOKMARK_ADD_KEY = "+ add current path"
|
|
22
38
|
|
|
23
39
|
// Function: createSession
|
|
24
40
|
// Creates the shared model for a two-pane browser.
|
|
@@ -29,62 +45,217 @@ const PREVIEW_BYTES = 65536
|
|
|
29
45
|
// await session.load()
|
|
30
46
|
// session.dispatch("pane.toggle")
|
|
31
47
|
// ```
|
|
32
|
-
function createSession({ fs, os, left, right, host, bookmarks } = {}) {
|
|
48
|
+
function createSession({ fs, os, left, right, host, bookmarks, viewMode = "explorer" } = {}) {
|
|
33
49
|
const session = {}
|
|
34
50
|
session.fs = fs
|
|
35
51
|
session.os = os ?? createNullOs()
|
|
36
52
|
session.panes = { left, right }
|
|
53
|
+
// Auxiliary panes: the explorer's parent column and the inspector's
|
|
54
|
+
// directory column. Both start on a valid path so a refresh before their
|
|
55
|
+
// first sync can never read a blank one.
|
|
56
|
+
session.panes.parent = createPane({ fs, path: parentPath(left.path.value) ?? left.path.value })
|
|
57
|
+
session.panes.preview = createPane({ fs, path: left.path.value })
|
|
58
|
+
// File picker: a private pane the command panel browses while a path
|
|
59
|
+
// argument is being picked. Kept out of `session.panes` so `load`, the
|
|
60
|
+
// pane refresh loop and pane navigation leave it alone; it is (re)read
|
|
61
|
+
// when the picker opens (see `core/commands#openPicker`).
|
|
62
|
+
session.pickerPane = createPane({ fs, path: left.path.value })
|
|
37
63
|
// Shown in the header title; empty when the environment has no hostname.
|
|
38
64
|
session.host = host ?? ""
|
|
65
|
+
// The renderer installs its quit hook at mount; the panel's Quit command
|
|
66
|
+
// calls it (core cannot exit a process or unmount a DOM app itself).
|
|
67
|
+
session.quit = null
|
|
39
68
|
// Bookmark model (named paths). The store defaults to in-memory.
|
|
40
69
|
session.bookmarks = bookmarks ?? createBookmarks()
|
|
70
|
+
// Undo/redo journal: reversible file operations (`mkdir`, `copy`, `move`,
|
|
71
|
+
// `rename`, `replace`, `trash`) record an inverse here; `^Z`/`^⇧Z` and the `u` history
|
|
72
|
+
// panel drive it. Session-only.
|
|
73
|
+
session.journal = createJournal()
|
|
74
|
+
// The shared list overlay (`u` history, `⇧T` trash): one modal list at a
|
|
75
|
+
// time. `overlay` is "history" | "trash" | null; `overlayItems` holds the
|
|
76
|
+
// rows the active overlay renders; `overlayIndex` is its cursor. A history
|
|
77
|
+
// row carries `index` (its position in the journal), a trash row carries
|
|
78
|
+
// `trashPath`.
|
|
79
|
+
session.overlay = cell(null)
|
|
80
|
+
session.overlayItems = cell([])
|
|
81
|
+
session.overlayIndex = cell(0)
|
|
82
|
+
// View mode: the three-column "explorer" (parent · current · preview) the
|
|
83
|
+
// workspace opens in, or the two-pane "commander". `view.toggleMode` (`t`)
|
|
84
|
+
// flips it; the workspace chrome in `components/Frame` reads it to decide
|
|
85
|
+
// which columns exist. Callers that need the two-pane workspace (tests, a
|
|
86
|
+
// host that prefers it) pass `viewMode: "commander"`.
|
|
87
|
+
session.viewMode = cell(viewMode)
|
|
41
88
|
session.active = cell("left")
|
|
42
|
-
// The
|
|
43
|
-
|
|
44
|
-
|
|
89
|
+
// The source/destination pane focus last was on. The auxiliary columns
|
|
90
|
+
// are attached to it, so `currentId` survives focus moving into them.
|
|
91
|
+
session.anchor = cell("left")
|
|
92
|
+
// The pane that owns the current directory: the focused pane when focus
|
|
93
|
+
// is on a source/destination pane, the anchor otherwise. Everything that
|
|
94
|
+
// changes a *path* (open, up, back, breadcrumbs, the command panel's
|
|
95
|
+
// relative arguments) targets it; cursor-only movement targets the
|
|
96
|
+
// focused pane.
|
|
97
|
+
session.currentId = derived([session.active, session.anchor], ([active, anchor]) =>
|
|
98
|
+
active === "left" || active === "right" ? active : anchor,
|
|
99
|
+
)
|
|
100
|
+
// The current pane's directory, tracked by the header title and by the
|
|
101
|
+
// parent column's mirror.
|
|
102
|
+
session.activePath = derived([session.currentId, left.path, right.path], ([current, l, r]) =>
|
|
103
|
+
current === "left" ? l : r,
|
|
45
104
|
)
|
|
46
105
|
session.showPreview = cell(true)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
106
|
+
// The inspector maximized over the workspace columns (`⇧V`): the preview
|
|
107
|
+
// takes the whole pane row until toggled back, leaving the identity and
|
|
108
|
+
// command bars in place. Independent of `showPreview`, so restoring the
|
|
109
|
+
// layout returns the inline inspector to exactly what it was.
|
|
110
|
+
session.previewFull = cell(false)
|
|
111
|
+
// The fullscreen inspector overlay: a framed preview covering the whole
|
|
112
|
+
// viewport. It opens from `v` on a narrow terminal and from Enter on a file
|
|
113
|
+
// that cannot be edited (see `core/commands#pane.enter`), and closes on Esc
|
|
114
|
+
// or `v`. Core-owned so a command can request it; the renderers only show
|
|
115
|
+
// it (`components/Frame#showViewer`). Independent of `showPreview` and
|
|
116
|
+
// `previewFull`, which drive the inline and the maximized inspector.
|
|
117
|
+
session.viewerOpen = cell(false)
|
|
51
118
|
session.notice = cell(null)
|
|
52
119
|
session.confirm = cell(null)
|
|
53
120
|
// The pane whose filter input currently owns the keyboard, or null.
|
|
54
121
|
session.filtering = cell(null)
|
|
122
|
+
// Back/forward restore the focused column alongside the listing: each
|
|
123
|
+
// source/destination pane captures `active` into its history entries and
|
|
124
|
+
// restores it on Esc/⇧Esc. A column the current mode does not show falls
|
|
125
|
+
// back to the pane owning the current directory.
|
|
126
|
+
const focusContext = {
|
|
127
|
+
get: () => session.active.value,
|
|
128
|
+
set: (value) => {
|
|
129
|
+
if (value) session.active.set(visibleColumn(session, value))
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
left.setContext(focusContext)
|
|
133
|
+
right.setContext(focusContext)
|
|
55
134
|
|
|
56
|
-
//
|
|
57
|
-
|
|
135
|
+
// Focus remembers which source/destination pane it came from.
|
|
136
|
+
effect([session.active], ([active]) => {
|
|
137
|
+
if (active === "left" || active === "right") session.anchor.set(active)
|
|
138
|
+
})
|
|
139
|
+
// The parent column only exists in explorer mode: leaving the mode hands
|
|
140
|
+
// focus back to the pane it was following.
|
|
141
|
+
effect([session.viewMode], ([mode]) => {
|
|
142
|
+
if (mode !== "explorer" && session.active.value === "parent") {
|
|
143
|
+
session.active.set(session.currentId.value)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
// The parent column mirrors the parent of the current directory. It never
|
|
147
|
+
// moves on its own: opening, stepping up or following a breadcrumb in it
|
|
148
|
+
// retargets the current pane instead (see `core/commands`). Its cursor
|
|
149
|
+
// follows the current directory, so the mirror always highlights the
|
|
150
|
+
// entry the current pane is showing. Mirroring never records history: the
|
|
151
|
+
// column's own back/forward stays on the paths the user navigated.
|
|
152
|
+
//
|
|
153
|
+
// `parent.entries` is a dependency so the cursor also lands when the
|
|
154
|
+
// mirror's listing arrives *after* the effect first runs — the explorer is
|
|
155
|
+
// the opening mode, so its parent column is populated by the initial
|
|
156
|
+
// `load` rather than by a later navigation.
|
|
157
|
+
effect([session.activePath, session.viewMode, session.panes.parent.entries], ([path, mode]) => {
|
|
158
|
+
if (mode !== "explorer" || !path) return
|
|
159
|
+
// The filesystem root has no parent; mirror itself rather than read a
|
|
160
|
+
// blank path.
|
|
161
|
+
const target = parentPath(path) ?? path
|
|
162
|
+
const parent = session.panes.parent
|
|
163
|
+
// Land the mirror's cursor on the current directory once its listing
|
|
164
|
+
// knows it. A sync left over from an older navigation yields: it
|
|
165
|
+
// checks the live path before writing the cursor.
|
|
166
|
+
const sync = () => {
|
|
167
|
+
if (session.activePath.value !== path) return
|
|
168
|
+
const index = indexOfPath(parent.entries.value, path)
|
|
169
|
+
if (index >= 0) parent.cursor.set(index)
|
|
170
|
+
}
|
|
171
|
+
if (parent.path.value !== target) void parent.navigate(target, { record: false }).then(sync)
|
|
172
|
+
else sync()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Bookmark panel: one modal list. `bookmarkQuery` filters the stored
|
|
176
|
+
// bookmarks by name or path; `bookmarkIndex` is its cursor; `bookmarkTarget`
|
|
177
|
+
// is the `{ path, isDir }` the add row would record (null while closed).
|
|
58
178
|
session.bookmarkOpen = cell(false)
|
|
59
|
-
session.bookmarkMode = cell("list")
|
|
60
179
|
session.bookmarkQuery = cell("")
|
|
61
180
|
session.bookmarkIndex = cell(0)
|
|
62
|
-
// The path being recorded while in "name" mode.
|
|
63
181
|
session.bookmarkTarget = cell(null)
|
|
64
182
|
|
|
65
|
-
// Command panel (
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// that filtered list; `panelRowIndex` is the cursor in the
|
|
69
|
-
// argument rows (headers + items); `panelFocus` is a
|
|
70
|
-
// ("ops": typing
|
|
71
|
-
//
|
|
72
|
-
// it never mutates the real
|
|
183
|
+
// Command panel (`:`): the one entry point, listing the CLI operations,
|
|
184
|
+
// with the selected operation's arguments bound from the live context.
|
|
185
|
+
// `panelQuery` filters the entry list by typed name; `panelOpIndex` selects
|
|
186
|
+
// an entry in that filtered list; `panelRowIndex` is the cursor in the
|
|
187
|
+
// flattened argument rows (headers + items); `panelFocus` is a
|
|
188
|
+
// `PANEL_FOCUS` token ("ops": typing
|
|
189
|
+
// filters entries, "args": an argument owns the keyboard). `panelDraft`
|
|
190
|
+
// holds the transient bindings the user tweaks — it never mutates the real
|
|
191
|
+
// pane marks.
|
|
73
192
|
session.panelOpen = cell(false)
|
|
74
193
|
session.panelQuery = cell("")
|
|
75
194
|
session.panelOpIndex = cell(0)
|
|
76
195
|
session.panelRowIndex = cell(0)
|
|
196
|
+
// The bit cursor inside a permission row (0=r, 1=w, 2=x): Space toggles
|
|
197
|
+
// the cell it marks, Left/Right move it.
|
|
198
|
+
session.panelPermBit = cell(0)
|
|
77
199
|
session.panelFocus = cell(PANEL_FOCUS.OPS)
|
|
78
200
|
session.panelDraft = cell([])
|
|
79
|
-
session.
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
session.
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
201
|
+
session.panelEntries = cell(panelEntries())
|
|
202
|
+
// Errors from a panel run stay beside the action so the user can retry
|
|
203
|
+
// without losing the command draft.
|
|
204
|
+
session.panelRunError = cell(null)
|
|
205
|
+
// Amber/red operations are two-step: the first Run prints the assembled
|
|
206
|
+
// command and arms `[Confirm]`; the second executes it. Any draft or
|
|
207
|
+
// operation change disarms it.
|
|
208
|
+
session.panelArmed = cell(false)
|
|
209
|
+
|
|
210
|
+
// Picker sub-mode: while a `dest`/`path` argument is being browsed the
|
|
211
|
+
// panel's argument column shows the picker's listing instead of the
|
|
212
|
+
// argument rows. `pickerMode` ("dir"/"file"/"any") decides what the
|
|
213
|
+
// list offers and what Enter accepts; `pickerArg` is the argument the
|
|
214
|
+
// chosen path is written back to; `pickerPendingRun` reruns the operation
|
|
215
|
+
// once a pick that was holding up a run completes.
|
|
216
|
+
session.pickerOpen = cell(false)
|
|
217
|
+
session.pickerMode = cell("dir")
|
|
218
|
+
session.pickerArg = cell(-1)
|
|
219
|
+
session.pickerPendingRun = cell(false)
|
|
220
|
+
// The picker's editable path and the region that owns the keyboard: "path"
|
|
221
|
+
// (the text field) or "rows" (the entry list). `pickerQuery` is the fragment
|
|
222
|
+
// the path text resolves to and drives the pane's fuzzy filter.
|
|
223
|
+
session.pickerPathText = cell("")
|
|
224
|
+
session.pickerFocus = cell("path")
|
|
225
|
+
// The directory relative path text resolves against (set when the picker
|
|
226
|
+
// opens), so typing a relative path does not drift as the picker navigates.
|
|
227
|
+
session.pickerBasePath = cell("")
|
|
228
|
+
session.pickerQuery = cell("")
|
|
229
|
+
// Serializes path resolution and drops stale keystrokes (see
|
|
230
|
+
// `core/picker#createPickerPathQueue`).
|
|
231
|
+
session.pickerPaths = createPickerPathQueue(session)
|
|
232
|
+
|
|
233
|
+
// Auto-refresh follows the columns the view actually shows. A hidden pane
|
|
234
|
+
// stops watching — and, on adapters without a native watcher, stops
|
|
235
|
+
// polling the filesystem — until it is shown again; resuming re-reads it
|
|
236
|
+
// once so the column never comes back stale. The picker's private pane
|
|
237
|
+
// only watches while the picker is open.
|
|
238
|
+
effect(
|
|
239
|
+
[session.viewMode, session.showPreview, session.currentId, session.pickerOpen],
|
|
240
|
+
([mode, showPreview, current, pickerOpen]) => {
|
|
241
|
+
const visible = new Set()
|
|
242
|
+
if (mode === "explorer") {
|
|
243
|
+
visible.add("parent")
|
|
244
|
+
visible.add(current)
|
|
245
|
+
} else {
|
|
246
|
+
visible.add("left")
|
|
247
|
+
visible.add("right")
|
|
248
|
+
}
|
|
249
|
+
if (showPreview) visible.add("preview")
|
|
250
|
+
for (const [id, pane] of Object.entries(session.panes)) {
|
|
251
|
+
pane.setAutoRefresh?.(visible.has(id))
|
|
252
|
+
}
|
|
253
|
+
session.pickerPane?.setAutoRefresh?.(Boolean(pickerOpen))
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
// The bookmark panel keeps its cursor on the first match whenever the query
|
|
258
|
+
// changes.
|
|
88
259
|
effect([session.bookmarkQuery], () => session.bookmarkIndex.set(0))
|
|
89
260
|
|
|
90
261
|
// Filter editing is bound to the active pane: switching pane drops the
|
|
@@ -92,42 +263,72 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
|
|
|
92
263
|
effect([session.active], () => session.filtering.set(null))
|
|
93
264
|
|
|
94
265
|
// The entry under the cursor of each pane, as a small plain object. The
|
|
95
|
-
// stat details (ctime, mode, uid, gid)
|
|
96
|
-
//
|
|
97
|
-
session.leftCursorEntry = derived([left.entries, left.cursor
|
|
98
|
-
entrySummary(
|
|
266
|
+
// listing carries the stat details (ctime, mode, uid, gid), so the detail
|
|
267
|
+
// line updates as the cursor moves without a separate stat pass.
|
|
268
|
+
session.leftCursorEntry = derived([left.entries, left.cursor], ([entries, cursor]) =>
|
|
269
|
+
entrySummary(entries[cursor]),
|
|
270
|
+
)
|
|
271
|
+
session.rightCursorEntry = derived([right.entries, right.cursor], ([entries, cursor]) =>
|
|
272
|
+
entrySummary(entries[cursor]),
|
|
99
273
|
)
|
|
100
|
-
|
|
101
|
-
|
|
274
|
+
// The same detail line for the explorer's parent column.
|
|
275
|
+
const parent = session.panes.parent
|
|
276
|
+
session.parentCursorEntry = derived([parent.entries, parent.cursor], ([entries, cursor]) =>
|
|
277
|
+
entrySummary(entries[cursor]),
|
|
102
278
|
)
|
|
103
279
|
|
|
104
|
-
// Preview target: a `view`/`stat` pin, or the
|
|
280
|
+
// Preview target: a `view`/`stat` pin, or the current pane's cursor entry.
|
|
281
|
+
// Focus on an auxiliary column does not move it: the preview keeps
|
|
282
|
+
// following the pane that owns the directory.
|
|
105
283
|
session.previewTarget = cell(null)
|
|
106
284
|
session.previewEntry = derived(
|
|
107
|
-
[session.
|
|
108
|
-
([
|
|
285
|
+
[session.currentId, session.leftCursorEntry, session.rightCursorEntry, session.previewTarget],
|
|
286
|
+
([current, l, r, target]) => target ?? (current === "left" ? l : r),
|
|
109
287
|
)
|
|
110
288
|
|
|
111
289
|
// Moving either cursor resumes following the cursor in the preview.
|
|
112
290
|
effect([session.leftCursorEntry, session.rightCursorEntry], () => session.previewTarget.set(null))
|
|
113
291
|
|
|
292
|
+
// The preview column mirrors the directory being previewed, so a
|
|
293
|
+
// directory reads as a pane in every view mode. It never moves on its
|
|
294
|
+
// own for the same reason as the parent column.
|
|
295
|
+
effect([session.previewEntry], ([entry]) => {
|
|
296
|
+
if (!entry?.isDir) return
|
|
297
|
+
if (session.panes.preview.path.value !== entry.path) {
|
|
298
|
+
void session.panes.preview.navigate(entry.path, { record: false })
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
114
302
|
session._previewToken = 0
|
|
115
303
|
|
|
116
304
|
// Preview payload: text head for files, metadata for the rest; `stat`
|
|
117
305
|
// targets carry `details` and render as a card even for directories.
|
|
118
306
|
session.preview = cell(null)
|
|
307
|
+
// Preview body scroll requests: a monotonic `{ delta, token }` event the
|
|
308
|
+
// renderers apply to their own body. Keeping it a signal rather than an
|
|
309
|
+
// offset lets each renderer clamp against its own measured viewport; the
|
|
310
|
+
// token makes two identical key presses distinct writes.
|
|
311
|
+
session.previewScroll = cell({ delta: 0, token: 0 })
|
|
119
312
|
session._stopPreviewWatch = effect([session.previewEntry, session.showPreview], async ([entry, show]) => {
|
|
120
313
|
if (!show || !entry) {
|
|
121
314
|
session.preview.set(null)
|
|
122
315
|
return
|
|
123
316
|
}
|
|
124
|
-
|
|
317
|
+
// The preview follows symlinks: its title names the traversal and its
|
|
318
|
+
// kind comes from what the link points at, not from the link's own
|
|
319
|
+
// name (a `notes -> README.md` previews as markdown). A compressed
|
|
320
|
+
// entry is classified by the name inside it and read through its
|
|
321
|
+
// decompressor (`notes.txt.gz` previews as text).
|
|
322
|
+
const source = previewSource(entry)
|
|
323
|
+
const kind = entry.isDir ? "directory" : previewKind(source.innerExt)
|
|
125
324
|
if (entry.details) {
|
|
126
325
|
session.preview.set({
|
|
127
326
|
kind,
|
|
128
|
-
title:
|
|
327
|
+
title: source.title,
|
|
129
328
|
size: entry.size,
|
|
130
|
-
ext:
|
|
329
|
+
ext: source.ext,
|
|
330
|
+
innerExt: source.innerExt,
|
|
331
|
+
compression: source.compression,
|
|
131
332
|
details: entry.details,
|
|
132
333
|
text: null,
|
|
133
334
|
})
|
|
@@ -138,14 +339,27 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
|
|
|
138
339
|
return
|
|
139
340
|
}
|
|
140
341
|
const token = ++session._previewToken
|
|
141
|
-
|
|
142
|
-
|
|
342
|
+
let head = null
|
|
343
|
+
if (kind === "text") {
|
|
344
|
+
if (source.compression) {
|
|
345
|
+
const bytes = await fs
|
|
346
|
+
.readBytes(entry.path, compressedBytes(source.compression))
|
|
347
|
+
.catch(() => null)
|
|
348
|
+
head = bytes
|
|
349
|
+
? await decompressHead(bytes, source.compression, PREVIEW_BYTES).catch(() => null)
|
|
350
|
+
: null
|
|
351
|
+
} else {
|
|
352
|
+
head = await fs.readTextHead(entry.path, PREVIEW_BYTES).catch(() => null)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
143
355
|
if (token !== session._previewToken) return
|
|
144
356
|
session.preview.set({
|
|
145
357
|
kind,
|
|
146
|
-
title:
|
|
358
|
+
title: source.title,
|
|
147
359
|
size: entry.size,
|
|
148
|
-
ext:
|
|
360
|
+
ext: source.ext,
|
|
361
|
+
innerExt: source.innerExt,
|
|
362
|
+
compression: source.compression,
|
|
149
363
|
text: head === null || hasBinaryByte(head) ? null : head,
|
|
150
364
|
})
|
|
151
365
|
})
|
|
@@ -177,113 +391,146 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
|
|
|
177
391
|
},
|
|
178
392
|
)
|
|
179
393
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
([active, l, r]) => (active === "left" ? l : r),
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
// Script path completion: the entries of the directory being typed (a
|
|
187
|
-
// token containing "/"), or the active pane's entries otherwise.
|
|
188
|
-
session.completionEntries = cell([])
|
|
189
|
-
session._completionToken = 0
|
|
190
|
-
effect(
|
|
191
|
-
[session.paletteMode, session.paletteQuery, session.activeEntries, session.active],
|
|
192
|
-
async ([mode, query, activeEntries, active]) => {
|
|
193
|
-
if (mode !== "script") {
|
|
194
|
-
session.completionEntries.set([])
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
const token = completionToken(query)
|
|
198
|
-
if (!token) {
|
|
199
|
-
session.completionEntries.set(activeEntries ?? [])
|
|
200
|
-
return
|
|
201
|
-
}
|
|
202
|
-
const base = session.panes[active]?.path.value ?? session.fs.cwd()
|
|
203
|
-
const dir = token.dir.startsWith("/") ? token.dir : join(base, token.dir)
|
|
204
|
-
const mine = ++session._completionToken
|
|
205
|
-
const entries = await session.fs.readDir(dir).catch(() => [])
|
|
206
|
-
if (mine !== session._completionToken) return
|
|
207
|
-
session.completionEntries.set(entries)
|
|
208
|
-
},
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
// Bookmark picker rows: the bookmarks fuzzily matched by name or path,
|
|
212
|
-
// ranked by fzy score. Only meaningful in "list" mode.
|
|
394
|
+
// Bookmark rows: the stored bookmarks fuzzily matched by name or path,
|
|
395
|
+
// ranked by fzy score. With no query, a first row offers to record
|
|
396
|
+
// `bookmarkTarget` when that path is not bookmarked yet.
|
|
213
397
|
session.bookmarkItems = derived(
|
|
214
|
-
[session.bookmarks.entries, session.bookmarkQuery, session.
|
|
215
|
-
([entries, query,
|
|
216
|
-
|
|
217
|
-
const rows = Object.entries(entries ?? {}).map(([name, entry]) => ({ name, ...entry }))
|
|
398
|
+
[session.bookmarks.entries, session.bookmarkQuery, session.bookmarkTarget],
|
|
399
|
+
([entries, query, target]) => {
|
|
400
|
+
const rows = Object.entries(entries ?? {}).map(([name, entry]) => ({ key: name, name, ...entry }))
|
|
218
401
|
const q = `${query ?? ""}`.trim()
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
402
|
+
let items
|
|
403
|
+
if (!q) {
|
|
404
|
+
items = rows.sort((a, b) => a.name.localeCompare(b.name))
|
|
405
|
+
} else {
|
|
406
|
+
const ranked = []
|
|
407
|
+
for (const row of rows) {
|
|
408
|
+
const best = Math.max(
|
|
409
|
+
hasMatch(q, row.name) ? score(q, row.name) : -Infinity,
|
|
410
|
+
hasMatch(q, row.path) ? score(q, row.path) : -Infinity,
|
|
411
|
+
)
|
|
412
|
+
if (best > -Infinity) ranked.push({ row, score: best })
|
|
413
|
+
}
|
|
414
|
+
ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : a.row.name.localeCompare(b.row.name)))
|
|
415
|
+
items = ranked.map((match) => match.row)
|
|
416
|
+
}
|
|
417
|
+
if (!q && target && !items.some((row) => row.path === target.path)) {
|
|
418
|
+
items = [{ key: BOOKMARK_ADD_KEY, add: true, name: BOOKMARK_ADD_KEY, path: target.path, isDir: target.isDir }, ...items]
|
|
227
419
|
}
|
|
228
|
-
|
|
229
|
-
return ranked.map((match) => match.row)
|
|
420
|
+
return items
|
|
230
421
|
},
|
|
231
422
|
)
|
|
232
423
|
|
|
233
424
|
// Command bar rows: context-dependent key hints (see core/commands). The
|
|
234
|
-
//
|
|
425
|
+
// panel button is dropped when a host disables the panel.
|
|
235
426
|
session.commandBarItems = derived(
|
|
236
|
-
[
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
session.filtering,
|
|
240
|
-
session.bookmarkOpen,
|
|
241
|
-
session.bookmarkMode,
|
|
242
|
-
session.panelOpen,
|
|
243
|
-
session.panelEnabled,
|
|
244
|
-
],
|
|
245
|
-
([open, mode, filtering, bookmarkOpen, bookmarkMode, panelOpen, panelEnabled]) => {
|
|
246
|
-
const items = getCommandBarItems(open, mode, filtering, bookmarkOpen, bookmarkMode, panelOpen)
|
|
247
|
-
if (panelEnabled) return items
|
|
248
|
-
return items.filter((item) => item.id !== "panel.toggle")
|
|
249
|
-
},
|
|
427
|
+
[session.filtering, session.bookmarkOpen, session.panelOpen, session.pickerOpen, session.panelArmed, session.overlay],
|
|
428
|
+
([filtering, bookmarkOpen, panelOpen, pickerOpen, panelArmed, overlay]) =>
|
|
429
|
+
getCommandBarItems({ filtering, bookmarkOpen, panelOpen, pickerOpen, panelArmed, overlay }),
|
|
250
430
|
)
|
|
251
431
|
|
|
252
|
-
// The
|
|
253
|
-
//
|
|
432
|
+
// The entry list filtered by the typed query; `panelOpIndex` indexes into
|
|
433
|
+
// this filtered list.
|
|
254
434
|
session.panelFiltered = derived(
|
|
255
|
-
[session.
|
|
256
|
-
([
|
|
435
|
+
[session.panelEntries, session.panelQuery],
|
|
436
|
+
([entries, query]) => filterPanelEntries(entries, query),
|
|
257
437
|
)
|
|
258
|
-
// The
|
|
259
|
-
session.
|
|
438
|
+
// The entry the command panel is editing, and its bound arguments.
|
|
439
|
+
session.panelEntry = derived(
|
|
260
440
|
[session.panelFiltered, session.panelOpIndex],
|
|
261
|
-
([
|
|
441
|
+
([entries, index]) => entries[Math.max(0, Math.min(index, entries.length - 1))] ?? null,
|
|
442
|
+
)
|
|
443
|
+
// The picker's visible listing for the current mode: a destination picker
|
|
444
|
+
// lists directories only, so every row is a valid choice; a file/any
|
|
445
|
+
// picker keeps the listing intact, where a directory row descends.
|
|
446
|
+
session.pickerVisible = derived(
|
|
447
|
+
[session.pickerPane.entries, session.pickerMode],
|
|
448
|
+
([entries, mode]) => pickerVisibleEntries(entries, mode),
|
|
449
|
+
)
|
|
450
|
+
session.pickerRows = derived(
|
|
451
|
+
[session.pickerVisible, session.pickerPane.path],
|
|
452
|
+
([entries, path]) => pickerRowList(entries, path),
|
|
262
453
|
)
|
|
263
|
-
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
454
|
+
// The argument column: the picker's entry rows while picking; otherwise the
|
|
455
|
+
// selected operation's arguments followed by its explicit Run action.
|
|
456
|
+
session.panelRows = derived(
|
|
457
|
+
[session.panelDraft, session.panelEntry, session.pickerOpen, session.pickerRows, session.panelRunError, session.panelArmed, session.activePath],
|
|
458
|
+
([draft, entry, open, picker, runError, armed, activePath]) => {
|
|
459
|
+
if (open) return picker
|
|
460
|
+
if (!entry) return []
|
|
461
|
+
const rows = panelRowList(draft)
|
|
462
|
+
// A command with no arguments still shows the context it would act
|
|
463
|
+
// on: a read-only PATH row over the active pane's path.
|
|
464
|
+
if (!rows.length && entry.showPath) {
|
|
465
|
+
rows.push({
|
|
466
|
+
type: "arg",
|
|
467
|
+
key: "arg:path",
|
|
468
|
+
rowIndex: 0,
|
|
469
|
+
argIndex: -1,
|
|
470
|
+
itemIndex: -1,
|
|
471
|
+
name: "PATH",
|
|
472
|
+
summary: activePath ?? "",
|
|
473
|
+
kind: "path",
|
|
474
|
+
role: "active",
|
|
475
|
+
optional: false,
|
|
476
|
+
variadic: false,
|
|
477
|
+
textual: false,
|
|
478
|
+
editable: false,
|
|
479
|
+
picker: false,
|
|
480
|
+
mode: "dir",
|
|
481
|
+
multi: false,
|
|
482
|
+
readonly: true,
|
|
483
|
+
value: activePath ?? "",
|
|
484
|
+
count: 0,
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
rows.push({
|
|
488
|
+
type: "run",
|
|
489
|
+
key: "panel:run",
|
|
490
|
+
rowIndex: rows.length,
|
|
491
|
+
name: runError ? "Retry" : armed ? "Confirm" : "Run",
|
|
492
|
+
risk: entry.risk ?? null,
|
|
493
|
+
group: entry.group ?? "File",
|
|
494
|
+
error: runError,
|
|
495
|
+
armed,
|
|
496
|
+
// The command printed below `[Confirm]`.
|
|
497
|
+
command: armed ? panelCommandLine(session, entry, draft) : null,
|
|
498
|
+
})
|
|
499
|
+
return rows
|
|
500
|
+
},
|
|
501
|
+
)
|
|
502
|
+
// The right pane's argument blocks (a label line over its value/field, and
|
|
503
|
+
// a bordered item list per selection).
|
|
504
|
+
// Operation-specific preflight output (currently `replace`'s old → new
|
|
505
|
+
// mapping) is distinct from the assembled shell command.
|
|
506
|
+
session.panelPreview = derived(
|
|
507
|
+
[session.panelDraft, session.panelEntry],
|
|
508
|
+
([draft, entry]) => {
|
|
509
|
+
const preview = entry?.op?.preview?.(session, argvFor(draft), draft)
|
|
510
|
+
return typeof preview === "string" ? preview : null
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
// Preserve the existing `panelCommand` projection for consumers: operations
|
|
514
|
+
// with a custom preview return it; all others return the assembled command.
|
|
515
|
+
// The panel renders custom preflight output separately from its Run action.
|
|
268
516
|
session.panelCommand = derived(
|
|
269
|
-
[session.panelDraft, session.
|
|
270
|
-
([draft,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return args ? `${name} ${args}` : name
|
|
517
|
+
[session.panelDraft, session.panelEntry, session.panelPreview],
|
|
518
|
+
([draft, entry, preview]) => {
|
|
519
|
+
if (typeof preview === "string") return preview
|
|
520
|
+
return panelCommandLine(session, entry, draft)
|
|
274
521
|
},
|
|
275
522
|
)
|
|
276
523
|
session.panelRunnable = derived([session.panelDraft], ([draft]) => panelRunnable(draft))
|
|
277
|
-
// The
|
|
278
|
-
// its
|
|
524
|
+
// The argument row under the right pane's cursor, and whether it is a
|
|
525
|
+
// textual argument (so the panel focuses its value field).
|
|
279
526
|
session.panelCursorRow = derived(
|
|
280
527
|
[session.panelRows, session.panelRowIndex],
|
|
281
528
|
([rows, row]) => rowAt(rows, row),
|
|
282
529
|
)
|
|
283
530
|
session.panelTextual = derived([session.panelCursorRow], ([row]) => isTextualRow(row))
|
|
284
|
-
// The text the
|
|
285
|
-
// effect copies the focused
|
|
286
|
-
// a textual
|
|
531
|
+
// The text the value field edits. A plain cell (the input writes through
|
|
532
|
+
// it); an effect copies the focused argument's value in whenever the cursor
|
|
533
|
+
// lands on a textual row, and another folds typing back into the transient
|
|
287
534
|
// draft so a value survives moving to another argument.
|
|
288
535
|
session.panelValue = cell("")
|
|
289
536
|
effect([session.panelCursorRow], ([row]) => {
|
|
@@ -296,67 +543,141 @@ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
|
|
|
296
543
|
}
|
|
297
544
|
})
|
|
298
545
|
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
// — the signature with the leading operation name stripped, so the name is
|
|
302
|
-
// not repeated next to the title.
|
|
303
|
-
session.panelItems = derived([session.panelFiltered], ([operations]) =>
|
|
304
|
-
operations.map((op) => ({
|
|
305
|
-
id: `op:${op.name}`,
|
|
306
|
-
title: op.name,
|
|
307
|
-
summary: `${op.signature ?? ""}`.replace(/^\S+\s*/, ""),
|
|
308
|
-
risk: op.risk ?? null,
|
|
309
|
-
})),
|
|
310
|
-
)
|
|
546
|
+
// The command-list column's rows: the filtered operations.
|
|
547
|
+
session.panelItems = derived([session.panelFiltered], ([entries]) => panelEntryRows(entries))
|
|
311
548
|
|
|
312
|
-
// Re-bind the panel draft when the panel opens or the selected
|
|
549
|
+
// Re-bind the panel draft when the panel opens or the selected entry
|
|
313
550
|
// changes. The panel is modal, so the pane context does not change
|
|
314
|
-
// underneath it;
|
|
315
|
-
effect([session.panelOpen, session.
|
|
551
|
+
// underneath it; opening the panel owns the draft while it is open.
|
|
552
|
+
effect([session.panelOpen, session.panelEntry], ([open, entry]) => {
|
|
316
553
|
if (!open) return
|
|
317
|
-
|
|
554
|
+
const draft = bindArgs(session, entry)
|
|
555
|
+
prefillPanelDraft(session, entry, draft)
|
|
556
|
+
session.panelDraft.set(draft)
|
|
318
557
|
})
|
|
319
|
-
// Selecting another
|
|
320
|
-
effect([session.
|
|
558
|
+
// Selecting another entry resets the argument cursor.
|
|
559
|
+
effect([session.panelEntry], () => {
|
|
321
560
|
session.panelRowIndex.set(0)
|
|
561
|
+
session.panelRunError.set(null)
|
|
562
|
+
session.panelArmed.set(false)
|
|
322
563
|
})
|
|
323
|
-
//
|
|
564
|
+
// Editing the draft after an error gives the next attempt a clean action
|
|
565
|
+
// row, and disarms a pending Confirm (the command changed under it).
|
|
566
|
+
effect([session.panelDraft], () => {
|
|
567
|
+
if (session.panelRunError.value) session.panelRunError.set(null)
|
|
568
|
+
if (session.panelArmed.value) session.panelArmed.set(false)
|
|
569
|
+
})
|
|
570
|
+
// Typing a query re-anchors the entry cursor on the first match.
|
|
324
571
|
effect([session.panelQuery], () => {
|
|
325
572
|
session.panelOpIndex.set(0)
|
|
326
573
|
})
|
|
327
574
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
session.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
575
|
+
// The editable path resolves to a directory plus a filter fragment; the
|
|
576
|
+
// queue drops stale keystrokes and keeps fast typing from landing an older
|
|
577
|
+
// navigation after a newer one (see `core/picker#createPickerPathQueue`).
|
|
578
|
+
effect([session.pickerPathText, session.pickerOpen], ([text, open]) => {
|
|
579
|
+
if (open) session.pickerPaths.schedule(text)
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// The picker's resolved fragment drives the picker pane's fuzzy filter and
|
|
583
|
+
// re-anchors the picker cursor on the first match. Only while picking, so
|
|
584
|
+
// the effect can never reset the argument cursor outside the picker.
|
|
585
|
+
effect([session.pickerQuery], ([query]) => {
|
|
586
|
+
session.pickerPane.setFilter(`${query ?? ""}`)
|
|
587
|
+
if (session.pickerOpen.value) {
|
|
588
|
+
const rows = session.pickerRows.value
|
|
589
|
+
const firstMatch = `${query ?? ""}` ? rows.findIndex((row) => row.type === "entry") : 0
|
|
590
|
+
session.panelRowIndex.set(firstMatch >= 0 ? firstMatch : 0)
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
// Closing the panel leaves the picker (it lives inside the panel).
|
|
594
|
+
effect([session.panelOpen], ([open]) => {
|
|
595
|
+
if (open) return
|
|
596
|
+
session.pickerOpen.set(false)
|
|
597
|
+
session.pickerArg.set(-1)
|
|
598
|
+
session.pickerPendingRun.set(false)
|
|
599
|
+
session.panelArmed.set(false)
|
|
600
|
+
})
|
|
344
601
|
|
|
345
602
|
session.dispatch = createDispatch(session)
|
|
346
603
|
session.load = () => load(session)
|
|
347
604
|
return session
|
|
348
605
|
}
|
|
349
606
|
|
|
607
|
+
// Function: visibleColumn
|
|
608
|
+
// `id` when the current view mode shows that column, else the pane that owns
|
|
609
|
+
// the current directory. Used to keep a restored focus on a column that
|
|
610
|
+
// exists.
|
|
611
|
+
function visibleColumn(session, id) {
|
|
612
|
+
const current = session.currentId.value
|
|
613
|
+
const columns =
|
|
614
|
+
session.viewMode.value === "explorer"
|
|
615
|
+
? session.showPreview.value
|
|
616
|
+
? ["parent", current, "preview"]
|
|
617
|
+
: ["parent", current]
|
|
618
|
+
: ["left", "right"]
|
|
619
|
+
return columns.includes(id) ? id : current
|
|
620
|
+
}
|
|
621
|
+
|
|
350
622
|
// Function: load
|
|
351
|
-
// Reads the initial listing for
|
|
623
|
+
// Reads the initial listing for every column — the two panes plus the two
|
|
624
|
+
// mirrors, which start on a valid path and so may never navigate — and the
|
|
625
|
+
// persisted bookmarks.
|
|
352
626
|
async function load(session) {
|
|
353
627
|
await Promise.all([
|
|
354
|
-
session.panes.
|
|
355
|
-
session.panes.right.refresh(),
|
|
628
|
+
...Object.values(session.panes).map((pane) => pane.refresh()),
|
|
356
629
|
session.bookmarks.load?.(),
|
|
357
630
|
])
|
|
358
631
|
}
|
|
359
632
|
|
|
633
|
+
// Function: prefillPanelDraft
|
|
634
|
+
// Seeds a freshly bound draft: `replace` prefills FIND with a single selected
|
|
635
|
+
// basename; exact `rename` prefills FROM and the containing directory from the
|
|
636
|
+
// active entry (or uses the pane's current directory when there is none).
|
|
637
|
+
function prefillPanelDraft(session, entry, draft) {
|
|
638
|
+
const name = entry?.op?.name
|
|
639
|
+
if (name === "replace") {
|
|
640
|
+
const find = (draft ?? []).find((binding) => binding.name === "FIND")
|
|
641
|
+
const selection = (draft ?? []).find((binding) => binding.kind === "selection")
|
|
642
|
+
const selected = (selection?.items ?? []).filter((item) => item.selected)
|
|
643
|
+
if (find && selected.length === 1) find.value = basename(selected[0].path)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
if (name !== "rename") return
|
|
647
|
+
const active = session.active?.value
|
|
648
|
+
const auxiliaryPane =
|
|
649
|
+
(active === "parent" && session.panes.parent) ||
|
|
650
|
+
(active === "preview" &&
|
|
651
|
+
session.showPreview?.value &&
|
|
652
|
+
session.previewEntry?.value?.isDir &&
|
|
653
|
+
!session.preview?.value &&
|
|
654
|
+
session.panes.preview)
|
|
655
|
+
const pane = auxiliaryPane || session.panes[session.currentId?.value ?? active] || session.panes.left
|
|
656
|
+
const from = (draft ?? []).find((binding) => binding.name === "FROM")
|
|
657
|
+
const path = (draft ?? []).find((binding) => binding.name === "PATH")
|
|
658
|
+
const marked = [...(pane.marked?.value ?? [])]
|
|
659
|
+
const selectedPath =
|
|
660
|
+
marked.length === 1 ? marked[0] : marked.length === 0 ? pane.currentEntry?.()?.path : null
|
|
661
|
+
if (selectedPath) {
|
|
662
|
+
if (from) from.value = basename(selectedPath)
|
|
663
|
+
if (path) path.value = parentPath(selectedPath) ?? pane.path.value ?? ""
|
|
664
|
+
} else if (path) {
|
|
665
|
+
path.value = pane.path.value ?? ""
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Function: panelCommandLine
|
|
670
|
+
// The command the panel prints below `[Confirm]`: an operation's own
|
|
671
|
+
// `displayCommand` when it has one (rename spells out the two resolved paths),
|
|
672
|
+
// otherwise the operation name followed by its assembled argument tokens.
|
|
673
|
+
function panelCommandLine(session, entry, draft) {
|
|
674
|
+
const display = entry?.op?.displayCommand?.(session, argvFor(draft), draft)
|
|
675
|
+
if (display) return display
|
|
676
|
+
const name = entry?.name ?? ""
|
|
677
|
+
const args = panelCommand(draft)
|
|
678
|
+
return args ? `${name} ${args}` : name
|
|
679
|
+
}
|
|
680
|
+
|
|
360
681
|
function entrySummary(entry) {
|
|
361
682
|
if (!entry) return null
|
|
362
683
|
return {
|
|
@@ -373,33 +694,10 @@ function entrySummary(entry) {
|
|
|
373
694
|
gid: entry.gid,
|
|
374
695
|
owner: entry.owner,
|
|
375
696
|
ext: entry.ext,
|
|
697
|
+
// The symlink's raw target, so the preview and the header can name
|
|
698
|
+
// where the link goes.
|
|
699
|
+
target: entry.target ?? null,
|
|
376
700
|
}
|
|
377
701
|
}
|
|
378
702
|
|
|
379
|
-
// Function: filterCommandRows
|
|
380
|
-
// Case-insensitive title filter; an empty query keeps every row.
|
|
381
|
-
function filterCommandRows(rows, query) {
|
|
382
|
-
const q = `${query ?? ""}`.trim().toLowerCase()
|
|
383
|
-
if (!q) return rows
|
|
384
|
-
return rows.filter((row) => row.title.toLowerCase().includes(q))
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Function: previewKind
|
|
388
|
-
// Preview classification for file entries; directories are not previewed.
|
|
389
|
-
function previewKind(entry) {
|
|
390
|
-
const image = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]
|
|
391
|
-
const media = ["mp3", "wav", "ogg", "flac", "mp4", "webm", "mkv", "mov", "pdf"]
|
|
392
|
-
if (entry.ext && image.includes(entry.ext)) return "image"
|
|
393
|
-
if (entry.ext && media.includes(entry.ext)) return "media"
|
|
394
|
-
return "text"
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function hasBinaryByte(text) {
|
|
398
|
-
for (let i = 0; i < 2048 && i < text.length; i++) {
|
|
399
|
-
const code = text.charCodeAt(i)
|
|
400
|
-
if (code < 9 || (code > 13 && code < 32)) return true
|
|
401
|
-
}
|
|
402
|
-
return false
|
|
403
|
-
}
|
|
404
|
-
|
|
405
703
|
export { createSession, load }
|