@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
@@ -4,11 +4,13 @@
4
4
  // Created: 2026-09-26
5
5
 
6
6
  // Module: tcmd/components/App
7
- // Root component: a header, two panes (breadcrumbs + FileList + summary each),
8
- // an optional preview column, the command bar and the command palette overlay.
7
+ // Root component: a header, two panes (breadcrumbs + FileList + detail each),
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
- // never mutates model state directly.
11
+ // never mutates model state directly. It also owns the shared modal key
12
+ // routing — the fullscreen viewer overlay included — so both renderers behave
13
+ // alike.
12
14
  //
13
15
  // Environment: `normalizeKey(event)` (optional; identity when omitted) and
14
16
  // `quit(self)`. Renderer-specific bindings (pane borders, status color) live in
@@ -17,31 +19,40 @@
17
19
  import { derived, unwrap } from "select/state/cells"
18
20
  import { DEFAULT_KEYMAP, matchKeymap } from "../core/keymap.js"
19
21
  import { PANEL_FOCUS } from "../core/panel.js"
22
+ import { isNarrow } from "./Frame.js"
23
+
24
+ // Constant: DOUBLE_CLICK_MS
25
+ // Two clicks on the same listing row within this window count as a double
26
+ // click, which opens the row like Enter (see `createEvents#list:select`).
27
+ const DOUBLE_CLICK_MS = 400
20
28
 
21
29
  // Function: createInit
22
30
  // State initializer binding the root component to a `session`.
