@civilization/dsh-drawio 0.1.0 → 0.2.1

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
@@ -5,7 +5,7 @@
5
5
 
6
6
  ## 边界
7
7
 
8
- - 不引入项目、目录或文件管理;继续使用 DSH 与 `dsh-better-sidebar`。
8
+ - 不引入项目、目录或文件管理;直接使用 DSH 官方右侧栏与文件工作台。
9
9
  - 任意 `.drawio` 文件都由画板打开,可停靠、拆分或浮动。
10
10
  - 编辑器资源由 DSH Host 从本机提供,不连接 diagrams.net。
11
11
  - 用户保存和 Agent 工具修改的是同一份 XML 文件。
@@ -31,8 +31,11 @@ dsh plugin --profile web add D:\project\dsh-drawio --registry=https://registry.n
31
31
  dsh web --port 3082 --no-open
32
32
  ```
33
33
 
34
- 已在 DSH 0.1.2-rc.1 与 dsh-better-sidebar 0.18.0 上验证构建、XML 操作、
35
- 本地静态资源加载和 `.drawio` 画板打开;浏览器控制台无错误或警告。
34
+ 客户端只注入 DSH 官方的 `slots` 与 `sidebarRightTabs` 服务。文件工作台打开
35
+ `.drawio` 文件时,由官方资源路由选择本插件的画板;读取和自动保存通过插件自有的
36
+ `/dsh-drawio/api` 接口完成,并限制在当前会话工作区内。
36
37
 
37
- 当前内置的精简 Draw.io runtime 固定为 31.4.5。基础图形、常用图形库和中文资源可用;
38
+ 已在 DSH 0.1.5-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
39
+
40
+ 当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
38
41
  云盘、在线图库、模板库、VSDX/Gliffy 导入和服务端导出未包含。
package/lib/client.js CHANGED
@@ -28,20 +28,57 @@ __export(index_exports, {
28
28
  module.exports = __toCommonJS(index_exports);
29
29
  var import_react = require("react");
30
30
  var import_jsx_runtime = require("react/jsx-runtime");
31
- var inject = ["betterSidebar"];
32
- var VIEWER_ID = "dsh-drawio:canvas";
31
+ var inject = ["slots", "sidebarRightTabs"];
32
+ var TAB_ID = "@civilization/dsh-drawio";
33
+ var TAB_KIND = "drawio";
33
34
  function apply(ctx) {
34
- const betterSidebar = ctx.betterSidebar;
35
- if (!betterSidebar) return;
36
- ctx.effect(() => betterSidebar.registerFileViewer({
37
- id: VIEWER_ID,
38
- title: () => "Draw.io \u753B\u677F",
39
- icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CanvasIcon, {}),
40
- exts: ["drawio"],
41
- priority: 100,
42
- fetchStrategy: "fsRead",
43
- component: DrawioCanvas
44
- }));
35
+ const official = ctx;
36
+ ctx.effect(() => official.sidebarRightTabs.register({
37
+ id: TAB_ID,
38
+ kind: TAB_KIND,
39
+ patterns: ["*.drawio"],
40
+ priority: "extension",
41
+ title: (address) => fileName(parseDrawioAddress(address)?.path ?? "Draw.io")
42
+ }), "dsh-drawio: official tab definition");
43
+ ctx.effect(() => official.slots.inject("sidebar.right.pane.tab", () => official.slots.register({
44
+ name: "sidebar.right.pane.tab",
45
+ key: TAB_ID
46
+ }, OfficialDrawioTabBody)), "dsh-drawio: official tab body");
47
+ ctx.effect(() => official.slots.inject("sidebar.right.pane.tab.title", () => official.slots.register({
48
+ name: "sidebar.right.pane.tab.title",
49
+ key: TAB_ID
50
+ }, OfficialDrawioTabTitle)), "dsh-drawio: official tab title");
51
+ }
52
+ function OfficialDrawioTabTitle({ useTabInfo }) {
53
+ const { tab } = useTabInfo();
54
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
55
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CanvasIcon, { size: 15 }),
56
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: tab.title })
57
+ ] });
58
+ }
59
+ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
60
+ const { tab } = useTabInfo();
61
+ const cwd = useSessions((sessions) => sessions?.byId?.[sessionId]?.cwd);
62
+ const resource = (0, import_react.useMemo)(() => parseDrawioAddress(tab.navigation.address), [tab.navigation.address]);
63
+ const [file, setFile] = (0, import_react.useState)(null);
64
+ const [error, setError] = (0, import_react.useState)(null);
65
+ const key = `${sessionId}\0${cwd ?? ""}\0${resource?.path ?? ""}`;
66
+ (0, import_react.useEffect)(() => {
67
+ if (!resource || resource.sessionId !== sessionId || !cwd) return;
68
+ const controller = new AbortController();
69
+ setError(null);
70
+ void fsRead({ sessionId, cwd }, resource.path, controller.signal).then((content) => {
71
+ if (!controller.signal.aborted) setFile({ key, content });
72
+ }).catch((reason) => {
73
+ if (!controller.signal.aborted) setError(messageOf(reason));
74
+ });
75
+ return () => controller.abort();
76
+ }, [key, resource?.sessionId, resource?.path, sessionId, cwd]);
77
+ if (!resource || resource.sessionId !== sessionId) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u6587\u4EF6\u5730\u5740\u4E0D\u662F\u5F53\u524D\u4F1A\u8BDD\u4E2D\u7684 Draw.io \u6587\u4EF6\u3002" });
78
+ if (!cwd) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u53EF\u7528\u7684\u5DE5\u4F5C\u533A\u3002" });
79
+ if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
80
+ if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(resource.path) });
81
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, path: resource.path, scope: { sessionId, cwd } });
45
82
  }
46
83
  function DrawioCanvas({ content, truncated, path, scope }) {
47
84
  const frame = (0, import_react.useRef)(null);
@@ -52,6 +89,8 @@ function DrawioCanvas({ content, truncated, path, scope }) {
52
89
  const [ready, setReady] = (0, import_react.useState)(false);
53
90
  const [saveState, setSaveState] = (0, import_react.useState)("loading");
54
91
  const [error, setError] = (0, import_react.useState)(truncated ? "\u6587\u4EF6\u8FC7\u5927\uFF0CDSH \u53EA\u8FD4\u56DE\u4E86\u622A\u65AD\u5185\u5BB9\uFF0C\u65E0\u6CD5\u5B89\u5168\u6253\u5F00\u3002" : null);
92
+ const lastUserActivity = (0, import_react.useRef)(0);
93
+ const isInteracting = (0, import_react.useRef)(false);
55
94
  const { dark, accent: accentColor } = useDshTheme();
56
95
  const src = (0, import_react.useMemo)(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${dark ? "1" : "0"}`, [dark]);
