@ssebastien/tcmd 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -38
- package/bin/tcmd +11 -3
- package/bin/tcmd.js +15 -0
- package/package.json +4 -3
- package/public/index.html +14 -0
- package/public/main.js +17501 -0
- package/public/style.css +901 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +28 -12
- package/src/js/tcmd/components/App.js +135 -85
- package/src/js/tcmd/components/App.xml +64 -15
- package/src/js/tcmd/components/BookmarkList.html +3 -2
- package/src/js/tcmd/components/BookmarkList.js +1 -1
- package/src/js/tcmd/components/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +17 -0
- package/src/js/tcmd/components/CommandArgRow.js +86 -26
- package/src/js/tcmd/components/CommandArgRow.xml +31 -4
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +3 -3
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +1 -0
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +54 -52
- package/src/js/tcmd/components/CommandPanel.xml +38 -24
- package/src/js/tcmd/components/Confirm.html +1 -1
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +1 -1
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- package/src/js/tcmd/components/FilePreview.html +14 -3
- package/src/js/tcmd/components/FilePreview.js +73 -10
- package/src/js/tcmd/components/FilePreview.xml +19 -6
- package/src/js/tcmd/components/FileRow.html +1 -1
- package/src/js/tcmd/components/FileRow.js +10 -3
- package/src/js/tcmd/components/FilterBar.html +1 -1
- package/src/js/tcmd/components/FilterBar.js +14 -4
- package/src/js/tcmd/components/FilterBar.xml +1 -1
- package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
- package/src/js/tcmd/components/Help.html +7 -0
- package/src/js/tcmd/components/Help.js +109 -0
- package/src/js/tcmd/components/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/icons.js +39 -5
- package/src/js/tcmd/components/index.js +3 -4
- package/src/js/tcmd/components/rows.js +37 -33
- package/src/js/tcmd/core/commands.js +422 -180
- package/src/js/tcmd/core/directory.js +145 -95
- package/src/js/tcmd/core/fs.js +29 -2
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/keymap.js +3 -3
- package/src/js/tcmd/core/operations.js +108 -15
- package/src/js/tcmd/core/pane.js +31 -8
- package/src/js/tcmd/core/panel.js +172 -82
- package/src/js/tcmd/core/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +280 -152
- package/src/js/tcmd/index.js +0 -1
- package/src/js/tcmd/tui/app.js +26 -5
- package/src/js/tcmd/tui/catalogue.js +6 -19
- package/src/js/tcmd/tui/index.js +1 -16
- package/src/js/tcmd/tui/theme.js +5 -0
- package/src/js/tcmd/web/app.js +158 -32
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +45 -39
- package/src/js/tcmd/web/effects.js +143 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/seed.js +3 -3
- package/src/js/tcmd/web/server.js +6 -4
- package/src/js/tcmd/web/theme.js +1 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/CommandPalette.xml +0 -16
- package/src/js/tcmd/components/PaletteRow.html +0 -3
- package/src/js/tcmd/components/PaletteRow.js +0 -43
- package/src/js/tcmd/components/PaletteRow.xml +0 -4
- package/src/js/tcmd/tui/help.js +0 -32
|
@@ -8,10 +8,20 @@
|
|
|
8
8
|
// their leading content; other kinds show a metadata card. Renderer-agnostic
|
|
9
9
|
// logic; `FilePreview.xml` / `FilePreview.html` are the presentations.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// A directory is the exception: it renders the *directory column* — its own
|
|
12
|
+
// breadcrumbs over a cursorable listing — so a previewed directory looks and
|
|
13
|
+
// behaves like a pane in every view mode (click a row to focus it, Enter to
|
|
14
|
+
// descend). The listing is the inspector pane's (`session.panes.preview`),
|
|
15
|
+
// threaded down as the `dir*` inputs; `paneId` is what rows, crumbs and the
|
|
16
|
+
// column body publish back to the App.
|
|
17
|
+
//
|
|
18
|
+
// Environment: `textFg`, `textSecondaryFg`, `textHeadingFg`, and the optional
|
|
19
|
+
// `actions` footer text (default: the TUI's `[Open O] [Edit E]` hints; the web
|
|
20
|
+
// substitutes a note that those keys do not exist in a browser).
|
|
12
21
|
|
|
13
22
|
import { unwrap } from "select/state/cells"
|
|
14
23
|
import { formatSize } from "./format.js"
|
|
24
|
+
import { previewSource } from "../core/session.js"
|
|
15
25
|
|
|
16
26
|
const KIND_LABELS = {
|
|
17
27
|
directory: "directory",
|
|
@@ -57,7 +67,9 @@ function previewTitle(preview) {
|
|
|
57
67
|
|
|
58
68
|
// Function: entryCard
|
|
59
69
|
// Metadata card for the entry the session did not preview: a directory, or a
|
|
60
|
-
// file whose kind has no inline preview.
|
|
70
|
+
// file whose kind has no inline preview. Directories normally take the
|
|
71
|
+
// directory column instead (see `isDirectoryPane`); the branch stays for a
|
|
72
|
+
// `stat` payload and for hosts that render no listing.
|
|
61
73
|
function entryCard(entry) {
|
|
62
74
|
if (!entry) return ""
|
|
63
75
|
const lines = []
|
|
@@ -69,42 +81,93 @@ function entryCard(entry) {
|
|
|
69
81
|
return lines.join("\n")
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
// Function: isDirectoryPane
|
|
85
|
+
// True when the inspector previews a plain directory: no `stat` card was
|
|
86
|
+
// pinned on it, so the directory column takes the body.
|
|
87
|
+
function isDirectoryPane(preview, entry) {
|
|
88
|
+
return Boolean(entry?.isDir) && !preview
|
|
89
|
+
}
|
|
90
|
+
|
|
72
91
|
// Function: headerTitle
|
|
73
|
-
// The inspector title: the preview's own title, else the cursor entry
|
|
92
|
+
// The inspector title: the preview's own title, else the cursor entry — whose
|
|
93
|
+
// symlink target and resolved extension `previewSource` supplies, so the
|
|
94
|
+
// header names the traversal before the async read lands.
|
|
74
95
|
function headerTitle(preview, entry) {
|
|
75
96
|
if (preview) return previewTitle(preview)
|
|
76
97
|
if (entry) {
|
|
77
|
-
const
|
|
78
|
-
|
|
98
|
+
const source = previewSource(entry)
|
|
99
|
+
const badge = entry.isDir ? "DIR" : (source.ext || "file").toUpperCase()
|
|
100
|
+
const name = entry.isDir ? `${entry.name}/` : entry.name
|
|
101
|
+
const target = entry.isSymlink && entry.target ? ` -> ${entry.target}` : ""
|
|
102
|
+
return `${name}${target} <${badge}>`
|
|
79
103
|
}
|
|
80
104
|
return "inspector"
|
|
81
105
|
}
|
|
82
106
|
|
|
83
107
|
// Function: createBehaviors
|
|
84
108
|
// Builds the FilePreview behavior map for the given renderer environment.
|
|
109
|
+
// `env.actions` overrides the footer line so a presentation can carry its own
|
|
110
|
+
// copy (the browser has no Open/Edit); the default advertises the TUI keys.
|
|
85
111
|
function createBehaviors(env) {
|
|
112
|
+
const actions = env.actions ?? (() => " [Open O] [Edit E]")
|
|
113
|
+
const directory = (_self, data) =>
|
|
114
|
+
isDirectoryPane(unwrap(data.preview), unwrap(data.entry))
|
|
115
|
+
const body = (_self, data) => !directory(_self, data)
|
|
86
116
|
return {
|
|
87
|
-
actions
|
|
117
|
+
actions,
|
|
88
118
|
actionsFg: () => env.textSecondaryFg,
|
|
89
119
|
cardText: (_self, data) => cardText(unwrap(data.preview)),
|
|
120
|
+
// The directory column: breadcrumbs over the inspector pane's listing,
|
|
121
|
+
// shaped like a pane header (a rule separates it from the body).
|
|
122
|
+
dirCrumbsView: (_self, data) => ({ segments: data.dirSegments, paneId: data.paneId }),
|
|
123
|
+
dirListView: (_self, data) => ({
|
|
124
|
+
entries: data.dirEntries,
|
|
125
|
+
cursor: data.dirCursor,
|
|
126
|
+
marked: data.dirMarked,
|
|
127
|
+
active: data.dirActive,
|
|
128
|
+
loading: data.dirLoading,
|
|
129
|
+
paneId: data.paneId,
|
|
130
|
+
}),
|
|
90
131
|
dimFg: () => env.textSecondaryFg,
|
|
91
132
|
emptyText: () => " No file selected\n Move the cursor or press v",
|
|
92
133
|
entryText: (_self, data) => entryCard(unwrap(data.entry)),
|
|
134
|
+
// The rule between the contents and the shortcut footer.
|
|
135
|
+
footRule: () => ["top"],
|
|
136
|
+
footRuleFg: () => env.textSecondaryFg,
|
|
137
|
+
headRule: () => ["top"],
|
|
138
|
+
headRuleFg: () => env.textSecondaryFg,
|
|
93
139
|
hasCard: (_self, data) => hasCard(unwrap(data.preview)),
|
|
94
|
-
// The metadata card fills in whenever there is a cursor entry but no
|
|
95
|
-
// preview payload (a directory, or a non-text file).
|
|
96
|
-
hasEntryCard: (_self, data) => !unwrap(data.preview) && Boolean(unwrap(data.entry)),
|
|
97
140
|
hasText: (_self, data) => hasText(unwrap(data.preview)),
|
|
98
141
|
headerTitle: (_self, data) => headerTitle(unwrap(data.preview), unwrap(data.entry)),
|
|
99
142
|
isEmpty: (_self, data) => !unwrap(data.preview) && !unwrap(data.entry),
|
|
143
|
+
isDirPane: directory,
|
|
100
144
|
previewText: (_self, data) => unwrap(data.preview)?.text ?? "",
|
|
101
145
|
previewTitle: (_self, data) => previewTitle(unwrap(data.preview)),
|
|
102
146
|
previewTitleColor: () => env.textHeadingFg,
|
|
103
147
|
show: (_self, data) => Boolean(unwrap(data.show)),
|
|
148
|
+
// One body at a time: the directory column, else the card/text/empty
|
|
149
|
+
// states, with the title row yielding to the breadcrumb header.
|
|
150
|
+
// `showEntryCard` fills in whenever there is a cursor entry but no
|
|
151
|
+
// preview payload — except a directory, which gets the listing.
|
|
152
|
+
showBody: body,
|
|
153
|
+
showCard: (_self, data) => body(_self, data) && hasCard(unwrap(data.preview)),
|
|
154
|
+
showEmpty: (_self, data) => body(_self, data) && !unwrap(data.preview) && !unwrap(data.entry),
|
|
155
|
+
showEntryCard: (_self, data) =>
|
|
156
|
+
body(_self, data) && !unwrap(data.preview) && Boolean(unwrap(data.entry)),
|
|
157
|
+
showText: (_self, data) => body(_self, data) && hasText(unwrap(data.preview)),
|
|
104
158
|
textFg: () => env.textFg,
|
|
105
159
|
}
|
|
106
160
|
}
|
|
107
161
|
|
|
108
|
-
export {
|
|
162
|
+
export {
|
|
163
|
+
cardText,
|
|
164
|
+
createBehaviors,
|
|
165
|
+
entryCard,
|
|
166
|
+
hasCard,
|
|
167
|
+
hasText,
|
|
168
|
+
headerTitle,
|
|
169
|
+
isDirectoryPane,
|
|
170
|
+
previewTitle,
|
|
171
|
+
}
|
|
109
172
|
|
|
110
173
|
// EOF
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
<!-- The inspector: a card/text body for a file, and a directory column
|
|
2
|
+
(breadcrumbs + cursorable listing) for a directory, so a previewed
|
|
3
|
+
directory reads as a pane. The body and the column share the border and
|
|
4
|
+
the footer, and the whole column is clickable to take focus. -->
|
|
1
5
|
<box when="show" out:title="headerTitle" out:titleColor="previewTitleColor" border borderStyle="rounded"
|
|
2
|
-
flexDirection="column" flexGrow="1" overflow="hidden">
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
flexDirection="column" flexGrow="1" overflow="hidden" on:click="paneId!pane:activate">
|
|
7
|
+
<box when="isDirPane" flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
|
|
8
|
+
<box flexGrow="1" flexShrink="1" minWidth="0" overflow="hidden">
|
|
9
|
+
<breadcrumbs in:segments="dirSegments" in:paneId="paneId" height="1"></breadcrumbs>
|
|
10
|
+
</box>
|
|
11
|
+
</box>
|
|
12
|
+
<box when="isDirPane" height="1" out:border="headRule" out:borderColor="headRuleFg"></box>
|
|
13
|
+
<filelist when="isDirPane" in:entries="dirEntries" in:cursor="dirCursor" in:marked="dirMarked"
|
|
14
|
+
in:active="dirActive" in:paneId="paneId" in:loading="dirLoading"></filelist>
|
|
15
|
+
<scrollbox when="showBody" flexGrow="1">
|
|
16
|
+
<text when="showText" out="previewText" out:fg="textFg"></text>
|
|
17
|
+
<text when="showCard" out="cardText" out:fg="dimFg"></text>
|
|
18
|
+
<text when="showEntryCard" out="entryText" out:fg="dimFg"></text>
|
|
19
|
+
<text when="showEmpty" out="emptyText" out:fg="dimFg"></text>
|
|
8
20
|
</scrollbox>
|
|
21
|
+
<box height="1" out:border="footRule" out:borderColor="footRuleFg"></box>
|
|
9
22
|
<box height="1"><text out="actions" out:fg="actionsFg"></text></box>
|
|
10
23
|
</box>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<li class="file-row" out:class="rowClass" on:click="paneId:paneId,index:index!list:select">
|
|
1
|
+
<li class="file-row" out:class="rowClass" out:data-marked="rowMarked" out:data-active="rowActive" out:data-kind="rowKind" on:click="paneId:paneId,index:index!list:select">
|
|
2
2
|
<span class="marker" out="rowMarker"></span>
|
|
3
3
|
<span class="name" out:class="rowNameClass" out="rowName"></span>
|
|
4
4
|
<span class="size" out="rowSize"></span>
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// markedRow, active, paneId }`, the same bag shape in both engines.
|
|
9
9
|
// `currentRow`/`markedRow` are plain per-row booleans snapshotted by the
|
|
10
10
|
// `fileRows` builder (see `rows.js`); `active` is the pane-focus cell, read
|
|
11
|
-
// with `unwrap` at the point of use.
|
|
11
|
+
// with `unwrap` at the point of use. A symlink row carries its target in the
|
|
12
|
+
// name (`notes -> ../real/notes`, see `rowName`).
|
|
12
13
|
//
|
|
13
14
|
// Environment: `cursorBg`, `cursorInactiveBg`, `currentClass`,
|
|
14
15
|
// `accentSecondaryFg`, `accentFg`, `linkFg`, `dirFg`, `devFg`, `fileFg`,
|
|
@@ -20,10 +21,13 @@ import { iconFor } from "./icons.js"
|
|
|
20
21
|
|
|
21
22
|
// Function: rowName
|
|
22
23
|
// The entry name with the directory slash (the icon is its own slot, so it
|
|
23
|
-
// can be dimmer than the name — see `rowIcon`)
|
|
24
|
+
// can be dimmer than the name — see `rowIcon`), and, for a symlink, the
|
|
25
|
+
// `-> target` tail `ls -l` prints. The name cell truncates, so a narrow pane
|
|
26
|
+
// drops the target before the name itself.
|
|
24
27
|
function rowName(entry) {
|
|
25
28
|
if (!entry) return ""
|
|
26
|
-
|
|
29
|
+
const name = entry.isDir ? `${entry.name}/` : entry.name
|
|
30
|
+
return entry.target ? `${name} -> ${entry.target}` : name
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
// Function: rowIcon
|
|
@@ -115,6 +119,9 @@ function createBehaviors(env) {
|
|
|
115
119
|
return undefined
|
|
116
120
|
},
|
|
117
121
|
rowClass: (_self, data) => (rowCurrent(data) ? env.currentClass : null),
|
|
122
|
+
rowMarked: (_self, data) => `${rowIsMarked(data)}`,
|
|
123
|
+
rowActive: (_self, data) => `${unwrap(data.active) !== false}`,
|
|
124
|
+
rowKind: (_self, data) => data.item?.isSymlink ? "link" : data.item?.isDir ? "dir" : data.item?.isDevice ? "dev" : "file",
|
|
118
125
|
// The icon is its own slot so it can sit dimmer than the name; it
|
|
119
126
|
// collapses when the environment keeps rows icon-free.
|
|
120
127
|
hasIcon: () => Boolean(env.icons),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div class="filter-bar" when="visible">
|
|
2
2
|
<span class="filter-prompt" out="prompt"></span>
|
|
3
|
-
<input when="editing" ref="query" inout="query" class="filter-input" />
|
|
3
|
+
<input when="editing" ref="query" inout="query" on:input="onFilter" class="filter-input" />
|
|
4
4
|
<span when="applied" class="filter-label" out="label"></span>
|
|
5
5
|
<span class="filter-alert" out="alert"></span>
|
|
6
6
|
</div>
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
// FilterBar: the fuzzy filter indicator, folded into the pane header so it
|
|
8
8
|
// never shifts the listing. While the pane owns the filter input
|
|
9
9
|
// (`filtering === paneId`) it shows a compact focused input bound to the pane's
|
|
10
|
-
// `query`;
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the
|
|
10
|
+
// `query`; each change also routes through the pane's `setFilter` (passed as
|
|
11
|
+
// `setFilter`), so the cursor re-anchors with no directory watcher. Once
|
|
12
|
+
// accepted it degrades to the same `/ query` indication, so the pane is
|
|
13
|
+
// navigable again and the filter can still be cleared with Esc. When the query
|
|
14
|
+
// matches no entry it shows a `no match` alert. The key hints live in the
|
|
15
|
+
// command bar, not here.
|
|
14
16
|
//
|
|
15
17
|
// Environment: `accentFg` (the applied label), `textSecondaryFg` (the `/`
|
|
16
18
|
// prompt), `warningFg` (the no-match alert).
|
|
@@ -33,6 +35,14 @@ function createBehaviors(env) {
|
|
|
33
35
|
applied: (self, data) => !editing(self, data) && activeQuery(self, data).length > 0,
|
|
34
36
|
editing,
|
|
35
37
|
focused: editing,
|
|
38
|
+
// The input both displays and captures through `inout`; `on:input`
|
|
39
|
+
// additionally routes the change through the pane's `setFilter` so the
|
|
40
|
+
// cursor re-anchors on the first match. The TUI delivers the input
|
|
41
|
+
// value directly, the DOM an event carrying it.
|
|
42
|
+
onFilter: (_self, data, event) => {
|
|
43
|
+
const value = typeof event === "string" ? event : `${event?.target?.value ?? ""}`
|
|
44
|
+
data.setFilter?.(value)
|
|
45
|
+
},
|
|
36
46
|
// The bare query (shared with the web presentation).
|
|
37
47
|
label: (self, data) => queryText(self, data),
|
|
38
48
|
labelFg: () => env.accentFg,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<box when="visible" flexDirection="row" height="1" flexShrink="0">
|
|
2
2
|
<text out="prompt" out:fg="promptFg"></text>
|
|
3
|
-
<input when="editing" ref="query" inout="query" out:focused="focused" width="14" height="1"></input>
|
|
3
|
+
<input when="editing" ref="query" inout="query" on:input="onFilter" out:focused="focused" width="14" height="1"></input>
|
|
4
4
|
<text when="applied" out="labelText" out:fg="labelFg" wrapMode="none" truncate="true"></text>
|
|
5
5
|
<text out="alert" out:fg="alertFg" wrapMode="none"></text>
|
|
6
6
|
</box>
|
|
@@ -3,17 +3,52 @@
|
|
|
3
3
|
// License: BSD
|
|
4
4
|
// Created: 2026-10-02
|
|
5
5
|
|
|
6
|
-
// Module: tcmd/
|
|
7
|
-
//
|
|
8
|
-
// breakpoints driven by the `uiSize` cell, the pane borders, header rules,
|
|
9
|
-
//
|
|
10
|
-
// injected into
|
|
11
|
-
//
|
|
6
|
+
// Module: tcmd/components/Frame
|
|
7
|
+
// Shared presentation helpers for the workspace chrome: the responsive
|
|
8
|
+
// breakpoints driven by the `uiSize` cell, the pane borders, header rules, the
|
|
9
|
+
// command-bar geometry and the overlay visibility gates. Everything here is
|
|
10
|
+
// injected into each renderer's App binding. Viewport dimensions are in
|
|
11
|
+
// monospace columns/rows; the browser measures them from its own font. The key
|
|
12
|
+
// reference's text lives with its overlay in `Help.js`.
|
|
12
13
|
|
|
13
14
|
import { unwrap } from "select/state/cells"
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
|
|
15
|
+
import { commandBarMarksItem, commandBarRowCount, commandBarStatus } from "./CommandBar.js"
|
|
16
|
+
import pkg from "../../../../package.json" with { type: "json" }
|
|
17
|
+
|
|
18
|
+
// Constant: VERSION
|
|
19
|
+
// The published version, read from package.json so the identity bar can never
|
|
20
|
+
// drift from what ships.
|
|
21
|
+
const VERSION = `${pkg?.version ?? ""}`
|
|
22
|
+
|
|
23
|
+
// Constant: MODE_SLOTS
|
|
24
|
+
// Per-letter slots the identity bar draws the view-mode word through, one
|
|
25
|
+
// `<text>` each so every letter can carry its own gradient colour (the same
|
|
26
|
+
// trick the `TinyCmd` brand uses). Sized for the longest mode name; a shorter
|
|
27
|
+
// one leaves the trailing slots empty, and an empty text node takes no column.
|
|
28
|
+
const MODE_SLOTS = "commander".length
|
|
29
|
+
|
|
30
|
+
// Function: modeWord
|
|
31
|
+
// The view-mode name as it reads in the identity bar.
|
|
32
|
+
function modeWord(viewMode) {
|
|
33
|
+
return viewMode === "explorer" ? "explorer" : "commander"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Function: modeGradient
|
|
37
|
+
// The `[from, to]` brand pair a mode word shimmers along: explorer takes the
|
|
38
|
+
// Tiny blues, commander the Cmd oranges, so the mode reads as part of the
|
|
39
|
+
// logo rather than as trailing status text.
|
|
40
|
+
function modeGradient(env, viewMode) {
|
|
41
|
+
return viewMode === "explorer"
|
|
42
|
+
? [env.theme.brandTinyFrom, env.theme.brandTinyTo]
|
|
43
|
+
: [env.theme.brandCmdFrom, env.theme.brandCmdTo]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Function: versionTrail
|
|
47
|
+
// The identity bar's version tail, ` · 0.1.3`. It carries its leading space
|
|
48
|
+
// so it follows the mode word's slots directly in both renderers.
|
|
49
|
+
function versionTrail() {
|
|
50
|
+
return VERSION ? ` · ${VERSION}` : ""
|
|
51
|
+
}
|
|
17
52
|
|
|
18
53
|
// Below this the two-pane workspace is replaced by a resize hint.
|
|
19
54
|
const MIN_WIDTH = 64
|
|
@@ -42,6 +77,21 @@ function isNarrow(data, self) {
|
|
|
42
77
|
return Boolean(size && (size.width ?? 0) > 0 && size.width < INSPECTOR_INLINE_WIDTH)
|
|
43
78
|
}
|
|
44
79
|
|
|
80
|
+
// Function: isExplorer
|
|
81
|
+
// True when the workspace is the three-column explorer view. Absent
|
|
82
|
+
// `viewMode` data (tests, host-less bindings) means the two-pane commander.
|
|
83
|
+
function isExplorer(data) {
|
|
84
|
+
return unwrap(data?.viewMode) === "explorer"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Function: currentColumnId
|
|
88
|
+
// The id of the column holding the current directory: the focused pane when
|
|
89
|
+
// focus is on source/destination, the anchor pane otherwise. (`core/pane` has
|
|
90
|
+
// the same question over a session; this one reads the view's data bag.)
|
|
91
|
+
function currentColumnId(data) {
|
|
92
|
+
return `${unwrap(data?.currentId) ?? unwrap(data?.active) ?? "left"}`
|
|
93
|
+
}
|
|
94
|
+
|
|
45
95
|
// Function: commandBarRowsFor
|
|
46
96
|
// The entries the bar currently shows: the context hints or the `bar`
|
|
47
97
|
// commands, plus the active pane's marked count as a trailing entry.
|
|
@@ -106,6 +156,31 @@ function shineOf(letters, from, to) {
|
|
|
106
156
|
function createFrameBehaviors(env) {
|
|
107
157
|
const tiny = shineOf("Tiny", env.theme.brandTinyFrom, env.theme.brandTinyTo)
|
|
108
158
|
const cmd = shineOf("Cmd", env.theme.brandCmdFrom, env.theme.brandCmdTo)
|
|
159
|
+
// The mode word, letter by letter: `modeL<i>` is the letter, `modeFg<i>`
|
|
160
|
+
// its gradient colour — the same per-letter trick the `TinyCmd` brand
|
|
161
|
+
// uses, since a terminal has no gradient to draw. The run is cached per
|
|
162
|
+
// word (there are only two), and both behaviors read the live `viewMode`,
|
|
163
|
+
// so flipping `t` redraws it in the other brand's colours.
|
|
164
|
+
const modeRuns = new Map()
|
|
165
|
+
const modeRun = (data) => {
|
|
166
|
+
const viewMode = unwrap(data?.viewMode)
|
|
167
|
+
const word = modeWord(viewMode)
|
|
168
|
+
let run = modeRuns.get(word)
|
|
169
|
+
if (!run) {
|
|
170
|
+
run = shineOf(word, ...modeGradient(env, viewMode))
|
|
171
|
+
modeRuns.set(word, run)
|
|
172
|
+
}
|
|
173
|
+
return run
|
|
174
|
+
}
|
|
175
|
+
const modeSlots = {}
|
|
176
|
+
for (let i = 0; i < MODE_SLOTS; i++) {
|
|
177
|
+
// The `when` gate matters: an empty text node still claims a column in
|
|
178
|
+
// the row, so the slots past the end of a shorter word are dropped
|
|
179
|
+
// from the layout instead of padding it.
|
|
180
|
+
modeSlots[`modeHas${i}`] = (_self, data) => modeRun(data)[i] !== undefined
|
|
181
|
+
modeSlots[`modeL${i}`] = (_self, data) => modeRun(data)[i]?.letter ?? ""
|
|
182
|
+
modeSlots[`modeFg${i}`] = (_self, data) => modeRun(data)[i]?.fg
|
|
183
|
+
}
|
|
109
184
|
return {
|
|
110
185
|
// Identity bar: "Tiny" shimmers along the brand blues, "Cmd" along a
|
|
111
186
|
// brighter orange than the mark accent, and the host trail follows in
|
|
@@ -130,6 +205,12 @@ function createFrameBehaviors(env) {
|
|
|
130
205
|
return host ? ` - ${host} ` : " "
|
|
131
206
|
},
|
|
132
207
|
topBarFg: () => env.theme.textHeadingFg,
|
|
208
|
+
// Right-aligned trail: the version, following the gradiented mode word
|
|
209
|
+
// (`modeL*`) slot by slot. Dimmer than the brand, so the row still
|
|
210
|
+
// reads left to right.
|
|
211
|
+
topBarStatus: () => versionTrail(),
|
|
212
|
+
topBarStatusFg: () => env.theme.textSecondaryFg,
|
|
213
|
+
...modeSlots,
|
|
133
214
|
|
|
134
215
|
// Responsive gates. `uiSize` is absent in tests and the web, so the
|
|
135
216
|
// workspace renders normally there.
|
|
@@ -160,14 +241,32 @@ function createFrameBehaviors(env) {
|
|
|
160
241
|
// filter is being edited or applied.
|
|
161
242
|
leftFilterOn: (_self, data) => Boolean(unwrap(data.leftFilter)),
|
|
162
243
|
rightFilterOn: (_self, data) => Boolean(unwrap(data.rightFilter)),
|
|
244
|
+
parentFilterOn: (_self, data) => Boolean(unwrap(data.parentFilter)),
|
|
245
|
+
|
|
246
|
+
// Which columns the workspace draws. Commander tiles source and
|
|
247
|
+
// destination; explorer drops destination for the parent of the
|
|
248
|
+
// current directory, which leads the row so the current pane stays in
|
|
249
|
+
// the middle. The inspector column follows `showInspectorInline` and
|
|
250
|
+
// completes the three columns in either mode. Maximizing the inspector
|
|
251
|
+
// (`⇧V`) hides every column so the preview can take the row.
|
|
252
|
+
showParentPane: (_self, data) => isExplorer(data) && !unwrap(data?.previewFull),
|
|
253
|
+
showLeftPane: (_self, data) =>
|
|
254
|
+
(!isExplorer(data) || currentColumnId(data) === "left") && !unwrap(data?.previewFull),
|
|
255
|
+
showRightPane: (_self, data) =>
|
|
256
|
+
(!isExplorer(data) || currentColumnId(data) === "right") && !unwrap(data?.previewFull),
|
|
163
257
|
|
|
164
258
|
// The inline inspector shows when the preview is on and the viewport is
|
|
165
|
-
// wide enough; otherwise `v` opens the fullscreen viewer.
|
|
259
|
+
// wide enough; otherwise `v` opens the fullscreen viewer. Maximizing
|
|
260
|
+
// (`⇧V`) replaces the column with `showPreviewFull` below.
|
|
166
261
|
inspectorNarrow: (self, data) => isNarrow(data, self),
|
|
167
|
-
showInspectorInline: (self, data) =>
|
|
262
|
+
showInspectorInline: (self, data) =>
|
|
263
|
+
Boolean(unwrap(data.showPreview)) && !isNarrow(data, self) && !unwrap(data?.previewFull),
|
|
168
264
|
showViewer: (self, data) => Boolean(unwrap(data.viewerOpen)) && isNarrow(data, self),
|
|
265
|
+
// The maximized inspector: the preview fills the pane row in either
|
|
266
|
+
// view mode, pushed by `view.maximize` (`⇧V`).
|
|
267
|
+
showPreviewFull: (_self, data) => Boolean(unwrap(data?.previewFull)),
|
|
169
268
|
|
|
170
|
-
// Help overlay visibility; the content lives in `
|
|
269
|
+
// Help overlay visibility; the content lives in `Help.js` so the
|
|
171
270
|
// overlay has a single source of truth.
|
|
172
271
|
helpOpen: (_self, data) => Boolean(unwrap(data.helpOpen)),
|
|
173
272
|
|
|
@@ -180,90 +279,20 @@ function createFrameBehaviors(env) {
|
|
|
180
279
|
}
|
|
181
280
|
}
|
|
182
281
|
|
|
183
|
-
// Function: keyGlyph
|
|
184
|
-
// Human-readable glyph for a keymap chord ("↑", "⇧Tab", "^W", "PgUp").
|
|
185
|
-
function keyGlyph(chord) {
|
|
186
|
-
const name = chord.key
|
|
187
|
-
const named = {
|
|
188
|
-
up: "↑",
|
|
189
|
-
down: "↓",
|
|
190
|
-
left: "←",
|
|
191
|
-
right: "→",
|
|
192
|
-
return: "↵",
|
|
193
|
-
enter: "↵",
|
|
194
|
-
space: "Space",
|
|
195
|
-
tab: "Tab",
|
|
196
|
-
pageup: "PgUp",
|
|
197
|
-
pagedown: "PgDn",
|
|
198
|
-
escape: "Esc",
|
|
199
|
-
backspace: "Bksp",
|
|
200
|
-
home: "Home",
|
|
201
|
-
end: "End",
|
|
202
|
-
}
|
|
203
|
-
let base = named[name] ?? (name.length === 1 ? name.toUpperCase() : `${name}`)
|
|
204
|
-
if (chord.ctrl) base = `^${base}`
|
|
205
|
-
if (chord.meta) base = `⌘${base}`
|
|
206
|
-
if (chord.shift && name !== "tab") base = `⇧${base}`
|
|
207
|
-
return base
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Constant: GROUP_ORDER / GROUP_TITLES
|
|
211
|
-
// Help sections, in display order.
|
|
212
|
-
const GROUP_ORDER = ["nav", "pane", "list", "bookmark", "view", "file", "panel", "palette", "filter", "app"]
|
|
213
|
-
const GROUP_TITLES = {
|
|
214
|
-
app: "Session",
|
|
215
|
-
bookmark: "Bookmarks",
|
|
216
|
-
file: "Files",
|
|
217
|
-
filter: "Filter",
|
|
218
|
-
list: "Selection",
|
|
219
|
-
nav: "Navigation",
|
|
220
|
-
palette: "Commands",
|
|
221
|
-
panel: "Command panel",
|
|
222
|
-
pane: "Panes",
|
|
223
|
-
view: "View",
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Function: helpLines
|
|
227
|
-
// Builds the categorized key reference from the keymap and the command
|
|
228
|
-
// registry, so it can never drift from the actual bindings.
|
|
229
|
-
function helpLines() {
|
|
230
|
-
const byId = new Map(getCommands().map((command) => [command.id, command]))
|
|
231
|
-
const groups = new Map()
|
|
232
|
-
const seen = new Set()
|
|
233
|
-
for (const binding of DEFAULT_KEYMAP) {
|
|
234
|
-
const command = byId.get(binding.command)
|
|
235
|
-
if (!command) continue
|
|
236
|
-
const group = binding.command.split(".")[0]
|
|
237
|
-
if (!groups.has(group)) groups.set(group, new Map())
|
|
238
|
-
const rows = groups.get(group)
|
|
239
|
-
if (!rows.has(binding.command)) rows.set(binding.command, { keys: [], title: command.title })
|
|
240
|
-
const row = rows.get(binding.command)
|
|
241
|
-
const glyph = keyGlyph(binding)
|
|
242
|
-
if (!row.keys.includes(glyph) && !seen.has(`${binding.command}:${glyph}`)) {
|
|
243
|
-
row.keys.push(glyph)
|
|
244
|
-
seen.add(`${binding.command}:${glyph}`)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
const lines = []
|
|
248
|
-
for (const group of GROUP_ORDER) {
|
|
249
|
-
const rows = groups.get(group)
|
|
250
|
-
if (!rows) continue
|
|
251
|
-
lines.push(GROUP_TITLES[group] ?? group)
|
|
252
|
-
for (const row of rows.values()) {
|
|
253
|
-
lines.push(` ${row.keys.join("/").padEnd(9)} ${row.title}`)
|
|
254
|
-
}
|
|
255
|
-
lines.push("")
|
|
256
|
-
}
|
|
257
|
-
return lines
|
|
258
|
-
}
|
|
259
|
-
|
|
260
282
|
export {
|
|
261
283
|
commandBarLines,
|
|
262
284
|
createFrameBehaviors,
|
|
263
|
-
|
|
285
|
+
currentColumnId,
|
|
264
286
|
INSPECTOR_INLINE_WIDTH,
|
|
287
|
+
isExplorer,
|
|
265
288
|
isNarrow,
|
|
266
|
-
|
|
289
|
+
MIN_HEIGHT,
|
|
290
|
+
MIN_WIDTH,
|
|
291
|
+
MODE_SLOTS,
|
|
292
|
+
modeGradient,
|
|
293
|
+
modeWord,
|
|
267
294
|
sizeOf,
|
|
295
|
+
versionTrail,
|
|
296
|
+
VERSION,
|
|
268
297
|
}
|
|
269
298
|
// EOF
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-02
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/Help
|
|
7
|
+
// Shared behavior for the `?` key reference overlay, plus the reference text
|
|
8
|
+
// itself (`helpLines`). The content is built from the live keymap and command
|
|
9
|
+
// registry, so it cannot drift from the actual bindings. Both presentations
|
|
10
|
+
// (`Help.xml` / `Help.html`) render the same lines.
|
|
11
|
+
|
|
12
|
+
import { unwrap } from "select/state/cells"
|
|
13
|
+
import { DEFAULT_KEYMAP } from "../core/keymap.js"
|
|
14
|
+
import { getCommands } from "../core/commands.js"
|
|
15
|
+
|
|
16
|
+
// Function: keyGlyph
|
|
17
|
+
// Human-readable glyph for a keymap chord ("↑", "⇧Tab", "^W", "PgUp").
|
|
18
|
+
function keyGlyph(chord) {
|
|
19
|
+
const name = chord.key
|
|
20
|
+
const named = {
|
|
21
|
+
up: "↑",
|
|
22
|
+
down: "↓",
|
|
23
|
+
left: "←",
|
|
24
|
+
right: "→",
|
|
25
|
+
return: "↵",
|
|
26
|
+
enter: "↵",
|
|
27
|
+
space: "Space",
|
|
28
|
+
tab: "Tab",
|
|
29
|
+
pageup: "PgUp",
|
|
30
|
+
pagedown: "PgDn",
|
|
31
|
+
escape: "Esc",
|
|
32
|
+
backspace: "Bksp",
|
|
33
|
+
home: "Home",
|
|
34
|
+
end: "End",
|
|
35
|
+
}
|
|
36
|
+
let base = named[name] ?? (name.length === 1 ? name.toUpperCase() : `${name}`)
|
|
37
|
+
if (chord.ctrl) base = `^${base}`
|
|
38
|
+
if (chord.meta) base = `⌘${base}`
|
|
39
|
+
if (chord.shift && name !== "tab") base = `⇧${base}`
|
|
40
|
+
return base
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Constant: GROUP_ORDER / GROUP_TITLES
|
|
44
|
+
// Help sections, in display order.
|
|
45
|
+
const GROUP_ORDER = ["nav", "pane", "list", "bookmark", "view", "file", "panel", "filter", "app"]
|
|
46
|
+
const GROUP_TITLES = {
|
|
47
|
+
app: "Session",
|
|
48
|
+
bookmark: "Bookmarks",
|
|
49
|
+
file: "Files",
|
|
50
|
+
filter: "Filter",
|
|
51
|
+
list: "Selection",
|
|
52
|
+
nav: "Navigation",
|
|
53
|
+
panel: "Commands",
|
|
54
|
+
pane: "Panes",
|
|
55
|
+
view: "View",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Function: helpLines
|
|
59
|
+
// The categorized key reference, one line per heading or binding.
|
|
60
|
+
function helpLines() {
|
|
61
|
+
const byId = new Map(getCommands().map((command) => [command.id, command]))
|
|
62
|
+
const groups = new Map()
|
|
63
|
+
const seen = new Set()
|
|
64
|
+
for (const binding of DEFAULT_KEYMAP) {
|
|
65
|
+
const command = byId.get(binding.command)
|
|
66
|
+
if (!command) continue
|
|
67
|
+
const group = binding.command.split(".")[0]
|
|
68
|
+
if (!groups.has(group)) groups.set(group, new Map())
|
|
69
|
+
const rows = groups.get(group)
|
|
70
|
+
if (!rows.has(binding.command)) rows.set(binding.command, { keys: [], title: command.title })
|
|
71
|
+
const row = rows.get(binding.command)
|
|
72
|
+
const glyph = keyGlyph(binding)
|
|
73
|
+
if (!row.keys.includes(glyph) && !seen.has(`${binding.command}:${glyph}`)) {
|
|
74
|
+
row.keys.push(glyph)
|
|
75
|
+
seen.add(`${binding.command}:${glyph}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const lines = []
|
|
79
|
+
for (const group of GROUP_ORDER) {
|
|
80
|
+
const rows = groups.get(group)
|
|
81
|
+
if (!rows) continue
|
|
82
|
+
lines.push(GROUP_TITLES[group] ?? group)
|
|
83
|
+
for (const row of rows.values()) {
|
|
84
|
+
lines.push(` ${row.keys.join("/").padEnd(9)} ${row.title}`)
|
|
85
|
+
}
|
|
86
|
+
lines.push("")
|
|
87
|
+
}
|
|
88
|
+
return lines
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Function: createBehaviors
|
|
92
|
+
// Builds the Help behavior map for the given renderer environment.
|
|
93
|
+
function createBehaviors(env) {
|
|
94
|
+
return {
|
|
95
|
+
bg: () => env.theme.surfaceBg,
|
|
96
|
+
borderFg: () => env.theme.borderPrimaryActiveFg,
|
|
97
|
+
dimFg: () => env.theme.textSecondaryFg,
|
|
98
|
+
hint: () => " Esc / ? close ",
|
|
99
|
+
open: (_self, data) => Boolean(unwrap(data.open)),
|
|
100
|
+
text: () => helpLines().join("\n"),
|
|
101
|
+
textFg: () => env.theme.textFg,
|
|
102
|
+
title: () => " Keys ",
|
|
103
|
+
titleFg: () => env.theme.textHeadingFg,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { createBehaviors, helpLines, keyGlyph }
|
|
108
|
+
|
|
109
|
+
// EOF
|