dsh-mindmap 0.8.1 → 0.9.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
@@ -4,22 +4,42 @@ All notable changes to this project are documented here. Release-specific notes
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Fixed
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - Pathological Markdown tables now have row, column, and total-cell limits. PNG export rejects oversized canvas allocations instead of risking a browser crash.
13
+ - Switching sessions clears pending mindmap state and ignores stale directory responses; repeated resize events no longer disable automatic canvas fitting.
14
+ - Writes now require native approval by default. Directory opening and focus synchronization never overwrite a non-empty or unreadable chat draft.
15
+ - 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.
16
+
17
+ ## [0.8.2] - 2026-09-09
18
+
7
19
  ### Added
8
20
 
9
- - Step-by-step update convention for the AI (system prompt + `mindmap_update` tool description): when an edit has several parts, call `mindmap_update` as soon as each part is ready instead of one giant update at the end each call still carries the FULL document, and the panel's growth animation makes the tree visibly grow while the AI works. The former "panel updates in one step" wording is removed as it pointed the other way.
10
- - Progressive growth animation for the mindmap panel: after every update, newly added/changed nodes fade in one by one (breadth-first from root to leaves, staggered up to 90ms apart) instead of the whole tree popping in at once. Diffing rides on the existing stable structural node IDs, so unchanged nodes never flicker or replay; the total duration is compressed to stay within ~2s for large documents; connector lines fade in together with their new children. The layout is computed in full up front, so the animation is compositor-only (opacity/transform) with no reflow cost. A new `growthAnimation` setting (settings panel「面板」→「生长动画」, default on) turns it off entirely, and `prefers-reduced-motion` is respected. The `mindmap_update` contract is unchanged (still full markdown).
11
- - Block concept for mindmap nodes (per the 003 living spec): markdown content is now parsed into typed blocks — text, markdown (inline formatting), code (compact summary line + hover overlay for the full source), quote, and table (full grid, cells never split) — while block-level structure syntax (headings/lists/quotes) is split into child nodes and plain paragraphs are promoted to nodes instead of being stuffed into a parent's description. Inline formatting never triggers splitting.
12
- - Unified clickable links: any URL inside any block is rendered complete (never abbreviated), clickable, and opens in a new browser tab; links inside the hover overlay are clickable too.
13
- - Theme token system: node colors/corners/shadows/states are produced by the pure function `resolveNodeStyle` from theme tokens, so card style and color theme switch cleanly without touching layout code.
14
- - Node right-click menu gains「复制全文」: copies a node's complete own content to the clipboard (code blocks take the full fenced source, table blocks re-emit a valid Markdown table with the separator row restored, everything else takes the raw text), alongside the existing 复制为图片 / 导出为图片. A divider line separates the node title from the actions.
15
- - Hover overlay for truncated content: prose blocks cut by the line clamp and code blocks share one floating panel (fixed-positioned outside the zoom layer, scrollable full text); it always docks to the right of the node box and never flips left — clicking a node auto-focuses it into view, so hovering again always shows everything.
16
- - Canvas panning (021): the mindmap canvas can now be dragged around with the middle mouse button (anywhere, even over a node), the left button on blank canvas space (this is the Mac trackpad「click and drag」path), or Space + left button (even over a node, when the drag has to start on a card). Content follows the pointer 1:1, blank space shows a grab hand, and the canvas no longer chains its scroll to the host page at the edges. 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 no longer wipes the selection ring on release. Built on pointer events with pointer capture, so the pointer keeps dragging even outside the panel; touch input is left to native scrolling so momentum survives; and the gesture never re-renders the tree (cursor and text-selection lock go straight to the DOM, because a re-render would re-measure every `TreeRow`). Refinements from review (`_issues/001`): sub-threshold jitter no longer writes `scroll` or flashes the grab cursor (a 1–3px hand tremor on a plain click used to shift the canvas, and because the formula is absolute from the press anchor, skipping those early writes still tracks the pointer 1:1 — the full displacement lands in one jump once the threshold is crossed); the grab cursor and the text-selection lock are now applied on the first crossing rather than on press; a pan whose `setPointerCapture` failed no longer hangs when the pointer is released outside the scroller (a later button-less move ends the pan instead of dragging the canvas with nothing held); the click-suppression flag is cleared at the very top of every gesture rather than after the early returns, so a finished drag can no longer leak into a later tap that takes a non-panning path (touch tap, left-press on a node) and swallow it; and a second pointer can no longer drive an existing pan's anchor.
21
+ - Read-size guard for `mindmap_open` / `mindmap_get`: both tools `stat` the file first and refuse to read anything larger than 2 MB (`MAX_READ_BYTES`, the same cap as writes), so opening an accidentally huge document can no longer flood the model context.
22
+
23
+ ### Fixed
24
+
25
+ - Export table rendering: the per-cell wrap width is clamped to at least 12px (`tableCellMinInner`, one full-width character) in both box measurement and SVG drawing, so extremely wide tables (e.g. 60 columns) can never degenerate into one-character-per-line stacking.
17
26
 
18
27
  ### Changed
19
28
 
20
- - Length governance for node boxes (020 revision, replacing unbounded full-wrap): every node box is capped at 320px wide so long content wraps instead of stretching into horizontal bars. Prose blocks (text/md/list/quote-topic) that still exceed 6 wrapped lines are clamped with an ellipsis full text stays reachable via the hover overlay. The clamp uses an integer 20px line-height plus a hard `maxHeight` guard so the cut line never leaks half a glyph. Structural blocks keep their shape: tables get their own wider 680px writing surface (overflow scrolls inside the box) with per-cell min/max width guards.
21
- - GFM table parsing contract: a literal `|` inside a cell must be escaped as `\|` and the parser now honors it; column count is pinned to the separator row — short rows are padded with empty cells and extra cells are dropped, so the grid never turns ragged even when a row contains unescaped pipes.
22
- - Split the two largest client source fragments for maintainability (pure physical split, no behavior change): `src/client/ui/render.js` (572 lines) becomes `slot.js` (MindmapSlot) + `render.js` (NodeBox/TreeRow) + `canvas.js` (the 016 canvas/zoom region), and `src/client/ui/panel.js` (790 lines) becomes `panel.js` (component head) + `treetab.js` (the 013 directory-tree tab region) + `panelbody.js` (layout effect + overlay JSX). The split was done by line slicing only, and the rebuilt `client.js` is byte-for-byte identical to the previous build. `build-client.mjs` now decides fragment separators via an explicit `PHYSICAL_SPLITS` set instead of hard-coded list indices.
29
+ - Changelog realignment: entries that had shipped with 0.5.0–0.8.1 are moved from `[Unreleased]` into their own version sections, split by the real git tag boundaries; README and README.zh-CN updated to reflect the dual dsh compatibility (`0.1.1-rc.2` + `0.1.2-rc.1`), paragraph promotion (019 block concept), and the published release tag.
30
+ - Dependabot: major-version upgrades of `actions/checkout` and `actions/setup-node` (≥5) are ignored until evaluated separately (the two hanging PRs were closed).
31
+
32
+ ## [0.8.1] - 2026-09-08
33
+
34
+ ### Added
35
+
36
+ - `@deepseek-ai/dsh-tools` peer dependency with a four-range version window (`^0.1.0-rc.6 || ^0.1.1-rc.0 || ^0.1.2-alpha.0 || ^0.1.3-alpha.0`), so plugin installs resolve cleanly across the harness tooling line.
37
+
38
+ ### Changed
39
+
40
+ - Dependency refresh (`@deepseek-ai/cosmokit` 1.8.2 → 1.8.3, `@deepseek-ai/schemastery` 3.18.1 → 3.18.2, lockfile updated accordingly); `.gitignore` also ignores `_*` and `._*` at the repository root.
41
+
42
+ ## [0.8.0] - 2026-09-04
23
43
 
