@ssebastien/tcmd 0.0.0-stage → 0.1.1
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 +17 -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
package/README.md
CHANGED
|
@@ -1,3 +1,235 @@
|
|
|
1
|
-
#
|
|
1
|
+
# TinyCommand
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
```
|
|
4
|
+
██▓ ██▓
|
|
5
|
+
███▄▄ ███▀▀█▓▄ ███▀▀███▀▀█▓▄ ▄██▀▀███
|
|
6
|
+
███ ███ ▀▀▀▀ ███ ███ ███ ███ ███
|
|
7
|
+
███ ███ ███ ███ ███ ███ ███ ███
|
|
8
|
+
▀██▄▄ ███▄▄███ ███ ███ ███ ███▄▄███
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
TinyCommand `tcmd` is a midnight-commander-like CLI and desktop tool:
|
|
12
|
+
|
|
13
|
+
- Browse files in two panes (the left is the source, the right the
|
|
14
|
+
destination)
|
|
15
|
+
- Named bookmarks: `b` records the cursor entry (or the pane directory) under
|
|
16
|
+
a name defaulting to its basename, remembering whether it is a file or a
|
|
17
|
+
directory; `g` opens a fzf-style picker over bookmark names and paths. A
|
|
18
|
+
directory bookmark opens to show its contents, a file bookmark is selected
|
|
19
|
+
and previewed in its directory.
|
|
20
|
+
- Operations over the current panes: copy, move, delete/erase, stat, touch,
|
|
21
|
+
selection buffers, edit/view/open and shell commands
|
|
22
|
+
- Command palette with UI actions, plus a scripting mode (`:`) that completes
|
|
23
|
+
operations and the available filesets ($A, $B, …, $FILE)
|
|
24
|
+
- Keyboard and mouse navigation, inline file previews
|
|
25
|
+
- Bookmarks and quick find (later milestones)
|
|
26
|
+
|
|
27
|
+
References: [Marta.sh](https://marta.sh) for the UI, [fman.io](https://fman.io)
|
|
28
|
+
for the minimalist take.
|
|
29
|
+
|
|
30
|
+
## Running
|
|
31
|
+
|
|
32
|
+
Install [Bun](https://bun.sh), then run without installing tcmd globally:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm x @ssebastien/tcmd
|
|
36
|
+
npm x @ssebastien/tcmd -- /tmp ~
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or install it globally with `npm install -g @ssebastien/tcmd` and run `tcmd`.
|
|
40
|
+
|
|
41
|
+
For development:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
bun install # installs @ssebastien/select and @ssebastien/select-tui
|
|
45
|
+
bun run tcmd # open cwd (left) and ~ (right)
|
|
46
|
+
bun run tcmd /tmp ~ # open specific directories
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Keys: `↑/↓` move (`^↑/^↓` jump a few lines), `Enter/→` open, `←/Bksp` parent,
|
|
50
|
+
`Esc`/`⇧Esc` history back/forward, `Tab`/`⇧Tab` switch panes
|
|
51
|
+
(`⇧Tab` goes back), `Space` mark, `b` bookmark the cursor entry (or the pane
|
|
52
|
+
directory) under a basename-defaulted name, `g` jump to a bookmark, `:` or `m`
|
|
53
|
+
open the command panel (Commando: operations, their arguments, and the live
|
|
54
|
+
resolved command), `v` preview (fullscreen viewer when the terminal is narrow),
|
|
55
|
+
`O`/`E` open/edit the previewed file, `/` fuzzy-filter the active pane (`Enter`
|
|
56
|
+
keeps it, `Esc` clears), `^P` command palette, `!` run a shell command, `^R`
|
|
57
|
+
reload, `?` key reference, `q` quit.
|
|
58
|
+
|
|
59
|
+
The workspace opens with a slim identity bar (`TinyCmd - HOST`) over an
|
|
60
|
+
unframed tiling of pane boxes (the two middle edges sit side by
|
|
61
|
+
side) labelled **SRC** (left, source) / **DST** (right, destination), with a
|
|
62
|
+
header row (label + clickable breadcrumbs — collapsed to first letters like
|
|
63
|
+
`/h/s/W/Perso/tcmd` when the width demands — + the inline filter) separated by a
|
|
64
|
+
rule from the listing, an entry-detail line for the selected entry, and one
|
|
65
|
+
summary row, so the two panes stay aligned. The detail line shows
|
|
66
|
+
`date · type · size · rights` with the rights right-aligned, the date and size
|
|
67
|
+
bright, and the size/rights dropping out when the pane is too narrow; the
|
|
68
|
+
summary row is indented to match. The active pane's border and header
|
|
69
|
+
rule are highlighted and its cursor row uses the strong cursor colour, while
|
|
70
|
+
the inactive pane stays dim with a muted cursor. Rows lead with a yazi-style
|
|
71
|
+
Nerd Font icon (folder/link kind icons plus a per-extension icon, `Makefile`,
|
|
72
|
+
`Dockerfile` and dotfiles included), on unless `TCMD_ICONS=0` — it needs a
|
|
73
|
+
Nerd-Font-patched terminal font, and the web view never shows icons. A
|
|
74
|
+
context-aware inspector sits
|
|
75
|
+
on the right when the terminal is at least 100 columns wide, and becomes a
|
|
76
|
+
fullscreen viewer below that. The status bar lists every binding as a bracket
|
|
77
|
+
shorthand (`[Open ⏎]`) wrapping over as many rows as the width demands — the
|
|
78
|
+
marked count trails the list, and a notice or error swaps the keys for a
|
|
79
|
+
one-line status. Below 64×16
|
|
80
|
+
the app shows a resize hint instead of the workspace.
|
|
81
|
+
|
|
82
|
+
### Web version
|
|
83
|
+
|
|
84
|
+
The web view runs the same core on select.js and offers two filesystem
|
|
85
|
+
backends:
|
|
86
|
+
|
|
87
|
+
- **OPFS** (preferred): private browser storage, seeded with a demo tree on
|
|
88
|
+
first load. Needs a secure context (`localhost` or https).
|
|
89
|
+
- **HTTP API** (automatic fallback, or `?fs=http`): the server exposes the
|
|
90
|
+
real filesystem over `/api/*`, using the same Node adapter as the TUI.
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
make run-local # build and serve both backends (alias to `bun run web`)
|
|
94
|
+
bun run web # build and serve both backends on http://localhost:8787
|
|
95
|
+
bun run web:build # bundle only, into public/main.js
|
|
96
|
+
bun run web:serve # serve the existing bundle plus /api/*
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`make run-local` is the local dev entry point (it wraps `bun run web` and
|
|
100
|
+
honours `TCMD_WEB_PORT`, default 8787). The Makefile bootstraps the LittleSDK
|
|
101
|
+
into `deps/sdk` for the standard `make` targets.
|
|
102
|
+
|
|
103
|
+
Appending `?fs=opfs` or `?fs=http` to the URL forces one backend. The HTTP
|
|
104
|
+
backend needs the page served by `bun run web:serve` (a plain static server
|
|
105
|
+
only supports OPFS); the server binds to loopback and requires a per-process
|
|
106
|
+
token injected into `index.html`. `TCMD_WEB_ROOT=/some/dir` confines the API
|
|
107
|
+
(default `/`, the same access as `bun run tcmd`), but it is not a sandbox:
|
|
108
|
+
don't expose the port. With OPFS, deleting moves entries to `/.trash`; the API
|
|
109
|
+
uses the desktop trash. Open/edit/run are unavailable in the browser either
|
|
110
|
+
way (the null host adapter reports it in the command bar); everything else —
|
|
111
|
+
navigation, marks, scripting, copy/move/erase — works on either
|
|
112
|
+
backend. `public/index.html` is the host page; `public/main.js` is generated.
|
|
113
|
+
|
|
114
|
+
## Architecture
|
|
115
|
+
|
|
116
|
+
MVC with a presentation-agnostic core, so a web UI can share the same model
|
|
117
|
+
and controller:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
src/js/tcmd/
|
|
121
|
+
core/ Model + Controller. No rendering dependencies.
|
|
122
|
+
fs.js filesystem adapter interface (Node impl; web swaps in OPFS/HTTP)
|
|
123
|
+
os.js host adapter (open/edit/run; null impl for the web)
|
|
124
|
+
path.js pure string path ops
|
|
125
|
+
entries.js filter/sort over file entries (dirs first)
|
|
126
|
+
selection.js cursor/mark arithmetic
|
|
127
|
+
directory.js one pane's directory: path, entries, view, cursor, marks
|
|
128
|
+
pane.js one directory, proxying its cells
|
|
129
|
+
bookmarks.js named bookmarks (path + file/dir kind) + the memory/file stores
|
|
130
|
+
panel.js command panel: operation args bound from the live context
|
|
131
|
+
session.js two panes + active pane, preview, palette, panel, dispatch
|
|
132
|
+
keymap.js key chords -> command ids
|
|
133
|
+
commands.js command registry; commands are `run(session, payload)`
|
|
134
|
+
operations.js scripting operations (copy/move/delete/erase/stat/sel/…)
|
|
135
|
+
script.js scripting line parser, runner and palette completions
|
|
136
|
+
components/ Shared, renderer-agnostic component logic
|
|
137
|
+
App.js root controller: key + event -> dispatch
|
|
138
|
+
bind.js `ui.component(name).does(behaviors).using(source)` binding
|
|
139
|
+
rows.js keyed row-bag builders shared by both catalogues
|
|
140
|
+
FileList.js … one `.js` per component (behaviors + pure helpers)
|
|
141
|
+
FileRow.js … row behaviors shared by both engines
|
|
142
|
+
format.js presentation helpers (sizes/counts)
|
|
143
|
+
tui/ View (select.tui templates on OpenTUI)
|
|
144
|
+
app.js binds App.js + App.xml + TUI environment; size/help cells
|
|
145
|
+
catalogue.js binds each `components/*.js` to its `.xml` and registers tags
|
|
146
|
+
frame.js TUI-only chrome: frame title, breakpoints
|
|
147
|
+
help.js TUI-only `?` key-reference overlay behaviors
|
|
148
|
+
index.js TUI entry point; feeds `size` from renderer resize events
|
|
149
|
+
theme.js TUI palette resolver (dark/light/mono, NO_COLOR)
|
|
150
|
+
web/ Browser view (select.js templates on the DOM, same core)
|
|
151
|
+
app.js binds App.js + App.html + web environment
|
|
152
|
+
catalogue.js binds each `components/*.js` to its `.html`
|
|
153
|
+
index.js web entry point: `main(session, selector)`
|
|
154
|
+
main.js browser bootstrap: backend choice + session (bundled)
|
|
155
|
+
opfs.js OPFS filesystem adapter (browser storage)
|
|
156
|
+
http.js filesystem adapter over the `/api` JSON endpoints
|
|
157
|
+
server.js dev server: `public/` + `/api/*` (used by `bun run web:serve`)
|
|
158
|
+
seed.js demo tree written to OPFS on first load
|
|
159
|
+
header.js web-only title bar behaviors (+ `header.html`)
|
|
160
|
+
theme.js web state classes
|
|
161
|
+
vendor/ Vendored third-party code kept close to upstream
|
|
162
|
+
fzy.js fzy fuzzy scoring algorithm (MIT)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Each component is a triple: `ComponentName.js` (shared logic),
|
|
166
|
+
`ComponentName.xml` (TUI presentation) and `ComponentName.html` (web
|
|
167
|
+
presentation). Components that share a presentation bind the same source (e.g.
|
|
168
|
+
`Breadcrumbs` uses `RowList.xml` for the TUI and
|
|
169
|
+
`RowList.html` for the web). The `.js` imports neither renderer and reads
|
|
170
|
+
reactive cells with `unwrap` from `select/state/cells`; each catalogue injects
|
|
171
|
+
the renderer's runtime and theme through the `components/index.js` manifest,
|
|
172
|
+
binding via the shared `components/bind.js` helper
|
|
173
|
+
(`ui.component(name).does(behaviors).using(source)`, the contract both engines
|
|
174
|
+
implement). Published events are registered with `.sub(...)` (the select.js
|
|
175
|
+
contract) in both. List rows use the same keyed `Component.map` primitive over
|
|
176
|
+
reactive collections, sharing row behaviors over a `{ item, index, … }` bag
|
|
177
|
+
built by `components/rows.js`; cells stay in the bag and are read at the point
|
|
178
|
+
of use.
|
|
179
|
+
|
|
180
|
+
TUI-only components live under `tui/` (`frame.js`, `help.js`) and their `.xml`
|
|
181
|
+
templates under `components/`; they are registered only by `tui/catalogue.js`.
|
|
182
|
+
Web-only components live under `web/` (`header.js` + `header.html`). Shared
|
|
183
|
+
behaviors only ever grow additively (a new key for a new presentation),
|
|
184
|
+
so the other engine's presentation and the parity tests keep working unchanged.
|
|
185
|
+
|
|
186
|
+
Decoupling rules:
|
|
187
|
+
|
|
188
|
+
- `core/` state is reactive cells (`select/state/cells`) and commands mutate it
|
|
189
|
+
through `session.dispatch(id, payload)`; views only subscribe and publish.
|
|
190
|
+
- The filesystem and host are injected adapters (`core/fs`, `core/os`), so tests
|
|
191
|
+
run on an in-memory fs and the web view swaps the backend without touching
|
|
192
|
+
the model.
|
|
193
|
+
- Views receive data as cells (`in:` slots and mapped row bags keep cells
|
|
194
|
+
reactive) and read them with `unwrap`; they report intent via `pub()`ed
|
|
195
|
+
events (`list:select`, `crumbs:go`, …) that the app handles with `.sub(...)`
|
|
196
|
+
and maps to commands.
|
|
197
|
+
- `components/` stays free of renderer imports; each `tui/` / `web/` catalogue
|
|
198
|
+
injects the renderer's runtime and theme, so the same controller serves both.
|
|
199
|
+
|
|
200
|
+
## Dependencies
|
|
201
|
+
|
|
202
|
+
- [select.js](../select.js) — reactive cells and the template binding language
|
|
203
|
+
- [select.tui](../select.tui) — Select.js templates rendered on OpenTUI
|
|
204
|
+
- [@opentui/core](https://github.com/anomalyco/opentui) — terminal renderer
|
|
205
|
+
- [fzy.js](https://github.com/jhawthorn/fzy.js) — vendored fuzzy scoring (MIT,
|
|
206
|
+
see `src/js/tcmd/vendor/`)
|
|
207
|
+
- Bun as the runtime
|
|
208
|
+
|
|
209
|
+
`deps/` symlinks to sibling checkouts; `scripts/link-deps.sh` (run as
|
|
210
|
+
postinstall) re-links `node_modules` so upstream edits propagate.
|
|
211
|
+
|
|
212
|
+
## Tests
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
bun test # core, components, TUI and web tests
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Core tests use an in-memory filesystem; TUI tests drive the real component
|
|
219
|
+
tree with OpenTUI's test renderer (frame capture, mock keys and mouse). Web
|
|
220
|
+
tests run the shared logic through select.js on a happy-dom document.
|
|
221
|
+
|
|
222
|
+
## Roadmap
|
|
223
|
+
|
|
224
|
+
- [x] Walking skeleton: two panes, navigation, breadcrumbs, summary, command
|
|
225
|
+
bar, command palette, text preview
|
|
226
|
+
- [x] File operations (copy/move/delete/erase/stat/touch/sel), scripting mode
|
|
227
|
+
with the `$A`/`$B` pane filesets
|
|
228
|
+
- [x] Scripting completion (operations, filesets, active-directory and nested
|
|
229
|
+
paths)
|
|
230
|
+
- [x] Pane fuzzy filtering (`/` to filter, `Enter` to keep, `Esc` to clear)
|
|
231
|
+
- [x] Named bookmarks (`b` records, `g` jumps, persisted to a JSON file)
|
|
232
|
+
- [ ] Quick find, grep
|
|
233
|
+
- [ ] Image/PDF/audio/video previews
|
|
234
|
+
- [x] Web view on the same core (shared components rendered by select.js)
|
|
235
|
+
- [x] Pane navigation history (`Esc`/`⇧Esc` back/forward)
|
package/bin/tcmd
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# TinyCommand `tcmd` entry point.
|
|
3
|
+
if ! command -v bun >/dev/null 2>&1; then
|
|
4
|
+
printf '%s\n' 'tcmd requires Bun (https://bun.sh). Install Bun, then run `bun x @ssebastien/tcmd` again.' >&2
|
|
5
|
+
exit 1
|
|
6
|
+
fi
|
|
7
|
+
script=$0
|
|
8
|
+
while [ -L "$script" ]; do
|
|
9
|
+
dir=$(CDPATH= cd -- "$(dirname -- "$script")" && pwd)
|
|
10
|
+
link=$(readlink "$script")
|
|
11
|
+
case "$link" in
|
|
12
|
+
/*) script=$link ;;
|
|
13
|
+
*) script=$dir/$link ;;
|
|
14
|
+
esac
|
|
15
|
+
done
|
|
16
|
+
dir=$(CDPATH= cd -- "$(dirname -- "$script")" && pwd)
|
|
17
|
+
exec bun "$dir/../src/js/tcmd/tui/index.js" "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@ssebastien/tcmd",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "TinyCommand: a midnight-commander-like CLI and desktop tool",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/js/tcmd/index.js",
|
|
7
|
+
"files": ["bin/", "src/js/tcmd/", "README.md"],
|
|
8
|
+
"bin": {
|
|
9
|
+
"tcmd": "./bin/tcmd"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test tests",
|
|
13
|
+
"tcmd": "bun src/js/tcmd/tui/index.js",
|
|
14
|
+
"web": "bun run web:build && bun run web:serve",
|
|
15
|
+
"web:build": "bun build src/js/tcmd/web/main.js --outdir public --target browser",
|
|
16
|
+
"web:serve": "bun scripts/serve-web.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@opentui/core": "0.5.9",
|
|
20
|
+
"@ssebastien/select": "^1.2.1",
|
|
21
|
+
"@ssebastien/select-tui": "^0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"happy-dom": "^20.10.6"
|
|
25
|
+
},
|
|
26
|
+
"author": "Sébastien Pierre",
|
|
27
|
+
"license": "BSD",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/sebastien/tcmd.git"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"bun": ">=1.1.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div class="app" tabindex="0" on:keydown="keys">
|
|
2
|
+
<header out="headerView|Header"></header>
|
|
3
|
+
<main class="panes">
|
|
4
|
+
<section class="pane pane-left" out:class="leftPaneClass" on:click="leftId!pane:activate">
|
|
5
|
+
<div class="pane-header">
|
|
6
|
+
<div out="leftCrumbsView|Breadcrumbs"></div>
|
|
7
|
+
<div out="leftFilterView|FilterBar"></div>
|
|
8
|
+
</div>
|
|
9
|
+
<div out="leftListView|FileList"></div>
|
|
10
|
+
<div out="leftDetailView|EntryDetail"></div>
|
|
11
|
+
<div out="leftSummaryView|Summary"></div>
|
|
12
|
+
</section>
|
|
13
|
+
<section class="pane pane-right" out:class="rightPaneClass" on:click="rightId!pane:activate">
|
|
14
|
+
<div class="pane-header">
|
|
15
|
+
<div out="rightCrumbsView|Breadcrumbs"></div>
|
|
16
|
+
<div out="rightFilterView|FilterBar"></div>
|
|
17
|
+
</div>
|
|
18
|
+
<div out="rightListView|FileList"></div>
|
|
19
|
+
<div out="rightDetailView|EntryDetail"></div>
|
|
20
|
+
<div out="rightSummaryView|Summary"></div>
|
|
21
|
+
</section>
|
|
22
|
+
<div when="showPreview" out="previewView|FilePreview"></div>
|
|
23
|
+
</main>
|
|
24
|
+
<footer out="commandBarView|CommandBar"></footer>
|
|
25
|
+
<div when="paletteOpen" out="paletteView|CommandPalette"></div>
|
|
26
|
+
<div when="bookmarkOpen" out="bookmarkView|BookmarkList"></div>
|
|
27
|
+
<div out="confirmView|Confirm"></div>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/components/App
|
|
7
|
+
// Root component: a header, two panes (breadcrumbs + FileList + summary each),
|
|
8
|
+
// an optional preview column, the command bar and the command palette overlay.
|
|
9
|
+
// App is the controller seam: component events (published bubbles) and key
|
|
10
|
+
// chords are translated into `session.dispatch(commandId, payload)` calls; it
|
|
11
|
+
// never mutates model state directly.
|
|
12
|
+
//
|
|
13
|
+
// Environment: `normalizeKey(event)` (optional; identity when omitted) and
|
|
14
|
+
// `quit(self)`. Renderer-specific bindings (pane borders, status color) live in
|
|
15
|
+
// the per-renderer app binding.
|
|
16
|
+
|
|
17
|
+
import { derived, unwrap } from "select/state/cells"
|
|
18
|
+
import { DEFAULT_KEYMAP, matchKeymap } from "../core/keymap.js"
|
|
19
|
+
import { PANEL_FOCUS } from "../core/panel.js"
|
|
20
|
+
|
|
21
|
+
// Function: createInit
|
|
22
|
+
// State initializer binding the root component to a `session`.
|
|
23
|
+
function createInit(session) {
|
|
24
|
+
const left = session.panes.left
|
|
25
|
+
const right = session.panes.right
|
|
26
|
+
// Pane-focus flags so the listing can render the cursor differently in the
|
|
27
|
+
// inactive pane. Shared by both engines.
|
|
28
|
+
const leftActive = derived([session.active], ([active]) => active === "left")
|
|
29
|
+
const rightActive = derived([session.active], ([active]) => active === "right")
|
|
30
|
+
return () => ({
|
|
31
|
+
active: session.active,
|
|
32
|
+
// The preview target entry, so the inspector can show a directory card
|
|
33
|
+
// even when there is no text/card preview.
|
|
34
|
+
appEntry: session.previewEntry,
|
|
35
|
+
appHost: session.host,
|
|
36
|
+
appPath: session.activePath,
|
|
37
|
+
bookmarkIndex: session.bookmarkIndex,
|
|
38
|
+
bookmarkItems: session.bookmarkItems,
|
|
39
|
+
bookmarkMode: session.bookmarkMode,
|
|
40
|
+
bookmarkOpen: session.bookmarkOpen,
|
|
41
|
+
bookmarkQuery: session.bookmarkQuery,
|
|
42
|
+
bookmarkTarget: session.bookmarkTarget,
|
|
43
|
+
commandBarItems: session.commandBarItems,
|
|
44
|
+
confirm: session.confirm,
|
|
45
|
+
dispatch: session.dispatch,
|
|
46
|
+
filtering: session.filtering,
|
|
47
|
+
leftActive,
|
|
48
|
+
leftCursor: left.cursor,
|
|
49
|
+
leftDisk: left.disk,
|
|
50
|
+
leftEntries: left.entries,
|
|
51
|
+
leftEntry: session.leftCursorEntry,
|
|
52
|
+
leftError: left.error,
|
|
53
|
+
leftFilter: left.filter,
|
|
54
|
+
leftId: "left",
|
|
55
|
+
leftLoading: left.loading,
|
|
56
|
+
leftMarked: left.marked,
|
|
57
|
+
leftSegments: left.segments,
|
|
58
|
+
notice: session.notice,
|
|
59
|
+
paletteIndex: session.paletteIndex,
|
|
60
|
+
paletteItems: session.paletteItems,
|
|
61
|
+
paletteMode: session.paletteMode,
|
|
62
|
+
paletteOpen: session.paletteOpen,
|
|
63
|
+
paletteQuery: session.paletteQuery,
|
|
64
|
+
panelCommand: session.panelCommand,
|
|
65
|
+
panelDraft: session.panelDraft,
|
|
66
|
+
panelCursorRow: session.panelCursorRow,
|
|
67
|
+
panelFiltered: session.panelFiltered,
|
|
68
|
+
panelFocus: session.panelFocus,
|
|
69
|
+
panelOpIndex: session.panelOpIndex,
|
|
70
|
+
panelItems: session.panelItems,
|
|
71
|
+
panelOpen: session.panelOpen,
|
|
72
|
+
panelOperation: session.panelOperation,
|
|
73
|
+
panelQuery: session.panelQuery,
|
|
74
|
+
panelRowIndex: session.panelRowIndex,
|
|
75
|
+
panelRows: session.panelRows,
|
|
76
|
+
panelRunnable: session.panelRunnable,
|
|
77
|
+
panelTextual: session.panelTextual,
|
|
78
|
+
panelValue: session.panelValue,
|
|
79
|
+
preview: session.preview,
|
|
80
|
+
rightActive,
|
|
81
|
+
rightCursor: right.cursor,
|
|
82
|
+
rightDisk: right.disk,
|
|
83
|
+
rightEntries: right.entries,
|
|
84
|
+
rightEntry: session.rightCursorEntry,
|
|
85
|
+
rightError: right.error,
|
|
86
|
+
rightFilter: right.filter,
|
|
87
|
+
rightId: "right",
|
|
88
|
+
rightLoading: right.loading,
|
|
89
|
+
rightMarked: right.marked,
|
|
90
|
+
rightSegments: right.segments,
|
|
91
|
+
showPreview: session.showPreview,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Function: createBehaviors
|
|
96
|
+
// Builds the App data/key behavior map for the given renderer environment.
|
|
97
|
+
// Published events are registered separately via `createEvents()` (`.sub`).
|
|
98
|
+
function createBehaviors(env) {
|
|
99
|
+
return {
|
|
100
|
+
// Global key handling: confirm dialogs first, palette mode, then the
|
|
101
|
+
// keymap.
|
|
102
|
+
keys: (self, data, event) => {
|
|
103
|
+
const key = env.normalizeKey ? env.normalizeKey(event) : event
|
|
104
|
+
if (unwrap(data.confirm)) {
|
|
105
|
+
const name = key?.name
|
|
106
|
+
if (name === "y" || name === "return" || name === "enter") data.dispatch("confirm.yes")
|
|
107
|
+
else if (name === "n" || name === "escape") data.dispatch("confirm.no")
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (unwrap(data.paletteOpen)) {
|
|
111
|
+
if (key?.name === "escape") {
|
|
112
|
+
data.dispatch("palette.toggle")
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (key?.name === "up") {
|
|
116
|
+
data.dispatch("palette.up")
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (key?.name === "down") {
|
|
120
|
+
data.dispatch("palette.down")
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (key?.name === "tab") {
|
|
124
|
+
// Shift-Tab walks the palette list backwards.
|
|
125
|
+
data.dispatch(key.shift ? "palette.up" : "palette.complete")
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
129
|
+
self.pub("palette:confirm", `${unwrap(data.paletteQuery) ?? ""}`)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// Let the focused input handle typing.
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (unwrap(data.panelOpen)) {
|
|
136
|
+
const focus = unwrap(data.panelFocus)
|
|
137
|
+
// Escape closes; Tab cycles the focus ring (command ⇄ args);
|
|
138
|
+
// arrows move within the focused column; Enter runs the command.
|
|
139
|
+
if (key?.name === "escape") {
|
|
140
|
+
data.dispatch("panel.cancel")
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (key?.name === "tab") {
|
|
144
|
+
data.dispatch("panel.tab", { delta: key.shift ? -1 : 1 })
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (key?.name === "up") {
|
|
148
|
+
data.dispatch(focus === PANEL_FOCUS.ARGS ? "panel.prevArg" : "panel.up")
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (key?.name === "down") {
|
|
152
|
+
data.dispatch(focus === PANEL_FOCUS.ARGS ? "panel.nextArg" : "panel.down")
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
156
|
+
// A focused textual-argument field owns Enter (its `on:enter`
|
|
157
|
+
// publishes `panel:confirm`), so the command is not run twice.
|
|
158
|
+
if (focus === PANEL_FOCUS.ARGS && unwrap(data.panelTextual)) return
|
|
159
|
+
// Enter runs the highlighted command with the current draft.
|
|
160
|
+
data.dispatch("panel.run")
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
// Space toggles a selection item in the argument column.
|
|
164
|
+
if (key?.name === "space" && focus === PANEL_FOCUS.ARGS && !unwrap(data.panelTextual)) {
|
|
165
|
+
data.dispatch("panel.toggleItem")
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (key?.name === "backspace" && focus === PANEL_FOCUS.ARGS) {
|
|
169
|
+
// Editing the command filter from the argument column.
|
|
170
|
+
data.dispatch("panel.type", { backspace: true })
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
// While the command column is focused, or a textual argument
|
|
174
|
+
// owns the cursor, the focused input handles typing.
|
|
175
|
+
if (focus !== PANEL_FOCUS.ARGS || unwrap(data.panelTextual)) return
|
|
176
|
+
// Typing on a non-textual argument jumps back to the command
|
|
177
|
+
// filter and appends the character.
|
|
178
|
+
if (key?.name && key.name.length === 1 && !key.ctrl && !key.meta) {
|
|
179
|
+
data.dispatch("panel.type", { char: key.name })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (unwrap(data.bookmarkOpen)) {
|
|
185
|
+
const mode = unwrap(data.bookmarkMode)
|
|
186
|
+
if (key?.name === "escape") {
|
|
187
|
+
data.dispatch("bookmark.cancel")
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (mode === "name") {
|
|
191
|
+
// The input owns typing; Enter saves.
|
|
192
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
193
|
+
self.pub("bookmark:confirm")
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
// List mode: arrows move, Enter jumps.
|
|
199
|
+
if (key?.name === "up") {
|
|
200
|
+
data.dispatch("bookmark.up")
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if (key?.name === "down") {
|
|
204
|
+
data.dispatch("bookmark.down")
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
208
|
+
self.pub("bookmark:confirm")
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
// Let the focused input handle typing the fuzzy query.
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
const active = unwrap(data.active)
|
|
215
|
+
if (unwrap(data.filtering) === active) {
|
|
216
|
+
// The filter input owns the keyboard: Esc clears, Enter keeps
|
|
217
|
+
// the filter and returns to the list, arrows move the cursor.
|
|
218
|
+
if (key?.name === "escape") {
|
|
219
|
+
data.dispatch("filter.clear")
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
223
|
+
data.dispatch("filter.accept")
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (key?.name === "tab") {
|
|
227
|
+
// Tab keeps the filter and returns to the list; Shift-Tab
|
|
228
|
+
// switches back to the other pane.
|
|
229
|
+
data.dispatch(key.shift ? "pane.toggle" : "filter.accept")
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (key?.name === "up") {
|
|
233
|
+
data.dispatch("nav.up")
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if (key?.name === "down") {
|
|
237
|
+
data.dispatch("nav.down")
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
// Let the focused filter input handle typing.
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
// Esc clears an accepted filter on the active pane; without a
|
|
244
|
+
// filter (or on Shift-Esc) the keymap decides — Esc steps back
|
|
245
|
+
// through the pane's directory history.
|
|
246
|
+
if (key?.name === "escape" && !key.shift) {
|
|
247
|
+
const filter = active === "left" ? unwrap(data.leftFilter) : unwrap(data.rightFilter)
|
|
248
|
+
if (filter) {
|
|
249
|
+
data.dispatch("filter.clear")
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const id = matchKeymap(DEFAULT_KEYMAP, key)
|
|
254
|
+
if (!id) return
|
|
255
|
+
if (id === "app.quit") {
|
|
256
|
+
env.quit(self)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
// The command panel is a TUI-only view; the web renderer omits the
|
|
260
|
+
// capability so `m` never opens an invisible modal there.
|
|
261
|
+
if (id === "panel.toggle" && !env.panel) return
|
|
262
|
+
data.dispatch(id, { paneId: unwrap(data.active) })
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Function: createEvents
|
|
268
|
+
// App event subscriptions, registered with `.sub(...)`. Handlers receive
|
|
269
|
+
// `(self, data, event)` with the published payload in `event.data`. The
|
|
270
|
+
// environment carries the renderer's `quit` hook, so the command bar's Quit
|
|
271
|
+
// entry takes the same path as the `q` key.
|
|
272
|
+
function createEvents(env = {}) {
|
|
273
|
+
return {
|
|
274
|
+
"commandbar:run": (_self, data, event) => {
|
|
275
|
+
if (event.data === "app.quit") {
|
|
276
|
+
env.quit?.(_self)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (event.data) data.dispatch(event.data)
|
|
280
|
+
},
|
|
281
|
+
"panel:select": (_self, data, event) => {
|
|
282
|
+
// Clicking an operation row moves the cursor to it; clicking an
|
|
283
|
+
// argument/item row focuses the argument column (toggling an item).
|
|
284
|
+
const items = unwrap(data.panelItems) ?? []
|
|
285
|
+
const index = items.findIndex((row) => row.id === event.data)
|
|
286
|
+
if (index >= 0) {
|
|
287
|
+
data.dispatch("panel.setOp", { index })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
const rows = unwrap(data.panelRows) ?? []
|
|
291
|
+
const rowIndex = rows.findIndex((row) => row.key === event.data)
|
|
292
|
+
if (rowIndex < 0) return
|
|
293
|
+
data.dispatch("panel.focusArg")
|
|
294
|
+
data.dispatch("panel.setRow", { index: rowIndex })
|
|
295
|
+
const row = rows[rowIndex]
|
|
296
|
+
if (row?.type === "item") data.dispatch("panel.toggleItem")
|
|
297
|
+
},
|
|
298
|
+
"panel:confirm": (_self, data) => {
|
|
299
|
+
if (!unwrap(data.panelOpen)) return
|
|
300
|
+
data.dispatch("panel.run")
|
|
301
|
+
},
|
|
302
|
+
"crumbs:go": (_self, data, event) => {
|
|
303
|
+
// The "…" truncation marker carries no path.
|
|
304
|
+
if (!event.data?.path) return
|
|
305
|
+
data.dispatch("pane.setPath", event.data)
|
|
306
|
+
},
|
|
307
|
+
"list:select": (_self, data, event) => data.dispatch("list.cursor", event.data),
|
|
308
|
+
"pane:activate": (_self, data, event) => data.dispatch("pane.activate", { paneId: event.data }),
|
|
309
|
+
"bookmark:select": (_self, data, event) => {
|
|
310
|
+
// Clicking a bookmark row selects and jumps to it.
|
|
311
|
+
if (!unwrap(data.bookmarkOpen)) return
|
|
312
|
+
const items = unwrap(data.bookmarkItems) ?? []
|
|
313
|
+
const index = items.findIndex((row) => row.name === event.data)
|
|
314
|
+
if (index < 0) return
|
|
315
|
+
data.dispatch("bookmark.setIndex", { index })
|
|
316
|
+
data.dispatch("bookmark.select")
|
|
317
|
+
},
|
|
318
|
+
"bookmark:confirm": (_self, data) => {
|
|
319
|
+
if (!unwrap(data.bookmarkOpen)) return
|
|
320
|
+
if (unwrap(data.bookmarkMode) === "name") data.dispatch("bookmark.save")
|
|
321
|
+
else data.dispatch("bookmark.select")
|
|
322
|
+
},
|
|
323
|
+
"palette:confirm": (_self, data, event) => {
|
|
324
|
+
// The global key handler and the input's on:enter can both fire;
|
|
325
|
+
// only the first one (while the palette is open) acts.
|
|
326
|
+
if (!unwrap(data.paletteOpen)) return
|
|
327
|
+
if (unwrap(data.paletteMode) === "script") {
|
|
328
|
+
// Submit the typed line: run it, or complete the highlighted row
|
|
329
|
+
// when the input is empty.
|
|
330
|
+
data.dispatch("palette.submit")
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const items = unwrap(data.paletteItems) ?? []
|
|
334
|
+
const index = Math.max(0, Math.min(unwrap(data.paletteIndex) ?? 0, items.length - 1))
|
|
335
|
+
const match = items[index] ?? items[0]
|
|
336
|
+
if (match?.id) data.dispatch(match.id)
|
|
337
|
+
data.dispatch("palette.toggle")
|
|
338
|
+
},
|
|
339
|
+
"palette:run": (_self, data, event) => {
|
|
340
|
+
if (!event.data) return
|
|
341
|
+
if (unwrap(data.paletteMode) === "script") {
|
|
342
|
+
// Clicking a script row completes it; running stays on Enter.
|
|
343
|
+
const item = (unwrap(data.paletteItems) ?? []).find((row) => row.id === event.data)
|
|
344
|
+
if (item?.completion) data.dispatch("palette.setQuery", { query: item.completion })
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
data.dispatch(event.data)
|
|
348
|
+
data.dispatch("palette.toggle")
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export { createBehaviors, createEvents, createInit }
|
|
354
|
+
|
|
355
|
+
// EOF
|