@ssebastien/tcmd 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +17 -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,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