@civilization/dsh-drawio 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,9 +30,9 @@
30
30
  - **SVG (矢量图)**:提取无损矢量图形,任意放大不失真;
31
31
  - **XML-PNG (可编辑图)**:在 PNG 图像中内嵌图表 XML 原数据,既能作为常规图片展示,又可随时拖回 Draw.io 继续二次编辑。
32
32
  - **三大输出方式**:
33
- - **保存到工作区**:在当前画板文件同级目录下自动生成同名图片文件(如 `arch.drawio` -> `arch.png`),自动触发 DSH 文件系统感知;
34
- - **复制到剪贴板**:一键将 PNG 图片写入操作系统剪贴板(`navigator.clipboard`),方便在 Markdown、PRD 或聊天工具中直接 `Ctrl + V` 粘贴;
35
- - **下载图片文件**:触发浏览器原生下载弹窗保存到本地磁盘。
33
+ - **保存到工作区(仅 SVG)**:在当前画板文件同级目录下自动生成同名矢量文件(如 `arch.drawio` -> `arch.svg`),自动触发 DSH 文件系统感知。DSH 的 `ctx.fs` 只提供文本写入(无二进制写入口),光栅图落盘会退化成 base64 文本,因此 PNG / XML-PNG 不走此通道;
34
+ - **复制到剪贴板**:PNG / XML-PNG 写入操作系统剪贴板(`navigator.clipboard`),SVG 复制为源码文本,方便在 Markdown、PRD 或聊天工具中直接 `Ctrl + V` 粘贴;
35
+ - **下载图片文件**:触发浏览器原生下载弹窗保存到本地磁盘,三种格式均可用。
36
36
 
37
37
  ## 开发验证
38
38
 
@@ -49,7 +49,40 @@ dsh web --port 3082 --no-open
49
49
  `.drawio` 文件时,由官方资源路由选择本插件的画板;读取和自动保存通过插件自有的
50
50
  `/dsh-drawio/api` 接口完成,并限制在当前会话工作区内。
51
51
 
52
- 已在 DSH 0.1.5-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
52
+ ## 💡 DSH Tab 认领机制与模式声明(避坑指南)
53
+
54
+ 本插件向 DSH 官方 `sidebarRightTabs` 注册画板 Tab 类型时,声明了双重匹配模式:
55
+
56
+ ```typescript
57
+ patterns: ['dsh-resource://file/**/*.drawio', '*.drawio']
58
+ ```
59
+
60
+ ### 为什么必须包含整地址模式?
61
+
62
+ DSH 的 `sidebarRightTabs` 在判定「由谁打开此文件」时,采用如下候选排序规则(详见官方 `tab-registry.ts` 的 `candidates()`):
63
+
64
+ 1. **优先级分档(Priority Band)**:如 `builtin`、`extension` 等;
65
+ 2. **命中模式长度(Matched Pattern Length)**:同档位下,**匹配命中的模式字符数更长者胜**;
66
+ 3. **注册顺序(Registration Order)**。
67
+
68
+ #### 潜在冲突场景
69
+ 若插件仅声明裸扩展名 `*.drawio`(8 字符),一旦用户安装了带有“接管所有文件”的通用编辑器插件(例如某些侧边栏/文件编辑插件声明了 `dsh-resource://file/**`,长 22 字符):
70
+ - 二者同属 `extension` 优先级;
71
+ - 长度比对:`22 > 8`,通用文件编辑器**按字符长度胜出并强行接管**;
72
+ - 结果:用户在文件树点击 `.drawio`,打开的是别人的纯文本编辑器并展示 XML 源码,本插件画板排第二永不渲染(即便 `*.drawio` 在语义上更精确)。
73
+
74
+ #### 最佳实践解法
75
+ - **`dsh-resource://file/**/*.drawio`**(31 字符):含冒号的分隔符模式执行整地址匹配(如 `dsh-resource://file/session/<sid>/out/arch.drawio`),以 31 > 22 字符在同档位中反超通用编辑器,稳稳赢得 `.drawio` 文件的专属画板渲染权;
76
+ - **`*.drawio`**:不带分隔符的模式按 URI basename 匹配,继续兼容覆盖非 `dsh-resource://` 开头的任意形态地址。
77
+
78
+ > [!TIP]
79
+ > 此规则对所有**“按特定扩展名注册定制编辑器 / 视图 Tab”**的 DSH 插件均成立。编写特定扩展名 Tab 时,务必补全 `dsh-resource://file/**/*.ext` 整地址模式,防止被通用文件接管插件按模式长度压制。
80
+
81
+ 已在 DSH 0.2.0-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
82
+
83
+ `peerDependencies` 只声明**下界**(`>=0.1.5-rc.2`,无上界),以便跟随 DSH 的快速迭代:DSH 的兼容性判定用运行时版本比对这些范围,因此新版本不会再被旧插件卡住。代价是不再对未来的破坏性变更提供“响亮拒绝”,且**不保证前序版本**——已发布版本只在上述版本上验证过。
84
+
85
+ 画板地址解析直接复用 DSH 官方 `parseFileAddress`(`@deepseek-ai/dsh-util-workspace-path`),完整支持 `dsh-resource://file/session/<sid>/<path>` 与 `dsh-resource://file/absolute/<path>` 两种 scope;`absolute` 地址只有在文件位于当前会话工作区内时才能打开,工作区外会给出明确提示,服务端仍会二次校验边界。
53
86
 