24
44
  ### Fixed
25
45
 
@@ -28,8 +48,11 @@ All notable changes to this project are documented here. Release-specific notes
28
48
  - **Live panel data**: the session snapshot no longer carries a flat `nodes` array (conversation content moved to the Chat view), so the panel now reads nodes via `useChat → ChatSnapshot.legacy.nodes` (0.1.2-rc.1+) first and falls back to `useSession → SessionSnapshot.nodes` (dsh ≤0.1.1). `ToolResultNode` field names are unchanged across both, so document replay, auto-open, and error surfacing work as before.
29
49
  - **Settings panel**: `settings.describe` over the client connection now returns the descriptor array directly (0.1.2-rc.1+) instead of an aggregate `result.value.namespaces` (dsh ≤0.1.1); both envelope shapes are parsed.
30
50
  - **Hygiene**: `dsh.client.inject` additionally lists `@deepseek-ai/dsh-cordis-client-runner` (the 0.1.2-rc.1 browser runtime module id; `@deepseek-ai/dsh-client-runtime` is kept for 0.1.1-rc.2 — a missing id is silently skipped by the loader, so both coexist safely).
31
- - The panel now reliably auto-opens when the AI completes `mindmap_open` / `mindmap_create`. A structural fingerprint of the session nodes (`nodesFingerprint`) feeds a second `useSession` selector; its value comparison bypasses the reference-equality short-circuit that starved the auto-open effect whenever the host store mutated the nodes array in place.
32
- - The "AI 正在打开脑图…" loading state is no longer a dead end. Snapshot documents whose path differs from the tree-click key only by letter case (macOS case-insensitive filesystem) now merge automatically; errored mindmap tool results (`isError` or `ok !== true`) surface as an inline error; a ~30s watchdog switches to a timeout state. Both failure states offer a one-click retry that re-sends the open request.
51
+
52
+ ## [0.7.0] - 2026-08-31
53
+
54
+ ### Fixed
55
+
33
56
  - A code-review sweep (`_issues/002`) landed 11 fixes, each with a failing regression test first:
