dsh-plugin-workbench 0.0.6 → 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
  }
@@ -525,13 +559,14 @@ async function copyEntry(ctx, payload, signal) {
525
559
  const overwrite = typeof payload === "object" && payload !== null && payload.overwrite === true;
526
560
  if (typeof from !== "string" || from.trim().length === 0) return fail("copy: payload.from must be a non-empty string");
527
561
  if (typeof to !== "string" || to.trim().length === 0) return fail("copy: payload.to must be a non-empty string");
562
+ let toOs = "";
528
563
  try {
529
564
  const fromTarget = await ctx.fs.resolve(from, { signal });
530
565
  const fromOs = ctx.fs.processPath(fromTarget);
531
566
  const info = await ctx.fs.stat(fromTarget, signal);
532
567
  if (info === void 0) return fail(`path not found: ${from}`);
533
568
  const toTarget = await ctx.fs.resolve(to, { signal });
534
- const toOs = ctx.fs.processPath(toTarget);
569
+ toOs = ctx.fs.processPath(toTarget);
535
570
  if (fromOs.toLowerCase() === toOs.toLowerCase()) return fail("copy: source and destination are the same path");
536
571
  if (info.type === "directory") {
537
572
  const sep = fromOs.includes("\\") ? "\\" : "/";
@@ -549,13 +584,13 @@ async function copyEntry(ctx, payload, signal) {
549
584
  };
550
585
  } catch (error) {
551
586
  if (isFsErrorCode(error, "ERR_FS_CP_EEXIST")) return {
552
- ok: false,
553
- error: {
554
- code: "exists",
555
- message: "destination already exists",
556
- details: {}
587
+ ok: true,
588
+ value: {
589
+ path: toOs,
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.6",
4
+ "version": "0.0.8",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -68,6 +68,7 @@
68
68
  "cordis.patch.yml",
69
69
  "src",
70
70
  "README.md",
71
- "CHANGELOG.md"
71
+ "CHANGELOG.md",
72
+ "LICENSE"
72
73
  ]
73
74
  }
@@ -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] 结构完整,可发布");
@@ -34,6 +34,8 @@ export interface FsReadResult {
34
34
  /** Result of a context-menu mutation (create/rename/delete). */
35
35
  export interface FsMutationResult {
36
36
  path: string
37
+ /** Copy-only: `true` when the destination existed and the copy was skipped (client may ask about overwrite). */
38
+ exists?: boolean
37
39
  }
38
40
 
39
41
  interface SessionSummary {
@@ -588,10 +590,9 @@ export function FileExplorer({
588
590
  if (sameDest) dest = joinPath(targetDir, copyName(item.name))
589
591
  let overwritten = false
590
592
  try {
591
- try {
592
- await copyPath(item.path, dest, false)
593
- } catch (error) {
594
- if ((error as { code?: string }).code !== 'exists') throw error
593
+ const copied = await copyPath(item.path, dest, false)
594
+ if (copied.exists === true) {
595
+ // Collision: ask before overwriting, like the OS file manager.
595
596
  if (!window.confirm(t('confirm.overwrite', { name: item.name }))) continue
596
597
  overwritten = true
597
598
  await copyPath(item.path, dest, true)
@@ -901,6 +902,17 @@ export function FileExplorer({
901
902
  const onTreeKeyDown = useCallback((e: ReactKeyboardEvent) => {
902
903
  const mod = e.ctrlKey || e.metaKey
903
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
+ }
904
916
  if (mod && key === 'c') {
905
917
  const items = selectedItems()
906
918
  if (items.length === 0) return
@@ -52,7 +52,7 @@ interface RpcFailure {
52
52
  error: { code: string; message: string }
53
53
  }
54
54
 
55
- /** Error carrying the host's machine-readable `code` (e.g. 'exists' on copy collision). */
55
+ /** Error carrying the host's machine-readable `code` (always a core union code; copy collisions are reported in the value instead). */
56
56
  export interface RpcError extends Error {
57
57
  code?: string
58
58
  }
package/src/index.ts CHANGED
@@ -114,6 +114,13 @@ export interface FsWriteResult {
114
114
  /** Result of a context-menu mutation (create/rename/delete). */
115
115
  export interface FsMutationResult {
116
116
  path: string
117
+ /**
118
+ * Copy-only: `true` when the destination already existed and the copy was
119
+ * skipped (overwrite was false). Reported in the SUCCESS value — the generic
120
+ * RPC channel validates error bodies against the core's closed error-code
121
+ * union, so a custom error code like 'exists' would fail client-side parsing.
122
+ */
123
+ exists?: boolean
117
124
  }
118
125
 
119
126
  export type FilesRpcOk = { ok: true; value: FsListResult | FsReadResult | FsWriteResult | FsMutationResult }
@@ -195,6 +202,44 @@ function isFsErrorCode(error: unknown, code: string): boolean {
195
202
  return error instanceof Error && (error as { code?: unknown }).code === code
196
203
  }
197
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
+
198
243
  function fail(message: string): FilesRpcErr {
199
244
  return { ok: false, error: { code: 'internal', message, details: {} } }
200
245
  }
@@ -632,9 +677,13 @@ async function renameEntry(ctx: Context, payload: unknown, signal: AbortSignal):
632
677
  // fs.rename overwrites silently on POSIX; refuse when the destination exists.
633
678
  const existing = await ctx.fs.stat(toTarget, signal)
634
679
  if (existing !== undefined) return fail('rename: destination already exists')
635
- 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))
636
684
  return { ok: true, value: { path: toOsPath } }
637
685
  } catch (error) {
686
+ if (isInUseError(error)) return fail(lockMessage(path))
638
687
  return fail(mapError(error))
639
688
  }
640
689
  }
@@ -648,9 +697,12 @@ async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal):
648
697
  const info = await ctx.fs.stat(target, signal)
649
698
  if (info === undefined) return fail(`path not found: ${path}`)
650
699
  // Folders are removed recursively (the client confirms before calling).
651
- 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 }))
652
703
  return { ok: true, value: { path: osPath } }
653
704
  } catch (error) {
705
+ if (isInUseError(error)) return fail(lockMessage(path))
654
706
  return fail(mapError(error))
655
707
  }
656
708
  }
@@ -659,10 +711,13 @@ async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal):
659
711
  // Copy (the explorer's Copy / Cut + Paste)
660
712
  //
661
713
  // `fs.cp` handles files and (recursively) folders; `errorOnExist` turns a
662
- // colliding destination into a distinct `exists` error so the client can ask
663
- // the user whether to overwrite, exactly like the OS file manager. The same
664
- // `ctx.fs.resolve` → `processPath` resolution as every other endpoint applies,
665
- // so sandbox containment and error mapping stay uniform.
714
+ // colliding destination into an `ERR_FS_CP_EEXIST` throw, which is reported as
715
+ // a SUCCESS with `exists: true` so the client can ask the user whether to
716
+ // overwrite, exactly like the OS file manager. The same `ctx.fs.resolve` →
717
+ // `processPath` resolution as every other endpoint applies, so sandbox
718
+ // containment and error mapping stay uniform. (Reported in the value, never as
719
+ // an error body: generic-channel errors must use the core's closed error-code
720
+ // union, and a custom code there would fail the client-side response parse.)
666
721
  // ---------------------------------------------------------------------------
667
722
 
668
723
  async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
@@ -672,13 +727,14 @@ async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): P
672
727
  const overwrite = typeof payload === 'object' && payload !== null && (payload as { overwrite?: unknown }).overwrite === true
673
728
  if (typeof from !== 'string' || from.trim().length === 0) return fail('copy: payload.from must be a non-empty string')
674
729
  if (typeof to !== 'string' || to.trim().length === 0) return fail('copy: payload.to must be a non-empty string')
730
+ let toOs = ''
675
731
  try {
676
732
  const fromTarget = await ctx.fs.resolve(from, { signal })
677
733
  const fromOs = ctx.fs.processPath(fromTarget)
678
734
  const info = await ctx.fs.stat(fromTarget, signal)
679
735
  if (info === undefined) return fail(`path not found: ${from}`)
680
736
  const toTarget = await ctx.fs.resolve(to, { signal })
681
- const toOs = ctx.fs.processPath(toTarget)
737
+ toOs = ctx.fs.processPath(toTarget)
682
738
  // Refuse trivial self-copies and copying a folder into itself or one of
683
739
  // its own descendants (comparisons case-insensitive — Windows).
684
740
  const samePath = fromOs.toLowerCase() === toOs.toLowerCase()
@@ -692,8 +748,12 @@ async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): P
692
748
  return { ok: true, value: { path: toOs } }
693
749
  } catch (error) {
694
750
  if (isFsErrorCode(error, 'ERR_FS_CP_EEXIST')) {
695
- return { ok: false, error: { code: 'exists', message: 'destination already exists', details: {} } }
751
+ // Destination already exists and overwrite was not requested: report the
752
+ // collision as a successful no-op so the client can ask about overwrite.
753
+ return { ok: true, value: { path: toOs, exists: true } }
696
754
  }
755
+ // Copying a folder with an open inner file fails like move/delete do.
756
+ if (isInUseError(error)) return fail(lockMessage(from))
697
757
  return fail(mapError(error))
698
758
  }
699
759
  }