23
31
  function createInit(session) {
24
32
  const left = session.panes.left
25
33
  const right = session.panes.right
34
+ const parent = session.panes.parent ?? {}
35
+ const preview = session.panes.preview ?? {}
26
36
  // Pane-focus flags so the listing can render the cursor differently in the
27
37
  // inactive pane. Shared by both engines.
28
38
  const leftActive = derived([session.active], ([active]) => active === "left")
29
39
  const rightActive = derived([session.active], ([active]) => active === "right")
40
+ const parentActive = derived([session.active], ([active]) => active === "parent")
41
+ const previewActive = derived([session.active], ([active]) => active === "preview")
30
42
  return () => ({
31
43
  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.
44
+ // The preview target entry, so the inspector can show a directory
45
+ // column even when there is no text/card preview.
34
46
  appEntry: session.previewEntry,
35
47
  appHost: session.host,
36
48
  appPath: session.activePath,
37
49
  bookmarkIndex: session.bookmarkIndex,
38
50
  bookmarkItems: session.bookmarkItems,
39
- bookmarkMode: session.bookmarkMode,
40
51
  bookmarkOpen: session.bookmarkOpen,
41
52
  bookmarkQuery: session.bookmarkQuery,
42
- bookmarkTarget: session.bookmarkTarget,
43
53
  commandBarItems: session.commandBarItems,
44
54
  confirm: session.confirm,
55
+ currentId: session.currentId,
45
56
  dispatch: session.dispatch,
46
57
  filtering: session.filtering,
47
58
  leftActive,
@@ -55,28 +66,53 @@ function createInit(session) {
55
66
  leftLoading: left.loading,
56
67
  leftMarked: left.marked,
57
68
  leftSegments: left.segments,
69
+ leftSetFilter: (value) => left.setFilter(value),
58
70
  notice: session.notice,
59
- paletteIndex: session.paletteIndex,
60
- paletteItems: session.paletteItems,
61
- paletteMode: session.paletteMode,
62
- paletteOpen: session.paletteOpen,
63
- paletteQuery: session.paletteQuery,
64
- panelCommand: session.panelCommand,
71
+ // The shared list overlay (`u` history / `⇧T` trash).
72
+ overlay: session.overlay,
73
+ overlayIndex: session.overlayIndex,
74
+ overlayItems: session.overlayItems,
65
75
  panelDraft: session.panelDraft,
66
76
  panelCursorRow: session.panelCursorRow,
77
+ panelEntry: session.panelEntry,
67
78
  panelFiltered: session.panelFiltered,
68
79
  panelFocus: session.panelFocus,
69
80
  panelOpIndex: session.panelOpIndex,
70
81
  panelItems: session.panelItems,
71
82
  panelOpen: session.panelOpen,
72
- panelOperation: session.panelOperation,
83
+ panelPermBit: session.panelPermBit,
84
+ panelPreview: session.panelPreview,
73
85
  panelQuery: session.panelQuery,
74
86
  panelRowIndex: session.panelRowIndex,
75
87
  panelRows: session.panelRows,
76
- panelRunnable: session.panelRunnable,
77
88
  panelTextual: session.panelTextual,
78
89
  panelValue: session.panelValue,
90
+ // The explorer's first column: the parent of the current directory.
91
+ parentActive,
92
+ parentCursor: parent.cursor,
93
+ parentDisk: parent.disk,
94
+ parentEntries: parent.entries,
95
+ parentEntry: session.parentCursorEntry,
96
+ parentError: parent.error,
97
+ parentFilter: parent.filter,
98
+ parentId: "parent",
99
+ parentLoading: parent.loading,
100
+ parentMarked: parent.marked,
101
+ parentSegments: parent.segments,
102
+ parentSetFilter: (value) => parent.setFilter(value),
79
103
  preview: session.preview,
104
+ previewFull: session.previewFull,
105
+ // The full previews apply body scroll requests through this cell.
106
+ previewScroll: session.previewScroll,
107
+ // The third column: the directory the inspector previews, so it reads
108
+ // as a pane. Over a file the inspector stays a card and these idle.
109
+ previewActive,
110
+ previewCursor: preview.cursor,
111
+ previewEntries: preview.entries,
112
+ previewId: "preview",
113
+ previewLoading: preview.loading,
114
+ previewMarked: preview.marked,
115
+ previewSegments: preview.segments,
80
116
  rightActive,
81
117
  rightCursor: right.cursor,
82
118
  rightDisk: right.disk,
@@ -88,52 +124,149 @@ function createInit(session) {
88
124
  rightLoading: right.loading,
89
125
  rightMarked: right.marked,
90
126
  rightSegments: right.segments,
127
+ rightSetFilter: (value) => right.setFilter(value),
91
128
  showPreview: session.showPreview,
129
+ viewMode: session.viewMode,
130
+ // The panel's file picker: its open mode, editable path, focus region
131
+ // and rows, so the panel can render the picker view.
132
+ pickerOpen: session.pickerOpen,
133
+ pickerPathText: session.pickerPathText,
134
+ pickerFocus: session.pickerFocus,
135
+ pickerRows: session.pickerRows,
136
+ pickerQuery: session.pickerQuery,
92
137
  })
93
138
  }
94
139
 
140
+ // Function: fieldOwnsKeys
141
+ // True when one of the panel's inputs owns the keyboard: the command filter
142
+ // (`PANEL_FOCUS.OPS`) or a textual argument's value field (`panelTextual`).
143
+ // While an input owns the keyboard the shared handler leaves text editing to
144
+ // it — typing, Backspace, Delete, caret movement. A plain argument row or an
145
+ // item row has no input pressed, so the handler handles the key itself.
146
+ function fieldOwnsKeys(data, focus) {
147
+ return focus === PANEL_FOCUS.OPS || Boolean(unwrap(data.panelTextual))
148
+ }
149
+
150
+ // Function: isFilePreview
151
+ // True when the inspector shows a file — a text body or a metadata card — as
152
+ // opposed to a directory column, which keeps its listing cursor navigation.
153
+ function isFilePreview(data) {
154
+ const entry = unwrap(data.appEntry)
155
+ return Boolean(entry) && !entry.isDir
156
+ }
157
+
158
+ // Function: routePreviewKeys
159
+ // The arrows of a full preview: Up/Down scroll the body, Left/Right step to
160
+ // the previous/next entry (the cursor movement the preview already follows).
161
+ // Returns true when the key was handled, so the caller can stop routing.
162
+ function routePreviewKeys(data, key) {
163
+ if (!isFilePreview(data)) return false
164
+ const name = key?.name
165
+ if (name === "up" || name === "down") {
166
+ data.dispatch("preview.scroll", { delta: name === "up" ? -1 : 1 })
167
+ return true
168
+ }
169
+ if (name === "left" || name === "right") {
170
+ data.dispatch(name === "left" ? "nav.up" : "nav.down")
171
+ return true
172
+ }
173
+ return false
174
+ }
175
+
95
176
  // Function: createBehaviors
96
177
  // Builds the App data/key behavior map for the given renderer environment.
97
178
  // Published events are registered separately via `createEvents()` (`.sub`).
98
179
  function createBehaviors(env) {
99
180
  return {
100
- // Global key handling: confirm dialogs first, palette mode, then the
101
- // keymap.
181
+ // Global key handling: the fullscreen viewer modal first, then confirm
182
+ // dialogs, the command panel, and finally the keymap.
102
183
  keys: (self, data, event) => {
103
184
  const key = env.normalizeKey ? env.normalizeKey(event) : event
185
+ // The fullscreen inspector overlay is modal at any width: it opens
186
+ // from Enter on a file the host cannot edit (see `core/commands`)
187
+ // and from `v` below the inline breakpoint (below). While open it
188
+ // owns the keyboard — Esc or `v` closes it, ⇧O/⇧E act on the file,
189
+ // everything else is swallowed.
190
+ if (unwrap(data.viewerOpen)) {
191
+ if (key?.name === "escape" || (key?.name === "v" && !key.shift)) {
192
+ data.dispatch("view.toggleViewer")
193
+ } else if (key?.shift && key?.name === "o") {
194
+ data.dispatch("file.open", { paneId: unwrap(data.active) })
195
+ } else if (key?.shift && key?.name === "e") {
196
+ data.dispatch("file.edit", { paneId: unwrap(data.active) })
197
+ } else {
198
+ routePreviewKeys(data, key)
199
+ }
200
+ return
201
+ }
104
202
  if (unwrap(data.confirm)) {
105
203
  const name = key?.name
106
204
  if (name === "y" || name === "return" || name === "enter") data.dispatch("confirm.yes")
107
205
  else if (name === "n" || name === "escape") data.dispatch("confirm.no")
108
206
  return
109
207
  }
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) ?? ""}`)
208
+ if (unwrap(data.panelOpen)) {
209
+ // The picker is a sub-mode of the panel: its editable path owns
210
+ // typing, and the entry list owns navigation once focused.
211
+ if (unwrap(data.pickerOpen)) {
212
+ const field = unwrap(data.pickerFocus) === "rows" ? "rows" : "path"
213
+ if (key?.name === "escape") {
214
+ data.dispatch("picker.cancel")
215
+ return
216
+ }
217
+ if (key?.name === "tab") {
218
+ data.dispatch(field === "path" ? "picker.focusRows" : "picker.focusPath")
219
+ return
220
+ }
221
+ if (field === "path") {
222
+ // Typing, Backspace and caret movement belong to the path
223
+ // input. Up/Down hand over to the entries, Enter commits
224
+ // the typed path.
225
+ if (key?.name === "up" || key?.name === "down") {
226
+ data.dispatch("picker.focusRows")
227
+ return
228
+ }
229
+ if (key?.name === "return" || key?.name === "enter") {
230
+ data.dispatch("picker.path")
231
+ return
232
+ }
233
+ return
234
+ }
235
+ if (key?.name === "up") {
236
+ data.dispatch("picker.up")
237
+ return
238
+ }
239
+ if (key?.name === "down") {
240
+ data.dispatch("picker.down")
241
+ return
242
+ }
243
+ if (key?.name === "left") {
244
+ data.dispatch("picker.focusPath")
245
+ return
246
+ }
247
+ if (key?.name === "right") {
248
+ data.dispatch("picker.descend")
249
+ return
250
+ }
251
+ if (key?.name === "backspace") {
252
+ // An empty path (the current directory) steps up; a
253
+ // typed fragment is edited in the path input instead.
254
+ if (`${unwrap(data.pickerQuery) ?? ""}` === "") data.dispatch("picker.parent")
255
+ return
256
+ }
257
+ if (key?.name === "return" || key?.name === "enter") {
258
+ data.dispatch(key.ctrl ? "picker.chooseDir" : "picker.enter")
259
+ return
260
+ }
261
+ // Typing anywhere in the picker narrows the path.
262
+ const char = key?.name === "space" ? " " : key?.name
263
+ if (char && char.length === 1 && !key.ctrl && !key.meta) {
264
+ data.dispatch("picker.type", { char })
265
+ }
130
266
  return
131
267
  }
132
- // Let the focused input handle typing.
133
- return
134
- }
135
- if (unwrap(data.panelOpen)) {
136
268
  const focus = unwrap(data.panelFocus)
269
+ const fieldOwns = fieldOwnsKeys(data, focus)
137
270
  // Escape closes; Tab cycles the focus ring (command ⇄ args);
138
271
  // arrows move within the focused column; Enter runs the command.
139
272
  if (key?.name === "escape") {
@@ -152,29 +285,40 @@ function createBehaviors(env) {
152
285
  data.dispatch(focus === PANEL_FOCUS.ARGS ? "panel.nextArg" : "panel.down")
153
286
  return
154
287
  }
288
+ if (key?.name === "o" && key.ctrl) {
289
+ // Browse a path/destination argument in the file picker.
290
+ data.dispatch("panel.browse")
291
+ return
292
+ }
155
293
  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.
294
+ // Enter runs a complete command, or walks to the next
295
+ // missing field (see `panel.run`); the root routes it for
296
+ // every renderer, so no per-input `on:enter` is needed.
160
297
  data.dispatch("panel.run")
161
298
  return
162
299
  }
163
- // Space toggles a selection item in the argument column.
164
- if (key?.name === "space" && focus === PANEL_FOCUS.ARGS && !unwrap(data.panelTextual)) {
300
+ // While an input owns the keyboard (the command filter, or a
301
+ // textual argument's value field) it also owns text editing:
302
+ // typing, Backspace, Delete and caret movement pass through to
303
+ // it. Intercepting Backspace here used to bounce the cursor
304
+ // back to the command filter mid-edit.
305
+ if (fieldOwns) return
306
+ // A focused permission row: Left/Right walk its r/w/x cells.
307
+ if ((key?.name === "left" || key?.name === "right") && focus === PANEL_FOCUS.ARGS) {
308
+ data.dispatch("panel.moveBit", { delta: key.name === "left" ? -1 : 1 })
309
+ return
310
+ }
311
+ // A focused non-textual argument row: Space toggles its
312
+ // selection, any other printable key jumps back to the command
313
+ // filter and appends the character; Backspace edits the filter.
314
+ if (key?.name === "space") {
165
315
  data.dispatch("panel.toggleItem")
166
316
  return
167
317
  }
168
- if (key?.name === "backspace" && focus === PANEL_FOCUS.ARGS) {
169
- // Editing the command filter from the argument column.
318
+ if (key?.name === "backspace") {
170
319
  data.dispatch("panel.type", { backspace: true })
171
320
  return
172
321
  }
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
322
  if (key?.name && key.name.length === 1 && !key.ctrl && !key.meta) {
179
323
  data.dispatch("panel.type", { char: key.name })
180
324
  return
@@ -182,20 +326,14 @@ function createBehaviors(env) {
182
326
  return
183
327
  }
184
328
  if (unwrap(data.bookmarkOpen)) {
185
- const mode = unwrap(data.bookmarkMode)
329
+ // One panel: the filter input owns typing, arrows move the
330
+ // list, Enter activates the row. Backspace edits the query while
331
+ // it has text and removes the selected bookmark once it is
332
+ // empty; Delete always removes.
186
333
  if (key?.name === "escape") {
187
334
  data.dispatch("bookmark.cancel")
188
335
  return
189
336
  }
190
- if (mode === "name") {
191
- // The input owns typing; Enter saves.
192
- if (key?.name === "return" || key?.name === "enter") {
193
- self.pub("bookmark:confirm")
194
- return
195
- }
196
- return
197
- }
198
- // List mode: arrows move, Enter jumps.
199
337
  if (key?.name === "up") {
200
338
  data.dispatch("bookmark.up")
201
339
  return
@@ -204,11 +342,42 @@ function createBehaviors(env) {
204
342
  data.dispatch("bookmark.down")
205
343
  return
206
344
  }
345
+ if (key?.name === "delete") {
346
+ data.dispatch("bookmark.remove")
347
+ return
348
+ }
349
+ if (key?.name === "backspace" && !`${unwrap(data.bookmarkQuery) ?? ""}`.trim()) {
350
+ data.dispatch("bookmark.remove")
351
+ return
352
+ }
207
353
  if (key?.name === "return" || key?.name === "enter") {
208
354
  self.pub("bookmark:confirm")
209
355
  return
210
356
  }
211
- // Let the focused input handle typing the fuzzy query.
357
+ // Let the focused input handle typing the filter.
358
+ return
359
+ }
360
+ // The shared list overlay (history / trash) is modal: arrows move,
361
+ // Enter activates the row (jump in history, restore from trash), Esc
362
+ // closes. The key that opened it also closes it.
363
+ if (unwrap(data.overlay)) {
364
+ const mode = unwrap(data.overlay)
365
+ if (key?.name === "escape" || (key?.name === "u" && mode === "history") || (key?.name === "t" && key?.shift && mode === "trash")) {
366
+ data.dispatch("overlay.cancel")
367
+ return
368
+ }
369
+ if (key?.name === "up") {
370
+ data.dispatch("overlay.up")
371
+ return
372
+ }
373
+ if (key?.name === "down") {
374
+ data.dispatch("overlay.down")
375
+ return
376
+ }
377
+ if (key?.name === "return" || key?.name === "enter") {
378
+ data.dispatch("overlay.activate")
379
+ return
380
+ }
212
381
  return
213
382
  }
214
383
  const active = unwrap(data.active)
@@ -244,21 +413,34 @@ function createBehaviors(env) {
244
413
  // filter (or on Shift-Esc) the keymap decides — Esc steps back
245
414
  // through the pane's directory history.
246
415
  if (key?.name === "escape" && !key.shift) {
247
- const filter = active === "left" ? unwrap(data.leftFilter) : unwrap(data.rightFilter)
416
+ const filters = {
417
+ left: data.leftFilter,
418
+ parent: data.parentFilter,
419
+ right: data.rightFilter,
420
+ }
421
+ const filter = unwrap(filters[active])
248
422
  if (filter) {
249
423
  data.dispatch("filter.clear")
250
424
  return
251
425
  }
252
426
  }
427
+ // The maximized inspector (`⇧V`) previews a file full-width: the
428
+ // arrows scroll and step through files instead of driving the pane
429
+ // underneath. A directory column keeps its listing navigation.
430
+ if (unwrap(data.previewFull) && routePreviewKeys(data, key)) return
431
+ // Below the inline breakpoint the viewer overlay replaces the side
432
+ // panel, so `v` opens it instead of toggling `showPreview` (⇧V
433
+ // still maximizes over the columns, via the keymap).
434
+ if (key?.name === "v" && !key?.shift && isNarrow(data, self)) {
435
+ data.dispatch("view.toggleViewer")
436
+ return
437
+ }
253
438
  const id = matchKeymap(DEFAULT_KEYMAP, key)
254
439
  if (!id) return
255
440
  if (id === "app.quit") {
256
441
  env.quit(self)
257
442
  return
258
443
  }
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
444
  data.dispatch(id, { paneId: unwrap(data.active) })
263
445
  },
264
446
  }
@@ -266,90 +448,133 @@ function createBehaviors(env) {
266
448
 
267
449
  // Function: createEvents
268
450
  // App event subscriptions, registered with `.sub(...)`. Handlers receive
269
- // `(self, data, event)` with the published payload in `event.data`. The
270
- // environment carries the renderer's `quit` hook, so the command bar's Quit
271
- // entry takes the same path as the `q` key.
272
- function createEvents(env = {}) {
451
+ // `(self, data, event)` with the published payload in `event.data`.
452
+ function createEvents() {
453
+ // Last listing row clicked, for the double-click-to-open gesture: a second
454
+ // click on the same row within `DOUBLE_CLICK_MS` opens it like Enter.
455
+ let lastRowClick = { paneId: null, index: -1, at: 0 }
273
456
  return {
274
- "commandbar:run": (_self, data, event) => {
275
- if (event.data === "app.quit") {
276
- env.quit?.(_self)
457
+ "commandbar:run": (self, data, event) => {
458
+ // Below the inline breakpoint the bar's `[Preview v]` opens the
459
+ // viewer overlay, the same as the key (see `createBehaviors#keys`).
460
+ if (event.data === "view.togglePreview" && isNarrow(data, self)) {
461
+ data.dispatch("view.toggleViewer")
277
462
  return
278
463
  }
279
464
  if (event.data) data.dispatch(event.data)
280
465
  },
281
466
  "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).
467
+ // Clicking a command row moves the cursor to it (a group header is
468
+ // inert); clicking an argument/item row focuses the right pane
469
+ // (toggling an item); clicking a picker row chooses or navigates it.
284
470
  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 })