54
87
  当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
55
88
  云盘、在线图库、模板库、VSDX/Gliffy 导入未包含;已全面支持纯本地离线 Canvas + SVG 导出引擎。
package/lib/client.js CHANGED
@@ -23,11 +23,110 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
25
  apply: () => apply,
26
- inject: () => inject,
27
- parseDrawioAddress: () => parseDrawioAddress
26
+ inject: () => inject
28
27
  });
29
28
  module.exports = __toCommonJS(index_exports);
30
29
  var import_react = require("react");
30
+
31
+ // src/svg.js
32
+ function base64ToText(value) {
33
+ const clean = value.replace(/\s+/g, "");
34
+ const binary = atob(clean);
35
+ const bytes = new Uint8Array(binary.length);
36
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
37
+ return new TextDecoder().decode(bytes);
38
+ }
39
+ function svgMarkupOf(data) {
40
+ if (typeof data !== "string") return null;
41
+ const value = data.trim();
42
+ if (value === "") return null;
43
+ if (!value.startsWith("data:")) return value;
44
+ const comma = value.indexOf(",");
45
+ if (comma < 0) return null;
46
+ const header = value.slice("data:".length, comma).toLowerCase();
47
+ if (!header.startsWith("image/svg+xml")) return null;
48
+ const body = value.slice(comma + 1);
49
+ if (header.includes(";base64")) {
50
+ try {
51
+ return base64ToText(body);
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ try {
57
+ return decodeURIComponent(body);
58
+ } catch {
59
+ return body;
60
+ }
61
+ }
62
+
63
+ // node_modules/.pnpm/@deepseek-ai+dsh-util-workspace-path@0.2.0-rc.2_@deepseek-ai+cordis@4.0.4/node_modules/@deepseek-ai/dsh-util-workspace-path/lib/index.js
64
+ var FILE_ADDRESS_PREFIX = "dsh-resource://file/";
65
+ function isDriveSegment(segment) {
66
+ return segment !== void 0 && /^[A-Za-z]:$/.test(segment);
67
+ }
68
+ function parseFileAddress(address) {
69
+ try {
70
+ if (!address.startsWith(FILE_ADDRESS_PREFIX)) return void 0;
71
+ const end = address.search(/[?#]/);
72
+ const [scope, ...rest] = address.slice(20, end === -1 ? void 0 : end).split("/");
73
+ if (scope === "session") {
74
+ const [id, ...segments] = rest;
75
+ if (id === void 0 || id === "" || segments.length === 0) return void 0;
76
+ return {
77
+ scope,
78
+ sessionId: decodeURIComponent(id),
79
+ path: segments.map(decodeURIComponent).join("/")
80
+ };
81
+ }
82
+ if (scope === "absolute") {
83
+ const unc = rest[0] === "" && rest.length > 1;
84
+ const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent);
85
+ if (segments.length === 0 || segments[0] === "") return void 0;
86
+ if (unc) return {
87
+ scope,
88
+ path: `//${segments.join("/")}`
89
+ };
90
+ return {
91
+ scope,
92
+ path: isDriveSegment(segments[0]) ? segments.join("/") : `/${segments.join("/")}`
93
+ };
94
+ }
95
+ return;
96
+ } catch {
97
+ return;
98
+ }
99
+ }
100
+
101
+ // src/client/address.js
102
+ var DRAWIO_SUFFIX = /\.drawio$/i;
103
+ function parseDrawioAddress(address, fallbackSessionId) {
104
+ if (typeof address !== "string") return null;
105
+ const trimmed = address.trim();
106
+ if (trimmed === "") return null;
107
+ const parsed = parseFileAddress(trimmed);
108
+ if (parsed === void 0) return null;
109
+ if (parsed.path.includes("\0")) return null;
110
+ if (!DRAWIO_SUFFIX.test(parsed.path)) return null;
111
+ if (parsed.scope === "session") {
112
+ return { scope: "session", sessionId: parsed.sessionId, path: parsed.path };
113
+ }
114
+ return { scope: "absolute", sessionId: fallbackSessionId || "", path: parsed.path };
115
+ }
116
+ function relativeToWorkspace(path, cwd) {
117
+ if (typeof path !== "string" || typeof cwd !== "string") return null;
118
+ const root = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
119
+ if (root === "") return null;
120
+ const target = path.replace(/\\/g, "/");
121
+ const foldCase = /^[A-Za-z]:/.test(root) || root.startsWith("//");
122
+ const left = foldCase ? root.toLowerCase() : root;
123
+ const right = foldCase ? target.toLowerCase() : target;
124
+ if (right === left) return "";
125
+ if (right.startsWith(`${left}/`)) return target.slice(root.length + 1);
126
+ return null;
127
+ }
128
+
129
+ // src/client/index.tsx
31
130
  var import_jsx_runtime = require("react/jsx-runtime");
32
131
  var inject = ["slots", "sidebarRightTabs"];
33
132
  var TAB_ID = "@civilization/dsh-drawio";
@@ -37,7 +136,14 @@ function apply(ctx) {
37
136
  ctx.effect(() => official.sidebarRightTabs.register({
38
137
  id: TAB_ID,
39
138
  kind: TAB_KIND,
40
- patterns: ["*.drawio"],
139
+ // The whole-address glob is what actually wins this file: tab types are
140
+ // ranked by priority band, then by the length of the matched pattern, so a
141
+ // bare `*.drawio` (8 characters) loses the claim to any type declaring a
142
+ // broader address glob in the same `extension` band — e.g.
143
+ // dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which
144
+ // then renders the XML instead of this canvas. Keeping both patterns makes
145
+ // the specific one win wherever it matches.
146
+ patterns: ["dsh-resource://file/**/*.drawio", "*.drawio"],
41
147
  priority: "extension",
42
148
  title: (address) => fileName(parseDrawioAddress(address)?.path ?? "Draw.io")
43
149
  }), "dsh-drawio: official tab definition");
@@ -66,20 +172,23 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
66
172
  const resourceCwd = useSessions((sessions) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : void 0);
67
173
  const cwd = activeCwd || resourceCwd;
68
174
  const effectiveSessionId = sessionId || resource?.sessionId || "";
175
+ const relativePath = resource?.scope === "absolute" ? relativeToWorkspace(resource.path, cwd) : null;
176
+ const outsideWorkspace = resource?.scope === "absolute" && cwd !== void 0 && relativePath === null;
177
+ const openPath = resource?.scope === "absolute" ? relativePath : resource?.path ?? null;
69
178
  const [file, setFile] = (0, import_react.useState)(null);
70
179
  const [error, setError] = (0, import_react.useState)(null);
71
- const key = `${effectiveSessionId}\0${cwd ?? ""}\0${resource?.path ?? ""}`;
180
+ const key = `${effectiveSessionId}\0${cwd ?? ""}\0${openPath ?? ""}`;
72
181
  (0, import_react.useEffect)(() => {
73
- if (!resource?.path || !effectiveSessionId || !cwd) return;
182
+ if (!openPath || !effectiveSessionId || !cwd) return;
74
183
  const controller = new AbortController();
75
184
  setError(null);
76
- void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal).then((res) => {
185
+ void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal).then((res) => {
77
186
  if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version });
78
187
  }).catch((reason) => {
79
188
  if (!controller.signal.aborted) setError(messageOf(reason));
80
189
  });
81
190
  return () => controller.abort();
82
- }, [key, resource?.path, effectiveSessionId, cwd]);
191
+ }, [key, openPath, effectiveSessionId, cwd]);
83
192
  if (!resource || !resource.path) {
84
193
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
85
194
  CenteredMessage,
@@ -98,9 +207,19 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
98
207
  }
