dsh-plugin-workbench 0.0.4 → 0.0.6
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/CHANGELOG.md +65 -2
- package/README.md +29 -7
- package/lib/client.js +6770 -117
- package/lib/client.js.map +1 -1
- package/lib/index.js +467 -7
- package/package.json +2 -1
- package/src/client/FileExplorer.tsx +740 -13
- package/src/client/FilePreview.tsx +279 -17
- package/src/client/files.module.css +389 -3
- package/src/client/highlight.ts +18 -1
- package/src/client/index.ts +27 -4
- package/src/client/locales.ts +72 -0
- package/src/client/markdown.ts +69 -0
- package/src/client/store.ts +157 -2
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +601 -10
package/lib/index.js
CHANGED
|
@@ -1,10 +1,51 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import { basename, dirname } from "node:path";
|
|
4
|
+
import { cp, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
1
5
|
//#region src/index.ts
|
|
2
6
|
const name = "dsh-plugin-workbench";
|
|
3
|
-
const inject = [
|
|
7
|
+
const inject = [
|
|
8
|
+
"fs",
|
|
9
|
+
"connection",
|
|
10
|
+
"webServer"
|
|
11
|
+
];
|
|
4
12
|
/** Loopback-only logical RPC channel. */
|
|
5
13
|
const CHANNEL = "/dsh-plugin-files";
|
|
6
14
|
/** Files larger than this are never read for preview (client shows size + hint). */
|
|
7
15
|
const MAX_PREVIEW_BYTES = 524288;
|
|
16
|
+
/** Same-origin route serving raw bytes for image files (see module doc). */
|
|
17
|
+
const RAW_PREFIX = "/dsh-plugin-files/raw";
|
|
18
|
+
/** Same-origin SSE route streaming disk-change events to the preview pane. */
|
|
19
|
+
const EVENTS_PREFIX = "/dsh-plugin-files/events";
|
|
20
|
+
/** Images larger than this are never served to the preview (browser shows a hint). */
|
|
21
|
+
const MAX_IMAGE_BYTES = 20971520;
|
|
22
|
+
/** Coalesce bursty editor writes into a single change notification. */
|
|
23
|
+
const WATCH_DEBOUNCE_MS = 300;
|
|
24
|
+
/** Ignore fs.watch events caused by this plugin's own saves (see module doc). */
|
|
25
|
+
const SELF_WRITE_WINDOW_MS = 1500;
|
|
26
|
+
/** SSE keep-alive interval (proxies may otherwise drop idle connections). */
|
|
27
|
+
const SSE_HEARTBEAT_MS = 15e3;
|
|
28
|
+
const IMAGE_MIME = {
|
|
29
|
+
png: "image/png",
|
|
30
|
+
jpg: "image/jpeg",
|
|
31
|
+
jpeg: "image/jpeg",
|
|
32
|
+
gif: "image/gif",
|
|
33
|
+
webp: "image/webp",
|
|
34
|
+
avif: "image/avif",
|
|
35
|
+
bmp: "image/bmp",
|
|
36
|
+
ico: "image/x-icon",
|
|
37
|
+
svg: "image/svg+xml"
|
|
38
|
+
};
|
|
39
|
+
/** MIME type for a path's extension when it names a previewable image; undefined otherwise. */
|
|
40
|
+
function imageMimeOf(path) {
|
|
41
|
+
const idx = path.lastIndexOf(".");
|
|
42
|
+
if (idx < 0 || idx === path.length - 1) return void 0;
|
|
43
|
+
return IMAGE_MIME[path.slice(idx + 1).toLowerCase()];
|
|
44
|
+
}
|
|
45
|
+
/** True when the path names a previewable image file. */
|
|
46
|
+
function isImagePath(path) {
|
|
47
|
+
return imageMimeOf(path) !== void 0;
|
|
48
|
+
}
|
|
8
49
|
/** Map an fs entry type onto the wire `kind` union. */
|
|
9
50
|
function kindOf(type) {
|
|
10
51
|
if (type === "directory") return "dir";
|
|
@@ -42,7 +83,15 @@ const FS_ERROR_MESSAGES = {
|
|
|
42
83
|
FS_PERMISSION_DENIED: "permission denied",
|
|
43
84
|
FS_SANDBOX_DENIED: "sandbox denied",
|
|
44
85
|
FS_ABORTED: "aborted",
|
|
45
|
-
FS_IO_ERROR: "io error"
|
|
86
|
+
FS_IO_ERROR: "io error",
|
|
87
|
+
EEXIST: "file or folder already exists",
|
|
88
|
+
ENOENT: "path does not exist",
|
|
89
|
+
ENOTEMPTY: "folder is not empty",
|
|
90
|
+
EPERM: "permission denied",
|
|
91
|
+
EACCES: "permission denied",
|
|
92
|
+
ENOTDIR: "not a directory",
|
|
93
|
+
EISDIR: "is a directory",
|
|
94
|
+
EBUSY: "file is in use"
|
|
46
95
|
};
|
|
47
96
|
/** Human-readable message for a thrown value, honoring the fs error code taxonomy. */
|
|
48
97
|
function mapError(error) {
|
|
@@ -77,13 +126,232 @@ function pathOf(payload) {
|
|
|
77
126
|
* cancels the underlying fs call (or aborts between steps).
|
|
78
127
|
*/
|
|
79
128
|
function apply(ctx) {
|
|
129
|
+
const watchState = {
|
|
130
|
+
dirs: /* @__PURE__ */ new Map(),
|
|
131
|
+
files: /* @__PURE__ */ new Map(),
|
|
132
|
+
selfWrites: /* @__PURE__ */ new Map(),
|
|
133
|
+
clients: /* @__PURE__ */ new Set()
|
|
134
|
+
};
|
|
80
135
|
const handler = async (endpoint, payload, signal) => {
|
|
81
136
|
if (endpoint === "list") return listDir(ctx, payload, signal);
|
|
82
137
|
if (endpoint === "read") return readFile(ctx, payload, signal);
|
|
83
|
-
if (endpoint === "write") return writeFile(ctx, payload, signal);
|
|
138
|
+
if (endpoint === "write") return writeFile$1(ctx, watchState, payload, signal);
|
|
139
|
+
if (endpoint === "watch") return setWatch(ctx, watchState, payload, signal);
|
|
140
|
+
if (endpoint === "createFile") return createFile(ctx, payload, signal);
|
|
141
|
+
if (endpoint === "createDir") return createDir(ctx, payload, signal);
|
|
142
|
+
if (endpoint === "rename") return renameEntry(ctx, payload, signal);
|
|
143
|
+
if (endpoint === "delete") return deleteEntry(ctx, payload, signal);
|
|
144
|
+
if (endpoint === "copy") return copyEntry(ctx, payload, signal);
|
|
145
|
+
if (endpoint === "reveal") return revealInExplorer(ctx, payload, signal);
|
|
84
146
|
return fail(`unknown endpoint: ${endpoint}`);
|
|
85
147
|
};
|
|
86
|
-
ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" });
|
|
148
|
+
ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" }), "dsh-plugin-workbench: files rpc channel");
|
|
149
|
+
ctx.effect(() => ctx.webServer.register({
|
|
150
|
+
kind: "prefix",
|
|
151
|
+
path: RAW_PREFIX,
|
|
152
|
+
handler: (req, res) => {
|
|
153
|
+
serveRaw(ctx, req, res);
|
|
154
|
+
}
|
|
155
|
+
}), "dsh-plugin-workbench: raw image route");
|
|
156
|
+
ctx.effect(() => {
|
|
157
|
+
const disposeRoute = ctx.webServer.register({
|
|
158
|
+
kind: "exact",
|
|
159
|
+
path: EVENTS_PREFIX,
|
|
160
|
+
handler: (req, res) => sseHandler(watchState, req, res)
|
|
161
|
+
});
|
|
162
|
+
const heartbeat = setInterval(() => {
|
|
163
|
+
for (const res of watchState.clients) try {
|
|
164
|
+
res.write(": ping\n\n");
|
|
165
|
+
} catch {}
|
|
166
|
+
}, SSE_HEARTBEAT_MS);
|
|
167
|
+
return () => {
|
|
168
|
+
clearInterval(heartbeat);
|
|
169
|
+
disposeRoute();
|
|
170
|
+
disposeWatch(watchState);
|
|
171
|
+
};
|
|
172
|
+
}, "dsh-plugin-workbench: disk change stream (SSE)");
|
|
173
|
+
}
|
|
174
|
+
/** Serve one SSE client connection (kept open until the browser disconnects). */
|
|
175
|
+
function sseHandler(state, req, res) {
|
|
176
|
+
if ((req.method ?? "GET").toUpperCase() !== "GET") {
|
|
177
|
+
res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
178
|
+
res.end("method not allowed");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
res.writeHead(200, {
|
|
182
|
+
"content-type": "text/event-stream",
|
|
183
|
+
"cache-control": "no-cache",
|
|
184
|
+
connection: "keep-alive",
|
|
185
|
+
"x-accel-buffering": "no"
|
|
186
|
+
});
|
|
187
|
+
res.write(": connected\n\n");
|
|
188
|
+
state.clients.add(res);
|
|
189
|
+
req.on("close", () => {
|
|
190
|
+
state.clients.delete(res);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Push one `change` frame to every connected SSE client. */
|
|
194
|
+
function emitChange(state, path) {
|
|
195
|
+
const frame = `event: change\ndata: ${JSON.stringify({ path })}\n\n`;
|
|
196
|
+
for (const res of state.clients) try {
|
|
197
|
+
res.write(frame);
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Coalesce one filesystem event for an osPath into a single change
|
|
202
|
+
* notification (editors emit several events per save; the debounce collapses
|
|
203
|
+
* them). Events caused by this plugin's own saves are suppressed.
|
|
204
|
+
*/
|
|
205
|
+
function scheduleEmit(state, osPath) {
|
|
206
|
+
const entry = state.files.get(osPath);
|
|
207
|
+
if (entry === void 0) return;
|
|
208
|
+
const selfTs = state.selfWrites.get(osPath);
|
|
209
|
+
if (selfTs !== void 0 && Date.now() - selfTs < SELF_WRITE_WINDOW_MS) return;
|
|
210
|
+
if (entry.timer !== void 0) clearTimeout(entry.timer);
|
|
211
|
+
entry.timer = setTimeout(() => {
|
|
212
|
+
entry.timer = void 0;
|
|
213
|
+
const still = state.files.get(osPath);
|
|
214
|
+
if (still === void 0) return;
|
|
215
|
+
emitChange(state, still.path);
|
|
216
|
+
}, WATCH_DEBOUNCE_MS);
|
|
217
|
+
}
|
|
218
|
+
/** Start (or extend) the parent-dir watcher covering osPath. */
|
|
219
|
+
function watchDir(state, dir, osPath) {
|
|
220
|
+
let bucket = state.dirs.get(dir);
|
|
221
|
+
if (bucket === void 0) {
|
|
222
|
+
let watcher;
|
|
223
|
+
try {
|
|
224
|
+
watcher = watch(dir, { persistent: false }, (_eventType, filename) => {
|
|
225
|
+
const name = typeof filename === "string" ? filename : void 0;
|
|
226
|
+
if (name === void 0) {
|
|
227
|
+
for (const os of state.files.keys()) if (dirname(os) === dir) scheduleEmit(state, os);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const targets = state.dirs.get(dir)?.basenames.get(name);
|
|
231
|
+
if (targets === void 0) return;
|
|
232
|
+
for (const os of targets) scheduleEmit(state, os);
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
bucket = {
|
|
238
|
+
watcher,
|
|
239
|
+
basenames: /* @__PURE__ */ new Map()
|
|
240
|
+
};
|
|
241
|
+
state.dirs.set(dir, bucket);
|
|
242
|
+
}
|
|
243
|
+
const name = basename(osPath);
|
|
244
|
+
let targets = bucket.basenames.get(name);
|
|
245
|
+
if (targets === void 0) {
|
|
246
|
+
targets = /* @__PURE__ */ new Set();
|
|
247
|
+
bucket.basenames.set(name, targets);
|
|
248
|
+
}
|
|
249
|
+
targets.add(osPath);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
/** Stop watching one osPath (and its parent dir when nothing else uses it). */
|
|
253
|
+
function unwatch(state, osPath) {
|
|
254
|
+
const entry = state.files.get(osPath);
|
|
255
|
+
if (entry === void 0) return;
|
|
256
|
+
if (entry.timer !== void 0) clearTimeout(entry.timer);
|
|
257
|
+
state.files.delete(osPath);
|
|
258
|
+
state.selfWrites.delete(osPath);
|
|
259
|
+
const dir = dirname(osPath);
|
|
260
|
+
const bucket = state.dirs.get(dir);
|
|
261
|
+
if (bucket === void 0) return;
|
|
262
|
+
const name = basename(osPath);
|
|
263
|
+
const targets = bucket.basenames.get(name);
|
|
264
|
+
if (targets !== void 0) {
|
|
265
|
+
targets.delete(osPath);
|
|
266
|
+
if (targets.size === 0) bucket.basenames.delete(name);
|
|
267
|
+
}
|
|
268
|
+
if (bucket.basenames.size === 0) {
|
|
269
|
+
bucket.watcher.close();
|
|
270
|
+
state.dirs.delete(dir);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Close every watcher, pending timer and SSE client (effect disposal). */
|
|
274
|
+
function disposeWatch(state) {
|
|
275
|
+
for (const bucket of state.dirs.values()) bucket.watcher.close();
|
|
276
|
+
state.dirs.clear();
|
|
277
|
+
for (const entry of state.files.values()) if (entry.timer !== void 0) clearTimeout(entry.timer);
|
|
278
|
+
state.files.clear();
|
|
279
|
+
state.selfWrites.clear();
|
|
280
|
+
for (const res of state.clients) try {
|
|
281
|
+
res.end();
|
|
282
|
+
} catch {}
|
|
283
|
+
state.clients.clear();
|
|
284
|
+
}
|
|
285
|
+
/** Reconcile the watcher set with the client's open tab paths (idempotent). */
|
|
286
|
+
async function setWatch(ctx, state, payload, signal) {
|
|
287
|
+
const raw = typeof payload === "object" && payload !== null ? payload.paths : void 0;
|
|
288
|
+
if (!Array.isArray(raw) || raw.some((p) => typeof p !== "string")) return fail("watch: payload.paths must be an array of strings");
|
|
289
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
290
|
+
for (const p of raw) {
|
|
291
|
+
if (signal.aborted) break;
|
|
292
|
+
try {
|
|
293
|
+
const target = await ctx.fs.resolve(p, { signal });
|
|
294
|
+
const osPath = ctx.fs.processPath(target);
|
|
295
|
+
wanted.set(osPath, p);
|
|
296
|
+
} catch {}
|
|
297
|
+
}
|
|
298
|
+
for (const osPath of [...state.files.keys()]) if (!wanted.has(osPath)) unwatch(state, osPath);
|
|
299
|
+
for (const [osPath, displayPath] of wanted) {
|
|
300
|
+
if (state.files.has(osPath)) continue;
|
|
301
|
+
if (!watchDir(state, dirname(osPath), osPath)) continue;
|
|
302
|
+
state.files.set(osPath, {
|
|
303
|
+
path: displayPath,
|
|
304
|
+
timer: void 0
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
ok: true,
|
|
309
|
+
value: { path: "" }
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async function serveRaw(ctx, req, res) {
|
|
313
|
+
const text = (code, body) => {
|
|
314
|
+
res.writeHead(code, { "content-type": "text/plain; charset=utf-8" });
|
|
315
|
+
res.end(body);
|
|
316
|
+
};
|
|
317
|
+
try {
|
|
318
|
+
if ((req.method ?? "GET").toUpperCase() !== "GET") {
|
|
319
|
+
text(405, "method not allowed");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const rest = new URL(req.url ?? "/", "http://dsh.internal").pathname.slice(21).replace(/^\/+/, "");
|
|
323
|
+
if (rest.length === 0) {
|
|
324
|
+
text(404, "not found");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const path = decodeURIComponent(rest);
|
|
328
|
+
const mime = imageMimeOf(path);
|
|
329
|
+
if (mime === void 0) {
|
|
330
|
+
text(404, "not an image");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const target = await ctx.fs.resolve(path);
|
|
334
|
+
const info = await ctx.fs.stat(target);
|
|
335
|
+
if (info === void 0 || info.type !== "file") {
|
|
336
|
+
text(404, "not found");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if ((info.size ?? 0) > 20971520) {
|
|
340
|
+
text(413, "image too large");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const bytes = await ctx.fs.readBytes(target, void 0, MAX_IMAGE_BYTES);
|
|
344
|
+
res.writeHead(200, {
|
|
345
|
+
"content-type": mime,
|
|
346
|
+
"content-length": bytes.byteLength,
|
|
347
|
+
"cache-control": "private, max-age=300",
|
|
348
|
+
"x-content-type-options": "nosniff"
|
|
349
|
+
});
|
|
350
|
+
res.end(Buffer.from(bytes));
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (!res.headersSent) text(500, "internal error");
|
|
353
|
+
else res.destroy();
|
|
354
|
+
}
|
|
87
355
|
}
|
|
88
356
|
async function listDir(ctx, payload, signal) {
|
|
89
357
|
const path = pathOf(payload);
|
|
@@ -153,7 +421,7 @@ async function readFile(ctx, payload, signal) {
|
|
|
153
421
|
return fail(mapError(error));
|
|
154
422
|
}
|
|
155
423
|
}
|
|
156
|
-
async function writeFile(ctx, payload, signal) {
|
|
424
|
+
async function writeFile$1(ctx, state, payload, signal) {
|
|
157
425
|
const path = pathOf(payload);
|
|
158
426
|
const content = typeof payload === "object" && payload !== null ? payload.content : void 0;
|
|
159
427
|
if (path === void 0) return fail("write: payload.path must be a non-empty string");
|
|
@@ -164,10 +432,16 @@ async function writeFile(ctx, payload, signal) {
|
|
|
164
432
|
mode: "danger-full-access",
|
|
165
433
|
workspaceRoot: ctx.fs.processPath(target)
|
|
166
434
|
});
|
|
435
|
+
const osPath = ctx.fs.processPath(target);
|
|
436
|
+
state.selfWrites.set(osPath, Date.now());
|
|
437
|
+
if (state.selfWrites.size > 64) {
|
|
438
|
+
const cutoff = Date.now() - SELF_WRITE_WINDOW_MS * 4;
|
|
439
|
+
for (const [os, ts] of state.selfWrites) if (ts < cutoff) state.selfWrites.delete(os);
|
|
440
|
+
}
|
|
167
441
|
return {
|
|
168
442
|
ok: true,
|
|
169
443
|
value: {
|
|
170
|
-
path:
|
|
444
|
+
path: osPath,
|
|
171
445
|
size: content.length
|
|
172
446
|
}
|
|
173
447
|
};
|
|
@@ -175,5 +449,191 @@ async function writeFile(ctx, payload, signal) {
|
|
|
175
449
|
return fail(mapError(error));
|
|
176
450
|
}
|
|
177
451
|
}
|
|
452
|
+
async function createFile(ctx, payload, signal) {
|
|
453
|
+
const path = pathOf(payload);
|
|
454
|
+
if (path === void 0) return fail("createFile: payload.path must be a non-empty string");
|
|
455
|
+
try {
|
|
456
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
457
|
+
const osPath = ctx.fs.processPath(target);
|
|
458
|
+
await writeFile(osPath, "", { flag: "wx" });
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
value: { path: osPath }
|
|
462
|
+
};
|
|
463
|
+
} catch (error) {
|
|
464
|
+
return fail(mapError(error));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function createDir(ctx, payload, signal) {
|
|
468
|
+
const path = pathOf(payload);
|
|
469
|
+
if (path === void 0) return fail("createDir: payload.path must be a non-empty string");
|
|
470
|
+
try {
|
|
471
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
472
|
+
const osPath = ctx.fs.processPath(target);
|
|
473
|
+
await mkdir(osPath);
|
|
474
|
+
return {
|
|
475
|
+
ok: true,
|
|
476
|
+
value: { path: osPath }
|
|
477
|
+
};
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return fail(mapError(error));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function renameEntry(ctx, payload, signal) {
|
|
483
|
+
const path = pathOf(payload);
|
|
484
|
+
const to = typeof payload === "object" && payload !== null ? payload.to : void 0;
|
|
485
|
+
if (path === void 0) return fail("rename: payload.path must be a non-empty string");
|
|
486
|
+
if (typeof to !== "string" || to.trim().length === 0) return fail("rename: payload.to must be a non-empty string");
|
|
487
|
+
try {
|
|
488
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
489
|
+
const osPath = ctx.fs.processPath(target);
|
|
490
|
+
const toTarget = await ctx.fs.resolve(to, { signal });
|
|
491
|
+
const toOsPath = ctx.fs.processPath(toTarget);
|
|
492
|
+
if (await ctx.fs.stat(toTarget, signal) !== void 0) return fail("rename: destination already exists");
|
|
493
|
+
await rename(osPath, toOsPath);
|
|
494
|
+
return {
|
|
495
|
+
ok: true,
|
|
496
|
+
value: { path: toOsPath }
|
|
497
|
+
};
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return fail(mapError(error));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async function deleteEntry(ctx, payload, signal) {
|
|
503
|
+
const path = pathOf(payload);
|
|
504
|
+
if (path === void 0) return fail("delete: payload.path must be a non-empty string");
|
|
505
|
+
try {
|
|
506
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
507
|
+
const osPath = ctx.fs.processPath(target);
|
|
508
|
+
const info = await ctx.fs.stat(target, signal);
|
|
509
|
+
if (info === void 0) return fail(`path not found: ${path}`);
|
|
510
|
+
await rm(osPath, {
|
|
511
|
+
recursive: info.type === "directory",
|
|
512
|
+
force: true
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
ok: true,
|
|
516
|
+
value: { path: osPath }
|
|
517
|
+
};
|
|
518
|
+
} catch (error) {
|
|
519
|
+
return fail(mapError(error));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function copyEntry(ctx, payload, signal) {
|
|
523
|
+
const from = typeof payload === "object" && payload !== null ? payload.from : void 0;
|
|
524
|
+
const to = typeof payload === "object" && payload !== null ? payload.to : void 0;
|
|
525
|
+
const overwrite = typeof payload === "object" && payload !== null && payload.overwrite === true;
|
|
526
|
+
if (typeof from !== "string" || from.trim().length === 0) return fail("copy: payload.from must be a non-empty string");
|
|
527
|
+
if (typeof to !== "string" || to.trim().length === 0) return fail("copy: payload.to must be a non-empty string");
|
|
528
|
+
try {
|
|
529
|
+
const fromTarget = await ctx.fs.resolve(from, { signal });
|
|
530
|
+
const fromOs = ctx.fs.processPath(fromTarget);
|
|
531
|
+
const info = await ctx.fs.stat(fromTarget, signal);
|
|
532
|
+
if (info === void 0) return fail(`path not found: ${from}`);
|
|
533
|
+
const toTarget = await ctx.fs.resolve(to, { signal });
|
|
534
|
+
const toOs = ctx.fs.processPath(toTarget);
|
|
535
|
+
if (fromOs.toLowerCase() === toOs.toLowerCase()) return fail("copy: source and destination are the same path");
|
|
536
|
+
if (info.type === "directory") {
|
|
537
|
+
const sep = fromOs.includes("\\") ? "\\" : "/";
|
|
538
|
+
const prefix = fromOs.endsWith("\\") || fromOs.endsWith("/") ? fromOs : fromOs + sep;
|
|
539
|
+
if (toOs.toLowerCase().startsWith(prefix.toLowerCase())) return fail("copy: cannot copy a folder into itself");
|
|
540
|
+
}
|
|
541
|
+
await cp(fromOs, toOs, {
|
|
542
|
+
recursive: true,
|
|
543
|
+
force: overwrite,
|
|
544
|
+
errorOnExist: !overwrite
|
|
545
|
+
});
|
|
546
|
+
return {
|
|
547
|
+
ok: true,
|
|
548
|
+
value: { path: toOs }
|
|
549
|
+
};
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (isFsErrorCode(error, "ERR_FS_CP_EEXIST")) return {
|
|
552
|
+
ok: false,
|
|
553
|
+
error: {
|
|
554
|
+
code: "exists",
|
|
555
|
+
message: "destination already exists",
|
|
556
|
+
details: {}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
return fail(mapError(error));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/** Spawn one short-lived desktop command; resolves once the process launched. */
|
|
563
|
+
function runDesktop(command, args) {
|
|
564
|
+
return new Promise((resolve, reject) => {
|
|
565
|
+
const child = spawn(command, args, {
|
|
566
|
+
detached: true,
|
|
567
|
+
stdio: "ignore",
|
|
568
|
+
windowsHide: true
|
|
569
|
+
});
|
|
570
|
+
child.once("error", reject);
|
|
571
|
+
child.once("spawn", () => {
|
|
572
|
+
child.unref();
|
|
573
|
+
resolve();
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/** Run one command and capture its stdout; rejects on non-zero exit. */
|
|
578
|
+
function execCapture(command, args) {
|
|
579
|
+
return new Promise((resolve, reject) => {
|
|
580
|
+
const child = spawn(command, args, { windowsHide: true });
|
|
581
|
+
let out = "";
|
|
582
|
+
child.stdout.on("data", (chunk) => {
|
|
583
|
+
out += chunk.toString();
|
|
584
|
+
});
|
|
585
|
+
child.once("error", reject);
|
|
586
|
+
child.once("close", (code) => {
|
|
587
|
+
if (code === 0) resolve(out);
|
|
588
|
+
else reject(/* @__PURE__ */ new Error(`${command} exited with code ${code}`));
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
/** Reveal one resolved OS path in the platform's file manager. */
|
|
593
|
+
async function revealNative(osPath, isDir, signal) {
|
|
594
|
+
signal.throwIfAborted();
|
|
595
|
+
const platform = process.platform;
|
|
596
|
+
if (platform === "win32") {
|
|
597
|
+
await runDesktop("explorer.exe", isDir ? [osPath] : ["/select,", osPath]);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (platform === "darwin") {
|
|
601
|
+
await runDesktop("open", isDir ? [osPath] : ["-R", osPath]);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (platform === "linux") {
|
|
605
|
+
const env = process.env;
|
|
606
|
+
if (env.WSL_DISTRO_NAME !== void 0 || env.WSL_INTEROP !== void 0) {
|
|
607
|
+
const windowsPath = (await execCapture("wslpath", ["-w", osPath])).replace(/[\r\n]+$/, "");
|
|
608
|
+
if (windowsPath === "") throw new Error("wslpath returned no Windows path");
|
|
609
|
+
await runDesktop("explorer.exe", isDir ? [windowsPath] : ["/select,", windowsPath]);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
await runDesktop("xdg-open", [isDir ? osPath : dirname(osPath)]);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
throw new Error(`reveal in the file manager is unsupported on ${platform}`);
|
|
616
|
+
}
|
|
617
|
+
/** RPC endpoint: reveal one explorer path in the OS file manager. */
|
|
618
|
+
async function revealInExplorer(ctx, payload, signal) {
|
|
619
|
+
const path = pathOf(payload);
|
|
620
|
+
const kind = typeof payload === "object" && payload !== null ? payload.kind : void 0;
|
|
621
|
+
if (path === void 0) return fail("reveal: payload.path must be a non-empty string");
|
|
622
|
+
try {
|
|
623
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
624
|
+
const info = await ctx.fs.stat(target, signal);
|
|
625
|
+
if (info === void 0) return fail(`path not found: ${path}`);
|
|
626
|
+
const isDir = kind === "dir" || info.type === "directory";
|
|
627
|
+
const osPath = ctx.fs.processPath(target);
|
|
628
|
+
await revealNative(osPath, isDir, signal);
|
|
629
|
+
return {
|
|
630
|
+
ok: true,
|
|
631
|
+
value: { path: osPath }
|
|
632
|
+
};
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (signal.aborted) return fail("reveal: aborted");
|
|
635
|
+
return fail(mapError(error));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
178
638
|
//#endregion
|
|
179
|
-
export { CHANNEL, MAX_PREVIEW_BYTES, apply, inject, kindOf, mapDirEntry, mapError, name, sortEntries };
|
|
639
|
+
export { CHANNEL, EVENTS_PREFIX, MAX_IMAGE_BYTES, MAX_PREVIEW_BYTES, RAW_PREFIX, apply, imageMimeOf, inject, isImagePath, kindOf, mapDirEntry, mapError, name, sortEntries };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-workbench",
|
|
3
3
|
"description": "VS Code-style workspace file explorer + editable preview for the dsh web GUI",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.21.0",
|
|
7
7
|
"engines": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@types/react": "~18.3.1",
|
|
58
58
|
"highlight.js": "^11.11.1",
|
|
59
59
|
"lightningcss": "^1.32.0",
|
|
60
|
+
"markdown-it": "^15.0.0",
|
|
60
61
|
"react": "^18.2.0",
|
|
61
62
|
"tsdown": "0.22.14",
|
|
62
63
|
"typescript": "^5.6.0"
|