@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.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +7 -0
  3. package/package.json +37 -5
  4. package/src/js/tcmd/components/App.html +28 -0
  5. package/src/js/tcmd/components/App.js +355 -0
  6. package/src/js/tcmd/components/App.xml +98 -0
  7. package/src/js/tcmd/components/BookmarkList.html +12 -0
  8. package/src/js/tcmd/components/BookmarkList.js +73 -0
  9. package/src/js/tcmd/components/BookmarkList.xml +18 -0
  10. package/src/js/tcmd/components/BookmarkRow.html +5 -0
  11. package/src/js/tcmd/components/BookmarkRow.js +40 -0
  12. package/src/js/tcmd/components/BookmarkRow.xml +10 -0
  13. package/src/js/tcmd/components/Breadcrumbs.js +27 -0
  14. package/src/js/tcmd/components/CommandArgRow.js +90 -0
  15. package/src/js/tcmd/components/CommandArgRow.xml +5 -0
  16. package/src/js/tcmd/components/CommandBar.html +4 -0
  17. package/src/js/tcmd/components/CommandBar.js +126 -0
  18. package/src/js/tcmd/components/CommandBar.xml +4 -0
  19. package/src/js/tcmd/components/CommandItem.html +3 -0
  20. package/src/js/tcmd/components/CommandItem.js +78 -0
  21. package/src/js/tcmd/components/CommandItem.xml +6 -0
  22. package/src/js/tcmd/components/CommandPalette.html +7 -0
  23. package/src/js/tcmd/components/CommandPalette.js +58 -0
  24. package/src/js/tcmd/components/CommandPalette.xml +16 -0
  25. package/src/js/tcmd/components/CommandPanel.js +98 -0
  26. package/src/js/tcmd/components/CommandPanel.xml +35 -0
  27. package/src/js/tcmd/components/Confirm.html +3 -0
  28. package/src/js/tcmd/components/Confirm.js +36 -0
  29. package/src/js/tcmd/components/Confirm.xml +7 -0
  30. package/src/js/tcmd/components/Crumb.html +4 -0
  31. package/src/js/tcmd/components/Crumb.js +48 -0
  32. package/src/js/tcmd/components/Crumb.xml +4 -0
  33. package/src/js/tcmd/components/EntryDetail.html +15 -0
  34. package/src/js/tcmd/components/EntryDetail.js +194 -0
  35. package/src/js/tcmd/components/EntryDetail.xml +16 -0
  36. package/src/js/tcmd/components/FileList.html +1 -0
  37. package/src/js/tcmd/components/FileList.js +41 -0
  38. package/src/js/tcmd/components/FileList.xml +7 -0
  39. package/src/js/tcmd/components/FilePreview.html +5 -0
  40. package/src/js/tcmd/components/FilePreview.js +110 -0
  41. package/src/js/tcmd/components/FilePreview.xml +10 -0
  42. package/src/js/tcmd/components/FileRow.html +5 -0
  43. package/src/js/tcmd/components/FileRow.js +135 -0
  44. package/src/js/tcmd/components/FileRow.xml +10 -0
  45. package/src/js/tcmd/components/FilterBar.html +6 -0
  46. package/src/js/tcmd/components/FilterBar.js +50 -0
  47. package/src/js/tcmd/components/FilterBar.xml +6 -0
  48. package/src/js/tcmd/components/Help.xml +11 -0
  49. package/src/js/tcmd/components/PaletteRow.html +3 -0
  50. package/src/js/tcmd/components/PaletteRow.js +43 -0
  51. package/src/js/tcmd/components/PaletteRow.xml +4 -0
  52. package/src/js/tcmd/components/RowList.html +1 -0
  53. package/src/js/tcmd/components/RowList.xml +1 -0
  54. package/src/js/tcmd/components/Summary.html +3 -0
  55. package/src/js/tcmd/components/Summary.js +77 -0
  56. package/src/js/tcmd/components/Summary.xml +3 -0
  57. package/src/js/tcmd/components/bind.js +20 -0
  58. package/src/js/tcmd/components/format.js +155 -0
  59. package/src/js/tcmd/components/icons.js +185 -0
  60. package/src/js/tcmd/components/index.js +41 -0
  61. package/src/js/tcmd/components/rows.js +212 -0
  62. package/src/js/tcmd/core/bookmarks.js +175 -0
  63. package/src/js/tcmd/core/commands.js +930 -0
  64. package/src/js/tcmd/core/directory.js +194 -0
  65. package/src/js/tcmd/core/entries.js +88 -0
  66. package/src/js/tcmd/core/fs.js +266 -0
  67. package/src/js/tcmd/core/index.js +39 -0
  68. package/src/js/tcmd/core/keymap.js +60 -0
  69. package/src/js/tcmd/core/operations.js +453 -0
  70. package/src/js/tcmd/core/os.js +128 -0
  71. package/src/js/tcmd/core/pane.js +74 -0
  72. package/src/js/tcmd/core/panel.js +383 -0
  73. package/src/js/tcmd/core/path.js +57 -0
  74. package/src/js/tcmd/core/script.js +186 -0
  75. package/src/js/tcmd/core/selection.js +43 -0
  76. package/src/js/tcmd/core/session.js +405 -0
  77. package/src/js/tcmd/index.js +53 -0
  78. package/src/js/tcmd/tui/app.js +140 -0
  79. package/src/js/tcmd/tui/catalogue.js +300 -0
  80. package/src/js/tcmd/tui/frame.js +269 -0
  81. package/src/js/tcmd/tui/help.js +32 -0
  82. package/src/js/tcmd/tui/index.js +104 -0
  83. package/src/js/tcmd/tui/theme.js +177 -0
  84. package/src/js/tcmd/vendor/fzy.js +243 -0
  85. package/src/js/tcmd/web/app.js +154 -0
  86. package/src/js/tcmd/web/catalogue.js +159 -0
  87. package/src/js/tcmd/web/header.html +1 -0
  88. package/src/js/tcmd/web/header.js +38 -0
  89. package/src/js/tcmd/web/http.js +79 -0
  90. package/src/js/tcmd/web/index.js +22 -0
  91. package/src/js/tcmd/web/main.js +151 -0
  92. package/src/js/tcmd/web/opfs.js +282 -0
  93. package/src/js/tcmd/web/seed.js +90 -0
  94. package/src/js/tcmd/web/server.js +191 -0
  95. 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,3 @@
1
+ <div class="confirm" when="visible">
2
+ <p out="text"></p>
3
+ </div>
@@ -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,4 @@
1
+ <a class="crumb" href="#" out:class="crumbClass" on:click="path:item.path,paneId:paneId!crumbs:go">
2
+ <span out="crumbLabel"></span>
3
+ <span class="crumb-sep" out="crumbSep"></span>
4
+ </a>
@@ -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,4 @@
1
+ <box flexDirection="row" height="1" flexShrink="0" on:click="path:item.path,paneId:paneId!crumbs:go">
2
+ <text out="crumbLabel" out:fg="crumbFg" wrapMode="none"></text>
3
+ <text when="crumbSep" out="crumbSep" out:fg="crumbSepFg" wrapMode="none"></text>
4
+ </box>
@@ -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>
@@ -0,0 +1,5 @@
1
+ <aside class="preview" when="show" out:title="previewTitle">
2
+ <pre when="hasText" out="previewText" class="preview-text"></pre>
3
+ <div when="hasCard" out="cardText" class="preview-card"></div>
4
+ <footer class="preview-actions" out="actions"></footer>
5
+ </aside>