@ssebastien/tcmd 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. package/tinyjs.json +16 -0
@@ -4,90 +4,185 @@
4
4
  // Created: 2026-10-02
5
5
 
6
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.
7
+ // Shared behavior for the `?` key reference overlay, plus the reference data
8
+ // itself. The categories are built from the live keymap and command registry,
9
+ // so they cannot drift from the actual bindings. The TUI keeps the bracket
10
+ // shorthand (`[Open ⏎]`); the web renders toolbar-like action chips with Lucide
11
+ // icons and key badges. Rows are balanced across two columns with bright
12
+ // uppercase section headings. Both presentations (`Help.xml` / `Help.html`)
13
+ // render the same row data through `HelpRow`.
11
14
 
12
15
  import { unwrap } from "select/state/cells"
13
- import { DEFAULT_KEYMAP } from "../core/keymap.js"
16
+ import { DEFAULT_KEYMAP, keyGlyph } from "../core/keymap.js"
14
17
  import { getCommands } from "../core/commands.js"
18
+ import { commandLabel, keyGlyph as keyLabelGlyph } from "./CommandItem.js"
15
19
 
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
- }
20
+ // Constant: BRACKET
21
+ // The shorthand delimiters, shared with the command bar.
22
+ const OPEN = "["
23
+ const CLOSE = "]"
24
+
25
+ // Constant: CLOSE_HINT
26
+ // The footer shorthand: Esc dismisses the overlay.
27
+ const CLOSE_HINT = { title: "Close", keys: "Esc" }
28
+
29
+ // Constant: BOLD
30
+ // OpenTUI's `TextAttributes.BOLD` bit, set on section headings through the
31
+ // TUI's `out:attributes` slot. The web bolds through CSS instead.
32
+ const BOLD = 1
42
33
 
43
34
  // Constant: GROUP_ORDER / GROUP_TITLES
44
35
  // Help sections, in display order.
45
- const GROUP_ORDER = ["nav", "pane", "list", "bookmark", "view", "file", "panel", "filter", "app"]
36
+ const GROUP_ORDER = ["nav", "pane", "list", "bookmark", "view", "file", "history", "trash", "panel", "filter", "app"]
46
37
  const GROUP_TITLES = {
47
38
  app: "Session",
48
39
  bookmark: "Bookmarks",
49
40
  file: "Files",
50
41
  filter: "Filter",
42
+ history: "History",
51
43
  list: "Selection",
52
44
  nav: "Navigation",
53
45
  panel: "Commands",
54
46
  pane: "Panes",
47
+ trash: "Trash",
55
48
  view: "View",
56
49
  }
57
50
 
