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/CHANGELOG.md +26 -0
- package/README.md +33 -3
- package/lib/client.js +7 -4
- package/lib/client.js.map +1 -1
- package/lib/index.js +44 -9
- package/package.json +3 -2
- package/scripts/selfcheck.mjs +30 -0
- package/src/client/FileExplorer.tsx +16 -4
- package/src/client/index.ts +1 -1
- package/src/index.ts +68 -8
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
|
-
|
|
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:
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
package/src/client/index.ts
CHANGED
|
@@ -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` (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
663
|
-
//
|
|
664
|
-
// `ctx.fs.resolve` →
|
|
665
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|