@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.
- package/README.md +256 -62
- package/bin/tcmd +10 -2
- package/bin/tcmd.js +13 -2
- 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 +19 -6
- package/public/fonts/OFL.txt +93 -0
- package/public/index.html +14 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +20679 -0
- package/public/style.css +1515 -0
- package/scripts/build-desktop.js +31 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +80 -14
- package/src/js/tcmd/components/App.js +339 -114
- package/src/js/tcmd/components/App.xml +76 -22
- package/src/js/tcmd/components/BookmarkList.html +5 -5
- package/src/js/tcmd/components/BookmarkList.js +15 -41
- 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/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +28 -0
- package/src/js/tcmd/components/CommandArgRow.js +146 -30
- package/src/js/tcmd/components/CommandArgRow.xml +55 -4
- package/src/js/tcmd/components/CommandBar.html +2 -2
- package/src/js/tcmd/components/CommandBar.js +33 -27
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +25 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +78 -52
- package/src/js/tcmd/components/CommandPanel.xml +49 -25
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +22 -8
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- 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 +14 -4
- package/src/js/tcmd/components/FilePreview.js +111 -12
- package/src/js/tcmd/components/FilePreview.xml +28 -8
- package/src/js/tcmd/components/FileRow.html +2 -1
- package/src/js/tcmd/components/FileRow.js +70 -12
- package/src/js/tcmd/components/FilterBar.html +1 -1
- package/src/js/tcmd/components/FilterBar.js +14 -4
- package/src/js/tcmd/components/FilterBar.xml +1 -1
- package/src/js/tcmd/components/Frame.js +324 -0
- package/src/js/tcmd/components/Help.html +15 -0
- package/src/js/tcmd/components/Help.js +260 -0
- 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/{CommandPalette.xml → OverlayList.xml} +2 -5
- 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/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +176 -5
- package/src/js/tcmd/components/index.js +9 -5
- package/src/js/tcmd/components/rows.js +176 -48
- 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 +978 -260
- 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 +219 -93
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +438 -54
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +42 -6
- package/src/js/tcmd/core/operations.js +813 -48
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +33 -8
- package/src/js/tcmd/core/panel.js +280 -104
- 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/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +498 -200
- 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/index.js +0 -1
- package/src/js/tcmd/tui/app.js +118 -60
- package/src/js/tcmd/tui/catalogue.js +238 -42
- package/src/js/tcmd/tui/index.js +18 -19
- 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 +32 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +225 -41
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +81 -38
- package/src/js/tcmd/web/effects.js +223 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +4 -4
- package/src/js/tcmd/web/server.js +124 -21
- package/src/js/tcmd/web/theme.js +4 -0
- package/tinyjs.json +16 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/PaletteRow.html +0 -3
- package/src/js/tcmd/components/PaletteRow.js +0 -43
- package/src/js/tcmd/components/PaletteRow.xml +0 -4
- package/src/js/tcmd/tui/frame.js +0 -269
- 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
|
|
18
|
+
import { batch, cell, derived } from "select/state/cells"
|
|
14
19
|
import { viewEntries } from "./entries.js"
|
|
15
|
-
import {
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
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
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
114
|
+
// Function: setContext
|
|
115
|
+
// Replaces the owner context captured into history entries.
|
|
116
|
+
dir.setContext = (next) => {
|
|
117
|
+
context = next
|
|
118
|
+
}
|
|
71
119
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
201
|
+
if (token === readToken) {
|
|
202
|
+
moving = false
|
|
203
|
+
dir.loading.set(false)
|
|
204
|
+
}
|
|
98
205
|
}
|
|
99
206
|
}
|
|
100
207
|
|
|
101
|
-
// Function:
|
|
102
|
-
//
|
|
103
|
-
|
|
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:
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
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
|
|
132
|
-
if (
|
|
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
|
-
//
|
|
250
|
+
// Restores the next history entry; no-op at the end of the history.
|
|
139
251
|
dir.forward = async () => {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
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
|
|
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
|
|
150
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
188
|
-
|
|
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
|