dsh-plugin-workbench 0.0.17 → 0.0.20

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 CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.20] - 2026-09-07
4
+
5
+ ### Fixed
6
+
7
+ - 全链路代码审查(子代理逐变体模拟验证)后的三处收尾:
8
+ 1. **`npm-delta` 变体缺 watch 块**(中危):0.0.16/17 旧补丁(无 occupancy)用户经
9
+ delta 打完后没有 `slots/changed` 同步——运行时卸载插件列不即时收起(重启后才由
10
+ inject hook 兜底)。delta 表补上 watch 块注入,已用构造的旧补丁态产物实测命中。
11
+ 2. **host 自愈 marker 是脚本的弱子集**(形式问题):`src/index.ts` 的清单缺
12
+ `renderSlot("explorer.preview"` 且 explorerCol 用短前缀——受损/半回退 bundle 可能
13
+ 过 host 检查却过不了脚本校验,自愈永不触发。host 清单已与脚本 `PATCHED_MARKERS`
14
+ 7 项逐字同步。
15
+ 3. **`--target` 污染共享 pristine 备份**(运维隐患):脚本对任意 `--target` 文件也会
16
+ 刷新仓库内的 `client.js.orig`。现在备份簿记仅在默认 profile 路径下执行,
17
+ `--target` 时明确跳过。
18
+
19
+ ## [0.0.19] - 2026-09-07
20
+
21
+ ### Fixed
22
+
23
+ - **0.0.18 的 occupancy 门控把列永久收起了(装着插件也不显示 explorer 列)**。
24
+ 根因是时序:布局 store 的 actions 要到 root entry **首次渲染组装**时才经
25
+ `inject` hook 交给 `LayoutController`(`attachPanels`),而 0.0.18 把初始
26
+ occupancy 同步放在了 ui-layout 的 apply 阶段——那时 `#panels` 还是 undefined,
27
+ 同步被空实现吞掉;workbench 的注册又恰好发生在同一调用栈内(声明提交触发),
28
+ `slots/changed` 事件在监听器安装之前就已发完,之后再无事件 → `explorerOccupied`
29
+ 恒为 false → 列永不出现。修复:occupancy 同步**同时**放进 root 注册的 inject
30
+ hook(actions 刚 bind、首帧渲染前,此时插槽查询已能拿到已注册条目),
31
+ `slots/changed` 监听继续负责运行时装卸;补丁脚本对应新增 `npm-delta2` /
32
+ `desktop-ci-delta2` 变体与 marker。
33
+
34
+ ## [0.0.18] - 2026-09-07
35
+
36
+ ### Fixed
37
+
38
+ - **卸载插件后残留一条空白侧边栏([issue #2](https://github.com/Pasumao/dsh-plugin-workbench/issues/2))**。
39
+ 根因:布局补丁给 `dsh-client-ui-layout` 注入的第四列(explorer)是无条件渲染的——
40
+ 网格轨道恒为 `panels.explorer`(默认 260px,带侧栏底色与右边框),列内容来自本插件注册的
41
+ `explorer` 插槽;卸载本插件后插槽清空,但补丁仍留在布局 bundle 里(且插件的
42
+ `ensureLayoutPatch` 自愈机制还会在下次启动把补丁打回去),于是永远残留一条空列,
43
+ 刷新/重启都无法消除。修复:布局 store 新增 `explorerOccupied` 标志,`ui-layout` 的
44
+ apply 现在监听 `slots/changed` 事件,实时统计 `explorer` / `explorer.preview` 插槽的
45
+ 注册条目数——没有任何插件贡献时列宽归 0 自动收起,装回插件即刻恢复。
46
+ 补丁脚本新增 **delta 变体**(`npm-delta` / `desktop-ci-delta`):已带旧版补丁的 bundle
47
+ 无需还原即可原地升级到带 occupancy 门槛的新补丁;全新 bundle 走原有完整变体。
48
+ `src/index.ts` 的自愈 marker 列表同步追加 `explorerOccupied`。
49
+ 同时新增 `patch-layout.mjs --restore`(卸载恢复命令):用首次打补丁时备份的
50
+ pristine 副本把 `dsh-client-ui-layout` 还原为官方原始 bundle;备份策略改为
51
+ 「dsh 升级后首次重打时自动刷新 pristine」,并带两道防呆(目标是更新的原生 bundle、
52
+ 或备份属于更旧 dsh 版本时拒绝回滚,`--force` 覆盖)。
53
+
3
54
  ## [0.0.17] - 2026-09-05
4
55
 
5
56
  ### Fixed
package/README.en.md CHANGED
@@ -141,9 +141,19 @@ pnpm run typecheck
141
141
 
142
142
  ```powershell
143
143
  dsh plugin --profile web remove dsh-plugin-workbench
144
- # and restore the layout bundle (patches/layout.backup/client.js.orig dsh-client-ui-layout/lib/client.js)
144
+ # optional but recommended: restore the layout bundle to the stock dsh file
145
+ node node_modules/dsh-plugin-workbench/scripts/patch-layout.mjs --restore
146
+ # restart dsh web
145
147
  ```
146
148
 
149
+ > Skipping `--restore` is fine too: since 0.0.18 the layout patch is adaptive —
150
+ > the explorer column auto-collapses (width 0) whenever no plugin contributes to
151
+ > the `explorer` / `explorer.preview` slots, so an uninstall leaves no empty
152
+ > column behind. `--restore` simply returns the bundle to its pristine,
153
+ > unpatched state. The script has two safety guards: it refuses to restore when
154
+ > the target already looks like a newer pristine bundle (just-upgraded dsh) or
155
+ > when the backup belongs to an older dsh version (`--force` overrides).
156
+
147
157
  ## Notes
148
158
 
149
159
  - The `/dsh-plugin-files` RPC channel is loopback-only; write operations are executed explicitly under
@@ -185,6 +195,7 @@ alongside it:
185
195
  | [dsh-plugin-image-tools](https://www.npmjs.com/package/dsh-plugin-image-tools) | [GitHub repo](https://github.com/Pasumao/dsh-plugin-image-tools) | Image choice cards + inline images in replies + image intake for text-only models |
186
196
  | [dsh-plugin-table-zoom](https://www.npmjs.com/package/dsh-plugin-table-zoom) | [GitHub repo](https://github.com/Pasumao/dsh-plugin-table-zoom) | Floating window for long chat tables + one-click Markdown copy |
187
197
  | [dsh-plugin-windows-guard](https://www.npmjs.com/package/dsh-plugin-windows-guard) | [GitHub repo](https://github.com/Pasumao/dsh-plugin-windows-guard) | Windows environment safeguards: guideline skills + mojibake detection / dangerous-write interception / encoding diagnosis & repair |
198
+ | [dsh-plugin-context-trim](https://www.npmjs.com/package/dsh-plugin-context-trim) | [GitHub repo](https://github.com/Pasumao/dsh-plugin-context-trim) | Per-session injection gate: trim skills / tools / prompt sections per session |
188
199
 
189
200
  > For the remaining plugins in the series, see [Pasumao · dsh plugins](https://github.com/Pasumao); if you find them useful, a ⭐ on GitHub is always welcome.
190
201
 
package/README.md CHANGED
@@ -130,9 +130,17 @@ pnpm run typecheck
130
130
 
131
131
  ```powershell
132
132
  dsh plugin --profile web remove dsh-plugin-workbench
133
- # 并还原布局 bundle(patches/layout.backup/client.js.orig → dsh-client-ui-layout/lib/client.js)
133
+ # 可选:把布局 bundle 还原为官方原始版本(推荐,卸载后干净如初)
134
+ node node_modules/dsh-plugin-workbench/scripts/patch-layout.mjs --restore
135
+ # 重启 dsh web
134
136
  ```
135
137
 
138
+ > 不跑 `--restore` 也没关系:0.0.18 起布局补丁会自适应——explorer 列在没有插件
139
+ > 往 `explorer` / `explorer.preview` 插槽注册内容时自动收起(宽度归 0),
140
+ > 卸载后不会残留空列。`--restore` 只是把它还原成完全未打补丁的原始 bundle。
141
+ > 脚本带两道防呆:目标看起来已是未打补丁的新版 bundle(dsh 刚升级过)、或备份
142
+ > 属于更旧的 dsh 版本时,会拒绝回滚并提示(`--force` 可强行恢复)。
143
+
136
144
  ## 说明
137
145
 
138
146
  - `/dsh-plugin-files` RPC 通道仅限 loopback;写操作显式以 `danger-full-access` 执行;
@@ -171,6 +179,7 @@ dsh plugin --profile web remove dsh-plugin-workbench
171
179
  | [dsh-plugin-table-zoom](https://www.npmjs.com/package/dsh-plugin-table-zoom) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-table-zoom) | 聊天长表格浮窗查看 + 一键复制 Markdown |
172
180
  | [dsh-plugin-windows-guard](https://www.npmjs.com/package/dsh-plugin-windows-guard) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-windows-guard) | Windows 环境防坑:守则技能 + 乱码检测 / 危险写拦截 / 编码诊断修复 |
173
181
 
182
+ | [dsh-plugin-context-trim](https://www.npmjs.com/package/dsh-plugin-context-trim) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-context-trim) | 会话注入门控:skill / tool / 提示词段落按会话裁剪 |
174
183
  > 本系列其余插件见 [Pasumao · dsh 插件](https://github.com/Pasumao);觉得好用欢迎到 GitHub 点 ⭐。
175
184
 
176
185
  ## AI 生成声明
package/lib/index.js CHANGED
@@ -165,10 +165,13 @@ function pathOf(payload) {
165
165
  * fine — this is the exact failure this auto-heal guards against.
166
166
  */
167
167
  const LAYOUT_PATCH_MARKERS = [
168
- "\"explorerCol\": \"",
168
+ "\"explorerCol\": \"pI_x6G_explorerCol\"",
169
169
  "setExplorer: (d, px) => {",
170
170
  "renderSlot(\"explorer\"",
171
- "conversationSeat"
171
+ "renderSlot(\"explorer.preview\"",
172
+ "conversationSeat",
173
+ "explorerOccupied",
174
+ "entries(\"explorer\").length + ctx.slots"
172
175
  ];
173
176
  /** Resolve the installed dsh-client-ui-layout client bundle (profile node_modules junction). */
174
177
  function layoutClientPath() {
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 for the DeepSeek Harness web GUI — editable preview with syntax highlighting, tabs and line numbers, full right-click file operations (create / rename / delete / copy / cut / paste / reveal in file manager), inline image & markdown preview, @-mention files into chat, drag files into the composer, per-workspace state with undo. Turns the dsh web page into a lightweight code editor.",
4
- "version": "0.0.17",
4
+ "version": "0.0.20",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -21,6 +21,14 @@
21
21
  *
22
22
  * Usage:
23
23
  * node scripts/patch-layout.mjs [--target <abs path to client.js>] [--force]
24
+ * node scripts/patch-layout.mjs [--target <abs path to client.js>] --restore
25
+ *
26
+ * --restore copies the pristine backup (patches/layout.backup/client.js.orig,
27
+ * written by the first successful patch) back over the target — the uninstall
28
+ * recovery path: after removing this plugin, run this once and restart dsh web
29
+ * to get the stock dsh-client-ui-layout bundle back. Without it a leftover
30
+ * patch is harmless since 0.0.18 (the explorer column auto-hides when no
31
+ * plugin contributes to its slots), but restoring is the clean state.
24
32
  *
25
33
  * Default target resolves the profile junction to its npx-cache copy:
26
34
  * <DSH_HOME>/profiles/node_modules/@deepseek-ai/dsh-client-ui-layout/lib/client.js
@@ -37,6 +45,7 @@ const BACKUP_DIR = join(REPO_ROOT, 'patches', 'layout.backup')
37
45
  const args = process.argv.slice(2)
38
46
  const targetArg = args.includes('--target') ? args[args.indexOf('--target') + 1] : undefined
39
47
  const force = args.includes('--force')
48
+ const restore = args.includes('--restore')
40
49
 
41
50
  const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
42
51
  const defaultTarget = join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-client-ui-layout', 'lib', 'client.js')
@@ -125,17 +134,32 @@ const REPLACEMENTS = [
125
134
  {
126
135
  id: 'store.init.explorer',
127
136
  anchor: '\t\t\t\t\tsidebar: 280,\n\t\t\t\t\tdetails: 0,',
128
- replacement: '\t\t\t\t\tsidebar: 280,\n\t\t\t\t\texplorer: 260,\n\t\t\t\t\tdetails: 0,',
137
+ replacement: '\t\t\t\t\tsidebar: 280,\n\t\t\t\t\texplorer: 260,\n\t\t\t\t\texplorerOccupied: false,\n\t\t\t\t\tdetails: 0,',
129
138
  },
130
139
  {
131
140
  id: 'store.action.setExplorer',
132
141
  anchor: '\t\t\t\t\tsetDetails: (d, px) => {\n\t\t\t\t\t\td.details = clampWidth(px, 300, 520);\n\t\t\t\t\t},',
133
- replacement: '\t\t\t\t\tsetDetails: (d, px) => {\n\t\t\t\t\t\td.details = clampWidth(px, 300, 520);\n\t\t\t\t\t},\n\t\t\t\t\tsetExplorer: (d, px) => {\n\t\t\t\t\t\td.explorer = clampWidth(px, 200, 420);\n\t\t\t\t\t},',
142
+ replacement: '\t\t\t\t\tsetDetails: (d, px) => {\n\t\t\t\t\t\td.details = clampWidth(px, 300, 520);\n\t\t\t\t\t},\n\t\t\t\t\tsetExplorer: (d, px) => {\n\t\t\t\t\t\td.explorer = clampWidth(px, 200, 420);\n\t\t\t\t\t},\n\t\t\t\t\tsetExplorerOccupied: (d, occupied) => {\n\t\t\t\t\t\td.explorerOccupied = occupied;\n\t\t\t\t\t},',
143
+ },
144
+ {
145
+ id: 'controller.setExplorerOccupied',
146
+ anchor: '\t\t\tcloseDetails() {\n\t\t\t\tthis.#require().closeDetails();\n\t\t\t}',
147
+ replacement: '\t\t\tcloseDetails() {\n\t\t\t\tthis.#require().closeDetails();\n\t\t\t}\n\t\t\tsetExplorerOccupied(occupied) {\n\t\t\t\tif (this.#panels !== void 0) this.#panels.setExplorerOccupied(occupied);\n\t\t\t}',
148
+ },
149
+ {
150
+ id: 'apply.explorerOccupancy',
151
+ anchor: '\t\t\t\t\tinject: (actions) => {\n\t\t\t\t\t\tlayout.attachPanels(actions);\n\t\t\t\t\t\treturn {};\n\t\t\t\t\t}',
152
+ replacement: '\t\t\t\t\tinject: (actions) => {\n\t\t\t\t\t\tlayout.attachPanels(actions);\n\t\t\t\t\t\tlayout.setExplorerOccupied(ctx.slots.entries("explorer").length + ctx.slots.entries("explorer.preview").length > 0);\n\t\t\t\t\t\treturn {};\n\t\t\t\t\t}',
153
+ },
154
+ {
155
+ id: 'apply.explorerOccupancyWatch',
156
+ anchor: '\t\t\t\treturn () => {\n\t\t\t\t\tdisposeRegistration();\n\t\t\t\t\tdisposeService();\n\t\t\t\t};\n\t\t\t}, "ui-layout: service + root registration");',
157
+ replacement: '\t\t\t\treturn () => {\n\t\t\t\t\tdisposeRegistration();\n\t\t\t\t\tdisposeService();\n\t\t\t\t};\n\t\t\t}, "ui-layout: service + root registration");\n\t\t\tctx.effect(() => {\n\t\t\t\tconst syncExplorer = () => {\n\t\t\t\t\tlayout.setExplorerOccupied(ctx.slots.entries("explorer").length > 0 || ctx.slots.entries("explorer.preview").length > 0);\n\t\t\t\t};\n\t\t\t\tsyncExplorer();\n\t\t\t\treturn ctx.on("slots/changed", syncExplorer);\n\t\t\t}, "ui-layout: explorer occupancy sync");',
134
158
  },
135
159
  {
136
160
  id: 'appframe.computeCall',
137
161
  anchor: 'const cols = computeColumns(viewport, sidebarCollapsed ? 0 : panels.sidebar === 0 ? 280 : panels.sidebar, detailsSession === void 0 ? 0 : panels.details);',
138
- replacement: 'const explorerEffective = narrow ? 0 : panels.explorer;\n\t\t\tconst cols = computeColumns(viewport, sidebarCollapsed ? 0 : panels.sidebar === 0 ? 280 : panels.sidebar, explorerEffective, detailsSession === void 0 ? 0 : panels.details);',
162
+ replacement: 'const explorerEffective = narrow || !panels.explorerOccupied ? 0 : panels.explorer;\n\t\t\tconst cols = computeColumns(viewport, sidebarCollapsed ? 0 : panels.sidebar === 0 ? 280 : panels.sidebar, explorerEffective, detailsSession === void 0 ? 0 : panels.details);',
139
163
  },
140
164
  {
141
165
  id: 'appframe.explorerBase',
@@ -257,8 +281,55 @@ const REPLACEMENTS = [
257
281
  // desktop-ci variant: same table, with `computeColumns` swapped for the anchor
258
282
  // pair extracted mechanically from a real DSH Desktop packaged bundle.
259
283
  const DESKTOP_CI = JSON.parse(readFileSync(join(SCRIPT_DIR, 'layout-anchors.desktop-ci.json'), 'utf8'))
284
+
285
+ /**
286
+ * Delta table for bundles already carrying the PREVIOUS revision of this patch
287
+ * (explorer column without occupancy gating). Anchors exist only in that
288
+ * intermediate state, so these variants never match a pristine bundle — the
289
+ * full variants above win there. Same for both npm and desktop-ci builds:
290
+ * none of the delta anchors touch the `computeColumns` body.
291
+ */
292
+ const DELTA_REPLACEMENTS = [
293
+ {
294
+ id: 'delta.appframe.computeCall',
295
+ anchor: 'const explorerEffective = narrow ? 0 : panels.explorer;',
296
+ replacement: 'const explorerEffective = narrow || !panels.explorerOccupied ? 0 : panels.explorer;',
297
+ },
298
+ {
299
+ id: 'delta.store.init',
300
+ anchor: '\t\t\t\t\texplorer: 260,\n\t\t\t\t\tdetails: 0,',
301
+ replacement: '\t\t\t\t\texplorer: 260,\n\t\t\t\t\texplorerOccupied: false,\n\t\t\t\t\tdetails: 0,',
302
+ },
303
+ {
304
+ id: 'delta.store.action',
305
+ anchor: '\t\t\t\t\tsetExplorer: (d, px) => {\n\t\t\t\t\t\td.explorer = clampWidth(px, 200, 420);\n\t\t\t\t\t},',
306
+ replacement: '\t\t\t\t\tsetExplorer: (d, px) => {\n\t\t\t\t\t\td.explorer = clampWidth(px, 200, 420);\n\t\t\t\t\t},\n\t\t\t\t\tsetExplorerOccupied: (d, occupied) => {\n\t\t\t\t\t\td.explorerOccupied = occupied;\n\t\t\t\t\t},',
307
+ },
308
+ {
309
+ id: 'delta.controller',
310
+ anchor: '\t\t\tcloseDetails() {\n\t\t\t\tthis.#require().closeDetails();\n\t\t\t}',
311
+ replacement: '\t\t\tcloseDetails() {\n\t\t\t\tthis.#require().closeDetails();\n\t\t\t}\n\t\t\tsetExplorerOccupied(occupied) {\n\t\t\t\tif (this.#panels !== void 0) this.#panels.setExplorerOccupied(occupied);\n\t\t\t}',
312
+ },
313
+ {
314
+ id: 'delta.watch',
315
+ anchor: '\t\t\t\treturn () => {\n\t\t\t\t\tdisposeRegistration();\n\t\t\t\t\tdisposeService();\n\t\t\t\t};\n\t\t\t}, "ui-layout: service + root registration");',
316
+ replacement: '\t\t\t\treturn () => {\n\t\t\t\t\tdisposeRegistration();\n\t\t\t\t\tdisposeService();\n\t\t\t\t};\n\t\t\t}, "ui-layout: service + root registration");\n\t\t\tctx.effect(() => {\n\t\t\t\tconst syncExplorer = () => {\n\t\t\t\t\tlayout.setExplorerOccupied(ctx.slots.entries("explorer").length > 0 || ctx.slots.entries("explorer.preview").length > 0);\n\t\t\t\t};\n\t\t\t\tsyncExplorer();\n\t\t\t\treturn ctx.on("slots/changed", syncExplorer);\n\t\t\t}, "ui-layout: explorer occupancy sync");',
317
+ },
318
+ {
319
+ id: 'delta.apply',
320
+ anchor: '\t\t\t\t\tinject: (actions) => {\n\t\t\t\t\t\tlayout.attachPanels(actions);\n\t\t\t\t\t\treturn {};\n\t\t\t\t\t}',
321
+ replacement: '\t\t\t\t\tinject: (actions) => {\n\t\t\t\t\t\tlayout.attachPanels(actions);\n\t\t\t\t\t\tlayout.setExplorerOccupied(ctx.slots.entries("explorer").length + ctx.slots.entries("explorer.preview").length > 0);\n\t\t\t\t\t\treturn {};\n\t\t\t\t\t}',
322
+ },
323
+ ]
324
+
325
+ /** Delta #2: bundles carrying the FIRST occupancy delta (watch effect only, no
326
+ * inject-hook sync). Just the inject-hook injection; the watch block already exists. */
327
+ const DELTA2_REPLACEMENTS = DELTA_REPLACEMENTS.filter((item) => item.id === 'delta.apply')
328
+
260
329
  const VARIANTS = [
261
330
  { id: 'npm', replacements: REPLACEMENTS },
331
+ { id: 'npm-delta', replacements: DELTA_REPLACEMENTS },
332
+ { id: 'npm-delta2', replacements: DELTA2_REPLACEMENTS },
262
333
  {
263
334
  id: 'desktop-ci',
264
335
  replacements: REPLACEMENTS.map((item) =>
@@ -267,9 +338,11 @@ const VARIANTS = [
267
338
  : item
268
339
  ),
269
340
  },
341
+ { id: 'desktop-ci-delta', replacements: DELTA_REPLACEMENTS },
342
+ { id: 'desktop-ci-delta2', replacements: DELTA2_REPLACEMENTS },
270
343
  ]
271
344
 
272
- const PATCHED_MARKERS = ['"explorerCol": "pI_x6G_explorerCol"', 'setExplorer: (d, px) => {', 'renderSlot("explorer"', 'renderSlot("explorer.preview"', 'conversationSeat']
345
+ const PATCHED_MARKERS = ['"explorerCol": "pI_x6G_explorerCol"', 'setExplorer: (d, px) => {', 'renderSlot("explorer"', 'renderSlot("explorer.preview"', 'conversationSeat', 'explorerOccupied', 'entries("explorer").length + ctx.slots']
273
346
 
274
347
  function applyReplacements(original, replacements) {
275
348
  let patched = original
@@ -300,6 +373,44 @@ function main() {
300
373
  const real = realpathSync(target)
301
374
  const original = readFileSync(real, 'utf8')
302
375
 
376
+ // Uninstall recovery: copy the pristine pre-patch bundle back over the
377
+ // target. Runs before every other check — restoring must work regardless of
378
+ // the current bundle's patch state.
379
+ if (restore) {
380
+ const pristine = join(BACKUP_DIR, 'client.js.orig')
381
+ if (!existsSync(pristine)) {
382
+ console.error('[patch-layout] no pristine backup found at ' + pristine)
383
+ console.error('[patch-layout] recover by reinstalling the package instead:')
384
+ console.error(' cd <dsh profile dir> && pnpm install # (or upgrade dsh — the bundle ships with it)')
385
+ process.exit(1)
386
+ }
387
+ // Staleness guard 1: the target may be a NEWER pristine from a dsh upgrade
388
+ // (nothing to restore). Refuse unless --force.
389
+ const looksPatched = PATCHED_MARKERS.some((marker) => original.includes(marker))
390
+ // Staleness guard 2: the backup must itself be a pristine this script can
391
+ // still patch (an old-dsh backup is useless on a newer dsh — restoring it
392
+ // would be a silent downgrade). Refuse unless --force.
393
+ const backupText = readFileSync(pristine, 'utf8')
394
+ const backupPatchable = VARIANTS.filter((v) => v.id !== 'npm-delta' && v.id !== 'desktop-ci-delta')
395
+ .some((variant) => applyReplacements(backupText, variant.replacements).failures.length === 0)
396
+ if ((!looksPatched || !backupPatchable) && !force) {
397
+ if (!looksPatched) {
398
+ console.error('[patch-layout] target does not look patched — it may be a newer pristine bundle from a dsh upgrade.')
399
+ }
400
+ if (!backupPatchable) {
401
+ console.error('[patch-layout] the pristine backup predates the installed dsh version (its anchors no longer match).')
402
+ console.error('[patch-layout] recover by reinstalling the package instead:')
403
+ console.error(' cd <dsh profile dir> && pnpm install # (or upgrade dsh — the bundle ships with it)')
404
+ }
405
+ console.error('[patch-layout] refusing a risky restore; pass --force to restore anyway.')
406
+ process.exit(1)
407
+ }
408
+ copyFileSync(pristine, real)
409
+ console.log(`[patch-layout] restored the pristine dsh-client-ui-layout bundle: ${real}`)
410
+ console.log('[patch-layout] restart dsh web to serve the restored bundle.')
411
+ return
412
+ }
413
+
303
414
  const alreadyPatched = PATCHED_MARKERS.every((marker) => original.includes(marker))
304
415
  if (alreadyPatched && !force) {
305
416
  console.log(`[patch-layout] already patched (${real}) — nothing to do.`)
@@ -324,9 +435,23 @@ function main() {
324
435
 
325
436
  mkdirSync(BACKUP_DIR, { recursive: true })
326
437
  const pristine = join(BACKUP_DIR, 'client.js.orig')
327
- if (!existsSync(pristine)) {
438
+ // client.js.orig must hold the PRISTINE bundle of the CURRENT dsh version —
439
+ // it is what --restore hands back on uninstall. A dsh upgrade reverts the
440
+ // bundle to a fresh pristine, so whenever the pre-patch target itself looks
441
+ // unpatched, promote it (the previous orig belongs to an older dsh and would
442
+ // otherwise be restored over a newer install — a silent downgrade). Only a
443
+ // pre-patch state that already carries our markers gets the timestamped bak.
444
+ const targetLooksPatched = PATCHED_MARKERS.some((marker) => original.includes(marker))
445
+ const hadPristine = existsSync(pristine)
446
+ // The shared pristine backup backs --restore for the DEFAULT profile bundle.
447
+ // With an explicit --target (testing / another machine's bundle) never touch
448
+ // it — an unrelated file must not overwrite the restore source.
449
+ if (targetArg !== undefined) {
450
+ console.log('[patch-layout] --target given: skipping backup bookkeeping (the shared pristine backup is only managed for the default profile bundle).')
451
+ } else if (!hadPristine || !targetLooksPatched) {
328
452
  copyFileSync(real, pristine)
329
- console.log(`[patch-layout] pristine backup written: ${pristine}`)
453
+ if (!hadPristine) console.log(`[patch-layout] pristine backup written: ${pristine}`)
454
+ else console.log('[patch-layout] pristine backup refreshed (dsh upgrade detected — the old orig was a different version).')
330
455
  } else {
331
456
  const stamp = new Date().toISOString().replace(/[:.]/g, '-')
332
457
  copyFileSync(real, join(BACKUP_DIR, `client.js.${stamp}.bak`))
package/src/index.ts CHANGED
@@ -262,10 +262,22 @@ function pathOf(payload: unknown): string | undefined {
262
262
  * fine — this is the exact failure this auto-heal guards against.
263
263
  */
264
264
  const LAYOUT_PATCH_MARKERS = [
265
- '"explorerCol": "',
265
+ // Keep in exact sync with PATCHED_MARKERS in scripts/patch-layout.mjs — a
266
+ // weaker subset here means a damaged/partially-reverted bundle could pass
267
+ // the host check yet fail the script's verification, and auto-heal never
268
+ // fires for it.
269
+ '"explorerCol": "pI_x6G_explorerCol"',
266
270
  'setExplorer: (d, px) => {',
267
271
  'renderSlot("explorer"',
272
+ 'renderSlot("explorer.preview"',
268
273
  'conversationSeat',
274
+ // 0.0.18+: occupancy gating — the explorer column must auto-hide when no
275
+ // plugin contributes to the explorer / explorer.preview slots (issue #2).
276
+ // 0.0.19: the sync must ALSO run inside the root registration's inject hook —
277
+ // apply-time sync lands before attachPanels (a no-op) and after the one-shot
278
+ // registration emit, so without this the column stayed collapsed forever.
279
+ 'explorerOccupied',
280
+ 'entries("explorer").length + ctx.slots',
269
281
  ] as const
270
282
 
271
283
  /** Resolve the installed dsh-client-ui-layout client bundle (profile node_modules junction). */