58
- // Function: helpLines
59
- // The categorized key reference, one line per heading or binding.
60
- function helpLines() {
51
+ // Toolbar actions and key-reference entries share Lucide icons in the web UI.
52
+ // The TUI keeps its compact textual row layout.
53
+ const HELP_ICONS = {
54
+ "nav.up": "arrow-up",
55
+ "nav.down": "arrow-down",
56
+ "nav.jumpUp": "arrow-up",
57
+ "nav.jumpDown": "arrow-down",
58
+ "nav.pageUp": "arrow-up",
59
+ "nav.pageDown": "arrow-down",
60
+ "nav.home": "arrow-up",
61
+ "nav.end": "arrow-down",
62
+ "pane.enter": "arrow-right",
63
+ "pane.up": "arrow-up",
64
+ "pane.back": "arrow-left",
65
+ "pane.forward": "arrow-right",
66
+ "pane.toggle": "columns-2",
67
+ "pane.reload": "refresh-cw",
68
+ "list.mark": "check",
69
+ "bookmark.open": "bookmark",
70
+ "view.togglePreview": "panel-right",
71
+ "view.maximize": "maximize",
72
+ "view.toggleMode": "columns-2",
73
+ "file.mkdir": "folder-plus",
74
+ "file.open": "file",
75
+ "file.edit": "file-text",
76
+ "history.open": "refresh-cw",
77
+ "history.undo": "arrow-left",
78
+ "history.redo": "arrow-right",
79
+ "trash.open": "trash",
80
+ "panel.open": "terminal",
81
+ "panel.shell": "terminal",
82
+ "filter.start": "search",
83
+ "app.quit": "power",
84
+ "app.help": "circle-question-mark",
85
+ }
86
+
87
+ // Function: helpGroups
88
+ // The categorized key reference as `{ title, rows }` groups in display order.
89
+ // One row per command, with every distinct chord from the live keymap. The
90
+ // registry provides the action title.
91
+ function helpGroups() {
61
92
  const byId = new Map(getCommands().map((command) => [command.id, command]))
62
93
  const groups = new Map()
63
- const seen = new Set()
64
94
  for (const binding of DEFAULT_KEYMAP) {
65
95
  const command = byId.get(binding.command)
66
96
  if (!command) continue
67
97
  const group = binding.command.split(".")[0]
68
98
  if (!groups.has(group)) groups.set(group, new Map())
69
99
  const rows = groups.get(group)
70
- if (!rows.has(binding.command)) rows.set(binding.command, { keys: [], title: command.title })
100
+ if (!rows.has(binding.command)) rows.set(binding.command, { keys: [], id: binding.command, title: command.title })
71
101
  const row = rows.get(binding.command)
72
102
  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
- }
103
+ if (!row.keys.includes(glyph)) row.keys.push(glyph)
77
104
  }
78
- const lines = []
105
+ const out = []
79
106
  for (const group of GROUP_ORDER) {
80
107
  const rows = groups.get(group)
81
108
  if (!rows) continue
82
- lines.push(GROUP_TITLES[group] ?? group)
109
+ const list = []
83
110
  for (const row of rows.values()) {
84
- lines.push(` ${row.keys.join("/").padEnd(9)} ${row.title}`)
111
+ list.push({ id: row.id, title: row.title, keys: row.keys.map(keyLabelGlyph).join("/") })
85
112
  }
113
+ out.push({ title: GROUP_TITLES[group] ?? group, rows: list })
114
+ }
115
+ return out
116
+ }
117
+
118
+ // Function: helpLines
119
+ // The flat reference: group headings and rows, one per line, blank line
120
+ // between groups (the single-column fallback).
121
+ function helpLines() {
122
+ const lines = []
123
+ for (const group of helpGroups()) {
124
+ lines.push(group.title)
125
+ for (const row of group.rows) lines.push(commandLabel(row).trim())
86
126
  lines.push("")
87
127
  }
88
128
  return lines
89
129
  }
90
130
 
131
+ // Function: helpBlocks
132
+ // The reference split into two columns balanced by line count, as flat lists of
133
+ // renderable blocks: `{ type: "section", label }` headings,
134
+ // `{ type: "entry", open, head, keys, close }` rows and `{ type: "spacer" }`
135
+ // blank lines between groups. Every entry title and key is padded to the widest
136
+ // one across both columns, so all brackets share one width and the keys align
137
+ // down each column (keys right-aligned against the closing bracket).
138
+ function helpBlocks() {
139
+ const groups = helpGroups()
140
+ const commands = new Map(getCommands().map((command) => [command.id, command]))
141
+ let titleWidth = 0
142
+ let keyWidth = 0
143
+ for (const group of groups) {
144
+ for (const row of group.rows) {
145
+ titleWidth = Math.max(titleWidth, row.title.length)
146
+ keyWidth = Math.max(keyWidth, keyLabelGlyph(row.keys).length)
147
+ }
148
+ }
149
+ const columns = [[], []]
150
+ const loads = [0, 0]
151
+ for (const group of groups) {
152
+ const at = loads[0] <= loads[1] ? 0 : 1
153
+ columns[at].push({ type: "section", key: `section:${group.title}`, label: group.title.toUpperCase() })
154
+ for (const row of group.rows) {
155
+ const command = commands.get(row.id)
156
+ // The compact web badge shows the command's canonical key when the
157
+ // registry has one (lowercase `b`, `Spc`, `⇥`); otherwise fall back
158
+ // to the keymap chords, which may list several alternatives.
159
+ const shortcut = command?.keys ? keyLabelGlyph(command.keys) : keyLabelGlyph(row.keys)
160
+ columns[at].push({
161
+ type: "entry",
162
+ key: `entry:${row.id}`,
163
+ icon: HELP_ICONS[row.id] ?? "chevron-right",
164
+ title: row.title,
165
+ shortcut,
166
+ open: OPEN,
167
+ head: `${row.title.padEnd(titleWidth)} `,
168
+ keys: keyLabelGlyph(row.keys).padStart(keyWidth),
169
+ close: CLOSE,
170
+ })
171
+ }
172
+ // A blank row separates the groups (and costs one line, like the
173
+ // heading).
174
+ columns[at].push({ type: "spacer", key: `spacer:${group.title}` })
175
+ // A group costs its rows plus its heading and the blank after it.
176
+ loads[at] += group.rows.length + 2
177
+ }
178
+ // The blank line separates groups, so the bottom of each column ends on its
179
+ // last entry instead.
180
+ for (const column of columns) {
181
+ if (column[column.length - 1]?.type === "spacer") column.pop()
182
+ }
183
+ return { left: columns[0], right: columns[1] }
184
+ }
185
+
91
186
  // Function: createBehaviors
