@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
@@ -0,0 +1,61 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: scripts/serve-web
7
+ // CLI for the web view server: serves `public/` plus the `/api/*` filesystem
8
+ // API (see `src/js/tcmd/web/server.js`). The page uses OPFS when the browser
9
+ // has it and falls back to the API otherwise, so both backends come from this
10
+ // one command.
11
+ //
12
+ // Environment: HOST (default 127.0.0.1, an IP literal: 0.0.0.0 or :: serves
13
+ // every interface), PORT (default 8787), TCMD_WEB_ROOT (default "/", like the
14
+ // TUI).
15
+
16
+ import { createWebServer } from "../src/js/tcmd/web/server.js"
17
+ import { isIP } from "node:net"
18
+ import { fileURLToPath } from "node:url"
19
+
20
+ // Function: hostUrl
21
+ // The URL a client should open. IPv6 literals (possibly zone-qualified) are
22
+ // bracketed so the result is a valid URL.
23
+ function hostUrl(hostname, port) {
24
+ const host = isIP(hostname) === 6 ? `[${hostname}]` : hostname
25
+ return `http://${host}:${port}`
26
+ }
27
+
28
+ // Function: main
29
+ // Starts the existing loopback server. Published packages include the bundle;
30
+ // a source checkout builds it on demand without depending on the caller's cwd.
31
+ async function main(options = {}) {
32
+ const publicDir = options.publicDir ?? new URL("../public/", import.meta.url)
33
+ if (!(await Bun.file(new URL("main.js", publicDir)).exists())) {
34
+ const build = await Bun.build({
35
+ entrypoints: [fileURLToPath(new URL("../src/js/tcmd/web/main.js", import.meta.url))],
36
+ outdir: fileURLToPath(publicDir),
37
+ target: "browser",
38
+ })
39
+ if (!build.success) throw new AggregateError(build.logs, "Could not build the web UI")
40
+ }
41
+ const result = createWebServer({ ...options, publicDir })
42
+ const host = result.server.hostname
43
+ // A machine name resolves to several addresses (often including link-local
44
+ // IPv6), but the server binds only one of them, so prefer an IP literal.
45
+ if (host !== "127.0.0.1" && host !== "localhost" && isIP(host) === 0) {
46
+ console.warn(
47
+ `tcmd web: warning: ${host} is a name; bound to one of its addresses only.\n` +
48
+ ` Use an IP literal instead (0.0.0.0 or :: for every interface).`
49
+ )
50
+ }
51
+ console.log(`tcmd web: ${hostUrl(host, result.server.port)}`)
52
+ console.log(`filesystem API: /api/* rooted at ${result.root}`)
53
+ console.log("use ?fs=http for your files, or ?fs=opfs for browser storage")
54
+ return result
55
+ }
56
+
57
+ if (import.meta.main) await main()
58
+
59
+ export { hostUrl, main }
60
+
61
+ // EOF
@@ -1,28 +1,44 @@
1
- <div class="app" tabindex="0" on:keydown="keys">
2
- <header out="headerView|Header"></header>
3
- <main class="panes">
4
- <section class="pane pane-left" out:class="leftPaneClass" on:click="leftId!pane:activate">
1
+ <div class="app" ref="root" tabindex="0" on:keydown="keys">
2
+ <div out="headerView|Header"></div>
3
+ <section class="resize-hint" when="tooSmall">
4
+ <h2 out="minSizeTitle"></h2>
5
+ <p out="minSizeHint"></p>
6
+ </section>
7
+ <main class="panes" when="!tooSmall">
8
+ <section class="pane pane-parent" when="showParentPane" out:class="parentPaneClass" on:click="parentId!pane:activate">
5
9
  <div class="pane-header">
6
- <div out="leftCrumbsView|Breadcrumbs"></div>
10
+ <div when="!parentFilterOn" out="parentCrumbsView|Breadcrumbs"></div>
11
+ <div out="parentFilterView|FilterBar"></div>
12
+ </div>
13
+ <div data-slot="list" out="parentListView|FileList"></div>
14
+ <div out="parentDetailView|EntryDetail"></div>
15
+ <div out="parentSummaryView|Summary"></div>
16
+ </section>
17
+ <section class="pane pane-left" when="showLeftPane" out:class="leftPaneClass" on:click="leftId!pane:activate">
18
+ <div class="pane-header">
19
+ <div when="!leftFilterOn" out="leftCrumbsView|Breadcrumbs"></div>
7
20
  <div out="leftFilterView|FilterBar"></div>
8
21
  </div>