471
+ const item = items.find((row) => row.key === event.data)
472
+ if (item?.type === "op") {
473
+ data.dispatch("panel.setOp", { index: item.entryIndex })
288
474
  return
289
475
  }
476
+ if (item) return
290
477
  const rows = unwrap(data.panelRows) ?? []
291
478
  const rowIndex = rows.findIndex((row) => row.key === event.data)
292
479
  if (rowIndex < 0) return
480
+ const row = rows[rowIndex]
481
+ if (row?.type === "entry" || row?.type === "parent") {
482
+ data.dispatch("panel.focusArg")
483
+ data.dispatch("picker.setCursor", { index: rowIndex })
484
+ data.dispatch(row.type === "parent" ? "picker.parent" : "picker.enter")
485
+ return
486
+ }
487
+ if (row?.type === "run") {
488
+ data.dispatch("panel.focusArg")
489
+ data.dispatch("panel.setRow", { index: rowIndex })
490
+ data.dispatch("panel.run")
491
+ return
492
+ }
493
+ // A read-only value (a single-path selection, resolved from the
494
+ // pane) is a path worth copying: clicking it sends the value to the
495
+ // host clipboard. Copy runs last so its confirmation is not cleared
496
+ // by the focus dispatches (each dispatch resets the notice).
293
497
  data.dispatch("panel.focusArg")