92
187
  // Builds the Help behavior map for the given renderer environment.
93
188
  function createBehaviors(env) {
@@ -95,15 +190,71 @@ function createBehaviors(env) {
95
190
  bg: () => env.theme.surfaceBg,
96
191
  borderFg: () => env.theme.borderPrimaryActiveFg,
97
192
  dimFg: () => env.theme.textSecondaryFg,
98
- hint: () => " Esc / ? close ",
193
+ leftRows: (self, data) => env.rows?.(self, data, "left") ?? [],
194
+ rightRows: (self, data) => env.rows?.(self, data, "right") ?? [],
195
+ // The reference has no cursor for the scrollbox to follow, so its
196
+ // vertical bar is driven here: visible only while the two columns
197
+ // actually overflow the viewport. The viewport height is derived from
198
+ // the injected `size` (so the bar tracks a resize) and the dialog's own
199
+ // geometry (`Help.xml`: 82% tall, minus padding, borders, rule and
200
+ // footer).
201
+ scrollbar: (self, data) => {
202
+ const scroll = self?.ref?.scroll
203
+ const bar = scroll?.verticalScrollBar
204
+ if (!bar) return ""
205
+ const height = unwrap(data?.size)?.height ?? self?.ctx?.height ?? 0
206
+ const viewport = height > 0 ? Math.floor(height * 0.82) - 6 : 0
207
+ const columns = helpBlocks()
208
+ const rows = Math.max(columns.left.length, columns.right.length)
209
+ const want = viewport > 0 && rows > viewport
210
+ if (bar.visible !== want) bar.visible = want
211
+ return ""
212
+ },
99
213
  open: (_self, data) => Boolean(unwrap(data.open)),
100
- text: () => helpLines().join("\n"),
214
+ // The footer shorthand reads like the bar: dim brackets, bright title,
215
+ // accent key.
216
+ hintOpen: () => OPEN,
217
+ hintHead: () => `${CLOSE_HINT.title} `,
218
+ hintKeys: () => CLOSE_HINT.keys,
219
+ hintClose: () => CLOSE,
220
+ bracketFg: () => env.theme.textSecondaryFg,
221
+ headFg: () => env.theme.textFg,
222
+ keyFg: () => env.theme.accentFg,
223
+ // Separator rule above the footer (a top border on a one-row box).
224
+ rule: () => ["top"],
225
+ ruleFg: () => env.theme.textSecondaryFg,
101
226
  textFg: () => env.theme.textFg,
102
227
  title: () => " Keys ",
103
228
  titleFg: () => env.theme.textHeadingFg,
104
229
  }
105
230
  }
106
231
 
107
- export { createBehaviors, helpLines, keyGlyph }
232
+ // Function: createRowBehaviors
233
+ // Builds the HelpRow behavior map (one section heading or one key entry).
234
+ function createRowBehaviors(env) {
235
+ return {
236
+ isSection: (_self, data) => data.item?.type === "section",
237
+ isEntry: (_self, data) => data.item?.type === "entry",
238
+ section: (_self, data) => data.item?.label ?? "",
239
+ // Sections are bright; the TUI adds bold via OpenTUI's attribute bit
240
+ // (the web bolds through CSS instead).
241
+ sectionFg: () => env.theme.textHeadingFg,
242
+ sectionAttrs: () => BOLD,
243
+ open: (_self, data) => data.item?.open ?? "",
244
+ head: (_self, data) => data.item?.head ?? "",
245
+ keys: (_self, data) => data.item?.keys ?? "",
246
+ close: (_self, data) => data.item?.close ?? "",
247
+ hasIcon: (_self, data) => Boolean(env.theme.lucideIcons && data.item?.icon),
248
+ iconHref: (_self, data) =>
249
+ env.theme.lucideIcons && data.item?.icon ? `#lucide-${data.item.icon}` : "",
250
+ actionTitle: (_self, data) => data.item?.title ?? `${data.item?.head ?? ""}`.trim(),
251
+ shortcut: (_self, data) => data.item?.shortcut ?? `${data.item?.keys ?? ""}`.trim(),
252
+ bracketFg: () => env.theme.textSecondaryFg,
253
+ headFg: () => env.theme.textFg,
254
+ keyFg: () => env.theme.accentFg,
255
+ }
256
+ }
257
+
258
+ export { createBehaviors, createRowBehaviors, helpBlocks, helpGroups, helpLines, keyGlyph }
108
259
 
