@ssebastien/tcmd 0.1.3 → 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 (134) hide show
  1. package/README.md +256 -62
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/desktop/dist/index.html +13 -0
  5. package/desktop/dist/main.js +20298 -0
  6. package/desktop/dist/style.css +1515 -0
  7. package/desktop/frontend/index.html +13 -0
  8. package/package.json +19 -6
  9. package/public/fonts/OFL.txt +93 -0
  10. package/public/index.html +14 -0
  11. package/public/licenses/LUCIDE.txt +43 -0
  12. package/public/main.js +20679 -0
  13. package/public/style.css +1515 -0
  14. package/scripts/build-desktop.js +31 -0
  15. package/scripts/serve-web.js +61 -0
  16. package/src/js/tcmd/components/App.html +80 -14
  17. package/src/js/tcmd/components/App.js +339 -114
  18. package/src/js/tcmd/components/App.xml +76 -22
  19. package/src/js/tcmd/components/BookmarkList.html +5 -5
  20. package/src/js/tcmd/components/BookmarkList.js +15 -41
  21. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  22. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  23. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  24. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  25. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  26. package/src/js/tcmd/components/CommandArgRow.html +28 -0
  27. package/src/js/tcmd/components/CommandArgRow.js +146 -30
  28. package/src/js/tcmd/components/CommandArgRow.xml +55 -4
  29. package/src/js/tcmd/components/CommandBar.html +2 -2
  30. package/src/js/tcmd/components/CommandBar.js +33 -27
  31. package/src/js/tcmd/components/CommandBar.xml +2 -2
  32. package/src/js/tcmd/components/CommandItem.html +1 -1
  33. package/src/js/tcmd/components/CommandItem.js +25 -25
  34. package/src/js/tcmd/components/CommandItem.xml +2 -2
  35. package/src/js/tcmd/components/CommandPanel.html +29 -0
  36. package/src/js/tcmd/components/CommandPanel.js +78 -52
  37. package/src/js/tcmd/components/CommandPanel.xml +49 -25
  38. package/src/js/tcmd/components/Confirm.html +2 -2
  39. package/src/js/tcmd/components/EntryDetail.html +7 -7
  40. package/src/js/tcmd/components/EntryDetail.js +22 -8
  41. package/src/js/tcmd/components/FileList.html +5 -1
  42. package/src/js/tcmd/components/FileList.js +2 -1
  43. package/src/js/tcmd/components/FilePicker.html +10 -0
  44. package/src/js/tcmd/components/FilePicker.js +43 -0
  45. package/src/js/tcmd/components/FilePicker.xml +13 -0
  46. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  47. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  48. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  49. package/src/js/tcmd/components/FilePreview.html +14 -4
  50. package/src/js/tcmd/components/FilePreview.js +111 -12
  51. package/src/js/tcmd/components/FilePreview.xml +28 -8
  52. package/src/js/tcmd/components/FileRow.html +2 -1
  53. package/src/js/tcmd/components/FileRow.js +70 -12
  54. package/src/js/tcmd/components/FilterBar.html +1 -1
  55. package/src/js/tcmd/components/FilterBar.js +14 -4
  56. package/src/js/tcmd/components/FilterBar.xml +1 -1
  57. package/src/js/tcmd/components/Frame.js +324 -0
  58. package/src/js/tcmd/components/Help.html +15 -0
  59. package/src/js/tcmd/components/Help.js +260 -0
  60. package/src/js/tcmd/components/Help.xml +13 -4
  61. package/src/js/tcmd/components/HelpRow.html +8 -0
  62. package/src/js/tcmd/components/HelpRow.xml +7 -0
  63. package/src/js/tcmd/components/OverlayList.html +8 -0
  64. package/src/js/tcmd/components/OverlayList.js +69 -0
  65. package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
  66. package/src/js/tcmd/components/OverlayRow.html +5 -0
  67. package/src/js/tcmd/components/OverlayRow.js +47 -0
  68. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  69. package/src/js/tcmd/components/Summary.html +1 -1
  70. package/src/js/tcmd/components/Summary.js +1 -6
  71. package/src/js/tcmd/components/format.js +4 -23
  72. package/src/js/tcmd/components/icons.js +176 -5
  73. package/src/js/tcmd/components/index.js +9 -5
  74. package/src/js/tcmd/components/rows.js +176 -48
  75. package/src/js/tcmd/core/base64.js +64 -0
  76. package/src/js/tcmd/core/bookmarks.js +45 -8
  77. package/src/js/tcmd/core/commands.js +978 -260
  78. package/src/js/tcmd/core/concurrency.js +59 -0
  79. package/src/js/tcmd/core/decompress.js +117 -0
  80. package/src/js/tcmd/core/directory.js +219 -93
  81. package/src/js/tcmd/core/format.js +36 -0
  82. package/src/js/tcmd/core/fs.js +438 -54
  83. package/src/js/tcmd/core/history.js +58 -0
  84. package/src/js/tcmd/core/index.js +3 -9
  85. package/src/js/tcmd/core/journal.js +93 -0
  86. package/src/js/tcmd/core/keymap.js +42 -6
  87. package/src/js/tcmd/core/operations.js +813 -48
  88. package/src/js/tcmd/core/os.js +29 -3
  89. package/src/js/tcmd/core/pane.js +33 -8
  90. package/src/js/tcmd/core/panel.js +280 -104
  91. package/src/js/tcmd/core/path.js +30 -3
  92. package/src/js/tcmd/core/permissions.js +107 -0
  93. package/src/js/tcmd/core/picker.js +156 -0
  94. package/src/js/tcmd/core/preview.js +102 -0
  95. package/src/js/tcmd/core/script.js +7 -95
  96. package/src/js/tcmd/core/selection.js +9 -1
  97. package/src/js/tcmd/core/session.js +498 -200
  98. package/src/js/tcmd/core/watch.js +153 -0
  99. package/src/js/tcmd/desktop/backend/api.js +165 -0
  100. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  101. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  102. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  103. package/src/js/tcmd/desktop/fs.js +91 -0
  104. package/src/js/tcmd/desktop/main.js +194 -0
  105. package/src/js/tcmd/desktop/os.js +33 -0
  106. package/src/js/tcmd/index.js +0 -1
  107. package/src/js/tcmd/tui/app.js +118 -60
  108. package/src/js/tcmd/tui/catalogue.js +238 -42
  109. package/src/js/tcmd/tui/index.js +18 -19
  110. package/src/js/tcmd/tui/opentui.js +33 -0
  111. package/src/js/tcmd/tui/syntax.js +91 -0
  112. package/src/js/tcmd/tui/theme.js +32 -2
  113. package/src/js/tcmd/vendor/bzip2.js +276 -0
  114. package/src/js/tcmd/web/app.js +225 -41
  115. package/src/js/tcmd/web/bookmarks.js +36 -0
  116. package/src/js/tcmd/web/catalogue.js +81 -38
  117. package/src/js/tcmd/web/effects.js +223 -0
  118. package/src/js/tcmd/web/header.html +1 -1
  119. package/src/js/tcmd/web/header.js +15 -11
  120. package/src/js/tcmd/web/http.js +13 -0
  121. package/src/js/tcmd/web/index.js +2 -2
  122. package/src/js/tcmd/web/main.js +6 -66
  123. package/src/js/tcmd/web/opfs.js +297 -30
  124. package/src/js/tcmd/web/seed.js +4 -4
  125. package/src/js/tcmd/web/server.js +124 -21
  126. package/src/js/tcmd/web/theme.js +4 -0
  127. package/tinyjs.json +16 -0
  128. package/src/js/tcmd/components/CommandPalette.html +0 -7
  129. package/src/js/tcmd/components/CommandPalette.js +0 -58
  130. package/src/js/tcmd/components/PaletteRow.html +0 -3
  131. package/src/js/tcmd/components/PaletteRow.js +0 -43
  132. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  133. package/src/js/tcmd/tui/frame.js +0 -269
  134. package/src/js/tcmd/tui/help.js +0 -32
