@ssebastien/tcmd 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -58
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/OverlayList.xml +13 -0
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +612 -136
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/session.js +286 -116
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- package/tinyjs.json +16 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// License: BSD
|
|
3
|
+
// Module: tcmd/desktop/backend/fs
|
|
4
|
+
// txiki.js implementation of the filesystem adapter contract. Keep the
|
|
5
|
+
// runtime injectable so these operations can be tested under Bun without
|
|
6
|
+
// importing Node builtins into the shipped backend.
|
|
7
|
+
|
|
8
|
+
import { extname } from "../../core/path.js"
|
|
9
|
+
|
|
10
|
+
const PREVIEW_LIMIT = 1024 * 1024
|
|
11
|
+
const encoder = new TextEncoder()
|
|
12
|
+
const decoder = new TextDecoder()
|
|
13
|
+
|
|
14
|
+
function normalizePath(value, base = "/") {
|
|
15
|
+
if (typeof value !== "string" || !value || value.includes("\0")) {
|
|
16
|
+
throw new TypeError("path must be a non-empty string without NUL bytes")
|
|
17
|
+
}
|
|
18
|
+
const source = value.startsWith("/") ? value : `${base}/${value}`
|
|
19
|
+
const parts = []
|
|
20
|
+
for (const part of source.split("/")) {
|
|
21
|
+
if (!part || part === ".") continue
|
|
22
|
+
if (part === "..") parts.pop()
|
|
23
|
+
else parts.push(part)
|
|
24
|
+
}
|
|
25
|
+
return `/${parts.join("/")}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function joinPath(base, name) {
|
|
29
|
+
return `${base === "/" ? "" : base}/${name}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function basename(path) {
|
|
33
|
+
const parts = path.split("/").filter(Boolean)
|
|
34
|
+
return parts.at(-1) ?? "/"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dirname(path) {
|
|
38
|
+
const parts = path.split("/").filter(Boolean)
|
|
39
|
+
if (parts.length <= 1) return "/"
|
|
40
|
+
return `/${parts.slice(0, -1).join("/")}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isWithin(parent, child) {
|
|
44
|
+
return parent === child || (parent === "/" ? child.startsWith("/") : child.startsWith(`${parent}/`))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isDevice(stats) {
|
|
48
|
+
return Boolean(stats?.isBlockDevice || stats?.isCharacterDevice || stats?.isFIFO || stats?.isSocket)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function timeMs(value) {
|
|
52
|
+
if (value instanceof Date) return value.getTime()
|
|
53
|
+
const number = Number(value)
|
|
54
|
+
return Number.isFinite(number) ? number : 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function statEntry(stats, overrides = {}) {
|
|
58
|
+
return {
|
|
59
|
+
isDir: Boolean(stats?.isDirectory),
|
|
60
|
+
isSymlink: Boolean(overrides.isSymlink ?? stats?.isSymbolicLink),
|
|
61
|
+
isDevice: isDevice(stats),
|
|
62
|
+
size: Number(stats?.size ?? 0),
|
|
63
|
+
mtimeMs: timeMs(stats?.mtim ?? stats?.mtimeMs),
|
|
64
|
+
ctimeMs: timeMs(stats?.ctim ?? stats?.ctimeMs),
|
|
65
|
+
mode: Number(stats?.mode ?? 0),
|
|
66
|
+
uid: stats?.uid == null ? null : Number(stats.uid),
|
|
67
|
+
gid: stats?.gid == null ? null : Number(stats.gid),
|
|
68
|
+
owner: "",
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function missing(error) {
|
|
73
|
+
return error?.code === "ENOENT" || error?.code === "ENOTDIR"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pathExistsError(path) {
|
|
77
|
+
const error = new Error(`destination already exists: ${path}`)
|
|
78
|
+
error.code = "EEXIST"
|
|
79
|
+
return error
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseTrashInfo(text) {
|
|
83
|
+
const info = {}
|
|
84
|
+
for (const line of `${text ?? ""}`.split("\n")) {
|
|
85
|
+
const at = line.indexOf("=")
|
|
86
|
+
if (at <= 0) continue
|
|
87
|
+
const key = line.slice(0, at).trim()
|
|
88
|
+
const value = line.slice(at + 1).trim()
|
|
89
|
+
if (key === "Path") {
|
|
90
|
+
try {
|
|
91
|
+
info[key] = decodeURIComponent(value)
|
|
92
|
+
} catch {
|
|
93
|
+
info[key] = value
|
|
94
|
+
}
|
|
95
|
+
} else if (key === "DeletionDate") info[key] = value
|
|
96
|
+
}
|
|
97
|
+
return info
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function encodeTrashPath(path) {
|
|
101
|
+
return path.split("/").map((part) => encodeURIComponent(part)).join("/")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertNoNested(paths) {
|
|
105
|
+
for (let i = 0; i < paths.length; i++) {
|
|
106
|
+
for (let j = i + 1; j < paths.length; j++) {
|
|
107
|
+
if (isWithin(paths[i], paths[j]) || isWithin(paths[j], paths[i])) {
|
|
108
|
+
throw new Error(`overlapping paths: ${paths[i]} and ${paths[j]}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Function: parseColonIds
|
|
115
|
+
// Reads a passwd/group-style file into `{ id -> name }`; an unreadable file
|
|
116
|
+
// yields an empty map (numeric ids still work).
|
|
117
|
+
async function parseColonIds(runtime, path) {
|
|
118
|
+
const map = new Map()
|
|
119
|
+
try {
|
|
120
|
+
const text = decoder.decode(await runtime.readFile(path))
|
|
121
|
+
for (const line of text.split("\n")) {
|
|
122
|
+
const parts = line.split(":")
|
|
123
|
+
const id = Number(parts[2])
|
|
124
|
+
if (parts.length > 2 && Number.isFinite(id)) map.set(id, parts[0])
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// No passwd/group file: names cannot be resolved.
|
|
128
|
+
}
|
|
129
|
+
return map
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function idForName(names, value, kind) {
|
|
133
|
+
const text = `${value ?? ""}`.trim()
|
|
134
|
+
if (/^\d+$/.test(text)) return Number(text)
|
|
135
|
+
for (const [id, name] of names ?? []) if (name === text) return id
|
|
136
|
+
throw new Error(`chown: unknown ${kind}: ${text}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function resolveOwnerIds(runtime, owner) {
|
|
140
|
+
const text = `${owner ?? ""}`.trim()
|
|
141
|
+
if (!text) throw new Error("chown: OWNER must be USER[:GROUP]")
|
|
142
|
+
const [user, group] = text.split(":")
|
|
143
|
+
const users = user ? await parseColonIds(runtime, "/etc/passwd") : null
|
|
144
|
+
const groups = group ? await parseColonIds(runtime, "/etc/group") : null
|
|
145
|
+
return {
|
|
146
|
+
uid: user ? idForName(users, user, "user") : -1,
|
|
147
|
+
gid: group ? idForName(groups, group, "group") : -1,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createDesktopFs(options = {}) {
|
|
152
|
+
const runtime = options.runtime
|
|
153
|
+
if (!runtime) throw new TypeError("txiki.js runtime is required")
|
|
154
|
+
let { home = runtime.homeDir, cwd = runtime.cwd, env = runtime.env ?? {} } = options
|
|
155
|
+
if (env.OS === "Windows_NT") {
|
|
156
|
+
throw new Error("The tcmd desktop filesystem does not support Windows drive or UNC paths yet")
|
|
157
|
+
}
|
|
158
|
+
const root = "/"
|
|
159
|
+
home = normalizePath(home || "/")
|
|
160
|
+
cwd = normalizePath(cwd || home, home)
|
|
161
|
+
const dataHome = env.XDG_DATA_HOME?.startsWith("/")
|
|
162
|
+
? normalizePath(env.XDG_DATA_HOME)
|
|
163
|
+
: joinPath(home, ".local/share")
|
|
164
|
+
const trashRoot = joinPath(dataHome, "Trash")
|
|
165
|
+
const trashFiles = joinPath(trashRoot, "files")
|
|
166
|
+
const trashInfo = joinPath(trashRoot, "info")
|
|
167
|
+
const pathLocks = new Map()
|
|
168
|
+
|
|
169
|
+
const pathArg = (path) => normalizePath(path, cwd)
|
|
170
|
+
|
|
171
|
+
async function lstatOrNull(path) {
|
|
172
|
+
try {
|
|
173
|
+
return await runtime.lstat(path)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (missing(error)) return null
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function statSafe(path) {
|
|
181
|
+
try {
|
|
182
|
+
return statEntry(await runtime.stat(path))
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Function: readHead
|
|
189
|
+
// Leading `maxBytes` (bounded by `PREVIEW_LIMIT`) of a file, as raw bytes.
|
|
190
|
+
// `readTextHead` decodes it; `readBytes` hands it to the compressed-preview
|
|
191
|
+
// decompressor on the frontend.
|
|
192
|
+
async function readHead(pathValue, maxBytes = 65536) {
|
|
193
|
+
const path = pathArg(pathValue)
|
|
194
|
+
const requested = Number(maxBytes)
|
|
195
|
+
const length = Number.isFinite(requested)
|
|
196
|
+
? Math.max(0, Math.min(PREVIEW_LIMIT, Math.floor(requested)))
|
|
197
|
+
: 65536
|
|
198
|
+
if (!length) return new Uint8Array(0)
|
|
199
|
+
const handle = await runtime.open(path, "r")
|
|
200
|
+
try {
|
|
201
|
+
const buffer = new Uint8Array(length)
|
|
202
|
+
let count = 0
|
|
203
|
+
while (count < length) {
|
|
204
|
+
const read = await handle.read(buffer.subarray(count), count)
|
|
205
|
+
if (read == null || read === 0) break
|
|
206
|
+
count += read
|
|
207
|
+
}
|
|
208
|
+
return buffer.subarray(0, count)
|
|
209
|
+
} finally {
|
|
210
|
+
await handle.close()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function withPathLock(path, action) {
|
|
215
|
+
const key = pathArg(path)
|
|
216
|
+
const previous = pathLocks.get(key) ?? Promise.resolve()
|
|
217
|
+
let release
|
|
218
|
+
const gate = new Promise((resolve) => (release = resolve))
|
|
219
|
+
const tail = previous.then(() => gate)
|
|
220
|
+
pathLocks.set(key, tail)
|
|
221
|
+
await previous
|
|
222
|
+
try {
|
|
223
|
+
return await action()
|
|
224
|
+
} finally {
|
|
225
|
+
release()
|
|
226
|
+
if (pathLocks.get(key) === tail) pathLocks.delete(key)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function assertMutable(path) {
|
|
231
|
+
if (path === root) throw new Error("refusing to modify the filesystem root")
|
|
232
|
+
if (isWithin(trashRoot, path) || isWithin(path, trashRoot)) {
|
|
233
|
+
throw new Error("refusing to modify an internal trash path or its parent")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readDirNames(path) {
|
|
238
|
+
const handle = await runtime.readDir(path)
|
|
239
|
+
const names = []
|
|
240
|
+
try {
|
|
241
|
+
for await (const entry of handle) names.push(entry.name)
|
|
242
|
+
} finally {
|
|
243
|
+
await handle.close?.()
|
|
244
|
+
}
|
|
245
|
+
return names
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function physicalPath(path) {
|
|
249
|
+
try {
|
|
250
|
+
return await runtime.realPath(path)
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// Resolve the closest existing ancestor when the destination is new.
|
|
253
|
+
if (!missing(error) || path === root) throw error
|
|
254
|
+
return joinPath(await physicalPath(dirname(path)), basename(path))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function assertCopySafe(from, to) {
|
|
259
|
+
const source = await runtime.lstat(from)
|
|
260
|
+
if (source.isDirectory) {
|
|
261
|
+
if (isWithin(from, to) || isWithin(await runtime.realPath(from), await physicalPath(to))) {
|
|
262
|
+
throw new Error(`cannot copy a path into itself: ${to}`)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const destination = await lstatOrNull(to)
|
|
266
|
+
if (!destination) return
|
|
267
|
+
if (!source.isDirectory || !destination.isDirectory) throw pathExistsError(to)
|
|
268
|
+
for (const name of await readDirNames(from)) {
|
|
269
|
+
await assertCopySafe(joinPath(from, name), joinPath(to, name))
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function copyEntry(from, to) {
|
|
274
|
+
const source = await runtime.lstat(from)
|
|
275
|
+
const destination = await lstatOrNull(to)
|
|
276
|
+
if (source.isSymbolicLink) {
|
|
277
|
+
if (destination) throw pathExistsError(to)
|
|
278
|
+
const target = await runtime.readLink(from)
|
|
279
|
+
await runtime.symlink(target, to)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (source.isDirectory) {
|
|
283
|
+
if (destination && !destination.isDirectory) throw pathExistsError(to)
|
|
284
|
+
// Keep new directories private and owner-writable while populating,
|
|
285
|
+
// including when the source is read-only. Apply its exact mode last
|
|
286
|
+
// (mkdir's mode is filtered by umask). Merges retain destination modes.
|
|
287
|
+
if (!destination) await runtime.makeDir(to, { mode: 0o700 })
|
|
288
|
+
for (const name of await readDirNames(from)) {
|
|
289
|
+
await copyEntry(joinPath(from, name), joinPath(to, name))
|
|
290
|
+
}
|
|
291
|
+
if (!destination) await runtime.chmod(to, Number(source.mode) & 0o7777)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
if (!source.isFile) throw new Error(`cannot copy a special filesystem entry: ${from}`)
|
|
295
|
+
if (destination) throw pathExistsError(to)
|
|
296
|
+
await runtime.copyFile(from, to)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function copy(fromValue, toValue) {
|
|
300
|
+
const from = pathArg(fromValue)
|
|
301
|
+
const to = pathArg(toValue)
|
|
302
|
+
assertMutable(to)
|
|
303
|
+
return withPathLock(to, async () => {
|
|
304
|
+
await assertCopySafe(from, to)
|
|
305
|
+
await copyEntry(from, to)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function movePaths(fromValue, toValue, { internal = false } = {}) {
|
|
310
|
+
const from = pathArg(fromValue)
|
|
311
|
+
const to = pathArg(toValue)
|
|
312
|
+
if (!internal) {
|
|
313
|
+
assertMutable(from)
|
|
314
|
+
assertMutable(to)
|
|
315
|
+
}
|
|
316
|
+
if (from === to || isWithin(from, to)) throw new Error(`cannot move a path into itself: ${to}`)
|
|
317
|
+
return withPathLock(to, async () => {
|
|
318
|
+
const source = await lstatOrNull(from)
|
|
319
|
+
if (!source) throw Object.assign(new Error(`not found: ${from}`), { code: "ENOENT" })
|
|
320
|
+
if (await lstatOrNull(to)) throw pathExistsError(to)
|
|
321
|
+
try {
|
|
322
|
+
await runtime.rename(from, to)
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error?.code !== "EXDEV") throw error
|
|
325
|
+
await assertCopySafe(from, to)
|
|
326
|
+
await copyEntry(from, to)
|
|
327
|
+
try {
|
|
328
|
+
await runtime.remove(from)
|
|
329
|
+
} catch (removeError) {
|
|
330
|
+
throw new Error(`copied to ${to}, but could not remove the original ${from}: ${removeError.message}`, {
|
|
331
|
+
cause: removeError,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const move = (from, to) => movePaths(from, to)
|
|
339
|
+
|
|
340
|
+
async function reserveTrashName(base) {
|
|
341
|
+
for (let index = 0; ; index++) {
|
|
342
|
+
const name = index ? `${base}.${index}` : base
|
|
343
|
+
const file = joinPath(trashFiles, name)
|
|
344
|
+
if (await lstatOrNull(file)) continue
|
|
345
|
+
const infoFile = joinPath(trashInfo, `${name}.trashinfo`)
|
|
346
|
+
let handle
|
|
347
|
+
try {
|
|
348
|
+
handle = await runtime.open(infoFile, "wx")
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error?.code === "EEXIST") continue
|
|
351
|
+
throw error
|
|
352
|
+
}
|
|
353
|
+
if (await lstatOrNull(file)) {
|
|
354
|
+
await handle.close()
|
|
355
|
+
await runtime.remove(infoFile)
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
return { name, file, infoFile, handle }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function writeHandle(handle, text) {
|
|
363
|
+
const bytes = encoder.encode(text)
|
|
364
|
+
let offset = 0
|
|
365
|
+
while (offset < bytes.length) {
|
|
366
|
+
const written = await handle.write(bytes.subarray(offset), offset)
|
|
367
|
+
if (!written) throw new Error("could not write trash metadata")
|
|
368
|
+
offset += written
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const adapter = {
|
|
373
|
+
async readDir(pathValue) {
|
|
374
|
+
const path = pathArg(pathValue)
|
|
375
|
+
const handle = await runtime.readDir(path)
|
|
376
|
+
const entries = []
|
|
377
|
+
try {
|
|
378
|
+
for await (const dirent of handle) {
|
|
379
|
+
const full = joinPath(path, dirent.name)
|
|
380
|
+
let linkStats = null
|
|
381
|
+
try {
|
|
382
|
+
linkStats = await runtime.lstat(full)
|
|
383
|
+
} catch {
|
|
384
|
+
// Keep the directory entry visible if stat races with removal.
|
|
385
|
+
}
|
|
386
|
+
let followed = null
|
|
387
|
+
try {
|
|
388
|
+
followed = await runtime.stat(full)
|
|
389
|
+
} catch {
|
|
390
|
+
// Broken symlinks remain visible as non-directories.
|
|
391
|
+
}
|
|
392
|
+
const symlink = Boolean(linkStats?.isSymbolicLink ?? dirent.isSymbolicLink)
|
|
393
|
+
let target = null
|
|
394
|
+
if (symlink) {
|
|
395
|
+
try {
|
|
396
|
+
target = await runtime.readLink(full)
|
|
397
|
+
} catch {
|
|
398
|
+
// Link target text is supplemental metadata.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const stat = statEntry(followed ?? linkStats, { isSymlink: symlink })
|
|
402
|
+
const isDir = Boolean(dirent.isDirectory || (symlink && followed?.isDirectory))
|
|
403
|
+
entries.push({
|
|
404
|
+
name: dirent.name,
|
|
405
|
+
path: full,
|
|
406
|
+
isDir,
|
|
407
|
+
isSymlink: symlink,
|
|
408
|
+
target,
|
|
409
|
+
isDevice: isDevice(linkStats ?? dirent),
|
|
410
|
+
size: isDir ? 0 : Number(followed?.size ?? (symlink ? 0 : stat.size)),
|
|
411
|
+
mtimeMs: stat.mtimeMs,
|
|
412
|
+
ctimeMs: stat.ctimeMs,
|
|
413
|
+
mode: stat.mode,
|
|
414
|
+
uid: stat.uid,
|
|
415
|
+
gid: stat.gid,
|
|
416
|
+
owner: "",
|
|
417
|
+
ext: isDir ? null : extname(dirent.name),
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
} finally {
|
|
421
|
+
await handle.close?.()
|
|
422
|
+
}
|
|
423
|
+
return entries
|
|
424
|
+
},
|
|
425
|
+
async stat(pathValue) {
|
|
426
|
+
return statSafe(pathArg(pathValue))
|
|
427
|
+
},
|
|
428
|
+
realpath: (pathValue) => runtime.realPath(pathArg(pathValue)),
|
|
429
|
+
async isDir(pathValue) {
|
|
430
|
+
return Boolean((await adapter.stat(pathValue))?.isDir)
|
|
431
|
+
},
|
|
432
|
+
async readTextHead(pathValue, maxBytes = 65536) {
|
|
433
|
+
return decoder.decode(await readHead(pathValue, maxBytes))
|
|
434
|
+
},
|
|
435
|
+
readBytes: (pathValue, maxBytes = 65536) => readHead(pathValue, maxBytes),
|
|
436
|
+
async du(pathValue) {
|
|
437
|
+
const path = pathArg(pathValue)
|
|
438
|
+
const rootStat = await statSafe(path)
|
|
439
|
+
if (!rootStat) return 0
|
|
440
|
+
if (!rootStat.isDir) return rootStat.size
|
|
441
|
+
let total = 0
|
|
442
|
+
const visit = async (directory) => {
|
|
443
|
+
for (const entry of await adapter.readDir(directory)) {
|
|
444
|
+
if (entry.isSymlink || entry.isDevice) continue
|
|
445
|
+
if (entry.isDir) await visit(entry.path)
|
|
446
|
+
else total += entry.size
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
await visit(path)
|
|
450
|
+
return total
|
|
451
|
+
},
|
|
452
|
+
async disk(pathValue) {
|
|
453
|
+
try {
|
|
454
|
+
const stats = await runtime.statFs(pathArg(pathValue))
|
|
455
|
+
return {
|
|
456
|
+
free: Number(stats.bfree * stats.bsize),
|
|
457
|
+
total: Number(stats.blocks * stats.bsize),
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
return null
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
copy,
|
|
464
|
+
move,
|
|
465
|
+
async hardlink(fromValue, toValue) {
|
|
466
|
+
const from = pathArg(fromValue)
|
|
467
|
+
const to = pathArg(toValue)
|
|
468
|
+
assertMutable(to)
|
|
469
|
+
if (typeof runtime.link !== "function") throw new Error("hard links are not available in this runtime")
|
|
470
|
+
await withPathLock(to, async () => {
|
|
471
|
+
if (await lstatOrNull(to)) throw pathExistsError(to)
|
|
472
|
+
await runtime.link(from, to)
|
|
473
|
+
})
|
|
474
|
+
},
|
|
475
|
+
async symlink(targetValue, linkValue) {
|
|
476
|
+
const target = pathArg(targetValue)
|
|
477
|
+
const link = pathArg(linkValue)
|
|
478
|
+
assertMutable(link)
|
|
479
|
+
await withPathLock(link, async () => {
|
|
480
|
+
if (await lstatOrNull(link)) throw pathExistsError(link)
|
|
481
|
+
await runtime.symlink(target, link)
|
|
482
|
+
})
|
|
483
|
+
},
|
|
484
|
+
async chmod(pathValue, mode) {
|
|
485
|
+
const path = pathArg(pathValue)
|
|
486
|
+
assertMutable(path)
|
|
487
|
+
await runtime.chmod(path, mode)
|
|
488
|
+
},
|
|
489
|
+
async chown(pathValue, owner) {
|
|
490
|
+
const path = pathArg(pathValue)
|
|
491
|
+
assertMutable(path)
|
|
492
|
+
if (typeof runtime.chown !== "function") throw new Error("chown is not available in this runtime")
|
|
493
|
+
const { uid, gid } = await resolveOwnerIds(runtime, owner)
|
|
494
|
+
await runtime.chown(path, uid, gid)
|
|
495
|
+
},
|
|
496
|
+
async unlink(pathValue) {
|
|
497
|
+
const path = pathArg(pathValue)
|
|
498
|
+
assertMutable(path)
|
|
499
|
+
const stats = await lstatOrNull(path)
|
|
500
|
+
if (!stats?.isSymbolicLink) throw new Error(`not a symbolic link: ${path}`)
|
|
501
|
+
await runtime.remove(path)
|
|
502
|
+
},
|
|
503
|
+
async mkdir(pathValue) {
|
|
504
|
+
const path = pathArg(pathValue)
|
|
505
|
+
assertMutable(path)
|
|
506
|
+
await runtime.makeDir(path, { recursive: true })
|
|
507
|
+
},
|
|
508
|
+
async trash(paths) {
|
|
509
|
+
const sources = (paths ?? []).map(pathArg)
|
|
510
|
+
assertNoNested(sources)
|
|
511
|
+
for (const path of sources) {
|
|
512
|
+
assertMutable(path)
|
|
513
|
+
if (!(await lstatOrNull(path))) throw Object.assign(new Error(`not found: ${path}`), { code: "ENOENT" })
|
|
514
|
+
}
|
|
515
|
+
await runtime.makeDir(trashFiles, { recursive: true })
|
|
516
|
+
await runtime.makeDir(trashInfo, { recursive: true })
|
|
517
|
+
const trashed = []
|
|
518
|
+
for (const from of sources) {
|
|
519
|
+
const { name, file, infoFile, handle } = await reserveTrashName(basename(from))
|
|
520
|
+
try {
|
|
521
|
+
await writeHandle(
|
|
522
|
+
handle,
|
|
523
|
+
`[Trash Info]\nPath=${encodeTrashPath(from)}\nDeletionDate=${new Date().toISOString()}\n`,
|
|
524
|
+
)
|
|
525
|
+
} catch (error) {
|
|
526
|
+
await runtime.remove(infoFile).catch(() => {})
|
|
527
|
+
throw error
|
|
528
|
+
} finally {
|
|
529
|
+
await handle.close()
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await movePaths(from, file, { internal: true })
|
|
533
|
+
} catch (error) {
|
|
534
|
+
await runtime.remove(infoFile).catch(() => {})
|
|
535
|
+
throw error
|
|
536
|
+
}
|
|
537
|
+
trashed.push({ from, to: file, name })
|
|
538
|
+
}
|
|
539
|
+
return trashed
|
|
540
|
+
},
|
|
541
|
+
async trashList() {
|
|
542
|
+
let names
|
|
543
|
+
try {
|
|
544
|
+
names = await readDirNames(trashInfo)
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (missing(error)) return []
|
|
547
|
+
throw error
|
|
548
|
+
}
|
|
549
|
+
const rows = []
|
|
550
|
+
for (const filename of names) {
|
|
551
|
+
if (!filename.endsWith(".trashinfo")) continue
|
|
552
|
+
const name = filename.slice(0, -".trashinfo".length)
|
|
553
|
+
const trashPath = joinPath(trashFiles, name)
|
|
554
|
+
let info = {}
|
|
555
|
+
try {
|
|
556
|
+
info = parseTrashInfo(decoder.decode(await runtime.readFile(joinPath(trashInfo, filename))))
|
|
557
|
+
} catch {
|
|
558
|
+
// Show the item even if its metadata has gone missing.
|
|
559
|
+
}
|
|
560
|
+
const stats = await lstatOrNull(trashPath)
|
|
561
|
+
if (!stats) continue
|
|
562
|
+
rows.push({
|
|
563
|
+
name,
|
|
564
|
+
originalPath: info.Path ?? null,
|
|
565
|
+
deletedAt: info.DeletionDate ?? null,
|
|
566
|
+
trashPath,
|
|
567
|
+
isDir: Boolean(stats.isDirectory),
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
return rows.sort((a, b) => `${b.deletedAt ?? ""}`.localeCompare(`${a.deletedAt ?? ""}`))
|
|
571
|
+
},
|
|
572
|
+
async restore(paths, { validateDestination } = {}) {
|
|
573
|
+
const restores = []
|
|
574
|
+
const destinations = new Set()
|
|
575
|
+
for (const value of paths ?? []) {
|
|
576
|
+
const trashPath = pathArg(value)
|
|
577
|
+
if (dirname(trashPath) !== trashFiles) throw new Error(`not a trash entry: ${value}`)
|
|
578
|
+
const name = basename(trashPath)
|
|
579
|
+
const infoFile = joinPath(trashInfo, `${name}.trashinfo`)
|
|
580
|
+
const info = parseTrashInfo(decoder.decode(await runtime.readFile(infoFile)))
|
|
581
|
+
if (!info.Path?.startsWith("/")) throw new Error(`no valid original path recorded for ${name}`)
|
|
582
|
+
const original = pathArg(info.Path)
|
|
583
|
+
assertMutable(original)
|
|
584
|
+
const destination = validateDestination ? pathArg(await validateDestination(original)) : original
|
|
585
|
+
if (destinations.has(destination) || (await lstatOrNull(destination))) {
|
|
586
|
+
throw pathExistsError(destination)
|
|
587
|
+
}
|
|
588
|
+
destinations.add(destination)
|
|
589
|
+
restores.push({ trashPath, infoFile, destination })
|
|
590
|
+
}
|
|
591
|
+
assertNoNested(restores.map((restore) => restore.destination))
|
|
592
|
+
for (const { trashPath, infoFile, destination } of restores) {
|
|
593
|
+
if (await lstatOrNull(destination)) throw pathExistsError(destination)
|
|
594
|
+
await runtime.makeDir(dirname(destination), { recursive: true })
|
|
595
|
+
await movePaths(trashPath, destination, { internal: true })
|
|
596
|
+
await runtime.remove(infoFile)
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
async erase(paths) {
|
|
600
|
+
const targets = (paths ?? []).map(pathArg)
|
|
601
|
+
assertNoNested(targets)
|
|
602
|
+
for (const path of targets) assertMutable(path)
|
|
603
|
+
for (const path of targets) await runtime.remove(path)
|
|
604
|
+
},
|
|
605
|
+
async touch(pathValue) {
|
|
606
|
+
const path = pathArg(pathValue)
|
|
607
|
+
assertMutable(path)
|
|
608
|
+
const now = new Date()
|
|
609
|
+
try {
|
|
610
|
+
await runtime.utime(path, now, now)
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (!missing(error)) throw error
|
|
613
|
+
const handle = await runtime.open(path, "a")
|
|
614
|
+
await handle.close()
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
home: () => home,
|
|
618
|
+
cwd: () => cwd,
|
|
619
|
+
root: () => root,
|
|
620
|
+
}
|
|
621
|
+
return adapter
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export { createDesktopFs, dirname, isWithin, normalizePath }
|
|
625
|
+
|
|
626
|
+
// EOF
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// tinyjs backend entry point. The app bridge is the only path from the webview
|
|
2
|
+
// to filesystem and process access.
|
|
3
|
+
|
|
4
|
+
import { createDesktopBackend } from "./api.js"
|
|
5
|
+
|
|
6
|
+
const desktop = createDesktopBackend({ runtime: tjs })
|
|
7
|
+
|
|
8
|
+
export const api = desktop.api
|
|
9
|
+
export const onOpenFiles = desktop.onOpenFiles
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// License: BSD
|
|
3
|
+
// Module: tcmd/desktop/bookmarks
|
|
4
|
+
// Persist the shared bookmark model in tinyjs's per-application settings store
|
|
5
|
+
// rather than browser localStorage or the TUI's config file.
|
|
6
|
+
|
|
7
|
+
function createDesktopBookmarkStore({ store, key = "tcmd.bookmarks.v1" } = {}) {
|
|
8
|
+
if (!store?.get || !store?.set) throw new TypeError("tinyjs store API is required")
|
|
9
|
+
return {
|
|
10
|
+
async load() {
|
|
11
|
+
const value = await store.get(key)
|
|
12
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
|
|
13
|
+
},
|
|
14
|
+
async save(map) {
|
|
15
|
+
const saved = await store.set(key, map)
|
|
16
|
+
if (saved === false) throw new Error("Could not save bookmarks to application storage")
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { createDesktopBookmarkStore }
|
|
22
|
+
|
|
23
|
+
// EOF
|