57
96
  const post = (0, import_react.useCallback)((message) => {
@@ -74,17 +113,55 @@ function DrawioCanvas({ content, truncated, path, scope }) {
74
113
  }
75
114
  }, [scope.sessionId, scope.cwd, path]);
76
115
  const scheduleSave = (0, import_react.useCallback)((xml, immediate = false) => {
116
+ lastUserActivity.current = Date.now();
77
117
  latestEditor.current = xml;
78
- if (saveTimer.current !== void 0) window.clearTimeout(saveTimer.current);
79
- if (immediate) void persist(xml);
80
- else saveTimer.current = window.setTimeout(() => {
81
- void persist(latestEditor.current);
82
- }, 350);
118
+ if (saveTimer.current !== void 0) {
119
+ window.clearTimeout(saveTimer.current);
120
+ saveTimer.current = void 0;
121
+ }
122
+ if (immediate) {
123
+ void persist(xml);
124
+ } else {
125
+ saveTimer.current = window.setTimeout(() => {
126
+ saveTimer.current = void 0;
127
+ void persist(latestEditor.current);
128
+ }, 350);
129
+ }
83
130
  }, [persist]);
84
131
  (0, import_react.useEffect)(() => {
85
132
  latestFile.current = content ?? "";
86
133
  latestEditor.current = content ?? "";
87
134
  }, [content, path]);