109
260
  // EOF
@@ -3,9 +3,18 @@
3
3
  out:title="title" out:titleColor="titleFg" out:borderColor="borderFg"
4
4
  out:backgroundColor="bg" flexDirection="column"
5
5
  paddingLeft="2" paddingRight="2" paddingTop="1" paddingBottom="1">
6
- <scrollbox flexGrow="1">
7
- <text out="text" out:fg="textFg"></text>
8
- </scrollbox>
9
- <text out="hint" out:fg="dimFg"></text>
6
+ <helpscroll out="scrollbar" ref="scroll" flexGrow="1" overflow="hidden">
7
+ <box flexDirection="row" overflow="hidden">
8
+ <box out="leftRows" flexDirection="column" flexGrow="1" flexBasis="0" overflow="hidden"></box>
9
+ <box out="rightRows" flexDirection="column" flexGrow="1" flexBasis="0" overflow="hidden" paddingLeft="2"></box>
10
+ </box>
11
+ </helpscroll>
12
+ <box height="1" flexShrink="0" out:border="rule" out:borderColor="ruleFg"></box>
13
+ <box height="1" flexShrink="0" flexDirection="row" overflow="hidden">
14
+ <text out="hintOpen" out:fg="bracketFg" wrapMode="none"></text>
15
+ <text out="hintHead" out:fg="headFg" wrapMode="none"></text>
16
+ <text out="hintKeys" out:fg="keyFg" wrapMode="none"></text>
17
+ <text out="hintClose" out:fg="bracketFg" wrapMode="none"></text>
18
+ </box>
10
19
  </box>
11
20
  </box>
