@ssebastien/tcmd 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +7 -0
  3. package/package.json +37 -5
  4. package/src/js/tcmd/components/App.html +28 -0
  5. package/src/js/tcmd/components/App.js +355 -0
  6. package/src/js/tcmd/components/App.xml +98 -0
  7. package/src/js/tcmd/components/BookmarkList.html +12 -0
  8. package/src/js/tcmd/components/BookmarkList.js +73 -0
  9. package/src/js/tcmd/components/BookmarkList.xml +18 -0
  10. package/src/js/tcmd/components/BookmarkRow.html +5 -0
  11. package/src/js/tcmd/components/BookmarkRow.js +40 -0
  12. package/src/js/tcmd/components/BookmarkRow.xml +10 -0
  13. package/src/js/tcmd/components/Breadcrumbs.js +27 -0
  14. package/src/js/tcmd/components/CommandArgRow.js +90 -0
  15. package/src/js/tcmd/components/CommandArgRow.xml +5 -0
  16. package/src/js/tcmd/components/CommandBar.html +4 -0
  17. package/src/js/tcmd/components/CommandBar.js +126 -0
  18. package/src/js/tcmd/components/CommandBar.xml +4 -0
  19. package/src/js/tcmd/components/CommandItem.html +3 -0
  20. package/src/js/tcmd/components/CommandItem.js +78 -0
  21. package/src/js/tcmd/components/CommandItem.xml +6 -0
  22. package/src/js/tcmd/components/CommandPalette.html +7 -0
  23. package/src/js/tcmd/components/CommandPalette.js +58 -0
  24. package/src/js/tcmd/components/CommandPalette.xml +16 -0
  25. package/src/js/tcmd/components/CommandPanel.js +98 -0
  26. package/src/js/tcmd/components/CommandPanel.xml +35 -0
  27. package/src/js/tcmd/components/Confirm.html +3 -0
  28. package/src/js/tcmd/components/Confirm.js +36 -0
  29. package/src/js/tcmd/components/Confirm.xml +7 -0
  30. package/src/js/tcmd/components/Crumb.html +4 -0
  31. package/src/js/tcmd/components/Crumb.js +48 -0
  32. package/src/js/tcmd/components/Crumb.xml +4 -0
  33. package/src/js/tcmd/components/EntryDetail.html +15 -0
  34. package/src/js/tcmd/components/EntryDetail.js +194 -0
  35. package/src/js/tcmd/components/EntryDetail.xml +16 -0
  36. package/src/js/tcmd/components/FileList.html +1 -0
  37. package/src/js/tcmd/components/FileList.js +41 -0
  38. package/src/js/tcmd/components/FileList.xml +7 -0
  39. package/src/js/tcmd/components/FilePreview.html +5 -0
  40. package/src/js/tcmd/components/FilePreview.js +110 -0
  41. package/src/js/tcmd/components/FilePreview.xml +10 -0
  42. package/src/js/tcmd/components/FileRow.html +5 -0
  43. package/src/js/tcmd/components/FileRow.js +135 -0
  44. package/src/js/tcmd/components/FileRow.xml +10 -0
  45. package/src/js/tcmd/components/FilterBar.html +6 -0
  46. package/src/js/tcmd/components/FilterBar.js +50 -0
  47. package/src/js/tcmd/components/FilterBar.xml +6 -0
  48. package/src/js/tcmd/components/Help.xml +11 -0
  49. package/src/js/tcmd/components/PaletteRow.html +3 -0
  50. package/src/js/tcmd/components/PaletteRow.js +43 -0
  51. package/src/js/tcmd/components/PaletteRow.xml +4 -0
  52. package/src/js/tcmd/components/RowList.html +1 -0
  53. package/src/js/tcmd/components/RowList.xml +1 -0
  54. package/src/js/tcmd/components/Summary.html +3 -0
  55. package/src/js/tcmd/components/Summary.js +77 -0
  56. package/src/js/tcmd/components/Summary.xml +3 -0
  57. package/src/js/tcmd/components/bind.js +20 -0
  58. package/src/js/tcmd/components/format.js +155 -0
  59. package/src/js/tcmd/components/icons.js +185 -0
  60. package/src/js/tcmd/components/index.js +41 -0
  61. package/src/js/tcmd/components/rows.js +212 -0
  62. package/src/js/tcmd/core/bookmarks.js +175 -0
  63. package/src/js/tcmd/core/commands.js +930 -0
  64. package/src/js/tcmd/core/directory.js +194 -0
  65. package/src/js/tcmd/core/entries.js +88 -0
  66. package/src/js/tcmd/core/fs.js +266 -0
  67. package/src/js/tcmd/core/index.js +39 -0
  68. package/src/js/tcmd/core/keymap.js +60 -0
  69. package/src/js/tcmd/core/operations.js +453 -0
  70. package/src/js/tcmd/core/os.js +128 -0
  71. package/src/js/tcmd/core/pane.js +74 -0
  72. package/src/js/tcmd/core/panel.js +383 -0
  73. package/src/js/tcmd/core/path.js +57 -0
  74. package/src/js/tcmd/core/script.js +186 -0
  75. package/src/js/tcmd/core/selection.js +43 -0
  76. package/src/js/tcmd/core/session.js +405 -0
  77. package/src/js/tcmd/index.js +53 -0
  78. package/src/js/tcmd/tui/app.js +140 -0
  79. package/src/js/tcmd/tui/catalogue.js +300 -0
  80. package/src/js/tcmd/tui/frame.js +269 -0
  81. package/src/js/tcmd/tui/help.js +32 -0
  82. package/src/js/tcmd/tui/index.js +104 -0
  83. package/src/js/tcmd/tui/theme.js +177 -0
  84. package/src/js/tcmd/vendor/fzy.js +243 -0
  85. package/src/js/tcmd/web/app.js +154 -0
  86. package/src/js/tcmd/web/catalogue.js +159 -0
  87. package/src/js/tcmd/web/header.html +1 -0
  88. package/src/js/tcmd/web/header.js +38 -0
  89. package/src/js/tcmd/web/http.js +79 -0
  90. package/src/js/tcmd/web/index.js +22 -0
  91. package/src/js/tcmd/web/main.js +151 -0
  92. package/src/js/tcmd/web/opfs.js +282 -0
  93. package/src/js/tcmd/web/seed.js +90 -0
  94. package/src/js/tcmd/web/server.js +191 -0
  95. package/src/js/tcmd/web/theme.js +23 -0