@@ -0,0 +1,223 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-13
5
+
6
+ // Module: tcmd/web/effects
7
+ // DOM effects owned by the mounted App: viewport measurement, modal focus,
8
+ // default-key trapping and keeping the keyboard cursor visible. select.js
9
+ // renders without any of this, so the web binding owns it explicitly.
10
+
11
+ import { effect } from "select/state/cells"
12
+
13
+ const canvases = new WeakMap()
14
+
15
+ function isToolbarTarget(target) {
16
+ return Boolean(target?.closest?.(".desktop-toolbar"))
17
+ }
18
+
19
+ // Function: columnWidth
20
+ // Available monospace columns, or zero in a DOM without layout (happy-dom).
21
+ function columnWidth(el) {
22
+ if (!el?.clientWidth) return 0
23
+ const doc = el.ownerDocument
24
+ const view = doc.defaultView
25
+ let canvas = canvases.get(doc)
26
+ if (!canvas) {
27
+ canvas = doc.createElement("canvas")
28
+ canvases.set(doc, canvas)
29
+ }
30
+ const ctx = canvas.getContext("2d")
31
+ if (!ctx) return 0
32
+ const style = view.getComputedStyle(el)
33
+ ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
34
+ const width = ctx.measureText("0").width
35
+ const padding = (Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0)
36
+ return width ? Math.floor((el.clientWidth - padding) / width) : 0
37
+ }
38
+
39
+ // Function: keepVisible
40
+ // Scroll only the row's own list, never the page or an ancestor overlay.
41
+ function keepVisible(list, row) {
42
+ if (!list?.clientHeight || !row) return
43
+ const listRect = list.getBoundingClientRect()
44
+ const rowRect = row.getBoundingClientRect()
45
+ if (rowRect.top < listRect.top) list.scrollTop -= listRect.top - rowRect.top
46
+ else if (rowRect.bottom > listRect.bottom) list.scrollTop += rowRect.bottom - listRect.bottom
47
+ }
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
+
76
+ // Function: connectEffects
77
+ // Returns a frame scheduler and teardown; the App registers teardown with
78
+ // instance.effect(), so remounting leaves no listeners or observers behind.
79
+ function connectEffects(session, root, { size, help, viewer }) {
80
+ // Select initially clones into an inert template document; the live window
81
+ // is available before mount even though ownerDocument.defaultView is not.
82
+ const view = root.ownerDocument.defaultView ?? globalThis.window
83
+ const doc = view.document
84
+ let frame = null
85
+ let retries = 0
86
+ let disposed = false
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)
90
+ const modalOpen = () => Boolean(session.confirm.value || help.value || viewerOpen() || session.panelOpen.value || session.bookmarkOpen.value)
91
+ const focusTarget = () => {
92
+ if (session.confirm.value) return root.querySelector(".confirm")
93
+ if (help.value) return root.querySelector(".help")
94
+ if (viewerOpen()) return root.querySelector(".viewer")
95
+ if (session.panelOpen.value) {
96
+ if (session.pickerOpen.value) {
97
+ return session.pickerFocus.value === "path"
98
+ ? root.querySelector(".panel-picker-path")
99
+ : root.querySelector(".command-panel")
100
+ }
101
+ if (session.panelFocus.value === "ops") return root.querySelector(".panel-query")
102
+ return root.querySelector(session.panelTextual.value ? ".panel-value" : ".command-panel")
103
+ }
104
+ if (session.bookmarkOpen.value) return root.querySelector(".bookmark-query")
105
+ if (session.filtering.value) return root.querySelector(`.pane-${session.filtering.value} .filter-input`)
106
+ return root
107
+ }
108
+ const update = () => {
109
+ frame = null
110
+ if (disposed || !root.isConnected) return
111
+ const width = columnWidth(root)
112
+ const lineHeight = Number.parseFloat(view.getComputedStyle(root).lineHeight)
113
+ const height = lineHeight ? Math.floor(root.clientHeight / lineHeight) : 0
114
+ if (width && height && (size.value.width !== width || size.value.height !== height)) size.set({ width, height })
115
+ // A modal's field mounts on a later frame than the flag that opens it;
116
+ // retry a few frames before giving up so focus lands on the overlay
117
+ // instead of the root.
118
+ const target = focusTarget()
119
+ if (modalOpen() && !target && retries < 8) {
120
+ retries += 1
121
+ schedule()
122
+ return
123
+ }
124
+ retries = 0
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
129
+ if (focusable !== doc.activeElement) focusable.focus({ preventScroll: true })
130
+ for (const list of root.querySelectorAll(".file-list, .bookmark-rows, .panel-ops, .panel-args")) {
131
+ keepVisible(list, list.querySelector(".is-current, .is-selected, .is-focused"))
132
+ }
133
+ }
134
+ const schedule = () => {
135
+ // Root renders queue child renders on the next frame. Wait past those
136
+ // before focusing a newly mounted field or measuring a changed layout.
137
+ if (!disposed && frame === null) frame = view.requestAnimationFrame(() => {
138
+ frame = view.requestAnimationFrame(update)
139
+ })
140
+ }
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
149
+ const input = event.target?.tagName === "INPUT" || event.target?.tagName === "TEXTAREA"
150
+ // Editing keeps native text/caret keys except the ones the shared
151
+ // controller explicitly owns (vertical arrows, Enter and Tab).
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()
159
+ }
160
+ const focusIn = (event) => {
161
+ if (modalOpen() && event.target !== focusTarget()) schedule()
162
+ }
163
+ root.addEventListener("keydown", trapKeys)
164
+ doc.addEventListener("focusin", focusIn)
165
+ root.addEventListener("focusout", schedule)
166
+ view.addEventListener("resize", schedule)
167
+ const observer = view.ResizeObserver ? new view.ResizeObserver(schedule) : null
168
+ observer?.observe(root)
169
+ // Pane proportions also change when the inspector is toggled.
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
203
+ schedule()
204
+ return {
205
+ schedule,
206
+ dispose() {
207
+ disposed = true
208
+ stopFocusWatch?.()
209
+ stopPreviewWatch?.()
210
+ stopScrollWatch?.()
211
+ if (frame !== null) view.cancelAnimationFrame(frame)
212
+ observer?.disconnect()
213
+ root.removeEventListener("keydown", trapKeys)
214
+ root.removeEventListener("focusout", schedule)
215
+ doc.removeEventListener("focusin", focusIn)
216
+ view.removeEventListener("resize", schedule)
217
+ },
218
+ }
219
+ }
220
+
221
+ export { applyPreviewScroll, columnWidth, connectEffects, isToolbarTarget, keepVisible, resetPreviewScroll }
222
+
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></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>
@@ -4,32 +4,36 @@
4
4
  // Created: 2026-09-27
