@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
package/README.md CHANGED
@@ -1,3 +1,235 @@
1
- # Temporary Holding Version
1
+ # TinyCommand
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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,7 @@
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 `npm x tcmd` again.' >&2
5
+ exit 1
6
+ fi
7
+ exec bun "$(dirname "$0")/../src/js/tcmd/tui/index.js" "$@"
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
- "name": "@ssebastien/tcmd",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@ssebastien/tcmd",
3
+ "version": "0.1.0",
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