9
- <div out="leftListView|FileList"></div>
22
+ <div data-slot="list" out="leftListView|FileList"></div>
10
23
  <div out="leftDetailView|EntryDetail"></div>
11
24
  <div out="leftSummaryView|Summary"></div>
12
25
  </section>
13
- <section class="pane pane-right" out:class="rightPaneClass" on:click="rightId!pane:activate">
26
+ <section class="pane pane-right" when="showRightPane" out:class="rightPaneClass" on:click="rightId!pane:activate">
14
27
  <div class="pane-header">
15
- <div out="rightCrumbsView|Breadcrumbs"></div>
28
+ <div when="!rightFilterOn" out="rightCrumbsView|Breadcrumbs"></div>
16
29
  <div out="rightFilterView|FilterBar"></div>
17
30
  </div>
18
- <div out="rightListView|FileList"></div>
31
+ <div data-slot="list" out="rightListView|FileList"></div>
19
32
  <div out="rightDetailView|EntryDetail"></div>
20
33
  <div out="rightSummaryView|Summary"></div>
21
34
  </section>
22
- <div when="showPreview" out="previewView|FilePreview"></div>
35
+ <div data-slot="inspector" when="showInspectorInline" out="previewView|FilePreview"></div>
36
+ <div class="preview-max" when="showPreviewFull" out="previewView|FilePreview"></div>
23
37
  </main>
24
- <footer out="commandBarView|CommandBar"></footer>
25
- <div when="paletteOpen" out="paletteView|CommandPalette"></div>
38
+ <div out="commandBarView|CommandBar"></div>
39
+ <div class="viewer" when="showViewer" role="dialog" aria-modal="true" aria-label="File inspector" tabindex="-1" out="previewView|FilePreview"></div>
40
+ <div when="panelOpen" out="panelView|CommandPanel"></div>
26
41
  <div when="bookmarkOpen" out="bookmarkView|BookmarkList"></div>
27
42
  <div out="confirmView|Confirm"></div>
43
+ <div when="helpOpen" out="helpView|Help"></div>
28
44
  </div>
@@ -5,7 +5,7 @@
5
5
 
6
6
  // Module: tcmd/components/App
7
7
  // Root component: a header, two panes (breadcrumbs + FileList + summary each),
8
- // an optional preview column, the command bar and the command palette overlay.
8
+ // an optional preview column, the command bar and the command panel overlay.
9
9
  // App is the controller seam: component events (published bubbles) and key
10
10
  // chords are translated into `session.dispatch(commandId, payload)` calls; it
11
11
  // never mutates model state directly.
