@ssebastien/tcmd 0.0.0-stage → 0.1.0
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 +234 -2
- package/bin/tcmd +7 -0
- package/package.json +37 -5
- package/src/js/tcmd/components/App.html +28 -0
- package/src/js/tcmd/components/App.js +355 -0
- package/src/js/tcmd/components/App.xml +98 -0
- package/src/js/tcmd/components/BookmarkList.html +12 -0
- package/src/js/tcmd/components/BookmarkList.js +73 -0
- package/src/js/tcmd/components/BookmarkList.xml +18 -0
- package/src/js/tcmd/components/BookmarkRow.html +5 -0
- package/src/js/tcmd/components/BookmarkRow.js +40 -0
- package/src/js/tcmd/components/BookmarkRow.xml +10 -0
- package/src/js/tcmd/components/Breadcrumbs.js +27 -0
- package/src/js/tcmd/components/CommandArgRow.js +90 -0
- package/src/js/tcmd/components/CommandArgRow.xml +5 -0
- package/src/js/tcmd/components/CommandBar.html +4 -0
- package/src/js/tcmd/components/CommandBar.js +126 -0
- package/src/js/tcmd/components/CommandBar.xml +4 -0
- package/src/js/tcmd/components/CommandItem.html +3 -0
- package/src/js/tcmd/components/CommandItem.js +78 -0
- package/src/js/tcmd/components/CommandItem.xml +6 -0
- package/src/js/tcmd/components/CommandPalette.html +7 -0
- package/src/js/tcmd/components/CommandPalette.js +58 -0
- package/src/js/tcmd/components/CommandPalette.xml +16 -0
- package/src/js/tcmd/components/CommandPanel.js +98 -0
- package/src/js/tcmd/components/CommandPanel.xml +35 -0
- package/src/js/tcmd/components/Confirm.html +3 -0
- package/src/js/tcmd/components/Confirm.js +36 -0
- package/src/js/tcmd/components/Confirm.xml +7 -0
- package/src/js/tcmd/components/Crumb.html +4 -0
- package/src/js/tcmd/components/Crumb.js +48 -0
- package/src/js/tcmd/components/Crumb.xml +4 -0
- package/src/js/tcmd/components/EntryDetail.html +15 -0
- package/src/js/tcmd/components/EntryDetail.js +194 -0
- package/src/js/tcmd/components/EntryDetail.xml +16 -0
- package/src/js/tcmd/components/FileList.html +1 -0
- package/src/js/tcmd/components/FileList.js +41 -0
- package/src/js/tcmd/components/FileList.xml +7 -0
- package/src/js/tcmd/components/FilePreview.html +5 -0
- package/src/js/tcmd/components/FilePreview.js +110 -0
- package/src/js/tcmd/components/FilePreview.xml +10 -0
- package/src/js/tcmd/components/FileRow.html +5 -0
- package/src/js/tcmd/components/FileRow.js +135 -0
- package/src/js/tcmd/components/FileRow.xml +10 -0
- package/src/js/tcmd/components/FilterBar.html +6 -0
- package/src/js/tcmd/components/FilterBar.js +50 -0
- package/src/js/tcmd/components/FilterBar.xml +6 -0
- package/src/js/tcmd/components/Help.xml +11 -0
- package/src/js/tcmd/components/PaletteRow.html +3 -0
- package/src/js/tcmd/components/PaletteRow.js +43 -0
- package/src/js/tcmd/components/PaletteRow.xml +4 -0
- package/src/js/tcmd/components/RowList.html +1 -0
- package/src/js/tcmd/components/RowList.xml +1 -0
- package/src/js/tcmd/components/Summary.html +3 -0
- package/src/js/tcmd/components/Summary.js +77 -0
- package/src/js/tcmd/components/Summary.xml +3 -0
- package/src/js/tcmd/components/bind.js +20 -0
- package/src/js/tcmd/components/format.js +155 -0
- package/src/js/tcmd/components/icons.js +185 -0
- package/src/js/tcmd/components/index.js +41 -0
- package/src/js/tcmd/components/rows.js +212 -0
- package/src/js/tcmd/core/bookmarks.js +175 -0
- package/src/js/tcmd/core/commands.js +930 -0
- package/src/js/tcmd/core/directory.js +194 -0
- package/src/js/tcmd/core/entries.js +88 -0
- package/src/js/tcmd/core/fs.js +266 -0
- package/src/js/tcmd/core/index.js +39 -0
- package/src/js/tcmd/core/keymap.js +60 -0
- package/src/js/tcmd/core/operations.js +453 -0
- package/src/js/tcmd/core/os.js +128 -0
- package/src/js/tcmd/core/pane.js +74 -0
- package/src/js/tcmd/core/panel.js +383 -0
- package/src/js/tcmd/core/path.js +57 -0
- package/src/js/tcmd/core/script.js +186 -0
- package/src/js/tcmd/core/selection.js +43 -0
- package/src/js/tcmd/core/session.js +405 -0
- package/src/js/tcmd/index.js +53 -0
- package/src/js/tcmd/tui/app.js +140 -0
- package/src/js/tcmd/tui/catalogue.js +300 -0
- package/src/js/tcmd/tui/frame.js +269 -0
- package/src/js/tcmd/tui/help.js +32 -0
- package/src/js/tcmd/tui/index.js +104 -0
- package/src/js/tcmd/tui/theme.js +177 -0
- package/src/js/tcmd/vendor/fzy.js +243 -0
- package/src/js/tcmd/web/app.js +154 -0
- package/src/js/tcmd/web/catalogue.js +159 -0
- package/src/js/tcmd/web/header.html +1 -0
- package/src/js/tcmd/web/header.js +38 -0
- package/src/js/tcmd/web/http.js +79 -0
- package/src/js/tcmd/web/index.js +22 -0
- package/src/js/tcmd/web/main.js +151 -0
- package/src/js/tcmd/web/opfs.js +282 -0
- package/src/js/tcmd/web/seed.js +90 -0
- package/src/js/tcmd/web/server.js +191 -0
- package/src/js/tcmd/web/theme.js +23 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/CommandPalette
|
|
7
|
+
// CommandPalette: popup listing commands filtered by the typed query. In
|
|
8
|
+
// scripting mode it lists operations and filesets and prefixes the input with
|
|
9
|
+
// `:`. The row logic lives in `PaletteRow.js`; this parent maps its `items` to
|
|
10
|
+
// rows.
|
|
11
|
+
//
|
|
12
|
+
// Environment: `overlayBg`, `borderPrimaryActiveFg`, `textSecondaryFg`
|
|
13
|
+
// (footer), `textHeadingFg` (exposed as the `titleColor` behavior),
|
|
14
|
+
// `rows(data)` returning the keyed rows and `scrollTop(self, data)` (TUI
|
|
15
|
+
// viewport tracking).
|
|
16
|
+
|
|
17
|
+
import { unwrap } from "select/state/cells"
|
|
18
|
+
|
|
19
|
+
// Function: paletteSign
|
|
20
|
+
// The prompt shown before the input; overridable per renderer.
|
|
21
|
+
function paletteSign(mode) {
|
|
22
|
+
return unwrap(mode) === "script" ? ":" : "›"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Function: paletteTitle
|
|
26
|
+
function paletteTitle(mode) {
|
|
27
|
+
return unwrap(mode) === "script" ? "script" : "palette"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Function: createBehaviors
|
|
31
|
+
// Builds the CommandPalette behavior map for the given renderer environment.
|
|
32
|
+
function createBehaviors(env) {
|
|
33
|
+
const behaviors = {
|
|
34
|
+
bg: () => env.overlayBg,
|
|
35
|
+
borderFg: () => env.borderPrimaryActiveFg,
|
|
36
|
+
confirm: (self, data) => self.pub("palette:confirm", unwrap(data.query) ?? ""),
|
|
37
|
+
focused: (_self, data) => Boolean(unwrap(data.open)),
|
|
38
|
+
footer: (_self, data) =>
|
|
39
|
+
unwrap(data.mode) === "script"
|
|
40
|
+
? "Tab complete ↵ run Esc close"
|
|
41
|
+
: "↑↓ move Tab complete ↵ run Esc close",
|
|
42
|
+
footerFg: () => env.textSecondaryFg,
|
|
43
|
+
sign: (_self, data) => paletteSign(data.mode),
|
|
44
|
+
title: (_self, data) => paletteTitle(data.mode),
|
|
45
|
+
titleColor: () => env.textHeadingFg,
|
|
46
|
+
}
|
|
47
|
+
if (env?.rows) {
|
|
48
|
+
behaviors.rows = (_self, data) => env.rows(self, data)
|
|
49
|
+
}
|
|
50
|
+
if (env?.scrollTop) {
|
|
51
|
+
behaviors.scrollTop = (self, data) => env.scrollTop(self, data)
|
|
52
|
+
}
|
|
53
|
+
return behaviors
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { createBehaviors, paletteSign, paletteTitle }
|
|
57
|
+
|
|
58
|
+
// EOF
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<box when="open" width="100%" height="100%" alignItems="center" justifyContent="center">
|
|
2
|
+
<box width="72%" height="60%" border borderStyle="rounded"
|
|
3
|
+
out:title="title" out:titleColor="titleColor" out:borderColor="borderFg"
|
|
4
|
+
out:backgroundColor="bg" flexDirection="column">
|
|
5
|
+
<box flexDirection="row" height="1" paddingLeft="1">
|
|
6
|
+
<text out="sign" out:fg="titleColor"></text>
|
|
7
|
+
<input ref="query" inout="query" flexGrow="1" height="1" out:focused="focused" on:enter="confirm"></input>
|
|
8
|
+
</box>
|
|
9
|
+
<scrollbox ref="list" flexGrow="1">
|
|
10
|
+
<box out="rows" flexDirection="column"></box>
|
|
11
|
+
</scrollbox>
|
|
12
|
+
<box flexDirection="row" height="1" paddingLeft="1">
|
|
13
|
+
<text out="footer" out:fg="footerFg"></text>
|
|
14
|
+
</box>
|
|
15
|
+
</box>
|
|
16
|
+
</box>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-09
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/CommandPanel
|
|
7
|
+
// CommandPanel: the Commando-style overlay (`m` / `:`). A filter input at the
|
|
8
|
+
// top narrows the left operation list as you type (the matching command takes
|
|
9
|
+
// the cursor); a right column shows the selected operation's arguments: one
|
|
10
|
+
// header row per argument (its name and binding summary) and, for a selection,
|
|
11
|
+
// one row per path with a `[x]`/`[ ]` toggle. A vertical rule divides the two
|
|
12
|
+
// columns, a horizontal one separates them from the "runs" line below, which
|
|
13
|
+
// shows the assembled command the draft maps to. The footer advertises the
|
|
14
|
+
// panel keys. The row logic lives in `CommandArgRow.js`; this parent maps both
|
|
15
|
+
// the operation list and the flattened argument rows.
|
|
16
|
+
//
|
|
17
|
+
// Environment: `overlayBg`, `borderPrimaryActiveFg`, `textSecondaryFg`
|
|
18
|
+
// (footer), `textHeadingFg` (title/sign), `accentFg` (preview),
|
|
19
|
+
// `errorFg` (unrunnable preview), `opRows(self, data)` / `argRows(self, data)`
|
|
20
|
+
// returning the keyed row lists, and `argScrollTop(self, data)` (TUI viewport
|
|
21
|
+
// tracking).
|
|
22
|
+
|
|
23
|
+
import { unwrap } from "select/state/cells"
|
|
24
|
+
import { PANEL_FOCUS } from "../core/panel.js"
|
|
25
|
+
|
|
26
|
+
// Function: panelTitle
|
|
27
|
+
// " Command · copy " while an operation is selected (spaces so the title sits
|
|
28
|
+
// clear of the border corners).
|
|
29
|
+
function panelTitle(op) {
|
|
30
|
+
const name = unwrap(op)?.name
|
|
31
|
+
return name ? ` Command · ${name} ` : " Command "
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Function: panelFooter
|
|
35
|
+
const PANEL_FOOTER = "type to filter ⇥ args Spc toggle ↵ run Esc close"
|
|
36
|
+
|
|
37
|
+
// Function: panelSign
|
|
38
|
+
// The prompt before the command filter input (a trailing space so the typed
|
|
39
|
+
// text doesn't crowd the arrow; overridable per renderer).
|
|
40
|
+
function panelSign() {
|
|
41
|
+
return "› "
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Function: createBehaviors
|
|
45
|
+
// Builds the CommandPanel behavior map for the given renderer environment.
|
|
46
|
+
function createBehaviors(env) {
|
|
47
|
+
// The assembled command line's colour, shared by the line and its "runs"
|
|
48
|
+
// label: the preview reads as an error until every required argument is
|
|
49
|
+
// filled.
|
|
50
|
+
const previewColor = (data) => (unwrap(data.runnable) === false ? env.errorFg ?? env.accentFg : env.accentFg)
|
|
51
|
+
const behaviors = {
|
|
52
|
+
bg: () => env.overlayBg,
|
|
53
|
+
borderFg: () => env.borderPrimaryActiveFg,
|
|
54
|
+
// Enter in a text field runs the operation (the App key handler routes
|
|
55
|
+
// Enter for the non-input cases).
|
|
56
|
+
confirm: (self) => self.pub("panel:confirm"),
|
|
57
|
+
// The argument value field appears only while a textual argument owns
|
|
58
|
+
// the cursor; otherwise the command filter input keeps focus.
|
|
59
|
+
fieldVisible: (_self, data) => Boolean(unwrap(data.textual)) && unwrap(data.focus) === PANEL_FOCUS.ARGS,
|
|
60
|
+
fieldFocused: (_self, data) => Boolean(unwrap(data.textual)) && unwrap(data.focus) === PANEL_FOCUS.ARGS,
|
|
61
|
+
fieldPrompt: (_self, data) => {
|
|
62
|
+
const row = unwrap(data.argRow)
|
|
63
|
+
return row?.type === "arg" ? `${row.name}: ` : ""
|
|
64
|
+
},
|
|
65
|
+
footer: () => PANEL_FOOTER,
|
|
66
|
+
footerFg: () => env.textSecondaryFg,
|
|
67
|
+
// Dividers: a vertical rule between the operation list and the argument
|
|
68
|
+
// sidebar, a horizontal one above the "runs" preview — both in the
|
|
69
|
+
// secondary border colour, like the pane header rules.
|
|
70
|
+
ruleFg: () => env.borderSecondaryFg,
|
|
71
|
+
sideRule: () => ["left"],
|
|
72
|
+
headRule: () => ["top"],
|
|
73
|
+
labelFg: () => env.textSecondaryFg,
|
|
74
|
+
focusArgs: (_self, data) => unwrap(data.focus) === PANEL_FOCUS.ARGS,
|
|
75
|
+
focusOps: (_self, data) => unwrap(data.focus) !== PANEL_FOCUS.ARGS,
|
|
76
|
+
open: (_self, data) => Boolean(unwrap(data.open)),
|
|
77
|
+
// The assembled command line the draft maps to, and its colour (see
|
|
78
|
+
// `previewColor`); the "runs" label wears the same colour so the two
|
|
79
|
+
// read as one block.
|
|
80
|
+
preview: (_self, data) => unwrap(data.command) ?? "",
|
|
81
|
+
previewFg: (_self, data) => previewColor(data),
|
|
82
|
+
previewLabel: () => "runs",
|
|
83
|
+
previewLabelFg: (_self, data) => previewColor(data),
|
|
84
|
+
// The filter input owns the keyboard while the panel is open and the
|
|
85
|
+
// command column is focused; typing narrows the operation list.
|
|
86
|
+
queryFocused: (_self, data) => Boolean(unwrap(data.open)) && unwrap(data.focus) !== PANEL_FOCUS.ARGS,
|
|
87
|
+
sign: () => panelSign(),
|
|
88
|
+
signFg: () => env.textHeadingFg,
|
|
89
|
+
title: (_self, data) => panelTitle(data.operation),
|
|
90
|
+
titleColor: () => env.textHeadingFg,
|
|
91
|
+
}
|
|
92
|
+
if (env?.opRows) behaviors.opRows = (_self, data) => env.opRows(self, data)
|
|
93
|
+
if (env?.argRows) behaviors.argRows = (_self, data) => env.argRows(self, data)
|
|
94
|
+
if (env?.argScrollTop) behaviors.argScrollTop = (self, data) => env.argScrollTop(self, data)
|
|
95
|
+
return behaviors
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { PANEL_FOOTER, createBehaviors, panelSign, panelTitle }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<box when="open" width="100%" height="100%" alignItems="center" justifyContent="center">
|
|
2
|
+
<box width="80%" height="72%" border borderStyle="rounded"
|
|
3
|
+
out:title="title" out:titleColor="titleColor" out:borderColor="borderFg"
|
|
4
|
+
out:backgroundColor="bg" flexDirection="column">
|
|
5
|
+
<box flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
|
|
6
|
+
<text out="sign" out:fg="signFg" wrapMode="none"></text>
|
|
7
|
+
<input ref="query" inout="query" flexGrow="1" height="1" out:focused="queryFocused" on:enter="confirm"></input>
|
|
8
|
+
</box>
|
|
9
|
+
<!-- Horizontal rule under the filter input. -->
|
|
10
|
+
<box height="1" out:border="headRule" out:borderColor="ruleFg"></box>
|
|
11
|
+
<box flexDirection="row" flexGrow="1" overflow="hidden" columnGap="1">
|
|
12
|
+
<scrollbox flexGrow="2">
|
|
13
|
+
<box out="opRows" flexDirection="column"></box>
|
|
14
|
+
</scrollbox>
|
|
15
|
+
<!-- Vertical rule between the operation list and the argument sidebar. -->
|
|
16
|
+
<box width="1" out:border="sideRule" out:borderColor="ruleFg"></box>
|
|
17
|
+
<scrollbox ref="args" flexGrow="3" out:scrollTop="argScrollTop">
|
|
18
|
+
<box out="argRows" flexDirection="column"></box>
|
|
19
|
+
</scrollbox>
|
|
20
|
+
</box>
|
|
21
|
+
<!-- Horizontal rule above the assembled command preview. -->
|
|
22
|
+
<box height="1" out:border="headRule" out:borderColor="ruleFg"></box>
|
|
23
|
+
<box flexDirection="column" paddingLeft="1" paddingRight="1" flexShrink="0" overflow="hidden">
|
|
24
|
+
<text out="previewLabel" out:fg="previewLabelFg" wrapMode="none"></text>
|
|
25
|
+
<text out="preview" out:fg="previewFg" wrapMode="word"></text>
|
|
26
|
+
</box>
|
|
27
|
+
<box when="fieldVisible" flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
|
|
28
|
+
<text out="fieldPrompt" out:fg="labelFg" wrapMode="none"></text>
|
|
29
|
+
<input inout="value" flexGrow="1" height="1" out:focused="fieldFocused" on:enter="confirm"></input>
|
|
30
|
+
</box>
|
|
31
|
+
<box flexDirection="row" height="1" paddingLeft="1">
|
|
32
|
+
<text out="footer" out:fg="footerFg"></text>
|
|
33
|
+
</box>
|
|
34
|
+
</box>
|
|
35
|
+
</box>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-27
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/Confirm
|
|
7
|
+
// Confirm: a small y/n overlay for destructive operations (erase, overwrite).
|
|
8
|
+
// Reads the session's `confirm` cell (`{ text } | null`) and shows the
|
|
9
|
+
// pending question; App routes y/enter to `confirm.yes` and n/esc to
|
|
10
|
+
// `confirm.no`.
|
|
11
|
+
//
|
|
12
|
+
// Environment: `overlayBg` (surface), `errorFg` (border), `textHeadingFg`
|
|
13
|
+
// (text), `textSecondaryFg` (key hints).
|
|
14
|
+
|
|
15
|
+
import { unwrap } from "select/state/cells"
|
|
16
|
+
|
|
17
|
+
// Function: createBehaviors
|
|
18
|
+
// Builds the Confirm behavior map for the given renderer environment.
|
|
19
|
+
function createBehaviors(env) {
|
|
20
|
+
return {
|
|
21
|
+
bg: () => env.overlayBg,
|
|
22
|
+
borderFg: () => env.errorFg,
|
|
23
|
+
keysFg: () => env.textSecondaryFg,
|
|
24
|
+
text: (_self, data) => {
|
|
25
|
+
const pending = unwrap(data.confirm)
|
|
26
|
+
if (!pending) return ""
|
|
27
|
+
return `${pending.text ?? ""} [y]es [n]o`
|
|
28
|
+
},
|
|
29
|
+
textFg: () => env.textHeadingFg,
|
|
30
|
+
visible: (_self, data) => Boolean(unwrap(data.confirm)),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { createBehaviors }
|
|
35
|
+
|
|
36
|
+
// EOF
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<box when="visible" width="100%" height="100%" alignItems="center" justifyContent="center">
|
|
2
|
+
<box width="60%" height="3" border borderStyle="rounded" title="Confirm"
|
|
3
|
+
out:titleColor="keysFg" out:borderColor="borderFg" out:backgroundColor="bg"
|
|
4
|
+
flexDirection="column" justifyContent="center" paddingLeft="1" paddingRight="1">
|
|
5
|
+
<text out="text" out:fg="textFg"></text>
|
|
6
|
+
</box>
|
|
7
|
+
</box>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/Crumb
|
|
7
|
+
// A single breadcrumb segment: its name, then a "/" separator (dimmed, and
|
|
8
|
+
// omitted after the basename, which closes the path). Behaviors read
|
|
9
|
+
// `{ item: segment, last, paneId }`, the same bag shape in both engines
|
|
10
|
+
// (built by `Component.map`). The root segment renders as "/" alone.
|
|
11
|
+
//
|
|
12
|
+
// Environment: `textSecondaryFg`, `textHeadingFg`, `rootClass`, `childClass`.
|
|
13
|
+
|
|
14
|
+
// Function: isRoot
|
|
15
|
+
function isRoot(segment) {
|
|
16
|
+
return segment?.path === "/"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Function: crumbLabel
|
|
20
|
+
// The segment's display name: "/" for the root, the (possibly elided) name
|
|
21
|
+
// otherwise, and "…" for the truncation marker (no path).
|
|
22
|
+
function crumbLabel(segment) {
|
|
23
|
+
if (!segment) return ""
|
|
24
|
+
return isRoot(segment) ? "/" : `${segment.name}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Function: crumbSep
|
|
28
|
+
// The "/" drawn after a segment — every one but the last (the basename).
|
|
29
|
+
function crumbSep(segment, last) {
|
|
30
|
+
return last || isRoot(segment) ? "" : "/"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Function: createBehaviors
|
|
34
|
+
// Builds the Crumb behavior map for the given renderer environment.
|
|
35
|
+
function createBehaviors(env) {
|
|
36
|
+
const dim = (_self, data) => isRoot(data.item) || !data.item?.path
|
|
37
|
+
return {
|
|
38
|
+
crumbClass: (_self, data) => (isRoot(data.item) ? env.rootClass : env.childClass),
|
|
39
|
+
crumbLabel: (_self, data) => crumbLabel(data.item),
|
|
40
|
+
crumbSep: (_self, data) => crumbSep(data.item, data.last),
|
|
41
|
+
crumbSepFg: () => env.textSecondaryFg,
|
|
42
|
+
crumbFg: (_self, data) => (dim(_self, data) ? env.textSecondaryFg : env.textHeadingFg),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { crumbLabel, crumbSep, createBehaviors, isRoot }
|
|
47
|
+
|
|
48
|
+
// EOF
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div class="entry-detail">
|
|
2
|
+
<div class="entry-detail-line">
|
|
3
|
+
<span class="entry-detail-indent" out="detailIndent"></span>
|
|
4
|
+
<span class="entry-detail-marked" out="detailMarked"></span>
|
|
5
|
+
<span class="entry-detail-sep" when="detailSepTime" out="detailSep"></span>
|
|
6
|
+
<span class="entry-detail-time" out="detailTime"></span>
|
|
7
|
+
<span class="entry-detail-sep" when="detailSepType" out="detailSep"></span>
|
|
8
|
+
<span class="entry-detail-type" out:class="detailTypeClass" out="detailType"></span>
|
|
9
|
+
<span class="entry-detail-sep" when="detailSepSize" out="detailSep"></span>
|
|
10
|
+
<span class="entry-detail-size" out="detailSize"></span>
|
|
11
|
+
<span class="entry-detail-spacer" when="detailHasMode"></span>
|
|
12
|
+
<span class="entry-detail-sep" when="detailSepMode" out="detailSep"></span>
|
|
13
|
+
<span class="entry-detail-mode" out="detailMode"></span>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-06
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/EntryDetail
|
|
7
|
+
// EntryDetail: the detail line for the entry under a pane's cursor, shown just
|
|
8
|
+
// above that pane's summary (directory) row. A single line,
|
|
9
|
+
// `selected · date · type · size · rights`:
|
|
10
|
+
//
|
|
11
|
+
// - `selected` (the pane's marked count, above one) leads in the mark colour;
|
|
12
|
+
// - `date` (mtime) and `size` are the numeric fields, drawn bright;
|
|
13
|
+
// - `type` (file/dir/link/dev) is coloured like the listing;
|
|
14
|
+
// - `rights` (permission mode) is accented.
|
|
15
|
+
//
|
|
16
|
+
// When the pane is too narrow the trailing blocks give way first (`rights`,
|
|
17
|
+
// then `size`), keeping the date and type; the last block is elided if it
|
|
18
|
+
// still overflows. Directories drop the size (they have none). Each block and
|
|
19
|
+
// its separating rule is a separate slot so the pieces can carry their own
|
|
20
|
+
// colour. Renderer-agnostic logic; the `.xml` / `.html` presentations lay it
|
|
21
|
+
// out.
|
|
22
|
+
|
|
23
|
+
import { unwrap } from "select/state/cells"
|
|
24
|
+
import { formatMode, formatSize, formatTime } from "./format.js"
|
|
25
|
+
|
|
26
|
+
// Constant: KIND_LABELS
|
|
27
|
+
// Human labels for the entry-kind markers.
|
|
28
|
+
const KIND_LABELS = { file: "file", directory: "dir", symlink: "link", device: "dev" }
|
|
29
|
+
|
|
30
|
+
// Constant: SEP
|
|
31
|
+
// Field delimiter, spaces included.
|
|
32
|
+
const SEP = " · "
|
|
33
|
+
|
|
34
|
+
// Constant: INDENT
|
|
35
|
+
// Leading inset applied to the line.
|
|
36
|
+
const INDENT = " "
|
|
37
|
+
|
|
38
|
+
// Constant: MIN_GAP
|
|
39
|
+
// Least gap kept between the left fields and the right-aligned rights.
|
|
40
|
+
const MIN_GAP = 2
|
|
41
|
+
|
|
42
|
+
// Function: entryKind
|
|
43
|
+
// "file" | "directory" | "symlink" | "device" for an entry.
|
|
44
|
+
function entryKind(entry) {
|
|
45
|
+
if (entry?.isSymlink) return "symlink"
|
|
46
|
+
if (entry?.isDir) return "directory"
|
|
47
|
+
if (entry?.isDevice) return "device"
|
|
48
|
+
return "file"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Function: typeColor
|
|
52
|
+
// The listing colour for an entry's kind, mirroring `FileRow.nameFg`.
|
|
53
|
+
function typeColor(entry, env) {
|
|
54
|
+
const kind = entryKind(entry)
|
|
55
|
+
if (kind === "symlink") return env.linkFg
|
|
56
|
+
if (kind === "directory") return env.dirFg
|
|
57
|
+
if (kind === "device") return env.devFg
|
|
58
|
+
return env.fileFg
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Function: detailBlocks
|
|
62
|
+
// The ordered `{ key, text }` blocks for an entry: the marked count (when more
|
|
63
|
+
// than one item is selected), date, type, size (files only) and rights,
|
|
64
|
+
// skipping any empty one.
|
|
65
|
+
function detailBlocks(entry, markedCount = 0) {
|
|
66
|
+
const blocks = []
|
|
67
|
+
if (markedCount > 1) blocks.push({ key: "marked", text: `${markedCount} selected` })
|
|
68
|
+
if (!entry) return blocks
|
|
69
|
+
const time = formatTime(entry.mtimeMs)
|
|
70
|
+
if (time) blocks.push({ key: "time", text: time })
|
|
71
|
+
blocks.push({ key: "type", text: KIND_LABELS[entryKind(entry)] })
|
|
72
|
+
if (!entry.isDir) blocks.push({ key: "size", text: formatSize(entry.size) })
|
|
73
|
+
const mode = formatMode(entry.mode)
|
|
74
|
+
if (mode) blocks.push({ key: "mode", text: mode })
|
|
75
|
+
return blocks
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Function: joinBlocks
|
|
79
|
+
// Joins blocks with the field separator.
|
|
80
|
+
function joinBlocks(blocks) {
|
|
81
|
+
return blocks.map((block) => block.text).join(SEP)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Function: fitBlocks
|
|
85
|
+
// Reserves the rights on the right and fits the left fields (date, type, size)
|
|
86
|
+
// into the remaining room. When the pane is too narrow the size drops first,
|
|
87
|
+
// then the rights, and the last left block is elided if it still overflows.
|
|
88
|
+
// Returns the kept blocks in display order; `width <= 0` disables fitting.
|
|
89
|
+
function fitBlocks(blocks, width, measure, elide) {
|
|
90
|
+
if (width <= 0) return blocks
|
|
91
|
+
const mode = blocks.find((block) => block.key === "mode") ?? null
|
|
92
|
+
let left = blocks.filter((block) => block.key !== "mode")
|
|
93
|
+
const fits = (list, avail) => measure(`${INDENT}${joinBlocks(list)}`) <= avail
|
|
94
|
+
const dropSize = () => {
|
|
95
|
+
left = left.filter((block) => block.key !== "size")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Keep the rights pinned right if the left fields still fit beside them.
|
|
99
|
+
let keptMode = mode
|
|
100
|
+
if (keptMode) {
|
|
101
|
+
const reserve = measure(`${SEP}${keptMode.text}`) + MIN_GAP
|
|
102
|
+
if (!fits(left, width - reserve)) dropSize()
|
|
103
|
+
if (!fits(left, width - reserve)) keptMode = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const avail = keptMode ? width - measure(`${SEP}${keptMode.text}`) - MIN_GAP : width
|
|
107
|
+
if (!fits(left, avail)) dropSize()
|
|
108
|
+
if (!fits(left, avail) && elide && left.length) {
|
|
109
|
+
const last = left[left.length - 1]
|
|
110
|
+
const head = left.slice(0, -1)
|
|
111
|
+
const headText = head.length ? `${INDENT}${joinBlocks(head)}${SEP}` : INDENT
|
|
112
|
+
left = [...head, { ...last, text: elide(last.text, Math.max(1, avail - measure(headText))) }]
|
|
113
|
+
}
|
|
114
|
+
return keptMode ? [...left, keptMode] : left
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Function: detailLine
|
|
118
|
+
// Joined `selected · date · type · size · rights` line (the reference the
|
|
119
|
+
// tests assert against; the templates render the fitted slots instead).
|
|
120
|
+
function detailLine(entry, markedCount = 0) {
|
|
121
|
+
const blocks = detailBlocks(entry, markedCount)
|
|
122
|
+
return blocks.length ? `${INDENT}${joinBlocks(blocks)}` : ""
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Function: createBehaviors
|
|
126
|
+
// Builds the EntryDetail behavior map for the given renderer environment.
|
|
127
|
+
// `width(self)` / `measure` / `elide` are the optional viewport hooks; the
|
|
128
|
+
// numeric fields resolve through `env.textFg` (bright), the type through
|
|
129
|
+
// `typeColor`, the rights through `env.accentFg`, and the selected count
|
|
130
|
+
// through `env.accentSecondaryFg` (the mark colour).
|
|
131
|
+
function createBehaviors(env = {}) {
|
|
132
|
+
const measure = env.measure ?? ((text) => `${text}`.length)
|
|
133
|
+
// Fitted slot state for the current entry; recomputed per behavior call.
|
|
134
|
+
const layout = (self, data) => {
|
|
135
|
+
const entry = unwrap(data.entry)
|
|
136
|
+
const marked = unwrap(data.marked)
|
|
137
|
+
const markedCount = marked?.size ?? 0
|
|
138
|
+
const width = env.width ? env.width(self) : 0
|
|
139
|
+
const kept = fitBlocks(detailBlocks(entry, markedCount), width, measure, env.elide)
|
|
140
|
+
const keys = new Set(kept.map((block) => block.key))
|
|
141
|
+
const texts = {}
|
|
142
|
+
for (const block of kept) texts[block.key] = block.text
|
|
143
|
+
return { entry, keys, texts }
|
|
144
|
+
}
|
|
145
|
+
const has = (self, data, key) => layout(self, data).keys.has(key)
|
|
146
|
+
const text = (self, data, key) => layout(self, data).texts[key] ?? ""
|
|
147
|
+
const before = (self, data, key) => {
|
|
148
|
+
const keys = layout(self, data).keys
|
|
149
|
+
const order = ["marked", "time", "type", "size", "mode"]
|
|
150
|
+
const index = order.indexOf(key)
|
|
151
|
+
return order.slice(0, index).some((earlier) => keys.has(earlier))
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
// Separator rule between the listing and the detail line.
|
|
155
|
+
detailBorder: () => ["top"],
|
|
156
|
+
detailBorderFg: () => env.textSecondaryFg,
|
|
157
|
+
dimFg: () => env.textSecondaryFg,
|
|
158
|
+
brightFg: () => env.textFg,
|
|
159
|
+
|
|
160
|
+
// The line: indent + selected count + date + type + size + rights,
|
|
161
|
+
// each gated so a dropped block takes no width.
|
|
162
|
+
detailHasIndent: (self, data) => layout(self, data).keys.size > 0,
|
|
163
|
+
detailIndent: () => INDENT,
|
|
164
|
+
detailHasMarked: (self, data) => has(self, data, "marked"),
|
|
165
|
+
detailMarked: (self, data) => text(self, data, "marked"),
|
|
166
|
+
detailMarkedFg: () => env.accentSecondaryFg,
|
|
167
|
+
detailHasTime: (self, data) => has(self, data, "time"),
|
|
168
|
+
detailTime: (self, data) => text(self, data, "time"),
|
|
169
|
+
detailHasType: (self, data) => has(self, data, "type"),
|
|
170
|
+
detailType: (self, data) => text(self, data, "type"),
|
|
171
|
+
detailTypeClass: (_self, data) => `is-${entryKind(unwrap(data.entry))}`,
|
|
172
|
+
detailTypeFg: (_self, data) => typeColor(unwrap(data.entry), env),
|
|
173
|
+
detailHasSize: (self, data) => has(self, data, "size"),
|
|
174
|
+
detailSize: (self, data) => text(self, data, "size"),
|
|
175
|
+
detailHasMode: (self, data) => has(self, data, "mode"),
|
|
176
|
+
detailMode: (self, data) => text(self, data, "mode"),
|
|
177
|
+
detailModeFg: () => env.accentFg,
|
|
178
|
+
detailSepTime: (self, data) => has(self, data, "time") && before(self, data, "time"),
|
|
179
|
+
detailSepType: (self, data) => has(self, data, "type") && before(self, data, "type"),
|
|
180
|
+
detailSepSize: (self, data) => has(self, data, "size") && before(self, data, "size"),
|
|
181
|
+
detailSepMode: (self, data) => has(self, data, "mode") && before(self, data, "mode"),
|
|
182
|
+
detailSep: () => SEP,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export {
|
|
187
|
+
KIND_LABELS,
|
|
188
|
+
SEP,
|
|
189
|
+
createBehaviors,
|
|
190
|
+
detailBlocks,
|
|
191
|
+
detailLine,
|
|
192
|
+
entryKind,
|
|
193
|
+
typeColor,
|
|
194
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<box ref="box" flexDirection="column" height="2" out:border="detailBorder" out:borderColor="detailBorderFg"
|
|
2
|
+
borderStyle="single" overflow="hidden">
|
|
3
|
+
<box flexDirection="row" height="1" overflow="hidden">
|
|
4
|
+
<text when="detailHasIndent" out="detailIndent" out:fg="dimFg" wrapMode="none"></text>
|
|
5
|
+
<text when="detailHasMarked" out="detailMarked" out:fg="detailMarkedFg" wrapMode="none"></text>
|
|
6
|
+
<text when="detailSepTime" out="detailSep" out:fg="dimFg" wrapMode="none"></text>
|
|
7
|
+
<text when="detailHasTime" out="detailTime" out:fg="brightFg" wrapMode="none"></text>
|
|
8
|
+
<text when="detailSepType" out="detailSep" out:fg="dimFg" wrapMode="none"></text>
|
|
9
|
+
<text when="detailHasType" out="detailType" out:fg="detailTypeFg" wrapMode="none"></text>
|
|
10
|
+
<text when="detailSepSize" out="detailSep" out:fg="dimFg" wrapMode="none"></text>
|
|
11
|
+
<text when="detailHasSize" out="detailSize" out:fg="brightFg" wrapMode="none"></text>
|
|
12
|
+
<box when="detailHasMode" flexGrow="1" minWidth="0" flexShrink="1"></box>
|
|
13
|
+
<text when="detailSepMode" out="detailSep" out:fg="dimFg" wrapMode="none" flexShrink="0"></text>
|
|
14
|
+
<text when="detailHasMode" out="detailMode" out:fg="detailModeFg" wrapMode="none" flexShrink="0"></text>
|
|
15
|
+
</box>
|
|
16
|
+
</box>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<ul class="file-list" ref="list" out="rows"></ul>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/FileList
|
|
7
|
+
// FileList: the scrollable, cursorable list of entries for one pane. The row
|
|
8
|
+
// logic lives in `FileRow.js`; this parent maps its `entries` to rows and
|
|
9
|
+
// provides the empty/loading states.
|
|
10
|
+
//
|
|
11
|
+
// Environment:
|
|
12
|
+
// - `rows(data)` returning the keyed row list.
|
|
13
|
+
// - `scrollTop(self, data)` (TUI viewport tracking).
|
|
14
|
+
// - `textSecondaryFg` for the state rows.
|
|
15
|
+
|
|
16
|
+
import { unwrap } from "select/state/cells"
|
|
17
|
+
|
|
18
|
+
// Function: createBehaviors
|
|
19
|
+
// Builds the FileList behavior map for the given renderer environment.
|
|
20
|
+
function createBehaviors(env) {
|
|
21
|
+
const behaviors = {
|
|
22
|
+
emptyText: () => " (empty)",
|
|
23
|
+
emptyFg: () => env?.textSecondaryFg,
|
|
24
|
+
loadingText: () => " loading…",
|
|
25
|
+
loadingFg: () => env?.textSecondaryFg,
|
|
26
|
+
showEmpty: (_self, data) =>
|
|
27
|
+
!unwrap(data.loading) && (unwrap(data.entries)?.length ?? 0) === 0,
|
|
28
|
+
showLoading: (_self, data) => Boolean(unwrap(data.loading)),
|
|
29
|
+
}
|
|
30
|
+
if (env?.rows) {
|
|
31
|
+
behaviors.rows = (self, data) => env.rows(self, data)
|
|
32
|
+
}
|
|
33
|
+
if (env?.scrollTop) {
|
|
34
|
+
behaviors.scrollTop = (self, data) => env.scrollTop(self, data)
|
|
35
|
+
}
|
|
36
|
+
return behaviors
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { createBehaviors }
|
|
40
|
+
|
|
41
|
+
// EOF
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<box flexDirection="column" flexGrow="1" overflow="hidden">
|
|
2
|
+
<scrollbox ref="list" flexGrow="1" out:scrollTop="scrollTop">
|
|
3
|
+
<box out="rows" flexDirection="column"></box>
|
|
4
|
+
<text when="showLoading" out="loadingText" out:fg="loadingFg"></text>
|
|
5
|
+
<text when="showEmpty" out="emptyText" out:fg="emptyFg"></text>
|
|
6
|
+
</scrollbox>
|
|
7
|
+
</box>
|