diff --git a/01_content/client-factory-gate.mjs b/01_content/client-factory-gate.mjs
new file mode 100644
index 0000000..6817420
--- /dev/null
+++ b/01_content/client-factory-gate.mjs
@@ -0,0 +1,143 @@
+/**
+ * dsh-worktable 客户端工厂求值门禁(供 release-prep 调用;独立成模块便于失败测试)。
+ *
+ * 定位:这是「工厂求值门禁」,不是客户端端到端验收——只验证 bundle 在
+ * window.__ModuleLoader__.load 握手下能被真实求值一次,能抓住:
+ * - 顶层 TDZ / 求值异常(如 const Ge=Ge)
+ * - ModuleLoader ID 登记错误
+ * - 工厂引用了未授权的外部模块
+ * - 导出结构错误(apply 非函数 / inject 内容不对)
+ * 抓不到:React 真实渲染异常、apply(ctx) 与真实宿主服务的兼容、浏览器环境差异——
+ * 这些属于真实 DSH 冷启动验收(独立步骤)。
+ */
+import { createContext, runInNewContext } from 'node:vm'
+
+/** 客户端 external 精确白名单——当前真实 bundle 只 require react / react/jsx-runtime。
+ * 新增任何宿主依赖必须显式审核后加入此清单(防引用已删除宿主模块导致白屏)。 */
+export const CLIENT_EXTERNAL_ALLOW = [
+ 'react',
+ 'react/jsx-runtime',
+]
+
+/** 递归万能桩:任何属性访问/调用/构造都返回自身同型桩(覆盖 react 导出树与 jsx 调用)。 */
+function makeStub() {
+ const stub = function () { return makeStub() }
+ return new Proxy(stub, {
+ get: (_t, k) => {
+ if (k === Symbol.toPrimitive) return () => 'stub'
+ return makeStub()
+ },
+ apply: () => makeStub(),
+ construct: () => makeStub(),
+ })
+}
+
+/**
+ * 求值一个 client bundle 源码:捕获 window.__ModuleLoader__.load 的注册结果并执行工厂。
+ * @param source - lib/client.js 全文
+ * @param expectedId - 期望的 ModuleLoader ID('dsh-worktable')
+ * @param expectedInject - 期望的客户端 inject 数组
+ * @returns {{ exports: any, id: string }} 工厂执行结果
+ * @throws {Error} 任何门禁失败(TDZ/ID 错误/未知依赖/导出结构错误)
+ */
+export function gateClientFactory(source, expectedId, expectedInject) {
+ let captured = null
+ let loadCalls = 0
+ const actualRequires = new Set()
+ const localStorageStub = { getItem: () => null, setItem: () => {}, removeItem: () => {} }
+ const docStub = new Proxy(function () { return makeStub() }, {
+ get: (_t, k) => {
+ if (k === 'body' || k === 'documentElement') return makeStub()
+ if (k === 'createElement') return () => makeStub()
+ if (k === 'querySelector' || k === 'querySelectorAll' || k === 'getElementById') return () => null
+ if (k === 'addEventListener' || k === 'removeEventListener') return () => {}
+ if (k === Symbol.toPrimitive) return () => 'stub'
+ return makeStub()
+ },
+ apply: () => makeStub(),
+ })
+ const sandbox = {
+ window: {},
+ console: { log() {}, warn() {}, error() {} },
+ setTimeout, clearTimeout,
+ localStorage: localStorageStub,
+ document: docStub,
+ atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
+ btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
+ Blob: function Blob() {},
+ URL: { createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} },
+ MutationObserver: function MutationObserver() { return { observe: () => {}, disconnect: () => {} } },
+ ResizeObserver: function ResizeObserver() { return { observe: () => {}, unobserve: () => {}, disconnect: () => {} } },
+ IntersectionObserver: function IntersectionObserver() { return { observe: () => {}, unobserve: () => {}, disconnect: () => {} } },
+ requestAnimationFrame: (cb) => 0,
+ cancelAnimationFrame: () => {},
+ navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0', platform: 'Win32', language: 'zh-CN' },
+ fetch: () => Promise.reject(new Error('fetch unavailable in gate sandbox')),
+ indexedDB: undefined,
+ }
+ sandbox.window.__ModuleLoader__ = {
+ load: (reg) => {
+ loadCalls++
+ if (loadCalls > 1) throw new Error('window.__ModuleLoader__.load called more than once')
+ captured = reg
+ },
+ }
+ sandbox.window.addEventListener = () => {}
+ sandbox.window.removeEventListener = () => {}
+ sandbox.window.matchMedia = () => ({ matches: false, addEventListener: () => {}, removeEventListener: () => {} })
+ sandbox.window.localStorage = localStorageStub
+ sandbox.window.document = docStub
+ sandbox.self = sandbox.window // 浏览器全局 self === window
+ createContext(sandbox)
+ try {
+ // VM 超时:错误/恶意 bundle 的顶层无限循环不能卡死发布流程(10s)
+ runInNewContext(source, sandbox, { filename: 'client.js', timeout: 10000 })
+ } catch (e) {
+ throw new Error('client factory evaluation failed: ' + (e && e.message ? e.message : String(e)))
+ }
+ if (loadCalls === 0) {
+ throw new Error('window.__ModuleLoader__.load was never called')
+ }
+ if (!captured || typeof captured !== 'object') {
+ throw new Error('window.__ModuleLoader__.load was never called')
+ }
+ if (captured.id !== expectedId) {
+ throw new Error('ModuleLoader ID mismatch: got ' + JSON.stringify(captured.id) + ', expected ' + expectedId)
+ }
+ if (typeof captured.factory !== 'function') {
+ throw new Error('registered factory is not a function')
+ }
+ const requireStub = (id) => {
+ if (typeof id !== 'string') throw new Error('require called with non-string id')
+ actualRequires.add(id)
+ if (CLIENT_EXTERNAL_ALLOW.includes(id)) return makeStub()
+ throw new Error('unexpected external require: ' + id)
+ }
+ // 工厂调用放进受 timeout 控制的 vm 执行(防死循环卡死发布流程)
+ const runSandbox = { __factory: captured.factory, __requireStub: requireStub }
+ createContext(runSandbox)
+ let exportsObj
+ try {
+ exportsObj = runInNewContext('__factory(__requireStub)', runSandbox, { timeout: 10000 })
+ } catch (e) {
+ const msg = e && e.message ? e.message : String(e)
+ if (/timed out|script execution/i.test(msg)) throw new Error('factory evaluation timed out (>10s)')
+ throw new Error('factory threw during evaluation: ' + msg)
+ }
+ // 实际 require 集合必须严格等于白名单中已声明且被使用的项(按需子集);空集也放行
+ for (const id of actualRequires) {
+ if (!CLIENT_EXTERNAL_ALLOW.includes(id)) throw new Error('unexpected external require: ' + id)
+ }
+ if (!exportsObj || typeof exportsObj !== 'object') {
+ throw new Error('factory did not return module.exports object')
+ }
+ if (typeof exportsObj.apply !== 'function') {
+ throw new Error('client exports.apply is not a function (got ' + typeof exportsObj.apply + ')')
+ }
+ const inj = exportsObj.inject
+ if (!Array.isArray(inj)) throw new Error('client exports.inject is not an array')
+ const want = JSON.stringify(expectedInject)
+ const got = JSON.stringify(inj)
+ if (want !== got) throw new Error('client inject mismatch: got ' + got + ', expected ' + want)
+ return { id: captured.id, exports: exportsObj, actualRequires: [...actualRequires].sort() }
+}
diff --git a/01_content/client-factory-gate.test.mjs b/01_content/client-factory-gate.test.mjs
new file mode 100644
index 0000000..f8db722
--- /dev/null
+++ b/01_content/client-factory-gate.test.mjs
@@ -0,0 +1,72 @@
+/**
+ * client-factory-gate 失败测试:证明 TDZ / 错误 ID / 未知依赖 / 错误 inject 能被拦截。
+ * 用法:node client-factory-gate.test.mjs(不依赖 npm,纯 node;失败用例抛错即测试失败)
+ */
+import { gateClientFactory } from './client-factory-gate.mjs'
+
+const WRAP = (body) => "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports; " + body + " return module.exports; } });"
+const OK_BODY = "exports.apply = function apply() {}; exports.inject = ['slots','locale','sessions','conversation','workspaces'];"
+
+let pass = 0
+function expectFail(name, source, expectId, expectInject, pattern) {
+ try {
+ gateClientFactory(source, expectId, expectInject)
+ console.error('FAIL(should have been blocked): ' + name)
+ process.exit(1)
+ } catch (e) {
+ const msg = String(e && e.message ? e.message : e)
+ if (pattern && !pattern.test(msg)) {
+ console.error('FAIL(wrong reason): ' + name + ' => ' + msg)
+ process.exit(1)
+ }
+ console.log('ok blocked: ' + name + ' => ' + msg)
+ pass++
+ }
+}
+
+// 1. TDZ 顶层自引用(#491 同款 const Ge=Ge)
+expectFail('TDZ self-reference', WRAP("const Ge = Ge;"), 'dsh-worktable', ['slots'], /factory threw|evaluation failed/)
+
+// 2. 错误 ModuleLoader ID
+expectFail('wrong ModuleLoader ID', "window.__ModuleLoader__.load({ id: 'wrong-plugin', factory: (require) => { return { apply() {}, inject: [] } } });", 'dsh-worktable', [], /ID mismatch/)
+
+// 3. 未知外部依赖 require
+expectFail('unknown external require', WRAP("require('totally-unknown-pkg'); exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [], /unexpected external require/)
+
+// 4. 导出结构错误:apply 非函数
+expectFail('apply not a function', WRAP("exports.apply = null; exports.inject = [];"), 'dsh-worktable', [], /apply is not a function/)
+
+// 5. inject 内容错误
+expectFail('inject mismatch', WRAP("exports.apply = function(){}; exports.inject = ['slots'];"), 'dsh-worktable', ['slots','locale'], /inject mismatch/)
+
+// 6. 白名单外 @deepseek-ai 包(曾是最常见的"引用已删除宿主模块白屏")
+expectFail('disallowed @deepseek-ai require', WRAP("require('@deepseek-ai/dsh-client-runtime/client'); exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [], /unexpected external require/)
+
+// 7. 重复注册 ModuleLoader(load 调用超过一次)
+expectFail('double ModuleLoader registration', "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: () => ({ apply() {}, inject: [] }) }); window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: () => ({ apply() {}, inject: [] }) });", 'dsh-worktable', [], /more than once/)
+
+// 8. 正向:合法最小 bundle 通过
+try {
+ gateClientFactory(WRAP(OK_BODY), 'dsh-worktable', ['slots','locale','sessions','conversation','workspaces'])
+ console.log('ok passed: valid minimal bundle')
+ pass++
+} catch (e) {
+ console.error('FAIL(should pass): valid minimal bundle => ' + (e && e.message ? e.message : e))
+ process.exit(1)
+}
+
+// 9. 死循环工厂(>10s 超时拦截;用 11s 忙等模拟)
+expectFail('factory infinite loop timeout', WRAP("var t0 = Date.now(); while (Date.now() - t0 < 11000) {}; exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [], /timed out|>10s/)
+
+// 10. 正向:白名单内 require 被记录(react/jsx-runtime 允许)
+try {
+ const r = gateClientFactory(WRAP("require('react/jsx-runtime'); exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [])
+ if (!r.actualRequires.includes('react/jsx-runtime')) throw new Error('actualRequires not recorded')
+ console.log('ok passed: allowed require recorded')
+ pass++
+} catch (e) {
+ console.error('FAIL(should pass): allowed require => ' + (e && e.message ? e.message : e))
+ process.exit(1)
+}
+
+console.log('all gate tests passed: ' + pass + '/10')
diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json
index b3c93f1..c30b85a 100644
--- a/01_content/dsh.plugin.json
+++ b/01_content/dsh.plugin.json
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
- "version": "0.3.1",
+ "version": "0.3.2",
"entry": {
"name": "dsh-worktable",
"inject": []
diff --git a/01_content/lib/client.js b/01_content/lib/client.js
index 2fecb6b..05afa2b 100644
--- a/01_content/lib/client.js
+++ b/01_content/lib/client.js
@@ -8031,11 +8031,15 @@ var css = xterm_default + "\n" + [
".dsh-wt_addFolderPath{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-primary,#e6e8eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".dsh-wt_addFolderPathNone{color:var(--dsw-alias-label-tertiary,#6b7280);border-style:dashed}",
".dsh-wt_addFolderRow .dsh-wt_customLabel{flex:none;width:auto;text-align:left}",
+ ".dsh-wt_manualPathInput{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-primary,#e6e8eb)}",
+ ".dsh-wt_addFolderRow .dsh-wt_bindFolderChange:disabled{opacity:.5;cursor:default}",
+ ".dsh-wt_folderPickHint{margin-top:6px;margin-bottom:0;font-size:11.5px;line-height:17px;color:var(--dsw-alias-state-warning,#e0a33c)}",
+ ".dsh-wt_bindFolderBox .dsh-wt_addError{margin-top:6px;margin-bottom:0}",
// 绑定弹窗内的项目文件夹区
".dsh-wt_bindFolderBox{margin-top:8px;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px}",
- ".dsh-wt_bindFolderRow{display:flex;align-items:center;justify-content:space-between}",
- ".dsh-wt_bindFolderLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb)}",
- ".dsh-wt_bindFolderChange{flex:none;padding:1px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}",
+ ".dsh-wt_bindFolderRow{display:flex;align-items:center;gap:6px}",
+ ".dsh-wt_bindFolderLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb);margin-right:auto}",
+ ".dsh-wt_bindFolderChange{flex:none;padding:2px 10px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}",
".dsh-wt_bindFolderChange:hover{color:var(--dsw-alias-label-primary,#e6e8eb);border-color:var(--dsw-alias-border-l2,#3a4150)}",
".dsh-wt_bindFolderPath{margin-top:4px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".dsh-wt_bindFolderPathNone{color:var(--dsw-alias-label-tertiary,#6b7280)}",
@@ -8437,6 +8441,16 @@ var zh = {
"add.folderPick": "\u9009\u62E9\u4F4D\u7F6E\u2026",
"add.folderNone": "\u672A\u9009\u62E9\uFF08\u70B9\u53F3\u4FA7\u300C\u9009\u62E9\u4F4D\u7F6E\u2026\u300D\u5728\u8D44\u6E90\u7BA1\u7406\u5668\u7A97\u53E3\u91CC\u6311\u4E00\u4E2A\u6587\u4EF6\u5939\uFF09",
"add.folderRequired": "\u8BF7\u5148\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939\u7684\u4F4D\u7F6E",
+ "add.folderPicking": "\u7B49\u5F85\u9009\u62E9\u2026",
+ "add.folderPickerHint": "\u6B63\u5728\u7B49\u5F85\u6587\u4EF6\u5939\u9009\u62E9\uFF1B\u82E5\u672A\u770B\u5230\u9009\u62E9\u7A97\u53E3\uFF0C\u53EF\u68C0\u67E5\u4EFB\u52A1\u680F\uFF0C\u6216\u4F7F\u7528\u300C\u624B\u52A8\u8F93\u5165\u300D\u3002",
+ "add.folderPickFail": "\u76EE\u5F55\u9009\u62E9\u5931\u8D25\uFF1A{msg}",
+ "add.folderPickerUnavailable": "\u7CFB\u7EDF\u76EE\u5F55\u9009\u62E9\u5668\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7528\u300C\u624B\u52A8\u8F93\u5165\u300D\u7C98\u8D34\u8DEF\u5F84",
+ "add.folderManual": "\u624B\u52A8\u8F93\u5165",
+ "add.folderManualPh": "\u7C98\u8D34\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84\uFF0C\u56DE\u8F66\u786E\u8BA4",
+ "add.folderManualOk": "\u4F7F\u7528\u6B64\u8DEF\u5F84",
+ "add.folderManualEmpty": "\u8BF7\u8F93\u5165\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84",
+ "add.folderManualAbs": "\u8BF7\u8F93\u5165\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 C:\\Users\\me\\Projects \u6216 /home/me/projects\uFF09",
+ "add.folderCreateFail": "\u9879\u76EE\u6587\u4EF6\u5939\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u8DEF\u5F84\u540E\u91CD\u8BD5",
"add.layoutSave": "\u8FDB\u5165\u5DE5\u4F5C\u533A",
"add.layoutInvalid": "\u8BF7\u586B\u5199\u5E03\u5C40\u540D\u79F0",
"manage.deleteLayout": "\u5220\u9664\u5E03\u5C40",
@@ -8657,6 +8671,16 @@ var en = {
"add.folderPick": "Choose\u2026",
"add.folderNone": "Not chosen (click Choose\u2026 to pick a folder in the explorer window)",
"add.folderRequired": "Pick the project folder location first",
+ "add.folderPicking": "Waiting for picker\u2026",
+ "add.folderPickerHint": "Waiting for the folder picker. If no window appears, check the taskbar or use \u201CManual\u201D.",
+ "add.folderPickFail": "Folder pick failed: {msg}",
+ "add.folderPickerUnavailable": "System folder picker unavailable \u2014 paste the path via \u201CManual\u201D",
+ "add.folderManual": "Manual",
+ "add.folderManualPh": "Paste an absolute folder path, Enter to confirm",
+ "add.folderManualOk": "Use this path",
+ "add.folderManualEmpty": "Enter an absolute folder path first",
+ "add.folderManualAbs": "Absolute path required (e.g. C:\\Users\\me\\Projects or /home/me/projects)",
+ "add.folderCreateFail": "Failed to create the project folder \u2014 check the path and retry",
"add.layoutSave": "Enter workspace",
"add.layoutInvalid": "Enter a layout name",
"manage.deleteLayout": "Delete layout",
@@ -8872,9 +8896,19 @@ function basenameOf(p) {
var import_react = require("react");
// src/client/changelog.ts
-var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.1
+var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.2
-\u{1F527} \u672C\u7248\u672C\u4FEE\u590D\u8BF4\u660E\uFF1Av0.3.0 \u53D1\u5E03\u5305\u5B58\u5728\u6253\u5305\u7F3A\u9677\uFF08\u975E\u6807\u51C6 npm \u5305\u683C\u5F0F\uFF0C\u5B89\u88C5\u65F6\u4F1A\u62A5\u9519\u5BFC\u81F4\u63D2\u4EF6\u65E0\u6CD5\u52A0\u8F7D\uFF09\uFF0Cv0.3.1 \u5DF2\u6309\u6807\u51C6\u683C\u5F0F\u91CD\u65B0\u6253\u5305\u3002\u6B64\u524D\u5B89\u88C5\u5931\u8D25\u7684\u7528\u6237\uFF0C\u91CD\u65B0\u6267\u884C\u4E00\u6B21\u5347\u7EA7\u5B89\u88C5\u547D\u4EE4\u5373\u53EF\u6B63\u5E38\u4F7F\u7528\u672C\u7248\u672C\u3002
+\u{1F6E0}\uFE0F \u672C\u7248\u672C\u66F4\u65B0
+
+\u3010\u6587\u4EF6\u5939\u9009\u62E9\u4F53\u9A8C\u3011\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939\u65F6\uFF1A\u7B49\u5F85\u671F\u95F4\u6309\u94AE\u663E\u793A\u300C\u7B49\u5F85\u9009\u62E9\u2026\u300D\u5E76\u63D0\u793A\u300C\u82E5\u672A\u770B\u5230\u9009\u62E9\u7A97\u53E3\uFF0C\u53EF\u68C0\u67E5\u4EFB\u52A1\u680F\uFF0C\u6216\u4F7F\u7528\u624B\u52A8\u8F93\u5165\u300D\uFF1B\u65B0\u589E\u300C\u624B\u52A8\u8F93\u5165\u300D\u5165\u53E3\uFF0C\u53EF\u76F4\u63A5\u7C98\u8D34\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u76F8\u5BF9\u8DEF\u5F84\u4F1A\u88AB\u62D2\u7EDD\u5E76\u63D0\u793A\uFF09\uFF1B\u9009\u62E9\u5931\u8D25\u65F6\u663E\u793A\u5177\u4F53\u9519\u8BEF\uFF0C\u4E0D\u518D\u9759\u9ED8\u65E0\u53CD\u5E94\uFF1B\u4FEE\u590D\u4E86\u65E7\u9009\u62E9\u7A97\u53E3\u8FD4\u56DE\u540E\u8986\u76D6\u624B\u5DE5\u8F93\u5165\u3001\u5F39\u7A97\u5173\u95ED/\u5207\u6362\u540E\u72B6\u6001\u6B8B\u7559\u7B49\u95EE\u9898\u3002
+
+\u3010\u53D1\u5E03\u6D41\u7A0B\u52A0\u56FA\u3011\u53D1\u5E03\u5305\u65B0\u589E\u7ED3\u6784\u4E0E\u8EAB\u4EFD\u6821\u9A8C\u3001\u5BA2\u6237\u7AEF\u5DE5\u5382\u6C42\u503C\u95E8\u7981\u3001\u53CC\u8D44\u4EA7\u540C\u6E90\u6838\u5BF9\u4E0E\u8FDC\u7AEF SHA \u6BD4\u5BF9\uFF08\u5F00\u53D1\u8005\u4FA7\u6539\u8FDB\uFF0C\u4E0D\u5F71\u54CD\u63D2\u4EF6\u529F\u80FD\uFF09\u3002
+
+\u3010\u8FB9\u754C\u8BF4\u660E\u3011\u672C\u7248\u672C\u662F\u63D2\u4EF6\u4FA7\u4F53\u9A8C\u6539\u5584\u4E0E\u53D1\u5E03\u6D41\u7A0B\u52A0\u56FA\uFF1A\u4E0D\u6539\u53D8\u5BBF\u4E3B\u539F\u751F\u76EE\u5F55\u9009\u62E9\u5668\u7684\u884C\u4E3A\uFF08\u9009\u62E9\u7A97\u53E3\u4E0D\u7F6E\u524D\u5C5E DSH \u5BBF\u4E3B\u5DF2\u77E5\u95EE\u9898\uFF0C\u7B49\u5F85\u5B98\u65B9\u4FEE\u590D\uFF09\uFF0C\u4E5F\u4E0D\u4EE3\u8868\u6240\u6709\u542F\u52A8\u5931\u8D25\u5DF2\u89E3\u51B3\u3002\u5347\u7EA7\u5B89\u88C5\u540E\u8BF7\u5B8C\u6574\u9000\u51FA\u5E76\u91CD\u542F DSH \u518D\u5237\u65B0\u9875\u9762\uFF1B\u82E5\u91CD\u542F\u540E\u4ECD\u663E\u793A\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u4FDD\u7559\u5B8C\u6574\u9519\u8BEF\u4FE1\u606F\u53CD\u9988\u3002
+
+\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+
+\u{1F527} \u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.3.1\uFF09\uFF1Av0.3.0 \u53D1\u5E03\u5305\u5B58\u5728\u6253\u5305\u7F3A\u9677\uFF08\u975E\u6807\u51C6 npm \u5305\u683C\u5F0F\uFF0C\u5B89\u88C5\u65F6\u62A5\u9519\u5BFC\u81F4\u63D2\u4EF6\u65E0\u6CD5\u52A0\u8F7D\uFF09\uFF0Cv0.3.1 \u5DF2\u6309\u6807\u51C6\u683C\u5F0F\u91CD\u65B0\u6253\u5305\u3002
\u{1F6E0}\uFE0F \u4E00\u3001\u63A7\u5236\u5BA4 UI \u8C03\u6574
@@ -8902,7 +8936,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.1
\u30103.1 \u6587\u5B57\u4E0E\u7EC6\u8282\u6253\u78E8\u3011\u5168\u5C40\u5B57\u4F53\u4E0E\u5B57\u53F7\u7EDF\u4E00\u4F18\u5316\u3001\u4E0B\u62C9\u9762\u677F\u73BB\u7483\u5316\u4E0E\u5BF9\u9F50\u3001\u83DC\u5355\u70B9\u9009\u540E\u4FDD\u6301\u6253\u5F00\u4FBF\u4E8E\u8FDE\u7EED\u9884\u89C8\u3001\u6309\u94AE\u63CF\u8FB9\u4E0E\u60AC\u505C\u53CD\u9988\u7B49\u4EA4\u4E92\u7EC6\u8282\uFF1B\u540C\u65F6\u4FEE\u590D\u4E86\u591A\u9879\u4F53\u9A8C\u95EE\u9898\uFF08\u7167\u7247\u4E0A\u4F20\u6E05\u6670\u5EA6\u3001\u80CC\u666F\u7F51\u683C\u7EBF\u5728\u7167\u7247\u6A21\u5F0F\u4E0B\u4E0D\u751F\u6548\u3001\u6D45\u8272\u4E3B\u9898\u4E0B\u5DE5\u4F5C\u72B6\u6001\u5149\u6548\u4E0D\u53EF\u89C1\u7B49\uFF09\u3002`;
// src/client/updateCheck.ts
-var LOCAL_VERSION = false ? "dev" : "0.3.1";
+var LOCAL_VERSION = false ? "dev" : "0.3.2";
var UPDATE_REPO = "Aisland-SJL/dsh-worktable";
var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1";
var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1";
@@ -20008,7 +20042,7 @@ var WAVE_BG_B64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQUFBAYFBQUHBgYHCQ8KCQgIC
// src/client/index.tsx
var import_jsx_runtime2 = require("react/jsx-runtime");
-var LOCAL_VERSION2 = false ? "dev" : "0.3.1";
+var LOCAL_VERSION2 = false ? "dev" : "0.3.2";
var UPDATE_REPO2 = "Aisland-SJL/dsh-worktable";
var UPGRADE_CMD2 = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"';
var UPGRADE_AI2 = "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + UPGRADE_CMD2 + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762";
@@ -21040,6 +21074,16 @@ function WorktableSection(props) {
const [wsError, setWsError] = (0, import_react2.useState)(false);
const [wsFolderParent, setWsFolderParent] = (0, import_react2.useState)("");
const [wsFolderError, setWsFolderError] = (0, import_react2.useState)(false);
+ const [pickBusy, setPickBusy] = (0, import_react2.useState)(false);
+ const [pickErr, setPickErr] = (0, import_react2.useState)({ add: "", bind: "" });
+ const [manualPathFor, setManualPathFor] = (0, import_react2.useState)(null);
+ const [manualPathText, setManualPathText] = (0, import_react2.useState)("");
+ const pickSeqRef = (0, import_react2.useRef)(0);
+ const pickBusyRef = (0, import_react2.useRef)(false);
+ const mountedRef = (0, import_react2.useRef)(true);
+ (0, import_react2.useEffect)(() => () => {
+ mountedRef.current = false;
+ }, []);
const [iconPick, setIconPick] = (0, import_react2.useState)(null);
const [bindPick, setBindPick] = (0, import_react2.useState)(null);
const [bindGroups, setBindGroups] = (0, import_react2.useState)([]);
@@ -21700,6 +21744,7 @@ function WorktableSection(props) {
};
actionsRef.current = { openSplit, openConsole };
const openBindPick = (0, import_react2.useCallback)((id, anchor) => {
+ invalidatePickState();
const r = anchor.getBoundingClientRect();
const x = clamp2(Math.round(r.right + 8), 8, window.innerWidth - 300);
const y = clamp2(Math.round(r.top), 8, window.innerHeight - 420);
@@ -21708,19 +21753,68 @@ function WorktableSection(props) {
setBindGroups([]);
fetchSessionGroups().then((res) => setBindGroups(res.groups)).catch(() => setBindGroups([]));
}, []);
- const pickFolder = async (apply2) => {
+ const pickFolder = async (target, apply2) => {
+ if (pickBusyRef.current) return;
+ const ws = sessionBridge?.workspaces;
+ if (!ws || typeof ws.pickDirectory !== "function") {
+ setPickErr((prev) => ({ ...prev, [target]: t("add.folderPickerUnavailable") }));
+ return;
+ }
+ pickBusyRef.current = true;
+ setPickBusy(true);
+ setPickErr((prev) => ({ ...prev, [target]: "" }));
+ const seq = ++pickSeqRef.current;
try {
- const ws = sessionBridge?.workspaces;
- if (ws && typeof ws.pickDirectory === "function") {
- const p = await ws.pickDirectory();
- if (p && typeof p === "string") apply2(p);
+ const p = await ws.pickDirectory();
+ if (seq !== pickSeqRef.current) return;
+ if (!mountedRef.current) return;
+ if (p && typeof p === "string") apply2(p);
+ } catch (e) {
+ if (seq !== pickSeqRef.current) return;
+ if (!mountedRef.current) return;
+ const msg = e && typeof e === "object" && typeof e.message === "string" ? e.message : String(e ?? "");
+ setPickErr((prev) => ({ ...prev, [target]: t("add.folderPickFail", { msg: msg || "unknown" }) }));
+ } finally {
+ if (seq === pickSeqRef.current && mountedRef.current) {
+ pickBusyRef.current = false;
+ setPickBusy(false);
}
- } catch {
}
};
+ const invalidatePickState = () => {
+ pickSeqRef.current++;
+ pickBusyRef.current = false;
+ setPickBusy(false);
+ setManualPathFor(null);
+ setManualPathText("");
+ setPickErr({ add: "", bind: "" });
+ };
+ const applyManualPath = (target) => {
+ const p = manualPathText.trim();
+ if (!p) {
+ setPickErr((prev) => ({ ...prev, [target]: t("add.folderManualEmpty") }));
+ return;
+ }
+ if (!isAbs(p)) {
+ setPickErr((prev) => ({ ...prev, [target]: t("add.folderManualAbs") }));
+ return;
+ }
+ pickSeqRef.current++;
+ pickBusyRef.current = false;
+ setPickBusy(false);
+ if (target === "add") {
+ setWsFolderParent(p);
+ setWsFolderError(false);
+ } else if (bindPick) {
+ persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }));
+ }
+ setManualPathFor(null);
+ setManualPathText("");
+ setPickErr((prev) => ({ ...prev, [target]: "" }));
+ };
const changeBindFolder = () => {
- if (!bindPick) return;
- pickFolder((p) => {
+ if (!bindPick || pickBusyRef.current) return;
+ pickFolder("bind", (p) => {
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }));
});
};
@@ -22001,6 +22095,7 @@ function WorktableSection(props) {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
}
+ invalidatePickState();
setViewOptionsOpen(false);
setAddOpen(false);
};
@@ -22081,12 +22176,25 @@ function WorktableSection(props) {
setWsFolderError(true);
return;
}
+ if (!isAbs(folderPath)) {
+ setWsFolderError(true);
+ return;
+ }
try {
- await fetch("/api/worktable/mkdir", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: folderPath }) });
+ const r = await fetch("/api/worktable/mkdir", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: folderPath }) });
+ if (!r.ok) {
+ setWsFolderError(true);
+ setPickErr((prev) => ({ ...prev, add: t("add.folderCreateFail") }));
+ return;
+ }
} catch {
+ setWsFolderError(true);
+ setPickErr((prev) => ({ ...prev, add: t("add.folderCreateFail") }));
+ return;
}
const layout = buildLayout(wsPreset, name);
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout], folders: { ...prev.folders, [layout.id]: folderPath } }));
+ invalidatePickState();
setWsName("");
setWsFolderParent("");
setWsError(false);
@@ -22481,6 +22589,7 @@ function WorktableSection(props) {
"aria-label": t("menu.viewOptions"),
title: t("menu.viewOptions"),
onClick: () => {
+ invalidatePickState();
setViewOptionsOpen((v) => !v);
setAddOpen(false);
},
@@ -22495,6 +22604,7 @@ function WorktableSection(props) {
"aria-label": t("menu.add"),
title: t("menu.add"),
onClick: () => {
+ invalidatePickState();
setAddOpen((v) => !v);
setViewOptionsOpen(false);
if (!wsFolderParent) {
@@ -22530,7 +22640,10 @@ function WorktableSection(props) {
}
)
] }),
- addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", onClick: () => setAddOpen(false) }),
+ addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", onClick: () => {
+ invalidatePickState();
+ setAddOpen(false);
+ } }),
addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_menu dsh-wt_add dsh-wt_pop", style: { position: "fixed", left: popLeft, top: popTop, width: 360, zIndex: 80 }, children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_menuLabel", children: t("add.chooseLayout") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_presets", children: [
@@ -22555,6 +22668,7 @@ function WorktableSection(props) {
className: "dsh-wt_preset dsh-wt_presetAdd",
title: t("customLayout.addTitle"),
onClick: () => {
+ invalidatePickState();
setAddOpen(false);
setCustomOpen(true);
},
@@ -22581,14 +22695,54 @@ function WorktableSection(props) {
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_addFolderRow", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_customLabel", children: t("add.folderParent") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_addFolderPath" + (wsFolderParent ? "" : " dsh-wt_addFolderPathNone"), title: wsFolderParent || "", children: wsFolderParent || t("add.folderNone") }),
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: () => {
- pickFolder((p) => {
- setWsFolderParent(p);
- setWsFolderError(false);
- });
- setWsError(false);
- }, children: t("add.folderPick") })
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
+ "button",
+ {
+ type: "button",
+ className: "dsh-wt_bindFolderChange",
+ disabled: pickBusy,
+ onClick: () => {
+ pickFolder("add", (p) => {
+ setWsFolderParent(p);
+ setWsFolderError(false);
+ });
+ setWsError(false);
+ },
+ children: pickBusy ? t("add.folderPicking") : t("add.folderPick")
+ }
+ ),
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
+ "button",
+ {
+ type: "button",
+ className: "dsh-wt_bindFolderChange",
+ title: t("add.folderManual"),
+ onClick: () => {
+ setManualPathFor(manualPathFor === "add" ? null : "add");
+ setPickErr((prev) => ({ ...prev, add: "" }));
+ },
+ children: t("add.folderManual")
+ }
+ )
] }),
+ pickBusy && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_folderPickHint", children: t("add.folderPickerHint") }),
+ manualPathFor === "add" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_addFolderRow", children: [
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
+ "input",
+ {
+ type: "text",
+ className: "dsh-wt_manualPathInput",
+ placeholder: t("add.folderManualPh"),
+ value: manualPathText,
+ onChange: (e) => setManualPathText(e.target.value),
+ onKeyDown: (e) => {
+ if (e.key === "Enter") applyManualPath("add");
+ }
+ }
+ ),
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: () => applyManualPath("add"), children: t("add.folderManualOk") })
+ ] }),
+ pickErr.add && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_addError", children: pickErr.add }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_addBtn", onClick: saveLayout, children: t("add.layoutSave") })
] }),
wsError && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_addError", children: t("add.layoutInvalid") }),
@@ -22828,6 +22982,7 @@ function WorktableSection(props) {
] })
] }),
bindPick && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", style: { zIndex: 83 }, onClick: () => {
+ invalidatePickState();
setBindPick(null);
setBindListOpen(false);
} }),
@@ -22838,12 +22993,40 @@ function WorktableSection(props) {
"\u{1F4C1} ",
t("bind.folder")
] }),
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: changeBindFolder, children: [
- t("bind.folderChange"),
- " \u21BB"
- ] })
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", disabled: pickBusy, onClick: changeBindFolder, children: pickBusy ? t("add.folderPicking") : t("bind.folderChange") + " \u21BB" }),
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
+ "button",
+ {
+ type: "button",
+ className: "dsh-wt_bindFolderChange",
+ title: t("add.folderManual"),
+ onClick: () => {
+ setManualPathFor(manualPathFor === "bind" ? null : "bind");
+ setPickErr((prev) => ({ ...prev, bind: "" }));
+ },
+ children: t("add.folderManual")
+ }
+ )
] }),
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_bindFolderPath" + (projects.folders[bindPick.id] ? "" : " dsh-wt_bindFolderPathNone"), title: projects.folders[bindPick.id] ?? "", children: projects.folders[bindPick.id] ?? t("bind.folderNone") })
+ pickBusy && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_folderPickHint", children: t("add.folderPickerHint") }),
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_bindFolderPath" + (projects.folders[bindPick.id] ? "" : " dsh-wt_bindFolderPathNone"), title: projects.folders[bindPick.id] ?? "", children: projects.folders[bindPick.id] ?? t("bind.folderNone") }),
+ manualPathFor === "bind" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_addFolderRow", children: [
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
+ "input",
+ {
+ type: "text",
+ className: "dsh-wt_manualPathInput",
+ placeholder: t("add.folderManualPh"),
+ value: manualPathText,
+ onChange: (e) => setManualPathText(e.target.value),
+ onKeyDown: (e) => {
+ if (e.key === "Enter") applyManualPath("bind");
+ }
+ }
+ ),
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: () => applyManualPath("bind"), children: t("add.folderManualOk") })
+ ] }),
+ pickErr.bind && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_addError", children: pickErr.bind })
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_bindFolderBox", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_bindFolderRow", children: [
diff --git a/01_content/lib/index.js b/01_content/lib/index.js
index 406e273..19bcfb9 100644
--- a/01_content/lib/index.js
+++ b/01_content/lib/index.js
@@ -88,7 +88,7 @@ body {
var dshell_default2 = '\n\n\n
\n \n \n \u6211\u7684\u7A97\u53E3\n \n\n\n \n \n
\u7A97\u53E3\u6807\u9898
\n
\u4E00\u53E5\u8BDD\u8BF4\u660E\u8FD9\u4E2A\u7A97\u53E3\u505A\u4EC0\u4E48\u3002
\n\n \n
\n \u5DF2\u5B8C\u6210\n \u8FDB\u884C\u4E2D\n \u672A\u5F00\u59CB\n
\n\n \n
\n \n \n \n
\n\n \n
\n
\n
\u603B\u6570
\n
128
\n
+12.4%
\n
\n
\n
\u8FDB\u884C\u4E2D
\n
7
\n
\n
\n
\u5DF2\u5B8C\u6210
\n
121
\n
\n
\n\n \n
\n
\n \u6761\u76EE\u4E00\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u6628\u5929\n
\n
\n \u6761\u76EE\u4E8C\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u5DF2\u53D1\u5E03\n
\n
\n\n \n
\n
\u8BE6\u60C5
\n
\n
\u5B57\u6BB5 A\u503C A
\n
\u5B57\u6BB5 B\u503C B
\n
\n
\n
\n
\n\n \n
\n \n \n
\n
\n\n\n';
// src/index.ts
-var PLUGIN_VERSION = false ? "dev" : "0.3.1";
+var PLUGIN_VERSION = false ? "dev" : "0.3.2";
var name = "dsh-worktable";
var inject = ["webServer", "sessions"];
var HEALTH_PATH = "/api/worktable/health";
diff --git a/01_content/package.json b/01_content/package.json
index 86245e7..3426733 100644
--- a/01_content/package.json
+++ b/01_content/package.json
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
- "version": "0.3.1",
+ "version": "0.3.2",
"license": "MIT",
"type": "module",
"main": "lib/index.js",
@@ -34,7 +34,9 @@
"scripts": {
"build": "node build.mjs",
"check": "node --check lib/index.js && node --check lib/client.js",
- "pack": "node release-prep.mjs"
+ "pack": "node release-prep.mjs",
+ "test:gate": "node client-factory-gate.test.mjs",
+ "verify:remote": "node verify-remote.mjs"
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
diff --git a/01_content/release-prep.mjs b/01_content/release-prep.mjs
index 72c7a48..cbf8503 100644
--- a/01_content/release-prep.mjs
+++ b/01_content/release-prep.mjs
@@ -13,10 +13,11 @@
*/
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
-import { existsSync, mkdirSync, readFileSync, rmSync, copyFileSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, copyFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
+import { gateClientFactory } from './client-factory-gate.mjs'
const HERE = dirname(fileURLToPath(import.meta.url)) // 固定 = 01_content,绝不依赖执行 cwd
// Windows 下 spawnSync('npm') 有 ENOENT、spawnSync('npm.cmd') 有 EINVAL 问题。
@@ -47,6 +48,40 @@ if (manifest.version !== pkg.version) {
const VERSION = pkg.version
console.log('[release-prep] version = ' + VERSION)
+// ---------- 1b. 身份断言(package/manifest/patch 统一为 dsh-worktable) ----------
+const IDENTITY = 'dsh-worktable'
+if (pkg.name !== IDENTITY) { console.error('[release-prep] FAIL: package.json name = ' + pkg.name); process.exit(1) }
+if (manifest.name !== IDENTITY) { console.error('[release-prep] FAIL: dsh.plugin.json name = ' + manifest.name); process.exit(1) }
+if (!manifest.entry || manifest.entry.name !== IDENTITY) { console.error('[release-prep] FAIL: dsh.plugin.json entry.name != ' + IDENTITY); process.exit(1) }
+// cordis.patch.yml 严格检查:按缩进层级解析,只接受「- insert: 下的子列表项(id/name=IDENTITY)」结构
+const patchRaw = readFileSync(join(HERE, 'cordis.patch.yml'), 'utf8')
+const patchLines = patchRaw.split(/\r?\n/)
+let patchOk = true
+const patchEntries = []
+let cur = null
+for (const rawLine of patchLines) {
+ const line = rawLine.trimEnd()
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
+ const indent = line.length - line.trimStart().length
+ const text = line.trim()
+ if (indent === 0 && text === '- insert:') { cur = {}; patchEntries.push(cur); continue }
+ if (indent === 0) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml unexpected top-level line: ' + JSON.stringify(text)); break }
+ if (indent === 4 && text === '- id: ' + IDENTITY) { if (cur && cur.id !== undefined) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml duplicate - id: item (only one plugin entry allowed)'); break }; if (cur) cur.id = IDENTITY; continue }
+ if (indent === 4 && /^- id:/.test(text)) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml insert item id != ' + IDENTITY + ': ' + JSON.stringify(text)); break }
+ if (indent === 6 && text === 'name: ' + IDENTITY) { if (cur && cur.name !== undefined) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml duplicate name field'); break }; if (cur) cur.name = IDENTITY; continue }
+ if (indent === 6 && /^name:/.test(text)) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml insert item name != ' + IDENTITY + ': ' + JSON.stringify(text)); break }
+ patchOk = false
+ console.error('[release-prep] FAIL: cordis.patch.yml unexpected line (indent ' + indent + '): ' + JSON.stringify(text))
+ break
+}
+if (patchOk && patchEntries.length !== 1) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml must contain exactly one insert entry, got ' + patchEntries.length) }
+if (patchOk && (patchEntries[0].id !== IDENTITY || patchEntries[0].name !== IDENTITY)) {
+ patchOk = false
+ console.error('[release-prep] FAIL: cordis.patch.yml insert entry incomplete: ' + JSON.stringify(patchEntries[0]))
+}
+if (!patchOk) process.exit(1)
+console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)')
+
// ---------- 2. 构建 + 语法检查 ----------
// 构建必带 cwd: HERE(脚本自身位置=01_content),否则从仓库根调用会输出到根 lib/ 造成旧包
const build = spawnSync(process.execPath, [join(HERE, 'build.mjs')], { stdio: 'inherit', cwd: HERE })
@@ -108,6 +143,8 @@ writeFileSync(assertJs, [
"const fails = []",
"if (typeof m.apply !== 'function') fails.push('apply not function: ' + typeof m.apply)",
"if (!Array.isArray(m.inject)) fails.push('inject not array')",
+ "if (!m.inject.includes('webServer')) fails.push('server inject missing webServer')",
+ "if (!m.inject.includes('sessions')) fails.push('server inject missing sessions')",
"if (m.name !== 'dsh-worktable') fails.push('name mismatch: ' + m.name)",
"if (m.HEALTH_PATH !== '/api/worktable/health') fails.push('HEALTH_PATH mismatch: ' + m.HEALTH_PATH)",
"const pkg = JSON.parse(readFileSync('./node_modules/dsh-worktable/package.json', 'utf8'))",
@@ -124,16 +161,46 @@ const imp = spawnSync(process.execPath, ['--input-type=module', '--eval', 'await
if (imp.status !== 0) { console.error('[release-prep] FAIL: import() assertion'); console.error(imp.stderr); process.exit(1) }
console.log('[release-prep] install + import assertion passed')
+// ---------- 5b. 客户端工厂求值门禁(ModuleLoader 握手真实执行一次;非端到端验收) ----------
+// 读「安装目录中的最终产物」lib/client.js(node_modules/dsh-worktable/lib/client.js),而非工作目录——验收最终包
+const clientSrc = readFileSync(join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js'), 'utf8')
+const CLIENT_EXPECTED_INJECT = ['slots', 'locale', 'sessions', 'conversation', 'workspaces']
+try {
+ gateClientFactory(clientSrc, 'dsh-worktable', CLIENT_EXPECTED_INJECT)
+} catch (e) {
+ console.error('[release-prep] FAIL: client factory gate: ' + (e && e.message ? e.message : String(e)))
+ process.exit(1)
+}
+console.log('[release-prep] client factory gate passed (ModuleLoader id + factory evaluation + inject)')
+
// ---------- 6. 双资产从同一已验证包复制 + SHA-256 ----------
-const fixedDir = join(HERE, 'dist')
-mkdirSync(fixedDir, { recursive: true })
-const assetFixed = join(fixedDir, 'dsh-worktable.tgz')
-const assetVer = join(fixedDir, 'dsh-worktable-' + VERSION + '.tgz')
-copyFileSync(tgz, assetFixed)
+const outDir = join(HERE, 'dist', 'v' + VERSION)
+mkdirSync(outDir, { recursive: true })
+// 输出目录终态约束:只允许固定名 + 版本化两个 tgz;出现未知文件 = 报错,绝不删除
+const KNOWN = new Set(['dsh-worktable.tgz', 'dsh-worktable-' + VERSION + '.tgz'])
+for (const ent of readdirSync(outDir)) {
+ if (!KNOWN.has(ent)) {
+ console.error('[release-prep] FAIL: dist/v' + VERSION + '/ contains unknown file: ' + ent + ' (not deleting; fix manually)')
+ process.exit(1)
+ }
+}
+const assetFixed = join(outDir, 'dsh-worktable.tgz')
+const assetVer = join(outDir, 'dsh-worktable-' + VERSION + '.tgz')
+copyFileSync(tgz, assetFixed) // 同版本重跑:覆盖这两个已知文件是允许的
copyFileSync(tgz, assetVer)
+// 复制后终态断言:目录恰好两个已知文件(无遗漏/无多余),双 SHA 一致
+const finalEntries = readdirSync(outDir).sort()
+const finalSet = new Set(finalEntries)
+if (finalEntries.length !== 2 || !finalSet.has('dsh-worktable.tgz') || !finalSet.has('dsh-worktable-' + VERSION + '.tgz')) {
+ console.error('[release-prep] FAIL: post-copy final listing mismatch: ' + finalEntries.join(', '))
+ process.exit(1)
+}
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex')
-console.log('[release-prep] DONE — 双资产同源(同 SHA-256):')
-console.log(' dist/dsh-worktable.tgz sha256:' + sha(assetFixed))
-console.log(' dist/dsh-worktable-' + VERSION + '.tgz sha256:' + sha(assetVer))
-console.log('[release-prep] 提示:本脚本未推送、未打 tag、未发布。请用 gh 手动上传双资产并下载远端比对。')
+const shaFixed = sha(assetFixed)
+const shaVer = sha(assetVer)
+if (shaFixed !== shaVer) { console.error('[release-prep] FAIL: two assets differ after copy'); process.exit(1) }
+console.log('[release-prep] DONE — 双资产同源(同 SHA-256),终态目录恰好 2 文件:')
+console.log(' dist/v' + VERSION + '/dsh-worktable.tgz sha256:' + shaFixed)
+console.log(' dist/v' + VERSION + '/dsh-worktable-' + VERSION + '.tgz sha256:' + shaVer)
+console.log('[release-prep] 提示:本脚本未推送、未打 tag、未发布。上传后请运行 verify-remote.mjs --expect-sha ' + shaFixed + ' 核对远端。')
rmSync(work, { recursive: true, force: true })
diff --git a/01_content/src/client/changelog.ts b/01_content/src/client/changelog.ts
index 6cf66f4..0748d76 100644
--- a/01_content/src/client/changelog.ts
+++ b/01_content/src/client/changelog.ts
@@ -1,8 +1,18 @@
-/** 更新公告正文(v0.3.1 = v0.3.0 功能 + 发布包修复;呈现时按纯文本保留换行,不做 md 渲染) */
-export const CHANGELOG_VERSION = 'v0.3.1'
-export const CHANGELOG_V030 = `更新公告 · v0.3.1
+/** 更新公告正文(v0.3.2:文件夹选择体验 + 发布流程加固;呈现时按纯文本保留换行,不做 md 渲染) */
+export const CHANGELOG_VERSION = 'v0.3.2'
+export const CHANGELOG_V030 = `更新公告 · v0.3.2
-🔧 本版本修复说明:v0.3.0 发布包存在打包缺陷(非标准 npm 包格式,安装时会报错导致插件无法加载),v0.3.1 已按标准格式重新打包。此前安装失败的用户,重新执行一次升级安装命令即可正常使用本版本。
+🛠️ 本版本更新
+
+【文件夹选择体验】选择项目文件夹时:等待期间按钮显示「等待选择…」并提示「若未看到选择窗口,可检查任务栏,或使用手动输入」;新增「手动输入」入口,可直接粘贴文件夹绝对路径(相对路径会被拒绝并提示);选择失败时显示具体错误,不再静默无反应;修复了旧选择窗口返回后覆盖手工输入、弹窗关闭/切换后状态残留等问题。
+
+【发布流程加固】发布包新增结构与身份校验、客户端工厂求值门禁、双资产同源核对与远端 SHA 比对(开发者侧改进,不影响插件功能)。
+
+【边界说明】本版本是插件侧体验改善与发布流程加固:不改变宿主原生目录选择器的行为(选择窗口不置前属 DSH 宿主已知问题,等待官方修复),也不代表所有启动失败已解决。升级安装后请完整退出并重启 DSH 再刷新页面;若重启后仍显示加载失败,请保留完整错误信息反馈。
+
+───────
+
+🔧 此前版本说明(v0.3.1):v0.3.0 发布包存在打包缺陷(非标准 npm 包格式,安装时报错导致插件无法加载),v0.3.1 已按标准格式重新打包。
🛠️ 一、控制室 UI 调整
diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx
index 07bfcdc..fe506d2 100644
--- a/01_content/src/client/index.tsx
+++ b/01_content/src/client/index.tsx
@@ -1179,6 +1179,17 @@ function WorktableSection(props: any) {
// 新建项目强制工作文件夹:经「选择位置…」弹窗选定(系统资源管理器式选择窗)
const [wsFolderParent, setWsFolderParent] = useState('')
const [wsFolderError, setWsFolderError] = useState(false)
+ /** 文件夹选择器状态:busy=等待系统选择器;err=按入口分开的失败原因(用户取消不报错);manual=手工输入展开目标 */
+ const [pickBusy, setPickBusy] = useState(false)
+ const [pickErr, setPickErr] = useState<{ add: string; bind: string }>({ add: '', bind: '' })
+ const [manualPathFor, setManualPathFor] = useState<'add' | 'bind' | null>(null)
+ const [manualPathText, setManualPathText] = useState('')
+ /** 文件夹选择在途锁 + 请求序号(useRef:跨渲染稳定;弹窗关闭/切换目标/手工确认时自增使旧请求失效) */
+ const pickSeqRef = useRef(0)
+ const pickBusyRef = useRef(false)
+ /** 组件卸载后不再应用旧选择结果 */
+ const mountedRef = useRef(true)
+ useEffect(() => () => { mountedRef.current = false }, [])
/** 图标选择器:kind + 目标 id + 弹窗锚点坐标(fixed 定位) */
const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; x: number; y: number } | null>(null)
/** 对话绑定弹窗:项目 id + 锚点坐标;bindGroups = 打开时抓取的会话分组;
@@ -1820,6 +1831,7 @@ function buildCustomLayoutPrompt(req: string): string {
/** 打开对话绑定弹窗:抓取会话分组 + 锚点定位 */
const openBindPick = useCallback((id: string, anchor: HTMLElement) => {
+ invalidatePickState() // 切换目标:失效在途选择请求、清空旧错误与手工输入
const r = anchor.getBoundingClientRect()
const x = clamp(Math.round(r.right + 8), 8, window.innerWidth - 300)
const y = clamp(Math.round(r.top), 8, window.innerHeight - 420)
@@ -1829,21 +1841,70 @@ function buildCustomLayoutPrompt(req: string): string {
fetchSessionGroups().then((res) => setBindGroups(res.groups)).catch(() => setBindGroups([]))
}, [])
- /** 弹出系统文件夹选择窗(宿主 pickDirectory);选中后回调 */
- const pickFolder = async (apply: (p: string) => void) => {
+ /** 弹出系统文件夹选择窗(宿主 pickDirectory);选中后回调。
+ * - useRef 在途锁 + 请求序号:真实防重入;手工确认/新请求会使旧请求失效,旧弹窗返回后丢弃
+ * - 返回 null = 用户取消:不报错
+ * - 抛异常 = 显示真实错误信息,不再静默吞掉
+ * - 宿主能力不可用 = 提示改用「手动输入」 */
+ const pickFolder = async (target: 'add' | 'bind', apply: (p: string) => void) => {
+ if (pickBusyRef.current) return
+ const ws = sessionBridge?.workspaces as any
+ if (!ws || typeof ws.pickDirectory !== 'function') {
+ setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickerUnavailable') }))
+ return
+ }
+ pickBusyRef.current = true
+ setPickBusy(true)
+ setPickErr((prev) => ({ ...prev, [target]: '' }))
+ const seq = ++pickSeqRef.current
try {
- const ws = sessionBridge?.workspaces as any
- if (ws && typeof ws.pickDirectory === 'function') {
- const p = await ws.pickDirectory()
- if (p && typeof p === 'string') apply(p)
+ const p = await ws.pickDirectory()
+ if (seq !== pickSeqRef.current) return // 已被手工确认/新请求/弹窗关闭失效:丢弃旧弹窗结果
+ if (!mountedRef.current) return // 组件已卸载:不再应用
+ if (p && typeof p === 'string') apply(p)
+ // p 为 null:用户取消,静默
+ } catch (e: any) {
+ if (seq !== pickSeqRef.current) return
+ if (!mountedRef.current) return // 组件已卸载:不再更新状态
+ const msg = (e && typeof e === 'object' && typeof e.message === 'string') ? e.message : String(e ?? '')
+ setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickFail', { msg: msg || 'unknown' }) }))
+ } finally {
+ if (seq === pickSeqRef.current && mountedRef.current) {
+ pickBusyRef.current = false
+ setPickBusy(false)
}
- } catch { /* 取消或不可用 */ }
+ }
+ }
+
+ /** 使在途选择请求失效并清空选择器 UI 状态(弹窗关闭/切换目标时调用) */
+ const invalidatePickState = () => {
+ pickSeqRef.current++
+ pickBusyRef.current = false
+ setPickBusy(false)
+ setManualPathFor(null)
+ setManualPathText('')
+ setPickErr({ add: '', bind: '' })
+ }
+
+ /** 手工输入绝对路径(选择器不可用/失败的降级入口):必须通过 isAbs,并失效在途系统选择请求 */
+ const applyManualPath = (target: 'add' | 'bind') => {
+ const p = manualPathText.trim()
+ if (!p) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderManualEmpty') })); return }
+ if (!isAbs(p)) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderManualAbs') })); return }
+ pickSeqRef.current++ // 使在途系统选择请求失效(旧弹窗返回后丢弃)
+ pickBusyRef.current = false
+ setPickBusy(false)
+ if (target === 'add') { setWsFolderParent(p); setWsFolderError(false) }
+ else if (bindPick) { persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) }
+ setManualPathFor(null)
+ setManualPathText('')
+ setPickErr((prev) => ({ ...prev, [target]: '' }))
}
/** 绑定弹窗「更改」:弹窗选择文件夹,直接作为项目文件夹 */
const changeBindFolder = () => {
- if (!bindPick) return
- pickFolder((p) => {
+ if (!bindPick || pickBusyRef.current) return
+ pickFolder('bind', (p) => {
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }))
})
}
@@ -2123,6 +2184,7 @@ function buildCustomLayoutPrompt(req: string): string {
const startRect = root.getBoundingClientRect()
dragRef.current = { startY: e.clientY, startX: e.clientX, startRect, dragging: false, prevFloat: float }
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) } catch {}
+ invalidatePickState()
setViewOptionsOpen(false); setAddOpen(false)
}
@@ -2219,12 +2281,15 @@ function buildCustomLayoutPrompt(req: string): string {
if (!name) { setWsError(true); return }
const folderPath = wsFolderParent.trim()
if (!folderPath) { setWsFolderError(true); return }
+ if (!isAbs(folderPath)) { setWsFolderError(true); return }
try {
- // 轻量兜底:选中的路径理应存在,mkdir 幂等无害
- await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: folderPath }) })
- } catch { /* 非致命 */ }
+ // 兜底建目录;HTTP 非 2xx 视为失败,不能继续保存(路径可能无效)
+ const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: folderPath }) })
+ if (!r.ok) { setWsFolderError(true); setPickErr((prev) => ({ ...prev, add: t('add.folderCreateFail') })); return }
+ } catch { setWsFolderError(true); setPickErr((prev) => ({ ...prev, add: t('add.folderCreateFail') })); return }
const layout = buildLayout(wsPreset, name)
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout], folders: { ...prev.folders, [layout.id]: folderPath } }))
+ invalidatePickState() // 保存成功:失效在途选择请求(防止旧选择器稍后返回写回已重置的表单)
setWsName(''); setWsFolderParent(''); setWsError(false); setWsFolderError(false)
setAddOpen(false)
openSplit(layout)
@@ -2649,7 +2714,7 @@ function buildCustomLayoutPrompt(req: string): string {
className="dsh-wt_iconBtn"
aria-label={t('menu.viewOptions')}
title={t('menu.viewOptions')}
- onClick={() => { setViewOptionsOpen((v) => !v); setAddOpen(false) }}
+ onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
>{ICON_VIEW_OPTIONS}