@ssebastien/tcmd 0.1.4 → 0.1.7
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 +169 -58
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/OverlayList.xml +13 -0
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +612 -136
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/session.js +286 -116
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- package/tinyjs.json +16 -0
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
// default-key trapping and keeping the keyboard cursor visible. select.js
|
|
9
9
|
// renders without any of this, so the web binding owns it explicitly.
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { effect } from "select/state/cells"
|
|
12
12
|
|
|
13
13
|
const canvases = new WeakMap()
|
|
14
14
|
|
|
15
|
+
function isToolbarTarget(target) {
|
|
16
|
+
return Boolean(target?.closest?.(".desktop-toolbar"))
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
// Function: columnWidth
|
|
16
20
|
// Available monospace columns, or zero in a DOM without layout (happy-dom).
|
|
17
21
|
function columnWidth(el) {
|
|
@@ -42,6 +46,33 @@ function keepVisible(list, row) {
|
|
|
42
46
|
else if (rowRect.bottom > listRect.bottom) list.scrollTop += rowRect.bottom - listRect.bottom
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
// Function: previewBodies
|
|
50
|
+
// The scrollable preview bodies in `root`: the inspector, the maximized
|
|
51
|
+
// preview and the viewer each reuse one text/card body.
|
|
52
|
+
function previewBodies(root) {
|
|
53
|
+
return root?.querySelectorAll?.(".preview-text, .preview-card") ?? []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Function: resetPreviewScroll
|
|
57
|
+
// Send the preview body back to the top. The inspector, the maximized preview
|
|
58
|
+
// and the viewer each reuse one scrollable body, so a scroll offset would
|
|
59
|
+
// otherwise carry over to the next file.
|
|
60
|
+
function resetPreviewScroll(root) {
|
|
61
|
+
for (const body of previewBodies(root)) {
|
|
62
|
+
body.scrollTop = 0
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Function: applyPreviewScroll
|
|
67
|
+
// Apply a full-preview scroll request from the App's arrow routing. The DOM
|
|
68
|
+
// clamps `scrollTop` against the visible body, so the edges need no guard.
|
|
69
|
+
function applyPreviewScroll(root, delta) {
|
|
70
|
+
if (!delta) return
|
|
71
|
+
for (const body of previewBodies(root)) {
|
|
72
|
+
body.scrollTop = Math.max(0, body.scrollTop + delta)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
// Function: connectEffects
|
|
46
77
|
// Returns a frame scheduler and teardown; the App registers teardown with
|
|
47
78
|
// instance.effect(), so remounting leaves no listeners or observers behind.
|
|
@@ -53,14 +84,20 @@ function connectEffects(session, root, { size, help, viewer }) {
|
|
|
53
84
|
let frame = null
|
|
54
85
|
let retries = 0
|
|
55
86
|
let disposed = false
|
|
56
|
-
|
|
87
|
+
// The viewer overlay is a modal at any width (Enter opens it on a file
|
|
88
|
+
// that cannot be edited); it owns focus while open.
|
|
89
|
+
const viewerOpen = () => Boolean(viewer.value)
|
|
57
90
|
const modalOpen = () => Boolean(session.confirm.value || help.value || viewerOpen() || session.panelOpen.value || session.bookmarkOpen.value)
|
|
58
91
|
const focusTarget = () => {
|
|
59
92
|
if (session.confirm.value) return root.querySelector(".confirm")
|
|
60
93
|
if (help.value) return root.querySelector(".help")
|
|
61
94
|
if (viewerOpen()) return root.querySelector(".viewer")
|
|
62
95
|
if (session.panelOpen.value) {
|
|
63
|
-
if (session.
|
|
96
|
+
if (session.pickerOpen.value) {
|
|
97
|
+
return session.pickerFocus.value === "path"
|
|
98
|
+
? root.querySelector(".panel-picker-path")
|
|
99
|
+
: root.querySelector(".command-panel")
|
|
100
|
+
}
|
|
64
101
|
if (session.panelFocus.value === "ops") return root.querySelector(".panel-query")
|
|
65
102
|
return root.querySelector(session.panelTextual.value ? ".panel-value" : ".command-panel")
|
|
66
103
|
}
|
|
@@ -75,12 +112,6 @@ function connectEffects(session, root, { size, help, viewer }) {
|
|
|
75
112
|
const lineHeight = Number.parseFloat(view.getComputedStyle(root).lineHeight)
|
|
76
113
|
const height = lineHeight ? Math.floor(root.clientHeight / lineHeight) : 0
|
|
77
114
|
if (width && height && (size.value.width !== width || size.value.height !== height)) size.set({ width, height })
|
|
78
|
-
// A fullscreen viewer only exists while the viewport is narrow;
|
|
79
|
-
// widening back to the inline inspector drops the stale state so a
|
|
80
|
-
// later narrow resize does not resurrect it. Reads the cell (not just
|
|
81
|
-
// the fresh measurement) so a host that drives `size` directly is
|
|
82
|
-
// honoured too.
|
|
83
|
-
if (viewer.value && size.value.width >= INSPECTOR_INLINE_WIDTH) viewer.set(false)
|
|
84
115
|
// A modal's field mounts on a later frame than the flag that opens it;
|
|
85
116
|
// retry a few frames before giving up so focus lands on the overlay
|
|
86
117
|
// instead of the root.
|
|
@@ -91,7 +122,10 @@ function connectEffects(session, root, { size, help, viewer }) {
|
|
|
91
122
|
return
|
|
92
123
|
}
|
|
93
124
|
retries = 0
|
|
94
|
-
|
|
125
|
+
// Do not steal focus as Tab moves between native toolbar buttons.
|
|
126
|
+
const toolbarFocused = !modalOpen() && !session.filtering.value &&
|
|
127
|
+
root.contains(doc.activeElement) && isToolbarTarget(doc.activeElement)
|
|
128
|
+
const focusable = toolbarFocused ? doc.activeElement : target ?? root
|
|
95
129
|
if (focusable !== doc.activeElement) focusable.focus({ preventScroll: true })
|
|
96
130
|
for (const list of root.querySelectorAll(".file-list, .bookmark-rows, .panel-ops, .panel-args")) {
|
|
97
131
|
keepVisible(list, list.querySelector(".is-current, .is-selected, .is-focused"))
|
|
@@ -105,12 +139,23 @@ function connectEffects(session, root, { size, help, viewer }) {
|
|
|
105
139
|
})
|
|
106
140
|
}
|
|
107
141
|
const trapKeys = (event) => {
|
|
142
|
+
// Reload remains disabled even when native controls own the other keys.
|
|
143
|
+
// F5 now refreshes the listing too, so it must not reload the page.
|
|
144
|
+
if ((event.ctrlKey && event.key.toLowerCase() === "r") || event.key === "F5") {
|
|
145
|
+
event.preventDefault()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (isToolbarTarget(event.target)) return
|
|
108
149
|
const input = event.target?.tagName === "INPUT" || event.target?.tagName === "TEXTAREA"
|
|
109
150
|
// Editing keeps native text/caret keys except the ones the shared
|
|
110
151
|
// controller explicitly owns (vertical arrows, Enter and Tab).
|
|
111
|
-
if (input && [" ", "
|
|
112
|
-
|
|
113
|
-
|
|
152
|
+
if (input && [" ", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key)) return
|
|
153
|
+
// The bookmark filter edits its query like any input; once it is empty,
|
|
154
|
+
// Backspace removes the selected bookmark instead. Delete always does.
|
|
155
|
+
const bookmarkFilterEmpty = session.bookmarkOpen.value && !`${session.bookmarkQuery?.value ?? ""}`.trim()
|
|
156
|
+
if (input && event.key === "Backspace" && !bookmarkFilterEmpty) return
|
|
157
|
+
if (session.bookmarkOpen.value && event.key === "Delete") event.preventDefault()
|
|
158
|
+
if ([" ", "Backspace", "Enter", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Escape"].includes(event.key)) event.preventDefault()
|
|
114
159
|
}
|
|
115
160
|
const focusIn = (event) => {
|
|
116
161
|
if (modalOpen() && event.target !== focusTarget()) schedule()
|
|
@@ -123,11 +168,46 @@ function connectEffects(session, root, { size, help, viewer }) {
|
|
|
123
168
|
observer?.observe(root)
|
|
124
169
|
// Pane proportions also change when the inspector is toggled.
|
|
125
170
|
for (const pane of root.querySelectorAll(".pane")) observer?.observe(pane)
|
|
171
|
+
// Modal state can change before the initial focus frame runs (for example,
|
|
172
|
+
// the user opens the panel immediately after mount). Watch the focus-driving
|
|
173
|
+
// cells directly so that initial layout work cannot swallow that handoff.
|
|
174
|
+
const stopFocusWatch = effect(
|
|
175
|
+
[
|
|
176
|
+
session.panelOpen,
|
|
177
|
+
session.panelFocus,
|
|
178
|
+
session.panelOpIndex,
|
|
179
|
+
session.panelRowIndex,
|
|
180
|
+
session.panelTextual,
|
|
181
|
+
session.pickerOpen,
|
|
182
|
+
session.bookmarkOpen,
|
|
183
|
+
session.filtering,
|
|
184
|
+
session.confirm,
|
|
185
|
+
session.overlay,
|
|
186
|
+
help,
|
|
187
|
+
viewer,
|
|
188
|
+
],
|
|
189
|
+
schedule,
|
|
190
|
+
)
|
|
191
|
+
// The preview target and its payload both change as the cursor moves:
|
|
192
|
+
// reset on either so a new file starts at the top even when its text
|
|
193
|
+
// arrives asynchronously.
|
|
194
|
+
const previewInputs = [session.previewEntry, session.preview].filter((value) => value?.isReactive)
|
|
195
|
+
const stopPreviewWatch = previewInputs.length
|
|
196
|
+
? effect(previewInputs, () => resetPreviewScroll(root))
|
|
197
|
+
: null
|
|
198
|
+
// Full-preview arrow scrolling: the shared controller publishes a delta,
|
|
199
|
+
// the DOM body applies it to its own measured viewport.
|
|
200
|
+
const stopScrollWatch = session.previewScroll?.isReactive
|
|
201
|
+
? effect([session.previewScroll], ([request]) => applyPreviewScroll(root, request?.delta ?? 0))
|
|
202
|
+
: null
|
|
126
203
|
schedule()
|
|
127
204
|
return {
|
|
128
205
|
schedule,
|
|
129
206
|
dispose() {
|
|
130
207
|
disposed = true
|
|
208
|
+
stopFocusWatch?.()
|
|
209
|
+
stopPreviewWatch?.()
|
|
210
|
+
stopScrollWatch?.()
|
|
131
211
|
if (frame !== null) view.cancelAnimationFrame(frame)
|
|
132
212
|
observer?.disconnect()
|
|
133
213
|
root.removeEventListener("keydown", trapKeys)
|
|
@@ -138,6 +218,6 @@ function connectEffects(session, root, { size, help, viewer }) {
|
|
|
138
218
|
}
|
|
139
219
|
}
|
|
140
220
|
|
|
141
|
-
export { columnWidth, connectEffects, keepVisible }
|
|
221
|
+
export { applyPreviewScroll, columnWidth, connectEffects, isToolbarTarget, keepVisible, resetPreviewScroll }
|
|
142
222
|
|
|
143
223
|
// EOF
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<header class="app-header" ref="box"><span class="brand-tiny">Tiny</span><span class="brand-cmd">Cmd</span><span out="title"></span><span class="app-header-status"><span class="app-header-mode" out:class="modeClass" out="mode"></span><span out="statusSuffix"></span></span></header>
|
|
1
|
+
<header class="app-header" ref="box"><span class="brand-tiny">Tiny</span><span class="brand-cmd">Cmd</span><span out="title"></span><span class="app-header-status"><span class="app-header-mode" out:class="modeClass" on:click="!view:toggleMode"><svg class="header-mode-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-columns-2"></use></svg><span out="mode"></span></span><span out="statusSuffix"></span></span></header>
|
package/src/js/tcmd/web/http.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// into `index.html`; when the page is not served by `bun run web:serve`, the
|
|
15
15
|
// API is unreachable and the factory fails with a hint.
|
|
16
16
|
|
|
17
|
+
import { decodeBase64 } from "../core/base64.js"
|
|
18
|
+
|
|
17
19
|
// Function: apiToken
|
|
18
20
|
// Token injected into the host page, or null when absent/placeholder.
|
|
19
21
|
function apiToken(doc = globalThis.document) {
|
|
@@ -59,13 +61,24 @@ async function createHttpFs({ base = "/api", token, fetch: doFetch = globalThis.
|
|
|
59
61
|
return {
|
|
60
62
|
readDir: (path) => request("GET", "readDir", { params: { path } }),
|
|
61
63
|
stat: (path) => request("GET", "stat", { params: { path } }),
|
|
64
|
+
realpath: (path) => request("GET", "realpath", { params: { path } }),
|
|
62
65
|
isDir: async (path) => Boolean((await request("GET", "stat", { params: { path } }))?.isDir),
|
|
63
66
|
readTextHead: (path, maxBytes) => request("GET", "readTextHead", { params: { path, maxBytes } }),
|
|
67
|
+
readBytes: async (path, maxBytes) =>
|
|
68
|
+
decodeBase64((await request("GET", "readBytes", { params: { path, maxBytes } })).base64),
|
|
64
69
|
du: (path) => request("GET", "du", { params: { path } }),
|
|
65
70
|
disk: (path) => request("GET", "disk", { params: { path } }),
|
|
66
71
|
copy: (from, to) => request("POST", "copy", { body: { from, to } }),
|
|
67
72
|
move: (from, to) => request("POST", "move", { body: { from, to } }),
|
|
73
|
+
hardlink: (from, to) => request("POST", "hardlink", { body: { from, to } }),
|
|
74
|
+
symlink: (target, link) => request("POST", "symlink", { body: { target, link } }),
|
|
75
|
+
chmod: (path, mode) => request("POST", "chmod", { body: { path, mode } }),
|
|
76
|
+
chown: (path, owner) => request("POST", "chown", { body: { path, owner } }),
|
|
77
|
+
unlink: (path) => request("POST", "unlink", { body: { path } }),
|
|
78
|
+
mkdir: (path) => request("POST", "mkdir", { body: { path } }),
|
|
68
79
|
trash: (paths) => request("POST", "trash", { body: { paths } }),
|
|
80
|
+
trashList: () => request("GET", "trashList"),
|
|
81
|
+
restore: (paths) => request("POST", "restore", { body: { paths } }),
|
|
69
82
|
erase: (paths) => request("POST", "erase", { body: { paths } }),
|
|
70
83
|
touch: (path) => request("POST", "touch", { body: { path } }),
|
|
71
84
|
home: () => info.home,
|
package/src/js/tcmd/web/index.js
CHANGED
|
@@ -12,8 +12,8 @@ import { createWebApp } from "./app.js"
|
|
|
12
12
|
|
|
13
13
|
// Function: main
|
|
14
14
|
// Mounts the web UI for `session` into `selector` (default "#app").
|
|
15
|
-
function main(session, selector = "#app") {
|
|
16
|
-
const App = createWebApp(session)
|
|
15
|
+
function main(session, selector = "#app", options = {}) {
|
|
16
|
+
const App = createWebApp(session, options)
|
|
17
17
|
return App.new().mount(selector)
|
|
18
18
|
}
|
|
19
19
|
|
package/src/js/tcmd/web/opfs.js
CHANGED
|
@@ -15,9 +15,17 @@
|
|
|
15
15
|
// creating parents) backs the demo seeding in `web/main.js`.
|
|
16
16
|
//
|
|
17
17
|
// Trash: OPFS has no system trash, so `trash(paths)` moves entries into a
|
|
18
|
-
// `/.trash` directory at the storage root,
|
|
18
|
+
// `/.trash` directory at the storage root, recording original paths in
|
|
19
|
+
// `/.trash/index.json` so `trashList()`/`restore()` can browse and undelete
|
|
20
|
+
// them across reloads.
|
|
19
21
|
|
|
22
|
+
import { mapLimit } from "../core/concurrency.js"
|
|
20
23
|
import { basename, extname, join, normalize, parent, split } from "../core/path.js"
|
|
24
|
+
import { PREVIEW_BYTES } from "../core/preview.js"
|
|
25
|
+
|
|
26
|
+
// Constant: OPFS_READDIR_CONCURRENCY
|
|
27
|
+
// Bounded window for the per-entry `getFile` metadata calls of a listing.
|
|
28
|
+
const OPFS_READDIR_CONCURRENCY = 64
|
|
21
29
|
|
|
22
30
|
// Function: createOpfsFs
|
|
23
31
|
// Builds the filesystem adapter. `root` overrides the OPFS root handle (tests);
|
|
@@ -129,23 +137,36 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
|
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
// Function: readDir
|
|
132
|
-
// Lists `path` as FileEntry-like rows.
|
|
140
|
+
// Lists `path` as FileEntry-like rows. Directory handles arrive through an
|
|
141
|
+
// async iterator, but each file's metadata (`getFile`) is an independent
|
|
142
|
+
// async call, so the entries are mapped with a bounded window instead of
|
|
143
|
+
// one await at a time.
|
|
133
144
|
async function readDir(path) {
|
|
134
145
|
const directory = await resolveDir(normalize(path))
|
|
135
|
-
const
|
|
136
|
-
for await (const handle of directory.values())
|
|
137
|
-
return
|
|
146
|
+
const handles = []
|
|
147
|
+
for await (const handle of directory.values()) handles.push(handle)
|
|
148
|
+
return mapLimit(handles, OPFS_READDIR_CONCURRENCY, (handle) => toEntry(path, handle))
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
// Function: readTextHead
|
|
141
152
|
// Leading `maxBytes` of a text file, decoded as UTF-8.
|
|
142
|
-
async function readTextHead(path, maxBytes =
|
|
153
|
+
async function readTextHead(path, maxBytes = PREVIEW_BYTES) {
|
|
143
154
|
const handle = await resolveFile(normalize(path))
|
|
144
155
|
if (!handle) throw notFound(path)
|
|
145
156
|
const file = await handle.getFile()
|
|
146
157
|
return file.slice(0, maxBytes).text()
|
|
147
158
|
}
|
|
148
159
|
|
|
160
|
+
// Function: readBytes
|
|
161
|
+
// Leading `maxBytes` of a file as raw bytes, for the compressed previews
|
|
162
|
+
// `core/session` decompresses.
|
|
163
|
+
async function readBytes(path, maxBytes = PREVIEW_BYTES) {
|
|
164
|
+
const handle = await resolveFile(normalize(path))
|
|
165
|
+
if (!handle) throw notFound(path)
|
|
166
|
+
const file = await handle.getFile()
|
|
167
|
+
return new Uint8Array(await file.slice(0, maxBytes).arrayBuffer())
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
// Function: du
|
|
150
171
|
// Recursive size in bytes; unreadable entries contribute 0.
|
|
151
172
|
async function du(path) {
|
|
@@ -176,14 +197,19 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
|
|
|
176
197
|
// Function: copy
|
|
177
198
|
// Recursive copy of a file or directory; an existing directory target is
|
|
178
199
|
// merged into (the operations layer already appends the basename when the
|
|
179
|
-
// user copies "into" a directory).
|
|
200
|
+
// user copies "into" a directory), but colliding entries are never replaced.
|
|
180
201
|
async function copy(from, to) {
|
|
202
|
+
assertOutsideTrash(to)
|
|
203
|
+
return withPathLock(to, () => copyEntry(from, to))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function copyEntry(from, to) {
|
|
207
|
+
await assertCopySafe(from, to)
|
|
181
208
|
const stats = await stat(from)
|
|
182
|
-
if (!stats) throw notFound(from)
|
|
183
209
|
if (stats.isDir) {
|
|
184
210
|
if (!(await stat(to))?.isDir) await ensureDir(to)
|
|
185
211
|
for await (const handle of (await resolveDir(from)).values()) {
|
|
186
|
-
await
|
|
212
|
+
await copyEntry(join(from, handle.name), join(to, handle.name))
|
|
187
213
|
}
|
|
188
214
|
return
|
|
189
215
|
}
|
|
@@ -195,6 +221,25 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
|
|
|
195
221
|
await writable.close()
|
|
196
222
|
}
|
|
197
223
|
|
|
224
|
+
async function assertCopySafe(from, to) {
|
|
225
|
+
const sourcePath = normalize(from)
|
|
226
|
+
const targetPath = normalize(to)
|
|
227
|
+
const source = await stat(sourcePath)
|
|
228
|
+
if (!source) throw notFound(from)
|
|
229
|
+
if (
|
|
230
|
+
sourcePath === targetPath ||
|
|
231
|
+
(source.isDir && targetPath.startsWith(sourcePath === "/" ? "/" : `${sourcePath}/`))
|
|
232
|
+
) {
|
|
233
|
+
throw new Error(`cannot copy a path into itself: ${to}`)
|
|
234
|
+
}
|
|
235
|
+
const destination = await stat(targetPath)
|
|
236
|
+
if (!destination) return
|
|
237
|
+
if (!source.isDir || !destination.isDir) throw pathExistsError(to)
|
|
238
|
+
for (const entry of await readDir(sourcePath)) {
|
|
239
|
+
await assertCopySafe(entry.path, join(targetPath, entry.name))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
198
243
|
// Function: eraseOne
|
|
199
244
|
// Removes one entry (recursively for directories); the root is a no-op.
|
|
200
245
|
async function eraseOne(path) {
|
|
@@ -204,19 +249,174 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
|
|
|
204
249
|
await directory.removeEntry(basename(normalized), { recursive: true })
|
|
205
250
|
}
|
|
206
251
|
|
|
252
|
+
// Function: mkdir
|
|
253
|
+
// Creates `path` (and its parents) as a directory.
|
|
254
|
+
async function mkdir(path) {
|
|
255
|
+
const normalized = normalize(path)
|
|
256
|
+
assertOutsideTrash(normalized)
|
|
257
|
+
if (normalized === "/") return
|
|
258
|
+
return withPathLock(normalized, () => ensureDir(normalized))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Constant: TRASH_DIR / TRASH_INDEX
|
|
262
|
+
// Trashed entries live under `/.trash/<name>` and the original paths are
|
|
263
|
+
// recorded in `/.trash/index.json`, so a restore survives a reload.
|
|
264
|
+
const TRASH_DIR = "/.trash"
|
|
265
|
+
const TRASH_INDEX = `${TRASH_DIR}/index.json`
|
|
266
|
+
|
|
267
|
+
// Function: readTrashIndex
|
|
268
|
+
// The trash index rows, or [] when the index is missing.
|
|
269
|
+
async function readTrashIndex() {
|
|
270
|
+
try {
|
|
271
|
+
const handle = await resolveFile(TRASH_INDEX)
|
|
272
|
+
if (!handle) return []
|
|
273
|
+
const parsed = JSON.parse(await (await handle.getFile()).text())
|
|
274
|
+
if (!Array.isArray(parsed)) throw new Error("invalid OPFS trash index")
|
|
275
|
+
return parsed
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error?.code === "ENOENT" || error?.name === "NotFoundError") return []
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function writeTrashIndex(rows) {
|
|
283
|
+
await writeTextFile(TRASH_INDEX, JSON.stringify(rows))
|
|
284
|
+
}
|
|
285
|
+
|
|
207
286
|
// Function: trash
|
|
208
|
-
// Moves entries into `/.trash` under a timestamped name,
|
|
209
|
-
//
|
|
287
|
+
// Moves entries into `/.trash` under a timestamped name, recording each
|
|
288
|
+
// original path in the index so it can be listed and restored.
|
|
210
289
|
let trashSeq = 0
|
|
290
|
+
let trashQueue = Promise.resolve()
|
|
291
|
+
const pathQueues = new Map()
|
|
292
|
+
function withPathLocks(paths, action) {
|
|
293
|
+
const keys = [
|
|
294
|
+
...new Set(
|
|
295
|
+
paths.flatMap((path) => {
|
|
296
|
+
const chain = []
|
|
297
|
+
let current = normalize(path)
|
|
298
|
+
while (current !== "/") {
|
|
299
|
+
chain.push(current)
|
|
300
|
+
current = parent(current) ?? "/"
|
|
301
|
+
}
|
|
302
|
+
if (!chain.length) chain.push("/")
|
|
303
|
+
return chain
|
|
304
|
+
}),
|
|
305
|
+
),
|
|
306
|
+
].sort()
|
|
307
|
+
const locks = globalThis.navigator?.locks
|
|
308
|
+
const run = (index) => {
|
|
309
|
+
if (index === keys.length) return action()
|
|
310
|
+
const key = keys[index]
|
|
311
|
+
if (locks?.request) return locks.request(`tcmd-opfs-path:${key}`, { mode: "exclusive" }, () => run(index + 1))
|
|
312
|
+
const previous = pathQueues.get(key) ?? Promise.resolve()
|
|
313
|
+
let release
|
|
314
|
+
const gate = new Promise((resolve) => (release = resolve))
|
|
315
|
+
const tail = previous.then(() => gate)
|
|
316
|
+
pathQueues.set(key, tail)
|
|
317
|
+
return previous.then(() => run(index + 1)).finally(() => {
|
|
318
|
+
release()
|
|
319
|
+
if (pathQueues.get(key) === tail) pathQueues.delete(key)
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
return run(0)
|
|
323
|
+
}
|
|
324
|
+
const withPathLock = (path, action) => withPathLocks([path], action)
|
|
325
|
+
|
|
326
|
+
function withTrashLock(action) {
|
|
327
|
+
const locks = globalThis.navigator?.locks
|
|
328
|
+
if (locks?.request) return locks.request("tcmd-opfs-trash-index", { mode: "exclusive" }, action)
|
|
329
|
+
// ponytail: fallback serialization is adapter-local; Web Locks handles tabs where available.
|
|
330
|
+
const pending = trashQueue.then(action, action)
|
|
331
|
+
trashQueue = pending.catch(() => {})
|
|
332
|
+
return pending
|
|
333
|
+
}
|
|
334
|
+
|
|
211
335
|
async function trash(paths) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
336
|
+
return withTrashLock(async () => {
|
|
337
|
+
await ensureDir(TRASH_DIR)
|
|
338
|
+
const index = await readTrashIndex()
|
|
339
|
+
const trashed = []
|
|
340
|
+
for (const path of paths) {
|
|
341
|
+
const normalized = normalize(path)
|
|
342
|
+
if (normalized === "/") continue
|
|
343
|
+
assertOutsideTrash(normalized)
|
|
344
|
+
await withPathLocks([normalized, TRASH_DIR], async () => {
|
|
345
|
+
let name
|
|
346
|
+
let to
|
|
347
|
+
do {
|
|
348
|
+
name = `${Date.now()}-${++trashSeq}-${basename(normalized)}`
|
|
349
|
+
to = join(TRASH_DIR, name)
|
|
350
|
+
} while (await stat(to))
|
|
351
|
+
await copyEntry(normalized, to)
|
|
352
|
+
const row = { name, originalPath: normalized, deletedAt: new Date().toISOString() }
|
|
353
|
+
index.push(row)
|
|
354
|
+
try {
|
|
355
|
+
await writeTrashIndex(index)
|
|
356
|
+
} catch (error) {
|
|
357
|
+
index.pop()
|
|
358
|
+
await eraseOne(to)
|
|
359
|
+
throw error
|
|
360
|
+
}
|
|
361
|
+
trashed.push({ from: normalized, to, name })
|
|
362
|
+
// Persist the restore record before removing the source. If removing
|
|
363
|
+
// it fails, both copies remain discoverable rather than losing the
|
|
364
|
+
// only restore metadata.
|
|
365
|
+
await eraseOne(normalized)
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
return trashed
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Function: trashList
|
|
373
|
+
// The trash rows, newest first. Entries whose copy is gone are skipped.
|
|
374
|
+
async function trashList() {
|
|
375
|
+
const index = await readTrashIndex()
|
|
376
|
+
const rows = []
|
|
377
|
+
for (const row of index) {
|
|
378
|
+
const trashPath = join(TRASH_DIR, row.name)
|
|
379
|
+
const stats = await stat(trashPath)
|
|
380
|
+
if (!stats) continue
|
|
381
|
+
rows.push({
|
|
382
|
+
name: row.name,
|
|
383
|
+
originalPath: row.originalPath ?? null,
|
|
384
|
+
deletedAt: row.deletedAt ?? null,
|
|
385
|
+
trashPath,
|
|
386
|
+
isDir: Boolean(stats.isDir),
|
|
387
|
+
})
|
|
219
388
|
}
|
|
389
|
+
return rows.sort((a, b) => `${b.deletedAt ?? ""}`.localeCompare(`${a.deletedAt ?? ""}`))
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Function: restore
|
|
393
|
+
// Moves trashed entries back to their recorded original paths.
|
|
394
|
+
async function restore(trashPaths) {
|
|
395
|
+
return withTrashLock(async () => {
|
|
396
|
+
const wanted = new Set((trashPaths ?? []).map((path) => normalize(path)))
|
|
397
|
+
const index = await readTrashIndex()
|
|
398
|
+
const rows = index.filter((row) => wanted.has(normalize(join(TRASH_DIR, row.name))) && row.originalPath)
|
|
399
|
+
if (rows.length !== wanted.size) throw new Error("unknown trash entry")
|
|
400
|
+
const destinations = rows.map((row) => normalize(row.originalPath))
|
|
401
|
+
if (new Set(destinations).size !== destinations.length) throw pathExistsError(destinations[0])
|
|
402
|
+
for (const destination of destinations) assertOutsideTrash(destination)
|
|
403
|
+
assertNoNestedDestinations(destinations)
|
|
404
|
+
for (const row of rows) {
|
|
405
|
+
if (await stat(row.originalPath)) throw pathExistsError(row.originalPath)
|
|
406
|
+
}
|
|
407
|
+
let remaining = index.slice()
|
|
408
|
+
for (const row of rows) {
|
|
409
|
+
const trashPath = join(TRASH_DIR, row.name)
|
|
410
|
+
await withPathLocks([trashPath, row.originalPath], async () => {
|
|
411
|
+
await ensureDir(parent(row.originalPath) ?? "/")
|
|
412
|
+
if (await stat(row.originalPath)) throw pathExistsError(row.originalPath)
|
|
413
|
+
await copyEntry(trashPath, row.originalPath)
|
|
414
|
+
remaining = remaining.filter((candidate) => candidate !== row)
|
|
415
|
+
await writeTrashIndex(remaining)
|
|
416
|
+
await eraseOne(trashPath)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
})
|
|
220
420
|
}
|
|
221
421
|
|
|
222
422
|
// Function: touch
|
|
@@ -224,21 +424,29 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
|
|
|
224
424
|
// (OPFS exposes no utimes).
|
|
225
425
|
async function touch(path) {
|
|
226
426
|
const normalized = normalize(path)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
427
|
+
assertOutsideTrash(normalized)
|
|
428
|
+
return withPathLock(normalized, async () => {
|
|
429
|
+
const stats = await stat(normalized)
|
|
430
|
+
if (stats?.isDir) return
|
|
431
|
+
const directory = await resolveDir(parent(normalized) ?? "/")
|
|
432
|
+
const handle = await directory.getFileHandle(basename(normalized), { create: true })
|
|
433
|
+
if (!stats) return
|
|
434
|
+
const bytes = await (await handle.getFile()).arrayBuffer()
|
|
435
|
+
const writable = await handle.createWritable()
|
|
436
|
+
await writable.write(bytes)
|
|
437
|
+
await writable.close()
|
|
438
|
+
})
|
|
236
439
|
}
|
|
237
440
|
|
|
238
441
|
// Function: writeText
|
|
239
442
|
// Creates (or replaces) a UTF-8 text file, creating parent directories.
|
|
240
443
|
async function writeText(path, text) {
|
|
241
444
|
const normalized = normalize(path)
|
|
445
|
+
assertOutsideTrash(normalized)
|
|
446
|
+
return withPathLock(normalized, () => writeTextFile(normalized, text))
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function writeTextFile(normalized, text) {
|
|
242
450
|
const directory = await resolveDir(parent(normalized) ?? "/", { create: true })
|
|
243
451
|
const handle = await directory.getFileHandle(basename(normalized), { create: true })
|
|
244
452
|
const writable = await handle.createWritable()
|
|
@@ -246,21 +454,64 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
|
|
|
246
454
|
await writable.close()
|
|
247
455
|
}
|
|
248
456
|
|
|
457
|
+
function assertOutsideTrash(path) {
|
|
458
|
+
const normalized = normalize(path)
|
|
459
|
+
if (normalized === TRASH_DIR || normalized.startsWith(`${TRASH_DIR}/`)) {
|
|
460
|
+
const error = new Error(`reserved OPFS trash path: ${normalized}`)
|
|
461
|
+
error.code = "EPERM"
|
|
462
|
+
throw error
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
249
466
|
return {
|
|
250
467
|
readDir,
|
|
251
468
|
stat,
|
|
469
|
+
async realpath(path) {
|
|
470
|
+
const normalized = normalize(path)
|
|
471
|
+
if (!(await stat(normalized))) throw notFound(path)
|
|
472
|
+
return normalized
|
|
473
|
+
},
|
|
252
474
|
isDir: async (path) => Boolean((await stat(path))?.isDir),
|
|
253
475
|
readTextHead,
|
|
476
|
+
readBytes,
|
|
254
477
|
du,
|
|
255
478
|
disk,
|
|
256
479
|
copy,
|
|
257
480
|
move: async (from, to) => {
|
|
258
|
-
|
|
259
|
-
|
|
481
|
+
assertOutsideTrash(from)
|
|
482
|
+
assertOutsideTrash(to)
|
|
483
|
+
return withPathLocks([from, to], async () => {
|
|
484
|
+
if (await stat(to)) throw pathExistsError(to)
|
|
485
|
+
await copyEntry(from, to)
|
|
486
|
+
await eraseOne(from)
|
|
487
|
+
})
|
|
488
|
+
},
|
|
489
|
+
// OPFS has no links, ownership or permission modes. The operations
|
|
490
|
+
// report a clear error instead of silently doing nothing.
|
|
491
|
+
hardlink: async () => {
|
|
492
|
+
throw new Error("hard links are not available in the browser filesystem")
|
|
493
|
+
},
|
|
494
|
+
symlink: async () => {
|
|
495
|
+
throw new Error("symbolic links are not available in the browser filesystem")
|
|
260
496
|
},
|
|
497
|
+
chmod: async () => {
|
|
498
|
+
throw new Error("permission modes are not available in the browser filesystem")
|
|
499
|
+
},
|
|
500
|
+
chown: async () => {
|
|
501
|
+
throw new Error("ownership is not available in the browser filesystem")
|
|
502
|
+
},
|
|
503
|
+
unlink: async (path) => {
|
|
504
|
+
throw new Error(`not a symbolic link: ${path}`)
|
|
505
|
+
},
|
|
506
|
+
mkdir,
|
|
261
507
|
trash,
|
|
508
|
+
trashList,
|
|
509
|
+
restore,
|
|
262
510
|
erase: async (paths) => {
|
|
263
|
-
for (const path of paths)
|
|
511
|
+
for (const path of paths) {
|
|
512
|
+
assertOutsideTrash(path)
|
|
513
|
+
await withPathLock(path, () => eraseOne(path))
|
|
514
|
+
}
|
|
264
515
|
},
|
|
265
516
|
touch,
|
|
266
517
|
writeText,
|
|
@@ -277,6 +528,22 @@ function notFound(path) {
|
|
|
277
528
|
return error
|
|
278
529
|
}
|
|
279
530
|
|
|
531
|
+
function pathExistsError(path) {
|
|
532
|
+
const error = new Error(`destination already exists: ${path}`)
|
|
533
|
+
error.code = "EEXIST"
|
|
534
|
+
return error
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function assertNoNestedDestinations(paths) {
|
|
538
|
+
for (let i = 0; i < paths.length; i++) {
|
|
539
|
+
for (let j = i + 1; j < paths.length; j++) {
|
|
540
|
+
if (paths[j].startsWith(`${paths[i]}/`) || paths[i].startsWith(`${paths[j]}/`)) {
|
|
541
|
+
throw pathExistsError(paths[j])
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
280
547
|
export { createOpfsFs }
|
|
281
548
|
|
|
282
549
|
// EOF
|
package/src/js/tcmd/web/seed.js
CHANGED
|
@@ -23,7 +23,7 @@ const DEMO_TREE = {
|
|
|
23
23
|
"- Left/Backspace go to the parent directory",
|
|
24
24
|
"- Tab switch pane",
|
|
25
25
|
"- Space mark the current entry",
|
|
26
|
-
"- b
|
|
26
|
+
"- b bookmarks (filter, add current path, Enter open, Del remove)",
|
|
27
27
|
"- : command panel (operations and app actions)",
|
|
28
28
|
"- ! shell command",
|
|
29
29
|
"- q quit (unmounts the UI; reload to start over)",
|