99
208
  );
100
209
  }
210
+ if (outsideWorkspace) {
211
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
212
+ CenteredMessage,
213
+ {
214
+ title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F",
215
+ detail: `\u8BE5\u6587\u4EF6\u4E0D\u5728\u5F53\u524D\u5DE5\u4F5C\u533A\u5185\uFF0C\u753B\u677F\u53EA\u80FD\u6253\u5F00\u5DE5\u4F5C\u533A\u4E2D\u7684 .drawio \u6587\u4EF6\uFF1A${resource.path}`
216
+ }
217
+ );
218
+ }
219
+ if (!openPath) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u65E0\u6CD5\u786E\u5B9A\u753B\u677F\u6587\u4EF6\u7684\u5DE5\u4F5C\u533A\u76F8\u5BF9\u8DEF\u5F84\u3002" });
101
220
  if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
102
- if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(resource.path) });
103
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, version: file.version, path: resource.path, scope: { sessionId: effectiveSessionId, cwd } });
221
+ if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(openPath) });
222
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, version: file.version, path: openPath, scope: { sessionId: effectiveSessionId, cwd } });
104
223
  }
105
224
  function DrawioCanvas({ content, version, truncated, path, scope }) {
106
225
  const frame = (0, import_react.useRef)(null);
@@ -221,6 +340,10 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
221
340
  }, []);
222
341
  const startExport = (0, import_react.useCallback)((target) => {
223
342
  if (!ready || isExporting) return;
343
+ if (target === "workspace" && exportFormat !== "svg") {
344
+ showToast("\u4EC5 SVG \u652F\u6301\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\uFF0CPNG / XML-PNG \u8BF7\u4F7F\u7528\u4E0B\u8F7D\u6216\u526A\u8D34\u677F");
345
+ return;
346
+ }
224
347
  const ext = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
225
348
  const base = path.replace(/\.drawio$/i, "");
226
349
  const targetPath = `${base}${ext}`;
@@ -239,7 +362,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
239
362
  border: 10,
240
363
  spin: "\u6B63\u5728\u6E32\u67D3\u5BFC\u51FA\u56FE\u7247..."
241
364
  });
