@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
@@ -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
@@ -9,39 +9,34 @@
9
9
  // figures, the selection buffer and the navigation history (back/forward).
10
10
  // A pane owns exactly one directory — the
11
11
  // former multi-tab model is gone — and the pane's cells proxy it directly.
12
+ //
13
+ // Reads commit atomically: the displayed directory (path, listing, filter,
14
+ // marks, cursor) is replaced in one reactive batch once its replacement is
15
+ // ready, and only the latest read may publish (see `read`). Listings carry
16
+ // their stat details, so the cursor entry needs no separate enrichment pass.
12
17
 
13
- import { cell, derived, effect } from "select/state/cells"
18
+ import { batch, cell, derived } from "select/state/cells"
14
19
  import { viewEntries } from "./entries.js"
15
- import { clampCursor, currentEntry, toggleMark } from "./selection.js"
20
+ import { createHistory } from "./history.js"
21
+ import { clampCursor, currentEntry, indexOfPath, toggleMark } from "./selection.js"
16
22
  import { SEP, parent as parentPath, split, prefix } from "./path.js"
17
-
18
- // Function: enrichEntry
19
- // Merges `stat` details (ctime, mode, uid, gid) the listing may not carry.
20
- async function enrichEntry(fs, entry) {
21
- if (!entry) return null
22
- const stats = await fs.stat(entry.path).catch(() => null)
23
- if (!stats) return entry
24
- return {
25
- ...entry,
26
- size: entry.isDir ? 0 : (stats.size ?? entry.size),
27
- mtimeMs: stats.mtimeMs ?? entry.mtimeMs,
28
- ctimeMs: stats.ctimeMs ?? entry.ctimeMs,
29
- mode: stats.mode ?? entry.mode,
30
- uid: stats.uid ?? entry.uid,
31
- gid: stats.gid ?? entry.gid,
32
- }
33
- }
23
+ import { createPathWatcher } from "./watch.js"
34
24
 
35
25
  // Function: createDirectory
36
26
  // Creates the directory state for one pane, bound to a filesystem adapter.
37
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
+ //
38
33
  // Example:
39
34
  // ```javascript
40
35
  // const dir = createDirectory({ fs: createNodeFs(), path: "/home" })
41
36
  // await dir.refresh()
42
37
  // dir.entries.value // sorted, filtered FileEntry[]
43
38
  // ```
