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/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
@@ -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.9",
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
@@ -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
- await renameFs(osPath, toOsPath)
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
- await rm(osPath, { recursive: info.type === 'directory', force: true })
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
  }