@ssebastien/tcmd 0.1.4 → 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 (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. package/tinyjs.json +16 -0
@@ -179,6 +179,7 @@ function extensionOf(name) {
179
179
  // file icon.
180
180
  function iconFor(entry) {
181
181
  if (!entry) return ""
182
+ if (entry.add) return entry.isDir ? DIR_ICON : FILE_ICON
182
183
  if (entry.isSymlink) return SYMLINK_ICON
183
184
  if (entry.isDevice) return DEVICE_ICON
184
185
  if (entry.isDir) return DIR_ICON
@@ -187,6 +188,141 @@ function iconFor(entry) {
187
188
  return NAME_ICONS[plain] ?? EXTENSION_ICONS[extensionOf(name)] ?? FILE_ICON
188
189
  }
189
190
 
191
+ // Function: lucideIconFor
192
+ // Lucide's outline icons are used by the web renderers, where SVG works without
193
+ // a patched terminal font. The TUI keeps the detailed Nerd Font mapping above;
194
+ // the web uses a smaller set of semantic file-kind icons.
195
+ const LUCIDE_NAME_ICONS = {
196
+ cmakelists: "file-code",
197
+ dockerfile: "file-code",
198
+ env: "settings",
199
+ gitattributes: "settings",
200
+ gitconfig: "settings",
201
+ gitignore: "settings",
202
+ gitmodules: "settings",
203
+ gnumakefile: "file-code",
204
+ license: "file-text",
205
+ licence: "file-text",
206
+ makefile: "file-code",
207
+ npmrc: "settings",
208
+ readme: "file-text",
209
+ }
210
+ const LUCIDE_EXTENSION_ICONS = {
211
+ "7z": "file-archive",
212
+ aac: "audio-lines",
213
+ avi: "video",
214
+ bmp: "file-image",
215
+ bz2: "file-archive",
216
+ c: "file-code",
217
+ cc: "file-code",
218
+ cjs: "file-code",
219
+ clj: "file-code",
220
+ cmake: "file-code",
221
+ csv: "file-text",
222
+ cfg: "settings",
223
+ conf: "settings",
224
+ cpp: "file-code",
225
+ cs: "file-code",
226
+ css: "file-code",
227
+ d: "file-code",
228
+ db: "database",
229
+ diff: "file-code",
230
+ ex: "file-code",
231
+ exs: "file-code",
232
+ env: "settings",
233
+ flac: "audio-lines",
234
+ gif: "file-image",
235
+ go: "file-code",
236
+ gql: "file-code",
237
+ graphql: "file-code",
238
+ gz: "file-archive",
239
+ h: "file-code",
240
+ hbs: "file-code",
241
+ hs: "file-code",
242
+ hpp: "file-code",
243
+ htm: "file-code",
244
+ html: "file-code",
245
+ ini: "settings",
246
+ java: "file-code",
247
+ jar: "file-archive",
248
+ jpeg: "file-image",
249
+ jpg: "file-image",
250
+ js: "file-code",
251
+ jsx: "file-code",
252
+ json: "file-code",
253
+ kt: "file-code",
254
+ kts: "file-code",
255
+ less: "file-code",
256
+ lock: "settings",
257
+ log: "file-text",
258
+ lua: "file-code",
259
+ m4a: "audio-lines",
260
+ md: "file-text",
261
+ markdown: "file-text",
262
+ mk: "file-code",
263
+ mkv: "video",
264
+ mov: "video",
265
+ mp3: "audio-lines",
266
+ mp4: "video",
267
+ mjs: "file-code",
268
+ nim: "file-code",
269
+ ogg: "audio-lines",
270
+ pdf: "file-text",
271
+ perl: "file-code",
272
+ patch: "file-code",
273
+ php: "file-code",
274
+ pl: "file-code",
275
+ png: "file-image",
276
+ ps1: "file-code",
277
+ py: "file-code",
278
+ r: "file-code",
279
+ rb: "file-code",
280
+ rar: "file-archive",
281
+ rs: "file-code",
282
+ sass: "file-code",
283
+ scala: "file-code",
284
+ scss: "file-code",
285
+ sh: "file-code",
286
+ sql: "file-code",
287
+ sqlite: "database",
288
+ sqlite3: "database",
289
+ svg: "file-image",
290
+ swift: "file-code",
291
+ tar: "file-archive",
292
+ text: "file-text",
293
+ toml: "settings",
294
+ ts: "file-code",
295
+ tsv: "file-text",
296
+ txt: "file-text",
297
+ tsx: "file-code",
298
+ vim: "file-code",
299
+ vue: "file-code",
300
+ wav: "audio-lines",
301
+ webm: "video",
302
+ webp: "file-image",
303
+ xml: "file-code",
304
+ xz: "file-archive",
305
+ yaml: "file-code",
306
+ yml: "file-code",
307
+ zip: "file-archive",
308
+ zsh: "file-code",
309
+ }
310
+
311
+ function lucideIconFor(entry) {
312
+ if (!entry) return ""
313
+ if (entry.add) return "folder-plus"
314
+ if (entry.isSymlink) return "link-2"
315
+ if (entry.isDevice) return "cpu"
316
+ if (entry.isDir) return "folder"
317
+
318
+ const name = `${entry.name ?? ""}`
319
+ const basename = name.slice(name.lastIndexOf("/") + 1)
320
+ const extension = extensionOf(basename)
321
+ const stem = extension ? basename.slice(0, -(extension.length + 1)) : basename
322
+ const plain = (basename.startsWith(".") ? basename.slice(1) : basename).toLowerCase()
323
+ return LUCIDE_NAME_ICONS[plain] ?? LUCIDE_NAME_ICONS[stem.toLowerCase()] ?? LUCIDE_EXTENSION_ICONS[extension] ?? "file"
324
+ }
325
+
190
326
  // Function: iconsEnabled
191
327
  // Whether rows show icons: on by default, `TCMD_ICONS=0|false|off` opts out
192
328
  // for terminals without a Nerd Font.
@@ -213,6 +349,7 @@ export {
213
349
  extensionOf,
214
350
  iconFor,
215
351
  iconsEnabled,
352
+ lucideIconFor,
216
353
  sourceMarker,
217
354
  }
218
355
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  // Binding
13
13
  export { bind } from "./bind.js"
14
- export { commandBarMarks, commandBarMarksItem, commandBarRowCount, commandBarStatus } from "./CommandBar.js"
14
+ export { commandBarFit, commandBarMarks, commandBarMarksItem, commandBarStatus } from "./CommandBar.js"
15
15
 
16
16
  // Behaviors
17
17
  export { createBehaviors as appBehaviors, createEvents as appEvents, createInit as appInit } from "./App.js"
@@ -26,15 +26,20 @@ export { createBehaviors as confirmBehaviors } from "./Confirm.js"
26
26
  export { createBehaviors as crumbBehaviors } from "./Crumb.js"
27
27
  export { createBehaviors as entryDetailBehaviors } from "./EntryDetail.js"
28
28
  export { createBehaviors as fileListBehaviors } from "./FileList.js"
29
+ export { createBehaviors as filePickerBehaviors } from "./FilePicker.js"
30
+ export { createBehaviors as filePickerRowBehaviors } from "./FilePickerRow.js"
29
31
  export { createBehaviors as filePreviewBehaviors } from "./FilePreview.js"
30
32
  export { createBehaviors as fileRowBehaviors } from "./FileRow.js"
31
33
  export { createBehaviors as filterBarBehaviors } from "./FilterBar.js"
32
34
  export { createBehaviors as helpBehaviors } from "./Help.js"
35
+ export { createRowBehaviors as helpRowBehaviors } from "./Help.js"
36
+ export { createBehaviors as overlayListBehaviors } from "./OverlayList.js"
37
+ export { createBehaviors as overlayRowBehaviors } from "./OverlayRow.js"
33
38
  export { createBehaviors as summaryBehaviors } from "./Summary.js"
34
39
 
35
40
  // Helpers
36
41
  export { formatCounts, formatDisk, formatMode, formatSize, formatTime } from "./format.js"
37
- export { iconFor, iconsEnabled, sourceMarker } from "./icons.js"
38
- export { bookmarkRows, commandItemRows, crumbRows, fileRows, fileWindow, panelArgRows, panelOpRows } from "./rows.js"
42
+ export { iconFor, iconsEnabled, lucideIconFor, sourceMarker } from "./icons.js"
43
+ export { bookmarkRows, commandItemRows, crumbRows, filePickerRows, fileRows, fileWindow, helpRows, listingWindow, overlayRows, panelArgRows, panelOpRows, panelRunRows } from "./rows.js"
39
44
 
40
45
  // EOF
@@ -8,8 +8,7 @@
8
8
  // `(self, data) → Component.map(…)`: it turns a parent's data bag into the
9
9
  // `{ item, index, … }` bags its row components receive, so the bag shape is
10
10
  // defined once and both renderers stay in sync. `self` is the parent list
11
- // instance — only `fileRows` uses it, to fall back to the measured scrollbox
12
- // height when no viewport is threaded down.
11
+ // instance, kept in the signature for the shared `(self, data)` contract.
13
12
  //
14
13
  // Row state (cursor, marks, selection) is snapshotted into plain per-row
15
14
  // booleans (`currentRow`, `markedRow`, `selectedRow`) instead of broadcasting
@@ -27,6 +26,7 @@
27
26
  // (`CommandArgRow.wantsFocus`), which the builders do not know.
28
27
 
29
28
  import { unwrap } from "select/state/cells"
29
+ import { helpBlocks } from "./Help.js"
30
30
 
31
31
  // Function: isCell
32
32
  // True when `value` is a reactive cell, using the same `isReactive` marker the
@@ -49,6 +49,15 @@ function isSelected(data) {
49
49
  // list, so a scroll step or a cursor nudge does not first expose a blank row.
50
50
  const ROW_WINDOW_MARGIN = 8
51
51
 
52
+ // Constant: UNWINDOWED_ROWS
53
+ // How many rows a list without a threaded viewport (the inspector's directory
54
+ // column, the web) mounts before it windows around the cursor. Such a list
55
+ // renders its *whole* listing so its scrollbox can scroll all of it, but the
56
+ // terminal renderer cannot create unbounded rows — a many-thousand-row
57
+ // directory fails to render — so the mount is bounded here. The bound sits
58
+ // well under that limit and leaves normal directories fully mounted.
59
+ const UNWINDOWED_ROWS = 500
60
+
52
61
  // Function: fileWindow
53
62
  // The windowed slice of a file listing: only `[start, start+len)` rows are
54
63
  // mounted around the cursor, so a large directory does not lay out thousands of
@@ -71,6 +80,26 @@ function fileWindow(data, viewport) {
71
80
  return { start, len, local: cursor - start, total }
72
81
  }
73
82
 
83
+ // Function: listingWindow
84
+ // The rows a `FileList` mounts, shared by the row builder and the scroll
85
+ // keeper so they always agree on the window. A threaded viewport (the panes)
86
+ // windows around the cursor with a margin. Without one (the inspector's
87
+ // directory column) the whole listing is mounted — so its scrollbox scrolls
88
+ // all of it — up to `UNWINDOWED_ROWS`; beyond that the same cursor-anchored
89
+ // window keeps the mount bounded while the cursor still reaches every row.
90
+ function listingWindow(data, viewport) {
91
+ const vp = Math.floor(viewport ?? 0)
92
+ if (vp > 0) return fileWindow(data, vp)
93
+ const entries = unwrap(data.entries) ?? []
94
+ const total = entries.length
95
+ const cursor = isCell(data.cursor) ? unwrap(data.cursor) : (data.cursor ?? 0)
96
+ if (total <= UNWINDOWED_ROWS) return { start: 0, len: total, local: cursor, total }
97
+ const maxStart = Math.max(0, total - UNWINDOWED_ROWS)
98
+ const start = Math.max(0, Math.min(cursor - Math.floor(UNWINDOWED_ROWS / 2), maxStart))
99
+ const len = Math.min(UNWINDOWED_ROWS, total - start)
100
+ return { start, len, local: cursor - start, total }
101
+ }
102
+
74
103
  // Function: crumbRows
75
104
  // `{ item: segment, index, last, paneId }` bags for `Crumb` rows; `last`
76
105
  // marks the basename, which carries no separator.
@@ -99,11 +128,11 @@ function fileRows(FileRow) {
99
128
  const entries = unwrap(data.entries) ?? []
100
129
  const cursor = isCell(data.cursor) ? unwrap(data.cursor) : data.cursor
101
130
  const marked = isCell(data.marked) ? unwrap(data.marked) : data.marked
102
- // The viewport height is threaded down reactively (from `uiSize`), so the
103
- // window is correct from the first render; fall back to the measured
104
- // scrollbox height on renderers that do not provide it.
105
- const viewport = unwrap(data.viewport) || self?.ref?.list?.height || 0
106
- const { start, len } = fileWindow(data, viewport)
131
+ // The window is shared with `fileListScrollTop` (see `listingWindow`).
132
+ // The panes window around their cursor; the inspector mounts its whole
133
+ // listing so its scrollbox can scroll all of it, bounded for the
134
+ // terminal renderer.
135
+ const { start, len } = listingWindow(data, unwrap(data.viewport) ?? 0)
107
136
  const bag = (item, index) => ({
108
137
  item,
109
138
  index,
@@ -132,41 +161,70 @@ function commandItemRows(CommandItem) {
132
161
 
133
162
  // Function: panelOpRows
134
163
  // `{ item, index, row, cursor, focus }` bags for the command panel's command
135
- // list (`CommandArgRow`). The list is the flat `core/panel.panelEntryRows`: one
136
- // operation per row. `row` is the normalized shape the component reads —
137
- // `name` is the operation name and `summary` its argument list; `risk` tints
138
- // destructive/caution operations.
164
+ // list (`CommandArgRow`). `panelEntryRows` contributes group headings and
165
+ // name-only operation rows. `entryIndex` maps operations to the command cursor;
166
+ // group headings take no cursor. `risk` tints destructive/caution operations.
139
167
  function panelOpRows(CommandArgRow) {
140
168
  return (self, data) =>
141
169
  CommandArgRow.map(
142
170
  data.items ?? [],
143
171
  (item, index) => ({
144
172
  item,
145
- index: item.entryIndex ?? index,
173
+ index: item.type === "op" ? item.entryIndex ?? index : -1,
146
174
  row: {
147
- type: "op",
148
- key: item.id,
175
+ type: item.type,
176
+ key: item.key,
149
177
  name: item.name,
150
- summary: item.summary ?? "",
178
+ group: item.group,
151
179
  risk: item.risk ?? null,
152
180
  },
153
181
  cursor: data.opCursor,
154
182
  focus: data.focus,
155
183
  }),
156
- (item) => item.id,
184
+ (item) => item.key,
157
185
  )
158
186
  }
159
187
 
188
+ // Function: filePickerRows
189
+ // `{ item, index, key, currentRow, markedRow, active }` bags for the file
190
+ // picker's `FilePickerRow`s. The picker rows are already flat (parent row plus
191
+ // entries); the same bounded window as an unwindowed listing keeps a huge
192
+ // directory from mounting every row in the terminal.
193
+ function filePickerRows(FilePickerRow) {
194
+ return (self, data) => {
195
+ const rows = unwrap(data.rows) ?? []
196
+ const cursor = isCell(data.cursor) ? unwrap(data.cursor) : (data.cursor ?? 0)
197
+ const { start, len } = listingWindow({ entries: rows, cursor }, 0)
198
+ const bag = (item, index) => ({
199
+ item,
200
+ index,
201
+ key: item.key ?? item.path,
202
+ currentRow: index === cursor,
203
+ markedRow: false,
204
+ active: true,
205
+ paneId: null,
206
+ })
207
+ if (start === 0 && len === rows.length) {
208
+ return FilePickerRow.map(rows, bag, (item) => item.key ?? item.path)
209
+ }
210
+ return FilePickerRow.map(
211
+ rows.slice(start, start + len),
212
+ (item, index) => bag(item, start + index),
213
+ (item) => item.key ?? item.path,
214
+ )
215
+ }
216
+ }
217
+
160
218
  // Function: panelArgRows
161
- // `{ item, index, row, cursor, focus, value, labelWidth }` bags for the command
162
- // panel's argument pane (`CommandArgRow`). The rows are the flat
219
+ // `{ item, index, row, cursor, focus, value }` bags for the command
220
+ // panel's scrolling argument pane (`CommandArgRow`). The rows are the flat
163
221
  // `core/panel.panelRowList`; this wraps each selection's item run in a top and
164
222
  // bottom `frame` row so the list reads as one bordered box. `index` is the
165
223
  // row's flat position (the frame rows take none), so the pane cursor still
166
224
  // addresses the same rows.
167
225
  function panelArgRows(CommandArgRow) {
168
226
  return (self, data) => {
169
- const rows = unwrap(data.rows) ?? []
227
+ const rows = (unwrap(data.rows) ?? []).filter((row) => row.type !== "run")
170
228
  const visual = []
171
229
  for (let i = 0; i < rows.length; i++) {
172
230
  const row = rows[i]
@@ -188,29 +246,95 @@ function panelArgRows(CommandArgRow) {
188
246
  row: item,
189
247
  cursor: data.argCursor,
190
248
  focus: data.focus,
249
+ permBit: data.permBit,
191
250
  value: data.value,
192
- labelWidth: unwrap(data.labelWidth) ?? 8,
193
251
  }),
194
252
  (item) => item.key,
195
253
  )
196
254
  }
197
255
  }
198
256
 
257
+ // Function: panelRunRows
258
+ // The explicit Run/Retry action is outside the scrolling argument list, while
259
+ // sharing its cursor/focus state and row component.
260
+ function panelRunRows(CommandArgRow) {
261
+ return (self, data) => {
262
+ const rows = (unwrap(data.rows) ?? []).filter((row) => row.type === "run")
263
+ return CommandArgRow.map(
264
+ rows,
265
+ (item, index) => ({
266
+ item,
267
+ index: item.rowIndex ?? index,
268
+ row: item,
269
+ cursor: data.argCursor,
270
+ focus: data.focus,
271
+ value: data.value,
272
+ }),
273
+ (item) => item.key,
274
+ )
275
+ }
276
+ }
277
+
278
+ // Function: helpRows
279
+ // `{ item, index }` bags for the `?` reference's `HelpRow`s. The blocks are
280
+ // precomputed and balanced in `Help.helpBlocks`; `side` picks the column.
281
+ function helpRows(HelpRow) {
282
+ return (self, data, side) => {
283
+ const blocks = helpBlocks()[side] ?? []
284
+ return HelpRow.map(
285
+ blocks,
286
+ (item, index) => ({ item, index }),
287
+ (item) => item.key,
288
+ )
289
+ }
290
+ }
291
+
199
292
  // Function: bookmarkRows
200
- // `{ item: { name, path }, index, selectedRow }` bags for `BookmarkRow` rows.
201
- // `selectedRow` is the plain per-row selection flag so a picker move rewrites
202
- // only the changed rows.
293
+ // `{ item: { key, name, path }, index, selectedRow }` bags for `BookmarkRow`
294
+ // rows. `selectedRow` is the plain per-row selection flag so a picker move
295
+ // rewrites only the changed rows; `key` separates the synthetic add row from
296
+ // stored bookmarks.
203
297
  function bookmarkRows(BookmarkRow) {
204
298
  return (self, data) => {
205
299
  const selected = isCell(data.selected) ? unwrap(data.selected) : data.selected
206
300
  return BookmarkRow.map(
207
301
  data.items ?? [],
208
302
  (item, index) => ({ item, index, selectedRow: index === selected }),
209
- (bookmark) => bookmark.name,
303
+ (bookmark) => bookmark.key ?? bookmark.name,
304
+ )
305
+ }
306
+ }
307
+
308
+ // Function: overlayRows
309
+ // `{ item, index, currentRow }` bags for the shared list overlay's `OverlayRow`s
310
+ // (history / trash). `currentRow` is the plain per-row cursor flag, so a move
311
+ // rewrites only the changed rows.
312
+ function overlayRows(OverlayRow) {
313
+ return (self, data) => {
314
+ const items = unwrap(data.items) ?? []
315
+ const cursor = isCell(data.cursor) ? unwrap(data.cursor) : data.cursor
316
+ return OverlayRow.map(
317
+ items,
318
+ (item, index) => ({ item, index, currentRow: index === cursor }),
319
+ (item) => item.key ?? item.text,
210
320
  )
211
321
  }
212
322
  }
213
323
 
214
- export { bookmarkRows, commandItemRows, crumbRows, fileRows, fileWindow, isSelected, panelArgRows, panelOpRows }
324
+ export {
325
+ bookmarkRows,
326
+ commandItemRows,
327
+ crumbRows,
328
+ filePickerRows,
329
+ fileRows,
330
+ fileWindow,
331
+ helpRows,
332
+ isSelected,
333
+ listingWindow,
334
+ overlayRows,
335
+ panelArgRows,
336
+ panelOpRows,
337
+ panelRunRows,
338
+ }
215
339
 
216
340
  // EOF
@@ -0,0 +1,64 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-20
5
+
6
+ // Module: tcmd/core/base64
7
+ // Standard base64 for adapters that can only carry bytes as JSON text — the
8
+ // web API and the tinyjs bridge. Pure JavaScript: neither `Buffer` nor
9
+ // `btoa`/`atob` is guaranteed in every target (the desktop backend runs under
10
+ // txiki.js), and compressed previews stay small enough for a string.
11
+
12
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
13
+
14
+ // Constant: LOOKUP
15
+ // Byte code -> 6-bit value, -1 for anything outside the alphabet.
16
+ const LOOKUP = (() => {
17
+ const table = new Int16Array(128).fill(-1)
18
+ for (let i = 0; i < ALPHABET.length; i++) table[ALPHABET.charCodeAt(i)] = i
19
+ return table
20
+ })()
21
+
22
+ // Function: encodeBase64
23
+ // Base64 text for a byte array, with `=` padding.
24
+ function encodeBase64(bytes) {
25
+ let text = ""
26
+ for (let i = 0; i < bytes.length; i += 3) {
27
+ const first = bytes[i]
28
+ const second = i + 1 < bytes.length ? bytes[i + 1] : undefined
29
+ const third = i + 2 < bytes.length ? bytes[i + 2] : undefined
30
+ text += ALPHABET[first >> 2]
31
+ text += ALPHABET[((first & 0x03) << 4) | ((second ?? 0) >> 4)]
32
+ text += second === undefined ? "=" : ALPHABET[((second & 0x0f) << 2) | ((third ?? 0) >> 6)]
33
+ text += third === undefined ? "=" : ALPHABET[third & 0x3f]
34
+ }
35
+ return text
36
+ }
37
+
38
+ // Function: decodeBase64
39
+ // Bytes for base64 text; padding, whitespace and foreign characters are
40
+ // ignored, so a truncated or line-wrapped payload still decodes.
41
+ function decodeBase64(text) {
42
+ const source = `${text ?? ""}`
43
+ const bytes = new Uint8Array(Math.floor((source.length * 3) / 4))
44
+ let length = 0
45
+ let buffer = 0
46
+ let bits = 0
47
+ for (let i = 0; i < source.length; i++) {
48
+ const code = source.charCodeAt(i)
49
+ if (code === 61) break // "=" padding: nothing follows it.
50
+ const value = code < LOOKUP.length ? LOOKUP[code] : -1
51
+ if (value < 0) continue
52
+ buffer = (buffer << 6) | value
53
+ bits += 6
54
+ if (bits >= 8) {
55
+ bits -= 8
56
+ bytes[length++] = (buffer >> bits) & 0xff
57
+ }
58
+ }
59
+ return bytes.slice(0, length)
60
+ }
61
+
62
+ export { decodeBase64, encodeBase64 }
63
+
64
+ // EOF
@@ -4,16 +4,18 @@
4
4
  // Created: 2026-10-09
5
5
 
6
6
  // Module: tcmd/core/bookmarks
7
- // Named bookmarks: a `{ name -> { path, isDir } }` map the user records with
8
- // `b` and jumps to with `g`. A bookmark remembers whether its path is a file
9
- // or a directory (or `null` when only a legacy path is known), so a jump can
10
- // open a directory but select-and-preview a file. Bookmarks are pure model
11
- // state (a reactive cell) plus a persistence layer: a `BookmarkStore`
12
- // interface with an in-memory default (tests, web) and a Node/JSON-file
13
- // implementation (the TUI). The session only ever sees the store interface, so
14
- // the browser bundle stays free of Node builtins.
7
+ // Named bookmarks: a `{ name -> { path, isDir } }` map shown by the `b`
8
+ // bookmarks panel, which filters them and records the current path under an
9
+ // automatic name. A bookmark remembers whether its path is a file or a
10
+ // directory (or `null` when only a legacy path is known), so a jump can open a
11
+ // directory but select-and-preview a file. Bookmarks are pure model state (a
12
+ // reactive cell) plus a persistence layer: a `BookmarkStore` interface with an
13
+ // in-memory default (tests, web) and a Node/JSON-file implementation (the TUI).
14
+ // The session only ever sees the store interface, so the browser bundle stays
15
+ // free of Node builtins.
15
16
 
16
17
  import { cell } from "select/state/cells"
18
+ import { basename } from "./path.js"
17
19
 
18
20
  // Function: normalizeName
19
21
  // Trims a bookmark name; empty names are rejected by the callers.
@@ -29,6 +31,27 @@ function isValidName(name) {
29
31
  return text.length > 0 && !/[\s/]/.test(text)
30
32
  }
31
33
 
34
+ // Function: suggestName
35
+ // The automatic name for a path: its basename with whitespace (and the root
36
+ // separator) turned into dashes. Paths without a usable segment fall back to
37
+ // `bookmark`, so the add action always has a valid name.
38
+ function suggestName(path) {
39
+ const text = basename(path).replace(/[\s/]+/g, "-").replace(/^-+|-+$/g, "")
40
+ return isValidName(text) ? text : "bookmark"
41
+ }
42
+
43
+ // Function: uniqueName
44
+ // `base`, suffixed with `-2`, `-3`, … until the map has no entry under it.
45
+ function uniqueName(map, base) {
46
+ let name = base
47
+ let suffix = 2
48
+ while (Object.hasOwn(map ?? {}, name)) {
49
+ name = `${base}-${suffix}`
50
+ suffix += 1
51
+ }
52
+ return name
53
+ }
54
+
32
55
  // Function: normalizeEntry
33
56
  // Coerces a stored value to `{ path, isDir }`: a plain string is a legacy
34
57
  // path with an unknown kind (`isDir: null`), resolved when it is jumped to.
@@ -91,6 +114,18 @@ function createBookmarks({ store = createMemoryBookmarkStore(), entries = {} } =
91
114
  return clean
92
115
  }
93
116
 
117
+ // Function: add
118
+ // Records `path` under an automatic name (`suggestName`, disambiguated on
119
+ // collision) and persists. Returns `{ name, added }`; an already-bookmarked
120
+ // path keeps its existing name and reports `added: false`.
121
+ bookmarks.add = async (path, isDir = null) => {
122
+ const existing = bookmarks.find(path)
123
+ if (existing) return { name: existing.name, added: false }
124
+ const name = uniqueName(bookmarks.entries.value, suggestName(path))
125
+ await bookmarks.record(name, path, isDir)
126
+ return { name, added: true }
127
+ }
128
+
94
129
  // Function: remove
95
130
  // Deletes a bookmark and persists. Returns true when one was removed.
96
131
  bookmarks.remove = async (name) => {
@@ -172,4 +207,6 @@ export {
172
207
  normalizeEntry,
173
208
  normalizeMap,
174
209
  normalizeName,
210
+ suggestName,
211
+ uniqueName,
175
212
  }