@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
@@ -9,10 +9,13 @@
9
9
  // `tcmd/tui/app.js` for the DOM backend.
10
10
 
11
11
  import { ui } from "select/ui"
12
- import { unwrap } from "select/state/cells"
12
+ import { cell, derived, unwrap } from "select/state/cells"
13
13
  import appHtml from "../components/App.html" with { type: "text" }
14
14
  import { appBehaviors, appEvents, appInit } from "../components/index.js"
15
15
  import { registerWebComponents } from "./catalogue.js"
16
+ import { elideCrumbs } from "../components/format.js"
17
+ import { createFrameBehaviors, INSPECTOR_INLINE_WIDTH, paneColumnWidth } from "../components/Frame.js"
18
+ import { connectEffects, isToolbarTarget } from "./effects.js"
16
19
  import { webTheme } from "./theme.js"
17
20
 
18
21
  // Map DOM key names to the core keymap chord names.
@@ -45,108 +48,289 @@ function normalizeKey(event) {
45
48
  // Child data keeps the session cells (as `in:` slots do in the TUI), so the
46
49
  // child instances subscribe and update granularly; behaviors read cells with
47
50
  // `unwrap` at the point of use.
51
+
52
+ // The classes one pane column carries: focus (`is-active`) and, in explorer
53
+ // mode, the extra width of the middle (`is-major`) column.
54
+ function paneClass(data, paneId) {
55
+ const active = unwrap(data.active) === paneId
56
+ const major = unwrap(data.viewMode) === "explorer" && `${unwrap(data.currentId)}` === paneId
57
+ return [active && webTheme.activeClass, major && "is-major"].filter(Boolean).join(" ") || null
58
+ }
59
+
48
60
  function webViewBehaviors() {
49
61
  return {
50
- headerView: (_self, data) => ({ host: data.appHost, path: data.appPath }),
62
+ // The view mode as an app-level class (`is-explorer` / `is-commander`),
63
+ // so the CSS can tint the active pane per mode (blue/gold).
64
+ appModeClass: (_self, data) => [
65
+ `is-${unwrap(data.viewMode) === "explorer" ? "explorer" : "commander"}`,
66
+ unwrap(data.desktopMode) ? "is-desktop" : null,
67
+ ].filter(Boolean).join(" "),
68
+ headerView: (_self, data) => ({ host: data.appHost, path: data.appPath, mode: data.viewMode }),
69
+ desktopViewLabel: (_self, data) => unwrap(data.viewMode) === "explorer" ? "Explorer" : "Commander",
70
+ desktopPreviewLabel: (_self, data) => unwrap(data.showPreview) ? "Hide preview" : "Show preview",
51
71
  bookmarkView: (_self, data) => ({
52
72
  items: data.bookmarkItems,
53
- mode: data.bookmarkMode,
54
73
  open: data.bookmarkOpen,
55
74
  query: data.bookmarkQuery,
56
75
  selected: data.bookmarkIndex,
57
- target: data.bookmarkTarget,
58
76
  }),
59
77
  commandBarView: (_self, data) => ({
60
78
  items: data.commandBarItems,
79
+ desktopMode: data.desktopMode,
61
80
  leftError: data.leftError,
62
81
  notice: data.notice,
63
82
  rightError: data.rightError,
83
+ active: data.active,
84
+ leftMarked: data.leftMarked,
85
+ rightMarked: data.rightMarked,
86
+ uiSize: data.uiSize,
64
87
  }),
65
88
  confirmView: (_self, data) => ({ confirm: data.confirm }),
66
89
  leftCrumbsView: (_self, data) => ({
67
90
  segments: data.leftSegments,
68
91
  paneId: data.leftId,
69
92
  }),
70
- leftDetailView: (_self, data) => ({ entry: data.leftEntry, marked: data.leftMarked }),
93
+ leftDetailView: (_self, data) => ({ entry: data.leftEntry, marked: data.leftMarked, width: data.paneWidth }),
71
94
  leftFilterView: (_self, data) => ({
72
95
  query: data.leftFilter,
73
96
  paneId: data.leftId,
74
97
  filtering: data.filtering,
75
98
  entries: data.leftEntries,
99
+ setFilter: data.leftSetFilter,
76
100
  }),
77
- leftLabelView: (_self, data) => ({ paneId: data.leftId, active: data.active }),
78
101
  leftListView: (_self, data) => ({
79
102
  entries: data.leftEntries,
80
103
  cursor: data.leftCursor,
81
104
  marked: data.leftMarked,
82
105
  paneId: data.leftId,
106
+ active: data.leftActive,
107
+ loading: data.leftLoading,
83
108
  }),
84
- leftPaneClass: (_self, data) => (unwrap(data.active) === "left" ? webTheme.activeClass : null),
85
- leftSummaryView: (_self, data) => ({
86
- entries: data.leftEntries,
87
- disk: data.leftDisk,
109
+ leftPaneClass: (_self, data) => paneClass(data, "left"),
110
+ // The explorer's first column: the parent of the current directory,
111
+ // a full pane of its own (header, filter, listing, detail).
112
+ parentCrumbsView: (_self, data) => ({
113
+ segments: data.parentSegments,
114
+ paneId: data.parentId,
115
+ }),
116
+ parentDetailView: (_self, data) => ({
117
+ entry: data.parentEntry,
118
+ marked: data.parentMarked,
119
+ width: data.paneWidth,
120
+ }),
121
+ parentFilterView: (_self, data) => ({
122
+ query: data.parentFilter,
123
+ paneId: data.parentId,
124
+ filtering: data.filtering,
125
+ entries: data.parentEntries,
126
+ setFilter: data.parentSetFilter,
127
+ }),
128
+ parentListView: (_self, data) => ({
129
+ entries: data.parentEntries,
130
+ cursor: data.parentCursor,
131
+ marked: data.parentMarked,
132
+ paneId: data.parentId,
133
+ active: data.parentActive,
134
+ loading: data.parentLoading,
88
135
  }),
89
- paletteView: (_self, data) => ({
90
- // Cells pass through (as `in:` slots do in the TUI); `query` is written
91
- // back by the input's `inout` two-way binding.
92
- items: data.paletteItems,
93
- mode: data.paletteMode,
94
- open: data.paletteOpen,
95
- query: data.paletteQuery,
96
- selected: data.paletteIndex,
136
+ parentPaneClass: (_self, data) => paneClass(data, "parent"),
137
+ panelView: (_self, data) => ({
138
+ // Cells pass through (as `in:` slots do in the TUI); `query`/`value`
139
+ // are written back by the inputs' `inout` two-way bindings.
140
+ open: data.panelOpen,
141
+ entry: data.panelEntry,
142
+ items: data.panelItems,
143
+ opCursor: data.panelOpIndex,
144
+ rows: data.panelRows,
145
+ argCursor: data.panelRowIndex,
146
+ focus: data.panelFocus,
147
+ preview: data.panelPreview,
148
+ picking: data.pickerOpen,
149
+ pickerPathText: data.pickerPathText,
150
+ pickerFocus: data.pickerFocus,
151
+ pickerRows: data.pickerRows,
152
+ query: data.panelQuery,
153
+ value: data.panelValue,
154
+ }),
155
+ helpView: (_self, data) => ({ open: data.helpOpen }),
156
+ overlayView: (_self, data) => ({
157
+ open: data.overlay,
158
+ mode: data.overlay,
159
+ items: data.overlayItems,
160
+ cursor: data.overlayIndex,
97
161
  }),
98
162
  previewView: (_self, data) => ({
99
163
  preview: data.preview,
100
- show: data.showPreview,
164
+ show: true,
165
+ entry: data.appEntry,
166
+ paneId: data.previewId,
167
+ // The directory column: the inspector pane's own listing, so a
168
+ // previewed directory reads and behaves like a pane.
169
+ dirSegments: data.previewSegments,
170
+ dirEntries: data.previewEntries,
171
+ dirCursor: data.previewCursor,
172
+ dirMarked: data.previewMarked,
173
+ dirActive: data.previewActive,
174
+ dirLoading: data.previewLoading,
101
175
  }),
102
176
  rightCrumbsView: (_self, data) => ({
103
177
  segments: data.rightSegments,
104
178
  paneId: data.rightId,
105
179
  }),
106
- rightDetailView: (_self, data) => ({ entry: data.rightEntry, marked: data.rightMarked }),
180
+ rightDetailView: (_self, data) => ({ entry: data.rightEntry, marked: data.rightMarked, width: data.paneWidth }),
107
181
  rightFilterView: (_self, data) => ({
108
182
  query: data.rightFilter,
109
183
  paneId: data.rightId,
110
184
  filtering: data.filtering,
111
185
  entries: data.rightEntries,
186
+ setFilter: data.rightSetFilter,
112
187
  }),
113
- rightLabelView: (_self, data) => ({ paneId: data.rightId, active: data.active }),
114
188
  rightListView: (_self, data) => ({
115
189
  entries: data.rightEntries,
116
190
  cursor: data.rightCursor,
117
191
  marked: data.rightMarked,
118
192
  paneId: data.rightId,
193
+ active: data.rightActive,
194
+ loading: data.rightLoading,
119
195
  }),
120
- rightPaneClass: (_self, data) => (unwrap(data.active) === "right" ? webTheme.activeClass : null),
121
- rightSummaryView: (_self, data) => ({
122
- entries: data.rightEntries,
123
- disk: data.rightDisk,
124
- }),
196
+ rightPaneClass: (_self, data) => paneClass(data, "right"),
125
197
  }
126
198
  }
127
199
 
128
200
  // Function: createWebApp
129
201
  // Builds the root App component for `session` on the web presentation.
130
- function createWebApp(session) {
202
+ function createWebApp(session, options = {}) {
131
203
  registerWebComponents()
132
- // The command panel is a TUI-only view; disable it so no web dispatch path
133
- // (key, palette, command bar) opens an invisible modal, and drop its
134
- // palette entries (`:` and `m` both fall back to the script palette).
135
- session.panelEnabled?.set(false)
136
- const palette = session.paletteCommands?.value
137
- if (palette) {
138
- session.paletteCommands.set(palette.filter((row) => row.id !== "panel.toggle" && row.id !== "panel.open"))
139
- }
204
+ const size = options.size ?? cell({ width: 0, height: 0 })
205
+ const help = cell(false)
206
+ // The fullscreen inspector overlay lives in the session (core), so a
207
+ // command — Enter on a file that cannot be edited — can open it; the shared
208
+ // controller routes its keys (`components/App#createBehaviors`).
209
+ const viewer = session.viewerOpen
210
+ const paneWidth = derived([size, session.showPreview], ([value, show]) => {
211
+ if (!value.width) return 0
212
+ const columns = show && value.width >= INSPECTOR_INLINE_WIDTH ? 3 : 2
213
+ return Math.max(1, Math.floor(value.width / columns) - 4)
214
+ })
215
+ // Breadcrumb budgets follow each pane's OWN share of the row (explorer
216
+ // grows the current column, so its mirrors are narrower than an even
217
+ // split), or the crumbs overflow the narrower columns.
218
+ const crumbWidth = (paneId) =>
219
+ derived([size, session.showPreview, session.viewMode, session.currentId], ([value, showPreview, viewMode, currentId]) => {
220
+ const columns = paneColumnWidth(value?.width, paneId, { viewMode, currentId, showPreview })
221
+ return columns > 0 ? Math.max(1, columns - 4) : 0
222
+ })
223
+ const crumbs = (paneId, pane) =>
224
+ derived([pane.segments, crumbWidth(paneId)], ([segments, width]) => elideCrumbs(segments, width))
225
+ const quit = options.onQuit ?? ((self) => self.unmount?.())
226
+ // DOM inputs have no native `enter` event; the root routes Enter for them.
227
+ const baseKeys = appBehaviors({ normalizeKey, quit }).keys
228
+ const events = appEvents()
140
229
  return ui(appHtml)
141
- .init(appInit(session))
230
+ .init((self) => {
231
+ // The panel's Quit command reaches the host through this hook.
232
+ session.quit = () => quit(self)
233
+ return {
234
+ ...appInit(session)(),
235
+ desktopMode: Boolean(options.desktopMode),
236
+ uiSize: size,
237
+ helpOpen: help,
238
+ viewerOpen: viewer,
239
+ paneWidth,
240
+ leftSegments: crumbs("left", session.panes.left),
241
+ parentSegments: crumbs("parent", session.panes.parent),
242
+ rightSegments: crumbs("right", session.panes.right),
243
+ previewSegments: crumbs("preview", session.panes.preview),
244
+ }
245
+ })
142
246
  .does({
143
- ...appBehaviors({
144
- normalizeKey,
145
- quit: (self) => self.unmount?.(),
146
- }),
247
+ ...createFrameBehaviors({ theme: webTheme }),
147
248
  ...webViewBehaviors(),
249
+ minSizeTitle: () => "Window too small",
250
+ "effects!": (self) => {
251
+ if (!self._effects) self.effect(() => {
252
+ self._effects = connectEffects(session, self.ref.root, { size, help, viewer })
253
+ return () => self._effects.dispose()
254
+ })
255
+ self._effects.schedule()
256
+ },
257
+ keys: (self, data, event) => {
258
+ if (event.isComposing) return
259
+ const busy = unwrap(data.confirm) || unwrap(data.bookmarkOpen) || unwrap(data.panelOpen) || unwrap(data.filtering) || unwrap(data.overlay)
260
+ if (event.key === "F6" && unwrap(data.desktopMode) && !busy && !help.value && !viewer.value) {
261
+ event.preventDefault()
262
+ const target = isToolbarTarget(event.target) ? self.ref.root : self.ref.root.querySelector(".desktop-toolbar button")
263
+ target?.focus({ preventScroll: true })
264
+ return
265
+ }
266
+ // Native controls own their activation and traversal keys, not the
267
+ // pane underneath them. Escape hands focus back to the workspace.
268
+ if (isToolbarTarget(event.target)) {
269
+ if (event.key === "Escape") {
270
+ event.preventDefault()
271
+ self.ref.root.focus({ preventScroll: true })
272
+ }
273
+ return
274
+ }
275
+ const key = normalizeKey(event)
276
+ if (help.value) {
277
+ if (["escape", "q", "?"].includes(key.name)) help.set(false)
278
+ return
279
+ }
280
+ if (!busy && key.name === "?") {
281
+ help.set(true)
282
+ return
283
+ }
284
+ // The viewer overlay and the narrow-`v` shortcut are shared
285
+ // modal routing, handled by `components/App#createBehaviors`.
286
+ return baseKeys(self, data, event)
287
+ },
288
+ dialogStop: (_self, _data, event) => event.stopPropagation(),
289
+ })
290
+ .sub({
291
+ ...events,
292
+ "dialog:panel-cancel": (_self, data) => {
293
+ if (!unwrap(data.desktopMode)) return
294
+ data.dispatch(unwrap(data.pickerOpen) ? "picker.cancel" : "panel.cancel")
295
+ },
296
+ "dialog:bookmark-cancel": (_self, data) => {
297
+ if (unwrap(data.desktopMode)) data.dispatch("bookmark.cancel")
298
+ },
299
+ "dialog:overlay-cancel": (_self, data) => {
300
+ if (unwrap(data.desktopMode)) data.dispatch("overlay.cancel")
301
+ },
302
+ "dialog:confirm-cancel": (_self, data) => {
303
+ if (unwrap(data.desktopMode)) data.dispatch("confirm.no")
304
+ },
305
+ "dialog:help-cancel": (_self, data) => {
306
+ if (unwrap(data.desktopMode)) help.set(false)
307
+ },
308
+ "dialog:viewer-cancel": (_self, data) => {
309
+ if (unwrap(data.desktopMode)) data.dispatch("view.toggleViewer")
310
+ },
311
+ "toolbar:open-folder": async () => {
312
+ try {
313
+ await options.onOpenFolder?.()
314
+ } catch (error) {
315
+ await options.onError?.(error)
316
+ }
317
+ },
318
+ "toolbar:back": (_self, data) => data.dispatch("pane.back"),
319
+ "toolbar:forward": (_self, data) => data.dispatch("pane.forward"),
320
+ "toolbar:up": (_self, data) => data.dispatch("pane.up"),
321
+ "toolbar:refresh": (_self, data) => data.dispatch("pane.reload"),
322
+ "toolbar:toggle-mode": (_self, data) => data.dispatch("view.toggleMode"),
323
+ "toolbar:toggle-preview": (_self, data) => data.dispatch("view.togglePreview"),
324
+ "toolbar:commands": (_self, data) => data.dispatch("panel.open"),
325
+ "toolbar:help": () => help.set(true),
326
+ "commandbar:run": (self, data, event) => {
327
+ if (event.data === "app.help") {
328
+ help.set(!help.value)
329
+ return
330
+ }
331
+ events["commandbar:run"](self, data, event)
332
+ },
148
333
  })
149
- .sub(appEvents({ quit: (self) => self.unmount?.() }))
150
334
  }
151
335
 
152
336
  export { createWebApp, normalizeKey }
@@ -0,0 +1,36 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-13
5
+
6
+ // Module: tcmd/web/bookmarks
7
+ // The `core/bookmarks` store contract backed by localStorage
8
+ // (`createWebBookmarkStore`, the web twin of the TUI's
9
+ // `createFileBookmarkStore`). Backend/root namespaces keep browser-private and
10
+ // host-filesystem paths apart. Persistence is best-effort, just like the TUI
11
+ // store (private browsing may deny storage).
12
+
13
+ // Function: createWebBookmarkStore
14
+ function createWebBookmarkStore({ mode, root, storage } = {}) {
15
+ const key = JSON.stringify(["tcmd.bookmarks", mode, root])
16
+ return {
17
+ async load() {
18
+ try {
19
+ return JSON.parse((storage ?? globalThis.localStorage).getItem(key) ?? "{}")
20
+ } catch {
21
+ return {}
22
+ }
23
+ },
24
+ async save(map) {
25
+ try {
26
+ (storage ?? globalThis.localStorage).setItem(key, JSON.stringify(map))
27
+ } catch {
28
+ // Keep in-memory bookmarks usable when persistent storage is denied.
29
+ }
30
+ },
31
+ }
32
+ }
33
+
34
+ export { createWebBookmarkStore }
35
+
36
+ // EOF
@@ -13,20 +13,27 @@
13
13
  // presentation bind the same source (`Breadcrumbs` uses `RowList.html`).
14
14
 
15
15
  import { ui } from "select/ui"
16
+ import { unwrap } from "select/state/cells"
16
17
  import bookmarkListHtml from "../components/BookmarkList.html" with { type: "text" }
17
18
  import bookmarkRowHtml from "../components/BookmarkRow.html" with { type: "text" }
18
19
  import commandBarHtml from "../components/CommandBar.html" with { type: "text" }
20
+ import commandArgRowHtml from "../components/CommandArgRow.html" with { type: "text" }
21
+ import commandPanelHtml from "../components/CommandPanel.html" with { type: "text" }
19
22
  import commandItemHtml from "../components/CommandItem.html" with { type: "text" }
20
- import commandPaletteHtml from "../components/CommandPalette.html" with { type: "text" }
21
23
  import confirmHtml from "../components/Confirm.html" with { type: "text" }
22
24
  import crumbHtml from "../components/Crumb.html" with { type: "text" }
23
25
  import entryDetailHtml from "../components/EntryDetail.html" with { type: "text" }
24
26
  import fileListHtml from "../components/FileList.html" with { type: "text" }
27
+ import filePickerHtml from "../components/FilePicker.html" with { type: "text" }
28
+ import filePickerRowHtml from "../components/FilePickerRow.html" with { type: "text" }
25
29
  import filePreviewHtml from "../components/FilePreview.html" with { type: "text" }
26
30
  import fileRowHtml from "../components/FileRow.html" with { type: "text" }
27
31
  import filterBarHtml from "../components/FilterBar.html" with { type: "text" }
32
+ import helpHtml from "../components/Help.html" with { type: "text" }
33
+ import helpRowHtml from "../components/HelpRow.html" with { type: "text" }
28
34
  import headerHtml from "./header.html" with { type: "text" }
29
- import paletteRowHtml from "../components/PaletteRow.html" with { type: "text" }
35
+ import overlayListHtml from "../components/OverlayList.html" with { type: "text" }
36
+ import overlayRowHtml from "../components/OverlayRow.html" with { type: "text" }
30
37
  import rowListHtml from "../components/RowList.html" with { type: "text" }
31
38
  import summaryHtml from "../components/Summary.html" with { type: "text" }
32
39
 
@@ -37,67 +44,75 @@ import {
37
44
  bookmarkRows,
38
45
  breadcrumbsBehaviors,
39
46
  commandBarBehaviors,
47
+ commandBarFit,
48
+ commandBarMarksItem,
49
+ commandArgRowBehaviors,
40
50
  commandItemBehaviors,
41
51
  commandItemRows,
42
- commandPaletteBehaviors,
52
+ commandPanelBehaviors,
43
53
  confirmBehaviors,
44
54
  crumbBehaviors,
45
55
  crumbRows,
46
56
  entryDetailBehaviors,
47
57
  fileListBehaviors,
58
+ filePickerBehaviors,
59
+ filePickerRowBehaviors,
60
+ filePickerRows,
48
61
  filePreviewBehaviors,
49
62
  fileRowBehaviors,
50
63
  fileRows,
51
64
  filterBarBehaviors,
52
- paletteRowBehaviors,
53
- paletteRows,
65
+ helpBehaviors,
66
+ helpRowBehaviors,
67
+ helpRows,
68
+ overlayListBehaviors,
69
+ overlayRowBehaviors,
70
+ overlayRows,
71
+ panelArgRows,
72
+ panelOpRows,
73
+ panelRunRows,
54
74
  summaryBehaviors,
55
75
  } from "../components/index.js"
56
76
  import { createBehaviors as headerBehaviors } from "./header.js"
77
+ import { columnWidth } from "./effects.js"
57
78
  import { webTheme } from "./theme.js"
58
79
 
59
80
  let registered = false
60
81
 
61
- // Advance width of one monospace cell, cached from the header's computed font.
62
- let charWidth = 0
82
+ // Function: paneWidth
83
+ // Reading the injected width cell keeps fitting reactive on pure resizes.
84
+ function paneWidth(self, data) {
85
+ const width = unwrap(data.width) ?? 0
86
+ return columnWidth(self.ref.box) || width
87
+ }
63
88
 
64
- // Function: columnWidth
65
- // Columns available in `el`, measured from the monospace advance width. Returns
66
- // 0 when the DOM cannot measure (happy-dom, no layout), disabling compression.
67
- // ponytail: measured per render; a pure viewport resize waits for the next one.
68
- function columnWidth(el) {
69
- const px = el?.clientWidth || el?.ownerDocument?.defaultView?.innerWidth || 0
70
- if (!px) return 0
71
- const view = el.ownerDocument?.defaultView
72
- if (!view) return 0
73
- if (!charWidth) {
74
- try {
75
- const ctx = el.ownerDocument.createElement("canvas").getContext("2d")
76
- const style = view.getComputedStyle(el)
77
- ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
78
- charWidth = ctx.measureText("0").width || 0
79
- } catch {
80
- return 0
81
- }
82
- }
83
- return charWidth ? Math.floor(px / charWidth) : 0
89
+ function elide(text, width) {
90
+ return width > 0 && text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
84
91
  }
85
92
 
86
93
  // Function: createComponents
87
94
  // Binds every shared component to its `.html` presentation.
88
95
  function createComponents() {
89
96
  const commandItem = bind(ui, "CommandItem", commandItemHtml, commandItemBehaviors(webTheme))
97
+ // Keep command namespace markers textual; the bundled Nerd face is reserved
98
+ // for filesystem names and paths.
99
+ const commandArgRow = bind(ui, "CommandArgRow", commandArgRowHtml, commandArgRowBehaviors({
100
+ ...webTheme,
101
+ icons: false,
102
+ }))
90
103
  const crumb = bind(ui, "Crumb", crumbHtml, crumbBehaviors(webTheme))
91
104
  const fileRow = bind(ui, "FileRow", fileRowHtml, fileRowBehaviors(webTheme))
105
+ const filePickerRow = bind(ui, "FilePickerRow", filePickerRowHtml, filePickerRowBehaviors(webTheme))
92
106
  const filterBar = bind(ui, "FilterBar", filterBarHtml, filterBarBehaviors(webTheme))
93
- const paletteRow = bind(ui, "PaletteRow", paletteRowHtml, paletteRowBehaviors(webTheme))
94
107
  const bookmarkRow = bind(ui, "BookmarkRow", bookmarkRowHtml, bookmarkRowBehaviors(webTheme))
108
+ const overlayRow = bind(ui, "OverlayRow", overlayRowHtml, overlayRowBehaviors(webTheme))
109
+ const helpRow = bind(ui, "HelpRow", helpRowHtml, helpRowBehaviors({ theme: webTheme }))
95
110
  return {
96
111
  BookmarkRow: bookmarkRow,
97
112
  CommandItem: commandItem,
113
+ CommandArgRow: commandArgRow,
98
114
  Crumb: crumb,
99
115
  FileRow: fileRow,
100
- PaletteRow: paletteRow,
101
116
  Breadcrumbs: bind(ui, "Breadcrumbs", rowListHtml, breadcrumbsBehaviors({
102
117
  ...webTheme,
103
118
  listClass: "breadcrumbs",
@@ -110,25 +125,50 @@ function createComponents() {
110
125
  CommandBar: bind(ui, "CommandBar", commandBarHtml, commandBarBehaviors({
111
126
  ...webTheme,
112
127
  listClass: "command-bar-keys",
113
- rows: commandItemRows(commandItem),
128
+ rows: (self, data) => {
129
+ const viewport = unwrap(data.uiSize)
130
+ const viewportWidth = Math.floor(viewport?.width ?? 0)
131
+ // The measured box is exact but still shows the previous width on
132
+ // the frame a resize arrives; prefer the injected viewport then.
133
+ const width = viewportWidth > 0 ? viewportWidth - 1 : columnWidth(self.ref.box)
134
+ const marks = commandBarMarksItem(data)
135
+ const items = unwrap(data.items) ?? []
136
+ return commandItemRows(commandItem)(self, {
137
+ ...data,
138
+ items: commandBarFit(marks ? [...items, marks] : items, width),
139
+ })
140
+ },
114
141
  })),
115
- CommandPalette: bind(ui, "CommandPalette", commandPaletteHtml, commandPaletteBehaviors({
142
+ CommandPanel: bind(ui, "CommandPanel", commandPanelHtml, commandPanelBehaviors({
116
143
  ...webTheme,
117
- rows: paletteRows(paletteRow),
144
+ opRows: panelOpRows(commandArgRow),
145
+ argRows: panelArgRows(commandArgRow),
146
+ runRows: panelRunRows(commandArgRow),
118
147
  })),
119
148
  Confirm: bind(ui, "Confirm", confirmHtml, confirmBehaviors(webTheme)),
120
- EntryDetail: bind(ui, "EntryDetail", entryDetailHtml, entryDetailBehaviors(webTheme)),
149
+ EntryDetail: bind(ui, "EntryDetail", entryDetailHtml, entryDetailBehaviors({ ...webTheme, width: paneWidth, elide })),
121
150
  FileList: bind(ui, "FileList", fileListHtml, fileListBehaviors({
122
151
  ...webTheme,
123
152
  rows: fileRows(fileRow),
124
153
  })),
125
- FilePreview: bind(ui, "FilePreview", filePreviewHtml, filePreviewBehaviors(webTheme)),
154
+ FilePicker: bind(ui, "FilePicker", filePickerHtml, filePickerBehaviors({
155
+ ...webTheme,
156
+ rows: filePickerRows(filePickerRow),
157
+ })),
158
+ FilePreview: bind(ui, "FilePreview", filePreviewHtml, filePreviewBehaviors({
159
+ ...webTheme,
160
+ // Open/edit/run are TUI-only: the browser says so instead of
161
+ // advertising keys it cannot honour.
162
+ actions: () => " Open / edit are unavailable in the browser",
163
+ })),
126
164
  FilterBar: filterBar,
127
- Header: bind(ui, "Header", headerHtml, headerBehaviors({
165
+ Help: bind(ui, "Help", helpHtml, helpBehaviors({ theme: webTheme, rows: helpRows(helpRow) })),
166
+ Header: bind(ui, "Header", headerHtml, headerBehaviors(webTheme)),
167
+ OverlayList: bind(ui, "OverlayList", overlayListHtml, overlayListBehaviors({
128
168
  ...webTheme,
129
- width: (self) => columnWidth(self.ref.box),
169
+ rows: overlayRows(overlayRow),
130
170
  })),
131
- Summary: bind(ui, "Summary", summaryHtml, summaryBehaviors(webTheme)),
171
+ Summary: bind(ui, "Summary", summaryHtml, summaryBehaviors({ ...webTheme, width: paneWidth, elide })),
132
172
  }
133
173
  }
134
174
 
@@ -143,13 +183,16 @@ function registerWebComponents() {
143
183
  ui.format("Breadcrumbs", components.Breadcrumbs)
144
184
  ui.format("BookmarkList", components.BookmarkList)
145
185
  ui.format("CommandBar", components.CommandBar)
146
- ui.format("CommandPalette", components.CommandPalette)
186
+ ui.format("CommandPanel", components.CommandPanel)
147
187
  ui.format("Confirm", components.Confirm)
148
188
  ui.format("EntryDetail", components.EntryDetail)
149
189
  ui.format("FileList", components.FileList)
190
+ ui.format("FilePicker", components.FilePicker)
150
191
  ui.format("FilePreview", components.FilePreview)
151
192
  ui.format("FilterBar", components.FilterBar)
152
193
  ui.format("Header", components.Header)
194
+ ui.format("Help", components.Help)
195
+ ui.format("OverlayList", components.OverlayList)
153
196
  ui.format("Summary", components.Summary)
154
197
  return components
155
198
  }