34
57
  - **Markdown parsing**: an unclosed `---` frontmatter fence at the top no longer swallows the whole document into an empty tree; the root-title echo merge (`firstTopH1Seen`) is set only by a top-level H1 so an earlier H2 or a heading inside a quote block no longer suppresses it; GFM table column count is pinned to the **separator row** (short rows padded, extra cells dropped) instead of the header; fenced code blocks close only on a matching fence type (`~~~` is no longer ended by a ``` line) and `parseTableRow` honors an escaped `\|` preceded by an escaped backslash (`\\|`).
35
58
  - **Document snapshots**: `mergeDocuments` only deletes a dropped local entry when no live snapshot owns the same path, so a recreated file that a snapshot also references keeps its panel openable.
@@ -37,6 +60,40 @@ All notable changes to this project are documented here. Release-specific notes
37
60
  - **Host safety & approval**: the tree route and all mindmap path resolutions now reject symlink escapes out of the working directory via a `realpath` walk-up (`resolvesInsideBase`); `requireApproval` gates `mindmap_create` as well as `mindmap_update`; and `listDirectoryLevel` stops iterating once it hits the entry cap (perf) while still flagging truncation.
38
61
  - **PNG export**: wide tables measure their box height using the same clamped per-column width the renderer uses, so wrapped cell text no longer overflows the measured box bottom.
39
62
 
63
+ ## [0.6.0] - 2026-08-30
64
+
65
+ ### Added
66
+
67
+ - Canvas panning (021): the mindmap canvas can now be dragged around with the middle mouse button (anywhere, even over a node), the left button on blank canvas space (this is the Mac trackpad「click and drag」path), or Space + left button (even over a node, when the drag has to start on a card). Content follows the pointer 1:1, blank space shows a grab hand, and the canvas no longer chains its scroll to the host page at the edges. 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 no longer wipes the selection ring on release. Built on pointer events with pointer capture, so the pointer keeps dragging even outside the panel; touch input is left to native scrolling so momentum survives; and the gesture never re-renders the tree (cursor and text-selection lock go straight to the DOM, because a re-render would re-measure every `TreeRow`). Refinements from review (`_issues/001`): sub-threshold jitter no longer writes `scroll` or flashes the grab cursor (a 1–3px hand tremor on a plain click used to shift the canvas, and because the formula is absolute from the press anchor, skipping those early writes still tracks the pointer 1:1 — the full displacement lands in one jump once the threshold is crossed); the grab cursor and the text-selection lock are now applied on the first crossing rather than on press; a pan whose `setPointerCapture` failed no longer hangs when the pointer is released outside the scroller (a later button-less move ends the pan instead of dragging the canvas with nothing held); the click-suppression flag is cleared at the very top of every gesture rather than after the early returns, so a finished drag can no longer leak into a later tap that takes a non-panning path (touch tap, left-press on a node) and swallow it; and a second pointer can no longer drive an existing pan's anchor.
68
+
69
+ ## [0.5.1] - 2026-08-26
70
+
71
+ ### Added
72
+
73
+ - Block concept for mindmap nodes (per the 003 living spec): markdown content is now parsed into typed blocks — text, markdown (inline formatting), code (compact summary line + hover overlay for the full source), quote, and table (full grid, cells never split) — while block-level structure syntax (headings/lists/quotes) is split into child nodes and plain paragraphs are promoted to nodes instead of being stuffed into a parent's description. Inline formatting never triggers splitting.
74
+ - Unified clickable links: any URL inside any block is rendered complete (never abbreviated), clickable, and opens in a new browser tab; links inside the hover overlay are clickable too.
75
+
76
+ ## [0.5.0] - 2026-08-26
77
+
78
+ ### Added
79
+
80
+ - Step-by-step update convention for the AI (system prompt + `mindmap_update` tool description): when an edit has several parts, call `mindmap_update` as soon as each part is ready instead of one giant update at the end — each call still carries the FULL document, and the panel's growth animation makes the tree visibly grow while the AI works. The former "panel updates in one step" wording is removed as it pointed the other way.
81
+ - Progressive growth animation for the mindmap panel: after every update, newly added/changed nodes fade in one by one (breadth-first from root to leaves, staggered up to 90ms apart) instead of the whole tree popping in at once. Diffing rides on the existing stable structural node IDs, so unchanged nodes never flicker or replay; the total duration is compressed to stay within ~2s for large documents; connector lines fade in together with their new children. The layout is computed in full up front, so the animation is compositor-only (opacity/transform) with no reflow cost. A new `growthAnimation` setting (settings panel「面板」→「生长动画」, default on) turns it off entirely, and `prefers-reduced-motion` is respected. The `mindmap_update` contract is unchanged (still full markdown).
82
+ - Theme token system: node colors/corners/shadows/states are produced by the pure function `resolveNodeStyle` from theme tokens, so card style and color theme switch cleanly without touching layout code.
83
+ - Node right-click menu gains「复制全文」: copies a node's complete own content to the clipboard (code blocks take the full fenced source, table blocks re-emit a valid Markdown table with the separator row restored, everything else takes the raw text), alongside the existing 复制为图片 / 导出为图片. A divider line separates the node title from the actions.
84
+ - Hover overlay for truncated content: prose blocks cut by the line clamp and code blocks share one floating panel (fixed-positioned outside the zoom layer, scrollable full text); it always docks to the right of the node box and never flips left — clicking a node auto-focuses it into view, so hovering again always shows everything.
85
+
86
+ ### Changed
87
+
88
+ - Length governance for node boxes (020 revision, replacing unbounded full-wrap): every node box is capped at 320px wide so long content wraps instead of stretching into horizontal bars. Prose blocks (text/md/list/quote-topic) that still exceed 6 wrapped lines are clamped with an ellipsis — full text stays reachable via the hover overlay. The clamp uses an integer 20px line-height plus a hard `maxHeight` guard so the cut line never leaks half a glyph. Structural blocks keep their shape: tables get their own wider 680px writing surface (overflow scrolls inside the box) with per-cell min/max width guards.
89
+ - GFM table parsing contract: a literal `|` inside a cell must be escaped as `\|` and the parser now honors it; column count is pinned to the separator row — short rows are padded with empty cells and extra cells are dropped, so the grid never turns ragged even when a row contains unescaped pipes.
90
+ - Split the two largest client source fragments for maintainability (pure physical split, no behavior change): `src/client/ui/render.js` (572 lines) becomes `slot.js` (MindmapSlot) + `render.js` (NodeBox/TreeRow) + `canvas.js` (the 016 canvas/zoom region), and `src/client/ui/panel.js` (790 lines) becomes `panel.js` (component head) + `treetab.js` (the 013 directory-tree tab region) + `panelbody.js` (layout effect + overlay JSX). The split was done by line slicing only, and the rebuilt `client.js` is byte-for-byte identical to the previous build. `build-client.mjs` now decides fragment separators via an explicit `PHYSICAL_SPLITS` set instead of hard-coded list indices.
91
+
92
+ ### Fixed
93
+
94
+ - The panel now reliably auto-opens when the AI completes `mindmap_open` / `mindmap_create`. A structural fingerprint of the session nodes (`nodesFingerprint`) feeds a second `useSession` selector; its value comparison bypasses the reference-equality short-circuit that starved the auto-open effect whenever the host store mutated the nodes array in place.
95
+ - The "AI 正在打开脑图…" loading state is no longer a dead end. Snapshot documents whose path differs from the tree-click key only by letter case (macOS case-insensitive filesystem) now merge automatically; errored mindmap tool results (`isError` or `ok !== true`) surface as an inline error; a ~30s watchdog switches to a timeout state. Both failure states offer a one-click retry that re-sends the open request.
96
+
40
97
  ## [0.4.1] - 2026-08-24
41
98
 
42
99
  ### Changed
package/README.md CHANGED
@@ -22,17 +22,17 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
22
22
  - **Directory tree tab** — a persistent tree of the session working directory (served by a plugin-owned read-only route), lazy-loaded per directory; right-click to create a mindmap (at the root or inside a directory); left-click a `.md` to open it instantly and hand it to the AI for editing.
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
- - **MarkGrove-style mapping** — heading hierarchy, nested lists (empty items become placeholder nodes), code blocks as leaf nodes, paragraphs as node notes, 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.
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. 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
27
  - **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.
28
+ - **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
29
 
30
30
  ## Requirements
31
31
 
32
32
  | Component | Baseline |
33
33
  | --- | --- |
34
34
  | Node.js | 20.11 or newer |
35
- | DeepSeek Harness | tested against `0.1.1-rc.2` |
35
+ | DeepSeek Harness | tested against `0.1.1-rc.2` and `0.1.2-rc.1` |
36
36
 
37
37
  ## Installation
38
38
 
@@ -42,7 +42,7 @@ Development (link install, live source):
42
42
  dsh plugin --profile web add link:/path/to/dsh-mindmap
43
43
  ```
44
44
 
45
- Released tag (once published):
45
+ Released tag:
46
46
 
47
47
  ```bash
48
48
  dsh plugin --profile <profile> add <pkg>#v<version>
package/README.zh-CN.md CHANGED
@@ -22,17 +22,17 @@
22
22
  - **常驻目录树 tab**——工作目录结构懒加载树(插件自建只读路由);空白处/目录右键新建脑图;左键点 `.md` 秒开 tab 并自动交给 AI 打开,随后直接对话继续编辑。
23
23
  - **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
24
24
  - **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
25
- - **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落挂节点说明、稳定结构 ID、节点间直角折线。
26
- - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
25
+ - **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落升格为独立节点(019 块概念)、稳定结构 ID、节点间直角折线。
26
+ - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);无论内容是否溢出都可横纵双向拖动,内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
27
27
  - **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
28
- - **安全**——`mindmap_create`/`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药(开启后两个写工具都走审批);客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
28
+ - **安全**——`mindmap_create`/`mindmap_update` 默认进入原生审批;可通过 `requireApproval: false` 为自动化场景明确关闭。客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
29
29
 
30
30
  ## 环境要求
31
31
 
32
32
  | 组件 | 基线 |
33
33
  | --- | --- |
34
34
  | Node.js | 20.11 及以上 |
35
- | DeepSeek Harness | 实测于 `0.1.1-rc.2` |
35
+ | DeepSeek Harness | 实测于 `0.1.1-rc.2` 与 `0.1.2-rc.1` |
36
36
 
37
37
  ## 安装
38
38
 
@@ -42,7 +42,7 @@
42
42
  dsh plugin --profile web add link:/path/to/dsh-mindmap
43
43
  ```
44
44
 
45
- 发布 tag(发布后):
45
+ 发布 tag
46
46
 
47
47
  ```bash
48
48
  dsh plugin --profile <profile> add <pkg>#v<version>
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
  }