@@ -23,14 +23,18 @@ import { PANEL_FOCUS } from "../core/panel.js"
23
23
  function createInit(session) {
24
24
  const left = session.panes.left
25
25
  const right = session.panes.right
26
+ const parent = session.panes.parent ?? {}
27
+ const preview = session.panes.preview ?? {}
26
28
  // Pane-focus flags so the listing can render the cursor differently in the
27
29
  // inactive pane. Shared by both engines.
28
30
  const leftActive = derived([session.active], ([active]) => active === "left")
29
31
  const rightActive = derived([session.active], ([active]) => active === "right")
32
+ const parentActive = derived([session.active], ([active]) => active === "parent")
33
+ const previewActive = derived([session.active], ([active]) => active === "preview")
30
34
  return () => ({
31
35
  active: session.active,
32
- // The preview target entry, so the inspector can show a directory card
33
- // even when there is no text/card preview.
36
+ // The preview target entry, so the inspector can show a directory
37
+ // column even when there is no text/card preview.
34
38
  appEntry: session.previewEntry,
35
39
  appHost: session.host,
36
40
  appPath: session.activePath,
@@ -42,6 +46,7 @@ function createInit(session) {
42
46
  bookmarkTarget: session.bookmarkTarget,
43
47
  commandBarItems: session.commandBarItems,
44
48
  confirm: session.confirm,
49
+ currentId: session.currentId,
45
50
  dispatch: session.dispatch,
46
51
  filtering: session.filtering,
47
52
  leftActive,
@@ -55,28 +60,49 @@ function createInit(session) {
55
60
  leftLoading: left.loading,
56
61
  leftMarked: left.marked,
57
62
  leftSegments: left.segments,
63
+ leftSetFilter: (value) => left.setFilter(value),
58
64
  notice: session.notice,
59
- paletteIndex: session.paletteIndex,
60
- paletteItems: session.paletteItems,
61
- paletteMode: session.paletteMode,
62
- paletteOpen: session.paletteOpen,
63
- paletteQuery: session.paletteQuery,
64
65
  panelCommand: session.panelCommand,
65
66
  panelDraft: session.panelDraft,
66
67
  panelCursorRow: session.panelCursorRow,
68
+ panelEntry: session.panelEntry,
67
69
  panelFiltered: session.panelFiltered,
68
70
  panelFocus: session.panelFocus,
69
71
  panelOpIndex: session.panelOpIndex,
70
72
  panelItems: session.panelItems,
73
+ panelLabelWidth: session.panelLabelWidth,
71
74
  panelOpen: session.panelOpen,
72
- panelOperation: session.panelOperation,
75
+ panelPicking: session.panelPicking,
73
76
  panelQuery: session.panelQuery,
74
77
  panelRowIndex: session.panelRowIndex,
75
78
  panelRows: session.panelRows,
76
79
  panelRunnable: session.panelRunnable,
77
80
  panelTextual: session.panelTextual,
78
81
  panelValue: session.panelValue,
82
+ // The explorer's first column: the parent of the current directory.
83
+ parentActive,
84
+ parentCursor: parent.cursor,
85
+ parentDisk: parent.disk,
86
+ parentEntries: parent.entries,
87
+ parentEntry: session.parentCursorEntry,
88
+ parentError: parent.error,
89
+ parentFilter: parent.filter,
90
+ parentId: "parent",
91
+ parentLoading: parent.loading,
92
+ parentMarked: parent.marked,
93
+ parentSegments: parent.segments,
94
+ parentSetFilter: (value) => parent.setFilter(value),
79
95
  preview: session.preview,
96
+ previewFull: session.previewFull,
97
+ // The third column: the directory the inspector previews, so it reads
98
+ // as a pane. Over a file the inspector stays a card and these idle.
99
+ previewActive,
100
+ previewCursor: preview.cursor,
101
+ previewEntries: preview.entries,
102
+ previewId: "preview",
103
+ previewLoading: preview.loading,
104
+ previewMarked: preview.marked,
105
+ previewSegments: preview.segments,
80
106
  rightActive,
81
107
  rightCursor: right.cursor,
82
108
  rightDisk: right.disk,
@@ -88,17 +114,33 @@ function createInit(session) {
88
114
  rightLoading: right.loading,
89
115
  rightMarked: right.marked,
90
116
  rightSegments: right.segments,
117
+ rightSetFilter: (value) => right.setFilter(value),
91
118
  showPreview: session.showPreview,
119
+ viewMode: session.viewMode,
120
+ // The panel's file picker: its filter input and the directory it
121
+ // currently browses, so the panel can render the picker view.
122
+ pickerPath: session.pickerPane?.path,
123
+ pickerQuery: session.pickerQuery,
92
124
  })
93
125
  }
94
126
 
127
+ // Function: fieldOwnsKeys
128
+ // True when one of the panel's inputs owns the keyboard: the command filter
129
+ // (`PANEL_FOCUS.OPS`) or a textual argument's value field (`panelTextual`).
130
+ // While an input owns the keyboard the shared handler leaves text editing to
131
+ // it — typing, Backspace, Delete, caret movement. A plain argument row or an
132
+ // item row has no input pressed, so the handler handles the key itself.
133
+ function fieldOwnsKeys(data, focus) {
134
+ return focus === PANEL_FOCUS.OPS || Boolean(unwrap(data.panelTextual))
135
+ }
136
+
95
137
  // Function: createBehaviors
96
138
  // Builds the App data/key behavior map for the given renderer environment.
97
139
  // Published events are registered separately via `createEvents()` (`.sub`).
98
140
  function createBehaviors(env) {
99
141
  return {
100
- // Global key handling: confirm dialogs first, palette mode, then the
101
- // keymap.
142
+ // Global key handling: confirm dialogs first, the command panel, then
143
+ // the keymap.
102
144
  keys: (self, data, event) => {
103
145
  const key = env.normalizeKey ? env.normalizeKey(event) : event
104
146
  if (unwrap(data.confirm)) {
@@ -107,33 +149,46 @@ function createBehaviors(env) {
107
149
  else if (name === "n" || name === "escape") data.dispatch("confirm.no")
108
150
  return
109
151
  }
110
- if (unwrap(data.paletteOpen)) {
111
- if (key?.name === "escape") {
112
- data.dispatch("palette.toggle")
113
- return
114
- }
115
- if (key?.name === "up") {
116
- data.dispatch("palette.up")
117
- return
118
- }
119
- if (key?.name === "down") {
120
- data.dispatch("palette.down")
121
- return
122
- }
123
- if (key?.name === "tab") {
124
- // Shift-Tab walks the palette list backwards.
125
- data.dispatch(key.shift ? "palette.up" : "palette.complete")
126
- return
127
- }
128
- if (key?.name === "return" || key?.name === "enter") {
129
- self.pub("palette:confirm", `${unwrap(data.paletteQuery) ?? ""}`)
152
+ if (unwrap(data.panelOpen)) {
153
+ // The picker is a sub-mode of the panel: it owns navigation and
154
+ // choice, while typing filters through its own input.
155
+ if (unwrap(data.panelPicking)) {
156
+ if (key?.name === "escape") {
157
+ data.dispatch("panel.pick.cancel")
158
+ return
159
+ }
160
+ if (key?.name === "tab") {
161
+ data.dispatch("panel.pick.cancel")
162
+ return
163
+ }
164
+ if (key?.name === "up") {
165
+ data.dispatch("panel.pick.up")
166
+ return
167
+ }
168
+ if (key?.name === "down") {
169
+ data.dispatch("panel.pick.down")
170
+ return
171
+ }
172
+ if (key?.name === "right") {
173
+ data.dispatch("panel.pick.descend")
174
+ return
175
+ }
176
+ if (key?.name === "backspace") {
177
+ // An empty filter steps up; otherwise the filter input
178
+ // owns Backspace and edits itself.
179
+ if (`${unwrap(data.pickerQuery) ?? ""}` === "") data.dispatch("panel.pick.parent")
180
+ return
181
+ }
182
+ if (key?.name === "return" || key?.name === "enter") {
183
+ data.dispatch(key.ctrl ? "panel.pick.chooseDir" : "panel.pick.enter")
184
+ return
185
+ }
186
+ // Everything else (typing, caret movement) is the filter
187
+ // input's to handle.
130
188
  return
131
189
  }
132
- // Let the focused input handle typing.
133
- return
134
- }
135
- if (unwrap(data.panelOpen)) {
136
190
  const focus = unwrap(data.panelFocus)
191
+ const fieldOwns = fieldOwnsKeys(data, focus)
137
192
  // Escape closes; Tab cycles the focus ring (command ⇄ args);
138
193
  // arrows move within the focused column; Enter runs the command.
139
194
  if (key?.name === "escape") {
@@ -152,29 +207,35 @@ function createBehaviors(env) {
152
207
  data.dispatch(focus === PANEL_FOCUS.ARGS ? "panel.nextArg" : "panel.down")
153
208
  return
154
209
  }
210
+ if (key?.name === "o" && key.ctrl) {
211
+ // Browse a path/destination argument in the file picker.
212
+ data.dispatch("panel.browse")
213
+ return
214
+ }
155
215
  if (key?.name === "return" || key?.name === "enter") {
156
- // A focused textual-argument field owns Enter (its `on:enter`
157
- // publishes `panel:confirm`), so the command is not run twice.
158
- if (focus === PANEL_FOCUS.ARGS && unwrap(data.panelTextual)) return
159
- // Enter runs the highlighted command with the current draft.
216
+ // Enter runs a complete command, or walks to the next
217
+ // missing field (see `panel.run`); the root routes it for
218
+ // every renderer, so no per-input `on:enter` is needed.
160
219
  data.dispatch("panel.run")
161
220
  return
162
221
  }
163
- // Space toggles a selection item in the argument column.
164
- if (key?.name === "space" && focus === PANEL_FOCUS.ARGS && !unwrap(data.panelTextual)) {
222
+ // While an input owns the keyboard (the command filter, or a
223
+ // textual argument's value field) it also owns text editing:
224
+ // typing, Backspace, Delete and caret movement pass through to
225
+ // it. Intercepting Backspace here used to bounce the cursor
226
+ // back to the command filter mid-edit.
227
+ if (fieldOwns) return
228
+ // A focused non-textual argument row: Space toggles its
229
+ // selection, any other printable key jumps back to the command
230
+ // filter and appends the character; Backspace edits the filter.
231
+ if (key?.name === "space") {
165
232
  data.dispatch("panel.toggleItem")
166
233
  return
167
234
  }
168
- if (key?.name === "backspace" && focus === PANEL_FOCUS.ARGS) {
169
- // Editing the command filter from the argument column.
235
+ if (key?.name === "backspace") {
170
236
  data.dispatch("panel.type", { backspace: true })
171
237
  return
172
238
  }
173
- // While the command column is focused, or a textual argument
174
- // owns the cursor, the focused input handles typing.
175
- if (focus !== PANEL_FOCUS.ARGS || unwrap(data.panelTextual)) return
176
- // Typing on a non-textual argument jumps back to the command
177
- // filter and appends the character.
178
239
  if (key?.name && key.name.length === 1 && !key.ctrl && !key.meta) {
179
240
  data.dispatch("panel.type", { char: key.name })
180
241
  return
@@ -244,7 +305,12 @@ function createBehaviors(env) {
244
305
  // filter (or on Shift-Esc) the keymap decides — Esc steps back
245
306
  // through the pane's directory history.
246
307
  if (key?.name === "escape" && !key.shift) {
247
- const filter = active === "left" ? unwrap(data.leftFilter) : unwrap(data.rightFilter)
308
+ const filters = {
309
+ left: data.leftFilter,
310
+ parent: data.parentFilter,
311
+ right: data.rightFilter,
312
+ }
313
+ const filter = unwrap(filters[active])
248
314
  if (filter) {
249
315
  data.dispatch("filter.clear")
250
316
  return
@@ -256,9 +322,6 @@ function createBehaviors(env) {
256
322
  env.quit(self)
257
323
  return
258
324
  }
259
- // The command panel is a TUI-only view; the web renderer omits the
260
- // capability so `m` never opens an invisible modal there.
261
- if (id === "panel.toggle" && !env.panel) return
262
325
  data.dispatch(id, { paneId: unwrap(data.active) })
263
326
  },
264
327
  }
@@ -279,24 +342,38 @@ function createEvents(env = {}) {
279
342
  if (event.data) data.dispatch(event.data)
280
343
  },
281
344
  "panel:select": (_self, data, event) => {
282
- // Clicking an operation row moves the cursor to it; clicking an
283
- // argument/item row focuses the argument column (toggling an item).
345
+ // Clicking a command row moves the cursor to it (a group header is
346
+ // inert); clicking an argument/item row focuses the right pane
347
+ // (toggling an item); clicking a picker row chooses it.
284
348
  const items = unwrap(data.panelItems) ?? []
285
- const index = items.findIndex((row) => row.id === event.data)
286
- if (index >= 0) {
287
- data.dispatch("panel.setOp", { index })
349
+ const item = items.find((row) => row.id === event.data)
350
+ if (item?.type === "op") {
351
+ data.dispatch("panel.setOp", { index: item.entryIndex })
288
352
  return
289
353
  }
354
+ if (item) return
290
355
  const rows = unwrap(data.panelRows) ?? []
291
356
  const rowIndex = rows.findIndex((row) => row.key === event.data)
292
357
  if (rowIndex < 0) return
358
+ const row = rows[rowIndex]
359
+ if (row?.type === "entry") {
360
+ data.dispatch("panel.focusArg")
361
+ data.dispatch("panel.pick.setCursor", { index: rowIndex })
362
+ data.dispatch("panel.pick.enter")
363
+ return
364
+ }
293
365
  data.dispatch("panel.focusArg")
294
366
  data.dispatch("panel.setRow", { index: rowIndex })
295
- const row = rows[rowIndex]
296
367
  if (row?.type === "item") data.dispatch("panel.toggleItem")
297
368
  },
298
369
  "panel:confirm": (_self, data) => {
370
+ // Published by a focused panel input's `on:enter`; the key handler
371
+ // leaves Enter to it (see `fieldOwnsKeys`).
299
372
  if (!unwrap(data.panelOpen)) return
373
+ if (unwrap(data.panelPicking)) {
374
+ data.dispatch("panel.pick.enter")
375
+ return
376
+ }
300
377
  data.dispatch("panel.run")
301
378
  },
302
379
  "crumbs:go": (_self, data, event) => {
@@ -320,33 +397,6 @@ function createEvents(env = {}) {
320
397
  if (unwrap(data.bookmarkMode) === "name") data.dispatch("bookmark.save")
321
398
  else data.dispatch("bookmark.select")
322
399
  },
323
- "palette:confirm": (_self, data, event) => {
324
- // The global key handler and the input's on:enter can both fire;
325
- // only the first one (while the palette is open) acts.
326
- if (!unwrap(data.paletteOpen)) return
327
- if (unwrap(data.paletteMode) === "script") {
328
- // Submit the typed line: run it, or complete the highlighted row
329
- // when the input is empty.
330
- data.dispatch("palette.submit")
331
- return
332
- }
333
- const items = unwrap(data.paletteItems) ?? []
334
- const index = Math.max(0, Math.min(unwrap(data.paletteIndex) ?? 0, items.length - 1))
335
- const match = items[index] ?? items[0]
336
- if (match?.id) data.dispatch(match.id)
337
- data.dispatch("palette.toggle")
338
- },
339
- "palette:run": (_self, data, event) => {
340
- if (!event.data) return
341
- if (unwrap(data.paletteMode) === "script") {
342
- // Clicking a script row completes it; running stays on Enter.
343
- const item = (unwrap(data.paletteItems) ?? []).find((row) => row.id === event.data)
344
- if (item?.completion) data.dispatch("palette.setQuery", { query: item.completion })
345
- return
346
- }
347
- data.dispatch(event.data)
348
- data.dispatch("palette.toggle")
349
- },
350
400
  }
351
401
  }
352
402
 
@@ -10,7 +10,7 @@
10
10
  <!-- Identity bar: the one visible trace of TinyCmd itself (the host
11
11
  aside, everything else on screen is the user's files). "Tiny"
12
12
  shimmers blue letter by letter, "Cmd" shimmers orange. -->
13
- <box flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
13
+ <box flexDirection="row" height="1" paddingLeft="1" paddingRight="1" overflow="hidden">
14
14
  <text out="brandT" out:fg="brandTFg" wrapMode="none"></text>
15
15
  <text out="brandI" out:fg="brandIFg" wrapMode="none"></text>
16
16
  <text out="brandN" out:fg="brandNFg" wrapMode="none"></text>
@@ -19,6 +19,23 @@
19
19
  <text out="brandM" out:fg="brandMFg" wrapMode="none"></text>
20
20
  <text out="brandD" out:fg="brandDFg" wrapMode="none"></text>
21
21
  <text out="topBar" out:fg="topBarFg" wrapMode="none"></text>
22
+ <box flexGrow="1" flexShrink="1" minWidth="0" overflow="hidden"></box>
23
+ <!-- The view mode, letter by letter so each carries its gradient:
24
+ explorer shimmers along the Tiny blues, commander along the
25
+ Cmd oranges (see `modeGradient` in Frame.js). The trailing
26
+ slots of the shorter word are empty and take no column. -->
27
+ <box flexDirection="row" flexShrink="0" overflow="hidden">
28
+ <text when="modeHas0" out="modeL0" out:fg="modeFg0" wrapMode="none"></text>
29
+ <text when="modeHas1" out="modeL1" out:fg="modeFg1" wrapMode="none"></text>
30
+ <text when="modeHas2" out="modeL2" out:fg="modeFg2" wrapMode="none"></text>
31
+ <text when="modeHas3" out="modeL3" out:fg="modeFg3" wrapMode="none"></text>
32
+ <text when="modeHas4" out="modeL4" out:fg="modeFg4" wrapMode="none"></text>
33
+ <text when="modeHas5" out="modeL5" out:fg="modeFg5" wrapMode="none"></text>
34
+ <text when="modeHas6" out="modeL6" out:fg="modeFg6" wrapMode="none"></text>
35
+ <text when="modeHas7" out="modeL7" out:fg="modeFg7" wrapMode="none"></text>
36
+ <text when="modeHas8" out="modeL8" out:fg="modeFg8" wrapMode="none"></text>
37
+ <text out="topBarStatus" out:fg="topBarStatusFg" wrapMode="none"></text>
38
+ </box>
22
39
  </box>
23
40
 
24
41
  <!-- Minimum-size gate: below the breakpoint the workspace is unusable. -->
@@ -27,17 +44,36 @@
27
44
  <text out="minSizeHint" out:fg="dimFg"></text>
28
45
  </box>
29
46
 
30
- <!-- Two boxed panes plus the inline inspector. -->
47
+ <!-- Columns: commander tiles source and destination; explorer leads
48
+ with the parent mirror so the current pane sits in the middle,
49
+ slightly wider than its neighbours. The inspector completes the
50
+ three columns in either mode. -->
31
51
  <box when="!tooSmall" flexDirection="column" flexGrow="1" overflow="hidden">
32
- <box flexDirection="row" flexGrow="1" overflow="hidden">
33
- <box flexDirection="column" flexGrow="1" border borderStyle="single"
52
+ <box when="!showPreviewFull" flexDirection="row" flexGrow="1" overflow="hidden">
53
+ <box when="showParentPane" flexDirection="column" flexGrow="1" flexBasis="0" border borderStyle="single"
54
+ out:border="leftBorder" out:borderColor="parentBorderFg"
55
+ on:click="parentId!pane:activate">
56
+ <box flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
57
+ <box when="!parentFilterOn" flexGrow="1" flexShrink="1" minWidth="0" overflow="hidden">
58
+ <breadcrumbs in:segments="parentSegments" in:paneId="parentId" height="1"></breadcrumbs>
59
+ </box>
60
+ <filterbar in:query="parentFilter" in:paneId="parentId" in:filtering="filtering" in:entries="parentEntries" in:setFilter="parentSetFilter"></filterbar>
61
+ </box>
62
+ <box height="1" out:border="headRule" out:borderColor="parentHeadRuleFg"></box>
63
+ <filelist in:entries="parentEntries" in:cursor="parentCursor" in:marked="parentMarked"
64
+ in:active="parentActive" in:paneId="parentId" in:loading="parentLoading"
65
+ in:viewport="listViewport"></filelist>
66
+ <entrydetail in:entry="parentEntry" in:marked="parentMarked" height="2"></entrydetail>
67
+ <summary in:entries="parentEntries" in:disk="parentDisk" height="1"></summary>
68
+ </box>
69
+ <box when="showLeftPane" flexDirection="column" flexBasis="0" out:flexGrow="leftGrow" border borderStyle="single"
34
70
  out:border="leftBorder" out:borderColor="leftBorderFg"
35
71
  on:click="leftId!pane:activate">
36
72
  <box flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
37
73
  <box when="!leftFilterOn" flexGrow="1" flexShrink="1" minWidth="0" overflow="hidden">
38
74
  <breadcrumbs in:segments="leftSegments" in:paneId="leftId" height="1"></breadcrumbs>
39
75
  </box>
40
- <filterbar in:query="leftFilter" in:paneId="leftId" in:filtering="filtering" in:entries="leftEntries"></filterbar>
76
+ <filterbar in:query="leftFilter" in:paneId="leftId" in:filtering="filtering" in:entries="leftEntries" in:setFilter="leftSetFilter"></filterbar>
41
77
  </box>
42
78
  <box height="1" out:border="headRule" out:borderColor="leftHeadRuleFg"></box>
43
79
  <filelist in:entries="leftEntries" in:cursor="leftCursor" in:marked="leftMarked"
@@ -46,14 +82,14 @@
46
82
  <entrydetail in:entry="leftEntry" in:marked="leftMarked" height="2"></entrydetail>
47
83
  <summary in:entries="leftEntries" in:disk="leftDisk" height="1"></summary>
48
84
  </box>
49
- <box flexDirection="column" flexGrow="1" border borderStyle="single"
85
+ <box when="showRightPane" flexDirection="column" flexBasis="0" out:flexGrow="rightGrow" border borderStyle="single"
50
86
  out:border="rightBorder" out:borderColor="rightBorderFg"
51
87
  on:click="rightId!pane:activate">
52
88
  <box flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
53
89
  <box when="!rightFilterOn" flexGrow="1" flexShrink="1" minWidth="0" overflow="hidden">
54
90
  <breadcrumbs in:segments="rightSegments" in:paneId="rightId" height="1"></breadcrumbs>
55
91
  </box>
56
- <filterbar in:query="rightFilter" in:paneId="rightId" in:filtering="filtering" in:entries="rightEntries"></filterbar>
92
+ <filterbar in:query="rightFilter" in:paneId="rightId" in:filtering="filtering" in:entries="rightEntries" in:setFilter="rightSetFilter"></filterbar>
57
93
  </box>
58
94
  <box height="1" out:border="headRule" out:borderColor="rightHeadRuleFg"></box>
59
95
  <filelist in:entries="rightEntries" in:cursor="rightCursor" in:marked="rightMarked"
@@ -63,8 +99,20 @@
63
99
  <summary in:entries="rightEntries" in:disk="rightDisk" height="1"></summary>
64
100
  </box>
65
101
  <filepreview when="showInspectorInline" in:preview="preview" in:show="showInspectorInline"
66
- in:entry="appEntry" flexGrow="1"></filepreview>
102
+ in:entry="appEntry" in:paneId="previewId"
103
+ in:dirSegments="previewSegments" in:dirEntries="previewEntries"
104
+ in:dirCursor="previewCursor" in:dirMarked="previewMarked"
105
+ in:dirActive="previewActive" in:dirLoading="previewLoading"
106
+ flexGrow="1" flexBasis="0"></filepreview>
67
107
  </box>
108
+ <!-- Maximized inspector (`⇧V`): the preview takes the whole pane
109
+ row, the identity and command bars staying in place. -->
110
+ <filepreview when="showPreviewFull" in:preview="preview" in:show="showPreviewFull"
111
+ in:entry="appEntry" in:paneId="previewId"
112
+ in:dirSegments="previewSegments" in:dirEntries="previewEntries"
113
+ in:dirCursor="previewCursor" in:dirMarked="previewMarked"
114
+ in:dirActive="previewActive" in:dirLoading="previewLoading"
115
+ flexGrow="1"></filepreview>
68
116
  <commandbar in:items="commandBarItems" in:notice="notice" in:leftError="leftError"
69
117
  in:rightError="rightError" in:active="active"
70
118
  in:leftMarked="leftMarked" in:rightMarked="rightMarked" out:height="barHeight"></commandbar>
@@ -75,18 +123,19 @@
75
123
  <box when="showViewer" position="absolute" top="0" left="0" width="100%" height="100%" zIndex="10"
76
124
  alignItems="center" justifyContent="center">
77
125
  <box width="80%" height="90%">
78
- <filepreview in:preview="preview" in:show="showViewer" in:entry="appEntry"></filepreview>
126
+ <filepreview in:preview="preview" in:show="showViewer" in:entry="appEntry" in:paneId="previewId"
127
+ in:dirSegments="previewSegments" in:dirEntries="previewEntries"
128
+ in:dirCursor="previewCursor" in:dirMarked="previewMarked"
129
+ in:dirActive="previewActive" in:dirLoading="previewLoading"></filepreview>
79
130
  </box>
80
131
  </box>
81
132
 
82
- <commandpalette when="paletteOpen" position="absolute" top="0" left="0" width="100%" height="100%" zIndex="20"
83
- in:open="paletteOpen" in:mode="paletteMode" in:query="paletteQuery"
84
- in:items="paletteItems" in:selected="paletteIndex"></commandpalette>
85
133
  <commandpanel when="panelOpen" position="absolute" top="0" left="0" width="100%" height="100%" zIndex="22"
86
- in:open="panelOpen" in:operation="panelOperation" in:items="panelItems"
134
+ in:open="panelOpen" in:entry="panelEntry" in:items="panelItems"
87
135
  in:opCursor="panelOpIndex" in:rows="panelRows" in:argCursor="panelRowIndex"
88
- in:focus="panelFocus" in:textual="panelTextual" in:argRow="panelCursorRow"
89
- in:command="panelCommand" in:runnable="panelRunnable"
136
+ in:labelWidth="panelLabelWidth"
137
+ in:focus="panelFocus" in:command="panelCommand" in:runnable="panelRunnable"
138
+ in:picking="panelPicking" in:pickerQuery="pickerQuery" in:pickerPath="pickerPath"
90
139
  in:query="panelQuery" in:value="panelValue"></commandpanel>
91
140
  <bookmarklist when="bookmarkOpen" position="absolute" top="0" left="0" width="100%" height="100%" zIndex="25"
92
141
  in:open="bookmarkOpen" in:mode="bookmarkMode" in:query="bookmarkQuery"
@@ -1,8 +1,9 @@
1
- <div class="bookmark-list" when="open">
1
+ <div class="bookmark-list" when="open" role="dialog" aria-modal="true" aria-label="Bookmarks">
2
2
  <div class="bookmark-dialog">
3
+ <h2 out="title"></h2>
3
4
  <header>
4
5
  <span class="bookmark-sign" out="sign"></span>
5
- <input class="bookmark-query" type="text" inout="query" on:enter="confirm">
6
+ <input class="bookmark-query" type="text" inout="query" aria-label="Bookmark name or query" on:enter="confirm">
6
7
  </header>
7
8
  <p class="bookmark-target" when="inputVisible" out="target"></p>
8
9
  <p class="bookmark-empty" when="emptyList" out="empty"></p>
@@ -62,7 +62,7 @@ function createBehaviors(env) {
62
62
  emptyFg: () => env.textSecondaryFg,
63
63
  }
64
64
  if (env?.rows) {
65
- behaviors.rows = (_self, data) => env.rows(self, data)
65
+ behaviors.rows = (self, data) => env.rows(self, data)
66
66
  }
67
67
  if (env?.scrollTop) {
68
68
  behaviors.scrollTop = (self, data) => env.scrollTop(self, data)
@@ -17,7 +17,7 @@
17
17
  function createBehaviors(env) {
18
18
  const behaviors = { listClass: () => env?.listClass }
19
19
  if (env?.rows) {
20
- behaviors.rows = (_self, data) => env.rows(self, data)
20
+ behaviors.rows = (self, data) => env.rows(self, data)
21
21
  }
22
22
  return behaviors
23
23
  }