@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,155 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/components/format
7
+ // Presentation-only formatting helpers (sizes, counts, header title) shared by
8
+ // every component for every renderer.
9
+
10
+ // Function: formatTitle
11
+ // Header line `TinyCmd - HOST DIR/BASE` for the given directory. When it
12
+ // exceeds `width` columns the path's middle segments collapse to their first
13
+ // letter, left to right, keeping the basename (`/home/seb/app` -> `/h/s/app`).
14
+ // `host` is optional; `width <= 0` means "unknown width" and disables
15
+ // compression.
16
+ function formatTitle(host, path, width) {
17
+ const lead = host ? `TinyCmd - ${host} ` : "TinyCmd - "
18
+ const parts = `${path ?? ""}`.split("/").filter(Boolean)
19
+ let text = `${lead}/${parts.join("/")}`
20
+ if (width <= 0 || text.length <= width) return text
21
+ // Collapse middle segments (every segment but the basename), one at a time.
22
+ for (let i = 0; i < parts.length - 1; i++) {
23
+ parts[i] = parts[i][0]
24
+ text = `${lead}/${parts.join("/")}`
25
+ if (text.length <= width) return text
26
+ }
27
+ // Still too wide: drop leading segments, then hard-truncate.
28
+ let rest = parts
29
+ while (rest.length > 1) {
30
+ rest = rest.slice(1)
31
+ text = `${lead}…/${rest.join("/")}`
32
+ if (text.length <= width) return text
33
+ }
34
+ return `${text.slice(0, Math.max(0, width - 1))}…`
35
+ }
36
+
37
+ // Function: crumbSpan
38
+ // Columns a breadcrumb segment occupies: the root "/" is one column; a name
39
+ // is its width plus one for the "/" separator, except the basename (the last
40
+ // segment) which carries no separator.
41
+ function crumbSpan(segment, isLast) {
42
+ if (isRootish(segment)) return 1
43
+ return `${segment.name}`.length + (isLast ? 0 : 1)
44
+ }
45
+
46
+ // Function: isRootish
47
+ // True for the leading "/" segment.
48
+ function isRootish(segment) {
49
+ return segment?.path === "/"
50
+ }
51
+
52
+ // Function: elideCrumbs
53
+ // Width-aware breadcrumb segments. Full names while they fit `width`
54
+ // columns; then the leftmost middle segments collapse to their first letter
55
+ // (original case, e.g. Workspace -> W) one at a time, basename last; then —
56
+ // only when even one letter per segment cannot fit — leading segments are
57
+ // dropped behind a "…" marker. Every remaining segment keeps at least one
58
+ // letter and every "/" survives, so the strip stays clickable end to end.
59
+ // `width <= 0` (unknown, web) returns the segments untouched.
60
+ function elideCrumbs(segments, width) {
61
+ const list = segments ?? []
62
+ if (width <= 0 || list.length === 0) return list
63
+ const measure = (segs) =>
64
+ segs.reduce((cols, segment, i) => cols + crumbSpan(segment, i === segs.length - 1), 0)
65
+ if (measure(list) <= width) return list
66
+ // Collapse leftmost non-root segments to one letter; the basename stays.
67
+ const collapsed = list.map((segment) => ({ ...segment }))
68
+ for (let i = 1; i < collapsed.length - 1; i++) {
69
+ collapsed[i] = { ...collapsed[i], name: `${collapsed[i].name}`.slice(0, 1) }
70
+ if (measure(collapsed) <= width) return collapsed
71
+ }
72
+ // Even one letter each cannot fit: drop leading segments behind a "…".
73
+ const marker = { name: "…", path: null }
74
+ let rest = collapsed.slice(1)
75
+ while (rest.length > 1 && measure([marker, ...rest]) > width) {
76
+ rest = rest.slice(1)
77
+ }
78
+ return [marker, ...rest]
79
+ }
80
+
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
+ // Function: formatTime
104
+ // Local `YYYY-MM-DD HH:MM` stamp for a millisecond epoch; empty when absent.
105
+ function formatTime(ms) {
106
+ const value = Number(ms)
107
+ if (!Number.isFinite(value) || value <= 0) return ""
108
+ const date = new Date(value)
109
+ const pad = (n) => `${n}`.padStart(2, "0")
110
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
111
+ }
112
+
113
+ // Function: formatMode
114
+ // POSIX permission string ("-rw-r--r--", "drwxr-xr-x") from a stat mode; empty
115
+ // when the backend does not provide one. The leading character reflects the
116
+ // file type and the setuid/setgid/sticky bits replace the execute slot.
117
+ function formatMode(mode) {
118
+ if (!Number.isFinite(mode) || mode <= 0) return ""
119
+ const type = mode & 0o170000
120
+ const fileType = type === 0o040000 ? "d" : type === 0o120000 ? "l" : type === 0o020000 ? "c" : type === 0o060000 ? "b" : type === 0o010000 ? "p" : "-"
121
+ const bits = mode & 0o7777
122
+ const triple = (shift, special, set, unset) => {
123
+ const part = (bits >> shift) & 7
124
+ const exec = part & 1 ? "x" : "-"
125
+ const execSpecial = special ? (exec === "x" ? set : unset) : exec
126
+ return `${part & 4 ? "r" : "-"}${part & 2 ? "w" : "-"}${execSpecial}`
127
+ }
128
+ return (
129
+ fileType +
130
+ triple(6, bits & 0o4000, "s", "S") +
131
+ triple(3, bits & 0o2000, "s", "S") +
132
+ triple(0, bits & 0o1000, "t", "T")
133
+ )
134
+ }
135
+
136
+ // Function: formatCounts
137
+ // Summary counts line: "3 dirs, 12 files, 4.2K".
138
+ function formatCounts(entries) {
139
+ let dirs = 0
140
+ let files = 0
141
+ let bytes = 0
142
+ const list = entries ?? []
143
+ for (let i = 0; i < list.length; i++) {
144
+ if (list[i].isDir) dirs++
145
+ else {
146
+ files++
147
+ bytes += list[i].size ?? 0
148
+ }
149
+ }
150
+ return `${dirs} dirs, ${files} files, ${formatSize(bytes)}`
151
+ }
152
+
153
+ export { elideCrumbs, formatCounts, formatDisk, formatMode, formatSize, formatTime, formatTitle }
154
+
155
+ // EOF
@@ -0,0 +1,185 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-09
5
+
6
+ // Module: tcmd/components/icons
7
+ // Nerd Font icon for a file entry, in the spirit of yazi: a kind icon
8
+ // (directory, symlink, device node) wins, then a per-extension icon, then the
9
+ // generic file icon. A few basenames map directly too, so `Makefile`,
10
+ // `Dockerfile` and dotfiles like `.gitignore` get their icon even without a
11
+ // distinguishing extension.
12
+ //
13
+ // The glyphs are Nerd Font codepoints (from `glyphnames.json`), so they only
14
+ // render on a terminal patched with a Nerd Font: rows show them when the
15
+ // renderer environment enables them (`env.icons`, resolved from `TCMD_ICONS`
16
+ // in `tui/catalogue.js`; `TCMD_ICONS=0|false|off` opts out, and the web view
17
+ // never enables it because browsers lack the font).
18
+ //
19
+ // Pure string mapping: no Node builtins, no renderer imports, so both
20
+ // catalogues (TUI and web) share it.
21
+
22
+ // Constant: DIR_ICON, SYMLINK_ICON, DEVICE_ICON, FILE_ICON
23
+ // Kind icons. Symlinks read as a chain (matching `nameFg`, where a link wins
24
+ // over its target kind, like `dircolors`).
25
+ const DIR_ICON = "\u{f07b}" // nf-fa-folder
26
+ const SYMLINK_ICON = "\u{f0c1}" // nf-fa-link
27
+ const DEVICE_ICON = "\u{e795}" // nf-dev-terminal
28
+ const FILE_ICON = "\u{f016}" // nf-fa-file_o
29
+
30
+ // Constant: NAME_ICONS
31
+ // Basename (lowercase, leading dot stripped) → glyph, checked before the
32
+ // extension table.
33
+ const NAME_ICONS = {
34
+ "cmakelists": "\u{e794}", // nf-dev-cmake
35
+ "dockerfile": "\u{e650}", // nf-seti-docker
36
+ "gnumakefile": "\u{e673}", // nf-seti-makefile
37
+ "gitattributes": "\u{e65d}", // nf-seti-git
38
+ "gitconfig": "\u{e65d}", // nf-seti-git
39
+ "gitignore": "\u{e65d}", // nf-seti-git
40
+ "gitmodules": "\u{e65d}", // nf-seti-git
41
+ "license": "\u{e60a}", // nf-seti-license
42
+ "licence": "\u{e60a}", // nf-seti-license
43
+ "makefile": "\u{e673}", // nf-seti-makefile
44
+ "npmrc": "\u{e616}", // nf-seti-npm
45
+ "readme": "\u{e609}", // nf-seti-markdown
46
+ }
47
+
48
+ // Constant: EXTENSION_ICONS
49
+ // Extension (lowercase, no dot) → glyph. The icons are the seti theme's — the
50
+ // same source yazi draws from.
51
+ const EXTENSION_ICONS = {
52
+ // Languages
53
+ "c": "\u{e649}", // nf-seti-c
54
+ "cc": "\u{e646}", // nf-seti-cpp
55
+ "clj": "\u{e642}", // nf-seti-clojure
56
+ "cpp": "\u{e646}", // nf-seti-cpp
57
+ "cs": "\u{e648}", // nf-seti-c_sharp
58
+ "d": "\u{e651}", // nf-seti-d
59
+ "ex": "\u{e62d}", // nf-seti-elixir
60
+ "exs": "\u{e62d}", // nf-seti-elixir
61
+ "go": "\u{e627}", // nf-seti-go
62
+ "gql": "\u{e662}", // nf-seti-graphql
63
+ "graphql": "\u{e662}", // nf-seti-graphql
64
+ "h": "\u{e649}", // nf-seti-c
65
+ "hbs": "\u{e60f}", // nf-seti-mustache
66
+ "hs": "\u{e61f}", // nf-seti-haskell
67
+ "hpp": "\u{e646}", // nf-seti-cpp
68
+ "java": "\u{e66d}", // nf-seti-java
69
+ "js": "\u{e60c}", // nf-seti-javascript
70
+ "cjs": "\u{e60c}", // nf-seti-javascript
71
+ "mjs": "\u{e60c}", // nf-seti-javascript
72
+ "jsx": "\u{e625}", // nf-seti-react
73
+ "kt": "\u{e634}", // nf-seti-kotlin
74
+ "kts": "\u{e634}", // nf-seti-kotlin
75
+ "lua": "\u{e620}", // nf-seti-lua
76
+ "nim": "\u{e677}", // nf-seti-nim
77
+ "perl": "\u{e67e}", // nf-seti-perl
78
+ "pl": "\u{e67e}", // nf-seti-perl
79
+ "php": "\u{e608}", // nf-seti-php
80
+ "ps1": "\u{e691}", // nf-seti-shell
81
+ "py": "\u{e606}", // nf-seti-python
82
+ "r": "\u{e68a}", // nf-seti-r
83
+ "rb": "\u{e605}", // nf-seti-ruby
84
+ "rs": "\u{e68b}", // nf-seti-rust
85
+ "scala": "\u{e68e}", // nf-seti-scala
86
+ "sh": "\u{e691}", // nf-seti-shell
87
+ "sql": "\u{e7c4}", // nf-dev-sqlite
88
+ "swift": "\u{e699}", // nf-seti-swift
89
+ "ts": "\u{e628}", // nf-seti-typescript
90
+ "tsx": "\u{e625}", // nf-seti-react
91
+ "vim": "\u{e7c5}", // nf-dev-vim
92
+ "zsh": "\u{e691}", // nf-seti-shell
93
+ // Web
94
+ "css": "\u{e614}", // nf-seti-css
95
+ "htm": "\u{e60e}", // nf-seti-html
96
+ "html": "\u{e60e}", // nf-seti-html
97
+ "json": "\u{e60b}", // nf-seti-json
98
+ "less": "\u{e614}", // nf-seti-css
99
+ "sass": "\u{e603}", // nf-seti-sass
100
+ "scss": "\u{e603}", // nf-seti-sass
101
+ "svg": "\u{e698}", // nf-seti-svg
102
+ "vue": "\u{e6a0}", // nf-seti-vue
103
+ "xml": "\u{e619}", // nf-seti-xml
104
+ "yaml": "\u{e6a8}", // nf-seti-yml
105
+ "yml": "\u{e6a8}", // nf-seti-yml
106
+ // Data and docs
107
+ "csv": "\u{f0218}", // nf-md-file_delimited
108
+ "log": "\u{e64e}", // nf-seti-text
109
+ "pdf": "\u{e67d}", // nf-seti-pdf
110
+ "toml": "\u{e6b2}", // nf-custom-toml
111
+ "tsv": "\u{f0218}", // nf-md-file_delimited
112
+ "txt": "\u{e64e}", // nf-seti-text
113
+ // Media
114
+ "avi": "\u{e69f}", // nf-seti-video
115
+ "flac": "\u{e638}", // nf-seti-audio
116
+ "gif": "\u{e60d}", // nf-seti-image
117
+ "jpeg": "\u{e60d}", // nf-seti-image
118
+ "jpg": "\u{e60d}", // nf-seti-image
119
+ "m4a": "\u{e638}", // nf-seti-audio
120
+ "mkv": "\u{e69f}", // nf-seti-video
121
+ "mov": "\u{e69f}", // nf-seti-video
122
+ "mp3": "\u{e638}", // nf-seti-audio
123
+ "mp4": "\u{e69f}", // nf-seti-video
124
+ "ogg": "\u{e638}", // nf-seti-audio
125
+ "png": "\u{e60d}", // nf-seti-image
126
+ "wav": "\u{e638}", // nf-seti-audio
127
+ "webm": "\u{e69f}", // nf-seti-video
128
+ "webp": "\u{e60d}", // nf-seti-image
129
+ // Archives and misc
130
+ "7z": "\u{e6aa}", // nf-seti-zip
131
+ "bz2": "\u{e6aa}", // nf-seti-zip
132
+ "cfg": "\u{e615}", // nf-seti-config
133
+ "conf": "\u{e615}", // nf-seti-config
134
+ "db": "\u{e7c4}", // nf-dev-sqlite
135
+ "diff": "\u{f440}", // nf-oct-diff
136
+ "env": "\u{e615}", // nf-seti-config
137
+ "gz": "\u{e6aa}", // nf-seti-zip
138
+ "ini": "\u{e615}", // nf-seti-config
139
+ "jar": "\u{e6aa}", // nf-seti-zip
140
+ "lock": "\u{e672}", // nf-seti-lock
141
+ "patch": "\u{f440}", // nf-oct-diff
142
+ "rar": "\u{e6aa}", // nf-seti-zip
143
+ "tar": "\u{e6aa}", // nf-seti-zip
144
+ "xz": "\u{e6aa}", // nf-seti-zip
145
+ "zip": "\u{e6aa}", // nf-seti-zip
146
+ // Tooling
147
+ "cmake": "\u{e794}", // nf-dev-cmake
148
+ "mk": "\u{e673}", // nf-seti-makefile
149
+ }
150
+
151
+ // Function: extensionOf
152
+ // Lowercase extension without the dot. A leading dot (dotfile) or a trailing
153
+ // dot means "no extension".
154
+ function extensionOf(name) {
155
+ const base = `${name ?? ""}`
156
+ const dot = base.lastIndexOf(".")
157
+ if (dot <= 0 || dot === base.length - 1) return ""
158
+ return base.slice(dot + 1).toLowerCase()
159
+ }
160
+
161
+ // Function: iconFor
162
+ // The Nerd Font glyph for an entry: kind first (symlink wins over directory,
163
+ // matching `nameFg`), then the basename, then the extension, then the generic
164
+ // file icon.
165
+ function iconFor(entry) {
166
+ if (!entry) return ""
167
+ if (entry.isSymlink) return SYMLINK_ICON
168
+ if (entry.isDevice) return DEVICE_ICON
169
+ if (entry.isDir) return DIR_ICON
170
+ const name = `${entry.name ?? ""}`
171
+ const plain = (name.startsWith(".") ? name.slice(1) : name).toLowerCase()
172
+ return NAME_ICONS[plain] ?? EXTENSION_ICONS[extensionOf(name)] ?? FILE_ICON
173
+ }
174
+
175
+ // Function: iconsEnabled
176
+ // Whether rows show icons: on by default, `TCMD_ICONS=0|false|off` opts out
177
+ // for terminals without a Nerd Font.
178
+ function iconsEnabled(environment = globalThis.process?.env ?? {}) {
179
+ const value = environment.TCMD_ICONS
180
+ return value !== "0" && value !== "false" && value !== "off"
181
+ }
182
+
183
+ export { DEVICE_ICON, DIR_ICON, FILE_ICON, SYMLINK_ICON, extensionOf, iconFor, iconsEnabled }
184
+
185
+ // EOF
@@ -0,0 +1,41 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/components
7
+ // Manifest of the shared, renderer-agnostic component logic. Each module
8
+ // exports `createBehaviors(env)` and pure helpers; the TUI (`tui/catalogue`)
9
+ // and web (`web/catalogue`) bind them to their `.xml` / `.html` templates
10
+ // through the shared `bind` helper.
11
+
12
+ // Binding
13
+ export { bind } from "./bind.js"
14
+ export { commandBarMarks, commandBarMarksItem, commandBarRowCount, commandBarStatus } from "./CommandBar.js"
15
+
16
+ // Behaviors
17
+ export { createBehaviors as appBehaviors, createEvents as appEvents, createInit as appInit } from "./App.js"
18
+ export { createBehaviors as bookmarkListBehaviors } from "./BookmarkList.js"
19
+ export { createBehaviors as bookmarkRowBehaviors } from "./BookmarkRow.js"
20
+ export { createBehaviors as breadcrumbsBehaviors } from "./Breadcrumbs.js"
21
+ export { createBehaviors as commandBarBehaviors } from "./CommandBar.js"
22
+ export { createBehaviors as commandItemBehaviors } from "./CommandItem.js"
23
+ export { createBehaviors as commandArgRowBehaviors } from "./CommandArgRow.js"
24
+ export { createBehaviors as commandPanelBehaviors } from "./CommandPanel.js"
25
+ export { createBehaviors as commandPaletteBehaviors } from "./CommandPalette.js"
26
+ export { createBehaviors as confirmBehaviors } from "./Confirm.js"
27
+ export { createBehaviors as crumbBehaviors } from "./Crumb.js"
28
+ export { createBehaviors as entryDetailBehaviors } from "./EntryDetail.js"
29
+ export { createBehaviors as fileListBehaviors } from "./FileList.js"
30
+ export { createBehaviors as filePreviewBehaviors } from "./FilePreview.js"
31
+ export { createBehaviors as fileRowBehaviors } from "./FileRow.js"
32
+ export { createBehaviors as filterBarBehaviors } from "./FilterBar.js"
33
+ export { createBehaviors as paletteRowBehaviors } from "./PaletteRow.js"
34
+ export { createBehaviors as summaryBehaviors } from "./Summary.js"
35
+
36
+ // Helpers
37
+ export { formatCounts, formatDisk, formatMode, formatSize, formatTime } from "./format.js"
38
+ export { iconFor, iconsEnabled } from "./icons.js"
39
+ export { bookmarkRows, commandItemRows, crumbRows, fileRows, fileWindow, paletteRows, panelArgRows, panelOpRows } from "./rows.js"
40
+
41
+ // EOF
@@ -0,0 +1,212 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/components/rows
7
+ // Keyed row builders shared by the TUI and web catalogues. Each builder is
8
+ // `(self, data) → Component.map(…)`: it turns a parent's data bag into the
9
+ // `{ item, index, … }` bags its row components receive, so the bag shape is
10
+ // defined once and both renderers stay in sync. `self` is the parent list
11
+ // instance — only `fileRows` uses it, to fall back to the measured scrollbox
12
+ // height when no viewport is threaded down.
13
+ //
14
+ // Row state (cursor, marks, selection) is snapshotted into plain per-row
15
+ // booleans (`currentRow`, `markedRow`, `selectedRow`) instead of broadcasting
16
+ // the shared cells into every row: a shared cell in every row bag makes a move
17
+ // re-render all N rows, while a plain per-row flag lets the keyed-map
18
+ // reconciliation update only the rows that changed. The builders still read
19
+ // the shared sources (they `unwrap` cursor/marked/selected), so they re-run on
20
+ // every move and rewrite just the two changed bags. Row behaviors read the
21
+ // flags through predicates that fall back to the shared cells for hosts that
22
+ // build bags by hand (`isSelected` here, `rowCurrent`/`rowIsMarked` in
23
+ // `FileRow`).
24
+ //
25
+ // Exception: the command panel's rows keep the shared reactive
26
+ // `cursor`/`focus` cells, because focus ownership depends on the row type
27
+ // (`CommandArgRow.wantsFocus`), which the builders do not know.
28
+
29
+ import { unwrap } from "select/state/cells"
30
+
31
+ // Function: isCell
32
+ // True when `value` is a reactive cell, using the same `isReactive` marker the
33
+ // library itself checks for.
34
+ function isCell(value) {
35
+ return Boolean(value && value.isReactive === true)
36
+ }
37
+
38
+ // Function: isSelected
39
+ // Whether a palette/bookmark row is the current selection. The plain
40
+ // `selectedRow` flag (snapshotted by the row builder) wins over the shared
41
+ // `selected` cell, so a move only rewrites the rows that actually changed.
42
+ function isSelected(data) {
43
+ if (data.selectedRow !== undefined) return data.selectedRow === true
44
+ return data.index === unwrap(data.selected)
45
+ }
46
+
47
+ // Constant: ROW_WINDOW_MARGIN
48
+ // Extra rows rendered above and below the viewport when a pane windows its
49
+ // list, so a scroll step or a cursor nudge does not first expose a blank row.
50
+ const ROW_WINDOW_MARGIN = 8
51
+
52
+ // Function: fileWindow
53
+ // The windowed slice of a file listing: only `[start, start+len)` rows are
54
+ // mounted around the cursor, so a large directory does not lay out thousands of
55
+ // off-screen rows every frame. The window follows the cursor with a margin and
56
+ // clamps at both ends; `local` is the cursor's index inside the window (what
57
+ // the scrollbox tracks) and `total` the full row count. A viewport of 0 (not
58
+ // yet known) mounts everything so the layout can be measured.
59
+ function fileWindow(data, viewport) {
60
+ const entries = unwrap(data.entries) ?? []
61
+ const total = entries.length
62
+ const cursor = isCell(data.cursor) ? unwrap(data.cursor) : (data.cursor ?? 0)
63
+ const vp = Math.floor(viewport ?? 0)
64
+ if (vp <= 0 || total <= vp) {
65
+ return { start: 0, len: total, local: cursor, total }
66
+ }
67
+ const count = vp + 2 * ROW_WINDOW_MARGIN
68
+ const maxStart = Math.max(0, total - count)
69
+ const start = Math.max(0, Math.min(cursor - ROW_WINDOW_MARGIN, maxStart))
70
+ const len = Math.min(count, total - start)
71
+ return { start, len, local: cursor - start, total }
72
+ }
73
+
74
+ // Function: crumbRows
75
+ // `{ item: segment, index, last, paneId }` bags for `Crumb` rows; `last`
76
+ // marks the basename, which carries no separator.
77
+ function crumbRows(Crumb) {
78
+ return (self, data) => {
79
+ const segments = data.segments ?? []
80
+ const last = segments.length - 1
81
+ return Crumb.map(
82
+ segments,
83
+ (item, index) => ({ item, index, last: index === last, paneId: data.paneId }),
84
+ (segment) => segment.path ?? "…",
85
+ )
86
+ }
87
+ }
88
+
89
+ // Function: fileRows
90
+ // `{ item: entry, index, currentRow, markedRow, active, paneId }` bags for
91
+ // `FileRow` rows. `currentRow`/`markedRow` are plain per-row booleans
92
+ // snapshotted from the shared `cursor`/`marked` sources (cells or plain
93
+ // values); the keyed map reconciles them by `$key`, so a cursor move rewrites
94
+ // two row bags and leaves the rest untouched. `active` stays the shared
95
+ // pane-focus cell, read with `unwrap` by the row behavior so the cursor row
96
+ // renders muted in the inactive pane.
97
+ function fileRows(FileRow) {
98
+ return (self, data) => {
99
+ const entries = unwrap(data.entries) ?? []
100
+ const cursor = isCell(data.cursor) ? unwrap(data.cursor) : data.cursor
101
+ const marked = isCell(data.marked) ? unwrap(data.marked) : data.marked
102
+ // The viewport height is threaded down reactively (from `uiSize`), so the
103
+ // window is correct from the first render; fall back to the measured
104
+ // scrollbox height on renderers that do not provide it.
105
+ const viewport = unwrap(data.viewport) || self?.ref?.list?.height || 0
106
+ const { start, len } = fileWindow(data, viewport)
107
+ const bag = (item, index) => ({
108
+ item,
109
+ index,
110
+ currentRow: index === cursor,
111
+ markedRow: Boolean(marked?.has?.(item.path)),
112
+ active: data.active,
113
+ paneId: data.paneId,
114
+ })
115
+ if (start === 0 && len === entries.length) {
116
+ return FileRow.map(entries, bag, (entry) => entry.path)
117
+ }
118
+ return FileRow.map(entries.slice(start, start + len), (item, i) => bag(item, start + i), (entry) => entry.path)
119
+ }
120
+ }
121
+
122
+ // Function: paletteRows
123
+ // `{ item: command, index, selectedRow }` bags for `PaletteRow` rows.
124
+ // `selectedRow` is the plain per-row selection flag so a palette move rewrites
125
+ // only the changed rows.
126
+ function paletteRows(PaletteRow) {
127
+ return (self, data) => {
128
+ const selected = isCell(data.selected) ? unwrap(data.selected) : data.selected
129
+ return PaletteRow.map(
130
+ data.items ?? [],
131
+ (item, index) => ({ item, index, selectedRow: index === selected }),
132
+ (command) => command.id,
133
+ )
134
+ }
135
+ }
136
+
137
+ // Function: commandItemRows
138
+ // `{ item: command, index }` bags for `CommandItem` rows.
139
+ function commandItemRows(CommandItem) {
140
+ return (self, data) =>
141
+ CommandItem.map(
142
+ data.items ?? [],
143
+ (item, index) => ({ item, index }),
144
+ (command) => command.id ?? `${command.title}:${command.keys}`,
145
+ )
146
+ }
147
+
148
+ // Function: panelOpRows
149
+ // `{ item: op, index, row, cursor, focus }` bags for the command panel's
150
+ // operation column (`CommandArgRow`). `row` is the normalized row shape the
151
+ // component reads — `name` is the operation title and `summary` its argument
152
+ // list (precomputed by `session.panelItems`, the signature with the leading
153
+ // name stripped); `risk` tints destructive/caution operations; `cursor` is the
154
+ // column's selected index.
155
+ function panelOpRows(CommandArgRow) {
156
+ return (self, data) =>
157
+ CommandArgRow.map(
158
+ data.items ?? [],
159
+ (item, index) => ({
160
+ item,
161
+ index,
162
+ row: {
163
+ type: "op",
164
+ key: item.id,
165
+ name: item.title,
166
+ summary: item.summary ?? "",
167
+ risk: item.risk ?? null,
168
+ },
169
+ cursor: data.opCursor,
170
+ focus: data.focus,
171
+ }),
172
+ (item) => item.id,
173
+ )
174
+ }
175
+
176
+ // Function: panelArgRows
177
+ // `{ item: row, index, row, cursor, focus }` bags for the command panel's
178
+ // argument column (`CommandArgRow`). The rows are already flattened by
179
+ // `core/panel.panelRowList`; `cursor` is the column's selected index.
180
+ function panelArgRows(CommandArgRow) {
181
+ return (self, data) =>
182
+ CommandArgRow.map(
183
+ data.rows ?? [],
184
+ (item, index) => ({
185
+ item,
186
+ index,
187
+ row: item,
188
+ cursor: data.argCursor,
189
+ focus: data.focus,
190
+ }),
191
+ (row) => row.key,
192
+ )
193
+ }
194
+
195
+ // Function: bookmarkRows
196
+ // `{ item: { name, path }, index, selectedRow }` bags for `BookmarkRow` rows.
197
+ // `selectedRow` is the plain per-row selection flag so a picker move rewrites
198
+ // only the changed rows.
199
+ function bookmarkRows(BookmarkRow) {
200
+ return (self, data) => {
201
+ const selected = isCell(data.selected) ? unwrap(data.selected) : data.selected
202
+ return BookmarkRow.map(
203
+ data.items ?? [],
204
+ (item, index) => ({ item, index, selectedRow: index === selected }),
205
+ (bookmark) => bookmark.name,
206
+ )
207
+ }
208
+ }
209
+
210
+ export { bookmarkRows, commandItemRows, crumbRows, fileRows, fileWindow, isSelected, paletteRows, panelArgRows, panelOpRows }
211
+
212
+ // EOF