@@ -950,6 +965,12 @@ window.__ModuleLoader__.load({
950
965
  nodeW: 220, padX: 12, padY: 8, hGap: 48, vGap: 12, pad: 20,
951
966
  fontSize: 13, lineHeight: 18,
952
967
  tableCellW: 110, tableCellPad: 8, tableMinW: 140, tableMaxW: 480,
968
+ // 单元格折行宽下限 = 导出字号 12px 的全角宽:极宽表(如 60 列)
969
+ // 列盒窄于内距时 cellInner 可能为负,钳到至少容纳一个全角字符;
970
+ // 测量与渲染必须共用同一钳制值(022 契约)。
971
+ tableCellMinInner: 12,
972
+ maxCanvasDimension: 8192,
973
+ maxCanvasPixels: 16 * 1024 * 1024,
953
974
  };
954
975
 
955
976
  function escapeXml(text) {
@@ -1021,7 +1042,7 @@ window.__ModuleLoader__.load({
1021
1042
  const rows = (node.data && node.data.rows) || [];
1022
1043
  const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
1023
1044
  const w = Math.min(EXPORT.tableMaxW, Math.max(EXPORT.tableMinW, cols * EXPORT.tableCellW));
1024
- const cellInner = w / cols - EXPORT.tableCellPad * 2;
1045
+ const cellInner = Math.max(w / cols - EXPORT.tableCellPad * 2, EXPORT.tableCellMinInner);
1025
1046
  const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
1026
1047
  const h = Math.max(EXPORT.lineHeight, rowLines.reduce((a, b) => a + b, 0) * EXPORT.lineHeight);
1027
1048
  return { w, h };
@@ -1101,14 +1122,15 @@ window.__ModuleLoader__.load({
1101
1122
  const rows = (node.data && node.data.rows) || [];
1102
1123
  const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
1103
1124
  const colW = p.size.w / cols;
1104
- const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), colW - EXPORT.tableCellPad * 2, EXPORT.fontSize - 1).length), 1));
1125
+ const innerW = Math.max(colW - EXPORT.tableCellPad * 2, EXPORT.tableCellMinInner);
1126
+ const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), innerW, EXPORT.fontSize - 1).length), 1));
1105
1127
  const rowH = rowLines.map((n) => n * EXPORT.lineHeight);
1106
1128
  let ry = boxY;