5
5
 
6
6
  // Module: tcmd/web/header
7
- // Header: the application title bar above the panes, showing
8
- // `TinyCmd - HOST DIR/BASE` for the active pane's directory (compressed to fit).
7
+ // Header: the slim application identity bar, showing `TinyCmd - HOST` and,
8
+ // right-aligned, the view mode and version (`explorer · 0.1.3`).
9
9
  // The brand renders as static spans — `Tiny` with a blue CSS gradient, `Cmd`
10
10
  // with a brighter orange one (both kept in sync with `tui/theme.js`) — so the
11
- // `title` behavior carries only the host and path tail (` - HOST DIR/BASE`).
11
+ // `title` behavior carries only the host tail (` - HOST`), `mode`/`modeClass`
12
+ // the gradiented mode word and `statusSuffix` the version that follows it.
12
13
  //
13
- // Environment: `textHeadingFg`, plus `width(self)` returning the available
14
- // columns (renderer-specific; 0/omitted disables compression).
14
+ // Environment: `textHeadingFg`.
15
15
 
16
16
  import { unwrap } from "select/state/cells"
17
- import { formatTitle } from "../components/format.js"
17
+ import { modeWord, versionTrail } from "../components/Frame.js"
18
18
 
19
19
  // Constant: BRAND
