@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,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
|