1107
1129
  rows.forEach((row, ri) => {
1108
1130
  row.forEach((cell, ci) => {
1109
1131
  const cx = p.x + ci * colW;
1110
1132
  parts.push(`<rect x="${cx}" y="${ry}" width="${colW}" height="${rowH[ri]}" fill="${ri === 0 ? palette.surfaceCode : "none"}" stroke="${palette.borderSubtle}" stroke-width="1"/>`);
1111
- const cellLines = wrapExportText(stripInlineForExport(cell), colW - EXPORT.tableCellPad * 2, EXPORT.fontSize - 1);
1133
+ const cellLines = wrapExportText(stripInlineForExport(cell), innerW, EXPORT.fontSize - 1);
1112
1134
  cellLines.forEach((ln, li) => {
1113
1135
  const ty = ry + (li + 0.5) * EXPORT.lineHeight + (EXPORT.fontSize - 1) * 0.35;
1114
1136
  parts.push(`<text x="${cx + EXPORT.tableCellPad}" y="${ty.toFixed(1)}" font-size="${EXPORT.fontSize - 1}" font-weight="${ri === 0 ? 600 : 400}" fill="${palette.text}">${escapeXml(ln)}</text>`);
@@ -1132,8 +1154,19 @@ window.__ModuleLoader__.load({
1132
1154
  return { svg: parts.join(""), width, height };
1133
1155
  }
1134
1156
 
1157
+ /** Canvas 分配前的硬上限,避免合法但超长的脑图耗尽浏览器内存。 */
1158
+ function exportCanvasSize(width, height) {
1159
+ const w = Math.max(1, Math.ceil(width));
1160
+ const h = Math.max(1, Math.ceil(height));
1161
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w > EXPORT.maxCanvasDimension || h > EXPORT.maxCanvasDimension || w * h > EXPORT.maxCanvasPixels) {
1162
+ throw new Error("脑图图片过大,请缩小导出范围后重试");
1163
+ }
1164
+ return { width: w, height: h };
1165
+ }
1166
+
1135
1167
  /** SVG → Image → 白底 canvas(下载 / 剪贴板共用,017 抽出)。 */
1136
1168
  async function renderSvgToCanvas(svg, width, height) {
1169
+ const size = exportCanvasSize(width, height);
1137
1170
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
1138
1171
  const img = new Image();
1139
1172
  await new Promise((resolve, reject) => {
@@ -1142,8 +1175,8 @@ window.__ModuleLoader__.load({
1142
1175
  img.src = url;
1143
1176
  });
1144
1177
  const canvas = document.createElement("canvas");
1145
- canvas.width = Math.max(1, Math.ceil(width));
1146
- canvas.height = Math.max(1, Math.ceil(height));
1178
+ canvas.width = size.width;
1179
+ canvas.height = size.height;
1147
1180
  const ctx2d = canvas.getContext("2d");
1148
1181
  ctx2d.fillStyle = "#ffffff";
1149
1182
  ctx2d.fillRect(0, 0, canvas.width, canvas.height);
@@ -1920,7 +1953,7 @@ window.__ModuleLoader__.load({
1920
1953
  // 阈值 4px 以内算「点击」:不写 scroll(手抖不挪画布)、不上抓手光标、
1921
1954
  // 不吞随后的 click——保留点空白取消选中 / 点节点聚焦的既有行为。
1922
1955
  // 触摸(触屏)不劫持——交给原生滚动,保住惯性。
1923
- const PAN = { threshold: 4 };
1956
+ const PAN = { threshold: 4, freeRange: 0.5 };
1924
1957
 
1925
1958
  /** 是否在该指针按下上启动平移。button: 0 左 / 1 中 / 2 右。 */
1926
1959
  function shouldStartPan(button, options) {
@@ -1942,6 +1975,18 @@ window.__ModuleLoader__.load({
1942
1975
  };
1943
1976
  }
1944
1977
 
1978
+ /** 原生滚动到边缘后,用视口半宽/高的有界位移继续保持内容跟手。 */
1979
+ function freePanOffset(start, dx, dy, scrollLeft, scrollTop, viewWidth, viewHeight) {
1980
+ const limitX = Number.isFinite(viewWidth) && viewWidth > 0 ? viewWidth * PAN.freeRange : 0;
1981
+ const limitY = Number.isFinite(viewHeight) && viewHeight > 0 ? viewHeight * PAN.freeRange : 0;
1982
+ const actualLeft = Number.isFinite(scrollLeft) ? scrollLeft : start.scrollLeft;
1983
+ const actualTop = Number.isFinite(scrollTop) ? scrollTop : start.scrollTop;
1984
+ return {
1985
+ x: Math.min(limitX, Math.max(-limitX, start.offsetX + dx + actualLeft - start.scrollLeft)),
1986
+ y: Math.min(limitY, Math.max(-limitY, start.offsetY + dy + actualTop - start.scrollTop)),
1987
+ };
1988
+ }
1989
+
1945
1990
  /** 空格键是否落在可输入元素里(聊天框/输入框与面板同 document,不能抢空格)。 */
1946
1991
  function isTextEntry(el) {
1947
1992
  if (!el || typeof el.tagName !== "string") return false;
@@ -2021,6 +2066,9 @@ window.__ModuleLoader__.load({
2021
2066
  // 都要重测一遍(O(n) 强制重排),大树上拖一下会明显顿一下。
2022
2067
  // 光标与「禁用选中」直接改 DOM style,全程零重渲染。
2023
2068
  const panRef = react.useRef(null);
2069
+ // 原生滚动没有余量或已到边缘时的有界补偿位移。同样只写 DOM,
2070
+ // 避免平移触发整棵树重渲染。
2071
+ const panOffsetRef = react.useRef({ x: 0, y: 0 });
2024
2072
  // 021 空格键:按下时左键在节点上也能拖。同样用 ref(按空格不该重渲染)。
2025
2073
  const spaceRef = react.useRef(false);
2026
2074
  // 021 拖过就吞掉随后那次 click(保留单击空白取消选中 / 点节点聚焦)。
@@ -2036,6 +2084,7 @@ window.__ModuleLoader__.load({
2036
2084
  const scroller = scrollRef.current;
2037
2085
  const content = contentRef.current;
2038
2086
  if (!scroller || !content) return;
2087
+ resetPanOffset();
2039
2088
  const rect = content.getBoundingClientRect();
2040
2089
  if (!(rect.width > 0) || !(rect.height > 0)) return;
2041
2090
  const committed = committedZoomRef.current;
@@ -2048,7 +2097,9 @@ window.__ModuleLoader__.load({
2048
2097
  scroller.scrollLeft = 0;
2049
2098
  scroller.scrollTop = 0;
2050
2099
  setZoomState(fit);
2100
+ return true;
2051
2101
  }
2102
+ return false;
2052
2103
  }
2053
2104
 
2054
2105
  // 挂载 / 文档切换:清除「用户已手动缩放」标记与熔断计数,
@@ -2082,6 +2133,7 @@ window.__ModuleLoader__.load({
2082
2133
  if (Math.abs(w - last.w) <= 2 && Math.abs(h - last.h) <= 2) return;
2083
2134
  }
2084
2135
  }
2136
+ if (!applyFit()) return;
2085
2137
  const now = Date.now();
2086
2138
  const stamps = fitStampRef.current = fitStampRef.current.filter((t) => now - t < 1500);
2087
2139
  if (stamps.length >= 5) {
@@ -2089,7 +2141,6 @@ window.__ModuleLoader__.load({
2089
2141
  return;
2090
2142
  }
2091
2143
  stamps.push(now);
2092
- applyFit();
2093
2144
  });
2094
2145
  observer.observe(content);
2095
2146
  observer.observe(scroller);
@@ -2133,6 +2184,18 @@ window.__ModuleLoader__.load({
2133
2184
  scroller.style.userSelect = active ? "none" : "";
2134
2185
  }
2135
2186
 
2187
+ function resetPanOffset() {
2188
+ panOffsetRef.current = { x: 0, y: 0 };
2189
+ const content = contentRef.current;
2190
+ if (content) content.style.transform = "";
2191
+ }
2192
+
2193
+ function applyPanOffset(offset) {
2194
+ const content = contentRef.current;
2195
+ if (!content) return;
2196
+ content.style.transform = offset.x || offset.y ? `translate(${offset.x}px, ${offset.y}px)` : "";
2197
+ }
2198
+
2136
2199
  function beginPan(e) {
2137
2200
  // 吞 click 的标记一律在本轮手势的最开头清零,放在所有早退之前:
2138
2201
  // 否则上轮手势留下的 true 会粘到下一次点击上——例:拖完画布后再
@@ -2151,6 +2214,8 @@ window.__ModuleLoader__.load({
2151
2214
  y: e.clientY,
2152
2215
  scrollLeft: scroller.scrollLeft,
2153
2216
  scrollTop: scroller.scrollTop,
2217
+ offsetX: panOffsetRef.current.x,
2218
+ offsetY: panOffsetRef.current.y,
2154
2219
  moved: false,
2155
2220
  };
2156
2221
  // 不在按下就上抓手光标:普通点击(位移 < 阈值)不该闪一下
@@ -2180,9 +2245,13 @@ window.__ModuleLoader__.load({
2180
2245
  // 首次越过阈值:这才算拖拽——上抓手光标 + 锁文本选择。
2181
2246
  if (!pan.moved) applyPanCursor(scroller, true);
2182
2247
  if (step.moved) pan.moved = true;
2183
- // 越界交给浏览器钳制(不自己算 scrollWidth,省一次重排)。
2248
+ // 先交给浏览器钳制;到边缘后把未被 scroll 消耗的位移补到内容
2249
+ // transform,上下左右始终可拖,且不读 scrollWidth 造成强制重排。
2184
2250
  scroller.scrollLeft = step.scrollLeft;
2185
2251
  scroller.scrollTop = step.scrollTop;
2252
+ const offset = freePanOffset(pan, e.clientX - pan.x, e.clientY - pan.y, scroller.scrollLeft, scroller.scrollTop, scroller.clientWidth, scroller.clientHeight);
2253
+ panOffsetRef.current = offset;
2254
+ applyPanOffset(offset);
2186
2255
  }
2187
2256
 
2188
2257
  function endPan() {
@@ -2204,7 +2273,7 @@ window.__ModuleLoader__.load({
2204
2273
  function setZoom(next, anchorViewport) {
2205
2274
  const value = clampZoom(next);
2206
2275
  anchorRef.current = anchorViewport && scrollRef.current
2207
- ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop }
2276
+ ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop, offsetX: panOffsetRef.current.x, offsetY: panOffsetRef.current.y }
2208
2277
  : null;
2209
2278
  zoomRef.current = value;
2210
2279
  setZoomState(value);
@@ -2225,8 +2294,8 @@ window.__ModuleLoader__.load({
2225
2294
  anchorRef.current = null;
2226
2295
  const ratio = anchor.prevZoom > 0 ? zoom / anchor.prevZoom : 1;
2227
2296
  if (!(ratio > 0) || ratio === 1) return;
2228
- scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2) * ratio - scroller.clientWidth / 2;
2229
- scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2) * ratio - scroller.clientHeight / 2;
2297
+ scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2 - anchor.offsetX) * ratio + anchor.offsetX - scroller.clientWidth / 2;
2298
+ scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2 - anchor.offsetY) * ratio + anchor.offsetY - scroller.clientHeight / 2;
2230
2299
  }, [zoom]);
2231
2300
 
2232
2301
  function zoomIn() {
@@ -2291,6 +2360,7 @@ window.__ModuleLoader__.load({
2291
2360
  focusRef.current = null;
2292
2361
  const scroller = scrollRef.current;
2293
2362
  if (!scroller || !focus || !focus.boxEl || !focus.boxEl.isConnected) return;
2363
+ resetPanOffset();
2294
2364
  const boxRect = focus.boxEl.getBoundingClientRect();
2295
2365
  const scrollerRect = scroller.getBoundingClientRect();
2296
2366
  const curX = boxRect.left + boxRect.width / 2 - scrollerRect.left;
@@ -2618,6 +2688,8 @@ window.__ModuleLoader__.load({
2618
2688
  const [filledHint, setFilledHint] = react.useState("");
2619
2689
  // fsTree:nodes = {path → 节点}, expanded = {path → true}, loading = {path → true}。
2620
2690
  const [fsTree, setFsTree] = react.useState({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
2691
+ // 会话切换时递增,使旧请求的异步回包不能写入新会话的目录树。
2692
+ const treeGenerationRef = react.useRef(0);
2621
2693
  // 013 右键菜单:{x, y, kind: "root"|"dir", rel};null = 关闭。
2622
2694
  const [treeMenu, setTreeMenu] = react.useState(null);
2623
2695
  // tab 右键菜单:{x, y, path}(path === TREE_TAB 时是「刷新目录树」)。
@@ -2744,10 +2816,24 @@ window.__ModuleLoader__.load({
2744
2816
  }, [merged]);
2745
2817
 
2746
2818
  // 013「所见即所编」焦点同步:AI 焦点 = 快照里最新工具结果的文档路径;
2747
- // 脑图视图激活且其文档 ≠ 焦点时,自动填「用 mindmap_open 打开 <它>」
2748
- // 并发送,让 AI 跟上用户眼睛看的那颗脑图。
2819
+ // 脑图视图激活且其文档 ≠ 焦点时,仅在草稿为空时自动发送,让 AI
2820
+ // 跟上用户眼睛看的那颗脑图,又不覆盖用户正在编辑的消息。
2749
2821
  const focusPath = docs.order.length > 0 ? docs.order[docs.order.length - 1] : null;
2750
2822
  const focusSentRef = react.useRef(null);
2823
+ // 所有这些状态都属于会话,不得让 A 会话的在途打开/目录结果遗留到 B。
2824
+ react.useEffect(() => {
2825
+ setLocalDocs({});
2826
+ setCurrentPath(null);
2827
+ setHiddenPath(null);
2828
+ setView("tree");
2829
+ setOpenTimedOut(false);
2830
+ setTreeMenu(null);
2831
+ setTabMenu(null);
2832
+ setFilledHint("");
2833
+ localErrorBaseRef.current = null;
2834
+ focusSentRef.current = null;
2835
+ prevIdsRef.current = { path: null, ids: null };
2836
+ }, [sessionId]);
2751
2837
  react.useEffect(() => {
2752
2838
  if (!open) return; // 面板收起时不自动发消息(014 overlay 形态守卫)
2753
2839
  if (!sessionId) return;
@@ -2755,14 +2841,9 @@ window.__ModuleLoader__.load({
2755
2841
  if (!docs.byPath[active]) return; // 本地占位:它的 open 请求已在途
2756
2842
  if (focusPath === active) return;
2757
2843
  if (focusSentRef.current === active) return; // 已发过,等 AI 结果追平
2758
- if (!inputActions || typeof inputActions.setDraft !== "function") return;
2759
2844
  const rel = fsTree.cwd ? relPathWithin(fsTree.cwd, active, stemOf(active)) : active;
2760
- try {
2761
- inputActions.setDraft(`用 mindmap_open 打开 ${rel}`);
2762
- if (typeof inputActions.submit === "function") inputActions.submit();
2845
+ if (submitEmptyDraft(`用 mindmap_open 打开 ${rel}`)) {
2763
2846
  focusSentRef.current = active;
2764
- } catch {
2765
- // 发送失败:下次 active/focus 变化会再试;也可手动在聊天里说。
2766
2847
  }
2767
2848
  }, [active, focusPath, fsTree.cwd, docs, open]);
2768
2849
 
@@ -2780,11 +2861,32 @@ window.__ModuleLoader__.load({
2780
2861
  }
2781
2862
 
2782
2863
  //#region 013 目录树 tab:懒加载树 + 把指令填进聊天输入框
2783
- // 主路径 = inputActions.setDraft(官方公共面,整串替换草稿);
2784
- // 无则降级剪贴板复制 + 面板内提示。
2864
+ // 只有宿主能确认草稿为空时才允许替换;未知/非空均保守放行给用户,
2865
+ // 不让目录打开或焦点同步静默丢失正在编辑的消息。
2866
+ function draftIsEmpty() {
2867
+ if (!inputActions || typeof inputActions.getDraft !== "function") return false;
2868
+ try {
2869
+ return !String(inputActions.getDraft() ?? "").trim();
2870
+ } catch {
2871
+ return false;
2872
+ }
2873
+ }
2874
+
2875
+ function submitEmptyDraft(text) {
2876
+ if (!draftIsEmpty() || typeof inputActions.setDraft !== "function" || typeof inputActions.submit !== "function") return false;
2877
+ try {
2878
+ inputActions.setDraft(text);
2879
+ inputActions.submit();
2880
+ return true;
2881
+ } catch {
2882
+ return false;
2883
+ }
2884
+ }
2885
+
2886
+ // 手动新建指令也不能覆盖草稿;空草稿时填入,否则复制到剪贴板。
2785
2887
  function fillDraft(text) {
2786
2888
  try {
2787
- if (inputActions && typeof inputActions.setDraft === "function") {
2889
+ if (draftIsEmpty() && typeof inputActions.setDraft === "function") {
2788
2890
  inputActions.setDraft(text);
2789
2891
  setFilledHint("指令已填入聊天输入框");
2790
2892
  return;
@@ -2817,10 +2919,14 @@ window.__ModuleLoader__.load({
2817
2919
  // 就位——AI 工具结果到达后同 path 覆盖占位,节点才渲染;随后用户接着
2818
2920
  // 说即可继续编辑(002 数据流不变:内容只来自 AI 工具结果)。
2819
2921
  function openMindmap(entry) {
2922
+ const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
2923
+ if (!submitEmptyDraft(text)) {
2924
+ setFilledHint("为保护未发送草稿,未自动打开。请发送或清空草稿后重试");
2925
+ return;
2926
+ }
2820
2927
  // 016:记录点击时刻的错误基线(errorByPath 与 latestError 的全部
2821
2928
  // 事件键)——只有其后新出现的错误才归因本次打开,旧错误不打扰。
2822
2929
  localErrorBaseRef.current = errorEventKeys(merged);
2823
- const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
2824
2930
  // ① 本地占位:脑图 tab 立即切过去、内容为空(op:"local" 触发加载态);
2825
2931
  // 新打开的脑图替换旧的那颗(单脑图模式)。
2826
2932
  setHiddenPath(null);
@@ -2837,31 +2943,9 @@ window.__ModuleLoader__.load({
2837
2943
  renamedFrom: null,
2838
2944
  },
2839
2945
  }));
2840
- // ② AI 就位:填指令并直接提交(失败降级剪贴板)。
2841
- let sent = false;
2842
- if (inputActions && typeof inputActions.setDraft === "function") {
2843
- try {
2844
- inputActions.setDraft(text);
2845
- if (typeof inputActions.submit === "function") {
2846
- inputActions.submit();
2847
- sent = true;
2848
- }
2849
- } catch {
2850
- // 落剪贴板降级
2851
- }
2852
- }
2853
- if (sent) {
2854
- // 标记已发,避免焦点同步 effect 对同一路径重复发送。
2855
- focusSentRef.current = entry.path;
2856
- setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
2857
- return;
2858
- }
2859
- if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
2860
- navigator.clipboard.writeText(text).catch(() => {});
2861
- setFilledHint("已复制指令到剪贴板,请粘贴到聊天输入框");
2862
- return;
2863
- }
2864
- setFilledHint(text);
2946
+ // 指令已在占位前提交;标记避免焦点同步对同一路径重复发送。
2947
+ focusSentRef.current = entry.path;
2948
+ setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
2865
2949
  }
2866
2950
 
2867
2951
  /** 016 加载态恢复:错误/超时后重试——重发打开指令并重启看门狗。 */
@@ -2876,11 +2960,13 @@ window.__ModuleLoader__.load({
2876
2960
  // 只读;返回 {path, cwd, entries:[{name,path,isDir,hidden}], truncated}。
2877
2961
  // 返回 true/false 供调用方决定是否标记展开(失败时不要把目录标成已展开)。
2878
2962
  async function loadTree(path) {
2963
+ const generation = treeGenerationRef.current;
2879
2964
  const key = path === undefined || path === null ? "" : path;
2880
2965
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: true }, error: null }));
2881
2966
  try {
2882
2967
  if (!mindmapFace || typeof mindmapFace.listTree !== "function") throw new Error("目录树能力不可用");
2883
2968
  const listing = await mindmapFace.listTree(sessionId, typeof path === "string" && path ? path : undefined);
2969
+ if (generation !== treeGenerationRef.current) return false;
2884
2970
  setFsTree((prev) => {
2885
2971
  const nodes = { ...prev.nodes };
2886
2972
  nodes[listing.path] = {
@@ -2903,6 +2989,7 @@ window.__ModuleLoader__.load({
2903
2989
  });
2904
2990
  return true;
2905
2991
  } catch (error) {
2992
+ if (generation !== treeGenerationRef.current) return false;
2906
2993
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: false }, error: String(error?.message ?? error) }));
2907
2994
  return false;
2908
2995
  }
@@ -2940,6 +3027,7 @@ window.__ModuleLoader__.load({
2940
3027
 
2941
3028
  // 首次挂载:拉根目录(会话 cwd)。
2942
3029
  react.useEffect(() => {
3030
+ treeGenerationRef.current += 1;
2943
3031
  if (!sessionId) return;
2944
3032
  setFsTree({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
2945
3033
  loadTree(undefined);
@@ -3293,7 +3381,12 @@ window.__ModuleLoader__.load({
3293
3381
  * namespaces 兜底,两代通吃。
3294
3382
  */
3295
3383
  function settingsNamespacesOf(res) {
3296
- const value = res?.result?.value;
3384
+ // connection 旧代理包一层 result.value;新版远端直接返回描述符数组。
3385
+ const value = res?.ok === true
3386
+ ? res.value
3387
+ : Array.isArray(res) || Array.isArray(res?.value)
3388
+ ? (Array.isArray(res) ? res : res.value)
3389
+ : res?.result?.value;
3297
3390
  if (Array.isArray(value)) return value;
3298
3391
  const list = value?.namespaces;
3299
3392
  return Array.isArray(list) ? list : [];
@@ -3353,22 +3446,56 @@ window.__ModuleLoader__.load({
3353
3446
  return parsed.value;
3354
3447
  };
3355
3448
 
3356
- // 015 设置面板:settings namespace(dsh-grafana 同款读写面)。
3357
- // connection ctx.get 可选查取(动态 ctx 契约);缺失时设置面板降级提示。
3358
- const connection = ctx.get("connection");
3359
- const settingsApi = connection && typeof connection.api === "object" ? connection.api : null;
3449
+ // 015 设置面板:connection/remote 在客户端插件启动后才可能就绪,不能在
3450
+ // apply 时捕获一次 undefined;每次读写前重新查取,服务晚到也能恢复。
3451
+ // 未声明 inject 的服务只能整名走 ctx.get 可选查取。带点号的服务
3452
+ // (remote.settings)同样是独立服务名:先取 remote 再读 .settings
3453
+ // 被守卫拒绝(cannot get property "remote.settings" without inject),
3454
+ // 故一律传全名,并对任何守卫异常降级为「服务不可用」。
3455
+ function serviceOf(name) {
3456
+ if (typeof ctx.get !== "function") return null;
3457
+ try {
3458
+ return ctx.get(name) ?? null;
3459
+ } catch {
3460
+ return null;
3461
+ }
3462
+ }
3463
+ function isSettingsApi(value) {
3464
+ try {
3465
+ return Boolean(value) && typeof value.describe === "function" && typeof value.update === "function";
3466
+ } catch {
3467
+ return false;
3468
+ }
3469
+ }
3470
+ function settingsApiOf() {
3471
+ const remote = serviceOf("remote.settings");
3472
+ if (isSettingsApi(remote)) return { kind: "remote", settings: remote };
3473
+ try {
3474
+ const settings = serviceOf("connection")?.api?.settings;
3475
+ if (isSettingsApi(settings)) return { kind: "connection", settings };
3476
+ } catch {
3477
+ // 守卫拒绝或连接形态异常:与服务缺失同样降级处理。
3478
+ }
3479
+ return null;
3480
+ }
3360
3481
  face.readSettings = async () => {
3361
- if (!settingsApi || typeof settingsApi.settings?.describe !== "function") return null;
3362
- const res = await settingsApi.settings.describe({});
3482
+ const api = settingsApiOf();
3483
+ if (!api) return null;
3484
+ const res = api.kind === "remote" ? await api.settings.describe() : await api.settings.describe({});
3363
3485
  const namespaces = settingsNamespacesOf(res);
3364
3486
  const ns = namespaces.find((n) => n?.ns === "mindmap");
3365
3487
  return ns?.value ?? null;
3366
3488
  };
3367
3489
  face.updateSettings = async (patch) => {
3368
- if (!settingsApi || typeof settingsApi.settings?.update !== "function") {
3490
+ const api = settingsApiOf();
3491
+ if (!api) {
3369
3492
  throw new Error("settings service unavailable");
3370
3493
  }
3371
- await settingsApi.settings.update({ ns: "mindmap", patch });
3494
+ if (api.kind === "remote" || api.settings.update.length !== 1) {
3495
+ await api.settings.update("mindmap", patch, undefined);
3496
+ } else {
3497
+ await api.settings.update({ ns: "mindmap", patch });
3498
+ }
3372
3499
  };
3373
3500
 
3374
3501
  // 015 设置面板:settings.section(list 槽、root scope)——设置页左栏
@@ -3410,6 +3537,8 @@ window.__ModuleLoader__.load({
3410
3537
  resultTextOfBlocks,
3411
3538
  stemOf,
3412
3539
  buildExportSvg,
3540
+ measureExportBox,
3541
+ exportCanvasSize,
3413
3542
  createIdFactory,
3414
3543
  collectTreeIds,
3415
3544
  planGrowthReveal,
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.'),
@@ -35,6 +36,7 @@ export const Config = Schema.object({
35
36
  })
36
37
 
37
38
  const MAX_CONTENT_BYTES = 2 * 1024 * 1024
39
+ const MAX_READ_BYTES = 2 * 1024 * 1024
38
40
  const MAX_NAME_CHARS = 80
39
41
  const TOOL_TIMEOUT_MS = 15_000
40
42
  const MAX_TREE_ENTRIES = 500
@@ -50,7 +52,7 @@ Tools:
50
52
  - mindmap_get(path): read the current markdown content.
51
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.
52
54
 
53
- 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.
54
56
 
55
57
  Behavior rules:
56
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.
@@ -244,17 +246,14 @@ function sanitizeStem(input) {
244
246
 
245
247
  /**
246
248
  * 解析脑图文件路径:相对路径以会话 cwd 为基;结果必须落在 cwd 内(决策 1),
247
- * 且必须以 .md 结尾。cwd 缺失时仅接受绝对路径。
249
+ * 且必须以 .md 结尾。cwd 是文件授权边界,缺失时必须失败关闭。
248
250
  * @returns 绝对规范化路径。
249
251
  */
250
252
  async function resolveMindmapPath(cwd, input) {
251
253
  if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
252
254
  const p = input.trim()
253
255
  if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
254
- if (!cwd) {
255
- if (!isAbsolute(p)) throw new Error('The session has no working directory; pass an absolute .md path.')
256
- return resolvePath(p)
257
- }
256
+ if (!cwd) throw new Error('The session has no working directory; cannot access a mindmap.')
258
257
  const resolved = resolvePath(cwd, p)
259
258
  const rel = relative(cwd, resolved)
260
259
  // realpath 兜底(#5):写路径经符号链接越狱是安全敏感操作。
@@ -291,6 +290,56 @@ function byteLength(value) {
291
290
  return new TextEncoder().encode(value).byteLength
292
291
  }
293
292
 
293
+ /**
294
+ * mindmap_open / mindmap_get 共用的有界读取。句柄打开后持续分块读取,文件在
295
+ * 初检后增长也不会绕过上限;非常规文件直接拒绝。
296
+ */
297
+ async function readMindmap(path) {
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(() => {})
340
+ }
341
+ }
342
+
294
343
  /** 工具结果信封:client 面板与模型共用的唯一载体。 */
295
344
  function buildResult(op, path, extra = {}) {
296
345
  const base = String(path ?? '').split(/[\\/]/).pop() || 'mindmap'
@@ -307,7 +356,7 @@ export function apply(ctx, config = {}) {
307
356
  // 入口配置作为 settings 组合层的 base:视觉三件套与默认宽度在这里
308
357
  // 透传(带 schema 同款默认值),用户设置层仍可在设置面板覆盖。
309
358
  const entryConfig = {
310
- requireApproval: config.requireApproval === true,
359
+ requireApproval: config.requireApproval !== false,
311
360
  defaultPanelWidth: typeof config.defaultPanelWidth === 'number' ? config.defaultPanelWidth : 42,
312
361
  lineStyle: config.lineStyle === 'curve' ? 'curve' : 'elbow',
313
362
  cardStyle: config.cardStyle === 'square' ? 'square' : 'rounded',
@@ -331,7 +380,7 @@ export function apply(ctx, config = {}) {
331
380
  ctx.systemPrompt.section({ name: 'tool:mindmap', order: 106, text: GUIDANCE })
332
381
 
333
382
  // 后悔药开关(决策 6):钩子常驻注册,运行时读 activeConfig().requireApproval
334
- // ——设置面板切换立即生效;关闭时直接放行(默认 false 免审批)。
383
+ // ——设置面板切换立即生效;仅明确关闭时直接放行。
335
384
  // 022:create 同为写路径,一并纳入审批(CONTRIBUTING:所有写路径安全敏感)。
336
385
  ctx.on('tools/pre-execute', async (exec, next) => {
337
386
  const decision = await next()
@@ -398,7 +447,7 @@ export function apply(ctx, config = {}) {
398
447
  timeoutMs: TOOL_TIMEOUT_MS,
399
448
  async execute(args, exec) {
400
449
  const path = await resolveMindmapPath(sessionCwd(exec, ctx.sessions), args?.path)
401
- const content = await readFile(path, 'utf8')
450
+ const content = await readMindmap(path)
402
451
  return buildResult('open', path, { content })
403
452
  },
404
453
  }))
@@ -417,7 +466,7 @@ export function apply(ctx, config = {}) {
417
466
  timeoutMs: TOOL_TIMEOUT_MS,
418
467
  async execute(args, exec) {
419
468
  const path = await resolveMindmapPath(sessionCwd(exec, ctx.sessions), args?.path)
420
- const content = await readFile(path, 'utf8')
469
+ const content = await readMindmap(path)
421
470
  return buildResult('get', path, { content })
422
471
  },
423
472
  }))
@@ -466,8 +515,8 @@ export function apply(ctx, config = {}) {
466
515
  finalPath = target
467
516
  }
468
517
  }
469
- if (hasContent) await writeFile(finalPath, args.content, 'utf8')
470
- 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)
471
520
  return buildResult('update', finalPath, { content, ...(renamedFrom ? { renamedFrom } : {}) })
472
521
  },
473
522
  }))
@@ -525,6 +574,9 @@ export function apply(ctx, config = {}) {
525
574
  export const internals = Object.freeze({
526
575
  GUIDANCE,
527
576
  MAX_CONTENT_BYTES,
577
+ MAX_READ_BYTES,
578
+ readMindmap,
579
+ writeMindmap,
528
580
  sanitizeStem,
529
581
  resolveMindmapPath,
530
582
  sessionCwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.8.1",
3
+ "version": "0.9.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