@ssebastien/tcmd 0.1.3 → 0.1.4

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 (79) hide show
  1. package/README.md +121 -38
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/package.json +4 -3
  5. package/public/index.html +14 -0
  6. package/public/main.js +17501 -0
  7. package/public/style.css +901 -0
  8. package/scripts/serve-web.js +61 -0
  9. package/src/js/tcmd/components/App.html +28 -12
  10. package/src/js/tcmd/components/App.js +135 -85
  11. package/src/js/tcmd/components/App.xml +64 -15
  12. package/src/js/tcmd/components/BookmarkList.html +3 -2
  13. package/src/js/tcmd/components/BookmarkList.js +1 -1
  14. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  15. package/src/js/tcmd/components/CommandArgRow.html +17 -0
  16. package/src/js/tcmd/components/CommandArgRow.js +86 -26
  17. package/src/js/tcmd/components/CommandArgRow.xml +31 -4
  18. package/src/js/tcmd/components/CommandBar.html +1 -1
  19. package/src/js/tcmd/components/CommandBar.js +3 -3
  20. package/src/js/tcmd/components/CommandItem.html +1 -1
  21. package/src/js/tcmd/components/CommandItem.js +1 -0
  22. package/src/js/tcmd/components/CommandPanel.html +29 -0
  23. package/src/js/tcmd/components/CommandPanel.js +54 -52
  24. package/src/js/tcmd/components/CommandPanel.xml +38 -24
  25. package/src/js/tcmd/components/Confirm.html +1 -1
  26. package/src/js/tcmd/components/EntryDetail.html +7 -7
  27. package/src/js/tcmd/components/EntryDetail.js +1 -1
  28. package/src/js/tcmd/components/FileList.html +5 -1
  29. package/src/js/tcmd/components/FileList.js +2 -1
  30. package/src/js/tcmd/components/FilePreview.html +14 -3
  31. package/src/js/tcmd/components/FilePreview.js +73 -10
  32. package/src/js/tcmd/components/FilePreview.xml +19 -6
  33. package/src/js/tcmd/components/FileRow.html +1 -1
  34. package/src/js/tcmd/components/FileRow.js +10 -3
  35. package/src/js/tcmd/components/FilterBar.html +1 -1
  36. package/src/js/tcmd/components/FilterBar.js +14 -4
  37. package/src/js/tcmd/components/FilterBar.xml +1 -1
  38. package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
  39. package/src/js/tcmd/components/Help.html +7 -0
  40. package/src/js/tcmd/components/Help.js +109 -0
  41. package/src/js/tcmd/components/Summary.html +1 -1
  42. package/src/js/tcmd/components/Summary.js +1 -6
  43. package/src/js/tcmd/components/icons.js +39 -5
  44. package/src/js/tcmd/components/index.js +3 -4
  45. package/src/js/tcmd/components/rows.js +37 -33
  46. package/src/js/tcmd/core/commands.js +422 -180
  47. package/src/js/tcmd/core/directory.js +145 -95
  48. package/src/js/tcmd/core/fs.js +29 -2
  49. package/src/js/tcmd/core/history.js +58 -0
  50. package/src/js/tcmd/core/index.js +3 -9
  51. package/src/js/tcmd/core/keymap.js +3 -3
  52. package/src/js/tcmd/core/operations.js +108 -15
  53. package/src/js/tcmd/core/pane.js +31 -8
  54. package/src/js/tcmd/core/panel.js +172 -82
  55. package/src/js/tcmd/core/script.js +7 -95
  56. package/src/js/tcmd/core/selection.js +9 -1
  57. package/src/js/tcmd/core/session.js +280 -152
  58. package/src/js/tcmd/index.js +0 -1
  59. package/src/js/tcmd/tui/app.js +26 -5
  60. package/src/js/tcmd/tui/catalogue.js +6 -19
  61. package/src/js/tcmd/tui/index.js +1 -16
  62. package/src/js/tcmd/tui/theme.js +5 -0
  63. package/src/js/tcmd/web/app.js +158 -32
  64. package/src/js/tcmd/web/bookmarks.js +36 -0
  65. package/src/js/tcmd/web/catalogue.js +45 -39
  66. package/src/js/tcmd/web/effects.js +143 -0
  67. package/src/js/tcmd/web/header.html +1 -1
  68. package/src/js/tcmd/web/header.js +15 -11
  69. package/src/js/tcmd/web/main.js +6 -66
  70. package/src/js/tcmd/web/seed.js +3 -3
  71. package/src/js/tcmd/web/server.js +6 -4
  72. package/src/js/tcmd/web/theme.js +1 -0
  73. package/src/js/tcmd/components/CommandPalette.html +0 -7
  74. package/src/js/tcmd/components/CommandPalette.js +0 -58
  75. package/src/js/tcmd/components/CommandPalette.xml +0 -16
  76. package/src/js/tcmd/components/PaletteRow.html +0 -3
  77. package/src/js/tcmd/components/PaletteRow.js +0 -43
  78. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  79. package/src/js/tcmd/tui/help.js +0 -32
