@ssebastien/tcmd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -2
- package/bin/tcmd +7 -0
- package/package.json +37 -5
- package/src/js/tcmd/components/App.html +28 -0
- package/src/js/tcmd/components/App.js +355 -0
- package/src/js/tcmd/components/App.xml +98 -0
- package/src/js/tcmd/components/BookmarkList.html +12 -0
- package/src/js/tcmd/components/BookmarkList.js +73 -0
- package/src/js/tcmd/components/BookmarkList.xml +18 -0
- package/src/js/tcmd/components/BookmarkRow.html +5 -0
- package/src/js/tcmd/components/BookmarkRow.js +40 -0
- package/src/js/tcmd/components/BookmarkRow.xml +10 -0
- package/src/js/tcmd/components/Breadcrumbs.js +27 -0
- package/src/js/tcmd/components/CommandArgRow.js +90 -0
- package/src/js/tcmd/components/CommandArgRow.xml +5 -0
- package/src/js/tcmd/components/CommandBar.html +4 -0
- package/src/js/tcmd/components/CommandBar.js +126 -0
- package/src/js/tcmd/components/CommandBar.xml +4 -0
- package/src/js/tcmd/components/CommandItem.html +3 -0
- package/src/js/tcmd/components/CommandItem.js +78 -0
- package/src/js/tcmd/components/CommandItem.xml +6 -0
- package/src/js/tcmd/components/CommandPalette.html +7 -0
- package/src/js/tcmd/components/CommandPalette.js +58 -0
- package/src/js/tcmd/components/CommandPalette.xml +16 -0
- package/src/js/tcmd/components/CommandPanel.js +98 -0
- package/src/js/tcmd/components/CommandPanel.xml +35 -0
- package/src/js/tcmd/components/Confirm.html +3 -0
- package/src/js/tcmd/components/Confirm.js +36 -0
- package/src/js/tcmd/components/Confirm.xml +7 -0
- package/src/js/tcmd/components/Crumb.html +4 -0
- package/src/js/tcmd/components/Crumb.js +48 -0
- package/src/js/tcmd/components/Crumb.xml +4 -0
- package/src/js/tcmd/components/EntryDetail.html +15 -0
- package/src/js/tcmd/components/EntryDetail.js +194 -0
- package/src/js/tcmd/components/EntryDetail.xml +16 -0
- package/src/js/tcmd/components/FileList.html +1 -0
- package/src/js/tcmd/components/FileList.js +41 -0
- package/src/js/tcmd/components/FileList.xml +7 -0
- package/src/js/tcmd/components/FilePreview.html +5 -0
- package/src/js/tcmd/components/FilePreview.js +110 -0
- package/src/js/tcmd/components/FilePreview.xml +10 -0
- package/src/js/tcmd/components/FileRow.html +5 -0
- package/src/js/tcmd/components/FileRow.js +135 -0
- package/src/js/tcmd/components/FileRow.xml +10 -0
- package/src/js/tcmd/components/FilterBar.html +6 -0
- package/src/js/tcmd/components/FilterBar.js +50 -0
- package/src/js/tcmd/components/FilterBar.xml +6 -0
- package/src/js/tcmd/components/Help.xml +11 -0
- package/src/js/tcmd/components/PaletteRow.html +3 -0
- package/src/js/tcmd/components/PaletteRow.js +43 -0
- package/src/js/tcmd/components/PaletteRow.xml +4 -0
- package/src/js/tcmd/components/RowList.html +1 -0
- package/src/js/tcmd/components/RowList.xml +1 -0
- package/src/js/tcmd/components/Summary.html +3 -0
- package/src/js/tcmd/components/Summary.js +77 -0
- package/src/js/tcmd/components/Summary.xml +3 -0
- package/src/js/tcmd/components/bind.js +20 -0
- package/src/js/tcmd/components/format.js +155 -0
- package/src/js/tcmd/components/icons.js +185 -0
- package/src/js/tcmd/components/index.js +41 -0
- package/src/js/tcmd/components/rows.js +212 -0
- package/src/js/tcmd/core/bookmarks.js +175 -0
- package/src/js/tcmd/core/commands.js +930 -0
- package/src/js/tcmd/core/directory.js +194 -0
- package/src/js/tcmd/core/entries.js +88 -0
- package/src/js/tcmd/core/fs.js +266 -0
- package/src/js/tcmd/core/index.js +39 -0
- package/src/js/tcmd/core/keymap.js +60 -0
- package/src/js/tcmd/core/operations.js +453 -0
- package/src/js/tcmd/core/os.js +128 -0
- package/src/js/tcmd/core/pane.js +74 -0
- package/src/js/tcmd/core/panel.js +383 -0
- package/src/js/tcmd/core/path.js +57 -0
- package/src/js/tcmd/core/script.js +186 -0
- package/src/js/tcmd/core/selection.js +43 -0
- package/src/js/tcmd/core/session.js +405 -0
- package/src/js/tcmd/index.js +53 -0
- package/src/js/tcmd/tui/app.js +140 -0
- package/src/js/tcmd/tui/catalogue.js +300 -0
- package/src/js/tcmd/tui/frame.js +269 -0
- package/src/js/tcmd/tui/help.js +32 -0
- package/src/js/tcmd/tui/index.js +104 -0
- package/src/js/tcmd/tui/theme.js +177 -0
- package/src/js/tcmd/vendor/fzy.js +243 -0
- package/src/js/tcmd/web/app.js +154 -0
- package/src/js/tcmd/web/catalogue.js +159 -0
- package/src/js/tcmd/web/header.html +1 -0
- package/src/js/tcmd/web/header.js +38 -0
- package/src/js/tcmd/web/http.js +79 -0
- package/src/js/tcmd/web/index.js +22 -0
- package/src/js/tcmd/web/main.js +151 -0
- package/src/js/tcmd/web/opfs.js +282 -0
- package/src/js/tcmd/web/seed.js +90 -0
- package/src/js/tcmd/web/server.js +191 -0
- package/src/js/tcmd/web/theme.js +23 -0
|
@@ -0,0 +1,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
|