44
- function createDirectory({ fs, path: initialPath }) {
39
+ function createDirectory({ fs, path: initialPath, context: initialContext, watch = true }) {
45
40
  const dir = { fs }
46
41
  dir.path = cell(initialPath)
47
42
  dir.rawEntries = cell([])
@@ -54,105 +49,231 @@ function createDirectory({ fs, path: initialPath }) {
54
49
  )
55
50
  dir.cursor = cell(0)
56
51
  dir.disk = cell(null)
57
- // Stat details (ctime, mode, uid, gid) for the entry under the cursor,
58
- // fetched lazily: listings only carry size/mtime, so the pane detail line
59
- // re-stats the selected path when the cursor moves (cached by path).
60
- dir.cursorStat = cell(null)
61
- let cursorStatPath = null
62
52
  // Selection buffer for this directory (the pane's marked paths).
63
53
  dir.marked = cell(new Set())
64
- // Navigation history: visited paths with the current one at
65
- // `history.index`, so back/forward walk the stack like a browser.
66
- dir.history = { stack: [initialPath], index: 0 }
54
+ // An opaque per-entry value the owner captures into the history and gets
55
+ // back on restore. The session uses it to restore the focused column; the
56
+ // directory itself never inspects it.
57
+ let context = initialContext
58
+ const contextGet = () => context?.get?.()
59
+ const contextSet = (value) => context?.set?.(value)
60
+ // Function: snapshot
61
+ // The directory's restorable view state: path, filter, cursor, marks and
62
+ // the owner's context.
63
+ const snapshot = () => ({
64
+ path: dir.path.value,
65
+ filter: dir.filter.value,
66
+ cursor: dir.cursor.value,
67
+ marks: new Set(dir.marked.value),
68
+ context: contextGet(),
69
+ })
70
+ // Navigation history: full view snapshots with the current one at
71
+ // `history.index`, so back/forward restore position, selection and filter
72
+ // like a browser.
73
+ dir.history = createHistory(snapshot())
74
+ // Cursor memory: the last cursor position per path, so re-entering a
75
+ // directory (a fresh `open`, not a history step) lands on the entry that
76
+ // was last selected there.
77
+ const cursorMemory = new Map()
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
+ }
67
113
 
68
- // A changing filter re-ranks the listing: snap the cursor back to the top
69
- // match. Disposed with the directory (see `dispose`).
70
- const stopFilterWatch = effect([dir.filter], () => dir.cursor.set(0))
114
+ // Function: setContext
115
+ // Replaces the owner context captured into history entries.
116
+ dir.setContext = (next) => {
117
+ context = next
118
+ }
71
119
 
72
- // Re-stat the cursor entry on movement; cache by path so a re-render that
73
- // does not move the cursor does not hit the filesystem again.
74
- const stopCursorWatch = effect([dir.entries, dir.cursor], () => {
75
- const entry = currentEntry(dir.entries.value, dir.cursor.value)
76
- if (!entry || entry.path === cursorStatPath) return
77
- cursorStatPath = entry.path
78
- dir.cursorStat.set(entry)
79
- void enrichEntry(fs, entry).then((enriched) => {
80
- if (cursorStatPath === entry.path) dir.cursorStat.set(enriched)
120
+ // Function: read
121
+ // Reads `path` and, unless a newer read supersedes it, commits the listing
122
+ // as one atomic update: the displayed directory stays intact until its
123
+ // replacement is ready, and disk figures never delay the listing.
124
+ //
125
+ // Options:
126
+ // - `move`: move to `path` (reset filter and marks, remember the cursor
127
+ // for re-entry). Without it, `read` re-reads the displayed directory in
128
+ // place, preserving filter, marks and cursor (`refresh`).
129
+ // - `record`: also record the move in the history (a navigation). Mirror
130
+ // columns move without recording.
131
+ // - `cursorPath`: land the cursor on that entry when the listing has it.
132
+ // - `restore`: a history snapshot to restore instead of a fresh move.
133
+ const read = async (
134
+ path,
135
+ { move = false, record = false, cursorPath = null, restore = null } = {},
136
+ ) => {
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
144
+ batch(() => {
145
+ dir.loading.set(true)
146
+ dir.error.set(null)
81
147
  })
82
- })
83
-
84
- // Function: refresh
85
- // Re-reads the directory at `dir.path`, updating `rawEntries` and clamping
86
- // the cursor. Sets `loading` while in flight; failures land in `error`.
87
- dir.refresh = async () => {
88
- dir.loading.set(true)
89
- dir.error.set(null)
90
148
  try {
91
- dir.rawEntries.set(await fs.readDir(dir.path.value))
92
- dir.cursor.set(clampCursor(dir.cursor.value, dir.entries.value.length))
93
- dir.disk.set(await fs.disk(dir.path.value))
149
+ const raw = await fs.readDir(path)
150
+ if (token !== readToken) return
151
+ const filter = restore ? restore.filter : isMove ? "" : dir.filter.value
152
+ const entries = viewEntries(raw, filter, dir.sort.value)
153
+ let cursor
154
+ if (restore) {
155
+ cursor = clampCursor(restore.cursor, entries.length)
156
+ } else {
157
+ const remembered = isMove && path !== dir.path.value
158
+ ? (cursorMemory.get(path) ?? 0)
159
+ : dir.cursor.value
160
+ const kept = keepPath ? indexOfPath(entries, keepPath) : -1
161
+ const selected = cursorPath ? indexOfPath(entries, cursorPath) : -1
162
+ cursor = clampCursor(kept >= 0 ? kept : selected >= 0 ? selected : remembered, entries.length)
163
+ }
164
+ // A move that rewrites the filter sets the cursor it computed; no
165
+ // watcher intervenes (the filter cell is written directly, not
166
+ // through `setFilter`).
167
+ batch(() => {
168
+ if (restore) {
169
+ dir.path.set(path)
170
+ dir.filter.set(restore.filter)
171
+ dir.marked.set(new Set(restore.marks))
172
+ dir.disk.set(null)
173
+ contextSet(restore.context)
174
+ } else if (isMove) {
175
+ cursorMemory.set(dir.path.value, dir.cursor.value)
176
+ // Capture the view being left before overwriting it, but
177
+ // only for recorded moves: mirror columns keep no history.
178
+ if (record) dir.history.replace(snapshot())
179
+ dir.path.set(path)
180
+ dir.filter.set("")
181
+ dir.marked.set(new Set())
182
+ dir.disk.set(null)
183
+ }
184
+ dir.rawEntries.set(raw)
185
+ dir.cursor.set(cursor)
186
+ dir.loading.set(false)
187
+ })
188
+ watchCurrentPath()
189
+ if (record) {
190
+ const entry = { path, filter: "", cursor, marks: new Set(), context: contextGet() }
191
+ // Re-navigating to the current path updates the entry instead of
192
+ // duplicating it.
193
+ if (dir.history.current()?.path === path) dir.history.replace(entry)
194
+ else dir.history.push(entry)
195
+ }
196
+ const disk = await fs.disk(path)
197
+ if (token === readToken) dir.disk.set(disk)
94
198
  } catch (error) {
95
- dir.error.set(`${error?.message ?? error}`)
199
+ if (token === readToken) dir.error.set(`${error?.message ?? error}`)
96
200
  } finally {
97
- dir.loading.set(false)
201
+ if (token === readToken) {
202
+ moving = false
203
+ dir.loading.set(false)
204
+ }
98
205
  }
99
206
  }
100
207
 
101
- // Function: apply
102
- // The actual move to `path`: sets the path, resets filter, marks and
103
- // cursor, then refreshes. History-neutral — navigate/back/forward record
104
- // their own steps around it.
105
- const apply = async (path) => {
106
- dir.path.set(path)
107
- dir.filter.set("")
108
- dir.marked.set(new Set())
109
- dir.cursor.set(0)
110
- await dir.refresh()
111
- }
208
+ // Function: refresh
209
+ // Re-reads the displayed directory, preserving filter, marks and cursor.
210
+ dir.refresh = () => read(dir.path.value)
112
211
 
113
- // Function: navigate
114
- // Moves the directory to `path` (resetting filter, marks and cursor),
115
- // records the step in the history and refreshes. Re-navigating to the
116
- // current path does not duplicate the entry.
117
- dir.navigate = async (path) => {
118
- const history = dir.history
119
- if (history.stack[history.index] !== path) {
120
- // A new navigation truncates the forward branch, like a browser.
121
- history.stack.length = history.index + 1
122
- history.stack.push(path)
123
- history.index = history.stack.length - 1
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()
124
229
  }
125
- await apply(path)
126
230
  }
127
231
 
232
+ // Function: navigate
233
+ // Moves the directory to `path` (resetting filter and marks, restoring the
234
+ // cursor remembered for `path`), records the step in the history and reads
235
+ // the listing. Re-navigating to the current path does not duplicate the
236
+ // entry. `record: false` mirrors without touching the history (the
237
+ // explorer's parent and inspector columns).
238
+ dir.navigate = (path, { cursorPath = null, record = true } = {}) =>
239
+ read(path, { move: true, record, cursorPath })
240
+
128
241
  // Function: back
129
- // Steps one entry back in the navigation history; no-op at its start.
242
+ // Restores the previous history entry — path, filter, cursor and marks —
243
+ // and reads it; no-op at the start of the history.
130
244
  dir.back = async () => {
131
- const history = dir.history
132
- if (history.index <= 0) return
133
- history.index -= 1
134
- await apply(history.stack[history.index])
245
+ const entry = dir.history.back()
246
+ if (entry) await read(entry.path, { restore: entry })
135
247
  }
136
248
 
137
249
  // Function: forward
138
- // Steps one entry forward in the navigation history; no-op at its end.
250
+ // Restores the next history entry; no-op at the end of the history.
139
251
  dir.forward = async () => {
140
- const history = dir.history
141
- if (history.index >= history.stack.length - 1) return
142
- history.index += 1
143
- await apply(history.stack[history.index])
252
+ const entry = dir.history.forward()
253
+ if (entry) await read(entry.path, { restore: entry })
144
254
  }
145
255
 
146
256
  // Function: up
147
- // Navigates to the parent directory; no-op at the filesystem root.
257
+ // Navigates to the parent directory, landing the cursor on the entry we
258
+ // came from — stepping up always returns to the previously selected
259
+ // element. No-op at the filesystem root.
148
260
  dir.up = async () => {
149
- const target = parentPath(dir.path.value)
150
- if (target) await dir.navigate(target)
261
+ const cameFrom = dir.path.value
262
+ const target = parentPath(cameFrom)
263
+ if (!target) return
264
+ await dir.navigate(target, { cursorPath: cameFrom })
265
+ }
266
+
267
+ // Function: setFilter
268
+ // Sets the fuzzy filter and re-anchors the cursor on the first match.
269
+ dir.setFilter = (query) => {
270
+ dir.filter.set(query)
271
+ dir.cursor.set(0)
151
272
  }
152
273
 
153
274
  // Function: clearFilter
154
275
  // Removes the directory's filter, restoring the full listing.
155
- dir.clearFilter = () => dir.filter.set("")
276
+ dir.clearFilter = () => dir.setFilter("")
156
277
 
157
278
  // Function: open
158
279
  // Opens the entry under the cursor: descends into directories.
@@ -182,12 +303,17 @@ function createDirectory({ fs, path: initialPath }) {
182
303
  })
183
304
 
184
305
  // Function: dispose
185
- // Releases the directory's reactive watchers.
306
+ // Cancels any in-flight read so a late listing can no longer publish, and
307
+ // releases the path watcher.
186
308
  dir.dispose = () => {
187
- stopFilterWatch()
188
- stopCursorWatch()
309
+ readToken += 1
310
+ stopWatching()
189
311
  }
190
312
 
313
+ // Start watching the opening directory; a later move re-arms the watcher
314
+ // on its target (see `read`).
315
+ watchCurrentPath()
316
+
191
317
  return dir
192
318
  }
193
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