@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.
Files changed (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. 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 { INSPECTOR_INLINE_WIDTH } from "../components/Frame.js"
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
- const viewerOpen = () => viewer.value && size.value.width > 0 && size.value.width < INSPECTOR_INLINE_WIDTH
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.panelPicking.value) return root.querySelector(".panel-picker-query")
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
- const focusable = target ?? root
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 && [" ", "Backspace", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key)) return
112
- if ([" ", "Backspace", "Enter", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Escape"].includes(event.key) ||
113
- (event.ctrlKey && event.key.toLowerCase() === "r")) event.preventDefault()
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>
@@ -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,
@@ -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
 
@@ -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, where they stay browsable.
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 entries = []
136
- for await (const handle of directory.values()) entries.push(await toEntry(path, handle))
137
- return entries
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 = 65536) {
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 copy(join(from, handle.name), join(to, handle.name))
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, keeping them
209
- // available for inspection instead of deleting them.
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
- 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)
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
- 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()
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
- await copy(from, to)
259
- await eraseOne(from)
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) await eraseOne(path)
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
@@ -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 / g record / jump to a bookmark",
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)",