dsh-plugin-workbench 0.0.7 → 0.0.8

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
@@ -105,6 +105,38 @@ function mapError(error) {
105
105
  function isFsErrorCode(error, code) {
106
106
  return error instanceof Error && error.code === code;
107
107
  }
108
+ /**
109
+ * EPERM / EBUSY from a move or remove almost always means the path — or, on
110
+ * Windows, a file INSIDE a directory being moved/deleted — is open elsewhere
111
+ * (an editor, Explorer, a terminal sitting inside it, an antivirus scan).
112
+ * EACCES is left alone: that usually means read-only/ACL, not a lock.
113
+ */
114
+ function isInUseError(error) {
115
+ if (!(error instanceof Error)) return false;
116
+ const code = error.code;
117
+ return code === "EPERM" || code === "EBUSY";
118
+ }
119
+ function sleep(ms) {
120
+ return new Promise((resolve) => setTimeout(resolve, ms));
121
+ }
122
+ /**
123
+ * Retry a mutation a bounded number of times while it fails with a transient
124
+ * Windows lock (indexer/antivirus handles usually clear within milliseconds).
125
+ * Genuine "permission denied" / "in use" errors surface after the retries.
126
+ */
127
+ async function withLockRetry(op, attempts = 3, gapMs = 150) {
128
+ for (let attempt = 1;; attempt += 1) try {
129
+ await op();
130
+ return;
131
+ } catch (error) {
132
+ if (!isInUseError(error) || attempt >= attempts) throw error;
133
+ await sleep(gapMs * attempt);
134
+ }
135
+ }
136
+ /** Actionable replacement for a bare "permission denied" when a folder is locked. */
137
+ function lockMessage(path) {
138
+ return `"${basename(path)}" is in use by another program — close any editor/Explorer view of it (or a terminal inside it) and retry`;
139
+ }
108
140
  function fail(message) {
109
141
  return {
110
142
  ok: false,
@@ -490,12 +522,13 @@ async function renameEntry(ctx, payload, signal) {
490
522
  const toTarget = await ctx.fs.resolve(to, { signal });
491
523
  const toOsPath = ctx.fs.processPath(toTarget);
492
524
  if (await ctx.fs.stat(toTarget, signal) !== void 0) return fail("rename: destination already exists");
493
- await rename(osPath, toOsPath);
525
+ await withLockRetry(() => rename(osPath, toOsPath));
494
526
  return {
495
527
  ok: true,
496
528
  value: { path: toOsPath }
497
529
  };
498
530
  } catch (error) {
531
+ if (isInUseError(error)) return fail(lockMessage(path));
499
532
  return fail(mapError(error));
500
533
  }
501
534
  }
@@ -507,15 +540,16 @@ async function deleteEntry(ctx, payload, signal) {
507
540
  const osPath = ctx.fs.processPath(target);
508
541
  const info = await ctx.fs.stat(target, signal);
509
542
  if (info === void 0) return fail(`path not found: ${path}`);
510
- await rm(osPath, {
543
+ await withLockRetry(() => rm(osPath, {
511
544
  recursive: info.type === "directory",
512
545
  force: true
513
- });
546
+ }));
514
547
  return {
515
548
  ok: true,
516
549
  value: { path: osPath }
517
550
  };
518
551
  } catch (error) {
552
+ if (isInUseError(error)) return fail(lockMessage(path));
519
553
  return fail(mapError(error));
520
554
  }
521
555
  }
@@ -556,6 +590,7 @@ async function copyEntry(ctx, payload, signal) {
556
590
  exists: true
557
591
  }
558
592
  };
593
+ if (isInUseError(error)) return fail(lockMessage(from));
559
594
  return fail(mapError(error));
560
595
  }