135
+ const markActivity = (0, import_react.useCallback)(() => {
136
+ lastUserActivity.current = Date.now();
137
+ }, []);
138
+ (0, import_react.useEffect)(() => {
139
+ const attached = /* @__PURE__ */ new WeakSet();
140
+ const handleFrameAttach = () => {
141
+ try {
142
+ const doc = frame.current?.contentDocument;
143
+ if (!doc || attached.has(doc)) return;
144
+ attached.add(doc);
145
+ const onDown = () => {
146
+ isInteracting.current = true;
147
+ lastUserActivity.current = Date.now();
148
+ };
149
+ const onUp = () => {
150
+ isInteracting.current = false;
151
+ lastUserActivity.current = Date.now();
152
+ };
153
+ const onKey = () => {
154
+ lastUserActivity.current = Date.now();
155
+ };
156
+ doc.addEventListener("pointerdown", onDown, true);
157
+ doc.addEventListener("pointerup", onUp, true);
158
+ doc.addEventListener("keydown", onKey, true);
159
+ } catch {
160
+ }
161
+ };
162
+ const timer = window.setInterval(handleFrameAttach, 800);
163
+ return () => window.clearInterval(timer);
164
+ }, []);
88
165
  (0, import_react.useEffect)(() => {
89
166
  setReady(false);
90
167
  setSaveState("loading");
@@ -97,7 +174,7 @@ function DrawioCanvas({ content, truncated, path, scope }) {
97
174
  return;
98
175
  }
99
176
  if (message.event === "configure") {
100
- post({ action: "configure", config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: "general;flowchart;basic;arrows2", enabledLibraries: ["general", "flowchart", "basic", "arrows2"] } });
177
+ post({ action: "configure", config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: "general;uml;er;bpmn;flowchart;basic;arrows2", enabledLibraries: null } });
101
178
  } else if (message.event === "init") {
102
179
  post({ action: "load", xml: latestFile.current, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
103
180
  setReady(true);
@@ -113,9 +190,14 @@ function DrawioCanvas({ content, truncated, path, scope }) {
113
190
  if (!ready || truncated) return;
114
191
  let cancelled = false;
115
192
  const poll = async () => {
193
+ if (isInteracting.current) return;
194
+ if (Date.now() - lastUserActivity.current < 1500) return;
195
+ if (saveTimer.current !== void 0) return;
196
+ if (typeof document !== "undefined" && document.hidden) return;
116
197
  try {
117
198
  const xml = await fsRead(scope, path);
118
- if (cancelled || xml === latestFile.current || xml === latestEditor.current) return;
199
+ if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
200
+ if (xml === latestFile.current || xml === latestEditor.current) return;
119
201
  latestFile.current = xml;
120
202
  latestEditor.current = xml;
121
203
  post({ action: "load", xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
@@ -129,7 +211,7 @@ function DrawioCanvas({ content, truncated, path, scope }) {
129
211
  };
130
212
  const timer = window.setInterval(() => {
131
213
  void poll();
132
- }, 1600);
214
+ }, 2500);
133
215
  return () => {
134
216
  cancelled = true;
135
217
  window.clearInterval(timer);
@@ -187,22 +269,21 @@ function detectDark() {
187
269
  if (numbers?.length === 3) return numbers[0] * 0.299 + numbers[1] * 0.587 + numbers[2] * 0.114 < 128;
188
270
  return document.documentElement.classList.contains("dark") || window.matchMedia("(prefers-color-scheme: dark)").matches;
189
271
  }
190
- async function fsRead(scope, path) {
191
- const value = await call("fs.read", scope, { path });
192
- if (value.kind !== "text" || value.truncated) throw new Error("Draw.io \u6587\u4EF6\u4E0D\u662F\u5B8C\u6574\u7684 UTF-8 \u6587\u672C\u3002");
193
- return value.content ?? "";
272
+ async function fsRead(scope, path, signal) {
273
+ const value = await call("read", scope, { path }, false, signal);
274
+ return value.content;
194
275
  }
195
276
  async function fsWrite(scope, path, content, keepalive = false) {
196
- await call("fs.write", scope, { path, content }, keepalive);
277
+ await call("write", scope, { path, content }, keepalive);
197
278
  }
198
- async function call(method, scope, extra, keepalive = false) {
199
- const response = await fetch(`/sidebar/api/${method}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: scope.sessionId, ...scope.cwd ? { cwd: scope.cwd } : {}, ...extra }), keepalive });
279
+ async function call(method, scope, extra, keepalive = false, signal) {
280
+ const response = await fetch(`/dsh-drawio/api/${method}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: scope.sessionId, ...scope.cwd ? { cwd: scope.cwd } : {}, ...extra }), keepalive, signal });
200
281
  const envelope = await response.json().catch(() => null);
201
282
  if (!response.ok || envelope?.ok !== true || envelope.value === void 0) throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`);
202
283
  return envelope.value;
203
284
  }
204
- function CanvasIcon() {
205
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { "aria-hidden": true, viewBox: "0 0 20 20", width: "16", height: "16", fill: "none", children: [
285
+ function CanvasIcon({ size = 16, className }) {
286
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { className, "aria-hidden": true, viewBox: "0 0 20 20", width: size, height: size, fill: "none", children: [
206
287
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "2.5", y: "2.5", width: "15", height: "15", rx: "2.5", stroke: "currentColor" }),
207
288
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "7", cy: "7", r: "1.5", fill: "currentColor" }),
208
289
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "13", cy: "13", r: "1.5", fill: "currentColor" }),
@@ -216,6 +297,19 @@ function CenteredMessage({ title, detail }) {
216
297
  ] });
217
298
  }
218
299
  var fileName = (path) => path.replace(/\\/g, "/").split("/").pop() || path;
300
+ function parseDrawioAddress(address) {
301
+ const prefix = "dsh-resource://file/session/";
302
+ if (!address.startsWith(prefix)) return null;
303
+ try {
304
+ const parts = address.slice(prefix.length).split("/").map(decodeURIComponent);
305
+ const sessionId = parts.shift() ?? "";
306
+ const path = parts.join("/");
307
+ if (!sessionId || !path || !/\.drawio$/i.test(path) || parts.some((part) => part.includes("/") || part.includes("\\") || part.includes("\0"))) return null;
308
+ return { sessionId, path };
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
219
313
  var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
220
314
  var statusText = (state) => state === "loading" ? "\u6B63\u5728\u8F7D\u5165" : state === "saving" ? "\u6B63\u5728\u4FDD\u5B58" : state === "external" ? "\u5DF2\u540C\u6B65 AI \u4FEE\u6539" : state === "error" ? "\u4FDD\u5B58\u5931\u8D25" : "\u5DF2\u4FDD\u5B58";
221
315
  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.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Local draw.io canvas and model tools for DeepSeek Harness",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -55,12 +55,15 @@
55
55
  "pako": "2.1.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@deepseek-ai/cordis": "^4.0.2",
59
- "@deepseek-ai/dsh-fs": "^0.1.2-rc.1",
60
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
61
- "@deepseek-ai/dsh-sandbox": "^0.1.2-rc.1",
62
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
63
- "dsh-better-sidebar": ">=0.18.0"
58
+ "@deepseek-ai/cordis": ">=4.0.0 <5.0.0-0",
59
+ "@deepseek-ai/dsh-fs": ">=0.1.2-rc.1 <0.2.0-0",
60
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.2-rc.1 <0.2.0-0",
61
+ "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0-0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@deepseek-ai/dsh-sandbox": {
65
+ "optional": true
66
+ }
64
67
  },
65
68
  "devDependencies": {
66
69
  "@deepseek-ai/cordis": "4.0.2",
@@ -69,7 +72,6 @@
69
72
  "@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
70
73
  "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
71
74
  "@types/react": "18.3.31",
72
- "dsh-better-sidebar": "0.18.0",
73
75
  "esbuild": "0.28.2",
74
76
  "react": "18.3.1",
75
77
  "typescript": "5.9.3"
@@ -1,23 +1,61 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
- import type {} from 'dsh-better-sidebar'
4
- import type { FileViewerProps, SessionScope } from 'dsh-better-sidebar/client/service'
5
3
 
6
- export const inject = ['betterSidebar']
7
- const VIEWER_ID = 'dsh-drawio:canvas'
4
+ export const inject = ['slots', 'sidebarRightTabs']
5
+ const TAB_ID = '@civilization/dsh-drawio'
6
+ const TAB_KIND = 'drawio'
7
+
8
+ interface SessionScope { sessionId: string; cwd?: string }
9
+ interface FileViewerProps { content?: string; truncated?: boolean; path: string; scope: SessionScope }
10
+ interface OfficialContext extends Context { slots: any; sidebarRightTabs: any }
8
11
 
9
12
  export function apply(ctx: Context): void {
10
- const betterSidebar = ctx.betterSidebar
11
- if (!betterSidebar) return
12
- ctx.effect(() => betterSidebar.registerFileViewer({
13
- id: VIEWER_ID,
14
- title: () => 'Draw.io 画板',
15
- icon: <CanvasIcon />,
16
- exts: ['drawio'],
17
- priority: 100,
18
- fetchStrategy: 'fsRead',
19
- component: DrawioCanvas,
20
- }))
13
+ const official = ctx as OfficialContext
14
+ ctx.effect(() => official.sidebarRightTabs.register({
15
+ id: TAB_ID,
16
+ kind: TAB_KIND,
17
+ patterns: ['*.drawio'],
18
+ priority: 'extension',
19
+ title: (address: string) => fileName(parseDrawioAddress(address)?.path ?? 'Draw.io'),
20
+ }), 'dsh-drawio: official tab definition')
21
+ ctx.effect(() => official.slots.inject('sidebar.right.pane.tab', () => official.slots.register({
22
+ name: 'sidebar.right.pane.tab',
23
+ key: TAB_ID,
24
+ }, OfficialDrawioTabBody)), 'dsh-drawio: official tab body')
25
+ ctx.effect(() => official.slots.inject('sidebar.right.pane.tab.title', () => official.slots.register({
26
+ name: 'sidebar.right.pane.tab.title',
27
+ key: TAB_ID,
28
+ }, OfficialDrawioTabTitle)), 'dsh-drawio: official tab title')
29
+ }
30
+
31
+ function OfficialDrawioTabTitle({ useTabInfo }: any): JSX.Element {
32
+ const { tab } = useTabInfo()
33
+ return <><CanvasIcon size={15} /><span>{tab.title}</span></>
34
+ }
35
+
36
+ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX.Element {
37
+ const { tab } = useTabInfo()
38
+ const cwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined
39
+ const resource = useMemo(() => parseDrawioAddress(tab.navigation.address), [tab.navigation.address])
40
+ const [file, setFile] = useState<{ key: string; content: string } | null>(null)
41
+ const [error, setError] = useState<string | null>(null)
42
+ const key = `${sessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
43
+
44
+ useEffect(() => {
45
+ if (!resource || resource.sessionId !== sessionId || !cwd) return
46
+ const controller = new AbortController()
47
+ setError(null)
48
+ void fsRead({ sessionId, cwd }, resource.path, controller.signal)
49
+ .then(content => { if (!controller.signal.aborted) setFile({ key, content }) })
50
+ .catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
51
+ return () => controller.abort()
52
+ }, [key, resource?.sessionId, resource?.path, sessionId, cwd])
53
+
54
+ if (!resource || resource.sessionId !== sessionId) return <CenteredMessage title="无法打开画板" detail="文件地址不是当前会话中的 Draw.io 文件。" />
55
+ if (!cwd) return <CenteredMessage title="无法打开画板" detail="当前会话没有可用的工作区。" />
56
+ if (error) return <CenteredMessage title="画板载入失败" detail={error} />
57
+ if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
58
+ return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId, cwd }} />
21
59
  }
22
60
 
23
61
  function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX.Element {
@@ -29,6 +67,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
29
67
  const [ready, setReady] = useState(false)
30
68
  const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'error'>('loading')
31
69
  const [error, setError] = useState<string | null>(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null)
70
+ const lastUserActivity = useRef(0)
71
+ const isInteracting = useRef(false)
32
72
  const { dark, accent: accentColor } = useDshTheme()
33
73
  const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${dark ? '1' : '0'}`, [dark])
34
74
 
@@ -54,10 +94,20 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
54
94
  }, [scope.sessionId, scope.cwd, path])
55
95
 
56
96
  const scheduleSave = useCallback((xml: string, immediate = false) => {
97
+ lastUserActivity.current = Date.now()
57
98
  latestEditor.current = xml
58
- if (saveTimer.current !== undefined) window.clearTimeout(saveTimer.current)
59
- if (immediate) void persist(xml)
60
- else saveTimer.current = window.setTimeout(() => { void persist(latestEditor.current) }, 350)
99
+ if (saveTimer.current !== undefined) {
100
+ window.clearTimeout(saveTimer.current)
101
+ saveTimer.current = undefined
102
+ }
103
+ if (immediate) {
104
+ void persist(xml)
105
+ } else {
106
+ saveTimer.current = window.setTimeout(() => {
107
+ saveTimer.current = undefined
108
+ void persist(latestEditor.current)
109
+ }, 350)
110
+ }
61
111
  }, [persist])
62
112
 
63
113
  useEffect(() => {
@@ -65,6 +115,38 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
65
115
  latestEditor.current = content ?? ''
66
116
  }, [content, path])
67
117
 
118
+ const markActivity = useCallback(() => {
119
+ lastUserActivity.current = Date.now()
120
+ }, [])
121
+
122
+ useEffect(() => {
123
+ const attached = new WeakSet<Document>()
124
+ const handleFrameAttach = () => {
125
+ try {
126
+ const doc = frame.current?.contentDocument
127
+ if (!doc || attached.has(doc)) return
128
+ attached.add(doc)
129
+ const onDown = () => {
130
+ isInteracting.current = true
131
+ lastUserActivity.current = Date.now()
132
+ }
133
+ const onUp = () => {
134
+ isInteracting.current = false
135
+ lastUserActivity.current = Date.now()
136
+ }
137
+ const onKey = () => {
138
+ lastUserActivity.current = Date.now()
139
+ }
140
+ // 仅在用户按住拖拽、松手或输入时感知,单纯鼠标移动(hover)不打断
141
+ doc.addEventListener('pointerdown', onDown, true)
142
+ doc.addEventListener('pointerup', onUp, true)
143
+ doc.addEventListener('keydown', onKey, true)
144
+ } catch {}
145
+ }
146
+ const timer = window.setInterval(handleFrameAttach, 800)
147
+ return () => window.clearInterval(timer)
148
+ }, [])
149
+
68
150
  useEffect(() => {
69
151
  setReady(false)
70
152
  setSaveState('loading')
@@ -74,7 +156,7 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
74
156
  try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data }
75
157
  catch { return }
76
158
  if (message.event === 'configure') {
77
- post({ action: 'configure', config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: 'general;flowchart;basic;arrows2', enabledLibraries: ['general', 'flowchart', 'basic', 'arrows2'] } })
159
+ post({ action: 'configure', config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: 'general;uml;er;bpmn;flowchart;basic;arrows2', enabledLibraries: null } })
78
160
  } else if (message.event === 'init') {
79
161
  post({ action: 'load', xml: latestFile.current, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
80
162
  setReady(true)
@@ -91,9 +173,21 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
91
173
  if (!ready || truncated) return
92
174
  let cancelled = false
93
175
  const poll = async () => {
176
+ // 1. 用户按住鼠标拖拽中,绝不读取
177
+ if (isInteracting.current) return
178
+ // 2. 刚松手或打字后 1.5 秒内(操作缓冲),暂停轮询
179
+ if (Date.now() - lastUserActivity.current < 1500) return
180
+ // 3. 本地正在防抖保存中,不从磁盘读取
181
+ if (saveTimer.current !== undefined) return
182
+ // 4. 浏览器标签页切后台时静默
183
+ if (typeof document !== 'undefined' && document.hidden) return
184
+
94
185
  try {
95
186
  const xml = await fsRead(scope, path)
96
- if (cancelled || xml === latestFile.current || xml === latestEditor.current) return
187
+ // 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果
188
+ if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
189
+ if (xml === latestFile.current || xml === latestEditor.current) return
190
+
97
191
  latestFile.current = xml
98
192
  latestEditor.current = xml
99
193
  post({ action: 'load', xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
@@ -103,7 +197,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
103
197
  if (!cancelled) setError(messageOf(reason))
104
198
  }
105
199
  }
106
- const timer = window.setInterval(() => { void poll() }, 1600)
200
+ // 空闲时每 2.5 秒正常读取一次
201
+ const timer = window.setInterval(() => { void poll() }, 2500)
107
202
  return () => { cancelled = true; window.clearInterval(timer) }
108
203
  }, [ready, truncated, scope.sessionId, scope.cwd, path, dark, post])
109
204
 
@@ -165,25 +260,24 @@ function detectDark(): boolean {
165
260
  return document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches
166
261
  }
167
262
 
168
- async function fsRead(scope: SessionScope, path: string): Promise<string> {
169
- const value = await call<{ kind: string; content?: string; truncated?: boolean }>('fs.read', scope, { path })
170
- if (value.kind !== 'text' || value.truncated) throw new Error('Draw.io 文件不是完整的 UTF-8 文本。')
171
- return value.content ?? ''
263
+ async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<string> {
264
+ const value = await call<{ content: string }>('read', scope, { path }, false, signal)
265
+ return value.content
172
266
  }
173
267
 
174
268
  async function fsWrite(scope: SessionScope, path: string, content: string, keepalive = false): Promise<void> {
175
- await call('fs.write', scope, { path, content }, keepalive)
269
+ await call('write', scope, { path, content }, keepalive)
176
270
  }
177
271
 
178
- async function call<T = { ok: true }>(method: string, scope: SessionScope, extra: Record<string, unknown>, keepalive = false): Promise<T> {
179
- const response = await fetch(`/sidebar/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: scope.sessionId, ...(scope.cwd ? { cwd: scope.cwd } : {}), ...extra }), keepalive })
272
+ async function call<T = { ok: true }>(method: string, scope: SessionScope, extra: Record<string, unknown>, keepalive = false, signal?: AbortSignal): Promise<T> {
273
+ const response = await fetch(`/dsh-drawio/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: scope.sessionId, ...(scope.cwd ? { cwd: scope.cwd } : {}), ...extra }), keepalive, signal })
180
274
  const envelope = await response.json().catch(() => null) as { ok?: boolean; value?: T; error?: { message?: string } } | null
181
275
  if (!response.ok || envelope?.ok !== true || envelope.value === undefined) throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`)
182
276
  return envelope.value
183
277
  }
184
278
 
185
- function CanvasIcon(): JSX.Element {
186
- return <svg aria-hidden viewBox="0 0 20 20" width="16" height="16" fill="none"><rect x="2.5" y="2.5" width="15" height="15" rx="2.5" stroke="currentColor"/><circle cx="7" cy="7" r="1.5" fill="currentColor"/><circle cx="13" cy="13" r="1.5" fill="currentColor"/><path d="M8.4 7.8l3.2 4.4M8.2 6.2h3.6M6.2 8.2v3.6" stroke="currentColor" strokeLinecap="round"/></svg>
279
+ function CanvasIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
280
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none"><rect x="2.5" y="2.5" width="15" height="15" rx="2.5" stroke="currentColor"/><circle cx="7" cy="7" r="1.5" fill="currentColor"/><circle cx="13" cy="13" r="1.5" fill="currentColor"/><path d="M8.4 7.8l3.2 4.4M8.2 6.2h3.6M6.2 8.2v3.6" stroke="currentColor" strokeLinecap="round"/></svg>
187
281
  }
188
282
 
189
283
  function CenteredMessage({ title, detail }: { title: string; detail: string }): JSX.Element {
@@ -191,6 +285,17 @@ function CenteredMessage({ title, detail }: { title: string; detail: string }):
191
285
  }
192
286
 
193
287
  const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
288
+ function parseDrawioAddress(address: string): { sessionId: string; path: string } | null {
289
+ const prefix = 'dsh-resource://file/session/'
290
+ if (!address.startsWith(prefix)) return null
291
+ try {
292
+ const parts = address.slice(prefix.length).split('/').map(decodeURIComponent)
293
+ const sessionId = parts.shift() ?? ''
294
+ const path = parts.join('/')
295
+ if (!sessionId || !path || !/\.drawio$/i.test(path) || parts.some(part => part.includes('/') || part.includes('\\') || part.includes('\0'))) return null
296
+ return { sessionId, path }
297
+ } catch { return null }
298
+ }
194
299
  const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
195
300
  const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'error' ? '保存失败' : '已保存'
196
301
  const fg = 'var(--dsw-alias-label-primary, currentColor)'