dsh-plugin-workbench 0.0.5 → 0.0.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/lib/index.js CHANGED
@@ -1,4 +1,7 @@
1
- import { mkdir, rename, rm, writeFile } from "node:fs/promises";
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";
2
5
  //#region src/index.ts
3
6
  const name = "dsh-plugin-workbench";
4
7
  const inject = [
@@ -12,8 +15,16 @@ const CHANNEL = "/dsh-plugin-files";
12
15
  const MAX_PREVIEW_BYTES = 524288;
13
16
  /** Same-origin route serving raw bytes for image files (see module doc). */
14
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";
15
20
  /** Images larger than this are never served to the preview (browser shows a hint). */
16
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;
17
28
  const IMAGE_MIME = {
18
29
  png: "image/png",
19
30
  jpg: "image/jpeg",
@@ -115,14 +126,23 @@ function pathOf(payload) {
115
126
  * cancels the underlying fs call (or aborts between steps).
116
127
  */
117
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
+ };
118
135
  const handler = async (endpoint, payload, signal) => {
119
136
  if (endpoint === "list") return listDir(ctx, payload, signal);
120
137
  if (endpoint === "read") return readFile(ctx, payload, signal);
121
- if (endpoint === "write") return writeFile$1(ctx, payload, signal);
138
+ if (endpoint === "write") return writeFile$1(ctx, watchState, payload, signal);
139
+ if (endpoint === "watch") return setWatch(ctx, watchState, payload, signal);
122
140
  if (endpoint === "createFile") return createFile(ctx, payload, signal);
123
141
  if (endpoint === "createDir") return createDir(ctx, payload, signal);
124
142
  if (endpoint === "rename") return renameEntry(ctx, payload, signal);
125
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);
126
146
  return fail(`unknown endpoint: ${endpoint}`);
127
147
  };
128
148
  ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" }), "dsh-plugin-workbench: files rpc channel");
@@ -133,6 +153,161 @@ function apply(ctx) {
133
153
  serveRaw(ctx, req, res);
134
154
  }
135
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
+ };
136
311
  }
137
312
  async function serveRaw(ctx, req, res) {
138
313
  const text = (code, body) => {
@@ -246,7 +421,7 @@ async function readFile(ctx, payload, signal) {
246
421
  return fail(mapError(error));
247
422
  }
248
423
  }
249
- async function writeFile$1(ctx, payload, signal) {
424
+ async function writeFile$1(ctx, state, payload, signal) {
250
425
  const path = pathOf(payload);
251
426
  const content = typeof payload === "object" && payload !== null ? payload.content : void 0;
252
427
  if (path === void 0) return fail("write: payload.path must be a non-empty string");
@@ -257,10 +432,16 @@ async function writeFile$1(ctx, payload, signal) {
257
432
  mode: "danger-full-access",
258
433
  workspaceRoot: ctx.fs.processPath(target)
259
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
+ }
260
441
  return {
261
442
  ok: true,
262
443
  value: {
263
- path: ctx.fs.processPath(target),
444
+ path: osPath,
264
445
  size: content.length
265
446
  }
266
447
  };
@@ -338,5 +519,121 @@ async function deleteEntry(ctx, payload, signal) {
338
519
  return fail(mapError(error));
339
520
  }
340
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
+ let toOs = "";
529
+ try {
530
+ const fromTarget = await ctx.fs.resolve(from, { signal });
531
+ const fromOs = ctx.fs.processPath(fromTarget);
532
+ const info = await ctx.fs.stat(fromTarget, signal);
533
+ if (info === void 0) return fail(`path not found: ${from}`);
534
+ const toTarget = await ctx.fs.resolve(to, { signal });
535
+ toOs = ctx.fs.processPath(toTarget);
536
+ if (fromOs.toLowerCase() === toOs.toLowerCase()) return fail("copy: source and destination are the same path");
537
+ if (info.type === "directory") {
538
+ const sep = fromOs.includes("\\") ? "\\" : "/";
539
+ const prefix = fromOs.endsWith("\\") || fromOs.endsWith("/") ? fromOs : fromOs + sep;
540
+ if (toOs.toLowerCase().startsWith(prefix.toLowerCase())) return fail("copy: cannot copy a folder into itself");
541
+ }
542
+ await cp(fromOs, toOs, {
543
+ recursive: true,
544
+ force: overwrite,
545
+ errorOnExist: !overwrite
546
+ });
547
+ return {
548
+ ok: true,
549
+ value: { path: toOs }
550
+ };
551
+ } catch (error) {
552
+ if (isFsErrorCode(error, "ERR_FS_CP_EEXIST")) return {
553
+ ok: true,
554
+ value: {
555
+ path: toOs,
556
+ exists: true
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
+ }
341
638
  //#endregion
342
- export { CHANNEL, MAX_IMAGE_BYTES, MAX_PREVIEW_BYTES, RAW_PREFIX, apply, imageMimeOf, inject, isImagePath, 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.5",
4
+ "version": "0.0.7",
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"
@@ -67,6 +68,7 @@
67
68
  "cordis.patch.yml",
68
69
  "src",
69
70
  "README.md",
70
- "CHANGELOG.md"
71
+ "CHANGELOG.md",
72
+ "LICENSE"
71
73
  ]
72
74
  }