@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,282 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/web/opfs
7
+ // Origin Private File System adapter for `core/fs`. Implements the adapter
8
+ // interface documented in `core/fs.js` on top of OPFS directory/file handles,
9
+ // mapping POSIX-style tcmd paths ("/home/tester") onto the storage root.
10
+ // Requires a secure context (https or localhost); the root handle is resolved
11
+ // lazily, so importing this module has no browser dependency and tests can
12
+ // inject a fake `root`.
13
+ //
14
+ // Beyond the core interface, `writeText` (create/replace a UTF-8 file,
15
+ // creating parents) backs the demo seeding in `web/main.js`.
16
+ //
17
+ // Trash: OPFS has no system trash, so `trash(paths)` moves entries into a
18
+ // `/.trash` directory at the storage root, where they stay browsable.
19
+
20
+ import { basename, extname, join, normalize, parent, split } from "../core/path.js"
21
+
22
+ // Function: createOpfsFs
23
+ // Builds the filesystem adapter. `root` overrides the OPFS root handle (tests);
24
+ // `home` is the directory `home()`/`cwd()` report.
25
+ function createOpfsFs({ root, home = "/home/tester" } = {}) {
26
+ let rootHandle = root ?? null
27
+
28
+ // Function: getRoot
29
+ // Resolves (once) and returns the OPFS root directory handle.
30
+ async function getRoot() {
31
+ if (rootHandle) return rootHandle
32
+ const storage = globalThis.navigator?.storage
33
+ if (!storage?.getDirectory) throw new Error("OPFS is not available in this browser")
34
+ rootHandle = await storage.getDirectory()
35
+ return rootHandle
36
+ }
37
+
38
+ // Function: resolveDir
39
+ // Resolves `path` to a directory handle; when `create` is set, missing
40
+ // directories along the way are created.
41
+ async function resolveDir(path, { create = false } = {}) {
42
+ let directory = await getRoot()
43
+ for (const name of split(path)) {
44
+ directory = await directory.getDirectoryHandle(name, { create })
45
+ }
46
+ return directory
47
+ }
48
+
49
+ // Function: resolveFile
50
+ // Resolves `path` to a file handle, or null when it is missing or is a
51
+ // directory.
52
+ async function resolveFile(path) {
53
+ const directory = await resolveDir(parent(path) ?? "/")
54
+ try {
55
+ return await directory.getFileHandle(basename(path))
56
+ } catch (error) {
57
+ if (error?.name === "NotFoundError" || error?.name === "TypeMismatchError") return null
58
+ throw error
59
+ }
60
+ }
61
+
62
+ // Function: ensureDir
63
+ // Creates `path` (and its parents) and returns its directory handle.
64
+ async function ensureDir(path) {
65
+ const normalized = normalize(path)
66
+ const directory = await resolveDir(parent(normalized) ?? "/", { create: true })
67
+ return directory.getDirectoryHandle(basename(normalized), { create: true })
68
+ }
69
+
70
+ // Function: toEntry
71
+ // Maps an OPFS handle to the core FileEntry shape.
72
+ async function toEntry(dirPath, handle) {
73
+ const path = join(dirPath, handle.name)
74
+ if (handle.kind === "directory") {
75
+ return {
76
+ name: handle.name,
77
+ path,
78
+ isDir: true,
79
+ isSymlink: false,
80
+ isDevice: false,
81
+ size: 0,
82
+ mtimeMs: 0,
83
+ ctimeMs: 0,
84
+ mode: 0,
85
+ uid: null,
86
+ gid: null,
87
+ owner: "",
88
+ ext: null,
89
+ }
90
+ }
91
+ const file = await handle.getFile()
92
+ return {
93
+ name: handle.name,
94
+ path,
95
+ isDir: false,
96
+ isSymlink: false,
97
+ isDevice: false,
98
+ size: file.size,
99
+ mtimeMs: file.lastModified,
100
+ ctimeMs: 0,
101
+ mode: 0,
102
+ uid: null,
103
+ gid: null,
104
+ owner: "",
105
+ ext: extname(handle.name),
106
+ }
107
+ }
108
+
109
+ // Function: stat
110
+ // Stat info for a file, a directory, or null when `path` does not exist.
111
+ async function stat(path) {
112
+ const normalized = normalize(path)
113
+ const zero = { ctimeMs: 0, mode: 0, uid: null, gid: null, owner: "" }
114
+ if (normalized === "/") return { isDir: true, isSymlink: false, isDevice: false, size: 0, mtimeMs: 0, ...zero }
115
+ try {
116
+ await resolveDir(normalized)
117
+ return { isDir: true, isSymlink: false, isDevice: false, size: 0, mtimeMs: 0, ...zero }
118
+ } catch (error) {
119
+ // Not a directory (missing, or a file sits at this path).
120
+ }
121
+ try {
122
+ const handle = await resolveFile(normalized)
123
+ if (!handle) return null
124
+ const file = await handle.getFile()
125
+ return { isDir: false, isSymlink: false, isDevice: false, size: file.size, mtimeMs: file.lastModified, ...zero }
126
+ } catch (error) {
127
+ return null
128
+ }
129
+ }
130
+
131
+ // Function: readDir
132
+ // Lists `path` as FileEntry-like rows.
133
+ async function readDir(path) {
134
+ const directory = await resolveDir(normalize(path))
135
+ const entries = []
136
+ for await (const handle of directory.values()) entries.push(await toEntry(path, handle))
137
+ return entries
138
+ }
139
+
140
+ // Function: readTextHead
141
+ // Leading `maxBytes` of a text file, decoded as UTF-8.
142
+ async function readTextHead(path, maxBytes = 65536) {
143
+ const handle = await resolveFile(normalize(path))
144
+ if (!handle) throw notFound(path)
145
+ const file = await handle.getFile()
146
+ return file.slice(0, maxBytes).text()
147
+ }
148
+
149
+ // Function: du
150
+ // Recursive size in bytes; unreadable entries contribute 0.
151
+ async function du(path) {
152
+ const stats = await stat(path)
153
+ if (!stats) return 0
154
+ if (!stats.isDir) return stats.size
155
+ let total = 0
156
+ for (const entry of await readDir(path)) {
157
+ total += entry.isDir ? await du(entry.path) : entry.size
158
+ }
159
+ return total
160
+ }
161
+
162
+ // Function: disk
163
+ // Storage estimate as `{ free, total }`, or null when the browser does not
164
+ // expose one.
165
+ async function disk() {
166
+ const storage = globalThis.navigator?.storage
167
+ if (!storage?.estimate) return null
168
+ try {
169
+ const { usage = 0, quota = 0 } = await storage.estimate()
170
+ return { free: Math.max(0, quota - usage), total: quota }
171
+ } catch (error) {
172
+ return null
173
+ }
174
+ }
175
+
176
+ // Function: copy
177
+ // Recursive copy of a file or directory; an existing directory target is
178
+ // merged into (the operations layer already appends the basename when the
179
+ // user copies "into" a directory).
180
+ async function copy(from, to) {
181
+ const stats = await stat(from)
182
+ if (!stats) throw notFound(from)
183
+ if (stats.isDir) {
184
+ if (!(await stat(to))?.isDir) await ensureDir(to)
185
+ for await (const handle of (await resolveDir(from)).values()) {
186
+ await copy(join(from, handle.name), join(to, handle.name))
187
+ }
188
+ return
189
+ }
190
+ const file = await (await resolveFile(from)).getFile()
191
+ const directory = await resolveDir(parent(to) ?? "/")
192
+ const handle = await directory.getFileHandle(basename(to), { create: true })
193
+ const writable = await handle.createWritable()
194
+ await writable.write(file)
195
+ await writable.close()
196
+ }
197
+
198
+ // Function: eraseOne
199
+ // Removes one entry (recursively for directories); the root is a no-op.
200
+ async function eraseOne(path) {
201
+ const normalized = normalize(path)
202
+ if (normalized === "/") return
203
+ const directory = await resolveDir(parent(normalized) ?? "/")
204
+ await directory.removeEntry(basename(normalized), { recursive: true })
205
+ }
206
+
207
+ // Function: trash
208
+ // Moves entries into `/.trash` under a timestamped name, keeping them
209
+ // available for inspection instead of deleting them.
210
+ let trashSeq = 0
211
+ async function trash(paths) {
212
+ await ensureDir("/.trash")
213
+ for (const path of paths) {
214
+ const normalized = normalize(path)
215
+ if (normalized === "/") continue
216
+ const name = `${Date.now()}-${++trashSeq}-${basename(normalized)}`
217
+ await copy(normalized, join("/.trash", name))
218
+ await eraseOne(normalized)
219
+ }
220
+ }
221
+
222
+ // Function: touch
223
+ // Creates an empty file, or rewrites an existing one to bump its mtime
224
+ // (OPFS exposes no utimes).
225
+ async function touch(path) {
226
+ const normalized = normalize(path)
227
+ const stats = await stat(normalized)
228
+ if (stats?.isDir) return
229
+ const directory = await resolveDir(parent(normalized) ?? "/")
230
+ const handle = await directory.getFileHandle(basename(normalized), { create: true })
231
+ if (!stats) return
232
+ const bytes = await (await handle.getFile()).arrayBuffer()
233
+ const writable = await handle.createWritable()
234
+ await writable.write(bytes)
235
+ await writable.close()
236
+ }
237
+
238
+ // Function: writeText
239
+ // Creates (or replaces) a UTF-8 text file, creating parent directories.
240
+ async function writeText(path, text) {
241
+ const normalized = normalize(path)
242
+ const directory = await resolveDir(parent(normalized) ?? "/", { create: true })
243
+ const handle = await directory.getFileHandle(basename(normalized), { create: true })
244
+ const writable = await handle.createWritable()
245
+ await writable.write(`${text}`)
246
+ await writable.close()
247
+ }
248
+
249
+ return {
250
+ readDir,
251
+ stat,
252
+ isDir: async (path) => Boolean((await stat(path))?.isDir),
253
+ readTextHead,
254
+ du,
255
+ disk,
256
+ copy,
257
+ move: async (from, to) => {
258
+ await copy(from, to)
259
+ await eraseOne(from)
260
+ },
261
+ trash,
262
+ erase: async (paths) => {
263
+ for (const path of paths) await eraseOne(path)
264
+ },
265
+ touch,
266
+ writeText,
267
+ home: () => home,
268
+ cwd: () => home,
269
+ }
270
+ }
271
+
272
+ // Function: notFound
273
+ // ENOENT-flavoured error for missing paths.
274
+ function notFound(path) {
275
+ const error = new Error(`ENOENT: ${path}`)
276
+ error.code = "ENOENT"
277
+ return error
278
+ }
279
+
280
+ export { createOpfsFs }
281
+
282
+ // EOF
@@ -0,0 +1,90 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/web/seed
7
+ // Demo tree for the browser view. `seedOpfs(fs)` writes it once per storage,
8
+ // guarded by a `/.seeded` marker, so a user-emptied tree is not re-populated
9
+ // on every reload. Takes any adapter with `stat` plus the OPFS `writeText`
10
+ // extra (see `web/opfs.js`), which keeps it testable off the browser.
11
+
12
+ // Constant: DEMO_TREE
13
+ // Absolute path -> UTF-8 contents; parent directories are created on demand.
14
+ const DEMO_TREE = {
15
+ "/README.md": [
16
+ "# TinyCommand web",
17
+ "",
18
+ "This tree lives in the browser's Origin Private File System (OPFS) and",
19
+ "is browsed by the very same core that drives the terminal UI.",
20
+ "",
21
+ "Handy keys:",
22
+ "- Enter/Right open a directory",
23
+ "- Left/Backspace go to the parent directory",
24
+ "- Tab switch pane",
25
+ "- Space mark the current entry",
26
+ "- b / g record / jump to a bookmark",
27
+ "- ^P command palette",
28
+ "- : scripting mode (copy, move, delete, ...)",
29
+ "- q quit (unmounts the UI; reload to start over)",
30
+ "",
31
+ "Open/edit/run are not available in the browser: the web uses the null",
32
+ "host adapter, so those commands report `... is not available in this",
33
+ "environment` in the command bar.",
34
+ "",
35
+ "Deleting moves entries to /.trash instead of removing them for good.",
36
+ ].join("\n"),
37
+ "/home/tester/notes.txt": [
38
+ "Notes",
39
+ "=====",
40
+ "",
41
+ "- tcmd shares its core between the TUI and the web view",
42
+ "- components/ holds the renderer-agnostic behaviors",
43
+ "- tui/ binds them to select.tui, web/ to select.js",
44
+ ].join("\n"),
45
+ "/home/tester/todo.txt": [
46
+ "TODO",
47
+ "----",
48
+ "[x] two panes",
49
+ "[x] named bookmarks",
50
+ "[x] command palette",
51
+ "[ ] quick find",
52
+ ].join("\n"),
53
+ "/documents/welcome.txt": [
54
+ "Welcome to the TinyCommand OPFS demo.",
55
+ "",
56
+ "Everything you do here happens in the browser's private storage:",
57
+ "create tabs, mark files, copy/move/erase inside this tree.",
58
+ "",
59
+ "Reload the page and your changes are still there.",
60
+ ].join("\n"),
61
+ "/documents/tips.txt": [
62
+ "Tip: the preview follows the cursor; `v` toggles it.",
63
+ "Tip: `stat` in scripting mode shows size details in the preview.",
64
+ "Tip: marked entries feed the $A/$B filesets in scripting mode.",
65
+ ].join("\n"),
66
+ "/projects/tcmd/README.md": [
67
+ "# tcmd (mirror)",
68
+ "",
69
+ "A pretend checkout, good for trying copy/erase on directories.",
70
+ ].join("\n"),
71
+ "/projects/tcmd/src/main.js": [
72
+ "export function main() {",
73
+ '\treturn "hello from a seeded OPFS file"',
74
+ "}",
75
+ "",
76
+ ].join("\n"),
77
+ }
78
+
79
+ // Function: seedOpfs
80
+ // Writes the demo tree when the `/.seeded` marker is absent.
81
+ async function seedOpfs(fs) {
82
+ if (await fs.stat("/.seeded")) return false
83
+ for (const [path, text] of Object.entries(DEMO_TREE)) await fs.writeText(path, text)
84
+ await fs.writeText("/.seeded", "")
85
+ return true
86
+ }
87
+
88
+ export { DEMO_TREE, seedOpfs }
89
+
90
+ // EOF
@@ -0,0 +1,191 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/web/server
7
+ // Development server for the web view. Serves `public/` and exposes a JSON
8
+ // filesystem API (`/api/*`) over the Node filesystem adapter, so browsers
9
+ // without OPFS (or users who want to browse real files) can run the web UI.
10
+ //
11
+ // Security: binds to loopback only by default and requires the per-process
12
+ // token, injected into `index.html` at serve time, on every API request. When
13
+ // `root` is set, every path is confined to it. This is a local development
14
+ // tool, not a sandbox: it serves the same files `bun run tcmd` can reach, so
15
+ // do not expose the port.
16
+
17
+ import { resolve as resolvePath, sep } from "node:path"
18
+ import { createNodeFs } from "../core/fs.js"
19
+
20
+ // Constant: TOKEN_PLACEHOLDER
21
+ // Value replaced in `index.html` when the page is served.
22
+ const TOKEN_PLACEHOLDER = "__TCMD_TOKEN__"
23
+
24
+ // Function: createWebServer
25
+ // Builds the server. `port` 0 picks a free port (tests); `root` defaults to
26
+ // "/" (the whole filesystem, like the TUI); `token` defaults to a fresh UUID.
27
+ function createWebServer({
28
+ root = process.env.TCMD_WEB_ROOT ?? "/",
29
+ port = Number(process.env.PORT ?? 8787),
30
+ hostname = "127.0.0.1",
31
+ publicDir = new URL("../../../../public/", import.meta.url),
32
+ token = crypto.randomUUID(),
33
+ } = {}) {
34
+ const fs = createNodeFs()
35
+ const base = resolvePath(root)
36
+ const rootHref = publicDir instanceof URL ? publicDir : new URL(`${publicDir.replace(/\/?$/, "/")}`, "file:///")
37
+
38
+ // Function: withinRoot
39
+ // True when `path` is the configured root or inside it.
40
+ function withinRoot(path) {
41
+ const resolved = resolvePath(path)
42
+ return base === sep || resolved === base || resolved.startsWith(base + sep)
43
+ }
44
+
45
+ // Function: confine
46
+ // Resolves a client path and rejects it when it escapes the root.
47
+ function confine(path) {
48
+ if (typeof path !== "string" || !path) throw httpError(400, "missing path")
49
+ const resolved = resolvePath(path)
50
+ if (!withinRoot(resolved)) throw httpError(403, `path outside ${base}`)
51
+ return resolved
52
+ }
53
+
54
+ // Function: jsonBody
55
+ // Reads a JSON request body; other content types are refused so cross-site
56
+ // form posts cannot reach the mutations.
57
+ async function jsonBody(request) {
58
+ if (!`${request.headers.get("content-type") ?? ""}`.includes("application/json")) {
59
+ throw httpError(415, "expected an application/json body")
60
+ }
61
+ try {
62
+ return await request.json()
63
+ } catch (error) {
64
+ throw httpError(400, "malformed JSON body")
65
+ }
66
+ }
67
+
68
+ // Constant: routes
69
+ // `METHOD /api/name` -> payload builder; the payload is wrapped in JSON.
70
+ const routes = {
71
+ "GET /api/info": () => ({
72
+ root: base,
73
+ home: withinRoot(fs.home()) ? fs.home() : base,
74
+ cwd: withinRoot(fs.cwd()) ? fs.cwd() : base,
75
+ }),
76
+ "GET /api/readDir": (_request, url) => fs.readDir(confine(url.searchParams.get("path"))),
77
+ "GET /api/stat": (_request, url) => fs.stat(confine(url.searchParams.get("path"))),
78
+ "GET /api/readTextHead": async (_request, url) => {
79
+ const path = confine(url.searchParams.get("path"))
80
+ const maxBytes = clampBytes(url.searchParams.get("maxBytes"))
81
+ return fs.readTextHead(path, maxBytes)
82
+ },
83
+ "GET /api/du": (_request, url) => fs.du(confine(url.searchParams.get("path"))),
84
+ "GET /api/disk": (_request, url) => fs.disk(confine(url.searchParams.get("path"))),
85
+ "POST /api/copy": async (request) => {
86
+ const body = await jsonBody(request)
87
+ return fs.copy(confine(body.from), confine(body.to))
88
+ },
89
+ "POST /api/move": async (request) => {
90
+ const body = await jsonBody(request)
91
+ return fs.move(confine(body.from), confine(body.to))
92
+ },
93
+ "POST /api/trash": async (request) => {
94
+ const body = await jsonBody(request)
95
+ return fs.trash(confinePaths(body.paths))
96
+ },
97
+ "POST /api/erase": async (request) => {
98
+ const body = await jsonBody(request)
99
+ return fs.erase(confinePaths(body.paths))
100
+ },
101
+ "POST /api/touch": async (request) => {
102
+ const body = await jsonBody(request)
103
+ return fs.touch(confine(body.path))
104
+ },
105
+ }
106
+
107
+ // Function: confinePaths
108
+ // Validates a `paths` array before a bulk operation.
109
+ function confinePaths(paths) {
110
+ if (!Array.isArray(paths)) throw httpError(400, "expected a paths array")
111
+ return paths.map(confine)
112
+ }
113
+
114
+ // Function: handleApi
115
+ // Auth + routing + error mapping for `/api/*`.
116
+ async function handleApi(request, url) {
117
+ if (request.headers.get("x-tcmd-token") !== token) {
118
+ return json({ error: "invalid or missing token" }, 403)
119
+ }
120
+ const route = routes[`${request.method} ${url.pathname}`]
121
+ if (!route) return json({ error: `unknown route: ${request.method} ${url.pathname}` }, 404)
122
+ try {
123
+ const value = await route(request, url)
124
+ return json(value === undefined ? { ok: true } : value)
125
+ } catch (error) {
126
+ const status = error?.status ?? (error?.code === "ENOENT" ? 404 : 500)
127
+ return json({ error: `${error?.message ?? error}` }, status)
128
+ }
129
+ }
130
+
131
+ // Function: handleStatic
132
+ // `public/` files, with the token injected into `index.html`.
133
+ async function handleStatic(url) {
134
+ let path
135
+ try {
136
+ path = decodeURIComponent(url.pathname)
137
+ } catch (error) {
138
+ return new Response("Bad request", { status: 400 })
139
+ }
140
+ if (path === "/") path = "/index.html"
141
+ const target = new URL(`.${path}`, rootHref)
142
+ if (!target.href.startsWith(rootHref.href)) return new Response("Not found", { status: 404 })
143
+ const file = Bun.file(target)
144
+ if (!(await file.exists())) return new Response("Not found", { status: 404 })
145
+ if (path === "/index.html") {
146
+ const html = (await file.text()).replaceAll(TOKEN_PLACEHOLDER, token)
147
+ return new Response(html, {
148
+ headers: { "Cache-Control": "no-store", "Content-Type": "text/html; charset=utf-8" },
149
+ })
150
+ }
151
+ return new Response(file, { headers: { "Cache-Control": "no-store" } })
152
+ }
153
+
154
+ const server = Bun.serve({
155
+ hostname,
156
+ port,
157
+ fetch(request) {
158
+ const url = new URL(request.url)
159
+ if (url.pathname.startsWith("/api/")) return handleApi(request, url)
160
+ return handleStatic(url)
161
+ },
162
+ })
163
+
164
+ return { server, token, root: base }
165
+ }
166
+
167
+ // Function: json
168
+ // One-line JSON response.
169
+ function json(payload, status = 200) {
170
+ return Response.json(payload, { status, headers: { "Cache-Control": "no-store" } })
171
+ }
172
+
173
+ // Function: httpError
174
+ // Error carrying the HTTP status the API should answer with.
175
+ function httpError(status, message) {
176
+ const error = new Error(message)
177
+ error.status = status
178
+ return error
179
+ }
180
+
181
+ // Function: clampBytes
182
+ // `maxBytes` query parameter, bounded to 1 MiB; defaults to the preview size.
183
+ function clampBytes(value) {
184
+ const bytes = Number(value)
185
+ if (!Number.isFinite(bytes) || bytes <= 0) return 65536
186
+ return Math.min(Math.floor(bytes), 1024 * 1024)
187
+ }
188
+
189
+ export { createWebServer, TOKEN_PLACEHOLDER }
190
+
191
+ // EOF
@@ -0,0 +1,23 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/web/theme
7
+ // Web presentation tokens. Colors are expressed as CSS classes, so the web
8
+ // theme only carries the state classes the shared behaviors bind through
9
+ // `out:class`; the TUI colors live in `tui/theme.js`.
10
+
11
+ const webTheme = {
12
+ activeClass: "is-active",
13
+ childClass: "is-child",
14
+ currentClass: "is-current",
15
+ dirClass: "is-dir",
16
+ errorClass: "is-error",
17
+ rootClass: "is-root",
18
+ selectedClass: "is-selected",
19
+ }
20
+
21
+ export { webTheme }
22
+
23
+ // EOF