@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
@@ -0,0 +1,59 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-18
5
+
6
+ // Module: tcmd/core/concurrency
7
+ // Bounded async helpers. Both filesystem adapters list a directory by statting
8
+ // every entry; doing that one await at a time starves the host's threadpool, so
9
+ // each issues a bounded window of requests concurrently instead. `mapLimit`
10
+ // bounds one pass; `createLimiter` bounds a recursive walk, where the fan-out
11
+ // at each level would otherwise multiply. Pure, so the browser bundle can share
12
+ // them with the Node adapter.
13
+
14
+ // Function: mapLimit
15
+ // `map` over `items` with at most `limit` mappers in flight, preserving order.
16
+ async function mapLimit(items, limit, mapper) {
17
+ const result = new Array(items.length)
18
+ let next = 0
19
+ const worker = async () => {
20
+ while (next < items.length) {
21
+ const index = next++
22
+ result[index] = await mapper(items[index], index)
23
+ }
24
+ }
25
+ const workers = []
26
+ for (let i = 0; i < Math.min(limit, items.length); i++) workers.push(worker())
27
+ await Promise.all(workers)
28
+ return result
29
+ }
30
+
31
+ // Function: createLimiter
32
+ // Wraps calls so at most `limit` run concurrently, queueing the rest. Unlike
33
+ // `mapLimit`, every call shares one global slot, so a recursive walk can bound
34
+ // its total in-flight work rather than multiplying the limit per level.
35
+ function createLimiter(limit) {
36
+ let active = 0
37
+ const queue = []
38
+ const pump = () => {
39
+ if (active >= limit || queue.length === 0) return
40
+ active += 1
41
+ const { fn, resolve, reject } = queue.shift()
42
+ Promise.resolve()
43
+ .then(fn)
44
+ .then(resolve, reject)
45
+ .finally(() => {
46
+ active -= 1
47
+ pump()
48
+ })
49
+ }
50
+ return (fn) =>
51
+ new Promise((resolve, reject) => {
52
+ queue.push({ fn, resolve, reject })
53
+ pump()
54
+ })
55
+ }
56
+
57
+ export { createLimiter, mapLimit }
58
+
59
+ // EOF
@@ -0,0 +1,117 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-20
5
+
6
+ // Module: tcmd/core/decompress
7
+ // Head decompression for compressed previews. `core/session` reads a bounded
8
+ // run of the file's raw bytes through the filesystem adapter and hands it here;
9
+ // the result is the leading decompressed text (never more than the requested
10
+ // budget), which the preview treats exactly like a plain text head.
11
+ //
12
+ // Gzip uses the platform's `DecompressionStream` (Bun and every browser ship
13
+ // it). Bzip2 has no such primitive, so the vendored decoder does the work; its
14
+ // output is a binary string that is decoded as UTF-8 here.
15
+
16
+ import { bzip2 } from "../vendor/bzip2.js"
17
+ import { PREVIEW_BYTES } from "./preview.js"
18
+
19
+ // Constant: BZIP2_BLOCK_BYTES
20
+ // A bzip2 stream decodes one block at a time and cannot emit anything until
21
+ // the whole block is in hand. Block size 9 holds at most 900 kB, so reading
22
+ // this much compressed data always covers the first block (plus its header).
23
+ const BZIP2_BLOCK_BYTES = 1024 * 1024
24
+
25
+ // Function: compressedBytes
26
+ // How many raw bytes the adapter should read for a compression format: gzip
27
+ // can be streamed and a preview-sized run is plenty; bzip2 needs its first
28
+ // whole block.
29
+ function compressedBytes(compression) {
30
+ return compression === "bzip2" ? BZIP2_BLOCK_BYTES : PREVIEW_BYTES
31
+ }
32
+
33
+ // Function: decompressHead
34
+ // Leading `maxBytes` of decompressed text for `compression` ("gzip" or
35
+ // "bzip2"). Throws on a corrupt or unsupported stream; callers fall back to a
36
+ // metadata card.
37
+ async function decompressHead(bytes, compression, maxBytes = PREVIEW_BYTES) {
38
+ if (compression === "gzip") return gunzipHead(bytes, maxBytes)
39
+ if (compression === "bzip2") return bunzip2Head(bytes, maxBytes)
40
+ throw new Error(`unsupported compression: ${compression}`)
41
+ }
42
+
43
+ // Function: gunzipHead
44
+ // Streams gzip members and stops at `maxBytes` of output. A truncated head is
45
+ // fine: the reader is cancelled once the budget is spent or the input ends.
46
+ async function gunzipHead(bytes, maxBytes) {
47
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"))
48
+ const reader = stream.getReader()
49
+ const chunks = []
50
+ let length = 0
51
+ try {
52
+ while (length < maxBytes) {
53
+ const { value, done } = await reader.read()
54
+ if (done) break
55
+ if (!value?.length) continue
56
+ chunks.push(value)
57
+ length += value.length
58
+ }
59
+ } finally {
60
+ await reader.cancel().catch(() => {})
61
+ }
62
+ return decodeBytes(chunks, maxBytes)
63
+ }
64
+
65
+ // Function: bunzip2Head
66
+ // Decodes blocks until `maxBytes` of output or the stream terminator. The
67
+ // decoder yields one byte per char code, so the collected string is converted
68
+ // back to bytes before UTF-8 decoding.
69
+ function bunzip2Head(bytes, maxBytes) {
70
+ let chunks
71
+ try {
72
+ const bits = bzip2.array(bytes)
73
+ const size = bzip2.header(bits)
74
+ chunks = []
75
+ let length = 0
76
+ while (length < maxBytes) {
77
+ const chunk = bzip2.decompress(bits, size, maxBytes - length)
78
+ if (chunk === -1) break
79
+ chunks.push(chunk)
80
+ length += chunk.length
81
+ }
82
+ } catch (error) {
83
+ // The vendored decoder throws bare strings.
84
+ throw error instanceof Error ? error : new Error(`${error}`)
85
+ }
86
+ return decodeBytes(chunks.map(byteString), maxBytes)
87
+ }
88
+
89
+ // Function: byteString
90
+ // The vendored decoder's char codes are bytes, not text.
91
+ function byteString(text) {
92
+ const bytes = new Uint8Array(text.length)
93
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff
94
+ return bytes
95
+ }
96
+
97
+ // Function: decodeBytes
98
+ // Concatenates the chunks (bounded to `maxBytes`) and decodes them as UTF-8.
99
+ function decodeBytes(chunks, maxBytes) {
100
+ const total = Math.min(
101
+ chunks.reduce((size, chunk) => size + chunk.length, 0),
102
+ maxBytes,
103
+ )
104
+ const bytes = new Uint8Array(total)
105
+ let offset = 0
106
+ for (const chunk of chunks) {
107
+ if (offset >= total) break
108
+ const take = Math.min(chunk.length, total - offset)
109
+ bytes.set(chunk.subarray(0, take), offset)
110
+ offset += take
111
+ }
112
+ return new TextDecoder().decode(bytes)
113
+ }
114
+
115
+ export { compressedBytes, decompressHead }
116
+
117
+ // EOF
@@ -20,17 +20,23 @@ import { viewEntries } from "./entries.js"
20
20
  import { createHistory } from "./history.js"