@@ -0,0 +1,8 @@
1
+ <div class="help-block">
2
+ <span class="help-section" when="isSection" out="section"></span>
3
+ <span class="help-entry" when="isEntry" role="group">
4
+ <svg class="help-icon ui-icon" when="hasIcon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use out:href="iconHref"></use></svg>
5
+ <span class="help-title" out="actionTitle"></span>
6
+ <kbd class="help-key" out="shortcut"></kbd>
7
+ </span>
8
+ </div>
@@ -0,0 +1,7 @@
1
+ <box height="1" flexDirection="row" flexShrink="0" overflow="hidden">
2
+ <text when="isSection" out="section" out:fg="sectionFg" out:attributes="sectionAttrs" wrapMode="none"></text>
3
+ <text when="isEntry" out="open" out:fg="bracketFg" wrapMode="none"></text>
4
+ <text when="isEntry" out="head" out:fg="headFg" wrapMode="none"></text>
5
+ <text when="isEntry" out="keys" out:fg="keyFg" wrapMode="none"></text>
6
+ <text when="isEntry" out="close" out:fg="bracketFg" wrapMode="none"></text>
7
+ </box>
@@ -0,0 +1,8 @@
1
+ <div class="overlay-list" when="open" role="dialog" aria-modal="true" aria-label="History and trash" on:click="!dialog:overlay-cancel">
2
+ <div class="overlay-dialog" on:click="dialogStop">
3
+ <h2 out="title"></h2>
4
+ <p class="overlay-empty" when="emptyVisible" out="empty"></p>
5
+ <ul class="overlay-rows" out="rows"></ul>
6
+ <footer out="footer"></footer>
7
+ </div>
8
+ </div>
@@ -0,0 +1,69 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-17
5
+
6
+ // Module: tcmd/components/OverlayList
7
+ // The shared modal list overlay: the undo history (`u`) and the trash browser
8
+ // (`⇧T`). One component, two modes — `history` lists the journal (a row jumps
9
+ // the file state to that point), `trash` lists trashed entries (a row restores
10
+ // one). The row logic lives in `OverlayRow.js`; this parent maps its `items` to
11
+ // rows and switches title/footer/empty text on the mode.
12
+ //
13
+ // Environment: `overlayBg`, `borderPrimaryActiveFg`, `textHeadingFg` (title),
14
+ // `textSecondaryFg` (footer/empty), `rows(data)` returning the keyed rows and
15
+ // `scrollTop(self, data)` (TUI viewport tracking).
16
+
17
+ import { unwrap } from "select/state/cells"
18
+
19
+ // Function: overlayMode
20
+ // "trash" or "history" for the data's mode (an absent mode reads as history).
21
+ function overlayMode(mode) {
22
+ return unwrap(mode) === "trash" ? "trash" : "history"
23
+ }
24
+
25
+ // Function: overlayTitle
26
+ function overlayTitle(mode) {
27
+ return overlayMode(mode)
28
+ }
29
+
30
+ // Function: overlayFooter
31
+ function overlayFooter(mode) {
32
+ return overlayMode(mode) === "trash"
33
+ ? "↑↓ move ↵ restore Esc close"
34
+ : "↑↓ move ↵ jump ^Z undo ^⇧Z redo Esc close"
35
+ }
36
+
37
+ // Function: overlayEmpty
38
+ // The empty-state line: no history yet, or an empty trash.
39
+ function overlayEmpty(mode) {
40
+ return overlayMode(mode) === "trash" ? " trash is empty" : " no history yet"
41
+ }
42
+
43
+ // Function: createBehaviors
44
+ // Builds the OverlayList behavior map for the given renderer environment.
45
+ function createBehaviors(env) {
46
+ const behaviors = {
47
+ bg: () => env.overlayBg,
48
+ borderFg: () => env.borderPrimaryActiveFg,
49
+ titleColor: () => env.textHeadingFg,
50
+ title: (_self, data) => overlayTitle(data.mode),
51
+ footer: (_self, data) => overlayFooter(data.mode),
52
+ footerFg: () => env.textSecondaryFg,
53
+ empty: (_self, data) => overlayEmpty(data.mode),
54
+ emptyFg: () => env.textSecondaryFg,
55
+ emptyVisible: (_self, data) => (unwrap(data.items)?.length ?? 0) === 0,
56
+ open: (_self, data) => Boolean(unwrap(data.open)),
57
+ }
58
+ if (env?.rows) {
59
+ behaviors.rows = (self, data) => env.rows(self, data)
60
+ }
61
+ if (env?.scrollTop) {
62
+ behaviors.scrollTop = (self, data) => env.scrollTop(self, data)
63
+ }
64
+ return behaviors
65
+ }
66
+
67
+ export { createBehaviors, overlayEmpty, overlayFooter, overlayMode, overlayTitle }
68
+
69
+ // EOF
@@ -0,0 +1,13 @@
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
+ <text when="emptyVisible" out="empty" out:fg="emptyFg" paddingLeft="1" wrapMode="none"></text>
6
+ <scrollbox when="!emptyVisible" ref="list" flexGrow="1">
7
+ <box out="rows" flexDirection="column"></box>
8
+ </scrollbox>
9
+ <box flexDirection="row" height="1" paddingLeft="1">
10
+ <text out="footer" out:fg="footerFg"></text>
11
+ </box>
12
+ </box>
13
+ </box>
@@ -0,0 +1,5 @@
1
+ <li class="overlay-row" out:class="rowClass" out:data-current="rowCurrent" out:data-dim="rowDim" on:click="item.key!overlay:select">
2
+ <span class="overlay-marker" out="rowMarker"></span>
3
+ <span class="overlay-text" out="rowText"></span>
4
+ <span class="overlay-meta" out="rowMeta"></span>
5
+ </li>
@@ -0,0 +1,47 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-17
5
+
6
+ // Module: tcmd/components/OverlayRow
7
+ // One row of the shared list overlay (`u` history / `⇧T` trash). Reads
8
+ // `{ item: { text, meta, dim }, index, currentRow }`; the builder snapshots the
9
+ // overlay cursor into a plain `currentRow` boolean, with the shared `cursor`
10
+ // cell as fallback for hand-built bags.
11
+ //
12
+ // Environment: `accentSecondaryFg`, `textFg`, `textSecondaryFg`,
13
+ // `textHeadingFg`, `cursorBg` (current row background), `currentClass` (web).
14
+
15
+ import { unwrap } from "select/state/cells"
16
+
17
+ // Function: rowCurrent
18
+ // Whether the row is the overlay cursor: the plain `currentRow` flag wins over
19
+ // the shared `cursor` cell.
20
+ function rowCurrent(data) {
21
+ if (data.currentRow !== undefined) return data.currentRow === true
22
+ return data.index === unwrap(data.cursor)
23
+ }
24
+
25
+ // Function: createBehaviors
26
+ // Builds the OverlayRow behavior map for the given renderer environment.
27
+ function createBehaviors(env) {
28
+ return {
29
+ rowBg: (_self, data) => (rowCurrent(data) ? env.cursorBg : undefined),
30
+ rowClass: (_self, data) => (rowCurrent(data) ? env.currentClass : null),
31
+ rowCurrent: (_self, data) => `${rowCurrent(data)}`,
32
+ rowDim: (_self, data) => `${Boolean(data.item?.dim)}`,
33
+ rowMarker: (_self, data) => (rowCurrent(data) ? "> " : " "),
34
+ rowMarkerFg: () => env.accentSecondaryFg,
35
+ rowText: (_self, data) => data.item?.text ?? "",
36
+ // The cursor row reads bright; an undone/dim row (a superseded history
37
+ // entry) takes the secondary colour, like a hint.
38
+ rowTextFg: (_self, data) =>
39
+ rowCurrent(data) ? env.textHeadingFg : data.item?.dim ? env.textSecondaryFg : env.textFg,
40
+ rowMeta: (_self, data) => data.item?.meta ?? "",
41
+ rowMetaFg: () => env.textSecondaryFg,
42
+ }
43
+ }
44
+
45
+ export { createBehaviors, rowCurrent }
46
+
47
+ // EOF
@@ -0,0 +1,9 @@
1
+ <box flexDirection="row" height="1" columnGap="1" out:backgroundColor="rowBg" on:click="item.key!overlay:select">
2
+ <text out="rowMarker" width="2" out:fg="rowMarkerFg"></text>
3
+ <box flexGrow="1" minWidth="0" overflow="hidden">
4
+ <text out="rowText" out:fg="rowTextFg" wrapMode="none" truncate="true"></text>
5
+ </box>
6
+ <box flexShrink="0" overflow="hidden">
7
+ <text out="rowMeta" out:fg="rowMetaFg" wrapMode="none" truncate="true"></text>
8
+ </box>
9
+ </box>
@@ -5,7 +5,10 @@
5
5
 