20
- // The brand word the static header spans render; stripped from the title so
21
- // the tail doesn't repeat it.
20
+ // The brand word the static header spans render.
22
21
  const BRAND = "TinyCmd"
23
22
 
24
23
  // Function: createBehaviors
25
24
  // Builds the Header behavior map for the given renderer environment.
26
25
  function createBehaviors(env) {
27
26
  return {
28
- title: (self, data) => {
29
- const full = formatTitle(unwrap(data.host), unwrap(data.path), env.width ? env.width(self, data) : 0)
30
- return full.startsWith(BRAND) ? full.slice(BRAND.length) : full
27
+ title: (_self, data) => {
28
+ const host = `${unwrap(data.host) ?? ""}`.trim()
29
+ return host ? ` - ${host}` : ""
31
30
  },
32
31
  titleFg: () => env.textHeadingFg,
32
+ // The mode word picks the brand gradient it shimmers along: explorer
33
+ // the Tiny blues, commander the Cmd oranges (see `.app-header-mode`).
34
+ mode: (_self, data) => modeWord(unwrap(data.mode)),
35
+ modeClass: (_self, data) => `mode-${modeWord(unwrap(data.mode))}`,
36
+ statusSuffix: () => versionTrail(),
33
37
  }
34
38
  }
35
39
 
@@ -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
 
@@ -13,13 +13,13 @@
13
13
  // preferred and HTTP is the fallback, so browsers without OPFS (or insecure
14
14
  // origins) still work when the page is served by `bun run web:serve`.
15
15
  //
16
- // select.js renders on animation frames and does no focus management, so this
17
- // module adds the two browser bits the shared components cannot know about:
18
- // default-key trapping and focus tracking.
16
+ // DOM effects (focus, key trapping, resize, scroll) live in `web/effects.js`
17
+ // and are wired by the App binding in `web/app.js`; bookmarks persist through
18
+ // `web/bookmarks.js`, namespaced by backend and API root.
19
19
  //
20
20
  // Built by `bun run web:build` (see package.json) into `public/main.js`.
21
21
 
22
- import { effect } from "select/state/cells"
22
+ import { createBookmarks } from "../core/bookmarks.js"
23
23
  import { createNullOs } from "../core/os.js"
24
24
  import { createPane } from "../core/pane.js"
25
25
  import { createSession, load } from "../core/session.js"
@@ -27,64 +27,7 @@ import { apiToken, createHttpFs } from "./http.js"
27
27
  import { main as mount } from "./index.js"
28
28
  import { createOpfsFs } from "./opfs.js"
29
29
  import { seedOpfs } from "./seed.js"
30
-
31
- // Constant: APP_KEYS
32
- // Keys with browser defaults that would fight the app keymap: focus traversal
33
- // (Tab), caret/scroll arrows, page scroll (Space) and history Backspace.
34
- const APP_KEYS = new Set([
35
- " ",
36
- "ArrowDown",
37
- "ArrowLeft",
38
- "ArrowRight",
39
- "ArrowUp",
40
- "Backspace",
41
- "Enter",
42
- "Tab",
43
- ])
44
-
45
- // Function: trapKeys
46
- // Keeps browser defaults out of the way while the app owns the keyboard. Space
47
- // and Backspace stay untouched in text inputs so the palette query can be
48
- // typed; the app routes every other listed key itself.
49
- function trapKeys(root) {
50
- root?.addEventListener("keydown", (event) => {
51
- const inInput = event.target instanceof HTMLInputElement
52
- if (inInput && (event.key === " " || event.key === "Backspace")) return
53
- if (APP_KEYS.has(event.key)) event.preventDefault()
54
- })
55
- }
56
-
57
- // Function: trackFocus
58
- // The key handler lives on the app root, so focus must stay inside it. The
59
- // palette input is focused while the palette is open (select.js does not), and
60
- // the active pane's filter input while that pane is filtering; focus returns to
61
- // the app root otherwise. Focus that leaves the app — a removed input, a click
62
- // on the page background — is pulled back.
63
- function trackFocus(session, root) {
64
- if (!root) return
65
- const focusInput = () => {
66
- if (session.paletteOpen.value) return document.querySelector("#app .palette-input")
67
- if (session.filtering.value) {
68
- return document.querySelector(`#app .pane-${session.filtering.value} .filter-input`)
69
- }
70
- return null
71
- }
72
- const applyFocus = () => {
73
- // select.js swaps the palette DOM on animation frames: wait for it.
74
- requestAnimationFrame(() => {
75
- const input = focusInput()
76
- if (input) input.focus()
77
- else if (!root.contains(document.activeElement)) root.focus()
78
- })
79
- }
80
- effect([session.paletteOpen, session.filtering], applyFocus)
81
- document.addEventListener("focusout", (event) => {
82
- if (!session.paletteOpen.value && !session.filtering.value && event.relatedTarget && root.contains(event.relatedTarget)) {
83
- return
84
- }
85
- applyFocus()
86
- })
87
- }
30
+ import { createWebBookmarkStore } from "./bookmarks.js"
88
31
 
89
32
  // Function: createOpfsBackend
90
33
  // OPFS adapter, probed and seeded. `readDir("/")` resolves the OPFS root, so
@@ -128,13 +71,10 @@ async function start() {
128
71
  left,
129
72
  right,
130
73
  host: globalThis.location?.hostname ?? "",
74
+ bookmarks: createBookmarks({ store: createWebBookmarkStore({ mode, root: mode === "opfs" ? "/" : fs.root() }) }),
131
75
  })
132
76
  await load(session)
133
77
  const instance = mount(session, "#app")
134
- const root = document.querySelector("#app .app")
135
- root?.focus()
136
- trapKeys(root)
137
- trackFocus(session, root)
138
78
  document.title = `TinyCMD web (${mode})`
139
79
  globalThis.tcmd = { fs, mode, session, instance }
140
80
  } catch (error) {