242
- }, [ready, isExporting, exportFormat, isTransparent, path, post]);
365
+ }, [ready, isExporting, exportFormat, isTransparent, path, post, showToast]);
243
366
  const handleExportResult = (0, import_react.useCallback)(async (pending, data) => {
244
367
  try {
245
368
  if (pending.target === "download") {
@@ -253,7 +376,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
253
376
  } else if (pending.target === "clipboard") {
254
377
  try {
255
378
  if (pending.format === "svg") {
256
- const svgText = data.startsWith("data:image/svg+xml") ? decodeURIComponent(data.split(",")[1] || "") : data;
379
+ const svgText = svgMarkupOf(data) ?? data;
257
380
  await navigator.clipboard.writeText(svgText);
258
381
  showToast("\u5DF2\u590D\u5236 SVG \u4EE3\u7801\u5230\u526A\u8D34\u677F");
259
382
  } else {
@@ -412,19 +535,24 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
412
535
  ),
413
536
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u900F\u660E\u80CC\u666F" })
414
537
  ] }) : null,
538
+ exportFormat !== "svg" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.menuSub, children: "\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\u4EC5\u652F\u6301 SVG\uFF1BPNG / XML-PNG \u8BF7\u7528\u4E0B\u8F7D\u6216\u526A\u8D34\u677F\u3002" }) : null,
415
539
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.actionCol, children: [
416
540
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
417
541
  "button",
418
542
  {
419
543
  type: "button",
420
544
  onClick: () => startExport("workspace"),
421
- style: { ...styles.actionBtn, ...styles.actionBtnPrimary, background: accentColor },
545
+ disabled: exportFormat !== "svg",
546
+ title: exportFormat === "svg" ? `\u4FDD\u5B58\u4E3A ${targetExt} \u5230\u753B\u677F\u540C\u7EA7\u76EE\u5F55` : "\u4EC5 SVG \u652F\u6301\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\uFF0CPNG / XML-PNG \u8BF7\u4F7F\u7528\u4E0B\u8F7D\u6216\u526A\u8D34\u677F",
547
+ style: {
548
+ ...styles.actionBtn,
549
+ ...exportFormat === "svg" ? { ...styles.actionBtnPrimary, background: accentColor } : { opacity: 0.5, cursor: "not-allowed" }
550
+ },
422
551
  children: [
423
552
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIcon, { size: 13 }),
424
553
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
425
- "\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A (",
426
- targetExt,
427
- ")"
554
+ "\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A ",
555
+ exportFormat === "svg" ? `(${targetExt})` : "(\u4EC5 SVG)"
428
556
  ] })
429
557
  ]
430
558
  }
@@ -578,41 +706,6 @@ function CenteredMessage({ title, detail }) {
578
706
  ] });
579
707
  }
580
708
  var fileName = (path) => path.replace(/\\/g, "/").split("/").pop() || path;
581
- function parseDrawioAddress(address, fallbackSessionId) {
582
- if (!address || typeof address !== "string") return null;
583
- try {
584
- let clean = address.trim();
585
- let parsedSessionId = "";
586
- if (clean.startsWith("dsh-resource://file/session/")) {
587
- const rest = clean.slice("dsh-resource://file/session/".length);
588
- const slashIdx = rest.indexOf("/");
589
- if (slashIdx >= 0) {
590
- parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx));
591
- clean = rest.slice(slashIdx + 1);
592
- } else {
593
- clean = rest;
594
- }
595
- } else if (clean.startsWith("dsh-resource://file/")) {
596
- clean = clean.slice("dsh-resource://file/".length);
597
- } else if (clean.startsWith("file:///")) {
598
- clean = clean.slice("file:///".length);
599
- }
600
- let decodedPath = clean;
601
- try {
602
- decodedPath = decodeURIComponent(clean);
603
- } catch {
604
- }
605
- decodedPath = decodedPath.replace(/\\/g, "/").replace(/^\/+/, "");
606
- if (!/\.drawio$/i.test(decodedPath)) return null;
607
- if (decodedPath.includes("\0")) return null;
608
- return {
609
- sessionId: parsedSessionId || fallbackSessionId || "",
610
- path: decodedPath
611
- };
612
- } catch {
613
- return null;
614
- }
615
- }
616
709
  var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
617
710
  var statusText = (state) => state === "loading" ? "\u6B63\u5728\u8F7D\u5165" : state === "saving" ? "\u6B63\u5728\u4FDD\u5B58" : state === "external" ? "\u5DF2\u540C\u6B65 AI \u4FEE\u6539" : state === "conflict" ? "\u68C0\u6D4B\u5230\u51B2\u7A81" : state === "error" ? "\u4FDD\u5B58\u5931\u8D25" : "\u5DF2\u4FDD\u5B58";
618
711
  var fg = "var(--dsw-alias-label-primary, currentColor)";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civilization/dsh-drawio",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Local draw.io canvas and model tools for DeepSeek Harness",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -51,15 +51,16 @@