@@ -0,0 +1,405 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/core/session
7
+ // A Session wires two panes plus cross-pane UI state (active pane, preview,
8
+ // command palette, bookmarks) into one dispatchable model. Everything reactive
9
+ // is a cell or derived cell; views subscribe, commands mutate.
10
+
11
+ import { cell, derived, effect } from "select/state/cells"
12
+ import { createBookmarks } from "./bookmarks.js"
13
+ import { commandRow, createDispatch, getCommandBarItems, getPaletteCommands } from "./commands.js"
14
+ import { createNullOs } from "./os.js"
15
+ import { PANEL_FOCUS, bindArgs, filterPanelOperations, isTextualRow, panelCommand, panelOperations, panelRowList, panelRunnable, rowAt, withArgValue } from "./panel.js"
16
+ import { basename, join } from "./path.js"
17
+ import { PANE_BUFFERS } from "./pane.js"
18
+ import { completionToken, scriptItems } from "./script.js"
19
+ import { hasMatch, score } from "../vendor/fzy.js"
20
+
21
+ const PREVIEW_BYTES = 65536
22
+
23
+ // Function: createSession
24
+ // Creates the shared model for a two-pane browser.
25
+ //
26
+ // Example:
27
+ // ```javascript
28
+ // const session = createSession({ fs: createNodeFs(), left: pane, right: pane })
29
+ // await session.load()
30
+ // session.dispatch("pane.toggle")
31
+ // ```
32
+ function createSession({ fs, os, left, right, host, bookmarks } = {}) {
33
+ const session = {}
34
+ session.fs = fs
35
+ session.os = os ?? createNullOs()
36
+ session.panes = { left, right }
37
+ // Shown in the header title; empty when the environment has no hostname.
38
+ session.host = host ?? ""
39
+ // Bookmark model (named paths). The store defaults to in-memory.
40
+ session.bookmarks = bookmarks ?? createBookmarks()
41
+ session.active = cell("left")
42
+ // The active pane's current directory, tracked by the header title.
43
+ session.activePath = derived([session.active, left.path, right.path], ([active, l, r]) =>
44
+ active === "left" ? l : r,
45
+ )
46
+ session.showPreview = cell(true)
47
+ session.paletteOpen = cell(false)
48
+ session.paletteMode = cell("ui")
49
+ session.paletteQuery = cell("")
50
+ session.paletteIndex = cell(0)
51
+ session.notice = cell(null)
52
+ session.confirm = cell(null)
53
+ // The pane whose filter input currently owns the keyboard, or null.
54
+ session.filtering = cell(null)
55
+
56
+ // Bookmark overlay: `bookmarkMode` is "name" while recording (an input for
57
+ // the new bookmark's name) or "list" while jumping (a fuzzy picker).
58
+ session.bookmarkOpen = cell(false)
59
+ session.bookmarkMode = cell("list")
60
+ session.bookmarkQuery = cell("")
61
+ session.bookmarkIndex = cell(0)
62
+ // The path being recorded while in "name" mode.
63
+ session.bookmarkTarget = cell(null)
64
+
65
+ // Command panel (`m` / `:`): a structured Commando-style view over the
66
+ // operations and their argument bindings. `panelQuery` filters the
67
+ // operation list by typed name; `panelOpIndex` selects the operation in
68
+ // that filtered list; `panelRowIndex` is the cursor in the flattened
69
+ // argument rows (headers + items); `panelFocus` is a `PANEL_FOCUS` token
70
+ // ("ops": typing filters operations, "args": an argument owns the
71
+ // keyboard). `panelDraft` holds the transient bindings the user tweaks —
72
+ // it never mutates the real pane marks.
73
+ session.panelOpen = cell(false)
74
+ session.panelQuery = cell("")
75
+ session.panelOpIndex = cell(0)
76
+ session.panelRowIndex = cell(0)
77
+ session.panelFocus = cell(PANEL_FOCUS.OPS)
78
+ session.panelDraft = cell([])
79
+ session.panelOps = cell(panelOperations())
80
+ // The command panel view is TUI-only; the web view disables it at startup
81
+ // so no dispatch path (key, palette, command bar) opens an invisible modal.
82
+ session.panelEnabled = cell(true)
83
+
84
+ // The command palette keeps its cursor on the first match whenever the
85
+ // query changes.
86
+ effect([session.paletteQuery], () => session.paletteIndex.set(0))
87
+ // The bookmark picker likewise.
88
+ effect([session.bookmarkQuery], () => session.bookmarkIndex.set(0))
89
+
90
+ // Filter editing is bound to the active pane: switching pane drops the
91
+ // input so keystrokes never leak into another pane's filter.
92
+ effect([session.active], () => session.filtering.set(null))
93
+
94
+ // The entry under the cursor of each pane, as a small plain object. The
95
+ // stat details (ctime, mode, uid, gid) come from the directory's lazy
96
+ // `cursorStat`, so the pane detail line updates as the cursor moves.
97
+ session.leftCursorEntry = derived([left.entries, left.cursor, left.cursorStat], ([entries, cursor, stat]) =>
98
+ entrySummary(stat ?? entries[cursor]),
99
+ )
100
+ session.rightCursorEntry = derived([right.entries, right.cursor, right.cursorStat], ([entries, cursor, stat]) =>
101
+ entrySummary(stat ?? entries[cursor]),
102
+ )
103
+
104
+ // Preview target: a `view`/`stat` pin, or the active pane's cursor entry.
105
+ session.previewTarget = cell(null)
106
+ session.previewEntry = derived(
107
+ [session.active, session.leftCursorEntry, session.rightCursorEntry, session.previewTarget],
108
+ ([active, l, r, target]) => target ?? (active === "left" ? l : r),
109
+ )
110
+
111
+ // Moving either cursor resumes following the cursor in the preview.
112
+ effect([session.leftCursorEntry, session.rightCursorEntry], () => session.previewTarget.set(null))
113
+
114
+ session._previewToken = 0
115
+
116
+ // Preview payload: text head for files, metadata for the rest; `stat`
117
+ // targets carry `details` and render as a card even for directories.
118
+ session.preview = cell(null)
119
+ session._stopPreviewWatch = effect([session.previewEntry, session.showPreview], async ([entry, show]) => {
120
+ if (!show || !entry) {
121
+ session.preview.set(null)
122
+ return
123
+ }
124
+ const kind = entry.isDir ? "directory" : previewKind(entry)
125
+ if (entry.details) {
126
+ session.preview.set({
127
+ kind,
128
+ title: basename(entry.path),
129
+ size: entry.size,
130
+ ext: entry.ext,
131
+ details: entry.details,
132
+ text: null,
133
+ })
134
+ return
135
+ }
136
+ if (entry.isDir) {
137
+ session.preview.set(null)
138
+ return
139
+ }
140
+ const token = ++session._previewToken
141
+ const head =
142
+ kind === "text" ? await fs.readTextHead(entry.path, PREVIEW_BYTES).catch(() => null) : null
143
+ if (token !== session._previewToken) return
144
+ session.preview.set({
145
+ kind,
146
+ title: basename(entry.path),
147
+ size: entry.size,
148
+ ext: entry.ext,
149
+ text: head === null || hasBinaryByte(head) ? null : head,
150
+ })
151
+ })
152
+
153
+ // Selection filesets: each pane's non-empty mark buffer ($A for the source
154
+ // pane, $B for the destination), plus $FILE for the entry the preview is
155
+ // following. These are the values scripting-mode completions offer.
156
+ session.filesets = derived(
157
+ [left.marked, right.marked, session.previewEntry],
158
+ ([leftMarks, rightMarks, previewEntry]) => {
159
+ const sets = []
160
+ if (leftMarks && leftMarks.size) {
161
+ const label = PANE_BUFFERS.left
162
+ sets.push({ id: `$${label}`, label, paths: [...leftMarks], count: leftMarks.size })
163
+ }
164
+ if (rightMarks && rightMarks.size) {
165
+ const label = PANE_BUFFERS.right
166
+ sets.push({ id: `$${label}`, label, paths: [...rightMarks], count: rightMarks.size })
167
+ }
168
+ if (previewEntry && !previewEntry.isDir) {
169
+ sets.push({
170
+ id: "$FILE",
171
+ label: "FILE",
172
+ paths: [previewEntry.path],
173
+ count: 1,
174
+ })
175
+ }
176
+ return sets
177
+ },
178
+ )
179
+
180
+ // The active pane's entries, used for script path completion.
181
+ session.activeEntries = derived(
182
+ [session.active, left.entries, right.entries],
183
+ ([active, l, r]) => (active === "left" ? l : r),
184
+ )
185
+
186
+ // Script path completion: the entries of the directory being typed (a
187
+ // token containing "/"), or the active pane's entries otherwise.
188
+ session.completionEntries = cell([])
189
+ session._completionToken = 0
190
+ effect(
191
+ [session.paletteMode, session.paletteQuery, session.activeEntries, session.active],
192
+ async ([mode, query, activeEntries, active]) => {
193
+ if (mode !== "script") {
194
+ session.completionEntries.set([])
195
+ return
196
+ }
197
+ const token = completionToken(query)
198
+ if (!token) {
199
+ session.completionEntries.set(activeEntries ?? [])
200
+ return
201
+ }
202
+ const base = session.panes[active]?.path.value ?? session.fs.cwd()
203
+ const dir = token.dir.startsWith("/") ? token.dir : join(base, token.dir)
204
+ const mine = ++session._completionToken
205
+ const entries = await session.fs.readDir(dir).catch(() => [])
206
+ if (mine !== session._completionToken) return
207
+ session.completionEntries.set(entries)
208
+ },
209
+ )
210
+
211
+ // Bookmark picker rows: the bookmarks fuzzily matched by name or path,
212
+ // ranked by fzy score. Only meaningful in "list" mode.
213
+ session.bookmarkItems = derived(
214
+ [session.bookmarks.entries, session.bookmarkQuery, session.bookmarkMode],
215
+ ([entries, query, mode]) => {
216
+ if (mode !== "list") return []
217
+ const rows = Object.entries(entries ?? {}).map(([name, entry]) => ({ name, ...entry }))
218
+ const q = `${query ?? ""}`.trim()
219
+ if (!q) return rows.sort((a, b) => a.name.localeCompare(b.name))
220
+ const ranked = []
221
+ for (const row of rows) {
222
+ const best = Math.max(
223
+ hasMatch(q, row.name) ? score(q, row.name) : -Infinity,
224
+ hasMatch(q, row.path) ? score(q, row.path) : -Infinity,
225
+ )
226
+ if (best > -Infinity) ranked.push({ row, score: best })
227
+ }
228
+ ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : a.row.name.localeCompare(b.row.name)))
229
+ return ranked.map((match) => match.row)
230
+ },
231
+ )
232
+
233
+ // Command bar rows: context-dependent key hints (see core/commands). The
234
+ // TUI-only panel button is dropped when the renderer disables the panel.
235
+ session.commandBarItems = derived(
236
+ [
237
+ session.paletteOpen,
238
+ session.paletteMode,
239
+ session.filtering,
240
+ session.bookmarkOpen,
241
+ session.bookmarkMode,
242
+ session.panelOpen,
243
+ session.panelEnabled,
244
+ ],
245
+ ([open, mode, filtering, bookmarkOpen, bookmarkMode, panelOpen, panelEnabled]) => {
246
+ const items = getCommandBarItems(open, mode, filtering, bookmarkOpen, bookmarkMode, panelOpen)
247
+ if (panelEnabled) return items
248
+ return items.filter((item) => item.id !== "panel.toggle")
249
+ },
250
+ )
251
+
252
+ // The operation list filtered by the typed query; `panelOpIndex` indexes
253
+ // into this filtered list.
254
+ session.panelFiltered = derived(
255
+ [session.panelOps, session.panelQuery],
256
+ ([operations, query]) => filterPanelOperations(operations, query),
257
+ )
258
+ // The operation the command panel is editing, and its bound arguments.
259
+ session.panelOperation = derived(
260
+ [session.panelFiltered, session.panelOpIndex],
261
+ ([operations, index]) => operations[Math.max(0, Math.min(index, operations.length - 1))] ?? null,
262
+ )
263
+ session.panelRows = derived([session.panelDraft], ([draft]) => panelRowList(draft))
264
+ // The assembled command line the draft maps to, and whether it is runnable.
265
+ // The preview answers "what will this run" as the shell would see it,
266
+ // prefixed with the operation name; a `«NAME»` marks a required argument
267
+ // still to fill.
268
+ session.panelCommand = derived(
269
+ [session.panelDraft, session.panelOperation],
270
+ ([draft, op]) => {
271
+ const args = panelCommand(draft)
272
+ const name = op?.name ?? ""
273
+ return args ? `${name} ${args}` : name
274
+ },
275
+ )
276
+ session.panelRunnable = derived([session.panelDraft], ([draft]) => panelRunnable(draft))
277
+ // The cursor row and whether it is a textual argument (so the panel focuses
278
+ // its text field).
279
+ session.panelCursorRow = derived(
280
+ [session.panelRows, session.panelRowIndex],
281
+ ([rows, row]) => rowAt(rows, row),
282
+ )
283
+ session.panelTextual = derived([session.panelCursorRow], ([row]) => isTextualRow(row))
284
+ // The text the input edits. A plain cell (the input writes through it); an
285
+ // effect copies the focused binding's value in whenever the cursor lands on
286
+ // a textual argument, and another folds typing back into the transient
287
+ // draft so a value survives moving to another argument.
288
+ session.panelValue = cell("")
289
+ effect([session.panelCursorRow], ([row]) => {
290
+ if (isTextualRow(row)) session.panelValue.set(`${row.value ?? ""}`)
291
+ })
292
+ effect([session.panelValue], ([value]) => {
293
+ const row = session.panelCursorRow.value
294
+ if (isTextualRow(row)) {
295
+ session.panelDraft.set(withArgValue(session.panelDraft.value, row.argIndex, value))
296
+ }
297
+ })
298
+
299
+ // A picker-row projection for the operation list (left column), over the
300
+ // query-filtered operations. `summary` is the argument list the row shows
301
+ // — the signature with the leading operation name stripped, so the name is
302
+ // not repeated next to the title.
303
+ session.panelItems = derived([session.panelFiltered], ([operations]) =>
304
+ operations.map((op) => ({
305
+ id: `op:${op.name}`,
306
+ title: op.name,
307
+ summary: `${op.signature ?? ""}`.replace(/^\S+\s*/, ""),
308
+ risk: op.risk ?? null,
309
+ })),
310
+ )
311
+
312
+ // Re-bind the panel draft when the panel opens or the selected operation
313
+ // changes. The panel is modal, so the pane context does not change
314
+ // underneath it; an explicit toggle owns the draft while it is open.
315
+ effect([session.panelOpen, session.panelOperation], ([open, op]) => {
316
+ if (!open) return
317
+ session.panelDraft.set(bindArgs(session, op))
318
+ })
319
+ // Selecting another operation resets the argument cursor.
320
+ effect([session.panelOperation], () => {
321
+ session.panelRowIndex.set(0)
322
+ })
323
+ // Typing a query re-anchors the operation cursor on the first match.
324
+ effect([session.panelQuery], () => {
325
+ session.panelOpIndex.set(0)
326
+ })
327
+
328
+ // Command palette rows: UI actions by default; in scripting mode, the
329
+ // operations plus the available filesets. Both are filtered by the query.
330
+ session.paletteCommands = cell(getPaletteCommands().map(commandRow))
331
+ session.paletteItems = derived(
332
+ [
333
+ session.paletteMode,
334
+ session.paletteQuery,
335
+ session.paletteCommands,
336
+ session.filesets,
337
+ session.completionEntries,
338
+ ],
339
+ ([mode, query, commands, filesets, entries]) =>
340
+ mode === "script"
341
+ ? scriptItems(query, filesets, { entries })
342
+ : filterCommandRows(commands, query),
343
+ )
344
+
345
+ session.dispatch = createDispatch(session)
346
+ session.load = () => load(session)
347
+ return session
348
+ }
349
+
350
+ // Function: load
351
+ // Reads the initial listing for both panes and the persisted bookmarks.
352
+ async function load(session) {
353
+ await Promise.all([
354
+ session.panes.left.refresh(),
355
+ session.panes.right.refresh(),
356
+ session.bookmarks.load?.(),
357
+ ])
358
+ }
359
+
360
+ function entrySummary(entry) {
361
+ if (!entry) return null
362
+ return {
363
+ name: entry.name,
364
+ path: entry.path,
365
+ isDir: entry.isDir,
366
+ isSymlink: entry.isSymlink,
367
+ isDevice: entry.isDevice,
368
+ size: entry.size,
369
+ mtimeMs: entry.mtimeMs,
370
+ ctimeMs: entry.ctimeMs,
371
+ mode: entry.mode,
372
+ uid: entry.uid,
373
+ gid: entry.gid,
374
+ owner: entry.owner,
375
+ ext: entry.ext,
376
+ }
377
+ }
378
+
379
+ // Function: filterCommandRows
380
+ // Case-insensitive title filter; an empty query keeps every row.
381
+ function filterCommandRows(rows, query) {
382
+ const q = `${query ?? ""}`.trim().toLowerCase()
383
+ if (!q) return rows
384
+ return rows.filter((row) => row.title.toLowerCase().includes(q))
385
+ }
386
+
387
+ // Function: previewKind
388
+ // Preview classification for file entries; directories are not previewed.
389
+ function previewKind(entry) {
390
+ const image = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]
391
+ const media = ["mp3", "wav", "ogg", "flac", "mp4", "webm", "mkv", "mov", "pdf"]
392
+ if (entry.ext && image.includes(entry.ext)) return "image"
393
+ if (entry.ext && media.includes(entry.ext)) return "media"
394
+ return "text"
395
+ }
396
+
397
+ function hasBinaryByte(text) {
398
+ for (let i = 0; i < 2048 && i < text.length; i++) {
399
+ const code = text.charCodeAt(i)
400
+ if (code < 9 || (code > 13 && code < 32)) return true
401
+ }
402
+ return false
403
+ }
404
+
405
+ export { createSession, load }
@@ -0,0 +1,53 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd
7
+ // Public surface of the UI-agnostic core. The TUI lives in `tcmd/tui`, a web
8
+ // UI will live in `tcmd/web`; both consume only what is exported here.
9
+ import * as core from "./core/index.js"
10
+
11
+ export default core
12
+ export {
13
+ DEFAULT_KEYMAP,
14
+ PANE_BUFFERS,
15
+ PANE_LABELS,
16
+ SEP,
17
+ basename,
18
+ clampCursor,
19
+ createBookmarks,
20
+ createDispatch,
21
+ createFileBookmarkStore,
22
+ createMemoryBookmarkStore,
23
+ createNodeFs,
24
+ createNodeOs,
25
+ createNullOs,
26
+ createPane,
27
+ createSession,
28
+ currentEntry,
29
+ expandAll,
30
+ expandArg,
31
+ extname,
32
+ filterEntries,
33
+ getCommand,
34
+ getCommands,
35
+ getOperation,
36
+ getOperations,
37
+ join,
38
+ load,
39
+ matchKeymap,
40
+ moveCursor,
41
+ normalize,
42
+ parent,
43
+ parseLine,
44
+ prefix,
45
+ rankEntries,
46
+ runLine,
47
+ scriptItems,
48
+ sortEntries,
49
+ split,
50
+ toggleMark,
51
+ tokenize,
52
+ viewEntries,
53
+ } from "./core/index.js"
@@ -0,0 +1,140 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/tui/app
7
+ // Binds the shared App controller (`components/App.js`) to the TUI layout
8
+ // (`components/App.xml`) and to the select.tui `ui` runtime.
9
+ //
10
+ // The TUI layers two presentation-only cells on top of the shared controller:
11
+ // `size` (viewport dimensions from the renderer's resize events) and `help`
12
+ // (the `?` overlay). A third, `viewer`, tracks the fullscreen inspector used
13
+ // below the inline-inspector breakpoint. None of them exist in the web view.
14
+
15
+ import { cell, derived, unwrap } from "select/state/cells"
16
+ import { ui } from "select.tui"
17
+ import appXml from "../components/App.xml" with { type: "text" }
18
+ import { elideCrumbs } from "../components/format.js"
19
+ import { appBehaviors, appEvents, appInit } from "../components/index.js"
20
+ import { registerComponents } from "./catalogue.js"
21
+ import { createFrameBehaviors, INSPECTOR_INLINE_WIDTH, isNarrow } from "./frame.js"
22
+ import { tuiTheme } from "./theme.js"
23
+
24
+ // Function: createAppComponent
25
+ // Builds the root component bound to a `session`.
26
+ //
27
+ // Options:
28
+ // - `size`: a `{ width, height }` cell fed by renderer resize events.
29
+ // - `help`: the `?` overlay visibility cell.
30
+ //
31
+ // Example:
32
+ // ```javascript
33
+ // const App = createAppComponent(session, { size })
34
+ // await render(App, { useMouse: true })
35
+ // ```
36
+ function createAppComponent(session, options = {}) {
37
+ registerComponents()
38
+ const size = options.size ?? cell({ width: 0, height: 0 })
39
+ const help = options.help ?? cell(false)
40
+ const viewer = options.viewer ?? cell(false)
41
+
42
+ // Columns available to a pane's breadcrumb strip: the viewport shared by
43
+ // the panes (plus the inline inspector), minus the pane border, the
44
+ // header inset, the SRC/DST slot and a safety column. `0` (unknown, web)
45
+ // disables the collapse.
46
+ const crumbWidth = derived([size, session.showPreview], ([value, showPreview]) => {
47
+ const width = Math.floor(value?.width ?? 0)
48
+ if (width <= 0) return 0
49
+ const columns = showPreview && width >= INSPECTOR_INLINE_WIDTH ? 3 : 2
50
+ return Math.max(8, Math.floor(width / columns) - 8)
51
+ })
52
+ // The elided breadcrumb segments for each pane (presentation-only view of
53
+ // the pane's own segment list; paths stay intact for clicks).
54
+ const leftCrumbs = derived([session.panes.left.segments, crumbWidth], ([segments, width]) =>
55
+ elideCrumbs(segments, width),
56
+ )
57
+ const rightCrumbs = derived([session.panes.right.segments, crumbWidth], ([segments, width]) =>
58
+ elideCrumbs(segments, width),
59
+ )
60
+
61
+ const quit = options.quit ?? ((self) => {
62
+ // Destroying the renderer from within a keypress deadlocks the native
63
+ // render loop; defer past the current frame.
64
+ setTimeout(() => {
65
+ self.quit()
66
+ process.exit(0)
67
+ }, 100)
68
+ })
69
+
70
+ const baseKeys = appBehaviors({ panel: true, quit }).keys
71
+ const frame = createFrameBehaviors({ theme: tuiTheme })
72
+
73
+ return ui
74
+ .component("App")
75
+ .init(() => ({
76
+ ...appInit(session)(),
77
+ uiSize: size,
78
+ helpOpen: help,
79
+ viewerOpen: viewer,
80
+ leftSegments: leftCrumbs,
81
+ rightSegments: rightCrumbs,
82
+ }))
83
+ .does({
84
+ ...frame,
85
+ keys: (self, data, event) => {
86
+ const key = event
87
+ const name = key?.name
88
+ const isQuestion = name === "?" || (name === "/" && key?.shift)
89
+ const busy =
90
+ unwrap(data.paletteOpen) ||
91
+ unwrap(data.confirm) ||
92
+ unwrap(data.filtering) ||
93
+ unwrap(data.bookmarkOpen) ||
94
+ unwrap(data.panelOpen)
95
+
96
+ // Help overlay owns the keyboard while open.
97
+ if (unwrap(data.helpOpen)) {
98
+ if (name === "escape" || name === "q" || isQuestion) help.set(false)
99
+ return
100
+ }
101
+ if (!busy && isQuestion) {
102
+ help.set(true)
103
+ return
104
+ }
105
+ // The fullscreen viewer: Esc closes, O/E act on the file.
106
+ if (viewer.value && isNarrow(data, self)) {
107
+ if (name === "escape") {
108
+ viewer.set(false)
109
+ return
110
+ }
111
+ if (name === "o" && key.shift) return data.dispatch("file.open", { paneId: unwrap(data.active) })
112
+ if (name === "e" && key.shift) return data.dispatch("file.edit", { paneId: unwrap(data.active) })
113
+ return
114
+ }
115
+ // Below the inline breakpoint `v` toggles the viewer overlay
116
+ // instead of the side panel.
117
+ if (!busy && name === "v" && isNarrow(data, self)) {
118
+ viewer.set(!viewer.value)
119
+ return
120
+ }
121
+ return baseKeys(self, data, event)
122
+ },
123
+ // TUI-only presentation of the shared App state.
124
+ leftBorderFg: (_self, data) =>
125
+ unwrap(data.active) === "left" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
126
+ rightBorderFg: (_self, data) =>
127
+ unwrap(data.active) === "right" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
128
+ // The header rule carries the same focus tint as the pane border.
129
+ leftHeadRuleFg: (_self, data) =>
130
+ unwrap(data.active) === "left" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
131
+ rightHeadRuleFg: (_self, data) =>
132
+ unwrap(data.active) === "right" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
133
+ })
134
+ .sub(appEvents({ quit }))
135
+ .using(appXml)
136
+ }
137
+
138
+ export { createAppComponent }
139
+
140
+ // EOF