dsh-plugin-workbench 0.0.7 → 0.0.9
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 +27 -0
- package/README.md +30 -2
- package/lib/client.js +6 -0
- package/lib/client.js.map +1 -1
- package/lib/index.js +116 -5
- package/package.json +1 -1
- package/scripts/selfcheck.mjs +30 -0
- package/src/client/FileExplorer.tsx +11 -0
- package/src/index.ts +131 -4
package/lib/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { watch } from "node:fs";
|
|
3
|
-
import { basename, dirname } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { cp, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { homedir } from "node:os";
|
|
5
7
|
//#region src/index.ts
|
|
6
8
|
const name = "dsh-plugin-workbench";
|
|
7
9
|
const inject = [
|
|
@@ -105,6 +107,38 @@ function mapError(error) {
|
|
|
105
107
|
function isFsErrorCode(error, code) {
|
|
106
108
|
return error instanceof Error && error.code === code;
|
|
107
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* EPERM / EBUSY from a move or remove almost always means the path — or, on
|
|
112
|
+
* Windows, a file INSIDE a directory being moved/deleted — is open elsewhere
|
|
113
|
+
* (an editor, Explorer, a terminal sitting inside it, an antivirus scan).
|
|
114
|
+
* EACCES is left alone: that usually means read-only/ACL, not a lock.
|
|
115
|
+
*/
|
|
116
|
+
function isInUseError(error) {
|
|
117
|
+
if (!(error instanceof Error)) return false;
|
|
118
|
+
const code = error.code;
|
|
119
|
+
return code === "EPERM" || code === "EBUSY";
|
|
120
|
+
}
|
|
121
|
+
function sleep(ms) {
|
|
122
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Retry a mutation a bounded number of times while it fails with a transient
|
|
126
|
+
* Windows lock (indexer/antivirus handles usually clear within milliseconds).
|
|
127
|
+
* Genuine "permission denied" / "in use" errors surface after the retries.
|
|
128
|
+
*/
|
|
129
|
+
async function withLockRetry(op, attempts = 3, gapMs = 150) {
|
|
130
|
+
for (let attempt = 1;; attempt += 1) try {
|
|
131
|
+
await op();
|
|
132
|
+
return;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!isInUseError(error) || attempt >= attempts) throw error;
|
|
135
|
+
await sleep(gapMs * attempt);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Actionable replacement for a bare "permission denied" when a folder is locked. */
|
|
139
|
+
function lockMessage(path) {
|
|
140
|
+
return `"${basename(path)}" is in use by another program — close any editor/Explorer view of it (or a terminal inside it) and retry`;
|
|
141
|
+
}
|
|
108
142
|
function fail(message) {
|
|
109
143
|
return {
|
|
110
144
|
ok: false,
|
|
@@ -122,10 +156,84 @@ function pathOf(payload) {
|
|
|
122
156
|
}
|
|
123
157
|
}
|
|
124
158
|
/**
|
|
159
|
+
* The explorer-column patch markers that scripts/patch-layout.mjs injects into
|
|
160
|
+
* the compiled dsh-client-ui-layout client bundle. A dsh upgrade (or a
|
|
161
|
+
* `pnpm install` that refreshes the ui-layout package) silently reverts that
|
|
162
|
+
* bundle, which makes the workbench column vanish even though this plugin is
|
|
163
|
+
* fine — this is the exact failure this auto-heal guards against.
|
|
164
|
+
*/
|
|
165
|
+
const LAYOUT_PATCH_MARKERS = [
|
|
166
|
+
"\"explorerCol\": \"",
|
|
167
|
+
"setExplorer: (d, px) => {",
|
|
168
|
+
"renderSlot(\"explorer\"",
|
|
169
|
+
"conversationSeat"
|
|
170
|
+
];
|
|
171
|
+
/** Resolve the installed dsh-client-ui-layout client bundle (profile node_modules junction). */
|
|
172
|
+
function layoutClientPath() {
|
|
173
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
174
|
+
return join(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-client-ui-layout", "lib", "client.js");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* True when the ui-layout bundle already carries the explorer-column patch.
|
|
178
|
+
* A missing bundle (non-standard install) is treated as "nothing to patch" so
|
|
179
|
+
* the check never blocks a boot; an unreadable file likewise bails out to the
|
|
180
|
+
* caller rather than throwing.
|
|
181
|
+
*/
|
|
182
|
+
function layoutIsPatched() {
|
|
183
|
+
try {
|
|
184
|
+
const target = layoutClientPath();
|
|
185
|
+
if (!existsSync(target)) return true;
|
|
186
|
+
const text = readFileSync(target, "utf8");
|
|
187
|
+
return LAYOUT_PATCH_MARKERS.every((marker) => text.includes(marker));
|
|
188
|
+
} catch {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Module-level guard so a HMR/re-apply burst never spawns the patch twice concurrently. */
|
|
193
|
+
let layoutPatchScheduled = false;
|
|
194
|
+
/**
|
|
195
|
+
* Re-apply the ui-layout explorer-column patch when it is missing.
|
|
196
|
+
*
|
|
197
|
+
* The workbench column renders into a fourth `explorer` slot that is added to
|
|
198
|
+
* the compiled dsh-client-ui-layout bundle by scripts/patch-layout.mjs. A dsh
|
|
199
|
+
* upgrade silently reverts that bundle, so this re-runs the same
|
|
200
|
+
* version-checked script: anchors that no longer match abort the script
|
|
201
|
+
* WITHOUT writing, so an incompatible dsh version never corrupts the bundle —
|
|
202
|
+
* it only logs a warning and the plugin still boots. Idempotent and
|
|
203
|
+
* non-blocking (spawned fire-and-forget), so it never delays a boot.
|
|
204
|
+
*/
|
|
205
|
+
function ensureLayoutPatch() {
|
|
206
|
+
if (layoutPatchScheduled) return;
|
|
207
|
+
layoutPatchScheduled = true;
|
|
208
|
+
try {
|
|
209
|
+
if (layoutIsPatched()) return;
|
|
210
|
+
const script = join(dirname(dirname(fileURLToPath(import.meta.url))), "scripts", "patch-layout.mjs");
|
|
211
|
+
const child = spawn(process.execPath, [script], {
|
|
212
|
+
stdio: "inherit",
|
|
213
|
+
windowsHide: true
|
|
214
|
+
});
|
|
215
|
+
child.on("error", (err) => {
|
|
216
|
+
console.warn("[dsh-plugin-workbench] re-applying ui-layout explorer patch failed:", err.message);
|
|
217
|
+
layoutPatchScheduled = false;
|
|
218
|
+
});
|
|
219
|
+
child.on("close", (code) => {
|
|
220
|
+
if (code === 0) console.log("[dsh-plugin-workbench] re-applied the missing dsh-client-ui-layout explorer patch (likely reverted by a dsh upgrade).");
|
|
221
|
+
else {
|
|
222
|
+
console.warn(`[dsh-plugin-workbench] ui-layout patch exited ${code}; the dsh version may have changed — run scripts/patch-layout.mjs manually.`);
|
|
223
|
+
layoutPatchScheduled = false;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.warn("[dsh-plugin-workbench] ui-layout patch check failed:", err);
|
|
228
|
+
layoutPatchScheduled = false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
125
232
|
* One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
|
|
126
233
|
* cancels the underlying fs call (or aborts between steps).
|
|
127
234
|
*/
|
|
128
235
|
function apply(ctx) {
|
|
236
|
+
ensureLayoutPatch();
|
|
129
237
|
const watchState = {
|
|
130
238
|
dirs: /* @__PURE__ */ new Map(),
|
|
131
239
|
files: /* @__PURE__ */ new Map(),
|
|
@@ -490,12 +598,13 @@ async function renameEntry(ctx, payload, signal) {
|
|
|
490
598
|
const toTarget = await ctx.fs.resolve(to, { signal });
|
|
491
599
|
const toOsPath = ctx.fs.processPath(toTarget);
|
|
492
600
|
if (await ctx.fs.stat(toTarget, signal) !== void 0) return fail("rename: destination already exists");
|
|
493
|
-
await rename(osPath, toOsPath);
|
|
601
|
+
await withLockRetry(() => rename(osPath, toOsPath));
|
|
494
602
|
return {
|
|
495
603
|
ok: true,
|
|
496
604
|
value: { path: toOsPath }
|
|
497
605
|
};
|
|
498
606
|
} catch (error) {
|
|
607
|
+
if (isInUseError(error)) return fail(lockMessage(path));
|
|
499
608
|
return fail(mapError(error));
|
|
500
609
|
}
|
|
501
610
|
}
|
|
@@ -507,15 +616,16 @@ async function deleteEntry(ctx, payload, signal) {
|
|
|
507
616
|
const osPath = ctx.fs.processPath(target);
|
|
508
617
|
const info = await ctx.fs.stat(target, signal);
|
|
509
618
|
if (info === void 0) return fail(`path not found: ${path}`);
|
|
510
|
-
await rm(osPath, {
|
|
619
|
+
await withLockRetry(() => rm(osPath, {
|
|
511
620
|
recursive: info.type === "directory",
|
|
512
621
|
force: true
|
|
513
|
-
});
|
|
622
|
+
}));
|
|
514
623
|
return {
|
|
515
624
|
ok: true,
|
|
516
625
|
value: { path: osPath }
|
|
517
626
|
};
|
|
518
627
|
} catch (error) {
|
|
628
|
+
if (isInUseError(error)) return fail(lockMessage(path));
|
|
519
629
|
return fail(mapError(error));
|
|
520
630
|
}
|
|
521
631
|
}
|
|
@@ -556,6 +666,7 @@ async function copyEntry(ctx, payload, signal) {
|
|
|
556
666
|
exists: true
|
|
557
667
|
}
|
|
558
668
|
};
|
|
669
|
+
if (isInUseError(error)) return fail(lockMessage(from));
|
|
559
670
|
return fail(mapError(error));
|
|
560
671
|
}
|
|
561
672
|
}
|
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
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
29
|
import { spawn } from 'node:child_process'
|
|
30
|
-
import { watch as watchFs } from 'node:fs'
|
|
30
|
+
import { existsSync, readFileSync, watch as watchFs } from 'node:fs'
|
|
31
31
|
import type { FSWatcher } from 'node:fs'
|
|
32
|
-
import { basename, dirname } from 'node:path'
|
|
32
|
+
import { basename, dirname, join } from 'node:path'
|
|
33
33
|
import { mkdir, rename as renameFs, rm, cp, writeFile as writeFileNode } from 'node:fs/promises'
|
|
34
|
+
import { fileURLToPath } from 'node:url'
|
|
35
|
+
import { homedir } from 'node:os'
|
|
34
36
|
import type { Context } from '@deepseek-ai/cordis'
|
|
35
37
|
|
|
36
38
|
export const name = 'dsh-plugin-workbench'
|
|
@@ -202,6 +204,44 @@ function isFsErrorCode(error: unknown, code: string): boolean {
|
|
|
202
204
|
return error instanceof Error && (error as { code?: unknown }).code === code
|
|
203
205
|
}
|
|
204
206
|
|
|
207
|
+
/**
|
|
208
|
+
* EPERM / EBUSY from a move or remove almost always means the path — or, on
|
|
209
|
+
* Windows, a file INSIDE a directory being moved/deleted — is open elsewhere
|
|
210
|
+
* (an editor, Explorer, a terminal sitting inside it, an antivirus scan).
|
|
211
|
+
* EACCES is left alone: that usually means read-only/ACL, not a lock.
|
|
212
|
+
*/
|
|
213
|
+
function isInUseError(error: unknown): boolean {
|
|
214
|
+
if (!(error instanceof Error)) return false
|
|
215
|
+
const code = (error as { code?: unknown }).code
|
|
216
|
+
return code === 'EPERM' || code === 'EBUSY'
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sleep(ms: number): Promise<void> {
|
|
220
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Retry a mutation a bounded number of times while it fails with a transient
|
|
225
|
+
* Windows lock (indexer/antivirus handles usually clear within milliseconds).
|
|
226
|
+
* Genuine "permission denied" / "in use" errors surface after the retries.
|
|
227
|
+
*/
|
|
228
|
+
async function withLockRetry(op: () => Promise<void>, attempts = 3, gapMs = 150): Promise<void> {
|
|
229
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
230
|
+
try {
|
|
231
|
+
await op()
|
|
232
|
+
return
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (!isInUseError(error) || attempt >= attempts) throw error
|
|
235
|
+
await sleep(gapMs * attempt)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Actionable replacement for a bare "permission denied" when a folder is locked. */
|
|
241
|
+
function lockMessage(path: string): string {
|
|
242
|
+
return `"${basename(path)}" is in use by another program — close any editor/Explorer view of it (or a terminal inside it) and retry`
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
function fail(message: string): FilesRpcErr {
|
|
206
246
|
return { ok: false, error: { code: 'internal', message, details: {} } }
|
|
207
247
|
}
|
|
@@ -214,11 +254,89 @@ function pathOf(payload: unknown): string | undefined {
|
|
|
214
254
|
return undefined
|
|
215
255
|
}
|
|
216
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The explorer-column patch markers that scripts/patch-layout.mjs injects into
|
|
259
|
+
* the compiled dsh-client-ui-layout client bundle. A dsh upgrade (or a
|
|
260
|
+
* `pnpm install` that refreshes the ui-layout package) silently reverts that
|
|
261
|
+
* bundle, which makes the workbench column vanish even though this plugin is
|
|
262
|
+
* fine — this is the exact failure this auto-heal guards against.
|
|
263
|
+
*/
|
|
264
|
+
const LAYOUT_PATCH_MARKERS = [
|
|
265
|
+
'"explorerCol": "',
|
|
266
|
+
'setExplorer: (d, px) => {',
|
|
267
|
+
'renderSlot("explorer"',
|
|
268
|
+
'conversationSeat',
|
|
269
|
+
] as const
|
|
270
|
+
|
|
271
|
+
/** Resolve the installed dsh-client-ui-layout client bundle (profile node_modules junction). */
|
|
272
|
+
function layoutClientPath(): string {
|
|
273
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
274
|
+
return join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-client-ui-layout', 'lib', 'client.js')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* True when the ui-layout bundle already carries the explorer-column patch.
|
|
279
|
+
* A missing bundle (non-standard install) is treated as "nothing to patch" so
|
|
280
|
+
* the check never blocks a boot; an unreadable file likewise bails out to the
|
|
281
|
+
* caller rather than throwing.
|
|
282
|
+
*/
|
|
283
|
+
function layoutIsPatched(): boolean {
|
|
284
|
+
try {
|
|
285
|
+
const target = layoutClientPath()
|
|
286
|
+
if (!existsSync(target)) return true
|
|
287
|
+
const text = readFileSync(target, 'utf8')
|
|
288
|
+
return LAYOUT_PATCH_MARKERS.every((marker) => text.includes(marker))
|
|
289
|
+
} catch {
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Module-level guard so a HMR/re-apply burst never spawns the patch twice concurrently. */
|
|
295
|
+
let layoutPatchScheduled = false
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Re-apply the ui-layout explorer-column patch when it is missing.
|
|
299
|
+
*
|
|
300
|
+
* The workbench column renders into a fourth `explorer` slot that is added to
|
|
301
|
+
* the compiled dsh-client-ui-layout bundle by scripts/patch-layout.mjs. A dsh
|
|
302
|
+
* upgrade silently reverts that bundle, so this re-runs the same
|
|
303
|
+
* version-checked script: anchors that no longer match abort the script
|
|
304
|
+
* WITHOUT writing, so an incompatible dsh version never corrupts the bundle —
|
|
305
|
+
* it only logs a warning and the plugin still boots. Idempotent and
|
|
306
|
+
* non-blocking (spawned fire-and-forget), so it never delays a boot.
|
|
307
|
+
*/
|
|
308
|
+
function ensureLayoutPatch(): void {
|
|
309
|
+
if (layoutPatchScheduled) return
|
|
310
|
+
layoutPatchScheduled = true
|
|
311
|
+
try {
|
|
312
|
+
if (layoutIsPatched()) return
|
|
313
|
+
const script = join(dirname(dirname(fileURLToPath(import.meta.url))), 'scripts', 'patch-layout.mjs')
|
|
314
|
+
const child = spawn(process.execPath, [script], { stdio: 'inherit', windowsHide: true })
|
|
315
|
+
child.on('error', (err) => {
|
|
316
|
+
console.warn('[dsh-plugin-workbench] re-applying ui-layout explorer patch failed:', err.message)
|
|
317
|
+
layoutPatchScheduled = false
|
|
318
|
+
})
|
|
319
|
+
child.on('close', (code) => {
|
|
320
|
+
if (code === 0) {
|
|
321
|
+
console.log('[dsh-plugin-workbench] re-applied the missing dsh-client-ui-layout explorer patch (likely reverted by a dsh upgrade).')
|
|
322
|
+
} else {
|
|
323
|
+
console.warn(`[dsh-plugin-workbench] ui-layout patch exited ${code}; the dsh version may have changed — run scripts/patch-layout.mjs manually.`)
|
|
324
|
+
layoutPatchScheduled = false
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.warn('[dsh-plugin-workbench] ui-layout patch check failed:', err)
|
|
329
|
+
layoutPatchScheduled = false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
217
333
|
/**
|
|
218
334
|
* One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
|
|
219
335
|
* cancels the underlying fs call (or aborts between steps).
|
|
220
336
|
*/
|
|
221
337
|
export function apply(ctx: Context): void {
|
|
338
|
+
// Re-apply the ui-layout explorer-column patch when a dsh upgrade reverted it.
|
|
339
|
+
ensureLayoutPatch()
|
|
222
340
|
// Per-apply watch state: created here (not module-level) so disable/reload
|
|
223
341
|
// cycles never leak watchers or SSE clients across applies.
|
|
224
342
|
const watchState: WatchState = {
|
|
@@ -639,9 +757,13 @@ async function renameEntry(ctx: Context, payload: unknown, signal: AbortSignal):
|
|
|
639
757
|
// fs.rename overwrites silently on POSIX; refuse when the destination exists.
|
|
640
758
|
const existing = await ctx.fs.stat(toTarget, signal)
|
|
641
759
|
if (existing !== undefined) return fail('rename: destination already exists')
|
|
642
|
-
|
|
760
|
+
// Windows refuses to move a directory while it (or a file inside it) is
|
|
761
|
+
// open elsewhere; transient locks clear quickly, so retry briefly before
|
|
762
|
+
// reporting the actionable "in use" message.
|
|
763
|
+
await withLockRetry(() => renameFs(osPath, toOsPath))
|
|
643
764
|
return { ok: true, value: { path: toOsPath } }
|
|
644
765
|
} catch (error) {
|
|
766
|
+
if (isInUseError(error)) return fail(lockMessage(path))
|
|
645
767
|
return fail(mapError(error))
|
|
646
768
|
}
|
|
647
769
|
}
|
|
@@ -655,9 +777,12 @@ async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal):
|
|
|
655
777
|
const info = await ctx.fs.stat(target, signal)
|
|
656
778
|
if (info === undefined) return fail(`path not found: ${path}`)
|
|
657
779
|
// Folders are removed recursively (the client confirms before calling).
|
|
658
|
-
|
|
780
|
+
// Like rename, a folder with an open inner file fails on Windows; retry
|
|
781
|
+
// transient locks, then report the actionable "in use" message.
|
|
782
|
+
await withLockRetry(() => rm(osPath, { recursive: info.type === 'directory', force: true }))
|
|
659
783
|
return { ok: true, value: { path: osPath } }
|
|
660
784
|
} catch (error) {
|
|
785
|
+
if (isInUseError(error)) return fail(lockMessage(path))
|
|
661
786
|
return fail(mapError(error))
|
|
662
787
|
}
|
|
663
788
|
}
|
|
@@ -707,6 +832,8 @@ async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): P
|
|
|
707
832
|
// collision as a successful no-op so the client can ask about overwrite.
|
|
708
833
|
return { ok: true, value: { path: toOs, exists: true } }
|
|
709
834
|
}
|
|
835
|
+
// Copying a folder with an open inner file fails like move/delete do.
|
|
836
|
+
if (isInUseError(error)) return fail(lockMessage(from))
|
|
710
837
|
return fail(mapError(error))
|
|
711
838
|
}
|
|
712
839
|
}
|