dsh-mindmap 0.8.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project are documented here. Release-specific notes are also published on GitHub Releases.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ### Added
8
+
9
+ - Collapsible subtrees: every node with children shows a small toggle on its connector. Collapsing hides the subtree and reports how many nodes are hidden, which keeps large maps navigable. The state is view-only — the markdown file is untouched and image export still covers the full subtree. Collapsed nodes reset when the document changes, and stale ids are pruned after the AI rewrites the tree.
10
+
11
+ ### Fixed
12
+
13
+ - 画布拖拽不再吞掉折叠开关等控件的点击。
14
+ - 目录打开/焦点同步:宿主草稿 API 不可读时不再静默放弃;有非空草稿时指令转入剪贴板。
15
+ - Settings now wait for a late-arriving client connection instead of permanently capturing its initial absence. The client also supports direct settings descriptors and the current positional update API, while retaining the legacy envelopes.
16
+ - Canvas dragging remains available horizontally and vertically when the map fits inside the viewport. Native scrolling is used first; bounded offset compensation continues the drag at an edge, and Fit or node focus returns the map to center.
17
+ - Mindmap file access now fails closed when the session working directory is unavailable. Reads are limited during streaming rather than trusting a pre-read size check, and updates use a same-directory temporary file plus atomic replacement to avoid partial writes.
18
+ - Pathological Markdown tables now have row, column, and total-cell limits. PNG export rejects oversized canvas allocations instead of risking a browser crash.
19
+ - Switching sessions clears pending mindmap state and ignores stale directory responses; repeated resize events no longer disable automatic canvas fitting.
20
+ - Writes now require native approval by default. Directory opening and focus synchronization never overwrite a non-empty or unreadable chat draft.
21
+ - The tool guidance no longer implies that a placeholder node needs a trailing space: any list item with no text after the marker becomes a placeholder.
22
+
5
23
  ## [0.8.2] - 2026-09-09
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -23,9 +23,10 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
23
23
  - **Single-mindmap mode** — two tabs only: 目录 (tree) and 脑图 (the current mindmap); opening another `.md` replaces the previous one.
24
24
  - **"What you see is what the AI edits"** — when the visible mindmap differs from the AI's working document, the panel automatically asks the AI to open it, keeping the chat focus in sync.
25
25
  - **MarkGrove-style mapping** — heading hierarchy, nested lists (empty items become placeholder nodes), code blocks as leaf nodes, paragraphs promoted to their own nodes (019 block concept), stable structural IDs, and orthogonal connector lines between nodes.
26
- - **Centered canvas with zoom and pan** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas. The canvas also pans by drag: the **middle button** anywhere (even over a node), the **left button on blank canvas** (the Mac trackpad「click and drag」path), or **Space + left button** when the drag must start on a card. Content follows the pointer 1:1, blank space shows a grab hand, and a 4px threshold separates drag from click — so clicking blank space still clears the selection and clicking a node still focuses it, while a real drag never wipes the selection ring.
26
+ - **Centered canvas with zoom and pan** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas. The canvas also pans by drag: the **middle button** anywhere (even over a node), the **left button on blank canvas** (the Mac trackpad「click and drag」path), or **Space + left button** when the drag must start on a card. Panning works in both directions even when content does not overflow; content follows the pointer 1:1, blank space shows a grab hand, and a 4px threshold separates drag from click — so clicking blank space still clears the selection and clicking a node still focuses it, while a real drag never wipes the selection ring.
27
+ - **Collapsible subtrees** — every node with children carries a small toggle on its connector: collapsing hides the whole subtree and reports how many nodes are hidden, so large maps stay navigable. It is view state only — the markdown file is untouched, image export still covers the full subtree, and switching documents expands everything again.
27
28
  - **PNG export** — one click on 导出图片 exports the current mindmap.
28
- - **Safety** — `mindmap_create` / `mindmap_update` are approval-free by default (files are git-managed) with a `requireApproval` switch as an escape hatch (when on, both write tools ask); the client has **no write path** to the filesystem — every edit goes through the AI tools.
29
+ - **Safety** — `mindmap_create` / `mindmap_update` require native approval by default. Automation can explicitly opt out with `requireApproval: false`; the client has **no write path** to the filesystem — every edit goes through the AI tools.
29
30
 
30
31
  ## Requirements
31
32
 
package/README.zh-CN.md CHANGED
@@ -23,9 +23,10 @@
23
23
  - **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
24
24
  - **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
25
25
  - **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落升格为独立节点(019 块概念)、稳定结构 ID、节点间直角折线。
26
- - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
26
+ - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);无论内容是否溢出都可横纵双向拖动,内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
27
+ - **子树折叠**——有子节点的节点在连线起点上带一个小开关:折叠后隐藏整棵子树并提示隐藏了多少节点,超大脑图也能逐层看。折叠只是视图状态,不改 markdown 文件,导出图片仍是完整子树;切换文档自动全部展开。
27
28
  - **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
28
- - **安全**——`mindmap_create`/`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药(开启后两个写工具都走审批);客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
29
+ - **安全**——`mindmap_create`/`mindmap_update` 默认进入原生审批;可通过 `requireApproval: false` 为自动化场景明确关闭。客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
29
30
 
30
31
  ## 环境要求
31
32
 
package/client.js CHANGED
@@ -264,6 +264,11 @@ window.__ModuleLoader__.load({
264
264
  };
265
265
  }
266
266
 
267
+ // 表格网格会按分隔行补齐,单独限额以防小体积输入膨胀成海量单元格。
268
+ const MAX_TABLE_COLUMNS = 100;
269
+ const MAX_TABLE_ROWS = 1000;
270
+ const MAX_TABLE_CELLS = 10000;
271
+
267
272
  /** 缩进宽度:tab 按 4 空格折算。 */
268
273
  function indentWidth(raw) {
269
274
  let width = 0;
@@ -309,7 +314,8 @@ window.__ModuleLoader__.load({
309
314
  if (node.kind === "code") return data.code || node.topic || "";
310
315
  if (node.kind === "table" && Array.isArray(data.rows) && data.rows.length > 0) {
311
316
  // data.rows 不含分隔行(解析时剔除);复制时补回,粘回 Markdown 仍是合法表格。
312
- const lines = data.rows.map((row) => `| ${row.join(" | ")} |`);
317
+ const escapeCell = (cell) => String(cell ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
318
+ const lines = data.rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`);
313
319
  if (data.rows.length > 1) lines.splice(1, 0, `| ${data.rows[0].map(() => "---").join(" | ")} |`);
314
320
  return lines.join("\n");
315
321
  }