21
21
  import { clampCursor, currentEntry, indexOfPath, toggleMark } from "./selection.js"
22
22
  import { SEP, parent as parentPath, split, prefix } from "./path.js"
23
+ import { createPathWatcher } from "./watch.js"
23
24
 
24
25
  // Function: createDirectory
25
26
  // Creates the directory state for one pane, bound to a filesystem adapter.
26
27
  //
28
+ // Options:
29
+ // - `watch`: true (default) auto-refreshes when the displayed directory
30
+ // changes; false disables it; an object forwards `{ delay, interval }` to
31
+ // `core/watch` (tests use a short delay).
32
+ //
27
33
  // Example:
28
34
  // ```javascript
29
35
  // const dir = createDirectory({ fs: createNodeFs(), path: "/home" })
30
36
  // await dir.refresh()
31
37
  // dir.entries.value // sorted, filtered FileEntry[]
32
38
  // ```
33
- function createDirectory({ fs, path: initialPath, context: initialContext }) {
39
+ function createDirectory({ fs, path: initialPath, context: initialContext, watch = true }) {
34
40
  const dir = { fs }
35
41
  dir.path = cell(initialPath)
36
42
  dir.rawEntries = cell([])
@@ -70,6 +76,40 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
70
76
  // was last selected there.
71
77
  const cursorMemory = new Map()
72
78
  let readToken = 0
79
+ // Auto-refresh: one watcher on the displayed path. It is re-armed after a
80
+ // move commits and released on dispose. A change notification during a
81
+ // *move* is ignored — the read in flight is already producing the next
82
+ // listing, and refreshing the path being left would race its token. During
83
+ // a plain refresh a notification may supersede it: the newer read carries
84
+ // the newest change, so no event is ever lost to a slow listing.
85
+ const watchOptions = watch === false ? null : watch === true || watch == null ? {} : watch
86
+ // Whether this directory currently watches its path. The session pauses
87
+ // columns the view does not show, so a hidden pane does not keep polling
88
+ // the filesystem (or hold a desktop RPC watcher) for nothing.
89
+ dir.autoRefresh = cell(Boolean(watchOptions))
90
+ let watchedPath = null
91
+ let stopWatcher = null
92
+ let moving = false
93
+ const stopWatching = () => {
94
+ stopWatcher?.()
95
+ stopWatcher = null
96
+ watchedPath = null
97
+ }
98
+ const watchCurrentPath = () => {
99
+ if (!watchOptions || !dir.autoRefresh.value || watchedPath === dir.path.value) return
100
+ stopWatching()
101
+ const path = dir.path.value
102
+ watchedPath = path
103
+ stopWatcher = createPathWatcher(
104
+ fs,
105
+ path,
106
+ () => {
107
+ if (moving || watchedPath !== dir.path.value) return
108
+ void dir.refresh()
109
+ },
110
+ watchOptions,
111
+ )
112
+ }
73
113
 
74
114
  // Function: setContext
75
115
  // Replaces the owner context captured into history entries.
@@ -95,6 +135,12 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
95
135
  { move = false, record = false, cursorPath = null, restore = null } = {},
96
136
  ) => {
97
137
  const token = ++readToken
138
+ const isMove = move || Boolean(restore)
139
+ // A refresh keeps the same entry selected when entries are inserted or
140
+ // removed above it; the raw cursor index alone would drift, which
141
+ // matters once refreshes happen behind the user's back.
142
+ const keepPath = !isMove ? (dir.currentEntry()?.path ?? null) : null
143
+ if (isMove) moving = true
98
144
  batch(() => {
99
145
  dir.loading.set(true)
100
146
  dir.error.set(null)
@@ -102,7 +148,6 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
102
148
  try {
103
149
  const raw = await fs.readDir(path)
104
150
  if (token !== readToken) return
105
- const isMove = move || Boolean(restore)
106
151
  const filter = restore ? restore.filter : isMove ? "" : dir.filter.value
107
152
  const entries = viewEntries(raw, filter, dir.sort.value)
108
153
  let cursor
@@ -112,8 +157,9 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
112
157
  const remembered = isMove && path !== dir.path.value
113
158
  ? (cursorMemory.get(path) ?? 0)
114
159
  : dir.cursor.value
160
+ const kept = keepPath ? indexOfPath(entries, keepPath) : -1
115
161
  const selected = cursorPath ? indexOfPath(entries, cursorPath) : -1
116
- cursor = clampCursor(selected >= 0 ? selected : remembered, entries.length)
162
+ cursor = clampCursor(kept >= 0 ? kept : selected >= 0 ? selected : remembered, entries.length)
117
163
  }
118
164
  // A move that rewrites the filter sets the cursor it computed; no
119
165
  // watcher intervenes (the filter cell is written directly, not
@@ -139,6 +185,7 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
139
185
  dir.cursor.set(cursor)
140
186
  dir.loading.set(false)
141
187
  })
188
+ watchCurrentPath()
142
189
  if (record) {
143
190
  const entry = { path, filter: "", cursor, marks: new Set(), context: contextGet() }
144
191
  // Re-navigating to the current path updates the entry instead of
@@ -151,7 +198,10 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
151
198
  } catch (error) {
152
199
  if (token === readToken) dir.error.set(`${error?.message ?? error}`)
153
200
  } finally {
154
- if (token === readToken) dir.loading.set(false)
201
+ if (token === readToken) {
202
+ moving = false
203
+ dir.loading.set(false)
204
+ }
155
205
  }
156
206
  }
157
207
 
@@ -159,6 +209,26 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
159
209
  // Re-reads the displayed directory, preserving filter, marks and cursor.
160
210
  dir.refresh = () => read(dir.path.value)
161
211
 
212
+ // Function: setAutoRefresh
213
+ // Pauses or resumes the path watcher. Resuming re-reads the listing: it
214
+ // may have changed while the column was hidden and unwatched. A no-op for
215
+ // a directory created with `watch: false`, and while a move is in flight
216
+ // (that read already produces the next listing and re-arms the watcher).
217
+ dir.setAutoRefresh = (enabled) => {
218
+ if (!watchOptions) return
219
+ const next = enabled !== false
220
+ if (dir.autoRefresh.value === next) return
221
+ dir.autoRefresh.set(next)
222
+ if (!next) {
223
+ stopWatching()
224
+ return
225
+ }
226
+ if (!moving) {
227
+ watchCurrentPath()
228
+ void dir.refresh()
229
+ }
230
+ }
231
+
162
232
  // Function: navigate
163
233
  // Moves the directory to `path` (resetting filter and marks, restoring the
164
234
  // cursor remembered for `path`), records the step in the history and reads
@@ -233,11 +303,17 @@ function createDirectory({ fs, path: initialPath, context: initialContext }) {
233
303
  })