6
6
  // Module: tcmd/components/format
7
7
  // Presentation-only formatting helpers (sizes, counts, header title) shared by
8
- // every component for every renderer.
8
+ // every component for every renderer. Size and disk formatting come from
9
+ // `core/format` because operations report the same values.
10
+
11
+ import { formatDisk, formatSize } from "../core/format.js"
9
12
 
10
13
  // Function: formatTitle
11
14
  // Header line `TinyCmd - HOST DIR/BASE` for the given directory. When it
@@ -78,28 +81,6 @@ function elideCrumbs(segments, width) {
78
81
  return [marker, ...rest]
79
82
  }
80
83
 
81
- // Function: formatSize
82
- // Human-readable byte size; directories pass through their own label.
83
- function formatSize(bytes) {
84
- const value = Number(bytes ?? 0)
85
- if (value < 1024) return `${value}B`
86
- const units = ["K", "M", "G", "T"]
87
- let size = value / 1024
88
- let unit = 0
89
- while (size >= 1024 && unit < units.length - 1) {
90
- size /= 1024
91
- unit++
92
- }
93
- return `${size >= 100 ? Math.round(size) : size.toFixed(1)}${units[unit]}`
94
- }
95
-
96
- // Function: formatDisk
97
- // Compact free/total disk line, e.g. "12G free of 256G".
98
- function formatDisk(disk) {
99
- if (!disk) return ""
100
- return `${formatSize(disk.free)} free of ${formatSize(disk.total)}`
101
- }
102
-
103
84
  // Function: formatTime
104
85
  // Local `YYYY-MM-DD HH:MM` stamp for a millisecond epoch; empty when absent.
105
86
  function formatTime(ms) {