@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,159 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/web/catalogue
|
|
7
|
+
// Binds the shared component logic in `tcmd/components` to the web (`.html`)
|
|
8
|
+
// presentations using select.js. Requires a DOM; import this module only in a
|
|
9
|
+
// browser (or a happy-dom test environment).
|
|
10
|
+
//
|
|
11
|
+
// List rows are rendered with select.js's keyed `Component.map` primitive; the
|
|
12
|
+
// row behaviors are shared with the TUI presentation. Components sharing a
|
|
13
|
+
// presentation bind the same source (`Breadcrumbs` uses `RowList.html`).
|
|
14
|
+
|
|
15
|
+
import { ui } from "select/ui"
|
|
16
|
+
import bookmarkListHtml from "../components/BookmarkList.html" with { type: "text" }
|
|
17
|
+
import bookmarkRowHtml from "../components/BookmarkRow.html" with { type: "text" }
|
|
18
|
+
import commandBarHtml from "../components/CommandBar.html" with { type: "text" }
|
|
19
|
+
import commandItemHtml from "../components/CommandItem.html" with { type: "text" }
|
|
20
|
+
import commandPaletteHtml from "../components/CommandPalette.html" with { type: "text" }
|
|
21
|
+
import confirmHtml from "../components/Confirm.html" with { type: "text" }
|
|
22
|
+
import crumbHtml from "../components/Crumb.html" with { type: "text" }
|
|
23
|
+
import entryDetailHtml from "../components/EntryDetail.html" with { type: "text" }
|
|
24
|
+
import fileListHtml from "../components/FileList.html" with { type: "text" }
|
|
25
|
+
import filePreviewHtml from "../components/FilePreview.html" with { type: "text" }
|
|
26
|
+
import fileRowHtml from "../components/FileRow.html" with { type: "text" }
|
|
27
|
+
import filterBarHtml from "../components/FilterBar.html" with { type: "text" }
|
|
28
|
+
import headerHtml from "./header.html" with { type: "text" }
|
|
29
|
+
import paletteRowHtml from "../components/PaletteRow.html" with { type: "text" }
|
|
30
|
+
import rowListHtml from "../components/RowList.html" with { type: "text" }
|
|
31
|
+
import summaryHtml from "../components/Summary.html" with { type: "text" }
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
bind,
|
|
35
|
+
bookmarkListBehaviors,
|
|
36
|
+
bookmarkRowBehaviors,
|
|
37
|
+
bookmarkRows,
|
|
38
|
+
breadcrumbsBehaviors,
|
|
39
|
+
commandBarBehaviors,
|
|
40
|
+
commandItemBehaviors,
|
|
41
|
+
commandItemRows,
|
|
42
|
+
commandPaletteBehaviors,
|
|
43
|
+
confirmBehaviors,
|
|
44
|
+
crumbBehaviors,
|
|
45
|
+
crumbRows,
|
|
46
|
+
entryDetailBehaviors,
|
|
47
|
+
fileListBehaviors,
|
|
48
|
+
filePreviewBehaviors,
|
|
49
|
+
fileRowBehaviors,
|
|
50
|
+
fileRows,
|
|
51
|
+
filterBarBehaviors,
|
|
52
|
+
paletteRowBehaviors,
|
|
53
|
+
paletteRows,
|
|
54
|
+
summaryBehaviors,
|
|
55
|
+
} from "../components/index.js"
|
|
56
|
+
import { createBehaviors as headerBehaviors } from "./header.js"
|
|
57
|
+
import { webTheme } from "./theme.js"
|
|
58
|
+
|
|
59
|
+
let registered = false
|
|
60
|
+
|
|
61
|
+
// Advance width of one monospace cell, cached from the header's computed font.
|
|
62
|
+
let charWidth = 0
|
|
63
|
+
|
|
64
|
+
// Function: columnWidth
|
|
65
|
+
// Columns available in `el`, measured from the monospace advance width. Returns
|
|
66
|
+
// 0 when the DOM cannot measure (happy-dom, no layout), disabling compression.
|
|
67
|
+
// ponytail: measured per render; a pure viewport resize waits for the next one.
|
|
68
|
+
function columnWidth(el) {
|
|
69
|
+
const px = el?.clientWidth || el?.ownerDocument?.defaultView?.innerWidth || 0
|
|
70
|
+
if (!px) return 0
|
|
71
|
+
const view = el.ownerDocument?.defaultView
|
|
72
|
+
if (!view) return 0
|
|
73
|
+
if (!charWidth) {
|
|
74
|
+
try {
|
|
75
|
+
const ctx = el.ownerDocument.createElement("canvas").getContext("2d")
|
|
76
|
+
const style = view.getComputedStyle(el)
|
|
77
|
+
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
|
|
78
|
+
charWidth = ctx.measureText("0").width || 0
|
|
79
|
+
} catch {
|
|
80
|
+
return 0
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return charWidth ? Math.floor(px / charWidth) : 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Function: createComponents
|
|
87
|
+
// Binds every shared component to its `.html` presentation.
|
|
88
|
+
function createComponents() {
|
|
89
|
+
const commandItem = bind(ui, "CommandItem", commandItemHtml, commandItemBehaviors(webTheme))
|
|
90
|
+
const crumb = bind(ui, "Crumb", crumbHtml, crumbBehaviors(webTheme))
|
|
91
|
+
const fileRow = bind(ui, "FileRow", fileRowHtml, fileRowBehaviors(webTheme))
|
|
92
|
+
const filterBar = bind(ui, "FilterBar", filterBarHtml, filterBarBehaviors(webTheme))
|
|
93
|
+
const paletteRow = bind(ui, "PaletteRow", paletteRowHtml, paletteRowBehaviors(webTheme))
|
|
94
|
+
const bookmarkRow = bind(ui, "BookmarkRow", bookmarkRowHtml, bookmarkRowBehaviors(webTheme))
|
|
95
|
+
return {
|
|
96
|
+
BookmarkRow: bookmarkRow,
|
|
97
|
+
CommandItem: commandItem,
|
|
98
|
+
Crumb: crumb,
|
|
99
|
+
FileRow: fileRow,
|
|
100
|
+
PaletteRow: paletteRow,
|
|
101
|
+
Breadcrumbs: bind(ui, "Breadcrumbs", rowListHtml, breadcrumbsBehaviors({
|
|
102
|
+
...webTheme,
|
|
103
|
+
listClass: "breadcrumbs",
|
|
104
|
+
rows: crumbRows(crumb),
|
|
105
|
+
})),
|
|
106
|
+
BookmarkList: bind(ui, "BookmarkList", bookmarkListHtml, bookmarkListBehaviors({
|
|
107
|
+
...webTheme,
|
|
108
|
+
rows: bookmarkRows(bookmarkRow),
|
|
109
|
+
})),
|
|
110
|
+
CommandBar: bind(ui, "CommandBar", commandBarHtml, commandBarBehaviors({
|
|
111
|
+
...webTheme,
|
|
112
|
+
listClass: "command-bar-keys",
|
|
113
|
+
rows: commandItemRows(commandItem),
|
|
114
|
+
})),
|
|
115
|
+
CommandPalette: bind(ui, "CommandPalette", commandPaletteHtml, commandPaletteBehaviors({
|
|
116
|
+
...webTheme,
|
|
117
|
+
rows: paletteRows(paletteRow),
|
|
118
|
+
})),
|
|
119
|
+
Confirm: bind(ui, "Confirm", confirmHtml, confirmBehaviors(webTheme)),
|
|
120
|
+
EntryDetail: bind(ui, "EntryDetail", entryDetailHtml, entryDetailBehaviors(webTheme)),
|
|
121
|
+
FileList: bind(ui, "FileList", fileListHtml, fileListBehaviors({
|
|
122
|
+
...webTheme,
|
|
123
|
+
rows: fileRows(fileRow),
|
|
124
|
+
})),
|
|
125
|
+
FilePreview: bind(ui, "FilePreview", filePreviewHtml, filePreviewBehaviors(webTheme)),
|
|
126
|
+
FilterBar: filterBar,
|
|
127
|
+
Header: bind(ui, "Header", headerHtml, headerBehaviors({
|
|
128
|
+
...webTheme,
|
|
129
|
+
width: (self) => columnWidth(self.ref.box),
|
|
130
|
+
})),
|
|
131
|
+
Summary: bind(ui, "Summary", summaryHtml, summaryBehaviors(webTheme)),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const components = createComponents()
|
|
136
|
+
|
|
137
|
+
// Function: registerWebComponents
|
|
138
|
+
// Idempotent registration of the parent components as select.js formatters, so
|
|
139
|
+
// App.html can resolve `out="...|Name"` processors.
|
|
140
|
+
function registerWebComponents() {
|
|
141
|
+
if (registered) return components
|
|
142
|
+
registered = true
|
|
143
|
+
ui.format("Breadcrumbs", components.Breadcrumbs)
|
|
144
|
+
ui.format("BookmarkList", components.BookmarkList)
|
|
145
|
+
ui.format("CommandBar", components.CommandBar)
|
|
146
|
+
ui.format("CommandPalette", components.CommandPalette)
|
|
147
|
+
ui.format("Confirm", components.Confirm)
|
|
148
|
+
ui.format("EntryDetail", components.EntryDetail)
|
|
149
|
+
ui.format("FileList", components.FileList)
|
|
150
|
+
ui.format("FilePreview", components.FilePreview)
|
|
151
|
+
ui.format("FilterBar", components.FilterBar)
|
|
152
|
+
ui.format("Header", components.Header)
|
|
153
|
+
ui.format("Summary", components.Summary)
|
|
154
|
+
return components
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { components, registerWebComponents }
|
|
158
|
+
|
|
159
|
+
// EOF
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<header class="app-header" ref="box"><span class="brand-tiny">Tiny</span><span class="brand-cmd">Cmd</span><span out="title"></span></header>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-27
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/web/header
|
|
7
|
+
// Header: the application title bar above the panes, showing
|
|
8
|
+
// `TinyCmd - HOST DIR/BASE` for the active pane's directory (compressed to fit).
|
|
9
|
+
// The brand renders as static spans — `Tiny` with a blue CSS gradient, `Cmd`
|
|
10
|
+
// with a brighter orange one (both kept in sync with `tui/theme.js`) — so the
|
|
11
|
+
// `title` behavior carries only the host and path tail (` - HOST DIR/BASE`).
|
|
12
|
+
//
|
|
13
|
+
// Environment: `textHeadingFg`, plus `width(self)` returning the available
|
|
14
|
+
// columns (renderer-specific; 0/omitted disables compression).
|
|
15
|
+
|
|
16
|
+
import { unwrap } from "select/state/cells"
|
|
17
|
+
import { formatTitle } from "../components/format.js"
|
|
18
|
+
|
|
19
|
+
// Constant: BRAND
|
|
20
|
+
// The brand word the static header spans render; stripped from the title so
|
|
21
|
+
// the tail doesn't repeat it.
|
|
22
|
+
const BRAND = "TinyCmd"
|
|
23
|
+
|
|
24
|
+
// Function: createBehaviors
|
|
25
|
+
// Builds the Header behavior map for the given renderer environment.
|
|
26
|
+
function createBehaviors(env) {
|
|
27
|
+
return {
|
|
28
|
+
title: (self, data) => {
|
|
29
|
+
const full = formatTitle(unwrap(data.host), unwrap(data.path), env.width ? env.width(self, data) : 0)
|
|
30
|
+
return full.startsWith(BRAND) ? full.slice(BRAND.length) : full
|
|
31
|
+
},
|
|
32
|
+
titleFg: () => env.textHeadingFg,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { BRAND, createBehaviors }
|
|
37
|
+
|
|
38
|
+
// EOF
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-27
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/web/http
|
|
7
|
+
// Filesystem adapter that talks to the JSON API exposed by `web/server.js`
|
|
8
|
+
// (`/api/*`) over `fetch`. Same interface as `web/opfs.js`, so the web entry
|
|
9
|
+
// can pick either backend. `createHttpFs` is async: it reads `/api/info` once
|
|
10
|
+
// to bake in the root/home/cwd paths the adapter interface reports
|
|
11
|
+
// synchronously.
|
|
12
|
+
//
|
|
13
|
+
// The token comes from the `<meta name="tcmd-token">` tag the server injects
|
|
14
|
+
// into `index.html`; when the page is not served by `bun run web:serve`, the
|
|
15
|
+
// API is unreachable and the factory fails with a hint.
|
|
16
|
+
|
|
17
|
+
// Function: apiToken
|
|
18
|
+
// Token injected into the host page, or null when absent/placeholder.
|
|
19
|
+
function apiToken(doc = globalThis.document) {
|
|
20
|
+
const value = doc?.querySelector?.('meta[name="tcmd-token"]')?.getAttribute("content")
|
|
21
|
+
return value && !value.startsWith("__") ? value : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Function: createHttpFs
|
|
25
|
+
// Builds the adapter. `base` is the API mount point (absolute URLs work too,
|
|
26
|
+
// which tests use); `fetch` is injectable.
|
|
27
|
+
async function createHttpFs({ base = "/api", token, fetch: doFetch = globalThis.fetch } = {}) {
|
|
28
|
+
const apiBase = new URL(`${base.replace(/\/$/, "")}/`, globalThis.location?.href ?? "http://localhost/")
|
|
29
|
+
|
|
30
|
+
// Function: request
|
|
31
|
+
// One API call: query parameters for reads, a JSON body for mutations.
|
|
32
|
+
async function request(method, route, { params, body } = {}) {
|
|
33
|
+
const url = new URL(route, apiBase)
|
|
34
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
35
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, `${value}`)
|
|
36
|
+
}
|
|
37
|
+
const init = { method, headers: {} }
|
|
38
|
+
if (token) init.headers["x-tcmd-token"] = token
|
|
39
|
+
if (body !== undefined) {
|
|
40
|
+
init.headers["content-type"] = "application/json"
|
|
41
|
+
init.body = JSON.stringify(body)
|
|
42
|
+
}
|
|
43
|
+
let response
|
|
44
|
+
try {
|
|
45
|
+
response = await doFetch(url, init)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`filesystem API unreachable at ${apiBase.origin} (serve the page with \`bun run web:serve\`)`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const payload = await response.json().catch(() => null)
|
|
53
|
+
throw new Error(payload?.error ?? `filesystem API error ${response.status}`)
|
|
54
|
+
}
|
|
55
|
+
return response.json()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const info = await request("GET", "info")
|
|
59
|
+
return {
|
|
60
|
+
readDir: (path) => request("GET", "readDir", { params: { path } }),
|
|
61
|
+
stat: (path) => request("GET", "stat", { params: { path } }),
|
|
62
|
+
isDir: async (path) => Boolean((await request("GET", "stat", { params: { path } }))?.isDir),
|
|
63
|
+
readTextHead: (path, maxBytes) => request("GET", "readTextHead", { params: { path, maxBytes } }),
|
|
64
|
+
du: (path) => request("GET", "du", { params: { path } }),
|
|
65
|
+
disk: (path) => request("GET", "disk", { params: { path } }),
|
|
66
|
+
copy: (from, to) => request("POST", "copy", { body: { from, to } }),
|
|
67
|
+
move: (from, to) => request("POST", "move", { body: { from, to } }),
|
|
68
|
+
trash: (paths) => request("POST", "trash", { body: { paths } }),
|
|
69
|
+
erase: (paths) => request("POST", "erase", { body: { paths } }),
|
|
70
|
+
touch: (path) => request("POST", "touch", { body: { path } }),
|
|
71
|
+
home: () => info.home,
|
|
72
|
+
cwd: () => info.cwd,
|
|
73
|
+
root: () => info.root,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { apiToken, createHttpFs }
|
|
78
|
+
|
|
79
|
+
// EOF
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-26
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/web
|
|
7
|
+
// Web entry point: builds the select.js root component for a session and
|
|
8
|
+
// mounts it into a DOM host. The model/controller is the shared `tcmd/core`;
|
|
9
|
+
// only the presentation differs from the TUI.
|
|
10
|
+
|
|
11
|
+
import { createWebApp } from "./app.js"
|
|
12
|
+
|
|
13
|
+
// Function: main
|
|
14
|
+
// Mounts the web UI for `session` into `selector` (default "#app").
|
|
15
|
+
function main(session, selector = "#app") {
|
|
16
|
+
const App = createWebApp(session)
|
|
17
|
+
return App.new().mount(selector)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { main }
|
|
21
|
+
|
|
22
|
+
// EOF
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-09-27
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/web/main
|
|
7
|
+
// Browser entry point for the web view. Picks a filesystem backend (OPFS when
|
|
8
|
+
// available, the HTTP API from `web/server.js` otherwise), builds the shared
|
|
9
|
+
// session over it plus the null host adapter, and mounts the select.js UI into
|
|
10
|
+
// `#app`. `window.tcmd` exposes the session for console poking.
|
|
11
|
+
//
|
|
12
|
+
// Backend choice: `?fs=opfs` or `?fs=http` forces one; by default OPFS is
|
|
13
|
+
// preferred and HTTP is the fallback, so browsers without OPFS (or insecure
|
|
14
|
+
// origins) still work when the page is served by `bun run web:serve`.
|
|
15
|
+
//
|
|
16
|
+
// select.js renders on animation frames and does no focus management, so this
|
|
17
|
+
// module adds the two browser bits the shared components cannot know about:
|
|
18
|
+
// default-key trapping and focus tracking.
|
|
19
|
+
//
|
|
20
|
+
// Built by `bun run web:build` (see package.json) into `public/main.js`.
|
|
21
|
+
|
|
22
|
+
import { effect } from "select/state/cells"
|
|
23
|
+
import { createNullOs } from "../core/os.js"
|
|
24
|
+
import { createPane } from "../core/pane.js"
|
|
25
|
+
import { createSession, load } from "../core/session.js"
|
|
26
|
+
import { apiToken, createHttpFs } from "./http.js"
|
|
27
|
+
import { main as mount } from "./index.js"
|
|
28
|
+
import { createOpfsFs } from "./opfs.js"
|
|
29
|
+
import { seedOpfs } from "./seed.js"
|
|
30
|
+
|
|
31
|
+
// Constant: APP_KEYS
|
|
32
|
+
// Keys with browser defaults that would fight the app keymap: focus traversal
|
|
33
|
+
// (Tab), caret/scroll arrows, page scroll (Space) and history Backspace.
|
|
34
|
+
const APP_KEYS = new Set([
|
|
35
|
+
" ",
|
|
36
|
+
"ArrowDown",
|
|
37
|
+
"ArrowLeft",
|
|
38
|
+
"ArrowRight",
|
|
39
|
+
"ArrowUp",
|
|
40
|
+
"Backspace",
|
|
41
|
+
"Enter",
|
|
42
|
+
"Tab",
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
// Function: trapKeys
|
|
46
|
+
// Keeps browser defaults out of the way while the app owns the keyboard. Space
|
|
47
|
+
// and Backspace stay untouched in text inputs so the palette query can be
|
|
48
|
+
// typed; the app routes every other listed key itself.
|
|
49
|
+
function trapKeys(root) {
|
|
50
|
+
root?.addEventListener("keydown", (event) => {
|
|
51
|
+
const inInput = event.target instanceof HTMLInputElement
|
|
52
|
+
if (inInput && (event.key === " " || event.key === "Backspace")) return
|
|
53
|
+
if (APP_KEYS.has(event.key)) event.preventDefault()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Function: trackFocus
|
|
58
|
+
// The key handler lives on the app root, so focus must stay inside it. The
|
|
59
|
+
// palette input is focused while the palette is open (select.js does not), and
|
|
60
|
+
// the active pane's filter input while that pane is filtering; focus returns to
|
|
61
|
+
// the app root otherwise. Focus that leaves the app — a removed input, a click
|
|
62
|
+
// on the page background — is pulled back.
|
|
63
|
+
function trackFocus(session, root) {
|
|
64
|
+
if (!root) return
|
|
65
|
+
const focusInput = () => {
|
|
66
|
+
if (session.paletteOpen.value) return document.querySelector("#app .palette-input")
|
|
67
|
+
if (session.filtering.value) {
|
|
68
|
+
return document.querySelector(`#app .pane-${session.filtering.value} .filter-input`)
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
const applyFocus = () => {
|
|
73
|
+
// select.js swaps the palette DOM on animation frames: wait for it.
|
|
74
|
+
requestAnimationFrame(() => {
|
|
75
|
+
const input = focusInput()
|
|
76
|
+
if (input) input.focus()
|
|
77
|
+
else if (!root.contains(document.activeElement)) root.focus()
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
effect([session.paletteOpen, session.filtering], applyFocus)
|
|
81
|
+
document.addEventListener("focusout", (event) => {
|
|
82
|
+
if (!session.paletteOpen.value && !session.filtering.value && event.relatedTarget && root.contains(event.relatedTarget)) {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
applyFocus()
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Function: createOpfsBackend
|
|
90
|
+
// OPFS adapter, probed and seeded. `readDir("/")` resolves the OPFS root, so
|
|
91
|
+
// it throws when the browser has no OPFS (a plain `stat("/")` would not).
|
|
92
|
+
async function createOpfsBackend() {
|
|
93
|
+
const fs = createOpfsFs()
|
|
94
|
+
await fs.readDir("/") // throws when OPFS is unavailable
|
|
95
|
+
await seedOpfs(fs)
|
|
96
|
+
return fs
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Function: createBackend
|
|
100
|
+
// Resolves `{ mode, fs }` from the `?fs=` override, or by trying OPFS first
|
|
101
|
+
// and falling back to the HTTP API.
|
|
102
|
+
async function createBackend() {
|
|
103
|
+
const requested = new URLSearchParams(globalThis.location?.search ?? "").get("fs")
|
|
104
|
+
if (requested === "opfs") return { mode: "opfs", fs: await createOpfsBackend() }
|
|
105
|
+
if (requested === "http") return { mode: "http", fs: await createHttpFs({ token: apiToken() }) }
|
|
106
|
+
try {
|
|
107
|
+
return { mode: "opfs", fs: await createOpfsBackend() }
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.info("tcmd web: OPFS unavailable, falling back to the HTTP filesystem API", error)
|
|
110
|
+
return { mode: "http", fs: await createHttpFs({ token: apiToken() }) }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Function: start
|
|
115
|
+
// Boots the session and mounts the UI.
|
|
116
|
+
async function start() {
|
|
117
|
+
const host = document.querySelector("#app")
|
|
118
|
+
try {
|
|
119
|
+
const { mode, fs } = await createBackend()
|
|
120
|
+
const left = createPane({ fs, path: fs.cwd() })
|
|
121
|
+
const right = createPane({
|
|
122
|
+
fs,
|
|
123
|
+
path: mode === "opfs" ? "/" : fs.home() === fs.cwd() ? fs.root() : fs.home(),
|
|
124
|
+
})
|
|
125
|
+
const session = createSession({
|
|
126
|
+
fs,
|
|
127
|
+
os: createNullOs(),
|
|
128
|
+
left,
|
|
129
|
+
right,
|
|
130
|
+
host: globalThis.location?.hostname ?? "",
|
|
131
|
+
})
|
|
132
|
+
await load(session)
|
|
133
|
+
const instance = mount(session, "#app")
|
|
134
|
+
const root = document.querySelector("#app .app")
|
|
135
|
+
root?.focus()
|
|
136
|
+
trapKeys(root)
|
|
137
|
+
trackFocus(session, root)
|
|
138
|
+
document.title = `TinyCMD web (${mode})`
|
|
139
|
+
globalThis.tcmd = { fs, mode, session, instance }
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const hint =
|
|
142
|
+
"OPFS needs a secure context (localhost or https); the HTTP filesystem API needs the page " +
|
|
143
|
+
"served by `bun run web:serve`."
|
|
144
|
+
host.textContent = `TinyCommand web could not start: ${error?.message ?? error}. ${hint}`
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
void start()
|
|
150
|
+
|
|
151
|
+
// EOF
|