diff --git a/01_content/README.md b/01_content/README.md index bfcc201..8a0af8d 100644 --- a/01_content/README.md +++ b/01_content/README.md @@ -8,6 +8,7 @@ - **分栏工作区引擎(自研)**:声明式布局预设(左栏/顶行/主行 + 右侧对话窗),窗格可拖拽分割、标签页模型;内置 资源管理器 / 终端 / 浏览器 / 动画 / 自定义窗口。 - **控制室**:默认自带项目(固定首位、不可删除),项目卡片网格实时监控所有项目的状态(工作中/待你决定/已完成),零轮询零 Token。 - **自动挂载握手**:项目内 agent 完成窗口任务后写 widget-result.json,产物自动挂进对应窗口。 +- **平台**:Windows 是当前完整验证平台;macOS 为实验性支持(核心文件路径代码已做跨平台适配,尚未真机端到端验证)。 ## 技术底座 @@ -20,7 +21,7 @@ 方式 A(推荐,无需 Git)——直接安装 GitHub Release 的安装包: - dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.2.0/dsh-worktable-0.2.0.tgz" + dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz" 方式 B(想改源码用)——克隆仓库后用本地路径注册(`link:` 只接受本地路径,不要带空格): diff --git a/01_content/build.mjs b/01_content/build.mjs index e789ce4..c19356f 100644 --- a/01_content/build.mjs +++ b/01_content/build.mjs @@ -14,6 +14,10 @@ import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) mkdirSync(join(here, 'lib'), { recursive: true }) const pkg = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')) +const pluginManifest = JSON.parse(readFileSync(join(here, 'dsh.plugin.json'), 'utf8')) +if (pluginManifest.version !== pkg.version) { + throw new Error('dsh-worktable build: package.json version (' + pkg.version + ') != dsh.plugin.json version (' + pluginManifest.version + ') — 发版前先统一两处版本号') +} const clientBanner = { js: "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports;", @@ -24,9 +28,10 @@ await build({ entryPoints: [join(here, 'src/index.ts')], outfile: 'lib/index.js', bundle: true, - sourcemap: true, + sourcemap: 'external', logLevel: 'info', platform: 'node', + define: { __WT_VERSION__: JSON.stringify(pkg.version) }, format: 'esm', target: ['node22'], external: ['@deepseek-ai/*', 'node:*', 'ws', 'node-pty'], @@ -37,7 +42,7 @@ await build({ entryPoints: [join(here, 'src/client/index.tsx')], outfile: 'lib/client.js', bundle: true, - sourcemap: true, + sourcemap: 'external', logLevel: 'info', platform: 'browser', format: 'cjs', diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index ae00f5f..d7c5415 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.2.1", + "version": "0.2.2", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.css b/01_content/lib/client.css deleted file mode 100644 index 9d35497..0000000 --- a/01_content/lib/client.css +++ /dev/null @@ -1,194 +0,0 @@ -/* node_modules/xterm/css/xterm.css */ -.xterm { - cursor: text; - position: relative; - user-select: none; - -ms-user-select: none; - -webkit-user-select: none; -} -.xterm.focus, -.xterm:focus { - outline: none; -} -.xterm .xterm-helpers { - position: absolute; - top: 0; - z-index: 5; -} -.xterm .xterm-helper-textarea { - padding: 0; - border: 0; - margin: 0; - position: absolute; - opacity: 0; - left: -9999em; - top: 0; - width: 0; - height: 0; - z-index: -5; - white-space: nowrap; - overflow: hidden; - resize: none; -} -.xterm .composition-view { - background: #000; - color: #FFF; - display: none; - position: absolute; - white-space: nowrap; - z-index: 1; -} -.xterm .composition-view.active { - display: block; -} -.xterm .xterm-viewport { - background-color: #000; - overflow-y: scroll; - cursor: default; - position: absolute; - right: 0; - left: 0; - top: 0; - bottom: 0; -} -.xterm .xterm-screen { - position: relative; -} -.xterm .xterm-screen canvas { - position: absolute; - left: 0; - top: 0; -} -.xterm .xterm-scroll-area { - visibility: hidden; -} -.xterm-char-measure-element { - display: inline-block; - visibility: hidden; - position: absolute; - top: 0; - left: -9999em; - line-height: normal; -} -.xterm.enable-mouse-events { - cursor: default; -} -.xterm.xterm-cursor-pointer, -.xterm .xterm-cursor-pointer { - cursor: pointer; -} -.xterm.column-select.focus { - cursor: crosshair; -} -.xterm .xterm-accessibility, -.xterm .xterm-message { - position: absolute; - left: 0; - top: 0; - bottom: 0; - right: 0; - z-index: 10; - color: transparent; - pointer-events: none; -} -.xterm .live-region { - position: absolute; - left: -9999px; - width: 1px; - height: 1px; - overflow: hidden; -} -.xterm-dim { - opacity: 1 !important; -} -.xterm-underline-1 { - text-decoration: underline; -} -.xterm-underline-2 { - text-decoration: double underline; -} -.xterm-underline-3 { - text-decoration: wavy underline; -} -.xterm-underline-4 { - text-decoration: dotted underline; -} -.xterm-underline-5 { - text-decoration: dashed underline; -} -.xterm-overline { - text-decoration: overline; -} -.xterm-overline.xterm-underline-1 { - text-decoration: overline underline; -} -.xterm-overline.xterm-underline-2 { - text-decoration: overline double underline; -} -.xterm-overline.xterm-underline-3 { - text-decoration: overline wavy underline; -} -.xterm-overline.xterm-underline-4 { - text-decoration: overline dotted underline; -} -.xterm-overline.xterm-underline-5 { - text-decoration: overline dashed underline; -} -.xterm-strikethrough { - text-decoration: line-through; -} -.xterm-screen .xterm-decoration-container .xterm-decoration { - z-index: 6; - position: absolute; -} -.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { - z-index: 7; -} -.xterm-decoration-overview-ruler { - z-index: 8; - position: absolute; - top: 0; - right: 0; - pointer-events: none; -} -.xterm-decoration-top { - z-index: 2; - position: relative; -} -/*! Bundled license information: - -xterm/css/xterm.css: - (** - * Copyright (c) 2014 The xterm.js authors. All rights reserved. - * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) - * https://github.com/chjj/term.js - * @license MIT - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - * Originally forked from (with the author's permission): - * Fabrice Bellard's javascript vt100 for jslinux: - * http://bellard.org/jslinux/ - * Copyright (c) 2011 Fabrice Bellard - * The original design remains. The terminal itself - * has been extended to include xterm CSI codes, among - * other features. - *) -*/ -/*# sourceMappingURL=client.css.map */ diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 736f565..4583690 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -8580,6 +8580,34 @@ var en = { }; var NS = "worktable"; +// src/client/pathutil.ts +function isAbs(p) { + if (/^[A-Za-z]:[\\/]/.test(p)) return true; + if (p.startsWith("\\\\")) return true; + if (p.startsWith("/")) return true; + return false; +} +function joinPath(base2, rel) { + const relTrim = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, ""); + const sep = base2.includes("\\") ? "\\" : "/"; + if (base2 === "/" || base2 === "\\") return base2 + relTrim; + if (/^[A-Za-z]:[\\/]$/.test(base2)) return base2 + relTrim; + const baseTrim = base2.replace(/[\\/]+$/, ""); + if (!baseTrim) return relTrim || sep; + return baseTrim + sep + relTrim; +} +function parentPathOf(p) { + const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + if (idx < 0) return p; + if (idx === 0) return p[0]; + if (idx === 2 && /^[A-Za-z]:[\\/]/.test(p)) return p.slice(0, 3); + return p.slice(0, idx); +} +function basenameOf(p) { + const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return idx < 0 ? p : p.slice(idx + 1); +} + // src/client/split.tsx var import_react = require("react"); var import_xterm2 = __toESM(require_xterm(), 1); @@ -16386,7 +16414,7 @@ var BUILTIN_LABEL_KEYS = { }; function tabTitleOf(content) { if (content.kind === "builtin") return T(BUILTIN_LABEL_KEYS[content.type]); - if (content.kind === "file") return basenameOf(content.path); + if (content.kind === "file") return basenameOf2(content.path); if (content.kind === "iframe" && content.title) return content.title; try { const u = new URL(content.url); @@ -16395,7 +16423,7 @@ function tabTitleOf(content) { return content.url; } } -function basenameOf(p) { +function basenameOf2(p) { const parts = String(p).replace(/[\\/]+$/, "").split(/[\\/]/); return parts[parts.length - 1] || String(p); } @@ -16423,13 +16451,6 @@ async function postJson(url, body) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); } -function parentPathOf(p) { - const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); - if (idx <= 0) return p; - let parent = p.slice(0, idx); - if (/^[A-Za-z]:$/.test(parent)) parent += "\\"; - return parent; -} var dragPane = null; var dragTab = null; var dropTarget = null; @@ -17555,10 +17576,10 @@ function FileViewer(props) { const ext = (props.path.split(".").pop() || "").toLowerCase(); const fileUrl = "/api/worktable/file?path=" + encodeURIComponent(props.path); if (ext === "pdf") { - return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("iframe", { className: "dsh-wt_paneFrame", src: fileUrl, title: basenameOf(props.path) }); + return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("iframe", { className: "dsh-wt_paneFrame", src: fileUrl, title: basenameOf2(props.path) }); } if (IMAGE_EXTS.test("." + ext)) { - return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-wt_imgView", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: fileUrl, alt: basenameOf(props.path) }) }); + return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-wt_imgView", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: fileUrl, alt: basenameOf2(props.path) }) }); } return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextViewer, { path: props.path, fileUrl, isMd: MD_EXTS.test("." + ext) }); } @@ -18266,7 +18287,7 @@ try { // src/client/index.tsx var import_jsx_runtime2 = require("react/jsx-runtime"); -var LOCAL_VERSION = false ? "0.2.0" : "0.2.1"; +var LOCAL_VERSION = false ? "dev" : "0.2.2"; var UPDATE_REPO = "Aisland-SJL/dsh-worktable"; var UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'; var UPGRADE_AI = "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + UPGRADE_CMD + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762"; @@ -18889,7 +18910,7 @@ async function promptIntoSession(sessionId, text2) { } var KNOWLEDGE_PACK = [ "\u3010\u63D2\u4EF6\u77E5\u8BC6\u5305\xB7\u8BF7\u76F4\u63A5\u91C7\u7528\uFF0C\u4E0D\u8981\u91CD\u65B0\u4FA6\u5BDF\u63D2\u4EF6\u6E90\u7801\u3011", - "- dsh-worktable \u662F\u672C\u673A DeepSeek Harness \u7684\u81EA\u5EFA\u5BB9\u5668\u63D2\u4EF6\uFF08\u4ED3\u5E93 E:\\AI_Workspace\\DeepseekHarness\\Projects\\dsh-worktable\uFF0C\u63D2\u4EF6\u5305\u6839\u76EE\u5F55 01_content/\uFF09\u3002", + "- dsh-worktable \u662F DeepSeek Harness \u7684\u81EA\u5EFA\u5BB9\u5668\u63D2\u4EF6\uFF08\u5DE5\u4F5C\u53F0\uFF1A\u4FA7\u8FB9\u680F\u91CC\u7684\u9879\u76EE\u5E94\u7528\u62BD\u5C49\uFF1B\u4E0D\u4E86\u89E3\u7684\u7EC6\u8282\u53EF\u76F4\u63A5\u5411\u7528\u6237\u63D0\u95EE\uFF09\u3002", "- \u7A97\u53E3\uFF08\u5185\u5BB9\u7A97\uFF09\u6A21\u578B\uFF1A\u6BCF\u4E2A\u7A97 = \u4E00\u4E2A\u6807\u7B7E\u9875\uFF1B\u672A\u6307\u6D3E\u65F6\u663E\u793A\u9009\u62E9\u5668\uFF08\u6D4F\u89C8\u5668/\u52A8\u753B/\u8D44\u6E90\u7BA1\u7406\u5668/\u7EC8\u7AEF/\u2728\u81EA\u5B9A\u4E49\uFF09\uFF1B", " \u7A97\u5185\u5BB9\u8FD8\u53EF\u653E iframe \u7F51\u9875\u4E0E\u6587\u4EF6\u9884\u89C8\uFF08.md/.txt/.tsx/.css/.html \u7B49\uFF09\u3002", "- \u7A97\u53E3\u53EF\u88C5\u8F7D\u7684\u5F62\u5F0F\uFF1AHTML \u5355\u6587\u4EF6\uFF08\u4EA4\u4E92 UI\uFF0C\u653E\u9879\u76EE\u6587\u4EF6\u5939\u540E\u5728\u8D44\u6E90\u7BA1\u7406\u5668\u70B9\u51FB\u6E32\u67D3\uFF09\u3001\u7F51\u9875 URL\u3001", @@ -18936,7 +18957,7 @@ async function createCustomSession(projectId, projectName, requirement, group, w const parent = g.parent.replace(/[\\/]+$/, ""); const name = g.name.replace(/^[\\/]+/, "").replace(/[\\/]+$/, ""); if (!parent || !name) throw new Error("invalid group path"); - const full = parent + "\\" + name; + const full = joinPath(parent, name); try { await ws.createDirectory?.(parent, name); } catch { @@ -19057,10 +19078,10 @@ function buildMountContent(folder, d) { const p = String(d?.path ?? "").trim(); if (!p) return null; if (d?.kind === "url") return { kind: "iframe", url: p, title: p }; - const full = p.indexOf(":") >= 0 ? p : folder + "\\" + p.replace(/^[\\/]+/, ""); + const full = isAbs(p) ? p : joinPath(folder, p); if (d?.kind === "file") return { kind: "file", path: full }; - const dir = full.replace(/[\\/][^\\/]*$/, ""); - const name = full.split(/[\\/]/).pop() ?? full; + const dir = parentPathOf(full); + const name = basenameOf(full); return { kind: "iframe", url: "/api/worktable/site/" + encodeURIComponent(dir) + "/" + encodeURIComponent(name), title: name }; } var notifyStateSeenRef = { current: {} }; @@ -19189,35 +19210,51 @@ function WorktableSection(props) { const [updateCheckOn, setUpdateCheckOn] = (0, import_react2.useState)(() => localStorage.getItem("dsh.worktable.updateCheck.v1") !== "0"); const [updateCopied, setUpdateCopied] = (0, import_react2.useState)(false); const [updateStatus, setUpdateStatus] = (0, import_react2.useState)("idle"); + const updateCheckingRef = (0, import_react2.useRef)(false); + const updateAliveRef = (0, import_react2.useRef)(true); + (0, import_react2.useEffect)(() => () => { + updateAliveRef.current = false; + }, []); const checkUpdates = (0, import_react2.useCallback)(async (force = false) => { + if (updateCheckingRef.current) return; const last = Number(localStorage.getItem("dsh.worktable.lastUpdateCheck.v1") ?? "0"); if (!force && Date.now() - last < 24 * 3600 * 1e3) return; + updateCheckingRef.current = true; setUpdateStatus("checking"); - let d = null; - for (let attempt = 0; attempt < 3 && !d; attempt++) { - try { - const r = await fetch("https://api.github.com/repos/" + UPDATE_REPO + "/releases/latest", { headers: { Accept: "application/vnd.github+json" }, cache: "no-store" }); - if (r.ok) d = await r.json(); - else if (r.status === 403 || r.status === 404) break; - } catch { + try { + let d = null; + for (let attempt = 0; attempt < 3 && !d && updateAliveRef.current; attempt++) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 8e3); + try { + const r = await fetch("https://api.github.com/repos/" + UPDATE_REPO + "/releases/latest", { headers: { Accept: "application/vnd.github+json" }, cache: "no-store", signal: ctrl.signal }); + if (r.ok) d = await r.json(); + else if (r.status === 403 || r.status === 404) break; + } catch { + } finally { + clearTimeout(timer); + } } - } - if (!d) { - setUpdateStatus("failed"); - return; - } - localStorage.setItem("dsh.worktable.lastUpdateCheck.v1", String(Date.now())); - const tag = (d.tag_name ?? "").replace(/^v/, ""); - if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { + if (!updateAliveRef.current) return; + if (!d) { + setUpdateStatus("failed"); + return; + } + localStorage.setItem("dsh.worktable.lastUpdateCheck.v1", String(Date.now())); + const tag = (d.tag_name ?? "").replace(/^v/, ""); + if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { + setUpdateStatus("uptodate"); + return; + } + if ((localStorage.getItem("dsh.worktable.skipVersion.v1") ?? "") === tag) { + setUpdateStatus("uptodate"); + return; + } + setUpdateInfo({ latest: tag, notes: (d.body ?? "").slice(0, 800), url: d.html_url ?? "" }); setUpdateStatus("uptodate"); - return; + } finally { + updateCheckingRef.current = false; } - if ((localStorage.getItem("dsh.worktable.skipVersion.v1") ?? "") === tag) { - setUpdateStatus("uptodate"); - return; - } - setUpdateInfo({ latest: tag, notes: (d.body ?? "").slice(0, 800), url: d.html_url ?? "" }); - setUpdateStatus("uptodate"); }, []); (0, import_react2.useEffect)(() => { if (updateCheckOn) void checkUpdates(); @@ -19674,7 +19711,7 @@ function WorktableSection(props) { return [ "\u3010\u4E3A dsh-worktable\uFF08\u5DE5\u4F5C\u53F0\uFF09\u63D2\u4EF6\u589E\u52A0\u4E00\u4E2A\u65B0\u7684\u5E03\u5C40\u9884\u8BBE\u3011", "", - "\u80CC\u666F\uFF1Adsh-worktable \u662F DeepSeek Harness \u7684\u81EA\u5EFA\u5BB9\u5668\u63D2\u4EF6\uFF08\u672C\u673A\u4ED3\u5E93 dsh-worktable\uFF0C\u63D2\u4EF6\u5305\u6839\u76EE\u5F55 01_content/\uFF09\u3002", + "\u80CC\u666F\uFF1Adsh-worktable \u662F DeepSeek Harness \u7684\u81EA\u5EFA\u5BB9\u5668\u63D2\u4EF6\uFF08\u672C\u673A\u4ED3\u5E93 dsh-worktable\uFF1B\u4E0D\u4E86\u89E3\u7684\u7EC6\u8282\u53EF\u76F4\u63A5\u5411\u7528\u6237\u63D0\u95EE\uFF09\u3002", "\u4FA7\u8FB9\u680F\u300C\u5DE5\u4F5C\u53F0\u300D\u533A\u5757\u7BA1\u7406\u9879\u76EE\uFF0C\u6BCF\u4E2A\u9879\u76EE\u6253\u5F00\u540E\u662F\u4E00\u4E2A\u5E73\u94FA\u5DE5\u4F5C\u533A\uFF08\u82E5\u5E72\u5185\u5BB9\u7A97 + \u53F3\u4FA7\u5BF9\u8BDD\u7A97\uFF09\u3002", "\u5E03\u5C40\u9884\u8BBE\u5B9A\u4E49\u5728 01_content/src/client/index.tsx \u7684 PRESET_DEFS \u6570\u7EC4\uFF0C\u9009\u62E9\u5668\u7F29\u7565\u56FE\u5728 presetThumb() \u51FD\u6570\u3002", "", @@ -19804,13 +19841,13 @@ function WorktableSection(props) { const ws = b.workspaces; const parent = consoleParent.replace(/[\\/]+$/, ""); const name = consoleName.replace(/^[\\/]+/, "").replace(/[\\/]+$/, ""); - const full = parent + "\\" + name; + const full = joinPath(parent, name); try { await ws?.createDirectory?.(parent, name); } catch { } try { - const r = await fetch("/api/worktable/mkdir", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ parent, name }) }); + const r = await fetch("/api/worktable/mkdir", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: full }) }); const d = await r.json(); if (!r.ok || !d?.ok) throw new Error(d?.error ?? "mkdir failed"); } catch { @@ -19926,7 +19963,7 @@ function WorktableSection(props) { try { let raw = rawManifest; if (raw == null) { - const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(folder + "\\widget-result.json"), { cache: "no-store" }); + const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(joinPath(folder, "widget-result.json")), { cache: "no-store" }); if (!r.ok) return; raw = (await r.text()).trim(); } @@ -19997,7 +20034,7 @@ function WorktableSection(props) { const folder = folders[pid]; if (!folder) continue; try { - const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(folder + "\\widget-result.json"), { cache: "no-store" }); + const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(joinPath(folder, "widget-result.json")), { cache: "no-store" }); if (!r.ok) continue; const raw = (await r.text()).trim(); if (!raw) continue; @@ -20918,7 +20955,7 @@ function WorktableSection(props) { updateStatus === "failed" && !updateInfo ? " \xB7 " + t("update.checkFail") : "" ] }), /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "dsh-wt_versionActions", children: [ - /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_updateBtn", onClick: () => void checkUpdates(true), children: updateStatus === "checking" ? t("update.checking") : t("update.checkNow") }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_updateBtn", disabled: updateStatus === "checking", onClick: () => void checkUpdates(true), children: updateStatus === "checking" ? t("update.checking") : t("update.checkNow") }), /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "dsh-wt_updateToggle", onClick: toggleUpdateCheck, children: [ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: t("update.autoCheck") }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_updateSwitch", "data-off": updateCheckOn ? void 0 : "true" }) @@ -21343,4 +21380,3 @@ markdown-it/dist/markdown-it.mjs: (*! markdown-it 15.0.0 https://github.com/markdown-it/markdown-it @license MIT *) */ return module.exports; } }); -//# sourceMappingURL=client.js.map diff --git a/01_content/lib/index.js b/01_content/lib/index.js index 940dad2..c798639 100644 --- a/01_content/lib/index.js +++ b/01_content/lib/index.js @@ -88,6 +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.2.2"; var name = "dsh-worktable"; var inject = ["webServer", "sessions"]; var HEALTH_PATH = "/api/worktable/health"; @@ -300,7 +301,7 @@ function apply(ctx) { kind: "exact", path: HEALTH_PATH, handler: (_req, res) => { - json(res, 200, { plugin: "dsh-worktable", version: "0.2.0", ok: true }); + json(res, 200, { plugin: "dsh-worktable", version: PLUGIN_VERSION, ok: true }); } }); webServer.register({ @@ -501,7 +502,7 @@ function apply(ctx) { } const abs = pathResolve(p); const fsx = await import("node:fs/promises"); - const parent = abs.split(/[\\/]/).slice(0, -1).join("\\") || "\\"; + const parent = dirname(abs); try { await fsx.access(parent); } catch { @@ -532,4 +533,3 @@ export { inject, name }; -//# sourceMappingURL=index.js.map diff --git a/01_content/package.json b/01_content/package.json index 157fb6d..5f6a50e 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.2.1", + "version": "0.2.2", "license": "MIT", "type": "module", "main": "lib/index.js", @@ -11,7 +11,8 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/client.js", "cordis.patch.yml", "dsh.plugin.json", "README.md", diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index a2beab7..5d0a2e5 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -1,6 +1,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { css } from './styles' import { NS, zh, en, type WorktableKey } from './locales' +import { isAbs, joinPath, parentPathOf, basenameOf } from './pathutil' import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split' /** @@ -21,7 +22,7 @@ type DockMode = 'footer' | 'float' // ── 更新检查(客户端直连 GitHub Releases API,只读 GET;失败静默)── declare const __WT_VERSION__: string -const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? '0.2.0' : __WT_VERSION__ +const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? 'dev' : __WT_VERSION__ const UPDATE_REPO = 'Aisland-SJL/dsh-worktable' const UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"' const UPGRADE_AI = '帮我升级 dsh-worktable:执行 ' + UPGRADE_CMD + ',完成后提醒我重启 dsh web 并刷新页面' @@ -729,7 +730,7 @@ export type NewSessionGroup = { kind: 'none' } | { kind: 'existing'; workspaceId /** 插件知识包:附在窗口任务提示词里,让接收方跳过对插件源码的重新侦察,直接干活 */ const KNOWLEDGE_PACK = [ '【插件知识包·请直接采用,不要重新侦察插件源码】', - '- dsh-worktable 是本机 DeepSeek Harness 的自建容器插件(仓库 E:\\AI_Workspace\\DeepseekHarness\\Projects\\dsh-worktable,插件包根目录 01_content/)。', + '- dsh-worktable 是 DeepSeek Harness 的自建容器插件(工作台:侧边栏里的项目应用抽屉;不了解的细节可直接向用户提问)。', '- 窗口(内容窗)模型:每个窗 = 一个标签页;未指派时显示选择器(浏览器/动画/资源管理器/终端/✨自定义);', ' 窗内容还可放 iframe 网页与文件预览(.md/.txt/.tsx/.css/.html 等)。', '- 窗口可装载的形式:HTML 单文件(交互 UI,放项目文件夹后在资源管理器点击渲染)、网页 URL、', @@ -783,7 +784,7 @@ export async function createCustomSession(projectId: string, projectName: string const parent = g.parent.replace(/[\\/]+$/, '') const name = g.name.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '') if (!parent || !name) throw new Error('invalid group path') - const full = parent + '\\' + name + const full = joinPath(parent, name) try { await ws.createDirectory?.(parent, name) } catch { /* 宿主 browse 能力本机为 native 时不可用 */ } try { // 兜底:插件服务端 mkdir(父目录必须已存在,避免误建深层目录) @@ -908,10 +909,10 @@ function buildMountContent(folder: string, d: any): any { const p = String(d?.path ?? '').trim() if (!p) return null if (d?.kind === 'url') return { kind: 'iframe', url: p, title: p } - const full = p.indexOf(':') >= 0 ? p : (folder + '\\' + p.replace(/^[\\/]+/, '')) + const full = isAbs(p) ? p : joinPath(folder, p) if (d?.kind === 'file') return { kind: 'file', path: full } - const dir = full.replace(/[\\/][^\\/]*$/, '') - const name = full.split(/[\\/]/).pop() ?? full + const dir = parentPathOf(full) + const name = basenameOf(full) return { kind: 'iframe', url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(name), title: name } } @@ -1052,25 +1053,38 @@ function WorktableSection(props: any) { const [updateCheckOn, setUpdateCheckOn] = useState(() => localStorage.getItem('dsh.worktable.updateCheck.v1') !== '0') const [updateCopied, setUpdateCopied] = useState(false) const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'uptodate' | 'failed'>('idle') + const updateCheckingRef = useRef(false) + const updateAliveRef = useRef(true) + useEffect(() => () => { updateAliveRef.current = false }, []) const checkUpdates = useCallback(async (force = false) => { + if (updateCheckingRef.current) return // 防重入:并发点击只保留一个 in-flight const last = Number(localStorage.getItem('dsh.worktable.lastUpdateCheck.v1') ?? '0') if (!force && Date.now() - last < 24 * 3600 * 1000) return // 自动检查节流;手动「立即检查」绕过节流 + updateCheckingRef.current = true setUpdateStatus('checking') - let d: { tag_name?: string; body?: string; html_url?: string } | null = null - for (let attempt = 0; attempt < 3 && !d; attempt++) { - try { - const r = await fetch('https://api.github.com/repos/' + UPDATE_REPO + '/releases/latest', { headers: { Accept: 'application/vnd.github+json' }, cache: 'no-store' }) - if (r.ok) d = await r.json() as { tag_name?: string; body?: string; html_url?: string } - else if (r.status === 403 || r.status === 404) break // 限流/不存在:不再重试 - } catch { /* 网络抖动:下一轮重试 */ } + try { + let d: { tag_name?: string; body?: string; html_url?: string } | null = null + for (let attempt = 0; attempt < 3 && !d && updateAliveRef.current; attempt++) { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 8000) // 单次 8s 超时,计入下一轮重试 + try { + const r = await fetch('https://api.github.com/repos/' + UPDATE_REPO + '/releases/latest', { headers: { Accept: 'application/vnd.github+json' }, cache: 'no-store', signal: ctrl.signal }) + if (r.ok) d = await r.json() as { tag_name?: string; body?: string; html_url?: string } + else if (r.status === 403 || r.status === 404) break // 限流/不存在:不再重试 + } catch { /* 网络抖动/超时:下一轮重试 */ } + finally { clearTimeout(timer) } + } + if (!updateAliveRef.current) return // 组件已卸载:不再更新状态 + if (!d) { setUpdateStatus('failed'); return } + localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now())) + const tag = (d.tag_name ?? '').replace(/^v/, '') + if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { setUpdateStatus('uptodate'); return } + if ((localStorage.getItem('dsh.worktable.skipVersion.v1') ?? '') === tag) { setUpdateStatus('uptodate'); return } + setUpdateInfo({ latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' }) + setUpdateStatus('uptodate') + } finally { + updateCheckingRef.current = false } - if (!d) { setUpdateStatus('failed'); return } - localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now())) - const tag = (d.tag_name ?? '').replace(/^v/, '') - if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { setUpdateStatus('uptodate'); return } - if ((localStorage.getItem('dsh.worktable.skipVersion.v1') ?? '') === tag) { setUpdateStatus('uptodate'); return } - setUpdateInfo({ latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' }) - setUpdateStatus('uptodate') }, []) useEffect(() => { if (updateCheckOn) void checkUpdates() }, [updateCheckOn, checkUpdates]) const copyUpgradeAi = async () => { @@ -1533,7 +1547,7 @@ function buildCustomLayoutPrompt(req: string): string { return [ '【为 dsh-worktable(工作台)插件增加一个新的布局预设】', '', - '背景:dsh-worktable 是 DeepSeek Harness 的自建容器插件(本机仓库 dsh-worktable,插件包根目录 01_content/)。', + '背景:dsh-worktable 是 DeepSeek Harness 的自建容器插件(本机仓库 dsh-worktable;不了解的细节可直接向用户提问)。', '侧边栏「工作台」区块管理项目,每个项目打开后是一个平铺工作区(若干内容窗 + 右侧对话窗)。', '布局预设定义在 01_content/src/client/index.tsx 的 PRESET_DEFS 数组,选择器缩略图在 presetThumb() 函数。', '', @@ -1646,10 +1660,10 @@ function buildCustomLayoutPrompt(req: string): string { const ws = b.workspaces as any const parent = consoleParent.replace(/[\\/]+$/, '') const name = consoleName.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '') - const full = parent + '\\' + name + const full = joinPath(parent, name) try { await ws?.createDirectory?.(parent, name) } catch {} try { - const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ parent, name }) }) + const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: full }) }) const d = await r.json() if (!r.ok || !d?.ok) throw new Error(d?.error ?? 'mkdir failed') } catch { /* 目录建不出的错误最终由 workspace.create 暴露 */ } @@ -1764,7 +1778,7 @@ function buildCustomLayoutPrompt(req: string): string { try { let raw = rawManifest if (raw == null) { - const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(folder + '\\widget-result.json'), { cache: 'no-store' }) + const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' }) if (!r.ok) return raw = (await r.text()).trim() } @@ -1839,7 +1853,7 @@ function buildCustomLayoutPrompt(req: string): string { const folder = folders[pid] if (!folder) continue try { - const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(folder + '\\widget-result.json'), { cache: 'no-store' }) + const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' }) if (!r.ok) continue const raw = (await r.text()).trim() if (!raw) continue @@ -2713,7 +2727,7 @@ function buildCustomLayoutPrompt(req: string): string { {updateStatus === 'failed' && !updateInfo ? ' · ' + t('update.checkFail') : ''} - diff --git a/01_content/src/client/pathutil.ts b/01_content/src/client/pathutil.ts new file mode 100644 index 0000000..8d30190 --- /dev/null +++ b/01_content/src/client/pathutil.ts @@ -0,0 +1,39 @@ +/** + * 客户端路径小工具(纯字符串,不引第三方库): + * 保留输入路径原有的分隔符风格,只做跨平台安全的拼接/取父/取名。 + * 覆盖:Windows 盘符根、UNC、POSIX 绝对路径与根目录、相对路径。 + */ + +/** 绝对路径判定:盘符 C:\ 或 C:/、UNC \\server\share、POSIX / 开头 */ +export function isAbs(p: string): boolean { + if (/^[A-Za-z]:[\\/]/.test(p)) return true + if (p.startsWith('\\\\')) return true + if (p.startsWith('/')) return true + return false +} + +/** 按 base 的分隔符风格拼接(base 含 \ 用 \,否则用 /;剥掉两端重复分隔符;盘符根与根目录不吞分隔符) */ +export function joinPath(base: string, rel: string): string { + const relTrim = rel.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '') + const sep = base.includes('\\') ? '\\' : '/' + if (base === '/' || base === '\\') return base + relTrim + if (/^[A-Za-z]:[\\/]$/.test(base)) return base + relTrim + const baseTrim = base.replace(/[\\/]+$/, '') + if (!baseTrim) return relTrim || sep + return baseTrim + sep + relTrim +} + +/** 取父目录:'/foo'→'/';'C:\foo'→'C:\';'C:\'→'C:\';'/'→'/';'\\server\share\x'→'\\server\share' */ +export function parentPathOf(p: string): string { + const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + if (idx < 0) return p + if (idx === 0) return p[0] + if (idx === 2 && /^[A-Za-z]:[\\/]/.test(p)) return p.slice(0, 3) + return p.slice(0, idx) +} + +/** 取文件名:'a/b/c.html'→'c.html' */ +export function basenameOf(p: string): string { + const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + return idx < 0 ? p : p.slice(idx + 1) +} diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 6923b24..9dbe8e0 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -1,4 +1,5 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react' +import { parentPathOf } from './pathutil' import { Terminal } from 'xterm' import MarkdownIt from 'markdown-it' @@ -253,14 +254,6 @@ async function postJson(url: string, body: unknown): Promise { return res.json() } -function parentPathOf(p: string): string { - const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) - if (idx <= 0) return p - let parent = p.slice(0, idx) - if (/^[A-Za-z]:$/.test(parent)) parent += '\\' - return parent -} - /** 拖拽换位暂存 */ let dragPane: { row: PaneRow; index: number } | null = null /** 标签拖拽暂存(跨窗移动) */ diff --git a/01_content/src/index.ts b/01_content/src/index.ts index 4f34aea..6c14c41 100644 --- a/01_content/src/index.ts +++ b/01_content/src/index.ts @@ -16,6 +16,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url' * node-pty 与 ws,缺失时该路由不注册、终端窗降级提示) */ +declare const __WT_VERSION__: string +const PLUGIN_VERSION = typeof __WT_VERSION__ === 'undefined' ? 'dev' : __WT_VERSION__ + export const name = 'dsh-worktable' export const inject = ['webServer', 'sessions'] @@ -209,7 +212,7 @@ export function apply(ctx: Context) { kind: 'exact', path: HEALTH_PATH, handler: (_req: any, res: any) => { - json(res, 200, { plugin: 'dsh-worktable', version: '0.2.0', ok: true }) + json(res, 200, { plugin: 'dsh-worktable', version: PLUGIN_VERSION, ok: true }) }, }) @@ -362,7 +365,7 @@ export function apply(ctx: Context) { if (!p) { json(res, 400, { error: 'missing path' }); return } const abs = pathResolve(p) const fsx = await import('node:fs/promises') - const parent = abs.split(/[\\/]/).slice(0, -1).join('\\') || '\\' + const parent = dirname(abs) try { await fsx.access(parent) } catch { json(res, 400, { error: 'parent not found' }); return } await fsx.mkdir(abs) json(res, 200, { ok: true, path: abs }) diff --git a/04_test/functional-diag.cjs b/04_test/functional-diag.cjs index a55ac3b..15a8343 100644 --- a/04_test/functional-diag.cjs +++ b/04_test/functional-diag.cjs @@ -474,8 +474,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); var out={}; try{ var st=window.__dshWorktable.splitStore; - var probe=await fetch('/api/worktable/write',{method:'POST',headers:{'content-type':'application/json'},body:'{}'}).catch(function(){return null;}); - out.writeRouteReady=!!(probe&&probe.status===400); + var probe=await fetch('/api/worktable/write',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:'C:/Users/SJL/AppData/Local/Temp/wt-edit-test.md',content:'# ORIG'})}).catch(function(){return null;}); + out.writeRouteReady=!!(probe&&probe.status===200); if(out.writeRouteReady){ st.open({id:'t-mdedit',title:'md',top:null,main:[{id:'m1',title:'m',min:200,content:null}],chatWidth:{default:320,min:240,max:600}}); await new Promise(function(r){setTimeout(r,400)}); @@ -708,28 +708,17 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 还原临时夹具 try { fs.writeFileSync(TEMP_MD, '# ORIG\n', 'utf8') } catch {} - const errors = events.filter((e) => { - const ent = e.method === 'Log.entryAdded' ? e.params.entry : null; - const txt = ent ? (ent.text || '') + '|' + (ent.url || '') : ''; - if (txt.indexOf('/api/worktable/write') >= 0) return false; - return true; - }).filter((e) => { - // 服务端新路由(site/write/workspaces):运行中的服务器未重启前其 404 属预期(重启后移除本过滤) - const ent = e.method === 'Log.entryAdded' ? e.params.entry : null; - const txt = ent ? (ent.text || '') + '|' + (ent.url || '') : ''; - if (txt.indexOf('/api/worktable/site') >= 0) return false; - if (txt.indexOf('/api/worktable/workspaces') >= 0) return false; - return true; - }).filter((e) => + // 严格门禁:不再掩盖任何插件路由错误(site/write/workspaces 曾因旧服务器未重启被过滤,已移除) + const errors = events.filter((e) => e.method === 'Runtime.exceptionThrown' || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error') || (e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error'), ).slice(0, 10).map((e) => e.method === 'Runtime.exceptionThrown' ? 'EXCEPTION: ' + ((e.params.exceptionDetails.exception || {}).description || e.params.exceptionDetails.text) - : 'LOG: ' + (e.params.entry ? e.params.entry.text : '').slice(0, 200)); + : 'LOG: ' + (e.params.entry ? e.params.entry.text : '').slice(0, 200) + (e.params.entry && e.params.entry.url ? ' @ ' + e.params.entry.url : '')); console.log('ERRORS_COUNT:', errors.length); errors.forEach((x) => console.log(x)); ws.close(); proc.kill(); - process.exit(0); + process.exit(errors.length === 0 ? 0 : 1); // 严格门禁:发现错误即非零退出 })().catch((e) => { console.log('SCRIPT_FAIL:', e); proc.kill(); process.exit(1); }); \ No newline at end of file diff --git a/04_test/pathutil-matrix.cjs b/04_test/pathutil-matrix.cjs new file mode 100644 index 0000000..0a6df7e --- /dev/null +++ b/04_test/pathutil-matrix.cjs @@ -0,0 +1,55 @@ +// pathutil-matrix.cjs — pathutil 路径矩阵定向验证(用 esbuild 把 TS 编译成 CJS 后实测) +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const { buildSync } = require('../01_content/node_modules/esbuild'); + +const out = path.join(os.tmpdir(), 'wt-pathutil-' + process.pid + '.cjs'); +buildSync({ + entryPoints: [path.join(__dirname, '..', '01_content', 'src', 'client', 'pathutil.ts')], + outfile: out, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', +}); +const u = require(out); +fs.unlinkSync(out); + +let fails = 0; +const eq = (label, got, want) => { + if (got === want) { console.log('PASS ' + label + ' = ' + JSON.stringify(got)); } + else { fails++; console.log('FAIL ' + label + ': got ' + JSON.stringify(got) + ' want ' + JSON.stringify(want)); } +}; + +// joinPath:Windows 反斜杠风格 / Windows 正斜杠风格 / POSIX / 根目录 / 盘符根 / UNC / 相对 +eq("join C:\\base+rel", u.joinPath('C:\\base', 'rel'), 'C:\\base\\rel'); +eq("join C:/base+rel", u.joinPath('C:/base', 'rel'), 'C:/base/rel'); +eq("join /Users+project", u.joinPath('/Users/name', 'project'), '/Users/name/project'); +eq("join /+project", u.joinPath('/', 'project'), '/project'); +eq("join C:\\+project", u.joinPath('C:\\', 'project'), 'C:\\project'); +eq("join UNC+x", u.joinPath('\\\\server\\share', 'x'), '\\\\server\\share\\x'); +eq("join rel+rel", u.joinPath('a/b', 'c'), 'a/b/c'); +eq("join trim seps", u.joinPath('C:\\base\\', '\\rel\\'), 'C:\\base\\rel'); + +// isAbs +eq("isAbs C:\\x", u.isAbs('C:\\x'), true); +eq("isAbs C:/x", u.isAbs('C:/x'), true); +eq("isAbs /x", u.isAbs('/x'), true); +eq("isAbs UNC", u.isAbs('\\\\server\\share'), true); +eq("isAbs rel", u.isAbs('rel/x'), false); + +// parentPathOf:POSIX 根 / 盘符根 / 根自身 / UNC / 相对 +eq("parent /foo", u.parentPathOf('/foo'), '/'); +eq("parent C:\\foo", u.parentPathOf('C:\\foo'), 'C:\\'); +eq("parent C:\\", u.parentPathOf('C:\\'), 'C:\\'); +eq("parent /", u.parentPathOf('/'), '/'); +eq("parent UNC/x", u.parentPathOf('\\\\server\\share\\x'), '\\\\server\\share'); +eq("parent a/b", u.parentPathOf('a/b'), 'a'); + +// basenameOf +eq("base a/b/c.html", u.basenameOf('a/b/c.html'), 'c.html'); +eq("base C:\\x\\y.txt", u.basenameOf('C:\\x\\y.txt'), 'y.txt'); + +console.log(fails === 0 ? 'MATRIX ALL PASS' : 'MATRIX FAILS: ' + fails); +process.exit(fails === 0 ? 0 : 1); diff --git a/04_test/probe-update-scenarios.cjs b/04_test/probe-update-scenarios.cjs new file mode 100644 index 0000000..af3bbc4 --- /dev/null +++ b/04_test/probe-update-scenarios.cjs @@ -0,0 +1,117 @@ +// probe-update-scenarios.cjs — 更新检查五场景定向验证(CDP Fetch 拦截;fulfill 带 CORS 头) +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9398; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1200,800', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-updscn2', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(JSON.parse(d))); }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const OK_BODY = Buffer.from(JSON.stringify({ tag_name: 'v0.1.0', body: 'scenario test', html_url: 'https://example.com/x' })).toString('base64'); +const FAIL_BODY = Buffer.from('{"message":"boom"}').toString('base64'); +const HI_BODY = Buffer.from(JSON.stringify({ tag_name: 'v0.2.3', body: 'v0.2.3 release notes', html_url: 'https://example.com/hi' })).toString('base64'); +const HDRS = [{ name: 'Content-Type', value: 'application/json' }, { name: 'Access-Control-Allow-Origin', value: '*' }]; +let mode = 'success'; +let reqCount = 0; +let fulfillCount = 0; +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { + const m = JSON.parse(String(raw)); + if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } + else if (m.method === 'Fetch.requestPaused') { + reqCount++; + const rid = m.params.requestId; + if (mode === 'pending') { + setTimeout(() => { try { ws.send(JSON.stringify({ id: ++id, method: 'Fetch.continueRequest', params: { requestId: rid } })); } catch {} }, 9000); + } else if (mode === 'fail500') { + ws.send(JSON.stringify({ id: ++id, method: 'Fetch.fulfillRequest', params: { requestId: rid, responseCode: 500, responseHeaders: HDRS, body: FAIL_BODY } })); + } else if (mode === 'twothenok') { + if (fulfillCount < 2) { fulfillCount++; ws.send(JSON.stringify({ id: ++id, method: 'Fetch.fulfillRequest', params: { requestId: rid, responseCode: 500, responseHeaders: HDRS, body: FAIL_BODY } })); } + else { ws.send(JSON.stringify({ id: ++id, method: 'Fetch.fulfillRequest', params: { requestId: rid, responseCode: 200, responseHeaders: HDRS, body: OK_BODY } })); } + } else if (mode === 'slowok') { + setTimeout(() => { try { ws.send(JSON.stringify({ id: ++id, method: 'Fetch.fulfillRequest', params: { requestId: rid, responseCode: 200, responseHeaders: HDRS, body: OK_BODY } })); } catch {} }, 2500); + } else if (mode === 'hiok') { + ws.send(JSON.stringify({ id: ++id, method: 'Fetch.fulfillRequest', params: { requestId: rid, responseCode: 200, responseHeaders: HDRS, body: HI_BODY } })); + } else { + ws.send(JSON.stringify({ id: ++id, method: 'Fetch.fulfillRequest', params: { requestId: rid, responseCode: 200, responseHeaders: HDRS, body: OK_BODY } })); + } + } + }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Fetch.enable', { patterns: [{ urlPattern: '*api.github.com*' }] }); + const evaluate = async (expression) => { + const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: String(r.result.exceptionDetails.text) }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + const rowSnap = () => evaluate("(function(){ var r=document.querySelector('.dsh-wt_versionRow'); if(!r) return null; var btn=Array.from(r.querySelectorAll('button')).find(function(b){ return b.textContent.indexOf('检查')>=0 || b.textContent.indexOf('Check')>=0; }); return { text: r.innerText.replace(/\\s+/g,' '), disabled: btn ? btn.disabled : null, label: btn ? btn.textContent.trim() : null }; })()"); + const openSettings = async () => { + await evaluate("(function(){ var b=document.querySelector('button[title=\"设置\"]')||document.querySelector('button[title=\"Settings\"]'); if(b) b.click(); })()"); + await sleep(500); + }; + const clickCheck = async () => { + await evaluate("(function(){ var r=document.querySelector('.dsh-wt_versionRow'); if(!r) return; var btn=Array.from(r.querySelectorAll('button')).find(function(b){ return b.textContent.indexOf('检查')>=0 || b.textContent.indexOf('Check')>=0; }); if(btn) btn.click(); })()"); + }; + async function scenario(name, opts) { + mode = opts.mode; fulfillCount = 0; + // 预热加载:让首屏自动检查在计数前跑完 + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + await sleep(6000); + const rail = await evaluate("!!document.querySelector('.dsh-wt_rail')"); + if (rail) { await evaluate("(function(){ var b=document.querySelector('button.hHd-Xa_toggle'); if(b) b.click(); })()"); await sleep(1500); } + await evaluate("localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now())); localStorage.removeItem('dsh.worktable.skipVersion.v1');"); + reqCount = 0; + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + await sleep(6000); + const rail2 = await evaluate("!!document.querySelector('.dsh-wt_rail')"); + if (rail2) { await evaluate("(function(){ var b=document.querySelector('button.hHd-Xa_toggle'); if(b) b.click(); })()"); await sleep(1500); } + await openSettings(); + const before = await rowSnap(); + await clickCheck(); + if (opts.reentry) { for (let i = 0; i < 4; i++) { await clickCheck(); } } + await sleep(Math.min(900, opts.waitMs)); + const mid = await rowSnap(); + await sleep(opts.waitMs); + const end = await rowSnap(); + console.log('SCENARIO[' + name + '] requests=' + reqCount); + console.log(' mid:', JSON.stringify(mid)); + console.log(' end:', JSON.stringify(end)); + return { before, mid, end, requests: reqCount }; + } + const results = []; + results.push(['1-success', await scenario('1-success', { mode: 'success', waitMs: 4000 })]); + results.push(['2-pending-timeout', await scenario('2-pending-timeout', { mode: 'pending', waitMs: 26000 })]); + results.push(['3-twothenok', await scenario('3-twothenok', { mode: 'twothenok', waitMs: 5000 })]); + results.push(['4-fail-all', await scenario('4-fail-all', { mode: 'fail500', waitMs: 4000 })]); + results.push(['5-reentry', await scenario('5-reentry', { mode: 'slowok', waitMs: 4000, reentry: true })]); + results.push(['6-higher-version', await scenario('6-higher-version', { mode: 'hiok', waitMs: 4000 })]); + const hiState = await evaluate("(function(){ var b=document.querySelector('.dsh-wt_updateBadge'); if(!b) return {badge:false}; b.click(); return {badge:true}; })()"); + await sleep(700); + const hiCard = await evaluate("(function(){ var c=document.querySelector('.dsh-wt_updateCard'); if(!c) return {card:false}; return {card:true, text:c.innerText.replace(/\\s+/g,' ')}; })()"); + console.log('HI_BADGE:', JSON.stringify(hiState), 'HI_CARD:', JSON.stringify(hiCard)); + let pass = true; + const ok = (cond, label) => { if (!cond) { pass = false; console.log(' ❌ ' + label); } else { console.log(' ✓ ' + label); } }; + const [, s1] = results[0]; ok(s1.requests >= 1 && /已是最新版本|Up to date/.test(s1.end.text || ''), '成功 → 已是最新版本'); + const [, s2] = results[1]; ok(s2.requests === 3 && /上次检查未成功|Last check failed/.test(s2.end.text || ''), 'pending×3 超时 → 失败提示'); ok(s2.mid && s2.mid.disabled === true && /检查中|Checking/.test(s2.mid.label || ''), '检查中按钮禁用+文案'); + const [, s3] = results[2]; ok(s3.requests === 3 && /已是最新版本|Up to date/.test(s3.end.text || ''), '两败一成 → 已是最新版本'); + const [, s4] = results[3]; ok(s4.requests === 3 && /上次检查未成功|Last check failed/.test(s4.end.text || ''), '三败 → 失败提示'); + const [, s5] = results[4]; ok(s5.requests === 1, '慢响应期间连点 5 次仅 1 个 in-flight(防重入)'); ok(s5.mid && s5.mid.disabled === true, '请求进行中按钮保持禁用'); + const [, s6] = results[5]; ok(s6.requests >= 1 && hiState && hiState.badge === true && hiCard && hiCard.card === true && /v0\.2\.3/.test(hiCard.text || ''), '更高版本 → 徽标亮起 + 更新卡显示 v0.2.3'); + console.log(pass ? 'ALL SCENARIOS PASS' : 'SCENARIO FAILURES PRESENT'); + ws.close(); proc.kill(); process.exit(pass ? 0 : 1); +})().catch((e) => { console.error('PROBE FAIL', e); try { proc.kill(); } catch {} process.exit(1); }); diff --git a/AGENTS.md b/AGENTS.md index 7315d2c..8cd2cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,8 @@ - **不替换、不禁用任何官方插件**(ui-sidebar / ui-workspace / ui-layout)。 - 所有状态只存 localStorage(键 `dsh.worktable.view.v1`),不读写工作区文件。 - dsh-travelatlas 是入驻项目而非本仓库的一部分;协议见 `02_process/PRD.md` §5.3/5.4。 +- **平台边界**:Windows 是当前完整验证平台;macOS 为实验性支持(核心文件路径代码已做跨平台适配, + 尚未真机端到端验证)。路径拼接必须走 `pathutil.ts` helper 或 Node `path` API,不手写分隔符。 ## 构建与验证 @@ -138,8 +140,9 @@ node --check lib/index.js previewCache;sweepPreviews 在打开控制室时 + 控制室开着且会话快照变化防抖 6s 触发; 失败静默回退内存路径 lastTextOf。拉取是带宽成本不是 Token 成本。 - 状态指示:卡片右上角小圆点已删(整卡光效表达状态);状态计算不变。 -- **更新检查(v0.2.1)**:客户端直连 GitHub Releases API 比版本(只读 GET;自动每天最多 - 一次,手动「立即检查」绕过节流;失败重试 3 次后状态行显示「上次检查未成功」)。 +- **更新检查(v0.2.2)**:客户端直连 GitHub Releases API 比版本(只读 GET;自动每天最多 + 一次,手动「立即检查」绕过节流;单次 8s 超时(AbortController)+ 最多 3 次重试, + in-flight 防重入、检查中按钮禁用、组件卸载后停止重试与状态更新;失败后状态行显示「上次检查未成功」)。 状态四态 idle/checking/uptodate/failed;徽标 = 「工作台」标题右侧琥珀呼吸小圆(SVG 同步 图标、不显示版本号),仅发现更新时出现;更新卡在设置面板顶部(复制 AI 提示词 + 忽略此 版本 + 命令框供终端用户手抄),版本号与自动检查开关在面板底部;设置弹窗右上角 ✕ 关闭、 diff --git a/README.md b/README.md index bf641f4..d030ca4 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,13 @@ node --check lib/index.js - **Build must run inside `01_content`** — building from the repo root writes `lib/` to the wrong place while the host keeps loading the old bundle - The client bundle keeps the `window.__ModuleLoader__.load` handshake; `react` and `@deepseek-ai/*` stay external -- Regression: `04_test/functional-diag.cjs` (20 steps) plus targeted probes (control room, bind panel, collapsed rail, model inheritance) +- Regression: `04_test/functional-diag.cjs` (20 steps, strict gate) plus targeted probes (control room, bind panel, collapsed rail, model inheritance), the path matrix (`04_test/pathutil-matrix.cjs`) and update-check scenarios (`04_test/probe-update-scenarios.cjs`) --- ## Known limits +- **Platform**: Windows is the fully tested platform. macOS support is experimental: the core file-path code has been adapted for cross-platform use, but no end-to-end test has been completed on macOS hardware. - State lives in the browser (`localStorage`) — projects, bindings and views do not sync across machines - The terminal pane is a plain PowerShell host on Windows (no PTY feature parity with the native terminal app) - Auto-mount requires the agent to actually write `widget-result.json` in the project folder diff --git a/README.zh.md b/README.zh.md index b6678bd..2c08b2c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -104,6 +104,7 @@ node --check lib/index.js ## 已知限制 +- **平台**:Windows 是当前完整验证平台。macOS 为实验性支持:核心文件路径代码已做跨平台适配,但尚未在 macOS 真机完成端到端验证。 - 状态在浏览器本地(localStorage)——项目、绑定与视图不跨设备同步 - 终端窗格在 Windows 上是朴素的 PowerShell 宿主(与原生终端应用无 PTY 对等) - 自动挂载要求 agent 确实在项目文件夹写出 `widget-result.json`