294
498
  data.dispatch("panel.setRow", { index: rowIndex })
295
- const row = rows[rowIndex]
296
499
  if (row?.type === "item") data.dispatch("panel.toggleItem")
500
+ if (row?.type === "arg" && row.readonly === true && row.summary) {
501
+ data.dispatch("panel.copy", { text: row.summary })
502
+ }
297
503
  },
298
504
  "panel:confirm": (_self, data) => {
505
+ // Published by a focused panel input's `on:enter`; the key handler
506
+ // leaves Enter to it (see `fieldOwnsKeys`).
299
507
  if (!unwrap(data.panelOpen)) return
508
+ if (unwrap(data.pickerOpen)) {
509
+ data.dispatch("picker.enter")
510
+ return
511
+ }
300
512
  data.dispatch("panel.run")
301
513
  },
514
+ "panel:perm": (_self, data, event) => {
515
+ // A click on one r/w/x cell: focus its class row, then toggle the
516
+ // bit. The payload is `argIndex:itemIndex:rowIndex` (see
517
+ // `core/panel#panelRowList`).
518
+ const [argIndex, itemIndex, rowIndex] = `${event.data ?? ""}`.split(":").map(Number)
519
+ if (![argIndex, itemIndex, rowIndex].every(Number.isInteger)) return
520
+ data.dispatch("panel.focusArg")
521
+ data.dispatch("panel.setRow", { index: rowIndex })
522
+ data.dispatch("panel.togglePerm", { argIndex, itemIndex })
523
+ },
524
+ "panel:close": (_self, data) => {
525
+ // The dialog's upper-right close shorthand: back out of the picker
526
+ // sub-mode first, otherwise close the panel.
527
+ if (!unwrap(data.panelOpen)) return
528
+ data.dispatch(unwrap(data.pickerOpen) ? "picker.cancel" : "panel.cancel")
529
+ },
302
530
  "crumbs:go": (_self, data, event) => {
303
531
  // The "…" truncation marker carries no path.
304
532
  if (!event.data?.path) return
305
533
  data.dispatch("pane.setPath", event.data)
306
534
  },