561
596
  }
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.7",
4
+ "version": "0.0.8",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -0,0 +1,30 @@
1
+ // 最小自检:发布前验证结构完整(离线、零依赖)。node scripts/selfcheck.mjs
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const failures = [];
8
+
9
+ const pkgPath = join(root, "package.json");
10
+ if (!existsSync(pkgPath)) failures.push("缺 package.json");
11
+ else {
12
+ let pkg;
13
+ try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); }
14
+ catch { failures.push("package.json 解析失败"); pkg = {}; }
15
+ if (pkg.name && pkg.main && !existsSync(join(root, pkg.main)))
16
+ failures.push(`入口不存在: ${pkg.main}`);
17
+ if (pkg.dsh?.bundle?.patch && !existsSync(join(root, pkg.dsh.bundle.patch)))
18
+ failures.push(`bundle patch 不存在: ${pkg.dsh.bundle.patch}`);
19
+ }
20
+
21
+ for (const f of ["README.md", "LICENSE"]) {
22
+ if (!existsSync(join(root, f))) failures.push(`缺 ${f}`);
23
+ }
24
+
25
+ if (failures.length) {
26
+ for (const f of failures) console.error(`[FAIL] ${f}`);
27
+ console.error(`${failures.length} 项失败`);
28
+ process.exit(1);
29
+ }
30
+ console.log("[PASS] 结构完整,可发布");
@@ -902,6 +902,17 @@ export function FileExplorer({
902
902
  const onTreeKeyDown = useCallback((e: ReactKeyboardEvent) => {
903
903
  const mod = e.ctrlKey || e.metaKey
904
904
  const key = e.key.toLowerCase()
905
+ // Never hijack Ctrl/Cmd+C/X while the user is copying an actual text
906
+ // selection (e.g. an inline error message rendered inside the tree): the
907
+ // browser must get the key so the selected text is copied instead of the
908
+ // selected files. Also let editable elements (if any ever live in the
909
+ // tree) keep their native copy/cut behavior.
910
+ if (mod && (key === 'c' || key === 'x')) {
911
+ const target = e.target as HTMLElement | null
912
+ if (target !== null && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return
913
+ const selection = window.getSelection()
914
+ if (selection !== null && !selection.isCollapsed && selection.toString().length > 0) return
915
+ }
905
916
  if (mod && key === 'c') {
906
917
  const items = selectedItems()
907
918
  if (items.length === 0) return
package/src/index.ts CHANGED
@@ -202,6 +202,44 @@ function isFsErrorCode(error: unknown, code: string): boolean {
202
202
  return error instanceof Error && (error as { code?: unknown }).code === code
203
203
  }
204
204
 
205
+ /**
206
+ * EPERM / EBUSY from a move or remove almost always means the path — or, on
207
+ * Windows, a file INSIDE a directory being moved/deleted — is open elsewhere
208
+ * (an editor, Explorer, a terminal sitting inside it, an antivirus scan).
209
+ * EACCES is left alone: that usually means read-only/ACL, not a lock.
210
+ */
211
+ function isInUseError(error: unknown): boolean {
212
+ if (!(error instanceof Error)) return false
213
+ const code = (error as { code?: unknown }).code
214
+ return code === 'EPERM' || code === 'EBUSY'
215
+ }
216
+
217
+ function sleep(ms: number): Promise<void> {
218
+ return new Promise((resolve) => setTimeout(resolve, ms))
219
+ }
220
+
221
+ /**
222
+ * Retry a mutation a bounded number of times while it fails with a transient
223
+ * Windows lock (indexer/antivirus handles usually clear within milliseconds).
224
+ * Genuine "permission denied" / "in use" errors surface after the retries.
225
+ */
226
+ async function withLockRetry(op: () => Promise<void>, attempts = 3, gapMs = 150): Promise<void> {
227
+ for (let attempt = 1; ; attempt += 1) {
228
+ try {
229
+ await op()
230
+ return
231
+ } catch (error) {
232
+ if (!isInUseError(error) || attempt >= attempts) throw error
233
+ await sleep(gapMs * attempt)
234
+ }
235
+ }
236
+ }
237
+
238
+ /** Actionable replacement for a bare "permission denied" when a folder is locked. */
239
+ function lockMessage(path: string): string {
240
+ return `"${basename(path)}" is in use by another program — close any editor/Explorer view of it (or a terminal inside it) and retry`
241
+ }
242
+
205
243
  function fail(message: string): FilesRpcErr {
206
244
  return { ok: false, error: { code: 'internal', message, details: {} } }
207
245
  }
@@ -639,9 +677,13 @@ async function renameEntry(ctx: Context, payload: unknown, signal: AbortSignal):
639
677
  // fs.rename overwrites silently on POSIX; refuse when the destination exists.
640
678
  const existing = await ctx.fs.stat(toTarget, signal)
641
679
  if (existing !== undefined) return fail('rename: destination already exists')
642
- await renameFs(osPath, toOsPath)
680
+ // Windows refuses to move a directory while it (or a file inside it) is
681
+ // open elsewhere; transient locks clear quickly, so retry briefly before
682
+ // reporting the actionable "in use" message.
683
+ await withLockRetry(() => renameFs(osPath, toOsPath))
643
684
  return { ok: true, value: { path: toOsPath } }
644
685
  } catch (error) {
686
+ if (isInUseError(error)) return fail(lockMessage(path))
645
687
  return fail(mapError(error))
646
688
  }
647
689
  }
@@ -655,9 +697,12 @@ async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal):
655
697
  const info = await ctx.fs.stat(target, signal)
656
698
  if (info === undefined) return fail(`path not found: ${path}`)
657
699
  // Folders are removed recursively (the client confirms before calling).
658
- await rm(osPath, { recursive: info.type === 'directory', force: true })
700
+ // Like rename, a folder with an open inner file fails on Windows; retry
701
+ // transient locks, then report the actionable "in use" message.
702
+ await withLockRetry(() => rm(osPath, { recursive: info.type === 'directory', force: true }))
659
703
  return { ok: true, value: { path: osPath } }
660
704
  } catch (error) {
705
+ if (isInUseError(error)) return fail(lockMessage(path))
661
706
  return fail(mapError(error))
662
707
  }
663
708
  }
@@ -707,6 +752,8 @@ async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): P
707
752
  // collision as a successful no-op so the client can ask about overwrite.
708
753
  return { ok: true, value: { path: toOs, exists: true } }
709
754
  }
755
+ // Copying a folder with an open inner file fails like move/delete do.
756
+ if (isInUseError(error)) return fail(lockMessage(from))
710
757
  return fail(mapError(error))
711
758
  }
712
759
  }