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/CHANGELOG.md +16 -0
- package/README.md +20 -0
- package/lib/client.js +6 -0
- package/lib/client.js.map +1 -1
- package/lib/index.js +38 -3
- package/package.json +1 -1
- package/scripts/selfcheck.mjs +30 -0
- package/src/client/FileExplorer.tsx +11 -0
- package/src/index.ts +49 -2
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|