307
- "list:select": (_self, data, event) => data.dispatch("list.cursor", event.data),
535
+ "list:select": (_self, data, event) => {
536
+ const paneId = event.data?.paneId
537
+ const index = event.data?.index
538
+ const now = Date.now()
539
+ // A click selects (focus + cursor); a second click on the same row
540
+ // within the window opens it like Enter. `list.cursor` activates the
541
+ // pane synchronously, so `pane.enter` then acts on the clicked pane.
542
+ const isDouble =
543
+ paneId === lastRowClick.paneId &&
544
+ index === lastRowClick.index &&
545
+ now - lastRowClick.at <= DOUBLE_CLICK_MS
546
+ lastRowClick = { paneId, index, at: isDouble ? 0 : now }
547
+ data.dispatch("list.cursor", event.data)
548
+ if (isDouble) data.dispatch("pane.enter", { paneId })
549
+ },
308
550
  "pane:activate": (_self, data, event) => data.dispatch("pane.activate", { paneId: event.data }),
551
+ // Clicking the identity bar's view-mode word flips explorer/commander.
552
+ "view:toggleMode": (_self, data) => data.dispatch("view.toggleMode"),
309
553
  "bookmark:select": (_self, data, event) => {
310
- // Clicking a bookmark row selects and jumps to it.
554
+ // Clicking a bookmark (or the add) row selects and activates it.
311
555
  if (!unwrap(data.bookmarkOpen)) return
312
556
  const items = unwrap(data.bookmarkItems) ?? []
313
- const index = items.findIndex((row) => row.name === event.data)
557
+ const index = items.findIndex((row) => row.key === event.data)
314
558
  if (index < 0) return
315
559
  data.dispatch("bookmark.setIndex", { index })
316
560
  data.dispatch("bookmark.select")
317
561
  },
318
562
  "bookmark:confirm": (_self, data) => {
319
563
  if (!unwrap(data.bookmarkOpen)) return
320
- if (unwrap(data.bookmarkMode) === "name") data.dispatch("bookmark.save")
321
- else data.dispatch("bookmark.select")
322
- },
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")
564
+ data.dispatch("bookmark.select")
338
565
  },
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")
566
+ "overlay:select": (_self, data, event) => {
567
+ // Clicking an overlay row moves the cursor to it and activates it.
568
+ if (!unwrap(data.overlay)) return
569
+ const rows = unwrap(data.overlayItems) ?? []
570
+ const index = rows.findIndex((row) => row.key === event.data)
571
+ if (index < 0) return
572
+ data.dispatch("overlay.setIndex", { index })
573
+ data.dispatch("overlay.activate")
349
574
  },
350
575
  }
351
576
  }
352
577
 
353
- export { createBehaviors, createEvents, createInit }
578
+ export { createBehaviors, createEvents, createInit, DOUBLE_CLICK_MS }
354
579
 
355
580
  // EOF