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