@@ -13,11 +13,13 @@
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" }
@@ -25,8 +27,8 @@ import fileListHtml from "../components/FileList.html" with { type: "text" }
25
27
  import filePreviewHtml from "../components/FilePreview.html" with { type: "text" }
26
28
  import fileRowHtml from "../components/FileRow.html" with { type: "text" }
27
29
  import filterBarHtml from "../components/FilterBar.html" with { type: "text" }
30
+ import helpHtml from "../components/Help.html" with { type: "text" }
28
31
  import headerHtml from "./header.html" with { type: "text" }
29
- import paletteRowHtml from "../components/PaletteRow.html" with { type: "text" }
30
32
  import rowListHtml from "../components/RowList.html" with { type: "text" }
31
33
  import summaryHtml from "../components/Summary.html" with { type: "text" }
32
34
 
@@ -37,9 +39,11 @@ import {
37
39
  bookmarkRows,
38
40
  breadcrumbsBehaviors,
39
41
  commandBarBehaviors,
42
+ commandBarMarksItem,
43
+ commandArgRowBehaviors,
40
44
  commandItemBehaviors,
41
45
  commandItemRows,
42
- commandPaletteBehaviors,
46
+ commandPanelBehaviors,
43
47
  confirmBehaviors,
44
48
  crumbBehaviors,
45
49
  crumbRows,
@@ -49,55 +53,48 @@ import {
49
53
  fileRowBehaviors,
50
54
  fileRows,
51
55
  filterBarBehaviors,
52
- paletteRowBehaviors,
53
- paletteRows,
56
+ helpBehaviors,
57
+ panelArgRows,
58
+ panelOpRows,
54
59
  summaryBehaviors,
55
60
  } from "../components/index.js"
56
61
  import { createBehaviors as headerBehaviors } from "./header.js"
62
+ import { columnWidth } from "./effects.js"
57
63
  import { webTheme } from "./theme.js"
58
64
 
59
65
  let registered = false
60
66
 
61
- // Advance width of one monospace cell, cached from the header's computed font.
62
- let charWidth = 0
67
+ // Function: paneWidth
68
+ // Reading the injected width cell keeps fitting reactive on pure resizes.
69
+ function paneWidth(self, data) {
70
+ const width = unwrap(data.width) ?? 0
71
+ return columnWidth(self.ref.box) || width
72
+ }
63
73
 
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
74
+ function elide(text, width) {
75
+ return width > 0 && text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
84
76
  }
85
77
 
86
78
  // Function: createComponents
87
79
  // Binds every shared component to its `.html` presentation.
88
80
  function createComponents() {
89
81
  const commandItem = bind(ui, "CommandItem", commandItemHtml, commandItemBehaviors(webTheme))
82
+ // Browsers lack the Nerd Font, so panel entries carry the text tag instead
83
+ // of the glyph.
84
+ const commandArgRow = bind(ui, "CommandArgRow", commandArgRowHtml, commandArgRowBehaviors({
85
+ ...webTheme,
86
+ icons: false,
87
+ }))
90
88
  const crumb = bind(ui, "Crumb", crumbHtml, crumbBehaviors(webTheme))
91
89
  const fileRow = bind(ui, "FileRow", fileRowHtml, fileRowBehaviors(webTheme))
92
90
  const filterBar = bind(ui, "FilterBar", filterBarHtml, filterBarBehaviors(webTheme))
93
- const paletteRow = bind(ui, "PaletteRow", paletteRowHtml, paletteRowBehaviors(webTheme))
94
91
  const bookmarkRow = bind(ui, "BookmarkRow", bookmarkRowHtml, bookmarkRowBehaviors(webTheme))
95
92
  return {
96
93
  BookmarkRow: bookmarkRow,
97
94
  CommandItem: commandItem,
95
+ CommandArgRow: commandArgRow,
98
96
  Crumb: crumb,
99
97
  FileRow: fileRow,
100
- PaletteRow: paletteRow,
101
98
  Breadcrumbs: bind(ui, "Breadcrumbs", rowListHtml, breadcrumbsBehaviors({
102
99
  ...webTheme,
103
100
  listClass: "breadcrumbs",
@@ -110,25 +107,33 @@ function createComponents() {
110
107
  CommandBar: bind(ui, "CommandBar", commandBarHtml, commandBarBehaviors({
111
108
  ...webTheme,
112
109
  listClass: "command-bar-keys",
113
- rows: commandItemRows(commandItem),
110
+ rows: (self, data) => {
111
+ const marks = commandBarMarksItem(data)
112
+ const items = unwrap(data.items) ?? []
113
+ return commandItemRows(commandItem)(self, { ...data, items: marks ? [...items, marks] : items })
114
+ },
114
115
  })),
115
- CommandPalette: bind(ui, "CommandPalette", commandPaletteHtml, commandPaletteBehaviors({
116
+ CommandPanel: bind(ui, "CommandPanel", commandPanelHtml, commandPanelBehaviors({
116
117
  ...webTheme,
117
- rows: paletteRows(paletteRow),
118
+ opRows: panelOpRows(commandArgRow),
119
+ argRows: panelArgRows(commandArgRow),
118
120
  })),
119
121
  Confirm: bind(ui, "Confirm", confirmHtml, confirmBehaviors(webTheme)),
120
- EntryDetail: bind(ui, "EntryDetail", entryDetailHtml, entryDetailBehaviors(webTheme)),
122
+ EntryDetail: bind(ui, "EntryDetail", entryDetailHtml, entryDetailBehaviors({ ...webTheme, width: paneWidth, elide })),
121
123
  FileList: bind(ui, "FileList", fileListHtml, fileListBehaviors({
122
124
  ...webTheme,
123
125
  rows: fileRows(fileRow),
124
126
  })),
125
- FilePreview: bind(ui, "FilePreview", filePreviewHtml, filePreviewBehaviors(webTheme)),
126
- FilterBar: filterBar,
127
- Header: bind(ui, "Header", headerHtml, headerBehaviors({
127
+ FilePreview: bind(ui, "FilePreview", filePreviewHtml, filePreviewBehaviors({
128
128
  ...webTheme,
129
- width: (self) => columnWidth(self.ref.box),
129
+ // Open/edit/run are TUI-only: the browser says so instead of
130
+ // advertising keys it cannot honour.
131
+ actions: () => " Open / edit are unavailable in the browser",
130
132
  })),
131
- Summary: bind(ui, "Summary", summaryHtml, summaryBehaviors(webTheme)),
133
+ FilterBar: filterBar,
134
+ Help: bind(ui, "Help", helpHtml, helpBehaviors({ theme: webTheme })),
135
+ Header: bind(ui, "Header", headerHtml, headerBehaviors(webTheme)),
136
+ Summary: bind(ui, "Summary", summaryHtml, summaryBehaviors({ ...webTheme, width: paneWidth, elide })),
132
137
  }
133
138
  }
134
139
 
@@ -143,13 +148,14 @@ function registerWebComponents() {
143
148
  ui.format("Breadcrumbs", components.Breadcrumbs)
144
149
  ui.format("BookmarkList", components.BookmarkList)
145
150
  ui.format("CommandBar", components.CommandBar)
146
- ui.format("CommandPalette", components.CommandPalette)
151
+ ui.format("CommandPanel", components.CommandPanel)
147
152
  ui.format("Confirm", components.Confirm)
148
153
  ui.format("EntryDetail", components.EntryDetail)
149
154
  ui.format("FileList", components.FileList)
150
155
  ui.format("FilePreview", components.FilePreview)
151
156
  ui.format("FilterBar", components.FilterBar)
152
157
  ui.format("Header", components.Header)
158
+ ui.format("Help", components.Help)
153
159
  ui.format("Summary", components.Summary)
154
160
  return components
155
161
  }
@@ -0,0 +1,143 @@
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 { INSPECTOR_INLINE_WIDTH } from "../components/Frame.js"
12
+
13
+ const canvases = new WeakMap()
14
+
15
+ // Function: columnWidth
16
+ // Available monospace columns, or zero in a DOM without layout (happy-dom).
17
+ function columnWidth(el) {
18
+ if (!el?.clientWidth) return 0
19
+ const doc = el.ownerDocument
20
+ const view = doc.defaultView
21
+ let canvas = canvases.get(doc)
22
+ if (!canvas) {
23
+ canvas = doc.createElement("canvas")
24
+ canvases.set(doc, canvas)
25
+ }
26
+ const ctx = canvas.getContext("2d")
27
+ if (!ctx) return 0
28
+ const style = view.getComputedStyle(el)
29
+ ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
30
+ const width = ctx.measureText("0").width
31
+ const padding = (Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0)
32
+ return width ? Math.floor((el.clientWidth - padding) / width) : 0
33
+ }
34
+
35
+ // Function: keepVisible
36
+ // Scroll only the row's own list, never the page or an ancestor overlay.
37
+ function keepVisible(list, row) {
38
+ if (!list?.clientHeight || !row) return
39
+ const listRect = list.getBoundingClientRect()
40
+ const rowRect = row.getBoundingClientRect()
41
+ if (rowRect.top < listRect.top) list.scrollTop -= listRect.top - rowRect.top
42
+ else if (rowRect.bottom > listRect.bottom) list.scrollTop += rowRect.bottom - listRect.bottom
43
+ }
44
+
45
+ // Function: connectEffects
46
+ // Returns a frame scheduler and teardown; the App registers teardown with
47
+ // instance.effect(), so remounting leaves no listeners or observers behind.
48
+ function connectEffects(session, root, { size, help, viewer }) {
49
+ // Select initially clones into an inert template document; the live window
50
+ // is available before mount even though ownerDocument.defaultView is not.
51
+ const view = root.ownerDocument.defaultView ?? globalThis.window
52
+ const doc = view.document
53
+ let frame = null
54
+ let retries = 0
55
+ let disposed = false
56
+ const viewerOpen = () => viewer.value && size.value.width > 0 && size.value.width < INSPECTOR_INLINE_WIDTH
57
+ const modalOpen = () => Boolean(session.confirm.value || help.value || viewerOpen() || session.panelOpen.value || session.bookmarkOpen.value)
58
+ const focusTarget = () => {
59
+ if (session.confirm.value) return root.querySelector(".confirm")
60
+ if (help.value) return root.querySelector(".help")
61
+ if (viewerOpen()) return root.querySelector(".viewer")
62
+ if (session.panelOpen.value) {
63
+ if (session.panelPicking.value) return root.querySelector(".panel-picker-query")
64
+ if (session.panelFocus.value === "ops") return root.querySelector(".panel-query")
65
+ return root.querySelector(session.panelTextual.value ? ".panel-value" : ".command-panel")
66
+ }
67
+ if (session.bookmarkOpen.value) return root.querySelector(".bookmark-query")
68
+ if (session.filtering.value) return root.querySelector(`.pane-${session.filtering.value} .filter-input`)
69
+ return root
70
+ }
71
+ const update = () => {
72
+ frame = null
73
+ if (disposed || !root.isConnected) return
74
+ const width = columnWidth(root)
75
+ const lineHeight = Number.parseFloat(view.getComputedStyle(root).lineHeight)
76
+ const height = lineHeight ? Math.floor(root.clientHeight / lineHeight) : 0
77
+ 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
+ // A modal's field mounts on a later frame than the flag that opens it;
85
+ // retry a few frames before giving up so focus lands on the overlay
86
+ // instead of the root.
87
+ const target = focusTarget()
88
+ if (modalOpen() && !target && retries < 8) {
89
+ retries += 1
90
+ schedule()
91
+ return
92
+ }
93
+ retries = 0
94
+ const focusable = target ?? root
95
+ if (focusable !== doc.activeElement) focusable.focus({ preventScroll: true })
96
+ for (const list of root.querySelectorAll(".file-list, .bookmark-rows, .panel-ops, .panel-args")) {
97
+ keepVisible(list, list.querySelector(".is-current, .is-selected, .is-focused"))
98
+ }
99
+ }
100
+ const schedule = () => {
101
+ // Root renders queue child renders on the next frame. Wait past those
102
+ // before focusing a newly mounted field or measuring a changed layout.
103
+ if (!disposed && frame === null) frame = view.requestAnimationFrame(() => {
104
+ frame = view.requestAnimationFrame(update)
105
+ })
106
+ }
107
+ const trapKeys = (event) => {
108
+ const input = event.target?.tagName === "INPUT" || event.target?.tagName === "TEXTAREA"
109
+ // Editing keeps native text/caret keys except the ones the shared
110
+ // 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()
114
+ }
115
+ const focusIn = (event) => {
116
+ if (modalOpen() && event.target !== focusTarget()) schedule()
117
+ }
118
+ root.addEventListener("keydown", trapKeys)
119
+ doc.addEventListener("focusin", focusIn)
120
+ root.addEventListener("focusout", schedule)
121
+ view.addEventListener("resize", schedule)
122
+ const observer = view.ResizeObserver ? new view.ResizeObserver(schedule) : null
123
+ observer?.observe(root)
124
+ // Pane proportions also change when the inspector is toggled.
125
+ for (const pane of root.querySelectorAll(".pane")) observer?.observe(pane)
126
+ schedule()
127
+ return {
128
+ schedule,
129
+ dispose() {
130
+ disposed = true
131
+ if (frame !== null) view.cancelAnimationFrame(frame)
132
+ observer?.disconnect()
133
+ root.removeEventListener("keydown", trapKeys)
134
+ root.removeEventListener("focusout", schedule)
135
+ doc.removeEventListener("focusin", focusIn)
136
+ view.removeEventListener("resize", schedule)
137
+ },
138
+ }
139
+ }
140
+
141
+ export { columnWidth, connectEffects, keepVisible }
142
+
143
+ // 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" out="mode"></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
 
@@ -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) {
@@ -24,8 +24,8 @@ const DEMO_TREE = {
24
24
  "- Tab switch pane",
25
25
  "- Space mark the current entry",
26
26
  "- b / g record / jump to a bookmark",
27
- "- ^P command palette",
28
- "- : scripting mode (copy, move, delete, ...)",
27
+ "- : command panel (operations and app actions)",
28
+ "- ! shell command",
29
29
  "- q quit (unmounts the UI; reload to start over)",
30
30
  "",
31
31
  "Open/edit/run are not available in the browser: the web uses the null",
@@ -47,7 +47,7 @@ const DEMO_TREE = {
47
47
  "----",
48
48
  "[x] two panes",
49
49
  "[x] named bookmarks",
50
- "[x] command palette",
50
+ "[x] command panel",
51
51
  "[ ] quick find",
52
52
  ].join("\n"),
53
53
  "/documents/welcome.txt": [
@@ -8,9 +8,10 @@
8
8
  // filesystem API (`/api/*`) over the Node filesystem adapter, so browsers
9
9
  // without OPFS (or users who want to browse real files) can run the web UI.
10
10
  //
11
- // Security: binds to loopback only by default and requires the per-process
12
- // token, injected into `index.html` at serve time, on every API request. When
13
- // `root` is set, every path is confined to it. This is a local development
11
+ // Security: binds to loopback only by default (`HOST` overrides the address)
12
+ // and requires the per-process token, injected into `index.html` at serve
13
+ // time, on every API request. When `root` is set, every path is confined to
14
+ // it. This is a local development
14
15
  // tool, not a sandbox: it serves the same files `bun run tcmd` can reach, so
15
16
  // do not expose the port.
16
17
 
@@ -24,10 +25,11 @@ const TOKEN_PLACEHOLDER = "__TCMD_TOKEN__"
24
25
  // Function: createWebServer
25
26
  // Builds the server. `port` 0 picks a free port (tests); `root` defaults to
26
27
  // "/" (the whole filesystem, like the TUI); `token` defaults to a fresh UUID.
28
+ // `hostname` and `port` default to the HOST and PORT environment variables.
27
29
  function createWebServer({
28
30
  root = process.env.TCMD_WEB_ROOT ?? "/",
29
31
  port = Number(process.env.PORT ?? 8787),
30
- hostname = "127.0.0.1",
32
+ hostname = process.env.HOST || "127.0.0.1",
31
33
  publicDir = new URL("../../../../public/", import.meta.url),
32
34
  token = crypto.randomUUID(),
33
35
  } = {}) {
@@ -14,6 +14,7 @@ const webTheme = {
14
14
  currentClass: "is-current",
15
15
  dirClass: "is-dir",
16
16
  errorClass: "is-error",
17
+ focusedClass: "is-focused",
17
18
  rootClass: "is-root",
18
19
  selectedClass: "is-selected",
19
20
  }
@@ -1,7 +0,0 @@
1
- <section class="palette" when="open">
2
- <div class="palette-line">
3
- <span class="palette-sign" out="sign"></span>
4
- <input ref="query" inout="query" class="palette-input" placeholder="Command..." on:enter="confirm" />
5
- </div>
6
- <ul class="palette-list" out="rows"></ul>
7
- </section>
@@ -1,58 +0,0 @@
1
- // Project: TinyCommand
2
- // Author: Sebastien Pierre
3
- // License: BSD
4
- // Created: 2026-09-26
5
-
6
- // Module: tcmd/components/CommandPalette
7
- // CommandPalette: popup listing commands filtered by the typed query. In
8
- // scripting mode it lists operations and filesets and prefixes the input with
9
- // `:`. The row logic lives in `PaletteRow.js`; this parent maps its `items` to
10
- // rows.
11
- //
12
- // Environment: `overlayBg`, `borderPrimaryActiveFg`, `textSecondaryFg`
13
- // (footer), `textHeadingFg` (exposed as the `titleColor` behavior),
14
- // `rows(data)` returning the keyed rows and `scrollTop(self, data)` (TUI
15
- // viewport tracking).
16
-
17
- import { unwrap } from "select/state/cells"
18
-
19
- // Function: paletteSign
20
- // The prompt shown before the input; overridable per renderer.
21
- function paletteSign(mode) {
22
- return unwrap(mode) === "script" ? ":" : "›"
23
- }
24
-
25
- // Function: paletteTitle
26
- function paletteTitle(mode) {
27
- return unwrap(mode) === "script" ? "script" : "palette"
28
- }
29
-
30
- // Function: createBehaviors
31
- // Builds the CommandPalette behavior map for the given renderer environment.
32
- function createBehaviors(env) {
33
- const behaviors = {
34
- bg: () => env.overlayBg,
35
- borderFg: () => env.borderPrimaryActiveFg,
36
- confirm: (self, data) => self.pub("palette:confirm", unwrap(data.query) ?? ""),
37
- focused: (_self, data) => Boolean(unwrap(data.open)),
38
- footer: (_self, data) =>
39
- unwrap(data.mode) === "script"
40
- ? "Tab complete ↵ run Esc close"
41
- : "↑↓ move Tab complete ↵ run Esc close",
42
- footerFg: () => env.textSecondaryFg,
43
- sign: (_self, data) => paletteSign(data.mode),
44
- title: (_self, data) => paletteTitle(data.mode),
45
- titleColor: () => env.textHeadingFg,
46
- }
47
- if (env?.rows) {
48
- behaviors.rows = (_self, data) => env.rows(self, data)
49
- }
50
- if (env?.scrollTop) {
51
- behaviors.scrollTop = (self, data) => env.scrollTop(self, data)
52
- }
53
- return behaviors
54
- }
55
-
56
- export { createBehaviors, paletteSign, paletteTitle }
57
-
58
- // EOF
@@ -1,16 +0,0 @@
1
- <box when="open" width="100%" height="100%" alignItems="center" justifyContent="center">
2
- <box width="72%" height="60%" border borderStyle="rounded"
3
- out:title="title" out:titleColor="titleColor" out:borderColor="borderFg"
4
- out:backgroundColor="bg" flexDirection="column">
5
- <box flexDirection="row" height="1" paddingLeft="1">
6
- <text out="sign" out:fg="titleColor"></text>
7
- <input ref="query" inout="query" flexGrow="1" height="1" out:focused="focused" on:enter="confirm"></input>
8
- </box>
9
- <scrollbox ref="list" flexGrow="1">
10
- <box out="rows" flexDirection="column"></box>
11
- </scrollbox>
12
- <box flexDirection="row" height="1" paddingLeft="1">
13
- <text out="footer" out:fg="footerFg"></text>
14
- </box>
15
- </box>
16
- </box>
@@ -1,3 +0,0 @@
1
- <li class="palette-item" out:class="rowClass" on:click="item.id!palette:run">
2
- <span out="paletteLabel"></span>
3
- </li>
@@ -1,43 +0,0 @@
1
- // Project: TinyCommand
2
- // Author: Sebastien Pierre
3
- // License: BSD
4
- // Created: 2026-09-26
5
-
6
- // Module: tcmd/components/PaletteRow
7
- // Command-palette row behaviors. Read `{ item: command, index, selected }`, the
8
- // same bag shape in both engines. Selection is snapshotted by the row builder
9
- // into a plain `selectedRow` boolean and read through the shared `isSelected`
10
- // predicate, with the `selected` cell as fallback for hand-built bags. Script
11
- // rows carry a `detail` (operation signature, fileset count) appended to the
12
- // label.
13
- //
14
- // Environment: `accentFg`, `textFg`, `selectionBg` (TUI row background),
15
- // `selectedClass` (web).
16
-
17
- import { isSelected } from "./rows.js"
18
-
19
- // Function: paletteLabel
20
- // "Switch [Tab]" for commands; script rows append their detail.
21
- function paletteLabel(command) {
22
- if (!command) return ""
23
- const keys = command.keys ? ` [${command.keys}]` : ""
24
- const detail = command.detail ? ` ${command.detail}` : ""
25
- return `${command.title}${keys}${detail}`
26
- }
27
-
28
- // Function: createBehaviors
29
- // Builds the PaletteRow behavior map for the given renderer environment.
30
- function createBehaviors(env) {
31
- return {
32
- paletteLabel: (_self, data) => paletteLabel(data.item),
33
- rowBg: (_self, data) => (isSelected(data) ? env.selectionBg : undefined),
34
- rowClass: (_self, data) => (isSelected(data) ? env.selectedClass : null),
35
- rowFg: (_self, data) => (isSelected(data) ? env.accentFg : env.textFg),
36
- // TUI: a marker so the selection is not color-only.
37
- rowMarker: (_self, data) => (isSelected(data) ? ">" : " "),
38
- }
39
- }
40
-
41
- export { createBehaviors, paletteLabel }
42
-
43
- // EOF