@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
package/src/js/tcmd/core/fs.js
CHANGED
|
@@ -11,12 +11,37 @@
|
|
|
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
|
|
22
47
|
//
|
|
@@ -30,27 +55,47 @@ import fs from "node:fs/promises"
|
|
|
30
55
|
import nodeFs from "node:fs"
|
|
31
56
|
import nodeOs from "node:os"
|
|
32
57
|
import nodePath from "node:path"
|
|
33
|
-
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"
|
|
34
61
|
|
|
35
62
|
// Owner-name caches, filled lazily from /etc/passwd and /etc/group. Browsers
|
|
36
63
|
// and non-POSIX hosts never populate them (the adapters there omit `owner`).
|
|
37
64
|
let passwdNames = null
|
|
38
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()
|
|
39
68
|
|
|
40
|
-
// Function:
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
async
|
|
44
|
-
|
|
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() {
|
|
45
74
|
if (process.platform !== "win32") {
|
|
46
75
|
passwdNames ??= await parseColonFile("/etc/passwd", 2)
|
|
47
76
|
groupNames ??= await parseColonFile("/etc/group", 2)
|
|
48
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 ""
|
|
49
85
|
const user = uid == null ? "" : passwdNames?.get(uid) ?? `${uid}`
|
|
50
86
|
const group = gid == null ? "" : groupNames?.get(gid) ?? `${gid}`
|
|
51
87
|
return user && group ? `${user}:${group}` : user || group
|
|
52
88
|
}
|
|
53
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
|
+
|
|
54
99
|
// Function: parseColonFile
|
|
55
100
|
// Reads a passwd/group-style file into a `{ id -> name }` map; unreadable
|
|
56
101
|
// files yield an empty map (names then fall back to the numeric id).
|
|
@@ -69,11 +114,38 @@ async function parseColonFile(path, nameColumn) {
|
|
|
69
114
|
return map
|
|
70
115
|
}
|
|
71
116
|
|
|
72
|
-
|
|
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) {
|
|
73
145
|
const stat = stats || {}
|
|
74
146
|
return {
|
|
75
147
|
name,
|
|
76
|
-
path:
|
|
148
|
+
path: prefix + name,
|
|
77
149
|
isDir,
|
|
78
150
|
isSymlink: Boolean(stat.isSymlink),
|
|
79
151
|
// The raw `readlink` text — relative when the link was written that
|
|
@@ -98,24 +170,41 @@ function isDeviceNode(node) {
|
|
|
98
170
|
)
|
|
99
171
|
}
|
|
100
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
|
+
|
|
101
185
|
// Function: createNodeFs
|
|
102
186
|
// Node/Bun-backed implementation of the filesystem adapter.
|
|
103
187
|
function createNodeFs() {
|
|
104
188
|
const adapter = {
|
|
105
189
|
async readDir(path) {
|
|
106
190
|
const dirents = await fs.readdir(path, { withFileTypes: true })
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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)
|
|
112
201
|
const isLink = d.isSymbolicLink()
|
|
113
202
|
// The dirent type describes the link itself, not what it
|
|
114
203
|
// points at: resolve through the followed stat so a link to a
|
|
115
204
|
// directory is listed (sorted, opened, previewed) as one. A
|
|
116
205
|
// broken link has no stats and stays a plain entry.
|
|
117
206
|
const isDir = d.isDirectory() || (isLink && Boolean(stats?.isDir))
|
|
118
|
-
|
|
207
|
+
const entry = toEntry(d.name, prefix, isDir, {
|
|
119
208
|
isSymlink: isLink,
|
|
120
209
|
target: isLink ? await readLinkSafe(full) : null,
|
|
121
210
|
isDevice: isDeviceNode(d),
|
|
@@ -126,18 +215,33 @@ function createNodeFs() {
|
|
|
126
215
|
uid: stats?.uid ?? null,
|
|
127
216
|
gid: stats?.gid ?? null,
|
|
128
217
|
})
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
218
|
+
entry.owner = ownerOf(stats?.uid, stats?.gid)
|
|
219
|
+
return entry
|
|
220
|
+
})
|
|
132
221
|
},
|
|
133
222
|
async stat(path) {
|
|
134
223
|
return statSafe(path)
|
|
135
224
|
},
|
|
225
|
+
realpath: (path) => fs.realpath(path),
|
|
136
226
|
async isDir(path) {
|
|
137
227
|
const stats = await statSafe(path)
|
|
138
228
|
return Boolean(stats?.isDir)
|
|
139
229
|
},
|
|
140
|
-
|
|
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) {
|
|
141
245
|
const file = nodeFs.openSync(path, "r")
|
|
142
246
|
try {
|
|
143
247
|
const buffer = Buffer.alloc(maxBytes)
|
|
@@ -147,6 +251,16 @@ function createNodeFs() {
|
|
|
147
251
|
nodeFs.closeSync(file)
|
|
148
252
|
}
|
|
149
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
|
+
},
|
|
150
264
|
async du(path) {
|
|
151
265
|
return duSafe(path)
|
|
152
266
|
},
|
|
@@ -159,35 +273,139 @@ function createNodeFs() {
|
|
|
159
273
|
}
|
|
160
274
|
},
|
|
161
275
|
async copy(from, to) {
|
|
162
|
-
|
|
276
|
+
return withPathLock(to, async () => {
|
|
277
|
+
await assertCopySafe(from, to)
|
|
278
|
+
await fs.cp(from, to, { recursive: true })
|
|
279
|
+
})
|
|
163
280
|
},
|
|
164
281
|
async move(from, to) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
await
|
|
170
|
-
await fs.
|
|
171
|
-
}
|
|
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 })
|
|
172
310
|
},
|
|
173
311
|
async trash(paths) {
|
|
174
312
|
const filesDir = trashDir("files")
|
|
175
313
|
const infoDir = trashDir("info")
|
|
176
314
|
await fs.mkdir(filesDir, { recursive: true })
|
|
177
315
|
await fs.mkdir(infoDir, { recursive: true })
|
|
316
|
+
const trashed = []
|
|
178
317
|
for (const source of paths) {
|
|
179
318
|
const absolute = nodePath.resolve(source)
|
|
180
|
-
const name = await
|
|
319
|
+
const { name, infoFile, handle } = await reserveTrashName(
|
|
320
|
+
filesDir,
|
|
321
|
+
infoDir,
|
|
322
|
+
nodePath.basename(absolute),
|
|
323
|
+
)
|
|
181
324
|
const target = nodePath.join(filesDir, name)
|
|
325
|
+
const info = `[Trash Info]\nPath=${encodeTrashPath(absolute)}\nDeletionDate=${new Date().toISOString()}\n`
|
|
182
326
|
try {
|
|
183
|
-
await
|
|
327
|
+
await handle.writeFile(info)
|
|
328
|
+
await handle.close()
|
|
184
329
|
} catch (error) {
|
|
185
|
-
|
|
186
|
-
await fs.
|
|
187
|
-
|
|
330
|
+
await handle.close().catch(() => {})
|
|
331
|
+
await fs.rm(infoFile, { force: true })
|
|
332
|
+
throw error
|
|
188
333
|
}
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
367
|
+
try {
|
|
368
|
+
stats = await fs.lstat(trashPath)
|
|
369
|
+
} catch (error) {
|
|
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)
|
|
399
|
+
}
|
|
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 })
|
|
191
409
|
}
|
|
192
410
|
},
|
|
193
411
|
async erase(paths) {
|
|
@@ -220,20 +438,151 @@ function trashDir(kind) {
|
|
|
220
438
|
return nodePath.join(dataHome, "Trash", kind)
|
|
221
439
|
}
|
|
222
440
|
|
|
223
|
-
// Function:
|
|
224
|
-
//
|
|
225
|
-
|
|
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) {
|
|
226
445
|
for (let i = 0; ; i++) {
|
|
227
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
|
|
228
451
|
try {
|
|
229
|
-
await fs.
|
|
452
|
+
handle = await fs.open(infoFile, "wx")
|
|
230
453
|
} catch (error) {
|
|
231
|
-
|
|
454
|
+
if (error?.code === "EEXIST") continue
|
|
455
|
+
throw error
|
|
232
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 }
|
|
233
463
|
}
|
|
234
464
|
}
|
|
235
465
|
|
|
236
|
-
|
|
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) {
|
|
237
586
|
try {
|
|
238
587
|
const stats = await fs.stat(path)
|
|
239
588
|
return {
|
|
@@ -246,13 +595,18 @@ async function statSafe(path) {
|
|
|
246
595
|
mode: stats.mode,
|
|
247
596
|
uid: stats.uid,
|
|
248
597
|
gid: stats.gid,
|
|
249
|
-
owner: await resolveOwner(stats.uid, stats.gid),
|
|
250
598
|
}
|
|
251
599
|
} catch (error) {
|
|
252
600
|
return null
|
|
253
601
|
}
|
|
254
602
|
}
|
|
255
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
|
+
|
|
256
610
|
// Function: readLinkSafe
|
|
257
611
|
// The raw `readlink` text of `path`, or null when it is not a link (or the
|
|
258
612
|
// link cannot be read). Relative targets stay relative, as written.
|
|
@@ -265,26 +619,29 @@ async function readLinkSafe(path) {
|
|
|
265
619
|
}
|
|
266
620
|
|
|
267
621
|
// Function: duSafe
|
|
268
|
-
// 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)
|
|
269
626
|
async function duSafe(path) {
|
|
270
|
-
const stats = await
|
|
627
|
+
const stats = await duLimit(() => statDetails(path))
|
|
271
628
|
if (!stats) return 0
|
|
272
629
|
if (!stats.isDir) return stats.size
|
|
273
|
-
let total = 0
|
|
274
630
|
let dirents
|
|
275
631
|
try {
|
|
276
|
-
dirents = await fs.readdir(path, { withFileTypes: true })
|
|
632
|
+
dirents = await duLimit(() => fs.readdir(path, { withFileTypes: true }))
|
|
277
633
|
} catch (error) {
|
|
278
634
|
return 0
|
|
279
635
|
}
|
|
280
|
-
|
|
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
|
|
281
639
|
const full = pathJoin(path, d.name)
|
|
282
|
-
if (d.isDirectory())
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
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
|
|
288
645
|
return total
|
|
289
646
|
}
|
|
290
647
|
|