@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
package/src/js/tcmd/core/fs.js
CHANGED
|
@@ -11,40 +11,91 @@
|
|
|
11
11
|
// - `readDir(path)`: Promise<FileEntry-like[]> - directory listing with stat info
|
|
12
12
|
// - `stat(path)`: Promise<{ isDir, isSymlink, size, mtimeMs, ctimeMs, mode, uid,
|
|
13
13
|
// gid } | null>
|
|
14
|
+
// - `realpath(path)`: Promise<string> - absolute path with symlinks resolved
|
|
14
15
|
// - `isDir(path)`: Promise<boolean>
|
|
16
|
+
// - `watch(path, onChange)`: stop() | null, or a promise of either - optional
|
|
17
|
+
// change notification for `path` (async for RPC adapters); unsupported
|
|
18
|
+
// paths are polled by `core/watch`
|
|
15
19
|
// - `readTextHead(path, maxBytes)`: Promise<string> - leading bytes as text
|
|
20
|
+
// - `readBytes(path, maxBytes)`: Promise<Uint8Array> - leading raw bytes (the
|
|
21
|
+
// compressed previews decompress these)
|
|
16
22
|
// - `du(path)`: Promise<number> - recursive size in bytes
|
|
17
23
|
// - `disk(path)`: Promise<{ free, total } | null> - filesystem capacity
|
|
18
|
-
// - `copy(from, to)`, `move(from, to)`: Promise<void> - files or directories
|
|
19
|
-
//
|
|
24
|
+
// - `copy(from, to)`, `move(from, to)`: Promise<void> - files or directories;
|
|
25
|
+
// copy can merge directories but never replaces an existing entry, and move
|
|
26
|
+
// requires an absent destination
|
|
27
|
+
// - `hardlink(from, to)`: Promise<void> - create a hard link, refusing an
|
|
28
|
+
// existing destination
|
|
29
|
+
// - `symlink(target, link)`: Promise<void> - create a symbolic link, refusing
|
|
30
|
+
// an existing destination
|
|
31
|
+
// - `chmod(path, mode)`: Promise<void> - set the permission bits (numeric)
|
|
32
|
+
// - `chown(path, owner)`: Promise<void> - set the owner from `USER[:GROUP]`
|
|
33
|
+
// (names or numeric ids)
|
|
34
|
+
// - `unlink(path)`: Promise<void> - remove one symbolic link, leaving its
|
|
35
|
+
// target (and regular files) alone
|
|
36
|
+
// - `mkdir(path)`: Promise<void> - create a directory (parents included)
|
|
37
|
+
// - `trash(paths)`: Promise<TrashedEntry[]> - move to the freedesktop trash,
|
|
38
|
+
// returning `{ from, to, name }` per path (`to` is the trash copy)
|
|
39
|
+
// - `trashList()`: Promise<TrashInfo[]> - the trash contents, newest first,
|
|
40
|
+
// as `{ name, originalPath, deletedAt, trashPath, isDir }`
|
|
41
|
+
// - `restore(trashPaths, options?)`: Promise<void> - move trashed entries back
|
|
42
|
+
// to their recorded original paths; `validateDestination(path)` is an
|
|
43
|
+
// optional host guard (used by the scoped web server)
|
|
44
|
+
// - `erase(paths)`: Promise<void> - delete without trash
|
|
20
45
|
// - `touch(path)`: Promise<void> - create or bump mtime
|
|
21
46
|
// - `home()`, `cwd()`: string - well-known directories
|
|
47
|
+
//
|
|
48
|
+
// A listing entry additionally reports symlinks as what they point at:
|
|
49
|
+
// `isSymlink` keeps the entry's own identity, `target` is the raw `readlink`
|
|
50
|
+
// text (relative as written, null otherwise), and `isDir`/`size` follow the
|
|
51
|
+
// link — so a link to a directory sorts, opens and previews as a directory,
|
|
52
|
+
// and a link to a file carries the target's size.
|
|
22
53
|
|
|
23
54
|
import fs from "node:fs/promises"
|
|
24
55
|
import nodeFs from "node:fs"
|
|
25
56
|
import nodeOs from "node:os"
|
|
26
57
|
import nodePath from "node:path"
|
|
27
|
-
import {
|
|
58
|
+
import { createLimiter, mapLimit } from "./concurrency.js"
|
|
59
|
+
import { SEP, extname, join as pathJoin, normalize } from "./path.js"
|
|
60
|
+
import { PREVIEW_BYTES } from "./preview.js"
|
|
28
61
|
|
|
29
62
|
// Owner-name caches, filled lazily from /etc/passwd and /etc/group. Browsers
|
|
30
63
|
// and non-POSIX hosts never populate them (the adapters there omit `owner`).
|
|
31
64
|
let passwdNames = null
|
|
32
65
|
let groupNames = null
|
|
66
|
+
// ponytail: process-local destination locks serialize callers here; cross-process no-replace moves need native support.
|
|
67
|
+
const pathLocks = new Map()
|
|
33
68
|
|
|
34
|
-
// Function:
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
async
|
|
38
|
-
|
|
69
|
+
// Function: loadOwners
|
|
70
|
+
// Fills the passwd/group caches once, so `ownerOf` can resolve synchronously.
|
|
71
|
+
// A listing resolves its owners after a single `loadOwners()`, instead of one
|
|
72
|
+
// async round-trip per entry.
|
|
73
|
+
async function loadOwners() {
|
|
39
74
|
if (process.platform !== "win32") {
|
|
40
75
|
passwdNames ??= await parseColonFile("/etc/passwd", 2)
|
|
41
76
|
groupNames ??= await parseColonFile("/etc/group", 2)
|
|
42
77
|
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Function: ownerOf
|
|
81
|
+
// "user:group" for a uid/gid from the (already loaded) names maps. Falls back
|
|
82
|
+
// to the numeric ids, or "" when both are absent.
|
|
83
|
+
function ownerOf(uid, gid) {
|
|
84
|
+
if (uid == null && gid == null) return ""
|
|
43
85
|
const user = uid == null ? "" : passwdNames?.get(uid) ?? `${uid}`
|
|
44
86
|
const group = gid == null ? "" : groupNames?.get(gid) ?? `${gid}`
|
|
45
87
|
return user && group ? `${user}:${group}` : user || group
|
|
46
88
|
}
|
|
47
89
|
|
|
90
|
+
// Function: resolveOwner
|
|
91
|
+
// "user:group" for a uid/gid, loading the names maps on first use. Falls back
|
|
92
|
+
// to the numeric ids, or "" when both are absent.
|
|
93
|
+
async function resolveOwner(uid, gid) {
|
|
94
|
+
if (uid == null && gid == null) return ""
|
|
95
|
+
await loadOwners()
|
|
96
|
+
return ownerOf(uid, gid)
|
|
97
|
+
}
|
|
98
|
+
|
|
48
99
|
// Function: parseColonFile
|
|
49
100
|
// Reads a passwd/group-style file into a `{ id -> name }` map; unreadable
|
|
50
101
|
// files yield an empty map (names then fall back to the numeric id).
|
|
@@ -63,13 +114,43 @@ async function parseColonFile(path, nameColumn) {
|
|
|
63
114
|
return map
|
|
64
115
|
}
|
|
65
116
|
|
|
66
|
-
|
|
117
|
+
// Function: idForName
|
|
118
|
+
// Resolves a user or group name to its numeric id (numeric ids pass through).
|
|
119
|
+
function idForName(names, value, kind) {
|
|
120
|
+
const text = `${value ?? ""}`.trim()
|
|
121
|
+
if (/^\d+$/.test(text)) return Number(text)
|
|
122
|
+
for (const [id, name] of names ?? []) if (name === text) return id
|
|
123
|
+
throw new Error(`chown: unknown ${kind}: ${text}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Function: resolveOwnerIds
|
|
127
|
+
// Splits `USER[:GROUP]` into `{ uid, gid }`; an omitted part is -1, which
|
|
128
|
+
// `chown` leaves unchanged.
|
|
129
|
+
async function resolveOwnerIds(owner) {
|
|
130
|
+
const text = `${owner ?? ""}`.trim()
|
|
131
|
+
if (!text) throw new Error("chown: OWNER must be USER[:GROUP]")
|
|
132
|
+
const [user, group] = text.split(":")
|
|
133
|
+
await loadOwners()
|
|
134
|
+
return {
|
|
135
|
+
uid: user ? idForName(passwdNames, user, "user") : -1,
|
|
136
|
+
gid: group ? idForName(groupNames, group, "group") : -1,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Function: toEntry
|
|
141
|
+
// Builds a listing entry. `prefix` is the directory path already ending in the
|
|
142
|
+
// separator, so the listing joins it once instead of normalizing the directory
|
|
143
|
+
// path per entry.
|
|
144
|
+
function toEntry(name, prefix, isDir, stats) {
|
|
67
145
|
const stat = stats || {}
|
|
68
146
|
return {
|
|
69
147
|
name,
|
|
70
|
-
path:
|
|
148
|
+
path: prefix + name,
|
|
71
149
|
isDir,
|
|
72
150
|
isSymlink: Boolean(stat.isSymlink),
|
|
151
|
+
// The raw `readlink` text — relative when the link was written that
|
|
152
|
+
// way — so the listing and the preview can show where it points.
|
|
153
|
+
target: stat.target ?? null,
|
|
73
154
|
isDevice: Boolean(stat.isDevice),
|
|
74
155
|
size: isDir ? 0 : stat.size ?? 0,
|
|
75
156
|
mtimeMs: stat.mtimeMs ?? 0,
|
|
@@ -89,19 +170,43 @@ function isDeviceNode(node) {
|
|
|
89
170
|
)
|
|
90
171
|
}
|
|
91
172
|
|
|
173
|
+
// Constant: READDIR_CONCURRENCY
|
|
174
|
+
// Per-entry stats in a listing are issued concurrently, bounded so a huge
|
|
175
|
+
// directory does not queue tens of thousands of requests at once. The Node
|
|
176
|
+
// threadpool services a handful of filesystem calls at a time, so a modest
|
|
177
|
+
// bound keeps it saturated without the memory churn of one promise per entry.
|
|
178
|
+
const READDIR_CONCURRENCY = 64
|
|
179
|
+
|
|
180
|
+
// Constant: DU_CONCURRENCY
|
|
181
|
+
// Global in-flight bound for the recursive `du` walk, shared across every
|
|
182
|
+
// directory level (see `duSafe`).
|
|
183
|
+
const DU_CONCURRENCY = 64
|
|
184
|
+
|
|
92
185
|
// Function: createNodeFs
|
|
93
186
|
// Node/Bun-backed implementation of the filesystem adapter.
|
|
94
187
|
function createNodeFs() {
|
|
95
188
|
const adapter = {
|
|
96
189
|
async readDir(path) {
|
|
97
190
|
const dirents = await fs.readdir(path, { withFileTypes: true })
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
191
|
+
// Normalize the directory once, not per entry: `toEntry` appends the
|
|
192
|
+
// name to this prefix.
|
|
193
|
+
const normalized = normalize(path)
|
|
194
|
+
const prefix = normalized === SEP ? SEP : normalized + SEP
|
|
195
|
+
// Resolve owners from the cached maps, once, up front: the per-entry
|
|
196
|
+
// work below then stays purely filesystem-bound and parallel.
|
|
197
|
+
await loadOwners()
|
|
198
|
+
return mapLimit(dirents, READDIR_CONCURRENCY, async (d) => {
|
|
199
|
+
const full = prefix + d.name
|
|
200
|
+
const stats = await statDetails(full)
|
|
201
|
+
const isLink = d.isSymbolicLink()
|
|
202
|
+
// The dirent type describes the link itself, not what it
|
|
203
|
+
// points at: resolve through the followed stat so a link to a
|
|
204
|
+
// directory is listed (sorted, opened, previewed) as one. A
|
|
205
|
+
// broken link has no stats and stays a plain entry.
|
|
206
|
+
const isDir = d.isDirectory() || (isLink && Boolean(stats?.isDir))
|
|
207
|
+
const entry = toEntry(d.name, prefix, isDir, {
|
|
208
|
+
isSymlink: isLink,
|
|
209
|
+
target: isLink ? await readLinkSafe(full) : null,
|
|
105
210
|
isDevice: isDeviceNode(d),
|
|
106
211
|
size: stats?.size ?? 0,
|
|
107
212
|
mtimeMs: stats?.mtimeMs ?? 0,
|
|
@@ -110,18 +215,33 @@ function createNodeFs() {
|
|
|
110
215
|
uid: stats?.uid ?? null,
|
|
111
216
|
gid: stats?.gid ?? null,
|
|
112
217
|
})
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
218
|
+
entry.owner = ownerOf(stats?.uid, stats?.gid)
|
|
219
|
+
return entry
|
|
220
|
+
})
|
|
116
221
|
},
|
|
117
222
|
async stat(path) {
|
|
118
223
|
return statSafe(path)
|
|
119
224
|
},
|
|
225
|
+
realpath: (path) => fs.realpath(path),
|
|
120
226
|
async isDir(path) {
|
|
121
227
|
const stats = await statSafe(path)
|
|
122
228
|
return Boolean(stats?.isDir)
|
|
123
229
|
},
|
|
124
|
-
|
|
230
|
+
// Function: watch (adapter method)
|
|
231
|
+
// Reports changes inside `path` through Node's directory watcher. The
|
|
232
|
+
// callback is coalesced by `core/watch`; the returned stop closes the
|
|
233
|
+
// watcher. A path this process cannot watch (watch limit, permissions,
|
|
234
|
+
// a deleted directory) yields null so the caller falls back to polling.
|
|
235
|
+
watch(path, onChange) {
|
|
236
|
+
try {
|
|
237
|
+
const watcher = nodeFs.watch(path, { persistent: false }, () => onChange?.())
|
|
238
|
+
watcher.on?.("error", () => watcher.close())
|
|
239
|
+
return () => watcher.close()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
async readTextHead(path, maxBytes = PREVIEW_BYTES) {
|
|
125
245
|
const file = nodeFs.openSync(path, "r")
|
|
126
246
|
try {
|
|
127
247
|
const buffer = Buffer.alloc(maxBytes)
|
|
@@ -131,6 +251,16 @@ function createNodeFs() {
|
|
|
131
251
|
nodeFs.closeSync(file)
|
|
132
252
|
}
|
|
133
253
|
},
|
|
254
|
+
async readBytes(path, maxBytes = PREVIEW_BYTES) {
|
|
255
|
+
const file = nodeFs.openSync(path, "r")
|
|
256
|
+
try {
|
|
257
|
+
const buffer = Buffer.alloc(maxBytes)
|
|
258
|
+
const bytes = nodeFs.readSync(file, buffer, 0, maxBytes, 0)
|
|
259
|
+
return new Uint8Array(buffer.subarray(0, bytes))
|
|
260
|
+
} finally {
|
|
261
|
+
nodeFs.closeSync(file)
|
|
262
|
+
}
|
|
263
|
+
},
|
|
134
264
|
async du(path) {
|
|
135
265
|
return duSafe(path)
|
|
136
266
|
},
|
|
@@ -143,35 +273,139 @@ function createNodeFs() {
|
|
|
143
273
|
}
|
|
144
274
|
},
|
|
145
275
|
async copy(from, to) {
|
|
146
|
-
|
|
276
|
+
return withPathLock(to, async () => {
|
|
277
|
+
await assertCopySafe(from, to)
|
|
278
|
+
await fs.cp(from, to, { recursive: true })
|
|
279
|
+
})
|
|
147
280
|
},
|
|
148
281
|
async move(from, to) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await
|
|
154
|
-
await fs.
|
|
155
|
-
}
|
|
282
|
+
await moveSafe(from, to)
|
|
283
|
+
},
|
|
284
|
+
async hardlink(from, to) {
|
|
285
|
+
await withPathLock(to, async () => {
|
|
286
|
+
if (await pathExists(to)) throw pathExistsError(to)
|
|
287
|
+
await fs.link(from, to)
|
|
288
|
+
})
|
|
289
|
+
},
|
|
290
|
+
async symlink(target, link) {
|
|
291
|
+
await withPathLock(link, async () => {
|
|
292
|
+
if (await pathExists(link)) throw pathExistsError(link)
|
|
293
|
+
await fs.symlink(target, link)
|
|
294
|
+
})
|
|
295
|
+
},
|
|
296
|
+
async chmod(path, mode) {
|
|
297
|
+
await fs.chmod(path, mode)
|
|
298
|
+
},
|
|
299
|
+
async chown(path, owner) {
|
|
300
|
+
const { uid, gid } = await resolveOwnerIds(owner)
|
|
301
|
+
await fs.chown(path, uid, gid)
|
|
302
|
+
},
|
|
303
|
+
async unlink(path) {
|
|
304
|
+
const stats = await fs.lstat(path)
|
|
305
|
+
if (!stats.isSymbolicLink()) throw new Error(`not a symbolic link: ${path}`)
|
|
306
|
+
await fs.unlink(path)
|
|
307
|
+
},
|
|
308
|
+
async mkdir(path) {
|
|
309
|
+
await fs.mkdir(path, { recursive: true })
|
|
156
310
|
},
|
|
157
311
|
async trash(paths) {
|
|
158
312
|
const filesDir = trashDir("files")
|
|
159
313
|
const infoDir = trashDir("info")
|
|
160
314
|
await fs.mkdir(filesDir, { recursive: true })
|
|
161
315
|
await fs.mkdir(infoDir, { recursive: true })
|
|
316
|
+
const trashed = []
|
|
162
317
|
for (const source of paths) {
|
|
163
318
|
const absolute = nodePath.resolve(source)
|
|
164
|
-
const name = await
|
|
319
|
+
const { name, infoFile, handle } = await reserveTrashName(
|
|
320
|
+
filesDir,
|
|
321
|
+
infoDir,
|
|
322
|
+
nodePath.basename(absolute),
|
|
323
|
+
)
|
|
165
324
|
const target = nodePath.join(filesDir, name)
|
|
325
|
+
const info = `[Trash Info]\nPath=${encodeTrashPath(absolute)}\nDeletionDate=${new Date().toISOString()}\n`
|
|
326
|
+
try {
|
|
327
|
+
await handle.writeFile(info)
|
|
328
|
+
await handle.close()
|
|
329
|
+
} catch (error) {
|
|
330
|
+
await handle.close().catch(() => {})
|
|
331
|
+
await fs.rm(infoFile, { force: true })
|
|
332
|
+
throw error
|
|
333
|
+
}
|
|
334
|
+
await handle.close()
|
|
335
|
+
try {
|
|
336
|
+
await moveSafe(absolute, target)
|
|
337
|
+
} catch (error) {
|
|
338
|
+
await fs.rm(infoFile, { force: true })
|
|
339
|
+
throw error
|
|
340
|
+
}
|
|
341
|
+
trashed.push({ from: absolute, to: target, name })
|
|
342
|
+
}
|
|
343
|
+
return trashed
|
|
344
|
+
},
|
|
345
|
+
async trashList() {
|
|
346
|
+
const filesDir = trashDir("files")
|
|
347
|
+
const infoDir = trashDir("info")
|
|
348
|
+
let files
|
|
349
|
+
try {
|
|
350
|
+
files = await fs.readdir(infoDir)
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return []
|
|
353
|
+
}
|
|
354
|
+
const rows = []
|
|
355
|
+
for (const file of files) {
|
|
356
|
+
if (!file.endsWith(".trashinfo")) continue
|
|
357
|
+
const name = file.slice(0, -".trashinfo".length)
|
|
358
|
+
const trashPath = nodePath.join(filesDir, name)
|
|
359
|
+
let info = {}
|
|
360
|
+
try {
|
|
361
|
+
info = parseTrashInfo(await fs.readFile(nodePath.join(infoDir, file), "utf8"))
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// A missing or unreadable info file leaves the row without a
|
|
364
|
+
// recorded original path; it is still listed.
|
|
365
|
+
}
|
|
366
|
+
let stats
|
|
166
367
|
try {
|
|
167
|
-
await fs.
|
|
368
|
+
stats = await fs.lstat(trashPath)
|
|
168
369
|
} catch (error) {
|
|
169
|
-
if (error?.code
|
|
170
|
-
|
|
171
|
-
|
|
370
|
+
if (error?.code === "ENOENT") continue
|
|
371
|
+
throw error
|
|
372
|
+
}
|
|
373
|
+
rows.push({
|
|
374
|
+
name,
|
|
375
|
+
originalPath: info.Path ?? null,
|
|
376
|
+
deletedAt: info.DeletionDate ?? null,
|
|
377
|
+
trashPath,
|
|
378
|
+
isDir: stats.isDirectory(),
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
return rows.sort((a, b) => `${b.deletedAt ?? ""}`.localeCompare(`${a.deletedAt ?? ""}`))
|
|
382
|
+
},
|
|
383
|
+
async restore(trashPaths, { validateDestination } = {}) {
|
|
384
|
+
const filesDir = nodePath.resolve(trashDir("files"))
|
|
385
|
+
const infoDir = trashDir("info")
|
|
386
|
+
const restores = []
|
|
387
|
+
const destinations = new Set()
|
|
388
|
+
for (const path of trashPaths ?? []) {
|
|
389
|
+
const trashPath = nodePath.resolve(path)
|
|
390
|
+
if (nodePath.dirname(trashPath) !== filesDir) throw new Error(`not a trash entry: ${path}`)
|
|
391
|
+
const name = nodePath.basename(trashPath)
|
|
392
|
+
const infoFile = nodePath.join(infoDir, `${name}.trashinfo`)
|
|
393
|
+
const info = parseTrashInfo(await fs.readFile(infoFile, "utf8"))
|
|
394
|
+
if (!info.Path) throw new Error(`no original path recorded for ${name}`)
|
|
395
|
+
if (!nodePath.isAbsolute(info.Path)) throw new Error(`invalid original path for ${name}`)
|
|
396
|
+
const destination = validateDestination ? await validateDestination(info.Path) : info.Path
|
|
397
|
+
if (destinations.has(destination) || (await pathExists(destination))) {
|
|
398
|
+
throw pathExistsError(destination)
|
|
172
399
|
}
|
|
173
|
-
|
|
174
|
-
|
|
400
|
+
destinations.add(destination)
|
|
401
|
+
restores.push({ trashPath, infoFile, destination })
|
|
402
|
+
}
|
|
403
|
+
assertNoNestedDestinations(restores.map((restore) => restore.destination))
|
|
404
|
+
for (const { trashPath, infoFile, destination } of restores) {
|
|
405
|
+
if (await pathExists(destination)) throw pathExistsError(destination)
|
|
406
|
+
await fs.mkdir(nodePath.dirname(destination), { recursive: true })
|
|
407
|
+
await moveSafe(trashPath, destination)
|
|
408
|
+
await fs.rm(infoFile, { force: true })
|
|
175
409
|
}
|
|
176
410
|
},
|
|
177
411
|
async erase(paths) {
|
|
@@ -204,20 +438,151 @@ function trashDir(kind) {
|
|
|
204
438
|
return nodePath.join(dataHome, "Trash", kind)
|
|
205
439
|
}
|
|
206
440
|
|
|
207
|
-
// Function:
|
|
208
|
-
//
|
|
209
|
-
|
|
441
|
+
// Function: reserveTrashName
|
|
442
|
+
// Atomically reserves the `.trashinfo` name so concurrent trash operations do
|
|
443
|
+
// not choose the same files/info pair.
|
|
444
|
+
async function reserveTrashName(filesDir, infoDir, base) {
|
|
210
445
|
for (let i = 0; ; i++) {
|
|
211
446
|
const name = i === 0 ? base : `${base}.${i}`
|
|
447
|
+
const target = nodePath.join(filesDir, name)
|
|
448
|
+
const infoFile = nodePath.join(infoDir, `${name}.trashinfo`)
|
|
449
|
+
if (await pathExists(target)) continue
|
|
450
|
+
let handle
|
|
212
451
|
try {
|
|
213
|
-
await fs.
|
|
452
|
+
handle = await fs.open(infoFile, "wx")
|
|
214
453
|
} catch (error) {
|
|
215
|
-
|
|
454
|
+
if (error?.code === "EEXIST") continue
|
|
455
|
+
throw error
|
|
216
456
|
}
|
|
457
|
+
if (await pathExists(target)) {
|
|
458
|
+
await handle.close()
|
|
459
|
+
await fs.rm(infoFile, { force: true })
|
|
460
|
+
continue
|
|
461
|
+
}
|
|
462
|
+
return { name, infoFile, handle }
|
|
217
463
|
}
|
|
218
464
|
}
|
|
219
465
|
|
|
220
|
-
|
|
466
|
+
// Function: moveSafe
|
|
467
|
+
// Renames `from` to `to`, falling back to copy+remove across filesystems
|
|
468
|
+
// (EXDEV), where `rename` cannot cross devices.
|
|
469
|
+
async function moveSafe(from, to) {
|
|
470
|
+
return withPathLock(to, async () => {
|
|
471
|
+
if (await pathExists(to)) throw pathExistsError(to)
|
|
472
|
+
try {
|
|
473
|
+
await fs.rename(from, to)
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error?.code !== "EXDEV") throw error
|
|
476
|
+
await fs.cp(from, to, { recursive: true })
|
|
477
|
+
await fs.rm(from, { recursive: true })
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function withPathLock(path, action) {
|
|
483
|
+
const key = nodePath.resolve(path)
|
|
484
|
+
const previous = pathLocks.get(key) ?? Promise.resolve()
|
|
485
|
+
let release
|
|
486
|
+
const gate = new Promise((resolve) => (release = resolve))
|
|
487
|
+
const tail = previous.then(() => gate)
|
|
488
|
+
pathLocks.set(key, tail)
|
|
489
|
+
await previous
|
|
490
|
+
try {
|
|
491
|
+
return await action()
|
|
492
|
+
} finally {
|
|
493
|
+
release()
|
|
494
|
+
if (pathLocks.get(key) === tail) pathLocks.delete(key)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function assertCopySafe(from, to) {
|
|
499
|
+
const source = await fs.lstat(from)
|
|
500
|
+
const sourcePath = nodePath.resolve(from)
|
|
501
|
+
const targetPath = nodePath.resolve(to)
|
|
502
|
+
const relative = nodePath.relative(sourcePath, targetPath)
|
|
503
|
+
if (
|
|
504
|
+
sourcePath === targetPath ||
|
|
505
|
+
(source.isDirectory() && relative && relative !== ".." && !relative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(relative))
|
|
506
|
+
) {
|
|
507
|
+
throw new Error(`cannot copy a path into itself: ${to}`)
|
|
508
|
+
}
|
|
509
|
+
let destination
|
|
510
|
+
try {
|
|
511
|
+
destination = await fs.lstat(to)
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error?.code === "ENOENT") return
|
|
514
|
+
throw error
|
|
515
|
+
}
|
|
516
|
+
if (!source.isDirectory() || !destination.isDirectory()) throw pathExistsError(to)
|
|
517
|
+
for (const name of await fs.readdir(from)) {
|
|
518
|
+
await assertCopySafe(nodePath.join(from, name), nodePath.join(to, name))
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function assertNoNestedDestinations(paths) {
|
|
523
|
+
for (let i = 0; i < paths.length; i++) {
|
|
524
|
+
for (let j = i + 1; j < paths.length; j++) {
|
|
525
|
+
const relative = nodePath.relative(paths[i], paths[j])
|
|
526
|
+
const reverse = nodePath.relative(paths[j], paths[i])
|
|
527
|
+
if (
|
|
528
|
+
(relative && relative !== ".." && !relative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(relative)) ||
|
|
529
|
+
(reverse && reverse !== ".." && !reverse.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(reverse))
|
|
530
|
+
) {
|
|
531
|
+
throw pathExistsError(paths[j])
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Function: parseTrashInfo
|
|
538
|
+
// Parses a freedesktop `.trashinfo` file (`Key=Value` lines under a
|
|
539
|
+
// `[Trash Info]` header) into a `{ Path, DeletionDate }` map.
|
|
540
|
+
function parseTrashInfo(text) {
|
|
541
|
+
const info = {}
|
|
542
|
+
for (const line of `${text ?? ""}`.split("\n")) {
|
|
543
|
+
const eq = line.indexOf("=")
|
|
544
|
+
if (eq <= 0) continue
|
|
545
|
+
const key = line.slice(0, eq).trim()
|
|
546
|
+
const value = line.slice(eq + 1).trim()
|
|
547
|
+
if (key === "Path") {
|
|
548
|
+
try {
|
|
549
|
+
info[key] = decodeURIComponent(value)
|
|
550
|
+
} catch (error) {
|
|
551
|
+
info[key] = value
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
info[key] = value
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return info
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Function: encodeTrashPath
|
|
561
|
+
// Percent-encodes path components for the freedesktop `.trashinfo` format.
|
|
562
|
+
function encodeTrashPath(path) {
|
|
563
|
+
return `${path}`.split(nodePath.sep).map(encodeURIComponent).join(nodePath.sep)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function pathExists(path) {
|
|
567
|
+
try {
|
|
568
|
+
await fs.lstat(path)
|
|
569
|
+
return true
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (error?.code === "ENOENT") return false
|
|
572
|
+
throw error
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function pathExistsError(path) {
|
|
577
|
+
const error = new Error(`destination already exists: ${path}`)
|
|
578
|
+
error.code = "EEXIST"
|
|
579
|
+
return error
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Function: statDetails
|
|
583
|
+
// The stat fields a listing needs, with no owner lookup — `readDir` resolves
|
|
584
|
+
// owners itself, from the cached maps, so the per-entry work stays parallel.
|
|
585
|
+
async function statDetails(path) {
|
|
221
586
|
try {
|
|
222
587
|
const stats = await fs.stat(path)
|
|
223
588
|
return {
|
|
@@ -230,34 +595,53 @@ async function statSafe(path) {
|
|
|
230
595
|
mode: stats.mode,
|
|
231
596
|
uid: stats.uid,
|
|
232
597
|
gid: stats.gid,
|
|
233
|
-
owner: await resolveOwner(stats.uid, stats.gid),
|
|
234
598
|
}
|
|
235
599
|
} catch (error) {
|
|
236
600
|
return null
|
|
237
601
|
}
|
|
238
602
|
}
|
|
239
603
|
|
|
604
|
+
async function statSafe(path) {
|
|
605
|
+
const details = await statDetails(path)
|
|
606
|
+
if (!details) return null
|
|
607
|
+
return { ...details, owner: await resolveOwner(details.uid, details.gid) }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Function: readLinkSafe
|
|
611
|
+
// The raw `readlink` text of `path`, or null when it is not a link (or the
|
|
612
|
+
// link cannot be read). Relative targets stay relative, as written.
|
|
613
|
+
async function readLinkSafe(path) {
|
|
614
|
+
try {
|
|
615
|
+
return await fs.readlink(path)
|
|
616
|
+
} catch (error) {
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
240
621
|
// Function: duSafe
|
|
241
|
-
// Recursive disk usage; unreadable entries contribute 0 and never throw.
|
|
622
|
+
// Recursive disk usage; unreadable entries contribute 0 and never throw. The
|
|
623
|
+
// walk fans out per directory but every filesystem call takes a slot from one
|
|
624
|
+
// global limiter, so a deep tree cannot multiply the concurrency per level.
|
|
625
|
+
const duLimit = createLimiter(DU_CONCURRENCY)
|
|
242
626
|
async function duSafe(path) {
|
|
243
|
-
const stats = await
|
|
627
|
+
const stats = await duLimit(() => statDetails(path))
|
|
244
628
|
if (!stats) return 0
|
|
245
629
|
if (!stats.isDir) return stats.size
|
|
246
|
-
let total = 0
|
|
247
630
|
let dirents
|
|
248
631
|
try {
|
|
249
|
-
dirents = await fs.readdir(path, { withFileTypes: true })
|
|
632
|
+
dirents = await duLimit(() => fs.readdir(path, { withFileTypes: true }))
|
|
250
633
|
} catch (error) {
|
|
251
634
|
return 0
|
|
252
635
|
}
|
|
253
|
-
|
|
636
|
+
const sizes = await mapLimit(dirents, DU_CONCURRENCY, (d) => {
|
|
637
|
+
// A symlink contributes nothing, matching the old link guard.
|
|
638
|
+
if (d.isSymbolicLink()) return 0
|
|
254
639
|
const full = pathJoin(path, d.name)
|
|
255
|
-
if (d.isDirectory())
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
640
|
+
if (d.isDirectory()) return duSafe(full)
|
|
641
|
+
return duLimit(() => statDetails(full)).then((child) => child?.size ?? 0)
|
|
642
|
+
})
|
|
643
|
+
let total = 0
|
|
644
|
+
for (const size of sizes) total += size
|
|
261
645
|
return total
|
|
262
646
|
}
|
|
263
647
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-14
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/history
|
|
7
|
+
// A browser-like history over snapshots: a stack with a cursor (`index`) into
|
|
8
|
+
// it. `push` truncates the forward branch, `back`/`forward` move the cursor and
|
|
9
|
+
// return the snapshot to restore, and `replace` updates the current entry in
|
|
10
|
+
// place. Pure bookkeeping — callers own what a snapshot contains and how to
|
|
11
|
+
// apply it.
|
|
12
|
+
|
|
13
|
+
// Function: createHistory
|
|
14
|
+
// Creates a history seeded with `initial` and the cursor on it.
|
|
15
|
+
function createHistory(initial) {
|
|
16
|
+
const stack = [initial]
|
|
17
|
+
let index = 0
|
|
18
|
+
return {
|
|
19
|
+
stack,
|
|
20
|
+
get index() {
|
|
21
|
+
return index
|
|
22
|
+
},
|
|
23
|
+
// Function: current
|
|
24
|
+
// The snapshot the cursor is on.
|
|
25
|
+
current: () => stack[index],
|
|
26
|
+
// Function: push
|
|
27
|
+
// Records `snapshot` after the cursor, dropping the forward branch.
|
|
28
|
+
push(snapshot) {
|
|
29
|
+
stack.length = index + 1
|
|
30
|
+
stack.push(snapshot)
|
|
31
|
+
index = stack.length - 1
|
|
32
|
+
},
|
|
33
|
+
// Function: replace
|
|
34
|
+
// Updates the current entry in place, so a later return restores the
|
|
35
|
+
// state as it was left (moved cursor, marks, filter).
|
|
36
|
+
replace(snapshot) {
|
|
37
|
+
stack[index] = snapshot
|
|
38
|
+
},
|
|
39
|
+
// Function: back
|
|
40
|
+
// Moves one entry back and returns its snapshot, or null at the start.
|
|
41
|
+
back() {
|
|
42
|
+
if (index <= 0) return null
|
|
43
|
+
index -= 1
|
|
44
|
+
return stack[index]
|
|
45
|
+
},
|
|
46
|
+
// Function: forward
|
|
47
|
+
// Moves one entry forward and returns its snapshot, or null at the end.
|
|
48
|
+
forward() {
|
|
49
|
+
if (index >= stack.length - 1) return null
|
|
50
|
+
index += 1
|
|
51
|
+
return stack[index]
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { createHistory }
|
|
57
|
+
|
|
58
|
+
// EOF
|