dsh-mindmap 0.5.1 → 0.7.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 +7 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/client.js +299 -52
- package/index.js +57 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,7 @@ All notable changes to this project are documented here. Release-specific notes
|
|
|
13
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
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
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.
|
|
16
17
|
|
|
17
18
|
### Changed
|
|
18
19
|
|
|
@@ -24,6 +25,12 @@ All notable changes to this project are documented here. Release-specific notes
|
|
|
24
25
|
|
|
25
26
|
- 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.
|
|
26
27
|
- 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.
|
|
28
|
+
- A code-review sweep (`_issues/002`) landed 11 fixes, each with a failing regression test first:
|
|
29
|
+
- **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 (`\\|`).
|
|
30
|
+
- **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.
|
|
31
|
+
- **Link rendering**: the bare-URL pattern no longer swallows CJK punctuation/full-width characters (`https://a.com,然后…` stops at the comma); `[text](url)` and bare links render as plain text unless the scheme is http/https/mailto (blocking `javascript:`/`data:`), and `openLink` calls `preventDefault` only when `window.open` actually succeeds, so host-blocked popups fall back to default navigation instead of a dead click.
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
27
34
|
|
|
28
35
|
## [0.4.1] - 2026-08-24
|
|
29
36
|
|
package/README.md
CHANGED
|
@@ -23,9 +23,9 @@ 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 as node notes, stable structural IDs, and orthogonal connector lines between nodes.
|
|
26
|
-
- **Centered canvas with zoom** — 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.
|
|
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.
|
|
27
27
|
- **PNG export** — one click on 导出图片 exports the current mindmap.
|
|
28
|
-
- **Safety** — `mindmap_update`
|
|
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
29
|
|
|
30
30
|
## Requirements
|
|
31
31
|
|
package/README.zh-CN.md
CHANGED
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
- **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
|
|
24
24
|
- **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
|
|
25
25
|
- **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落挂节点说明、稳定结构 ID、节点间直角折线。
|
|
26
|
-
-
|
|
26
|
+
- **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
|
|
27
27
|
- **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
|
|
28
|
-
- **安全**——`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval`
|
|
28
|
+
- **安全**——`mindmap_create`/`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药(开启后两个写工具都走审批);客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
|
|
29
29
|
|
|
30
30
|
## 环境要求
|
|
31
31
|
|
package/client.js
CHANGED
|
@@ -287,11 +287,12 @@ window.__ModuleLoader__.load({
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
/** 表格行 → 单元格数组(去首尾空段,保留中间空单元格)。
|
|
290
|
-
* 支持 GFM 转义:`\|`
|
|
290
|
+
* 支持 GFM 转义:`\|` 是字面竖线(占位符避位,剥标记后还原),不切单元格;
|
|
291
|
+
* `\\|` 则是已转义的反斜杠 + 真分隔符,照常切开。 */
|
|
291
292
|
function parseTableRow(line) {
|
|
292
293
|
return String(line ?? "").trim()
|
|
293
294
|
.replace(/^\|/, "").replace(/\|$/, "")
|
|
294
|
-
.replace(
|
|
295
|
+
.replace(/(?<!\\)((?:\\\\)*)\\\|/g, "$1\u0000")
|
|
295
296
|
.split("|")
|
|
296
297
|
.map((c) => c.replace(/\u0000/g, "|").trim());
|
|
297
298
|
}
|
|
@@ -333,13 +334,16 @@ window.__ModuleLoader__.load({
|
|
|
333
334
|
data: {},
|
|
334
335
|
};
|
|
335
336
|
// 根标题回声标记:记录文档常以文件名作首行 H1(如 "# 002-spike结论.md"),
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
337
|
+
// 而根节点标题就是文件名——首个顶层 H1 与根标题一致(或仅多 .md 后缀)
|
|
338
|
+
// 时并入根节点,避免标题显示两次。只有顶层 H1 参与回声:H2 等低级标题
|
|
339
|
+
// 不消耗名额,引用块递归(echoRoot=false)也不触碰本标记。
|
|
340
|
+
let firstTopH1Seen = false;
|
|
339
341
|
const lines = String(markdown ?? "").split(/\r?\n/);
|
|
340
342
|
|
|
341
343
|
let start = 0;
|
|
342
|
-
// 跳过 YAML frontmatter(--- ...
|
|
344
|
+
// 跳过 YAML frontmatter(--- ... ---):找不到闭合行说明不是
|
|
345
|
+
// frontmatter(如以水平线开头的合法文档),回退普通解析,
|
|
346
|
+
// 否则整篇会被吞成空树。
|
|
343
347
|
if (lines.length > 0 && /^\s*---\s*$/.test(lines[0])) {
|
|
344
348
|
for (start = 1; start < lines.length; start++) {
|
|
345
349
|
if (/^\s*---\s*$/.test(lines[start])) {
|
|
@@ -347,6 +351,7 @@ window.__ModuleLoader__.load({
|
|
|
347
351
|
break;
|
|
348
352
|
}
|
|
349
353
|
}
|
|
354
|
+
if (start >= lines.length) start = 0;
|
|
350
355
|
}
|
|
351
356
|
|
|
352
357
|
/**
|
|
@@ -393,12 +398,16 @@ window.__ModuleLoader__.load({
|
|
|
393
398
|
|
|
394
399
|
// 围栏代码块:整块成为一个叶节点,标题 = [语言] 首行摘要(盒内紧凑,
|
|
395
400
|
// 悬停浮层看全文——003 §5.3;data.code 全量保存)。
|
|
396
|
-
|
|
401
|
+
// GFM:闭合围栏须同字符且不少于开启长度——``` 块里的 ~~~、
|
|
402
|
+
// ```` 块里的 ``` 都是代码内容,不是围栏。
|
|
403
|
+
const fence = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
404
|
+
if (fence) {
|
|
397
405
|
flushParagraph();
|
|
398
406
|
listStack = [];
|
|
399
|
-
const lang = line.trim().slice(
|
|
407
|
+
const lang = line.trim().slice(fence[1].length).trim();
|
|
408
|
+
const fenceClose = new RegExp(`^\\s*${fence[1][0]}{${fence[1].length},}\\s*$`);
|
|
400
409
|
const buf = [];
|
|
401
|
-
for (i += 1; i < lineList.length &&
|
|
410
|
+
for (i += 1; i < lineList.length && !fenceClose.test(lineList[i]); i++) buf.push(lineList[i]);
|
|
402
411
|
const code = buf.join("\n");
|
|
403
412
|
const firstLine = (code.split("\n")[0] || "").trim();
|
|
404
413
|
const summary = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
|
|
@@ -419,12 +428,15 @@ window.__ModuleLoader__.load({
|
|
|
419
428
|
listStack = [];
|
|
420
429
|
const level = heading[1].length;
|
|
421
430
|
const text = heading[2].trim() || "(无标题)";
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
431
|
+
// 首个顶层 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
|
|
432
|
+
// 名额只属于顶层 H1:低级标题先行、引用块内出现标题都不影响回声。
|
|
433
|
+
if (echoRoot && level === 1) {
|
|
434
|
+
if (!firstTopH1Seen && (text === root.topic || text === `${root.topic}.md`)) {
|
|
435
|
+
firstTopH1Seen = true;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
firstTopH1Seen = true;
|
|
426
439
|
}
|
|
427
|
-
firstHeadingSeen = true;
|
|
428
440
|
while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
|
|
429
441
|
const basePath = parentRec() ? parentRec().path : "";
|
|
430
442
|
const node = {
|
|
@@ -475,19 +487,20 @@ window.__ModuleLoader__.load({
|
|
|
475
487
|
flushParagraph();
|
|
476
488
|
listStack = [];
|
|
477
489
|
i = j - 1;
|
|
478
|
-
|
|
479
|
-
//
|
|
480
|
-
|
|
490
|
+
// GFM 对齐契约:列数钉死在分隔行。表头与数据行同等待遇:
|
|
491
|
+
// 少列补空、多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
|
|
492
|
+
const cols = parseTableRow(rows[1]).length;
|
|
481
493
|
const toCols = (cells) => {
|
|
482
|
-
if (cells.length >=
|
|
483
|
-
return cells.concat(new Array(
|
|
494
|
+
if (cells.length >= cols) return cells.slice(0, cols);
|
|
495
|
+
return cells.concat(new Array(cols - cells.length).fill(""));
|
|
484
496
|
};
|
|
497
|
+
const header = toCols(parseTableRow(rows[0]));
|
|
485
498
|
const body = rows.slice(2).map((row) => toCols(parseTableRow(row)));
|
|
486
499
|
const tableRows = [header].concat(body);
|
|
487
500
|
appendNode({
|
|
488
501
|
id: idOf("table", tableRows.map((r) => r.join("\u0001")).join("\u0002"), parentPathOf()),
|
|
489
502
|
kind: "table",
|
|
490
|
-
topic: `${tableRows.length}×${
|
|
503
|
+
topic: `${tableRows.length}×${cols} 表格`,
|
|
491
504
|
children: [],
|
|
492
505
|
data: { rows: tableRows },
|
|
493
506
|
});
|
|
@@ -807,7 +820,9 @@ window.__ModuleLoader__.load({
|
|
|
807
820
|
const lower = p.toLowerCase();
|
|
808
821
|
if (snapPaths.some((sp) => sp.toLowerCase() === lower)) dropped.add(p);
|
|
809
822
|
}
|
|
810
|
-
|
|
823
|
+
// 只删本地条目;若该路径同时是存活快照文档
|
|
824
|
+
// (改名后又重建),快照保留,面板照常打开。
|
|
825
|
+
for (const p of dropped) if (!snapByPath[p]) delete byPath[p];
|
|
811
826
|
const order = [...(snapshot?.order ?? [])];
|
|
812
827
|
for (const p of Object.keys(localDocs)) {
|
|
813
828
|
if (!snapByPath[p] && !dropped.has(p)) order.push(p);
|
|
@@ -997,14 +1012,16 @@ window.__ModuleLoader__.load({
|
|
|
997
1012
|
return { text: stripInlineForExport(node.topic), lines: null };
|
|
998
1013
|
}
|
|
999
1014
|
|
|
1000
|
-
/** 019 盒尺寸估算:文本按折行行数生长;表格按行列数算网格尺寸。
|
|
1015
|
+
/** 019 盒尺寸估算:文本按折行行数生长;表格按行列数算网格尺寸。
|
|
1016
|
+
* 022:测量与渲染必须共用钳制后的列宽——先算盒宽再按 w/cols 折行,
|
|
1017
|
+
* 否则宽表(钳到 tableMaxW)按 110px 估行、渲染按更窄列宽折行,盒高不足。 */
|
|
1001
1018
|
function measureExportBox(node) {
|
|
1002
1019
|
if (node.kind === "table") {
|
|
1003
1020
|
const rows = (node.data && node.data.rows) || [];
|
|
1004
1021
|
const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
|
|
1005
|
-
const cellInner = EXPORT.tableCellW - EXPORT.tableCellPad * 2;
|
|
1006
|
-
const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
|
|
1007
1022
|
const w = Math.min(EXPORT.tableMaxW, Math.max(EXPORT.tableMinW, cols * EXPORT.tableCellW));
|
|
1023
|
+
const cellInner = w / cols - EXPORT.tableCellPad * 2;
|
|
1024
|
+
const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
|
|
1008
1025
|
const h = Math.max(EXPORT.lineHeight, rowLines.reduce((a, b) => a + b, 0) * EXPORT.lineHeight);
|
|
1009
1026
|
return { w, h };
|
|
1010
1027
|
}
|
|
@@ -1301,7 +1318,9 @@ window.__ModuleLoader__.load({
|
|
|
1301
1318
|
inlineCode: { fontFamily: "Menlo, monospace", fontSize: "12px", background: "var(--dsw-alias-fill-tsp-secondary)", borderRadius: "4px", padding: "0 3px" },
|
|
1302
1319
|
// 016 脑图画布:滚动区 + 居中层 + 右上角浮动缩放控制条。
|
|
1303
1320
|
canvasWrap: { flex: "1 1 auto", minHeight: 0, minWidth: 0, position: "relative", display: "flex", flexDirection: "column" },
|
|
1304
|
-
|
|
1321
|
+
// 021 平移:空白处抓手光标(节点盒自带 pointer 覆盖);overscroll
|
|
1322
|
+
// contain 让画布滚到边时不把滚动链传给宿主页面(聊天区不跟着动)。
|
|
1323
|
+
canvasScroll: { flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "auto", cursor: "grab", overscrollBehavior: "contain" },
|
|
1305
1324
|
// 居中层:内容小则铺满视口(100%),大则撑到内容尺寸(max-content);
|
|
1306
1325
|
// 子项用 margin:auto——空间充足双向居中,溢出时 margin 归零、从滚动
|
|
1307
1326
|
// 原点排布(flexbox 溢出居中裁剪的标准解法,无左/上侧裁剪)。
|
|
@@ -1539,18 +1558,25 @@ window.__ModuleLoader__.load({
|
|
|
1539
1558
|
//#region 019 血肉渲染:行内格式 + 大一统链接 + 表格块(规范源:003)
|
|
1540
1559
|
// 行内格式统一扫描序:图片/链接 → 行内代码 → 粗体 → 删除线 → 斜体 → 裸链接。
|
|
1541
1560
|
// 先命中先生效,裸链接放最后,避免吞掉已被 [文字](url) 消费的 URL。
|
|
1542
|
-
|
|
1561
|
+
// 裸链接字符类排除 CJK 标点与全角符号(,。、;()……),
|
|
1562
|
+
// 否则中文句读被吞进 URL;ASCII 括号放行,由配平裁剪兜底。
|
|
1563
|
+
const INLINE_PATTERN = /(!?\[[^\]]*\]\([^)]*\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(https?:\/\/[^\s\u3000-\u303f\uff00-\uffef]+)/g;
|
|
1543
1564
|
|
|
1544
1565
|
/** 大一统链接点击:在机器浏览器打开(新标签页),不触发画布聚焦缩放。 */
|
|
1545
1566
|
function openLink(event, url) {
|
|
1546
|
-
event.preventDefault();
|
|
1547
1567
|
event.stopPropagation();
|
|
1568
|
+
// 只在 window.open 成功后 preventDefault:宿主拦截(返回 null 或
|
|
1569
|
+
// 抛错)时不拦,锚点自带的 target=_blank 原生导航接管——链接永远可达。
|
|
1570
|
+
// (旧版 catch 里给只读属性 defaultPrevented 赋值是死代码,拦了默认
|
|
1571
|
+
// 行为又开不了窗,链接彻底点不开。)
|
|
1572
|
+
let opened = null;
|
|
1548
1573
|
try {
|
|
1549
|
-
window.open(url, "_blank", "noopener");
|
|
1574
|
+
opened = window.open(url, "_blank", "noopener");
|
|
1550
1575
|
} catch {
|
|
1551
|
-
|
|
1552
|
-
event.defaultPrevented = false;
|
|
1576
|
+
opened = null;
|
|
1553
1577
|
}
|
|
1578
|
+
if (!opened) return;
|
|
1579
|
+
event.preventDefault();
|
|
1554
1580
|
}
|
|
1555
1581
|
|
|
1556
1582
|
/**
|
|
@@ -1573,20 +1599,26 @@ window.__ModuleLoader__.load({
|
|
|
1573
1599
|
// [文字](url) 或 。图片块暂缓(003 §9):图语法退化为
|
|
1574
1600
|
// 指向原图的链接,同时把 alt 与原图地址都完整呈现(不缩减)。
|
|
1575
1601
|
const parsed = /^(!?)\[([^\]]*)\]\(([^)]*)\)$/.exec(token);
|
|
1576
|
-
//
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1602
|
+
// scheme 白名单:只放行 http/https/mailto。javascript:/data:
|
|
1603
|
+
// 等不进 href,整串原样退化为纯文本(不缩减,也不可执行)。
|
|
1604
|
+
if (!/^\s*(https?:|mailto:)/i.test(parsed[3])) {
|
|
1605
|
+
out.push(token);
|
|
1606
|
+
} else {
|
|
1607
|
+
// 普通链接标签取文字(无文字显地址);图语法带 alt 时两者都完整呈现。
|
|
1608
|
+
const label = parsed[1]
|
|
1609
|
+
? (parsed[2] ? `${parsed[2]} (${parsed[3]})` : parsed[3])
|
|
1610
|
+
: (parsed[2] || parsed[3]);
|
|
1611
|
+
out.push((0, react_jsx_runtime.jsx)("a", {
|
|
1612
|
+
key,
|
|
1613
|
+
href: parsed[3],
|
|
1614
|
+
target: "_blank",
|
|
1615
|
+
rel: "noopener noreferrer",
|
|
1616
|
+
style: S.inlineLink,
|
|
1617
|
+
title: parsed[3],
|
|
1618
|
+
onClick: (e) => openLink(e, parsed[3]),
|
|
1619
|
+
children: label,
|
|
1620
|
+
}, key));
|
|
1621
|
+
}
|
|
1590
1622
|
} else if (m[2]) {
|
|
1591
1623
|
out.push((0, react_jsx_runtime.jsx)("code", { key, style: S.inlineCode, children: token.slice(1, -1) }, key));
|
|
1592
1624
|
} else if (m[3]) {
|
|
@@ -1596,17 +1628,30 @@ window.__ModuleLoader__.load({
|
|
|
1596
1628
|
} else if (m[5]) {
|
|
1597
1629
|
out.push((0, react_jsx_runtime.jsx)("em", { key, children: token.slice(1, -1) }, key));
|
|
1598
1630
|
} else {
|
|
1599
|
-
//
|
|
1631
|
+
// 裸链接:完整显示、可点击。维基式配平括号属于 URL;未配平的
|
|
1632
|
+
// 尾 ) 退回正文当纯文本——href 干净,可见文本不丢字符。
|
|
1633
|
+
let url = token;
|
|
1634
|
+
let opens = 0;
|
|
1635
|
+
let closes = 0;
|
|
1636
|
+
for (const ch of url) {
|
|
1637
|
+
if (ch === "(") opens += 1;
|
|
1638
|
+
else if (ch === ")") closes += 1;
|
|
1639
|
+
}
|
|
1640
|
+
while (closes > opens && url.endsWith(")")) {
|
|
1641
|
+
url = url.slice(0, -1);
|
|
1642
|
+
closes -= 1;
|
|
1643
|
+
}
|
|
1600
1644
|
out.push((0, react_jsx_runtime.jsx)("a", {
|
|
1601
1645
|
key,
|
|
1602
|
-
href:
|
|
1646
|
+
href: url,
|
|
1603
1647
|
target: "_blank",
|
|
1604
1648
|
rel: "noopener noreferrer",
|
|
1605
1649
|
style: S.inlineLink,
|
|
1606
|
-
title:
|
|
1607
|
-
onClick: (e) => openLink(e,
|
|
1608
|
-
children:
|
|
1650
|
+
title: url,
|
|
1651
|
+
onClick: (e) => openLink(e, url),
|
|
1652
|
+
children: url,
|
|
1609
1653
|
}, key));
|
|
1654
|
+
if (url.length < token.length) out.push(token.slice(url.length));
|
|
1610
1655
|
}
|
|
1611
1656
|
last = m.index + token.length;
|
|
1612
1657
|
}
|
|
@@ -1850,8 +1895,54 @@ window.__ModuleLoader__.load({
|
|
|
1850
1895
|
return clampZoom(Math.min((viewW - ZOOM.padding) / treeW, (viewH - ZOOM.padding) / treeH, ZOOM.focusMax));
|
|
1851
1896
|
}
|
|
1852
1897
|
|
|
1853
|
-
|
|
1854
|
-
|
|
1898
|
+
//#region 021 画布平移:拖拽手势(中键 / 空白处左键 / 空格+左键)
|
|
1899
|
+
// 平移契约(三条手指路径,覆盖鼠标与 Mac 触摸板):
|
|
1900
|
+
// 中键拖动 —— 画布惯例,任何位置都拖(压节点上也拖)
|
|
1901
|
+
// 左键空白处拖动 —— Mac 触摸板「按住拖」= 左键拖拽,走这条
|
|
1902
|
+
// 空格 + 左键拖动 —— 压在节点上也能拖(节点上留文字选区给左键)
|
|
1903
|
+
// 方向:内容跟手(按下点始终贴着指针),故 scroll = 按下时 scroll − 位移。
|
|
1904
|
+
// 阈值 4px 以内算「点击」:不写 scroll(手抖不挪画布)、不上抓手光标、
|
|
1905
|
+
// 不吞随后的 click——保留点空白取消选中 / 点节点聚焦的既有行为。
|
|
1906
|
+
// 触摸(触屏)不劫持——交给原生滚动,保住惯性。
|
|
1907
|
+
const PAN = { threshold: 4 };
|
|
1908
|
+
|
|
1909
|
+
/** 是否在该指针按下上启动平移。button: 0 左 / 1 中 / 2 右。 */
|
|
1910
|
+
function shouldStartPan(button, options) {
|
|
1911
|
+
const { onNode = false, spaceHeld = false, touch = false } = options || {};
|
|
1912
|
+
if (touch) return false;
|
|
1913
|
+
if (button === 1) return true;
|
|
1914
|
+
if (button !== 0) return false;
|
|
1915
|
+
if (spaceHeld) return true;
|
|
1916
|
+
return !onNode;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/** 平移一步:目标 scroll = 按下时 scroll − 位移;moved 表示已越过点击阈值。 */
|
|
1920
|
+
function panScroll(start, dx, dy, threshold) {
|
|
1921
|
+
const limit = Number.isFinite(threshold) ? threshold : PAN.threshold;
|
|
1922
|
+
return {
|
|
1923
|
+
scrollLeft: start.scrollLeft - dx,
|
|
1924
|
+
scrollTop: start.scrollTop - dy,
|
|
1925
|
+
moved: Math.abs(dx) >= limit || Math.abs(dy) >= limit,
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/** 空格键是否落在可输入元素里(聊天框/输入框与面板同 document,不能抢空格)。 */
|
|
1930
|
+
function isTextEntry(el) {
|
|
1931
|
+
if (!el || typeof el.tagName !== "string") return false;
|
|
1932
|
+
const tag = el.tagName.toLowerCase();
|
|
1933
|
+
return tag === "input" || tag === "textarea" || el.isContentEditable === true;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/** 空格键是否落在「空格即激活」的控件上(按钮/链接/自定义控件)。
|
|
1937
|
+
* 这类元素上不能 preventDefault,否则挡掉空格激活。 */
|
|
1938
|
+
function isActivatable(el) {
|
|
1939
|
+
if (!el || typeof el.closest !== "function") return false;
|
|
1940
|
+
return Boolean(el.closest("button, a[href], [role='button'], input, textarea, select, [contenteditable='true']"));
|
|
1941
|
+
}
|
|
1942
|
+
//#endregion
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* 脑图画布:滚动区(canvasScroll)+ 居中层(canvasCenter,100%/max-content
|
|
1855
1946
|
* 双下限)+ 缩放内容(margin:auto + CSS zoom)。zoom 用 CSS zoom 而非
|
|
1856
1947
|
* transform:scale——它影响布局,滚动范围随缩放自动正确;TreeRow 连线是
|
|
1857
1948
|
* getBoundingClientRect 相对测量,父子同因子缩放,几何保持一致。
|
|
@@ -1909,6 +2000,19 @@ window.__ModuleLoader__.load({
|
|
|
1909
2000
|
codePanelTimerRef.current = setTimeout(() => setCodePanel(null), 150);
|
|
1910
2001
|
}
|
|
1911
2002
|
|
|
2003
|
+
// 021 画布平移锚点:本次拖拽按下时的指针与 scroll。刻意不用
|
|
2004
|
+
// state——平移起止若触发重渲染,每个 TreeRow 的 useLayoutEffect
|
|
2005
|
+
// 都要重测一遍(O(n) 强制重排),大树上拖一下会明显顿一下。
|
|
2006
|
+
// 光标与「禁用选中」直接改 DOM style,全程零重渲染。
|
|
2007
|
+
const panRef = react.useRef(null);
|
|
2008
|
+
// 021 空格键:按下时左键在节点上也能拖。同样用 ref(按空格不该重渲染)。
|
|
2009
|
+
const spaceRef = react.useRef(false);
|
|
2010
|
+
// 021 拖过就吞掉随后那次 click(保留单击空白取消选中 / 点节点聚焦)。
|
|
2011
|
+
const suppressClickRef = react.useRef(false);
|
|
2012
|
+
// 021 指针是否悬在画布上(空格键要不要拦默认行为的门控;纯
|
|
2013
|
+
// 读取,不参与渲染)。
|
|
2014
|
+
const hoverRef = react.useRef(false);
|
|
2015
|
+
|
|
1912
2016
|
// 测量并适配:自然尺寸 = getBoundingClientRect ÷ 已提交 zoom(与
|
|
1913
2017
|
// DOM 实际状态严格同步,无竞态)。值不变不动 state(bail-out),
|
|
1914
2018
|
// 值变化才重置滚动到原点让树回到居中;同步记录自然尺寸供防抖。
|
|
@@ -1976,6 +2080,111 @@ window.__ModuleLoader__.load({
|
|
|
1976
2080
|
return () => observer.disconnect();
|
|
1977
2081
|
}, []);
|
|
1978
2082
|
|
|
2083
|
+
// 021 空格键跟踪:指针悬在画布上时按住空格 → 左键可拖画布(画布惯例)。
|
|
2084
|
+
// 输入框/聊天框里按空格不抢;窗口失焦即松开,防「卡在按住态」。
|
|
2085
|
+
// 悬停门控(hoverRef)用于决定要不要拦空格的默认行为——全局拦会把
|
|
2086
|
+
// 聊天区的「空格翻页」一起干掉,只拦「指针在画布上」这一刻才安全。
|
|
2087
|
+
react.useEffect(() => {
|
|
2088
|
+
const isSpace = (e) => e.code === "Space" || e.key === " ";
|
|
2089
|
+
const onKeyDown = (e) => {
|
|
2090
|
+
if (!isSpace(e) || isTextEntry(e.target)) return;
|
|
2091
|
+
spaceRef.current = true;
|
|
2092
|
+
// 悬在画布上、且不在按钮/链接上:掐掉空格滚动宿主页面。
|
|
2093
|
+
if (hoverRef.current && !isActivatable(e.target)) e.preventDefault();
|
|
2094
|
+
};
|
|
2095
|
+
const onKeyUp = (e) => {
|
|
2096
|
+
if (!isSpace(e)) return;
|
|
2097
|
+
spaceRef.current = false;
|
|
2098
|
+
};
|
|
2099
|
+
const onBlur = () => {
|
|
2100
|
+
spaceRef.current = false;
|
|
2101
|
+
};
|
|
2102
|
+
window.addEventListener("keydown", onKeyDown);
|
|
2103
|
+
window.addEventListener("keyup", onKeyUp);
|
|
2104
|
+
window.addEventListener("blur", onBlur);
|
|
2105
|
+
return () => {
|
|
2106
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
2107
|
+
window.removeEventListener("keyup", onKeyUp);
|
|
2108
|
+
window.removeEventListener("blur", onBlur);
|
|
2109
|
+
};
|
|
2110
|
+
}, []);
|
|
2111
|
+
|
|
2112
|
+
// 021 平移三件套:按下记锚点 → 移动按差值写 scroll → 松手收尾。
|
|
2113
|
+
// 用 pointer 事件 + setPointerCapture:指针滑出画布(甚至滑出面板)
|
|
2114
|
+
// 仍跟手,不必在 window 上挂监听。
|
|
2115
|
+
function applyPanCursor(scroller, active) {
|
|
2116
|
+
scroller.style.cursor = active ? "grabbing" : "grab";
|
|
2117
|
+
scroller.style.userSelect = active ? "none" : "";
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function beginPan(e) {
|
|
2121
|
+
// 吞 click 的标记一律在本轮手势的最开头清零,放在所有早退之前:
|
|
2122
|
+
// 否则上轮手势留下的 true 会粘到下一次点击上——例:拖完画布后再
|
|
2123
|
+
// 触摸点按(touch 路径不启动平移就早退了),那次点击会被白吞一次。
|
|
2124
|
+
suppressClickRef.current = false;
|
|
2125
|
+
const scroller = scrollRef.current;
|
|
2126
|
+
if (!scroller || panRef.current) return;
|
|
2127
|
+
const target = e.target;
|
|
2128
|
+
const onNode = Boolean(target && typeof target.closest === "function" && target.closest("[data-mindmap-node]"));
|
|
2129
|
+
if (!shouldStartPan(e.button, { onNode, spaceHeld: spaceRef.current, touch: e.pointerType === "touch" })) return;
|
|
2130
|
+
// 中键:掐掉浏览器自动滚动;左键:掐掉拖选文本。
|
|
2131
|
+
e.preventDefault();
|
|
2132
|
+
panRef.current = {
|
|
2133
|
+
pointerId: e.pointerId,
|
|
2134
|
+
x: e.clientX,
|
|
2135
|
+
y: e.clientY,
|
|
2136
|
+
scrollLeft: scroller.scrollLeft,
|
|
2137
|
+
scrollTop: scroller.scrollTop,
|
|
2138
|
+
moved: false,
|
|
2139
|
+
};
|
|
2140
|
+
// 不在按下就上抓手光标:普通点击(位移 < 阈值)不该闪一下
|
|
2141
|
+
// grabbing、也不该提前锁掉文本选择——等真正越过阈值再上。
|
|
2142
|
+
if (e.pointerId != null && typeof scroller.setPointerCapture === "function") {
|
|
2143
|
+
try { scroller.setPointerCapture(e.pointerId); } catch { /* 捕获失败:退化为元素内拖拽,movePan 有兜底 */ }
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function movePan(e) {
|
|
2148
|
+
const pan = panRef.current;
|
|
2149
|
+
const scroller = scrollRef.current;
|
|
2150
|
+
if (!pan || !scroller) return;
|
|
2151
|
+
// 多指针(触屏/笔)下第二根指头的移动不该驱动第一根指头的锚点。
|
|
2152
|
+
if (e.pointerId != null && pan.pointerId != null && e.pointerId !== pan.pointerId) return;
|
|
2153
|
+
// 兜底:捕获失败退化为元素内拖拽时,指针在滚动区外松手就收不到
|
|
2154
|
+
// pointerup,panRef 会悬挂 → 之后不按键移动也会拖画布。无按键
|
|
2155
|
+
// 还在动说明早已松手,直接收尾(触摸进不了平移,不会误伤)。
|
|
2156
|
+
if (e.buttons === 0) {
|
|
2157
|
+
endPan();
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
const step = panScroll(pan, e.clientX - pan.x, e.clientY - pan.y, PAN.threshold);
|
|
2161
|
+
// 阈值内不写 scroll:点击手抖 1~3px 不该把画布挪走。公式是从按下
|
|
2162
|
+
// 锚点算的绝对值(非增量),跳过早期写入仍保持 1:1 跟手。
|
|
2163
|
+
if (!pan.moved && !step.moved) return;
|
|
2164
|
+
// 首次越过阈值:这才算拖拽——上抓手光标 + 锁文本选择。
|
|
2165
|
+
if (!pan.moved) applyPanCursor(scroller, true);
|
|
2166
|
+
if (step.moved) pan.moved = true;
|
|
2167
|
+
// 越界交给浏览器钳制(不自己算 scrollWidth,省一次重排)。
|
|
2168
|
+
scroller.scrollLeft = step.scrollLeft;
|
|
2169
|
+
scroller.scrollTop = step.scrollTop;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function endPan() {
|
|
2173
|
+
const pan = panRef.current;
|
|
2174
|
+
// 已收尾(pointerup 后浏览器会补发 lostpointercapture)→ 不再动手,
|
|
2175
|
+
// 否则会把 suppressClickRef 清掉、平移后误触发 click。
|
|
2176
|
+
if (!pan) return;
|
|
2177
|
+
panRef.current = null;
|
|
2178
|
+
const scroller = scrollRef.current;
|
|
2179
|
+
if (scroller) {
|
|
2180
|
+
applyPanCursor(scroller, false);
|
|
2181
|
+
if (pan.pointerId != null && typeof scroller.releasePointerCapture === "function") {
|
|
2182
|
+
try { scroller.releasePointerCapture(pan.pointerId); } catch { /* 已释放 */ }
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
suppressClickRef.current = pan.moved;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
1979
2188
|
function setZoom(next, anchorViewport) {
|
|
1980
2189
|
const value = clampZoom(next);
|
|
1981
2190
|
anchorRef.current = anchorViewport && scrollRef.current
|
|
@@ -2025,6 +2234,12 @@ window.__ModuleLoader__.load({
|
|
|
2025
2234
|
// 上限 focusMax)。事件委托:closest 找节点盒与所在子树 row,无需给
|
|
2026
2235
|
// 递归 TreeRow 传回调。聚焦视为用户手动缩放(停自动再适配)。
|
|
2027
2236
|
function onCanvasClick(e) {
|
|
2237
|
+
// 021:刚拖过画布(平移)的这次 click 不是点击,直接吞掉——
|
|
2238
|
+
// 否则每次平移松手都会顺手把选中环清掉。
|
|
2239
|
+
if (suppressClickRef.current) {
|
|
2240
|
+
suppressClickRef.current = false;
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2028
2243
|
const target = e.target;
|
|
2029
2244
|
if (!target || typeof target.closest !== "function") return;
|
|
2030
2245
|
const boxEl = target.closest("[data-mindmap-node]");
|
|
@@ -2135,7 +2350,29 @@ window.__ModuleLoader__.load({
|
|
|
2135
2350
|
// 017 空白处/缩放条右键只拦浏览器默认菜单(节点右键已 stopPropagation)。
|
|
2136
2351
|
onContextMenu: (e) => e.preventDefault(),
|
|
2137
2352
|
children: [
|
|
2138
|
-
(0, react_jsx_runtime.jsx)("div", {
|
|
2353
|
+
(0, react_jsx_runtime.jsx)("div", {
|
|
2354
|
+
ref: scrollRef,
|
|
2355
|
+
style: S.canvasScroll,
|
|
2356
|
+
onClick: onCanvasClick,
|
|
2357
|
+
// 021 画布平移:pointer 三件套 + 捕获,指针滑出画布仍跟手。
|
|
2358
|
+
onPointerDown: beginPan,
|
|
2359
|
+
onPointerMove: movePan,
|
|
2360
|
+
onPointerUp: endPan,
|
|
2361
|
+
// 系统手势打断(如三指滑动)与捕获丢失都走同一收尾。
|
|
2362
|
+
onPointerCancel: endPan,
|
|
2363
|
+
onLostPointerCapture: endPan,
|
|
2364
|
+
// 中键松开的 auxclick:掐掉自动滚动 / 剪贴板粘贴等默认行为。
|
|
2365
|
+
onAuxClick: (e) => {
|
|
2366
|
+
if (e.button === 1) e.preventDefault();
|
|
2367
|
+
},
|
|
2368
|
+
// 021 悬停门控:空格键只在指针悬在画布上时才拦默认行为。
|
|
2369
|
+
onPointerEnter: () => { hoverRef.current = true; },
|
|
2370
|
+
onPointerLeave: () => { hoverRef.current = false; },
|
|
2371
|
+
// 平移中掐掉原生 HTML5 拖拽(拖文字/图片会抢走手势)。
|
|
2372
|
+
onDragStart: (e) => {
|
|
2373
|
+
if (panRef.current) e.preventDefault();
|
|
2374
|
+
},
|
|
2375
|
+
children:
|
|
2139
2376
|
(0, react_jsx_runtime.jsx)("div", { style: S.canvasCenter, children:
|
|
2140
2377
|
(0, react_jsx_runtime.jsx)("div", { ref: contentRef, style: { margin: "auto", zoom }, children:
|
|
2141
2378
|
(0, react_jsx_runtime.jsx)(TreeRow, { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel: handleCodePanel })
|
|
@@ -3158,6 +3395,8 @@ window.__ModuleLoader__.load({
|
|
|
3158
3395
|
parseTableRow,
|
|
3159
3396
|
nodeFullText,
|
|
3160
3397
|
renderInline,
|
|
3398
|
+
// 链接点击:供测试验证开窗成功才拦默认行为(宿主拦截时退回原生导航)。
|
|
3399
|
+
openLink,
|
|
3161
3400
|
stripInlineForExport,
|
|
3162
3401
|
wrapExportText,
|
|
3163
3402
|
COLOR_THEMES,
|
|
@@ -3166,8 +3405,16 @@ window.__ModuleLoader__.load({
|
|
|
3166
3405
|
stepZoom,
|
|
3167
3406
|
fitZoom,
|
|
3168
3407
|
focusZoom,
|
|
3408
|
+
// 021 画布平移手势判定(供测试)。
|
|
3409
|
+
PAN,
|
|
3410
|
+
shouldStartPan,
|
|
3411
|
+
panScroll,
|
|
3412
|
+
isTextEntry,
|
|
3413
|
+
isActivatable,
|
|
3169
3414
|
TOOL_NAMES,
|
|
3170
3415
|
OPENING_OPS,
|
|
3416
|
+
// 021 画布组件:仅供测试驱动平移手势(不参与运行时契约)。
|
|
3417
|
+
MindmapCanvas,
|
|
3171
3418
|
});
|
|
3172
3419
|
return module.exports;
|
|
3173
3420
|
}
|
package/index.js
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
// - 四工具都带 path/name 参数(决策 3:多脑图并存,作用于指定那颗)。
|
|
10
10
|
// - 结果 JSON {ok, op, path, rootTitle, content, renamedFrom?}:content 全文
|
|
11
11
|
// 供模型续编辑,client 用同一份重放面板(工具结果即实时通道,002 第二节)。
|
|
12
|
-
// - requireApproval 配置(决策 6):默认 false 免审批;置 true 时
|
|
13
|
-
// 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
|
|
14
|
-
// namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
|
|
12
|
+
// - requireApproval 配置(决策 6):默认 false 免审批;置 true 时 mindmap_create
|
|
13
|
+
// 与 mindmap_update 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
|
|
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, rename, stat, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { access, opendir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
|
|
18
18
|
import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'
|
|
19
19
|
import Schema from '@deepseek-ai/schemastery'
|
|
20
20
|
|
|
@@ -26,7 +26,7 @@ export const inject = ['tools', 'systemPrompt', 'webServer', 'sessions']
|
|
|
26
26
|
// defaultPanelWidth 供客户端面板取默认宽度(20-80 钳制由客户端执行)。
|
|
27
27
|
export const SETTINGS_NAMESPACE = 'mindmap'
|
|
28
28
|
export const Config = Schema.object({
|
|
29
|
-
requireApproval: Schema.boolean().default(false).description('Require native DSH approval for every mindmap_update (including renameRoot). Files are git-managed, so it defaults to off. Hidden from the settings panel; still honored at runtime.'),
|
|
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
30
|
defaultPanelWidth: Schema.number().default(42).description('Default floating-panel width as a percentage of the viewport (clamped 20-80 on the client).'),
|
|
31
31
|
lineStyle: Schema.union(['curve', 'elbow']).default('elbow').description('Connector line style between nodes: curve (bezier) or elbow (orthogonal).'),
|
|
32
32
|
cardStyle: Schema.union(['rounded', 'square']).default('rounded').description('Node card corner style.'),
|
|
@@ -113,15 +113,44 @@ function escapesBase(rel) {
|
|
|
113
113
|
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
116
|
+
/**
|
|
117
|
+
* realpath 包含性校验(#5):字符串规范化只挡字面 `..`,cwd 内指向外部
|
|
118
|
+
* 的符号链接能骗过它。尾部不存在的段(待建文件)向上走最近的存在祖先
|
|
119
|
+
* 逐个 realpath——符号链接只能藏在已存在的段里。解析后仍在 base 内返回
|
|
120
|
+
* true;base 自身不存在或越界返回 false。
|
|
121
|
+
*/
|
|
122
|
+
async function resolvesInsideBase(resolved, base) {
|
|
123
|
+
let realBase
|
|
124
|
+
try {
|
|
125
|
+
realBase = await realpath(base)
|
|
126
|
+
} catch {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
let probe = resolved
|
|
130
|
+
for (;;) {
|
|
131
|
+
try {
|
|
132
|
+
const real = await realpath(probe)
|
|
133
|
+
if (real === realBase) return true
|
|
134
|
+
return !escapesBase(relative(realBase, real))
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
|
|
137
|
+
const parent = dirname(probe)
|
|
138
|
+
if (parent === probe) return false
|
|
139
|
+
probe = parent
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 请求路径校验:缺省 = 根 cwd;显式路径必须绝对、落在 cwd 内,
|
|
145
|
+
* 且解析符号链接后仍在内(#5)。 */
|
|
146
|
+
async function resolveTreePath(cwd, input) {
|
|
118
147
|
if (!cwd) throw httpError(400, 'no-cwd', 'session has no working directory')
|
|
119
148
|
if (input === undefined || input === null || String(input).trim() === '') return cwd
|
|
120
149
|
const p = String(input).trim()
|
|
121
150
|
if (!isAbsolute(p)) throw httpError(400, 'bad-request', `path must be absolute: ${JSON.stringify(p)}`)
|
|
122
151
|
const resolved = resolvePath(p)
|
|
123
152
|
const rel = relative(cwd, resolved)
|
|
124
|
-
if (escapesBase(rel)) {
|
|
153
|
+
if (escapesBase(rel) || !(await resolvesInsideBase(resolved, cwd))) {
|
|
125
154
|
throw httpError(400, 'bad-request', `path must stay inside the session working directory (${cwd})`)
|
|
126
155
|
}
|
|
127
156
|
return resolved
|
|
@@ -140,8 +169,10 @@ async function listDirectoryLevel(path, maxEntries = MAX_TREE_ENTRIES) {
|
|
|
140
169
|
try {
|
|
141
170
|
for await (const dirent of dir) {
|
|
142
171
|
if (rows.length >= maxEntries) {
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
// 022:到达上限即停(旧实现 continue 会把巨型目录整个遍历一遍)。
|
|
173
|
+
// truncated 只取布尔语义,无需精确计数剩余条目。
|
|
174
|
+
overflow = 1
|
|
175
|
+
break
|
|
145
176
|
}
|
|
146
177
|
rows.push({
|
|
147
178
|
name: dirent.name,
|
|
@@ -204,7 +235,7 @@ function sanitizeStem(input) {
|
|
|
204
235
|
* 且必须以 .md 结尾。cwd 缺失时仅接受绝对路径。
|
|
205
236
|
* @returns 绝对规范化路径。
|
|
206
237
|
*/
|
|
207
|
-
function resolveMindmapPath(cwd, input) {
|
|
238
|
+
async function resolveMindmapPath(cwd, input) {
|
|
208
239
|
if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
|
|
209
240
|
const p = input.trim()
|
|
210
241
|
if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
|
|
@@ -214,7 +245,8 @@ function resolveMindmapPath(cwd, input) {
|
|
|
214
245
|
}
|
|
215
246
|
const resolved = resolvePath(cwd, p)
|
|
216
247
|
const rel = relative(cwd, resolved)
|
|
217
|
-
|
|
248
|
+
// realpath 兜底(#5):写路径经符号链接越狱是安全敏感操作。
|
|
249
|
+
if (rel === '' || escapesBase(rel) || !(await resolvesInsideBase(resolved, cwd))) {
|
|
218
250
|
throw new Error(`mindmap path must stay inside the session working directory (${cwd}).`)
|
|
219
251
|
}
|
|
220
252
|
return resolved
|
|
@@ -288,10 +320,18 @@ export function apply(ctx, config = {}) {
|
|
|
288
320
|
|
|
289
321
|
// 后悔药开关(决策 6):钩子常驻注册,运行时读 activeConfig().requireApproval
|
|
290
322
|
// ——设置面板切换立即生效;关闭时直接放行(默认 false 免审批)。
|
|
323
|
+
// 022:create 同为写路径,一并纳入审批(CONTRIBUTING:所有写路径安全敏感)。
|
|
291
324
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
292
325
|
const decision = await next()
|
|
293
326
|
if (decision.kind !== 'allow') return decision
|
|
294
327
|
if (!activeConfig().requireApproval) return decision
|
|
328
|
+
if (exec.name === 'mindmap_create') {
|
|
329
|
+
const args = exec.arguments ?? {}
|
|
330
|
+
return {
|
|
331
|
+
kind: 'ask',
|
|
332
|
+
reason: `Create mindmap ${JSON.stringify(String(args.name ?? '?'))}. dsh-mindmap is configured with requireApproval.`,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
295
335
|
if (exec.name !== 'mindmap_update') return decision
|
|
296
336
|
const args = exec.arguments ?? {}
|
|
297
337
|
const renameNote = typeof args.renameRoot === 'string' && args.renameRoot ? `, rename root to "${args.renameRoot}"` : ''
|
|
@@ -318,7 +358,7 @@ export function apply(ctx, config = {}) {
|
|
|
318
358
|
const cwd = sessionCwd(exec)
|
|
319
359
|
if (!cwd) throw new Error('The session has no working directory; cannot create a mindmap.')
|
|
320
360
|
const stem = sanitizeStem(args?.name)
|
|
321
|
-
const path = resolveMindmapPath(cwd, `${stem}.md`)
|
|
361
|
+
const path = await resolveMindmapPath(cwd, `${stem}.md`)
|
|
322
362
|
// wx = 不存在才创建:原子拒绝已存在(含并发竞态)与同名目录,无 TOCTOU 窗口。
|
|
323
363
|
try {
|
|
324
364
|
await writeFile(path, '', { encoding: 'utf8', flag: 'wx' })
|
|
@@ -345,7 +385,7 @@ export function apply(ctx, config = {}) {
|
|
|
345
385
|
output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
|
|
346
386
|
timeoutMs: TOOL_TIMEOUT_MS,
|
|
347
387
|
async execute(args, exec) {
|
|
348
|
-
const path = resolveMindmapPath(sessionCwd(exec), args?.path)
|
|
388
|
+
const path = await resolveMindmapPath(sessionCwd(exec), args?.path)
|
|
349
389
|
const content = await readFile(path, 'utf8')
|
|
350
390
|
return buildResult('open', path, { content })
|
|
351
391
|
},
|
|
@@ -364,7 +404,7 @@ export function apply(ctx, config = {}) {
|
|
|
364
404
|
output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
|
|
365
405
|
timeoutMs: TOOL_TIMEOUT_MS,
|
|
366
406
|
async execute(args, exec) {
|
|
367
|
-
const path = resolveMindmapPath(sessionCwd(exec), args?.path)
|
|
407
|
+
const path = await resolveMindmapPath(sessionCwd(exec), args?.path)
|
|
368
408
|
const content = await readFile(path, 'utf8')
|
|
369
409
|
return buildResult('get', path, { content })
|
|
370
410
|
},
|
|
@@ -386,7 +426,7 @@ export function apply(ctx, config = {}) {
|
|
|
386
426
|
timeoutMs: TOOL_TIMEOUT_MS,
|
|
387
427
|
async execute(args, exec) {
|
|
388
428
|
const cwd = sessionCwd(exec)
|
|
389
|
-
const path = resolveMindmapPath(cwd, args?.path)
|
|
429
|
+
const path = await resolveMindmapPath(cwd, args?.path)
|
|
390
430
|
const hasContent = typeof args?.content === 'string'
|
|
391
431
|
if (!hasContent && typeof args?.renameRoot !== 'string') {
|
|
392
432
|
throw new Error('mindmap_update requires content (or renameRoot alone for a pure rename).')
|
|
@@ -453,7 +493,7 @@ export function apply(ctx, config = {}) {
|
|
|
453
493
|
sendJson(res, 400, { ok: false, error: { code: 'no-cwd', message: 'session has no working directory' } })
|
|
454
494
|
return
|
|
455
495
|
}
|
|
456
|
-
const dir = resolveTreePath(cwd, payload.path)
|
|
496
|
+
const dir = await resolveTreePath(cwd, payload.path)
|
|
457
497
|
const listing = await listDirectoryLevel(dir)
|
|
458
498
|
sendJson(res, 200, { ok: true, value: { ...listing, cwd } })
|
|
459
499
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mindmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|