234
304
 
235
305
  // Function: dispose
236
- // Cancels any in-flight read so a late listing can no longer publish.
306
+ // Cancels any in-flight read so a late listing can no longer publish, and
307
+ // releases the path watcher.
237
308
  dir.dispose = () => {
238
309
  readToken += 1
310
+ stopWatching()
239
311
  }
240
312
 
313
+ // Start watching the opening directory; a later move re-arms the watcher
314
+ // on its target (see `read`).
315
+ watchCurrentPath()
316
+
241
317
  return dir
242
318
  }
243
319
 
@@ -0,0 +1,36 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-31
5
+
6
+ // Module: tcmd/core/format
7
+ // Domain-level formatting of byte sizes and filesystem capacity. These live in
8
+ // core, not components, because operations report sizes too (`stat`, `du`,
9
+ // `df`); the component formatters re-export them so every renderer and every
10
+ // operation shares one wording.
11
+
12
+ // Function: formatSize
13
+ // Human-readable byte size, e.g. "512B", "2.0K", "3.8M".
14
+ function formatSize(bytes) {
15
+ const value = Number(bytes ?? 0)
16
+ if (value < 1024) return `${value}B`
17
+ const units = ["K", "M", "G", "T"]
18
+ let size = value / 1024
19
+ let unit = 0
20
+ while (size >= 1024 && unit < units.length - 1) {
21
+ size /= 1024
22
+ unit++
23
+ }
24
+ return `${size >= 100 ? Math.round(size) : size.toFixed(1)}${units[unit]}`
25
+ }
26
+
27
+ // Function: formatDisk
28
+ // Compact free/total disk line, e.g. "12G free of 256G".
29
+ function formatDisk(disk) {
30
+ if (!disk) return ""
31
+ return `${formatSize(disk.free)} free of ${formatSize(disk.total)}`
32
+ }
33
+
34
+ export { formatDisk, formatSize }
35
+
36
+ // EOF