@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.
- package/README.md +169 -58
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/OverlayList.xml +13 -0
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +612 -136
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/session.js +286 -116
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- 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)
|
|
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
|