51
51
  "provenance": true
52
52
  },
53
53
  "dependencies": {
54
+ "@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.2",
54
55
  "@deepseek-ai/schemastery": "3.18.2",
55
56
  "@xmldom/xmldom": "0.9.12",
56
57
  "pako": "2.1.0"
57
58
  },
58
59
  "peerDependencies": {
59
60
  "@deepseek-ai/cordis": ">=4.0.0 <5.0.0-0",
60
- "@deepseek-ai/dsh-fs": ">=0.1.2-rc.1 <0.2.0-0",
61
- "@deepseek-ai/dsh-host-webserver": ">=0.1.2-rc.1 <0.2.0-0",
62
- "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0-0"
61
+ "@deepseek-ai/dsh-fs": ">=0.1.5-rc.2",
62
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.5-rc.2",
63
+ "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2"
63
64
  },
64
65
  "peerDependenciesMeta": {
65
66
  "@deepseek-ai/dsh-sandbox": {
@@ -67,11 +68,11 @@
67
68
  }
68
69
  },
69
70
  "devDependencies": {
70
- "@deepseek-ai/cordis": "4.0.2",
71
- "@deepseek-ai/dsh-fs": "0.1.2-rc.1",
72
- "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
73
- "@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
74
- "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
71
+ "@deepseek-ai/cordis": "4.0.4",
72
+ "@deepseek-ai/dsh-fs": "0.2.0-rc.2",
73
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
74
+ "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
75
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
75
76
  "@types/react": "18.3.31",
76
77
  "esbuild": "0.28.2",
77
78
  "react": "18.3.1",
@@ -0,0 +1,64 @@
1
+ import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
2
+
3
+ const DRAWIO_SUFFIX = /\.drawio$/i
4
+
5
+ /**
6
+ * One Draw.io file named by a sidebar tab address.
7
+ * @typedef {object} DrawioAddress
8
+ * @property {'session' | 'absolute'} scope - which scope of the `dsh-resource://file/…` grammar named it.
9
+ * @property {string} sessionId - the session that resolves the path; empty for an absolute address opened without a pane session.
10
+ * @property {string} path - workspace-relative or absolute `/`-separated path, exactly as the address carried it.
11
+ */
12
+
13
+ /**
14
+ * Parse one sidebar tab address into the Draw.io file it names.
15
+ *
16
+ * The grammar is DSH's own (`dsh-resource://file/session/<sessionId>/<path>` and
17
+ * `dsh-resource://file/absolute/<path>`), so parsing is delegated to the product
18
+ * util rather than re-implemented here; only the Draw.io file filter and the
19
+ * fallback session for a session-less absolute address are added on top. A
20
+ * session-scoped path stays exactly as written — it may be workspace-relative
21
+ * or absolute — because the host resolves and confines it.
22
+ *
23
+ * @param {string | undefined} address - the address the tab was claimed for.
24
+ * @param {string} [fallbackSessionId] - the pane's session, used when the address carries none.
25
+ * @returns {DrawioAddress | null} the parsed file, or `null` when the address names anything else.
26
+ */
27
+ export function parseDrawioAddress(address, fallbackSessionId) {
28
+ if (typeof address !== 'string') return null
29
+ const trimmed = address.trim()
30
+ if (trimmed === '') return null
31
+ const parsed = parseFileAddress(trimmed)
32
+ if (parsed === undefined) return null
33
+ if (parsed.path.includes('\0')) return null
34
+ if (!DRAWIO_SUFFIX.test(parsed.path)) return null
35
+ if (parsed.scope === 'session') {
36
+ return { scope: 'session', sessionId: parsed.sessionId, path: parsed.path }
37
+ }
38
+ return { scope: 'absolute', sessionId: fallbackSessionId || '', path: parsed.path }
39
+ }
40
+
41
+ /**
42
+ * The workspace-relative form of an absolute path.
43
+ *
44
+ * An absolute address carries no session, so the canvas needs this answer
45
+ * before it can read through the host: a path under the session workspace is
46
+ * addressed relatively, and one outside it is not openable at all.
47
+ *
48
+ * @param {string | undefined} path - absolute path in either separator spelling.
49
+ * @param {string | undefined} cwd - the session workspace root.
50
+ * @returns {string | null} the workspace-relative path (empty for the root itself), or `null` when it is outside.
51
+ */
52
+ export function relativeToWorkspace(path, cwd) {
53
+ if (typeof path !== 'string' || typeof cwd !== 'string') return null
54
+ const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
55
+ if (root === '') return null
56
+ const target = path.replace(/\\/g, '/')
57
+ // A Windows drive or UNC root addresses the same file in either case.
58
+ const foldCase = /^[A-Za-z]:/.test(root) || root.startsWith('//')
59
+ const left = foldCase ? root.toLowerCase() : root
60
+ const right = foldCase ? target.toLowerCase() : target
61
+ if (right === left) return ''
62
+ if (right.startsWith(`${left}/`)) return target.slice(root.length + 1)
63
+ return null
64
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { svgMarkupOf } from '../svg.js'
4
+ import { parseDrawioAddress, relativeToWorkspace } from './address.js'
3
5
 
4
6
  export const inject = ['slots', 'sidebarRightTabs']
5
7
  const TAB_ID = '@civilization/dsh-drawio'
@@ -14,7 +16,14 @@ export function apply(ctx: Context): void {
14
16
  ctx.effect(() => official.sidebarRightTabs.register({
15
17
  id: TAB_ID,
16
18
  kind: TAB_KIND,
17
- patterns: ['*.drawio'],
19
+ // The whole-address glob is what actually wins this file: tab types are
20
+ // ranked by priority band, then by the length of the matched pattern, so a
21
+ // bare `*.drawio` (8 characters) loses the claim to any type declaring a
22
+ // broader address glob in the same `extension` band — e.g.
23
+ // dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which
24
+ // then renders the XML instead of this canvas. Keeping both patterns makes
25
+ // the specific one win wherever it matches.
26
+ patterns: ['dsh-resource://file/**/*.drawio', '*.drawio'],
18
27
  priority: 'extension',
19
28
  title: (address: string) => fileName(parseDrawioAddress(address)?.path ?? 'Draw.io'),
20
29
  }), 'dsh-drawio: official tab definition')
@@ -44,19 +53,25 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
44
53
  const cwd = activeCwd || resourceCwd
45
54
  const effectiveSessionId = sessionId || resource?.sessionId || ''
46
55
 
56
+ // An absolute address carries no session, so it is openable only when it names
57
+ // a file under this session's workspace; the host re-checks that boundary.
58
+ const relativePath = resource?.scope === 'absolute' ? relativeToWorkspace(resource.path, cwd) : null
59
+ const outsideWorkspace = resource?.scope === 'absolute' && cwd !== undefined && relativePath === null
60
+ const openPath = resource?.scope === 'absolute' ? relativePath : resource?.path ?? null
61
+
47
62
  const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null)
48
63
  const [error, setError] = useState<string | null>(null)
49
- const key = `${effectiveSessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
64
+ const key = `${effectiveSessionId}\0${cwd ?? ''}\0${openPath ?? ''}`
50
65
 
51
66
  useEffect(() => {
52
- if (!resource?.path || !effectiveSessionId || !cwd) return
67
+ if (!openPath || !effectiveSessionId || !cwd) return
53
68
  const controller = new AbortController()
54
69
  setError(null)
55
- void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal)
70
+ void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal)
56
71
  .then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) })
57
72
  .catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
58
73
  return () => controller.abort()
59
- }, [key, resource?.path, effectiveSessionId, cwd])
74
+ }, [key, openPath, effectiveSessionId, cwd])
60
75
 
61
76
  if (!resource || !resource.path) {
62
77
  return (
@@ -74,9 +89,18 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
74
89
  />
75
90
  )
76
91
  }
92
+ if (outsideWorkspace) {
93
+ return (
94
+ <CenteredMessage
95
+ title="无法打开画板"
96
+ detail={`该文件不在当前工作区内,画板只能打开工作区中的 .drawio 文件:${resource.path}`}
97
+ />
98
+ )
99
+ }
100
+ if (!openPath) return <CenteredMessage title="无法打开画板" detail="无法确定画板文件的工作区相对路径。" />
77
101
  if (error) return <CenteredMessage title="画板载入失败" detail={error} />
78
- if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
79
- return <DrawioCanvas content={file.content} version={file.version} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
102
+ if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(openPath)} />
103
+ return <DrawioCanvas content={file.content} version={file.version} path={openPath} scope={{ sessionId: effectiveSessionId, cwd }} />
80
104
  }
81
105
 
82
106
  function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element {
@@ -208,6 +232,13 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
208
232
 
209
233
  const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => {
210
234
  if (!ready || isExporting) return
235
+ // The workspace target is SVG-only: DSH's filesystem service writes text
236
+ // (there is no binary write seam), so a raster export would land as base64
237
+ // text. PNG and XML-PNG stay available through download and clipboard.
238
+ if (target === 'workspace' && exportFormat !== 'svg') {
239
+ showToast('仅 SVG 支持保存到工作区,PNG / XML-PNG 请使用下载或剪贴板')
240
+ return
241
+ }
211
242
  const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
212
243
  const base = path.replace(/\.drawio$/i, '')
213
244
  const targetPath = `${base}${ext}`
@@ -227,7 +258,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
227
258
  border: 10,
228
259
  spin: '正在渲染导出图片...',
229
260
  })
230
- }, [ready, isExporting, exportFormat, isTransparent, path, post])
261
+ }, [ready, isExporting, exportFormat, isTransparent, path, post, showToast])
231
262
 
232
263
  const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => {
233
264
  try {
@@ -242,9 +273,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
242
273
  } else if (pending.target === 'clipboard') {
243
274
  try {
244
275
  if (pending.format === 'svg') {
245
- const svgText = data.startsWith('data:image/svg+xml')
246
- ? decodeURIComponent(data.split(',')[1] || '')
247
- : data
276
+ const svgText = svgMarkupOf(data) ?? data
248
277
  await navigator.clipboard.writeText(svgText)
249
278
  showToast('已复制 SVG 代码到剪贴板')
250
279
  } else {
@@ -416,14 +445,23 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
416
445
  </label>
417
446
  ) : null}
418
447
 
448
+ {exportFormat !== 'svg' ? (
449
+ <span style={styles.menuSub}>保存到工作区仅支持 SVG;PNG / XML-PNG 请用下载或剪贴板。</span>
450
+ ) : null}
451
+
419
452
  <div style={styles.actionCol}>
420
453
  <button
421
454
  type="button"
422
455
  onClick={() => startExport('workspace')}
423
- style={{ ...styles.actionBtn, ...styles.actionBtnPrimary, background: accentColor }}
456
+ disabled={exportFormat !== 'svg'}
457
+ title={exportFormat === 'svg' ? `保存为 ${targetExt} 到画板同级目录` : '仅 SVG 支持保存到工作区,PNG / XML-PNG 请使用下载或剪贴板'}
458
+ style={{
459
+ ...styles.actionBtn,
460
+ ...(exportFormat === 'svg' ? { ...styles.actionBtnPrimary, background: accentColor } : { opacity: 0.5, cursor: 'not-allowed' }),
461
+ }}
424
462
  >
425
463
  <SaveIcon size={13} />
426
- <span>保存到工作区 ({targetExt})</span>
464
+ <span>保存到工作区 {exportFormat === 'svg' ? `(${targetExt})` : '(仅 SVG)'}</span>
427
465
  </button>
428
466
 
429
467
  <button
@@ -569,51 +607,6 @@ function CenteredMessage({ title, detail }: { title: string; detail: string }):
569
607
  }
570
608
 
571
609
  const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
572
-
573
- export function parseDrawioAddress(address?: string, fallbackSessionId?: string): { sessionId: string; path: string } | null {
574
- if (!address || typeof address !== 'string') return null
575
- try {
576
- let clean = address.trim()
577
- let parsedSessionId = ''
578
-
579
- if (clean.startsWith('dsh-resource://file/session/')) {
580
- const rest = clean.slice('dsh-resource://file/session/'.length)
581
- const slashIdx = rest.indexOf('/')
582
- if (slashIdx >= 0) {
583
- parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx))
584
- clean = rest.slice(slashIdx + 1)
585
- } else {
586
- clean = rest
587
- }
588
- } else if (clean.startsWith('dsh-resource://file/')) {
589
- clean = clean.slice('dsh-resource://file/'.length)
590
- } else if (clean.startsWith('file:///')) {
591
- clean = clean.slice('file:///'.length)
592
- }
593
-
594
- // 处理可能是 URL 编码的 path(支持嵌套目录 %2F)
595
- let decodedPath = clean
596
- try {
597
- decodedPath = decodeURIComponent(clean)
598
- } catch {}
599
-
600
- // 规范化斜杠并去除开头的斜杠
601
- decodedPath = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '')
602
-
603
- // 必须是 .drawio 文件
604
- if (!/\.drawio$/i.test(decodedPath)) return null
605
-
606
- // 防空字符
607
- if (decodedPath.includes('\0')) return null
608
-
609
- return {
610
- sessionId: parsedSessionId || fallbackSessionId || '',
611
- path: decodedPath,
612
- }
613
- } catch {
614
- return null
615
- }
616
- }
617
610
  const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
618
611
  const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存'
619
612
  const fg = 'var(--dsw-alias-label-primary, currentColor)'
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ import { stat, readFile } from 'node:fs/promises'
3
3
  import { extname, join, normalize, relative, sep } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import { isSvgMarkup, svgMarkupOf } from './svg.js'
6
7
  import { editDrawio, inspectDrawio, normalizeDrawio } from './xml.js'
7
8
 
8
9
  export const name = 'dsh-drawio'
@@ -105,27 +106,19 @@ async function serveCanvasApi(ctx, req, res) {
105
106
  const payload = await readJsonBody(req)
106
107
 
107
108
  if (method === 'save-image') {
108
- if (typeof payload.data !== 'string' || !payload.data.trim()) throw new Error('Image data is required.')
109
+ // The workspace target is SVG-only: DSH's filesystem service writes text,
110
+ // with no binary seam, so a raster export has no faithful landing place.
111
+ // PNG and XML-PNG are exported through the browser instead.
112
+ const markup = svgMarkupOf(payload.data)
113
+ if (markup === null || !isSvgMarkup(markup)) throw new Error('Only an SVG export can be saved to the workspace.')
114
+ const svg = markup.trim()
115
+ const bytes = Buffer.byteLength(svg)
116
+ if (bytes > MAX_DIAGRAM_BYTES) throw new Error('SVG data is too large to save safely.')
109
117
  const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path, true)
110
- let buffer
111
- if (payload.data.startsWith('data:')) {
112
- const comma = payload.data.indexOf(',')
113
- const base64 = comma >= 0 ? payload.data.slice(comma + 1) : payload.data
114
- buffer = Buffer.from(base64, 'base64')
115
- } else if (payload.data.startsWith('<svg') || payload.data.startsWith('<?xml')) {
116
- buffer = Buffer.from(payload.data, 'utf8')
117
- } else {
118
- buffer = Buffer.from(payload.data, 'base64')
119
- }
120
- if (buffer.length > MAX_REQUEST_BYTES) throw new Error('Image data is too large to save safely.')
121
118
  const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
122
- const outcome = typeof ctx.fs.writeFile === 'function'
123
- ? await ctx.fs.writeFile(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
124
- : typeof ctx.fs.writeBinary === 'function'
125
- ? await ctx.fs.writeBinary(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
126
- : await ctx.fs.writeText(target, payload.path.endsWith('.svg') ? buffer.toString('utf8') : buffer.toString('base64'), { kind: 'replace' }, undefined, sandboxPolicy)
119
+ const outcome = await ctx.fs.writeText(target, svg, { kind: 'replace' }, undefined, sandboxPolicy)
127
120
  ctx.emit?.('fs/observed', target, { kind: 'present', version: outcome?.version }, undefined)
128
- return respondJson(res, 200, { ok: true, value: { path: target.displayPath ?? payload.path, bytes: buffer.length } })
121
+ return respondJson(res, 200, { ok: true, value: { path: target.displayPath ?? payload.path, bytes } })
129
122
  }
130
123
 
131
124
  const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path)
@@ -147,10 +140,10 @@ async function serveCanvasApi(ctx, req, res) {
147
140
  }
148
141
  }
149
142
 
150
- async function resolveCanvasTarget(ctx, cwd, path, allowImage = false) {
143
+ async function resolveCanvasTarget(ctx, cwd, path, allowSvgExport = false) {
151
144
  if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('A workspace is required.')
152
- if (allowImage) {
153
- assertExportImagePath(path)
145
+ if (allowSvgExport) {
146
+ assertSvgExportPath(path)
154
147
  } else {
155
148
  assertDrawioPath(path)
156
149
  }
@@ -159,8 +152,8 @@ async function resolveCanvasTarget(ctx, cwd, path, allowImage = false) {
159
152
  return target
160
153
  }
161
154
 
162
- function assertExportImagePath(value) {
163
- if (typeof value !== 'string' || !/\.(png|svg|xml\.png)$/i.test(value)) throw new Error('path must end with .png or .svg')
155
+ function assertSvgExportPath(value) {
156
+ if (typeof value !== 'string' || !/\.svg$/i.test(value)) throw new Error('path must end with .svg')
164
157
  }
165
158
 
166
159
  async function readJsonBody(req) {
package/src/svg.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * SVG export payload handling shared by the host route and the canvas client.
3
+ *
4
+ * The bundled Draw.io runtime hands an export back in one of two carrier
5
+ * forms: the common `data:image/svg+xml;base64,<base64>` data URL, or a
6
+ * `data:image/svg+xml,<percent-encoded>` / raw-markup payload. Both are
7
+ * normalized to markup here so a caller never guesses which one it received.
8
+ * The module stays browser-safe: it decodes with `atob`/`TextDecoder`, never
9
+ * with Node's `Buffer`.
10
+ */
11
+
12
+ /**
13
+ * Decode one base64 payload without depending on Node globals.
14
+ * @param {string} value - base64 text, whitespace tolerated.
15
+ * @returns {string} the decoded text.
16
+ */
17
+ function base64ToText(value) {
18
+ const clean = value.replace(/\s+/g, '')
19
+ const binary = atob(clean)
20
+ const bytes = new Uint8Array(binary.length)
21
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
22
+ return new TextDecoder().decode(bytes)
23
+ }
24
+
25
+ /**
26
+ * Normalize one export payload to SVG markup.
27
+ * @param {unknown} data - raw markup, or a `data:` URL carrying it.
28
+ * @returns {string | null} the markup, or `null` when the payload names another media type or cannot be decoded.
29
+ */
30
+ export function svgMarkupOf(data) {
31
+ if (typeof data !== 'string') return null
32
+ const value = data.trim()
33
+ if (value === '') return null
34
+ if (!value.startsWith('data:')) return value
35
+ const comma = value.indexOf(',')
36
+ if (comma < 0) return null
37
+ const header = value.slice('data:'.length, comma).toLowerCase()
38
+ if (!header.startsWith('image/svg+xml')) return null
39
+ const body = value.slice(comma + 1)
40
+ if (header.includes(';base64')) {
41
+ try {
42
+ return base64ToText(body)
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+ try {
48
+ return decodeURIComponent(body)
49
+ } catch {
50
+ return body
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Whether one payload is SVG markup the workspace may store as text.
56
+ * @param {unknown} value - candidate markup.
57
+ * @returns {boolean} whether a root `<svg>` element is present.
58
+ */
59
+ export function isSvgMarkup(value) {
60
+ return typeof value === 'string' && /<svg[\s>]/i.test(value)
61
+ }