@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
@@ -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
@@ -36,7 +36,7 @@ function isCell(value) {
36
36
  }
37
37
 
38
38
  // Function: isSelected
39
- // Whether a palette/bookmark row is the current selection. The plain
39
+ // Whether a bookmark row is the current selection. The plain
40
40
  // `selectedRow` flag (snapshotted by the row builder) wins over the shared
41
41
  // `selected` cell, so a move only rewrites the rows that actually changed.
42
42
  function isSelected(data) {
@@ -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,
@@ -119,21 +148,6 @@ function fileRows(FileRow) {
119
148
  }
120
149
  }
121
150
 
122
- // Function: paletteRows
123
- // `{ item: command, index, selectedRow }` bags for `PaletteRow` rows.
124
- // `selectedRow` is the plain per-row selection flag so a palette move rewrites
125
- // only the changed rows.
126
- function paletteRows(PaletteRow) {
127
- return (self, data) => {
128
- const selected = isCell(data.selected) ? unwrap(data.selected) : data.selected
129
- return PaletteRow.map(
130
- data.items ?? [],
131
- (item, index) => ({ item, index, selectedRow: index === selected }),
132
- (command) => command.id,
133
- )
134
- }
135
- }
136
-
137
151
  // Function: commandItemRows
138
152
  // `{ item: command, index }` bags for `CommandItem` rows.
139
153
  function commandItemRows(CommandItem) {
@@ -146,67 +160,181 @@ function commandItemRows(CommandItem) {
146
160
  }
147
161
 
148
162
  // Function: panelOpRows
149
- // `{ item: op, index, row, cursor, focus }` bags for the command panel's
150
- // operation column (`CommandArgRow`). `row` is the normalized row shape the
151
- // component reads — `name` is the operation title and `summary` its argument
152
- // list (precomputed by `session.panelItems`, the signature with the leading
153
- // name stripped); `risk` tints destructive/caution operations; `cursor` is the
154
- // column's selected index.
163
+ // `{ item, index, row, cursor, focus }` bags for the command panel's command
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.
155
167
  function panelOpRows(CommandArgRow) {
156
168
  return (self, data) =>
157
169
  CommandArgRow.map(
158
170
  data.items ?? [],
159
171
  (item, index) => ({
160
172
  item,
161
- index,
173
+ index: item.type === "op" ? item.entryIndex ?? index : -1,
162
174
  row: {
163
- type: "op",
164
- key: item.id,
165
- name: item.title,
166
- summary: item.summary ?? "",
175
+ type: item.type,
176
+ key: item.key,
177
+ name: item.name,
178
+ group: item.group,
167
179
  risk: item.risk ?? null,
168
180
  },
169
181
  cursor: data.opCursor,
170
182
  focus: data.focus,
171
183
  }),
172
- (item) => item.id,
184
+ (item) => item.key,
185
+ )
186
+ }
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,
173
214
  )
215
+ }
174
216
  }
175
217
 
176
218
  // Function: panelArgRows
177
- // `{ item: row, index, row, cursor, focus }` bags for the command panel's
178
- // argument column (`CommandArgRow`). The rows are already flattened by
179
- // `core/panel.panelRowList`; `cursor` is the column's selected index.
219
+ // `{ item, index, row, cursor, focus, value }` bags for the command
220
+ // panel's scrolling argument pane (`CommandArgRow`). The rows are the flat
221
+ // `core/panel.panelRowList`; this wraps each selection's item run in a top and
222
+ // bottom `frame` row so the list reads as one bordered box. `index` is the
223
+ // row's flat position (the frame rows take none), so the pane cursor still
224
+ // addresses the same rows.
180
225
  function panelArgRows(CommandArgRow) {
181
- return (self, data) =>
182
- CommandArgRow.map(
183
- data.rows ?? [],
226
+ return (self, data) => {
227
+ const rows = (unwrap(data.rows) ?? []).filter((row) => row.type !== "run")
228
+ const visual = []
229
+ for (let i = 0; i < rows.length; i++) {
230
+ const row = rows[i]
231
+ if (row.type !== "item") {
232
+ visual.push(row)
233
+ continue
234
+ }
235
+ const first = i === 0 || rows[i - 1].type !== "item"
236
+ const last = i === rows.length - 1 || rows[i + 1].type !== "item"
237
+ if (first) visual.push({ ...row, type: "frame", variant: "top", key: `frame:top:${row.argIndex}` })
238
+ visual.push(row)
239
+ if (last) visual.push({ ...row, type: "frame", variant: "bottom", key: `frame:bottom:${row.argIndex}` })
240
+ }
241
+ return CommandArgRow.map(
242
+ visual,
184
243
  (item, index) => ({
185
244
  item,
186
- index,
245
+ index: item.type === "frame" ? -1 : item.rowIndex ?? index,
187
246
  row: item,
188
247
  cursor: data.argCursor,
189
248
  focus: data.focus,
249
+ permBit: data.permBit,
250
+ value: data.value,
190
251
  }),
191
- (row) => row.key,
252
+ (item) => item.key,
192
253
  )
254
+ }
255
+ }
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
+ }
193
290
  }
194
291
 
195
292
  // Function: bookmarkRows
196
- // `{ item: { name, path }, index, selectedRow }` bags for `BookmarkRow` rows.
197
- // `selectedRow` is the plain per-row selection flag so a picker move rewrites
198
- // 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.
199
297
  function bookmarkRows(BookmarkRow) {
200
298
  return (self, data) => {
201
299
  const selected = isCell(data.selected) ? unwrap(data.selected) : data.selected
202
300
  return BookmarkRow.map(
203
301
  data.items ?? [],
204
302
  (item, index) => ({ item, index, selectedRow: index === selected }),
205
- (bookmark) => bookmark.name,
303
+ (bookmark) => bookmark.key ?? bookmark.name,
206
304
  )
207
305
  }
208
306
  }
209
307
 
210
- export { bookmarkRows, commandItemRows, crumbRows, fileRows, fileWindow, isSelected, paletteRows, panelArgRows, panelOpRows }
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,
320
+ )
321
+ }
322
+ }
323
+
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
+ }
211
339
 
212
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
  }