@@ -480,30 +486,39 @@ window.__ModuleLoader__.load({
480
486
  // 019 表格块:连续 | 行且第二行为分隔行 → table 节点(003 §5.5,
481
487
  // data.rows 全量保留,单元格不拆子节点)。不满足分隔行条件的 | 行
482
488
  // 按普通段落处理。
483
- if (/^\s*\|/.test(line)) {
489
+ if (/^\s*\|/.test(line) && i + 1 < lineList.length && isTableSeparator(lineList[i + 1])) {
484
490
  const rows = [];
491
+ let truncated = false;
485
492
  let j = i;
486
- for (; j < lineList.length && /^\s*\|/.test(lineList[j]); j++) rows.push(lineList[j]);
487
- if (rows.length >= 2 && isTableSeparator(rows[1])) {
493
+ for (; j < lineList.length && /^\s*\|/.test(lineList[j]); j++) {
494
+ if (rows.length < MAX_TABLE_ROWS) rows.push(lineList[j]);
495
+ else truncated = true;
496
+ }
497
+ if (rows.length >= 2) {
488
498
  flushParagraph();
489
499
  listStack = [];
490
500
  i = j - 1;
491
501
  // GFM 对齐契约:列数钉死在分隔行。表头与数据行同等待遇:
492
502
  // 少列补空、多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
493
- const cols = parseTableRow(rows[1]).length;
503
+ const detectedCols = parseTableRow(rows[1]).length;
504
+ const cols = Math.min(detectedCols, MAX_TABLE_COLUMNS);
505
+ if (detectedCols > cols) truncated = true;
506
+ const maxRows = Math.min(rows.length, Math.max(2, Math.floor(MAX_TABLE_CELLS / cols)));
507
+ if (rows.length > maxRows) truncated = true;
508
+ const visibleRows = rows.slice(0, maxRows);
494
509
  const toCols = (cells) => {
495
510
  if (cells.length >= cols) return cells.slice(0, cols);
496
511
  return cells.concat(new Array(cols - cells.length).fill(""));
497
512
  };
498
- const header = toCols(parseTableRow(rows[0]));
499
- const body = rows.slice(2).map((row) => toCols(parseTableRow(row)));
513
+ const header = toCols(parseTableRow(visibleRows[0]));
514
+ const body = visibleRows.slice(2).map((row) => toCols(parseTableRow(row)));
500
515
  const tableRows = [header].concat(body);
501
516
  appendNode({
502
517
  id: idOf("table", tableRows.map((r) => r.join("\u0001")).join("\u0002"), parentPathOf()),
503
518
  kind: "table",
504
- topic: `${tableRows.length}×${cols} 表格`,
519
+ topic: truncated ? `${tableRows.length}×${cols} 表格(已截断)` : `${tableRows.length}×${cols} 表格`,
505
520
  children: [],
506
- data: { rows: tableRows },
521
+ data: { rows: tableRows, truncated },
507
522
  });
508
523
  continue;
509
524
  }
@@ -943,6 +958,75 @@ window.__ModuleLoader__.load({
943
958
  }
944
959
  //#endregion
945
960
 
961
+ //#region 025 草稿保护:能力探测(宿主是否让插件读到聊天草稿)
962
+ /**
963
+ * 读取当前聊天草稿。宿主契约只保证 setDraft/submit,读取面属可选能力:
964
+ * 逐个探测已知形态,读不到返回 null(= 不可知,不等于空草稿)。
965
+ */
966
+ function readDraftText(inputActions) {
967
+ if (!inputActions) return null;
968
+ try {
969
+ if (typeof inputActions.getDraft === "function") return String(inputActions.getDraft() ?? "");
970
+ if (typeof inputActions.draft === "string") return inputActions.draft;
971
+ if (typeof inputActions.getState === "function") {
972
+ const state = inputActions.getState();
973
+ if (state && typeof state.draft === "string") return state.draft;
974
+ }
975
+ } catch {
976
+ return null;
977
+ }
978
+ return null;
979
+ }
980
+
981
+ /**
982
+ * 是否因「已有未发送草稿」而放弃自动发送。只有确实读到非空草稿才拦截;
983
+ * 读不到时不拦——否则在不暴露草稿的宿主上,点目录文件会完全打不开。
984
+ */
985
+ function draftBlocksAutoSend(inputActions) {
986
+ const draft = readDraftText(inputActions);
987
+ return typeof draft === "string" && draft.trim() !== "";
988
+ }
989
+ //#endregion
990
+
991
+ //#region 025 子树折叠:画布视图态纯函数(不进 markdown 资产,只影响呈现)
992
+ /** 折叠集合切换:恒返回新集合,React 状态可直接按引用比较。 */
993
+ function toggleCollapsed(collapsed, id) {
994
+ const next = new Set(collapsed ?? []);
995
+ if (next.has(id)) next.delete(id);
996
+ else next.add(id);
997
+ return next;
998
+ }
999
+
1000
+ /** 子孙节点总数:折叠后用于提示「隐藏了多少节点」。 */
1001
+ function countDescendants(node) {
1002
+ let total = 0;
1003
+ const walk = (n) => {
1004
+ for (const child of n.children ?? []) {
1005
+ total += 1;
1006
+ walk(child);
1007
+ }
1008
+ };
1009
+ if (node) walk(node);
1010
+ return total;
1011
+ }
1012
+
1013
+ /**
1014
+ * 丢弃当前树里已不存在的折叠 id(AI 改写文档后旧 id 会失效)。
1015
+ * 没有变化时原样返回入参,避免制造新引用触发多余重渲染。
1016
+ */
1017
+ function pruneCollapsed(collapsed, tree) {
1018
+ if (!collapsed || collapsed.size === 0) return collapsed;
1019
+ const ids = collectTreeIds(tree);
1020
+ let changed = false;
1021
+ const next = new Set();
1022
+ for (const id of collapsed) {
1023
+ if (ids.has(id)) next.add(id);
1024
+ else changed = true;
1025
+ }
1026
+ return changed ? next : collapsed;
1027
+ }
1028
+ //#endregion
1029
+
946
1030
  //#region PNG 导出(SVG 序列化 → canvas → 下载 / 剪贴板)
947
1031
  // 019 可变盒高布局:盒高按内容估行数(全量换行的导出形态),表格节点加宽;
948
1032
  // 布局契约不变——叶子自上而下占行、父节点垂直居中于其子块。
@@ -954,6 +1038,8 @@ window.__ModuleLoader__.load({
954
1038
  // 列盒窄于内距时 cellInner 可能为负,钳到至少容纳一个全角字符;
955
1039
  // 测量与渲染必须共用同一钳制值(022 契约)。
956
1040
  tableCellMinInner: 12,
1041
+ maxCanvasDimension: 8192,
1042
+ maxCanvasPixels: 16 * 1024 * 1024,
957
1043
  };
958
1044
 
959
1045
  function escapeXml(text) {
@@ -1137,8 +1223,19 @@ window.__ModuleLoader__.load({
1137
1223
  return { svg: parts.join(""), width, height };
1138
1224
  }
1139
1225
 
1226
+ /** Canvas 分配前的硬上限,避免合法但超长的脑图耗尽浏览器内存。 */
1227
+ function exportCanvasSize(width, height) {
1228
+ const w = Math.max(1, Math.ceil(width));
1229
+ const h = Math.max(1, Math.ceil(height));
1230
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w > EXPORT.maxCanvasDimension || h > EXPORT.maxCanvasDimension || w * h > EXPORT.maxCanvasPixels) {
1231
+ throw new Error("脑图图片过大,请缩小导出范围后重试");
1232
+ }
1233
+ return { width: w, height: h };
1234
+ }
1235
+
1140
1236
  /** SVG → Image → 白底 canvas(下载 / 剪贴板共用,017 抽出)。 */
1141
1237
  async function renderSvgToCanvas(svg, width, height) {
1238
+ const size = exportCanvasSize(width, height);
1142
1239
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
1143
1240
  const img = new Image();
1144
1241
  await new Promise((resolve, reject) => {
@@ -1147,8 +1244,8 @@ window.__ModuleLoader__.load({
1147
1244
  img.src = url;
1148
1245
  });
1149
1246
  const canvas = document.createElement("canvas");
1150
- canvas.width = Math.max(1, Math.ceil(width));
1151
- canvas.height = Math.max(1, Math.ceil(height));
1247
+ canvas.width = size.width;
1248
+ canvas.height = size.height;
1152
1249
  const ctx2d = canvas.getContext("2d");
1153
1250
  ctx2d.fillStyle = "#ffffff";
1154
1251
  ctx2d.fillRect(0, 0, canvas.width, canvas.height);
@@ -1288,7 +1385,11 @@ window.__ModuleLoader__.load({
1288
1385
  swatchActive: { borderColor: "var(--dsw-alias-state-business-primary)", color: "var(--dsw-alias-label-primary)", boxShadow: "inset 0 0 0 1px var(--dsw-alias-state-business-primary)" },
1289
1386
  swatchDot: { width: "10px", height: "10px", borderRadius: "50%", flex: "none" },
1290
1387
  row: { display: "flex", alignItems: "center", minWidth: 0 },
1291
- childrenColumn: { display: "flex", flexDirection: "column", gap: "8px", marginLeft: "40px", minWidth: 0 },
1388
+ // 025:子列左距 16 + 折叠开关(16 + 左右各 4 外距)= 折叠前的 40,
1389
+ // 连线长度与既有版式保持一致。
1390
+ childrenColumn: { display: "flex", flexDirection: "column", gap: "8px", marginLeft: "16px", minWidth: 0 },
1391
+ // 025 折叠开关:压在连线起点上的小圆钮,叶子节点不渲染。
1392
+ collapseToggle: { flex: "none", width: "16px", height: "16px", margin: "0 4px", padding: 0, borderRadius: "50%", border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-secondary)", font: "inherit", fontSize: "11px", lineHeight: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", cursor: "pointer", zIndex: 1 },
1292
1393
  // 面板树连线层:正交折线(MarkGrove 的 orthogonalPath 风格),
1293
1394
  // 覆盖整行、点击穿透、置于节点盒之下。
1294
1395
  // 016:去掉 CSS width/height 百分比——在 auto-height 的 flex 行内,
@@ -1756,7 +1857,10 @@ window.__ModuleLoader__.load({
1756
1857
 
1757
1858
  /** 左→右递归树:节点盒 + 右侧子节点列 + 连线层(015 支持折线/曲线两种线型)。 */
1758
1859
  function TreeRow(props) {
1759
- const { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel } = props;
1860
+ const { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel, collapsed, onToggleCollapse } = props;
1861
+ // 025 折叠:纯视图态——markdown 资产不变,导出仍取完整子树。
1862
+ const hasChildren = Boolean(node.children && node.children.length > 0);
1863
+ const isCollapsed = hasChildren && Boolean(collapsed && collapsed.has(node.id));
1760
1864
  // 018 生长动画:本节点渐显延迟(新节点盒)与本行连线渐显延迟(有新子节点)。
1761
1865
  const revealDelay = reveal && reveal.nodes ? reveal.nodes.get(node.id) : undefined;
1762
1866
  const edgeRevealDelay = reveal && reveal.edges ? reveal.edges.get(node.id) : undefined;
@@ -1793,7 +1897,8 @@ window.__ModuleLoader__.load({
1793
1897
  const rowRect = rowEl.getBoundingClientRect();
1794
1898
  const boxRect = boxEl.getBoundingClientRect();
1795
1899
  const next = [];
1796
- for (const ref of childRefs.current) {
1900
+ // 折叠时子列未挂载:不量、不画线(ref 回调已置空,这里再兜一层)。
1901
+ for (const ref of isCollapsed ? [] : childRefs.current) {
1797
1902
  if (!ref) continue;
1798
1903
  const c = ref.getBoundingClientRect();
1799
1904
  // 视觉像素 → 行本地坐标(SVG 用户空间 = 本地空间)。
@@ -1871,13 +1976,30 @@ window.__ModuleLoader__.load({
1871
1976
  } : undefined,
1872
1977
  children: (0, react_jsx_runtime.jsx)(NodeBox, { node, theme, revealDelay, selectedId, onCodePanel }),
1873
1978
  }),
1874
- node.children && node.children.length > 0
1875
- ? (0, react_jsx_runtime.jsx)("div", { style: S.childrenColumn, children: node.children.map((child, idx) => (0, react_jsx_runtime.jsx)("div", {
1979
+ // 025 折叠开关:坐在盒与子列之间的连线起点上(有子节点才出现)。
1980
+ // stopPropagation 保证点它不触发画布的「点节点聚焦 / 点空白取消选中」。
1981
+ hasChildren
1982
+ ? (0, react_jsx_runtime.jsx)("button", {
1983
+ type: "button",
1984
+ "data-mindmap-collapse": "",
1985
+ style: S.collapseToggle,
1986
+ title: isCollapsed ? `展开子树(已隐藏 ${countDescendants(node)} 个节点)` : "折叠子树",
1987
+ "aria-expanded": isCollapsed ? "false" : "true",
1988
+ onClick: (e) => {
1989
+ e.preventDefault();
1990
+ e.stopPropagation();
1991
+ if (onToggleCollapse) onToggleCollapse(node.id);
1992
+ },
1993
+ children: isCollapsed ? "+" : "−",
1994
+ })
1995
+ : null,
1996
+ hasChildren && !isCollapsed
1997
+ ? (0, react_jsx_runtime.jsx)("div", { style: S.childrenColumn, "data-mindmap-children": "", children: node.children.map((child, idx) => (0, react_jsx_runtime.jsx)("div", {
1876
1998
  key: child.id,
1877
1999
  ref: (el) => {
1878
2000
  childRefs.current[idx] = el;
1879
2001
  },
1880
- children: (0, react_jsx_runtime.jsx)(TreeRow, { node: child, theme, onNodeContextMenu, reveal, selectedId, onCodePanel }),
2002
+ children: (0, react_jsx_runtime.jsx)(TreeRow, { node: child, theme, onNodeContextMenu, reveal, selectedId, onCodePanel, collapsed, onToggleCollapse }),
1881
2003
  }, child.id)) })
1882
2004
  : null,
1883
2005
  ] });
@@ -1925,7 +2047,7 @@ window.__ModuleLoader__.load({
1925
2047
  // 阈值 4px 以内算「点击」:不写 scroll(手抖不挪画布)、不上抓手光标、
1926
2048
  // 不吞随后的 click——保留点空白取消选中 / 点节点聚焦的既有行为。
1927
2049
  // 触摸(触屏)不劫持——交给原生滚动,保住惯性。
1928
- const PAN = { threshold: 4 };
2050
+ const PAN = { threshold: 4, freeRange: 0.5 };
1929
2051
 
1930
2052
  /** 是否在该指针按下上启动平移。button: 0 左 / 1 中 / 2 右。 */
1931
2053
  function shouldStartPan(button, options) {
@@ -1947,6 +2069,24 @@ window.__ModuleLoader__.load({
1947
2069
  };
1948
2070
  }
1949
2071
 
2072
+ /** 原生滚动到边缘后,用视口半宽/高的有界位移继续保持内容跟手。 */
2073
+ function freePanOffset(start, dx, dy, scrollLeft, scrollTop, viewWidth, viewHeight) {
2074
+ const limitX = Number.isFinite(viewWidth) && viewWidth > 0 ? viewWidth * PAN.freeRange : 0;
2075
+ const limitY = Number.isFinite(viewHeight) && viewHeight > 0 ? viewHeight * PAN.freeRange : 0;
2076
+ const actualLeft = Number.isFinite(scrollLeft) ? scrollLeft : start.scrollLeft;
2077
+ const actualTop = Number.isFinite(scrollTop) ? scrollTop : start.scrollTop;
2078
+ return {
2079
+ x: Math.min(limitX, Math.max(-limitX, start.offsetX + dx + actualLeft - start.scrollLeft)),
2080
+ y: Math.min(limitY, Math.max(-limitY, start.offsetY + dy + actualTop - start.scrollTop)),
2081
+ };
2082
+ }
2083
+
2084
+ /** 按下点是否落在画布内的交互控件上(这类按下不启动平移,留给控件自己)。 */
2085
+ function isCanvasControl(el) {
2086
+ if (!el || typeof el.closest !== "function") return false;
2087
+ return Boolean(el.closest("button, a[href], input, textarea, select, [role='button']"));
2088
+ }
2089
+
1950
2090
  /** 空格键是否落在可输入元素里(聊天框/输入框与面板同 document,不能抢空格)。 */
1951
2091
  function isTextEntry(el) {
1952
2092
  if (!el || typeof el.tagName !== "string") return false;
@@ -2005,6 +2145,9 @@ window.__ModuleLoader__.load({
2005
2145
  const nodeMenuRef = react.useRef(null);
2006
2146
  // 019 选中态:点击聚焦的节点下选选中环(002 §6 状态体系)。
2007
2147
  const [selectedId, setSelectedId] = react.useState(null);
2148
+ // 025 折叠子树:纯视图态(不写回 markdown,导出仍取完整子树)。
2149
+ // 切换文档时全部展开;AI 改写后清掉已消失节点的折叠标记。
2150
+ const [collapsed, setCollapsed] = react.useState(() => new Set());
2008
2151
  // 019 代码块悬停浮层:{node, anchor};null = 关闭。延迟关闭(150ms
2009
2152
  // 宽限)让鼠标能从节点盒移到面板上滚动全文,不闪灭。
2010
2153
  const [codePanel, setCodePanel] = react.useState(null);
@@ -2026,6 +2169,9 @@ window.__ModuleLoader__.load({
2026
2169
  // 都要重测一遍(O(n) 强制重排),大树上拖一下会明显顿一下。
2027
2170
  // 光标与「禁用选中」直接改 DOM style,全程零重渲染。
2028
2171
  const panRef = react.useRef(null);
2172
+ // 原生滚动没有余量或已到边缘时的有界补偿位移。同样只写 DOM,
2173
+ // 避免平移触发整棵树重渲染。
2174
+ const panOffsetRef = react.useRef({ x: 0, y: 0 });
2029
2175
  // 021 空格键:按下时左键在节点上也能拖。同样用 ref(按空格不该重渲染)。
2030
2176
  const spaceRef = react.useRef(false);
2031
2177
  // 021 拖过就吞掉随后那次 click(保留单击空白取消选中 / 点节点聚焦)。
@@ -2041,6 +2187,7 @@ window.__ModuleLoader__.load({
2041
2187
  const scroller = scrollRef.current;
2042
2188
  const content = contentRef.current;
2043
2189
  if (!scroller || !content) return;
2190
+ resetPanOffset();
2044
2191
  const rect = content.getBoundingClientRect();
2045
2192
  if (!(rect.width > 0) || !(rect.height > 0)) return;
2046
2193
  const committed = committedZoomRef.current;
@@ -2053,7 +2200,9 @@ window.__ModuleLoader__.load({
2053
2200
  scroller.scrollLeft = 0;
2054
2201
  scroller.scrollTop = 0;
2055
2202
  setZoomState(fit);
2203
+ return true;
2056
2204
  }
2205
+ return false;
2057
2206
  }
2058
2207
 
2059
2208
  // 挂载 / 文档切换:清除「用户已手动缩放」标记与熔断计数,
@@ -2062,6 +2211,7 @@ window.__ModuleLoader__.load({
2062
2211
  userZoomedRef.current = false;
2063
2212
  lastNaturalRef.current = null;
2064
2213
  fitStampRef.current = [];
2214
+ setCollapsed((prev) => (prev.size > 0 ? new Set() : prev));
2065
2215
  const id = requestAnimationFrame(applyFit);
2066
2216
  return () => cancelAnimationFrame(id);
2067
2217
  }, [fitKey]);
@@ -2087,6 +2237,7 @@ window.__ModuleLoader__.load({
2087
2237
  if (Math.abs(w - last.w) <= 2 && Math.abs(h - last.h) <= 2) return;
2088
2238
  }
2089
2239
  }
2240
+ if (!applyFit()) return;
2090
2241
  const now = Date.now();
2091
2242
  const stamps = fitStampRef.current = fitStampRef.current.filter((t) => now - t < 1500);
2092
2243
  if (stamps.length >= 5) {
@@ -2094,7 +2245,6 @@ window.__ModuleLoader__.load({
2094
2245
  return;
2095
2246
  }
2096
2247
  stamps.push(now);
2097
- applyFit();
2098
2248
  });
2099
2249
  observer.observe(content);
2100
2250
  observer.observe(scroller);
@@ -2138,6 +2288,18 @@ window.__ModuleLoader__.load({
2138
2288
  scroller.style.userSelect = active ? "none" : "";
2139
2289
  }
2140
2290
 
2291
+ function resetPanOffset() {
2292
+ panOffsetRef.current = { x: 0, y: 0 };
2293
+ const content = contentRef.current;
2294
+ if (content) content.style.transform = "";
2295
+ }
2296
+
2297
+ function applyPanOffset(offset) {
2298
+ const content = contentRef.current;
2299
+ if (!content) return;
2300
+ content.style.transform = offset.x || offset.y ? `translate(${offset.x}px, ${offset.y}px)` : "";
2301
+ }
2302
+
2141
2303
  function beginPan(e) {
2142
2304
  // 吞 click 的标记一律在本轮手势的最开头清零,放在所有早退之前:
2143
2305
  // 否则上轮手势留下的 true 会粘到下一次点击上——例:拖完画布后再
@@ -2146,6 +2308,10 @@ window.__ModuleLoader__.load({
2146
2308
  const scroller = scrollRef.current;
2147
2309
  if (!scroller || panRef.current) return;
2148
2310
  const target = e.target;
2311
+ // 025:画布内的控件(折叠开关等)必须先于平移拿到这次按下。
2312
+ // 否则 setPointerCapture 会把随后的 click 改派到滚动区,按钮
2313
+ // 永远收不到点击——「折叠按钮点了没反应」的根因。
2314
+ if (isCanvasControl(target)) return;
2149
2315
  const onNode = Boolean(target && typeof target.closest === "function" && target.closest("[data-mindmap-node]"));
2150
2316
  if (!shouldStartPan(e.button, { onNode, spaceHeld: spaceRef.current, touch: e.pointerType === "touch" })) return;
2151
2317
  // 中键:掐掉浏览器自动滚动;左键:掐掉拖选文本。
@@ -2156,6 +2322,8 @@ window.__ModuleLoader__.load({
2156
2322
  y: e.clientY,
2157
2323
  scrollLeft: scroller.scrollLeft,
2158
2324
  scrollTop: scroller.scrollTop,
2325
+ offsetX: panOffsetRef.current.x,
2326
+ offsetY: panOffsetRef.current.y,
2159
2327
  moved: false,
2160
2328
  };
2161
2329
  // 不在按下就上抓手光标:普通点击(位移 < 阈值)不该闪一下
@@ -2185,9 +2353,13 @@ window.__ModuleLoader__.load({
2185
2353
  // 首次越过阈值:这才算拖拽——上抓手光标 + 锁文本选择。
2186
2354
  if (!pan.moved) applyPanCursor(scroller, true);
2187
2355
  if (step.moved) pan.moved = true;
2188
- // 越界交给浏览器钳制(不自己算 scrollWidth,省一次重排)。
2356
+ // 先交给浏览器钳制;到边缘后把未被 scroll 消耗的位移补到内容
2357
+ // transform,上下左右始终可拖,且不读 scrollWidth 造成强制重排。
2189
2358
  scroller.scrollLeft = step.scrollLeft;
2190
2359
  scroller.scrollTop = step.scrollTop;
2360
+ const offset = freePanOffset(pan, e.clientX - pan.x, e.clientY - pan.y, scroller.scrollLeft, scroller.scrollTop, scroller.clientWidth, scroller.clientHeight);
2361
+ panOffsetRef.current = offset;
2362
+ applyPanOffset(offset);
2191
2363
  }
2192
2364
 
2193
2365
  function endPan() {
@@ -2209,7 +2381,7 @@ window.__ModuleLoader__.load({
2209
2381
  function setZoom(next, anchorViewport) {
2210
2382
  const value = clampZoom(next);
2211
2383
  anchorRef.current = anchorViewport && scrollRef.current
2212
- ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop }
2384
+ ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop, offsetX: panOffsetRef.current.x, offsetY: panOffsetRef.current.y }
2213
2385
  : null;
2214
2386
  zoomRef.current = value;
2215
2387
  setZoomState(value);
@@ -2230,8 +2402,8 @@ window.__ModuleLoader__.load({
2230
2402
  anchorRef.current = null;
2231
2403
  const ratio = anchor.prevZoom > 0 ? zoom / anchor.prevZoom : 1;
2232
2404
  if (!(ratio > 0) || ratio === 1) return;
2233
- scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2) * ratio - scroller.clientWidth / 2;
2234
- scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2) * ratio - scroller.clientHeight / 2;
2405
+ scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2 - anchor.offsetX) * ratio + anchor.offsetX - scroller.clientWidth / 2;
2406
+ scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2 - anchor.offsetY) * ratio + anchor.offsetY - scroller.clientHeight / 2;
2235
2407
  }, [zoom]);
2236
2408
 
2237
2409
  function zoomIn() {
@@ -2296,6 +2468,7 @@ window.__ModuleLoader__.load({
2296
2468
  focusRef.current = null;
2297
2469
  const scroller = scrollRef.current;
2298
2470
  if (!scroller || !focus || !focus.boxEl || !focus.boxEl.isConnected) return;
2471
+ resetPanOffset();
2299
2472
  const boxRect = focus.boxEl.getBoundingClientRect();
2300
2473
  const scrollerRect = scroller.getBoundingClientRect();
2301
2474
  const curX = boxRect.left + boxRect.width / 2 - scrollerRect.left;
@@ -2330,8 +2503,14 @@ window.__ModuleLoader__.load({
2330
2503
  setNodeMenu(null);
2331
2504
  // 019:文档内容变化时同步收掉代码浮层(节点对象已失效)。
2332
2505
  setCodePanel(null);
2506
+ // 025:树重解析后丢弃已消失节点的折叠标记(无变化时保持原引用)。
2507
+ setCollapsed((prev) => pruneCollapsed(prev, node));
2333
2508
  }, [node]);
2334
2509
 
2510
+ function toggleCollapse(id) {
2511
+ setCollapsed((prev) => toggleCollapsed(prev, id));
2512
+ }
2513
+
2335
2514
  // 017 右键节点:记录菜单锚点与目标子树(清掉上次的忙碌/错误态)。
2336
2515
  function onNodeContextMenu(e, target) {
2337
2516
  setNodeMenuBusy(null);
@@ -2396,7 +2575,7 @@ window.__ModuleLoader__.load({
2396
2575
  children:
2397
2576
  (0, react_jsx_runtime.jsx)("div", { style: S.canvasCenter, children:
2398
2577
  (0, react_jsx_runtime.jsx)("div", { ref: contentRef, style: { margin: "auto", zoom }, children:
2399
- (0, react_jsx_runtime.jsx)(TreeRow, { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel: handleCodePanel })
2578
+ (0, react_jsx_runtime.jsx)(TreeRow, { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel: handleCodePanel, collapsed, onToggleCollapse: toggleCollapse })
2400
2579
  })
2401
2580
  })
2402
2581
  }),
@@ -2623,6 +2802,8 @@ window.__ModuleLoader__.load({
2623
2802
  const [filledHint, setFilledHint] = react.useState("");
2624
2803
  // fsTree:nodes = {path → 节点}, expanded = {path → true}, loading = {path → true}。
2625
2804
  const [fsTree, setFsTree] = react.useState({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
2805
+ // 会话切换时递增,使旧请求的异步回包不能写入新会话的目录树。
2806
+ const treeGenerationRef = react.useRef(0);
2626
2807
  // 013 右键菜单:{x, y, kind: "root"|"dir", rel};null = 关闭。
2627
2808
  const [treeMenu, setTreeMenu] = react.useState(null);
2628
2809
  // tab 右键菜单:{x, y, path}(path === TREE_TAB 时是「刷新目录树」)。
@@ -2749,10 +2930,24 @@ window.__ModuleLoader__.load({
2749
2930
  }, [merged]);
2750
2931
 
2751
2932
  // 013「所见即所编」焦点同步:AI 焦点 = 快照里最新工具结果的文档路径;
2752
- // 脑图视图激活且其文档 ≠ 焦点时,自动填「用 mindmap_open 打开 <它>」
2753
- // 并发送,让 AI 跟上用户眼睛看的那颗脑图。
2933
+ // 脑图视图激活且其文档 ≠ 焦点时,仅在草稿为空时自动发送,让 AI
2934
+ // 跟上用户眼睛看的那颗脑图,又不覆盖用户正在编辑的消息。
2754
2935
  const focusPath = docs.order.length > 0 ? docs.order[docs.order.length - 1] : null;
2755
2936
  const focusSentRef = react.useRef(null);
2937
+ // 所有这些状态都属于会话,不得让 A 会话的在途打开/目录结果遗留到 B。
2938
+ react.useEffect(() => {
2939
+ setLocalDocs({});
2940
+ setCurrentPath(null);
2941
+ setHiddenPath(null);
2942
+ setView("tree");
2943
+ setOpenTimedOut(false);
2944
+ setTreeMenu(null);
2945
+ setTabMenu(null);
2946
+ setFilledHint("");
2947
+ localErrorBaseRef.current = null;
2948
+ focusSentRef.current = null;
2949
+ prevIdsRef.current = { path: null, ids: null };
2950
+ }, [sessionId]);
2756
2951
  react.useEffect(() => {
2757
2952
  if (!open) return; // 面板收起时不自动发消息(014 overlay 形态守卫)
2758
2953
  if (!sessionId) return;
@@ -2760,14 +2955,9 @@ window.__ModuleLoader__.load({
2760
2955
  if (!docs.byPath[active]) return; // 本地占位:它的 open 请求已在途
2761
2956
  if (focusPath === active) return;
2762
2957
  if (focusSentRef.current === active) return; // 已发过,等 AI 结果追平
2763
- if (!inputActions || typeof inputActions.setDraft !== "function") return;
2764
2958
  const rel = fsTree.cwd ? relPathWithin(fsTree.cwd, active, stemOf(active)) : active;
2765
- try {
2766
- inputActions.setDraft(`用 mindmap_open 打开 ${rel}`);
2767
- if (typeof inputActions.submit === "function") inputActions.submit();
2959
+ if (submitChatCommand(`用 mindmap_open 打开 ${rel}`)) {
2768
2960
  focusSentRef.current = active;
2769
- } catch {
2770
- // 发送失败:下次 active/focus 变化会再试;也可手动在聊天里说。
2771
2961
  }
2772
2962
  }, [active, focusPath, fsTree.cwd, docs, open]);
2773
2963
 
@@ -2785,11 +2975,28 @@ window.__ModuleLoader__.load({
2785
2975
  }
2786
2976
 
2787
2977
  //#region 013 目录树 tab:懒加载树 + 把指令填进聊天输入框
2788
- // 主路径 = inputActions.setDraft(官方公共面,整串替换草稿);
2789
- // 无则降级剪贴板复制 + 面板内提示。
2978
+ // 草稿保护:确实读到非空草稿才让路(改走剪贴板),读不到就按既有
2979
+ // 行为直填直发——宿主不暴露草稿时不能把功能整个卡死。
2980
+ function draftBlocked() {
2981
+ return draftBlocksAutoSend(inputActions);
2982
+ }
2983
+
2984
+ function submitChatCommand(text) {
2985
+ if (draftBlocked()) return false;
2986
+ if (!inputActions || typeof inputActions.setDraft !== "function" || typeof inputActions.submit !== "function") return false;
2987
+ try {
2988
+ inputActions.setDraft(text);
2989
+ inputActions.submit();
2990
+ return true;
2991
+ } catch {
2992
+ return false;
2993
+ }
2994
+ }
2995
+
2996
+ // 手动新建指令同样不覆盖已有草稿;否则填入输入框。
2790
2997
  function fillDraft(text) {
2791
2998
  try {
2792
- if (inputActions && typeof inputActions.setDraft === "function") {
2999
+ if (!draftBlocked() && inputActions && typeof inputActions.setDraft === "function") {
2793
3000
  inputActions.setDraft(text);
2794
3001
  setFilledHint("指令已填入聊天输入框");
2795
3002
  return;
@@ -2822,10 +3029,20 @@ window.__ModuleLoader__.load({
2822
3029
  // 就位——AI 工具结果到达后同 path 覆盖占位,节点才渲染;随后用户接着
2823
3030
  // 说即可继续编辑(002 数据流不变:内容只来自 AI 工具结果)。
2824
3031
  function openMindmap(entry) {
3032
+ const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
3033
+ // 有未发送草稿:不建占位、不抢输入框,改把指令交给用户自己发。
3034
+ if (draftBlocked()) {
3035
+ if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
3036
+ navigator.clipboard.writeText(text).catch(() => {});
3037
+ setFilledHint("检测到未发送的草稿,已保留;打开指令已复制到剪贴板,粘贴发送即可");
3038
+ } else {
3039
+ setFilledHint(`检测到未发送的草稿,已保留。请手动发送:${text}`);
3040
+ }
3041
+ return;
3042
+ }
2825
3043
  // 016:记录点击时刻的错误基线(errorByPath 与 latestError 的全部
2826
3044
  // 事件键)——只有其后新出现的错误才归因本次打开,旧错误不打扰。
2827
3045
  localErrorBaseRef.current = errorEventKeys(merged);
2828
- const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
2829
3046
  // ① 本地占位:脑图 tab 立即切过去、内容为空(op:"local" 触发加载态);
2830
3047
  // 新打开的脑图替换旧的那颗(单脑图模式)。
2831
3048
  setHiddenPath(null);
@@ -2843,19 +3060,7 @@ window.__ModuleLoader__.load({
2843
3060
  },
2844
3061
  }));
2845
3062
  // ② AI 就位:填指令并直接提交(失败降级剪贴板)。
2846
- let sent = false;
2847
- if (inputActions && typeof inputActions.setDraft === "function") {
2848
- try {
2849
- inputActions.setDraft(text);
2850
- if (typeof inputActions.submit === "function") {
2851
- inputActions.submit();
2852
- sent = true;
2853
- }
2854
- } catch {
2855
- // 落剪贴板降级
2856
- }
2857
- }
2858
- if (sent) {
3063
+ if (submitChatCommand(text)) {
2859
3064
  // 标记已发,避免焦点同步 effect 对同一路径重复发送。
2860
3065
  focusSentRef.current = entry.path;
2861
3066
  setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
@@ -2881,11 +3086,13 @@ window.__ModuleLoader__.load({
2881
3086
  // 只读;返回 {path, cwd, entries:[{name,path,isDir,hidden}], truncated}。
2882
3087
  // 返回 true/false 供调用方决定是否标记展开(失败时不要把目录标成已展开)。
2883
3088
  async function loadTree(path) {
3089
+ const generation = treeGenerationRef.current;
2884
3090
  const key = path === undefined || path === null ? "" : path;
2885
3091
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: true }, error: null }));
2886
3092
  try {
2887
3093
  if (!mindmapFace || typeof mindmapFace.listTree !== "function") throw new Error("目录树能力不可用");
2888
3094
  const listing = await mindmapFace.listTree(sessionId, typeof path === "string" && path ? path : undefined);
3095
+ if (generation !== treeGenerationRef.current) return false;
2889
3096
  setFsTree((prev) => {
2890
3097
  const nodes = { ...prev.nodes };
2891
3098
  nodes[listing.path] = {
@@ -2908,6 +3115,7 @@ window.__ModuleLoader__.load({
2908
3115
  });
2909
3116
  return true;
2910
3117
  } catch (error) {
3118
+ if (generation !== treeGenerationRef.current) return false;
2911
3119
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: false }, error: String(error?.message ?? error) }));
2912
3120
  return false;
2913
3121
  }
@@ -2945,6 +3153,7 @@ window.__ModuleLoader__.load({
2945
3153
 
2946
3154
  // 首次挂载:拉根目录(会话 cwd)。
2947
3155
  react.useEffect(() => {
3156
+ treeGenerationRef.current += 1;
2948
3157
  if (!sessionId) return;
2949
3158
  setFsTree({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
2950
3159
  loadTree(undefined);
@@ -3298,7 +3507,12 @@ window.__ModuleLoader__.load({
3298
3507
  * namespaces 兜底,两代通吃。
3299
3508
  */
3300
3509
  function settingsNamespacesOf(res) {
3301
- const value = res?.result?.value;
3510
+ // connection 旧代理包一层 result.value;新版远端直接返回描述符数组。
3511
+ const value = res?.ok === true
3512
+ ? res.value
3513
+ : Array.isArray(res) || Array.isArray(res?.value)
3514
+ ? (Array.isArray(res) ? res : res.value)
3515
+ : res?.result?.value;
3302
3516
  if (Array.isArray(value)) return value;
3303
3517
  const list = value?.namespaces;
3304
3518
  return Array.isArray(list) ? list : [];
@@ -3358,22 +3572,56 @@ window.__ModuleLoader__.load({
3358
3572
  return parsed.value;
3359
3573
  };
3360
3574
 
3361
- // 015 设置面板:settings namespace(dsh-grafana 同款读写面)。
3362
- // connection ctx.get 可选查取(动态 ctx 契约);缺失时设置面板降级提示。
3363
- const connection = ctx.get("connection");
3364
- const settingsApi = connection && typeof connection.api === "object" ? connection.api : null;
3575
+ // 015 设置面板:connection/remote 在客户端插件启动后才可能就绪,不能在
3576
+ // apply 时捕获一次 undefined;每次读写前重新查取,服务晚到也能恢复。
3577
+ // 未声明 inject 的服务只能整名走 ctx.get 可选查取。带点号的服务
3578
+ // (remote.settings)同样是独立服务名:先取 remote 再读 .settings
3579
+ // 被守卫拒绝(cannot get property "remote.settings" without inject),
3580
+ // 故一律传全名,并对任何守卫异常降级为「服务不可用」。
3581
+ function serviceOf(name) {
3582
+ if (typeof ctx.get !== "function") return null;
3583
+ try {
3584
+ return ctx.get(name) ?? null;
3585
+ } catch {
3586
+ return null;
3587
+ }
3588
+ }
3589
+ function isSettingsApi(value) {
3590
+ try {
3591
+ return Boolean(value) && typeof value.describe === "function" && typeof value.update === "function";
3592
+ } catch {
3593
+ return false;
3594
+ }
3595
+ }
3596
+ function settingsApiOf() {
3597
+ const remote = serviceOf("remote.settings");
3598
+ if (isSettingsApi(remote)) return { kind: "remote", settings: remote };
3599
+ try {
3600
+ const settings = serviceOf("connection")?.api?.settings;
3601
+ if (isSettingsApi(settings)) return { kind: "connection", settings };
3602
+ } catch {
3603
+ // 守卫拒绝或连接形态异常:与服务缺失同样降级处理。
3604
+ }
3605
+ return null;
3606
+ }
3365
3607
  face.readSettings = async () => {
3366
- if (!settingsApi || typeof settingsApi.settings?.describe !== "function") return null;
3367
- const res = await settingsApi.settings.describe({});
3608
+ const api = settingsApiOf();
3609
+ if (!api) return null;
3610
+ const res = api.kind === "remote" ? await api.settings.describe() : await api.settings.describe({});
3368
3611
  const namespaces = settingsNamespacesOf(res);
3369
3612
  const ns = namespaces.find((n) => n?.ns === "mindmap");
3370
3613
  return ns?.value ?? null;
3371
3614
  };
3372
3615
  face.updateSettings = async (patch) => {
3373
- if (!settingsApi || typeof settingsApi.settings?.update !== "function") {
3616
+ const api = settingsApiOf();
3617
+ if (!api) {
3374
3618
  throw new Error("settings service unavailable");
3375
3619
  }
3376
- await settingsApi.settings.update({ ns: "mindmap", patch });
3620
+ if (api.kind === "remote" || api.settings.update.length !== 1) {
3621
+ await api.settings.update("mindmap", patch, undefined);
3622
+ } else {
3623
+ await api.settings.update({ ns: "mindmap", patch });
3624
+ }
3377
3625
  };
3378
3626
 
3379
3627
  // 015 设置面板:settings.section(list 槽、root scope)——设置页左栏
@@ -3416,11 +3664,20 @@ window.__ModuleLoader__.load({
3416
3664
  stemOf,
3417
3665
  buildExportSvg,
3418
3666
  measureExportBox,
3667
+ exportCanvasSize,
3419
3668
  createIdFactory,
3420
3669
  collectTreeIds,
3421
3670
  planGrowthReveal,
3422
3671
  relPathWithin,
3423
3672
  visibleTreeRows,
3673
+ // 025 草稿保护:宿主草稿读取面的能力探测(供测试)。
3674
+ readDraftText,
3675
+ draftBlocksAutoSend,
3676
+ // 025 子树折叠:视图态纯函数 + 节点行组件(供测试)。
3677
+ toggleCollapsed,
3678
+ countDescendants,
3679
+ pruneCollapsed,
3680
+ TreeRow,
3424
3681
  // 019 皮肤层与血肉层纯函数(供测试)。
3425
3682
  resolveToken,
3426
3683
  resolveNodeStyle,
package/index.js CHANGED
@@ -9,12 +9,13 @@
9
9
  // - 四工具都带 path/name 参数(决策 3:多脑图并存,作用于指定那颗)。
10
10
  // - 结果 JSON {ok, op, path, rootTitle, content, renamedFrom?}:content 全文
11
11
  // 供模型续编辑,client 用同一份重放面板(工具结果即实时通道,002 第二节)。
12
- // - requireApproval 配置(决策 6):默认 false 免审批;置 true mindmap_create
13
- // mindmap_update 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
12
+ // - requireApproval 配置(决策 6):默认 true;仅明确关闭时 mindmap_create
13
+ // mindmap_update 可免原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
14
14
  // 015 起经 settings namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
15
15
  // - 依赖:仅 @deepseek-ai/schemastery(settings schema;发布包正常解析,
16
16
  // link 开发需先 npm i)。工具参数 schema 仍手写 JSON Schema(003 偏差 1)。
17
- import { access, opendir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
17
+ import { access, open, opendir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
18
+ import { randomUUID } from 'node:crypto'
18
19
  import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'
19
20
  import Schema from '@deepseek-ai/schemastery'
20
21
 
@@ -26,7 +27,7 @@ export const inject = ['tools', 'systemPrompt', 'webServer', 'sessions']
26
27
  // defaultPanelWidth 供客户端面板取默认宽度(20-80 钳制由客户端执行)。
27
28
  export const SETTINGS_NAMESPACE = 'mindmap'
28
29
  export const Config = Schema.object({
29
- requireApproval: Schema.boolean().default(false).description('Require native DSH approval for every mindmap_create and mindmap_update (including renameRoot). Files are git-managed, so it defaults to off. Hidden from the settings panel; still honored at runtime.'),
30
+ requireApproval: Schema.boolean().default(true).description('Require native DSH approval for every mindmap_create and mindmap_update (including renameRoot). Enabled by default and hidden from the settings panel; still honored at runtime.'),
30
31
  defaultPanelWidth: Schema.number().default(42).description('Default floating-panel width as a percentage of the viewport (clamped 20-80 on the client).'),
31
32
  lineStyle: Schema.union(['curve', 'elbow']).default('elbow').description('Connector line style between nodes: curve (bezier) or elbow (orthogonal).'),
32
33
  cardStyle: Schema.union(['rounded', 'square']).default('rounded').description('Node card corner style.'),
@@ -51,7 +52,7 @@ Tools:
51
52
  - mindmap_get(path): read the current markdown content.
52
53
  - mindmap_update(path, content, renameRoot?): write the FULL updated markdown. renameRoot renames the file to match a new root title (fails on name collision); use it only when the user asks to rename the root node.
53
54
 
54
- Markdown mapping (the panel's parser): headings nest by level (H1 are root children, H2 under the previous H1, ...); list items are child nodes nested by 2-space indentation; an EMPTY list item ("- " followed by nothing) renders as a placeholder node — use placeholders for planned-but-unwritten nodes; a fenced code block becomes a leaf node titled "[lang] first line"; plain paragraphs become the note text of the nearest heading.
55
+ Markdown mapping (the panel's parser): headings nest by level (H1 are root children, H2 under the previous H1, ...); list items are child nodes nested by 2-space indentation; a list item with no text after the marker renders as a placeholder node (both "-" and "- " work; no trailing space is required) — use placeholders for planned-but-unwritten nodes; a fenced code block becomes a leaf node titled "[lang] first line"; plain paragraphs become the note text of the nearest heading.
55
56
 
56
57
  Behavior rules:
57
58
  - When the user asks to create a mindmap, call mindmap_create. When the user asks to open, view, show, or switch to an existing mindmap, call mindmap_open (do not use mindmap_get alone). Both operations bring that document to the visible mindmap panel automatically.
@@ -245,17 +246,14 @@ function sanitizeStem(input) {
245
246
 
246
247
  /**
247
248
  * 解析脑图文件路径:相对路径以会话 cwd 为基;结果必须落在 cwd 内(决策 1),
248
- * 且必须以 .md 结尾。cwd 缺失时仅接受绝对路径。
249
+ * 且必须以 .md 结尾。cwd 是文件授权边界,缺失时必须失败关闭。
249
250
  * @returns 绝对规范化路径。
250
251
  */
251
252
  async function resolveMindmapPath(cwd, input) {
252
253
  if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
253
254
  const p = input.trim()
254
255
  if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
255
- if (!cwd) {
256
- if (!isAbsolute(p)) throw new Error('The session has no working directory; pass an absolute .md path.')
257
- return resolvePath(p)
258
- }
256
+ if (!cwd) throw new Error('The session has no working directory; cannot access a mindmap.')
259
257
  const resolved = resolvePath(cwd, p)
260
258
  const rel = relative(cwd, resolved)
261
259
  // realpath 兜底(#5):写路径经符号链接越狱是安全敏感操作。
@@ -293,15 +291,53 @@ function byteLength(value) {
293
291
  }
294
292
 
295
293
  /**
296
- * mindmap_open / mindmap_get 共用的读取路径:先 stat 查文件大小(不为测
297
- * 大小读全量),超限即拒;不存在时 stat 抛 ENOENT,readFile 的原语义不变。
294
+ * mindmap_open / mindmap_get 共用的有界读取。句柄打开后持续分块读取,文件在
295
+ * 初检后增长也不会绕过上限;非常规文件直接拒绝。
298
296
  */
299
297
  async function readMindmap(path) {
300
- const { size } = await stat(path)
301
- if (size > MAX_READ_BYTES) {
302
- throw new Error(`mindmap size exceeds the ${MAX_READ_BYTES}-byte limit.`)
298
+ const handle = await open(path, 'r')
299
+ try {
300
+ const info = await handle.stat()
301
+ if (!info.isFile()) throw new Error('mindmap path must be a regular file.')
302
+ if (info.size > MAX_READ_BYTES) {
303
+ throw new Error(`mindmap size exceeds the ${MAX_READ_BYTES}-byte limit.`)
304
+ }
305
+ const chunks = []
306
+ let total = 0
307
+ let position = 0
308
+ for (;;) {
309
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_READ_BYTES + 1 - total))
310
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position)
311
+ if (bytesRead === 0) break
312
+ total += bytesRead
313
+ if (total > MAX_READ_BYTES) {
314
+ throw new Error(`mindmap size exceeds the ${MAX_READ_BYTES}-byte limit.`)
315
+ }
316
+ chunks.push(chunk.subarray(0, bytesRead))
317
+ position += bytesRead
318
+ }
319
+ return Buffer.concat(chunks, total).toString('utf8')
320
+ } finally {
321
+ await handle.close()
322
+ }
323
+ }
324
+
325
+ /** 同目录临时文件完整落盘后再替换,任何写入失败都保留旧内容。 */
326
+ async function writeMindmap(path, content) {
327
+ const temp = join(dirname(path), `.${randomUUID()}.mindmap-tmp`)
328
+ const { mode } = await stat(path)
329
+ let handle
330
+ try {
331
+ handle = await open(temp, 'wx', mode & 0o777)
332
+ await handle.writeFile(content, 'utf8')
333
+ await handle.sync()
334
+ await handle.close()
335
+ handle = undefined
336
+ await rename(temp, path)
337
+ } finally {
338
+ if (handle) await handle.close().catch(() => {})
339
+ await unlink(temp).catch(() => {})
303
340
  }
304
- return readFile(path, 'utf8')
305
341
  }
306
342
 
307
343
  /** 工具结果信封:client 面板与模型共用的唯一载体。 */
@@ -320,7 +356,7 @@ export function apply(ctx, config = {}) {
320
356
  // 入口配置作为 settings 组合层的 base:视觉三件套与默认宽度在这里
321
357
  // 透传(带 schema 同款默认值),用户设置层仍可在设置面板覆盖。
322
358
  const entryConfig = {
323
- requireApproval: config.requireApproval === true,
359
+ requireApproval: config.requireApproval !== false,
324
360
  defaultPanelWidth: typeof config.defaultPanelWidth === 'number' ? config.defaultPanelWidth : 42,
325
361
  lineStyle: config.lineStyle === 'curve' ? 'curve' : 'elbow',
326
362
  cardStyle: config.cardStyle === 'square' ? 'square' : 'rounded',
@@ -344,7 +380,7 @@ export function apply(ctx, config = {}) {
344
380
  ctx.systemPrompt.section({ name: 'tool:mindmap', order: 106, text: GUIDANCE })
345
381
 
346
382
  // 后悔药开关(决策 6):钩子常驻注册,运行时读 activeConfig().requireApproval
347
- // ——设置面板切换立即生效;关闭时直接放行(默认 false 免审批)。
383
+ // ——设置面板切换立即生效;仅明确关闭时直接放行。
348
384
  // 022:create 同为写路径,一并纳入审批(CONTRIBUTING:所有写路径安全敏感)。
349
385
  ctx.on('tools/pre-execute', async (exec, next) => {
350
386
  const decision = await next()
@@ -479,8 +515,8 @@ export function apply(ctx, config = {}) {
479
515
  finalPath = target
480
516
  }
481
517
  }
482
- if (hasContent) await writeFile(finalPath, args.content, 'utf8')
483
- const content = hasContent ? args.content : await readFile(finalPath, 'utf8')
518
+ if (hasContent) await writeMindmap(finalPath, args.content)
519
+ const content = hasContent ? args.content : await readMindmap(finalPath)
484
520
  return buildResult('update', finalPath, { content, ...(renamedFrom ? { renamedFrom } : {}) })
485
521
  },
486
522
  }))
@@ -540,6 +576,7 @@ export const internals = Object.freeze({
540
576
  MAX_CONTENT_BYTES,
541
577
  MAX_READ_BYTES,
542
578
  readMindmap,
579
+ writeMindmap,
543
580
  sanitizeStem,
544
581
  resolveMindmapPath,
545
582
  sessionCwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.8.3",
3
+ "version": "0.10.0",
4
4
  "description": "Mindmap plugin for DeepSeek Harness: a plain markdown file in the working directory IS the mindmap; the chat edits it step by step and the right-side panel follows live.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,7 +46,8 @@
46
46
  "inject": [
47
47
  "@deepseek-ai/dsh-client-runtime",
48
48
  "@deepseek-ai/dsh-cordis-client-runner",
49
- "@deepseek-ai/dsh-client-ui-layout"
49
+ "@deepseek-ai/dsh-client-ui-layout",
50
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
50
51
  ],
51
52
  "platform": "web",
52
53
  "immediately": true