dsh-mindmap 0.1.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/client.js ADDED
@@ -0,0 +1,1383 @@
1
+ // dsh-mindmap —— 浏览器半边(ModuleLoader 单文件模块,无外部依赖)。
2
+ //
3
+ // 职责:
4
+ // - 「思维脑图」按钮:挂 conversation.session.header.actions(list 槽,追加式),
5
+ // 点击切换右侧悬浮脑图面板的开/合;面板宿主层(fixed)与按钮同槽位渲染,
6
+ // 会话能力(useSession/sessionId/inputActions)经 props 直给面板组件。
7
+ // - 脑图面板:014 起注册在 shell.overlay(list 槽、root scope、点击穿透层),
8
+ // 右缘贴边全高悬浮、左缘拖拽调宽(280~80% 视口,localStorage 持久化);
9
+ // details 槽已归还官方(原生「工具详情」栏恢复,003 的顶替方案退役)。
10
+ // - 实时数据通路:消费会话快照(useSession → nodes)里 mindmap_* 工具的
11
+ // ToolResultNode,重放出各文档的最新内容并渲染(002/003:无自定义事件通道,
12
+ // 工具调用本身就是事件流)。
13
+ // - markdown→脑图树:本文件内置零依赖解析器(MarkGrove mdastConverter 的映射
14
+ // 语法移植:标题栈→树、列表→子节点、空列表项=占位节点、代码块→首行摘要叶
15
+ // 节点、段落→挂标题的正文说明、结构路径稳定 ID)。
16
+ // - PNG 导出:树 → SVG → canvas → PNG 下载。
17
+ //
18
+ // 解析器等纯函数经 exports.internals 暴露给 Node 测试(vm 加载本文件,见
19
+ // test/client.test.js)。工具结果 JSON 由 host 半边(index.js)产出。
20
+ window.__ModuleLoader__.load({
21
+ id: "dsh-mindmap",
22
+ factory: (require) => {
23
+ var module = { exports: {} };
24
+ var exports = module.exports;
25
+ let react_jsx_runtime = require("react/jsx-runtime");
26
+ let react = require("react");
27
+
28
+ const inject = ["slots"];
29
+
30
+ // host 半边四个工具名(见 index.js);面板只认这些工具的结果。
31
+ const TOOL_NAMES = new Set(["mindmap_create", "mindmap_open", "mindmap_get", "mindmap_update"]);
32
+ // 这些 op 的「新到达」会触发面板自动展开(001 场景 1;001 决策 5:AI 自动
33
+ // 打开与手动开关并存)。update 不自动开面板,避免打扰正在看别的的用户。
34
+ const OPENING_OPS = new Set(["create", "open"]);
35
+
36
+ const EMPTY_NODES = [];
37
+
38
+ //#region markdown → 脑图树(零依赖手写解析)
39
+ /** 规范化节点内容用于稳定 ID:折叠空白、截断到 60 字符(MarkGrove 同款)。 */
40
+ function normalizeForId(text) {
41
+ return String(text ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
42
+ }
43
+
44
+ /**
45
+ * 稳定 ID 工厂:按「父结构路径 | 类型 | 规范化内容」计同名出现序,
46
+ * 路径+内容哈希成紧凑 id。位置漂移(前插/后插兄弟)不改变既有 id。
47
+ * 每次解析新建工厂(出现序计数器按次重置)。
48
+ */
49
+ function createIdFactory() {
50
+ const counters = new Map();
51
+ return function structuralId(kind, content, parentPath) {
52
+ const normalized = normalizeForId(content);
53
+ const key = `${parentPath}|${kind}|${normalized}`;
54
+ const idx = counters.get(key) || 0;
55
+ counters.set(key, idx + 1);
56
+ const path = parentPath ? `${parentPath}/${kind}-${idx}` : `${kind}-${idx}`;
57
+ let hash = 0;
58
+ const full = `${path}:${normalized}`;
59
+ for (let i = 0; i < full.length; i++) {
60
+ hash = ((hash << 5) - hash + full.charCodeAt(i)) | 0;
61
+ }
62
+ return `s${Math.abs(hash).toString(36)}`;
63
+ };
64
+ }
65
+
66
+ /** 缩进宽度:tab 按 4 空格折算。 */
67
+ function indentWidth(raw) {
68
+ let width = 0;
69
+ for (const ch of raw) width += ch === "\t" ? 4 : 1;
70
+ return width;
71
+ }
72
+
73
+ /**
74
+ * markdown → 脑图树。根节点 topic = 文档名(rootTitle,由调用方从文件路径
75
+ * 推导——001 决策 2:根节点标题 = markdown 文档名)。
76
+ * 节点 kind:root / heading / list / placeholder / code。
77
+ * 段落不成为节点,附到最近的标题(或根)的 data.description。
78
+ */
79
+ function parseMarkdownToTree(markdown, rootTitle) {
80
+ const idOf = createIdFactory();
81
+ const root = {
82
+ id: "root",
83
+ kind: "root",
84
+ topic: String(rootTitle ?? "").trim() || "脑图",
85
+ children: [],
86
+ data: {},
87
+ };
88
+ const headingStack = [];
89
+ let listStack = [];
90
+ // 根标题回声标记:记录文档常以文件名作首行 H1(如 "# 002-spike结论.md"),
91
+ // 而根节点标题就是文件名——首个 H1 与根标题一致(或仅多 .md 后缀)时
92
+ // 并入根节点,避免标题显示两次。
93
+ let firstHeadingSeen = false;
94
+ const lines = String(markdown ?? "").split(/\r?\n/);
95
+
96
+ const parentRec = () => (headingStack.length ? headingStack[headingStack.length - 1] : null);
97
+ const parentPathOf = () => {
98
+ if (listStack.length) return listStack[listStack.length - 1].path;
99
+ const h = parentRec();
100
+ return h ? h.path : "";
101
+ };
102
+ const parentNode = () => {
103
+ if (listStack.length) return listStack[listStack.length - 1].node;
104
+ const h = parentRec();
105
+ return h ? h.node : root;
106
+ };
107
+ const appendDescription = (node, text) => {
108
+ node.data.description = node.data.description ? `${node.data.description}\n${text}` : text;
109
+ };
110
+
111
+ let i = 0;
112
+ // 跳过 YAML frontmatter(--- ... ---)
113
+ if (lines.length > 0 && /^\s*---\s*$/.test(lines[0])) {
114
+ for (i = 1; i < lines.length; i++) {
115
+ if (/^\s*---\s*$/.test(lines[i])) {
116
+ i += 1;
117
+ break;
118
+ }
119
+ }
120
+ }
121
+
122
+ let paraBuffer = [];
123
+ const flushParagraph = () => {
124
+ if (paraBuffer.length === 0) return;
125
+ appendDescription(parentNode(), paraBuffer.join(" "));
126
+ paraBuffer = [];
127
+ };
128
+
129
+ for (; i < lines.length; i++) {
130
+ const line = lines[i];
131
+
132
+ // 围栏代码块:整块成为一个叶节点,标题 = [语言] 首行摘要。
133
+ if (/^\s*(```|~~~)/.test(line)) {
134
+ flushParagraph();
135
+ listStack = [];
136
+ const lang = line.trim().slice(3).trim();
137
+ const buf = [];
138
+ for (i += 1; i < lines.length && !/^\s*(```|~~~)/.test(lines[i]); i++) buf.push(lines[i]);
139
+ const code = buf.join("\n");
140
+ const firstLine = (code.split("\n")[0] || "").trim();
141
+ const summary = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
142
+ const node = {
143
+ id: idOf("code", code, parentPathOf()),
144
+ kind: "code",
145
+ topic: `[${lang || "code"}] ${summary}`,
146
+ children: [],
147
+ data: { lang, code, firstLine: firstLine || undefined },
148
+ };
149
+ parentNode().children.push(node);
150
+ continue;
151
+ }
152
+
153
+ // ATX 标题:按层级入栈挂树(H1 挂根、H2 挂前一个 H1……)。
154
+ const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
155
+ if (heading) {
156
+ flushParagraph();
157
+ listStack = [];
158
+ const level = heading[1].length;
159
+ const text = heading[2].trim() || "(无标题)";
160
+ // 首个 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
161
+ if (!firstHeadingSeen && level === 1 && (text === root.topic || text === `${root.topic}.md`)) {
162
+ firstHeadingSeen = true;
163
+ continue;
164
+ }
165
+ firstHeadingSeen = true;
166
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
167
+ const basePath = parentRec() ? parentRec().path : "";
168
+ const node = {
169
+ id: idOf("heading", text, basePath),
170
+ kind: "heading",
171
+ topic: text,
172
+ children: [],
173
+ data: { level },
174
+ };
175
+ parentNode().children.push(node);
176
+ headingStack.push({ level, node, path: `${basePath}/h${level}-${node.id}` });
177
+ continue;
178
+ }
179
+
180
+ // 水平分隔线:线性视觉脚手架,跳过。
181
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
182
+ flushParagraph();
183
+ continue;
184
+ }
185
+
186
+ // 列表项:缩进决定层级(约定每级 2 空格,tab=4);空项=占位节点。
187
+ const listItem = /^(\s*)([-*+]|(\d+)[.)])\s+(.*)$/.exec(line);
188
+ const listEmpty = /^(\s*)([-*+]|(\d+)[.)])\s*$/.exec(line);
189
+ if (listItem || listEmpty) {
190
+ flushParagraph();
191
+ const m = listItem || listEmpty;
192
+ const indent = indentWidth(m[1]);
193
+ const text = listItem ? m[4].trim() : "";
194
+ const ordered = m[3] !== undefined;
195
+ while (listStack.length > 0 && listStack[listStack.length - 1].indent >= indent) listStack.pop();
196
+ const topic = ordered && listItem ? `${m[3]}. ${text}` : text;
197
+ const node = text === ""
198
+ ? { id: idOf("list", "", parentPathOf()), kind: "placeholder", topic: "", children: [], data: {} }
199
+ : { id: idOf("list", topic, parentPathOf()), kind: "list", topic, children: [], data: ordered ? { ordered: true } : {} };
200
+ parentNode().children.push(node);
201
+ listStack.push({ indent, node, path: `${parentPathOf()}/${node.id}` });
202
+ continue;
203
+ }
204
+
205
+ // 引用块:v0 不映射,跳过。
206
+ if (/^\s*>/.test(line)) {
207
+ flushParagraph();
208
+ continue;
209
+ }
210
+
211
+ if (!line.trim()) {
212
+ flushParagraph();
213
+ continue;
214
+ }
215
+
216
+ paraBuffer.push(line.trim());
217
+ }
218
+ flushParagraph();
219
+
220
+ // 病理内容的兜底去重(MarkGrove 同款):重复 id 追加序号。
221
+ const seen = new Set();
222
+ const dedupe = (node) => {
223
+ if (seen.has(node.id)) {
224
+ let k = 1;
225
+ while (seen.has(`${node.id}-${k}`)) k++;
226
+ node.id = `${node.id}-${k}`;
227
+ }
228
+ seen.add(node.id);
229
+ for (const child of node.children) dedupe(child);
230
+ };
231
+ dedupe(root);
232
+ return root;
233
+ }
234
+ //#endregion
235
+
236
+ //#region 会话快照 → 文档集
237
+ /** 取工具结果里 text 块拼接的文本。 */
238
+ function resultTextOfBlocks(blocks) {
239
+ return (blocks ?? []).filter((b) => b?.type === "text").map((b) => b.text).join("\n");
240
+ }
241
+
242
+ /** 从路径取文档名(去 .md)——根节点标题。 */
243
+ function stemOf(path) {
244
+ const base = String(path ?? "").split(/[\\/]/).pop() || "mindmap";
245
+ return base.replace(/\.md$/i, "");
246
+ }
247
+
248
+ /**
249
+ * 重放会话快照里的 mindmap_* 工具结果,得到每个脑图文档的最新状态。
250
+ * nodes: ConversationSnapshot.nodes(ToolResultNode 含 call.name 与渲染后的
251
+ * content 文本块——host 的工具结果 JSON 就写在其中)。
252
+ * 返回 { order: path[], byPath: { path → {path, rootTitle, content, op, callId} } }。
253
+ */
254
+ function reduceDocuments(nodes) {
255
+ const byPath = Object.create(null);
256
+ let order = [];
257
+ for (const node of nodes ?? []) {
258
+ if (!node || node.kind !== "tool-result" || node.isError) continue;
259
+ const name = node.call?.name;
260
+ if (typeof name !== "string" || !TOOL_NAMES.has(name)) continue;
261
+ let parsed;
262
+ try {
263
+ parsed = JSON.parse(resultTextOfBlocks(node.content));
264
+ } catch {
265
+ continue;
266
+ }
267
+ if (!parsed || parsed.ok !== true || typeof parsed.path !== "string" || !parsed.path) continue;
268
+ // 根标题改名 = 文件重命名:旧路径键迁移到新路径。
269
+ const renamedFrom = typeof parsed.renamedFrom === "string" ? parsed.renamedFrom : null;
270
+ if (renamedFrom && byPath[renamedFrom] && renamedFrom !== parsed.path) {
271
+ delete byPath[renamedFrom];
272
+ order = order.filter((p) => p !== renamedFrom);
273
+ }
274
+ if (!byPath[parsed.path]) order.push(parsed.path);
275
+ byPath[parsed.path] = {
276
+ path: parsed.path,
277
+ rootTitle: typeof parsed.rootTitle === "string" && parsed.rootTitle ? parsed.rootTitle : stemOf(parsed.path),
278
+ content: typeof parsed.content === "string" ? parsed.content : "",
279
+ op: typeof parsed.op === "string" ? parsed.op : name,
280
+ callId: node.callId,
281
+ // 013:rename 迁移后保留旧路径,供本地直读 tab 清理(mergeDocuments)。
282
+ renamedFrom: typeof parsed.renamedFrom === "string" ? parsed.renamedFrom : null,
283
+ };
284
+ }
285
+ return { order, byPath };
286
+ }
287
+
288
+ /**
289
+ * 快照文档集(AI 工具结果)与本地直读文档集(read 路由即时打开)合并:
290
+ * - 快照优先(同 path 覆盖本地占位);
291
+ * - 本地文档追加在快照 order 之后;
292
+ * - 快照里有 renamedFrom 指向某本地路径时,丢弃该本地条目(文件已改名)。
293
+ */
294
+ function mergeDocuments(snapshot, localDocs) {
295
+ const byPath = { ...localDocs, ...snapshot.byPath };
296
+ const dropped = new Set();
297
+ for (const doc of Object.values(snapshot.byPath)) {
298
+ if (typeof doc.renamedFrom === "string" && doc.renamedFrom && localDocs[doc.renamedFrom]) {
299
+ dropped.add(doc.renamedFrom);
300
+ }
301
+ }
302
+ for (const p of dropped) delete byPath[p];
303
+ const order = [...snapshot.order];
304
+ for (const p of Object.keys(localDocs)) {
305
+ if (!snapshot.byPath[p] && !dropped.has(p)) order.push(p);
306
+ }
307
+ return { order, byPath };
308
+ }
309
+ //#endregion
310
+
311
+ //#region 目录树 tab:懒加载节点表 → 可见行(013)
312
+ /** 条目路径 → 相对工作目录(cwd 外/异常退回条目名)。 */
313
+ function relPathWithin(cwd, path, fallbackName) {
314
+ const base = String(cwd ?? "").replace(/[\\/]+$/, "").replace(/\\/g, "/");
315
+ const s = String(path ?? "").replace(/\\/g, "/");
316
+ if (!base) return fallbackName || s;
317
+ if (s === base) return "";
318
+ if (s.startsWith(`${base}/`)) return s.slice(base.length + 1);
319
+ return fallbackName || s;
320
+ }
321
+
322
+ /**
323
+ * 把懒加载节点表压成可见行列表(先序遍历)。
324
+ * nodes: { path → {path, name, parentPath, entries, truncated} };
325
+ * expanded: { path → true }。根 = parentPath 为 null 的节点。
326
+ * 目录只渲染一次:已加载且展开 → 节点行(递归子条目);否则 → entry 行。
327
+ * 返回 [{kind:"dir", node, depth} | {kind:"entry", entry, depth}]。
328
+ */
329
+ function visibleTreeRows(nodes, expanded) {
330
+ const rootPath = Object.keys(nodes ?? {}).find((p) => nodes[p]?.parentPath === null);
331
+ if (!rootPath) return [];
332
+ const rows = [];
333
+ const walk = (path, depth) => {
334
+ const node = nodes[path];
335
+ if (!node) return;
336
+ rows.push({ kind: "dir", node, depth });
337
+ if (!expanded?.[path]) return;
338
+ for (const entry of node.entries ?? []) {
339
+ if (entry.isDir && nodes[entry.path] && expanded?.[entry.path]) {
340
+ // 已加载且展开:只走节点行,避免与 entry 行重复渲染。
341
+ walk(entry.path, depth + 1);
342
+ } else {
343
+ rows.push({ kind: "entry", entry, depth: depth + 1 });
344
+ }
345
+ }
346
+ };
347
+ walk(rootPath, 0);
348
+ return rows;
349
+ }
350
+ //#endregion
351
+
352
+ //#region PNG 导出(SVG 序列化 → canvas → 下载)
353
+ const EXPORT = { nodeW: 200, nodeH: 30, hGap: 48, vGap: 10, pad: 20, fontSize: 13 };
354
+
355
+ function escapeXml(text) {
356
+ return String(text ?? "").replace(/[&<>"']/g, (ch) => ({
357
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;",
358
+ })[ch]);
359
+ }
360
+
361
+ function truncateForExport(text, max = 26) {
362
+ const s = String(text ?? "");
363
+ return [...s].length > max ? `${[...s].slice(0, max).join("")}…` : s;
364
+ }
365
+
366
+ /**
367
+ * 布局 + 生成导出用 SVG 字符串。左→右分层:x = 深度列,叶子自上而下占行,
368
+ * 父节点垂直居中于其子块;连线为水平贝塞尔。
369
+ */
370
+ function buildExportSvg(tree) {
371
+ const placed = [];
372
+ const edges = [];
373
+ let cursor = EXPORT.pad;
374
+ let maxDepth = 0;
375
+ const place = (node, depth, parent) => {
376
+ const entry = { node, depth, x: EXPORT.pad + depth * (EXPORT.nodeW + EXPORT.hGap), y: 0 };
377
+ placed.push(entry);
378
+ if (depth > maxDepth) maxDepth = depth;
379
+ if (parent) edges.push({ from: parent, to: entry });
380
+ if (node.children && node.children.length > 0) {
381
+ let first = null;
382
+ let last = null;
383
+ for (const child of node.children) {
384
+ const childEntry = place(child, depth + 1, entry);
385
+ if (!first) first = childEntry;
386
+ last = childEntry;
387
+ }
388
+ entry.y = (first.y + last.y) / 2;
389
+ } else {
390
+ entry.y = cursor + EXPORT.nodeH / 2;
391
+ cursor += EXPORT.nodeH + EXPORT.vGap;
392
+ }
393
+ return entry;
394
+ };
395
+ place(tree, 0, null);
396
+ const width = EXPORT.pad * 2 + (maxDepth + 1) * EXPORT.nodeW + maxDepth * EXPORT.hGap;
397
+ const height = Math.max(EXPORT.pad * 2 + EXPORT.nodeH, cursor - EXPORT.vGap + EXPORT.pad);
398
+ const parts = [];
399
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" font-family="-apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif">`);
400
+ parts.push(`<rect x="0" y="0" width="${width}" height="${height}" fill="#ffffff"/>`);
401
+ for (const e of edges) {
402
+ const x1 = e.from.x + EXPORT.nodeW;
403
+ const y1 = e.from.y;
404
+ const x2 = e.to.x;
405
+ const y2 = e.to.y;
406
+ const mid = (x1 + x2) / 2;
407
+ parts.push(`<path d="M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}" fill="none" stroke="#c8cdd6" stroke-width="1.5"/>`);
408
+ }
409
+ for (const p of placed) {
410
+ const isRoot = p.depth === 0;
411
+ const isPlaceholder = p.node.kind === "placeholder";
412
+ const isCode = p.node.kind === "code";
413
+ const boxY = p.y - EXPORT.nodeH / 2;
414
+ const fill = isRoot ? "#eef2ff" : isCode ? "#f5f2ea" : "#f6f7f9";
415
+ parts.push(`<rect x="${p.x}" y="${boxY}" width="${EXPORT.nodeW}" height="${EXPORT.nodeH}" rx="7" fill="${isPlaceholder ? "none" : fill}" stroke="${isRoot ? "#7c8cf8" : isPlaceholder ? "#b9c0cc" : "#d4d9e0"}" stroke-width="${isRoot ? 1.6 : 1}"${isPlaceholder ? ' stroke-dasharray="5,4"' : ""}/>`);
416
+ const label = isPlaceholder ? "待填写" : truncateForExport(p.node.topic);
417
+ const color = isRoot ? "#2f3ab2" : isPlaceholder ? "#9aa2b1" : "#1f2430";
418
+ const weight = isRoot ? 700 : p.node.kind === "heading" ? 600 : 400;
419
+ parts.push(`<text x="${p.x + 10}" y="${p.y + 4.5}" font-size="${EXPORT.fontSize}" font-weight="${weight}" font-family="${isCode ? "Menlo, monospace" : "inherit"}" fill="${color}">${escapeXml(label)}</text>`);
420
+ }
421
+ parts.push("</svg>");
422
+ return { svg: parts.join(""), width, height };
423
+ }
424
+
425
+ /** 浏览器侧导出:SVG → Image → canvas → PNG 下载。 */
426
+ async function exportPng(tree, rootTitle) {
427
+ const { svg, width, height } = buildExportSvg(tree);
428
+ const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
429
+ const img = new Image();
430
+ await new Promise((resolve, reject) => {
431
+ img.onload = () => resolve();
432
+ img.onerror = () => reject(new Error("脑图 SVG 渲染失败"));
433
+ img.src = url;
434
+ });
435
+ const canvas = document.createElement("canvas");
436
+ canvas.width = Math.max(1, Math.ceil(width));
437
+ canvas.height = Math.max(1, Math.ceil(height));
438
+ const ctx2d = canvas.getContext("2d");
439
+ ctx2d.fillStyle = "#ffffff";
440
+ ctx2d.fillRect(0, 0, canvas.width, canvas.height);
441
+ ctx2d.drawImage(img, 0, 0);
442
+ const dataUrl = canvas.toDataURL("image/png");
443
+ const a = document.createElement("a");
444
+ a.href = dataUrl;
445
+ a.download = `${(rootTitle || "mindmap").replace(/[\\/:*?"<>|]/g, "_")}.png`;
446
+ document.body.appendChild(a);
447
+ a.click();
448
+ a.remove();
449
+ }
450
+ //#endregion
451
+
452
+ //#region React 组件
453
+ const S = {
454
+ mButton: { display: "inline-flex", alignItems: "center", gap: "4px", padding: "0 8px", height: "22px", background: "var(--dsw-alias-fill-tsp-secondary)", color: "var(--dsw-alias-label-secondary)", border: "none", borderRadius: "6px", cursor: "pointer", font: "inherit", fontSize: "12px", whiteSpace: "nowrap" },
455
+ // 014 overlay 外壳:右缘贴边全高悬浮面板,点击穿透层里自 opt-in pointer-events。
456
+ panelHost: { position: "fixed", top: 0, right: 0, bottom: 0, left: 0, pointerEvents: "none", zIndex: 40 },
457
+ overlayRoot: { position: "absolute", top: 0, right: 0, bottom: 0, display: "flex", flexDirection: "column", background: "var(--dsw-alias-bg-base)", color: "var(--dsw-alias-label-primary)", fontSize: "13px", minWidth: 0, borderLeft: "1px solid var(--dsw-alias-border-l2)", boxShadow: "-8px 0 24px rgba(16,24,40,0.10)", pointerEvents: "auto" },
458
+ overlayHandle: { position: "absolute", left: -4, top: 0, bottom: 0, width: 8, cursor: "col-resize", zIndex: 1 },
459
+ header: { display: "flex", flexDirection: "column", gap: "6px", padding: "12px 14px 0", boxSizing: "border-box", borderBottom: "1px solid var(--dsw-alias-border-l2)" },
460
+ headerTop: { display: "flex", alignItems: "center", gap: "10px", flex: "none" },
461
+ tabRow: { display: "flex", alignItems: "flex-end", gap: "10px", marginTop: "auto", overflowX: "auto", overflowY: "hidden", minWidth: 0, flex: "none" },
462
+ tab: { border: "none", background: "none", cursor: "pointer", padding: "3px 12px", lineHeight: "18px", borderRadius: "8px 8px 0 0", font: "inherit", color: "var(--dsw-alias-label-secondary)", whiteSpace: "nowrap", maxWidth: "120px", overflow: "hidden", textOverflow: "ellipsis", transition: "background 0.08s ease, color 0.08s ease" },
463
+ tabHover: { background: "var(--dsw-alias-interactive-bg-hover)" },
464
+ // 激活 tab 用内阴影画 2px 指示条,贴着头部分隔线,不挤高度。
465
+ tabActive: { background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-primary)", boxShadow: "inset 0 -2px 0 0 var(--dsw-alias-state-business-primary)" },
466
+ // 文档 tab = 包裹(承载视觉)+ 标题按钮 + 关闭 ✕(013:可关闭标签页)。
467
+ tabWrap: { display: "inline-flex", alignItems: "flex-end", borderRadius: "8px 8px 0 0", overflow: "hidden", maxWidth: "160px", transition: "background 0.08s ease" },
468
+ tabTitle: { background: "none", border: "none", cursor: "pointer", font: "inherit", color: "inherit", padding: "3px 4px 3px 12px", lineHeight: "18px", whiteSpace: "nowrap", maxWidth: "110px", overflow: "hidden", textOverflow: "ellipsis" },
469
+ tabClose: { background: "none", border: "none", cursor: "pointer", padding: "3px 8px 3px 2px", lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)", fontSize: "11px", flex: "none" },
470
+ spacer: { flex: "1 1 auto" },
471
+ action: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-primary)", borderRadius: "8px", height: "20px", padding: "0 12px", cursor: "pointer", font: "inherit", fontSize: "12px", whiteSpace: "nowrap" },
472
+ body: { flex: "1 1 auto", minHeight: 0, overflow: "auto", padding: "16px" },
473
+ empty: { color: "var(--dsw-alias-label-tertiary)", lineHeight: 1.7 },
474
+ emptyHint: { color: "var(--dsw-alias-label-tertiary)", fontSize: "12px", lineHeight: 1.6, margin: "0" },
475
+ // 013:tab 秒建后的加载态(内容要等 AI 工具结果才渲染)。
476
+ loadingWrap: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "12px", height: "100%", minHeight: 0 },
477
+ loadingText: { color: "var(--dsw-alias-label-secondary)", fontSize: "13px", margin: "0" },
478
+ // 013 目录树 tab:树容器/行样式。视觉自成一套:emoji 图标 + M 徽标 +
479
+ // 悬停高亮 + 激活指示条,不做 VSCode 式 chevron/线框。
480
+ // 树容器 -2px 负边距抵消 body 16px 内距:树左缘 = 头部「目录」tab 左缘(14px)。
481
+ treeWrap: { display: "flex", flexDirection: "column", gap: "12px", height: "100%", minHeight: 0, marginLeft: "-2px", marginRight: "-2px" },
482
+ treeList: { flex: "1 1 auto", overflowY: "auto", display: "flex", flexDirection: "column", gap: "2px", minHeight: 0 },
483
+ treeRow: { display: "flex", alignItems: "center", gap: "8px", width: "100%", boxSizing: "border-box", fontSize: "13px", lineHeight: "22px", borderRadius: "8px", padding: "2px 10px", cursor: "default", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0, background: "none", border: "none", font: "inherit", color: "var(--dsw-alias-label-secondary)", textAlign: "left", transition: "background 0.08s ease" },
484
+ treeRowHover: { background: "var(--dsw-alias-interactive-bg-hover)" },
485
+ treeRowClickable: { cursor: "pointer" },
486
+ treeRowMd: { color: "var(--dsw-alias-label-primary)", fontWeight: 500 },
487
+ treeRowOther: { color: "var(--dsw-alias-label-tertiary)" },
488
+ treeRootRow: { fontWeight: 700, color: "var(--dsw-alias-label-primary)" },
489
+ treeCaret: { flex: "none", width: "16px", fontSize: "11px", color: "var(--dsw-alias-label-caption)", textAlign: "center" },
490
+ // .md 专属徽标:脑图品牌的识别点(与 VSCode 文件图标区分开)。
491
+ mdBadge: { flex: "none", width: "18px", height: "18px", borderRadius: "5px", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: "11px", fontWeight: 700, lineHeight: 1, background: "var(--dsw-alias-state-business-tertiary)", color: "var(--dsw-alias-state-business-primary)" },
492
+ fileDot: { flex: "none", width: "18px", height: "18px", display: "inline-flex", alignItems: "center", justifyContent: "center" },
493
+ fileDotCore: { width: "4px", height: "4px", borderRadius: "50%", background: "var(--dsw-alias-label-caption)" },
494
+ treeRefresh: { flex: "none", border: "none", background: "none", cursor: "pointer", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-label-tertiary)", padding: "0 8px", borderRadius: "6px", lineHeight: "20px" },
495
+ treeRefreshHover: { background: "var(--dsw-alias-interactive-bg-hover)", color: "var(--dsw-alias-label-primary)" },
496
+ treeError: { color: "var(--dsw-alias-label-error)", fontSize: "12px", lineHeight: 1.6, margin: "0" },
497
+ treeMenu: { position: "fixed", zIndex: 60, minWidth: "210px", background: "var(--dsw-alias-bg-layer-3)", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "10px", padding: "6px", boxShadow: "var(--dsw-shadow-lv2)" },
498
+ treeMenuItem: { display: "block", width: "100%", boxSizing: "border-box", textAlign: "left", border: "none", background: "none", cursor: "pointer", padding: "7px 12px", borderRadius: "8px", font: "inherit", fontSize: "13px", color: "var(--dsw-alias-label-primary)" },
499
+ row: { display: "flex", alignItems: "center", minWidth: 0 },
500
+ childrenColumn: { display: "flex", flexDirection: "column", gap: "8px", marginLeft: "40px", minWidth: 0 },
501
+ // 面板树连线层:正交折线(MarkGrove 的 orthogonalPath 风格),
502
+ // 覆盖整行、点击穿透、置于节点盒之下。
503
+ edgeLayer: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" },
504
+ box: { padding: "6px 12px", borderRadius: "10px", border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", maxWidth: "240px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 0 auto", boxShadow: "0 1px 2px rgba(16,24,40,0.04)" },
505
+ rootBox: { fontWeight: 700, fontSize: "14px", border: "1px solid var(--dsw-alias-border-l2-darkmode-thin, #b9c0cc)", background: "var(--dsw-alias-bg-module-platform, #eef2ff)" },
506
+ headingBox: { fontWeight: 600 },
507
+ placeholderBox: { padding: "6px 12px", borderRadius: "10px", border: "1px dashed var(--dsw-alias-border-l2)", color: "var(--dsw-alias-label-tertiary)", background: "none" },
508
+ codeBox: { fontFamily: "Menlo, monospace", fontSize: "12px" },
509
+ };
510
+
511
+ /**
512
+ * 「思维脑图」槽位组件(014):同一槽位渲染 M 按钮 + 悬浮面板宿主层。
513
+ * session scope 的 useSession/sessionId/inputActions 直给,经 props 传给
514
+ * MindmapDetailsPanel(无桥、无 useSyncExternalStore——shell.overlay 跨槽
515
+ * 方案实测未渲染,弃用后顺手把桥也删了)。
516
+ */
517
+ function MindmapSlot(props) {
518
+ const { useSession, sessionId, inputActions, mindmapFace } = props;
519
+ const nodes = useSession ? useSession((s) => (s && s.nodes) || EMPTY_NODES) : EMPTY_NODES;
520
+ const [open, setOpen] = react.useState(false);
521
+ return (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
522
+ (0, react_jsx_runtime.jsxs)("button", {
523
+ type: "button",
524
+ title: "脑图面板:展开 / 收起",
525
+ style: S.mButton,
526
+ onClick: () => setOpen((v) => !v),
527
+ children: [
528
+ (0, react_jsx_runtime.jsx)("svg", {
529
+ width: 14,
530
+ height: 14,
531
+ viewBox: "0 0 14 14",
532
+ fill: "none",
533
+ stroke: "currentColor",
534
+ strokeWidth: 1.4,
535
+ strokeLinecap: "round",
536
+ strokeLinejoin: "round",
537
+ "aria-hidden": "true",
538
+ style: { opacity: 0.7, flex: "none" },
539
+ children: [
540
+ (0, react_jsx_runtime.jsx)("circle", { cx: 2.5, cy: 7, r: 1.7 }),
541
+ (0, react_jsx_runtime.jsx)("circle", { cx: 11.5, cy: 3.5, r: 1.7 }),
542
+ (0, react_jsx_runtime.jsx)("circle", { cx: 11.5, cy: 10.5, r: 1.7 }),
543
+ (0, react_jsx_runtime.jsx)("path", { d: "M4.1 6.2 L9.9 4.2" }),
544
+ (0, react_jsx_runtime.jsx)("path", { d: "M4.1 7.8 L9.9 9.8" }),
545
+ ],
546
+ }),
547
+ "思维脑图",
548
+ ],
549
+ }),
550
+ (0, react_jsx_runtime.jsx)(MindmapDetailsPanel, {
551
+ open,
552
+ sessionId,
553
+ inputActions,
554
+ nodes,
555
+ mindmapFace,
556
+ onOpen: () => setOpen(true),
557
+ onClose: () => setOpen(false),
558
+ }),
559
+ ] });
560
+ }
561
+
562
+ function NodeBox(props) {
563
+ const { node } = props;
564
+ const style = node.kind === "root"
565
+ ? { ...S.box, ...S.rootBox }
566
+ : node.kind === "heading"
567
+ ? { ...S.box, ...S.headingBox }
568
+ : node.kind === "placeholder"
569
+ ? { ...S.placeholderBox }
570
+ : node.kind === "code"
571
+ ? { ...S.box, ...S.codeBox }
572
+ : S.box;
573
+ const title = node.data?.description
574
+ ? `${node.topic}\n\n${node.data.description}`
575
+ : node.data?.code
576
+ ? `${node.topic}\n\n${node.data.code}`
577
+ : node.topic;
578
+ return (0, react_jsx_runtime.jsx)("div", { style, title, children: node.kind === "placeholder" ? "待填写" : node.topic });
579
+ }
580
+
581
+ /** 左→右递归树:节点盒 + 右侧子节点列 + 正交折线连线层(MarkGrove 风格)。 */
582
+ function TreeRow(props) {
583
+ const { node } = props;
584
+ const rowRef = react.useRef(null);
585
+ const boxWrapRef = react.useRef(null);
586
+ const childRefs = react.useRef([]);
587
+ const [edges, setEdges] = react.useState([]);
588
+ const prevEdgesRef = react.useRef("");
589
+
590
+ // 测量父盒右缘与各子节点包裹块的几何位置,画直角折线
591
+ // (M x1 y1 H midX V y2 H x2);序列化比对防 setState 循环。
592
+ react.useLayoutEffect(() => {
593
+ const rowEl = rowRef.current;
594
+ const boxEl = boxWrapRef.current;
595
+ if (!rowEl || !boxEl) return;
596
+ const measure = () => {
597
+ const rowRect = rowEl.getBoundingClientRect();
598
+ const boxRect = boxEl.getBoundingClientRect();
599
+ const next = [];
600
+ for (const ref of childRefs.current) {
601
+ if (!ref) continue;
602
+ const c = ref.getBoundingClientRect();
603
+ const x1 = boxRect.right - rowRect.left;
604
+ const y1 = boxRect.top - rowRect.top + boxRect.height / 2;
605
+ const x2 = c.left - rowRect.left;
606
+ const y2 = c.top - rowRect.top + c.height / 2;
607
+ const midX = (x1 + x2) / 2;
608
+ next.push(`M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`);
609
+ }
610
+ const key = next.join("|");
611
+ if (prevEdgesRef.current === key) return;
612
+ prevEdgesRef.current = key;
613
+ setEdges(next);
614
+ };
615
+ measure();
616
+ let observer = null;
617
+ if (typeof ResizeObserver !== "undefined") {
618
+ observer = new ResizeObserver(measure);
619
+ observer.observe(rowEl);
620
+ observer.observe(boxEl);
621
+ }
622
+ window.addEventListener("resize", measure);
623
+ return () => {
624
+ if (observer) observer.disconnect();
625
+ window.removeEventListener("resize", measure);
626
+ };
627
+ });
628
+
629
+ return (0, react_jsx_runtime.jsxs)("div", { ref: rowRef, style: { ...S.row, position: "relative" }, children: [
630
+ edges.length > 0
631
+ ? (0, react_jsx_runtime.jsx)("svg", {
632
+ style: S.edgeLayer,
633
+ children: edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
634
+ key: i,
635
+ d,
636
+ stroke: "var(--dsw-alias-border-l2)",
637
+ strokeWidth: 1.5,
638
+ fill: "none",
639
+ }, i)),
640
+ })
641
+ : null,
642
+ (0, react_jsx_runtime.jsx)("div", { ref: boxWrapRef, style: { flex: "0 0 auto" }, children: (0, react_jsx_runtime.jsx)(NodeBox, { node }) }),
643
+ node.children && node.children.length > 0
644
+ ? (0, react_jsx_runtime.jsx)("div", { style: S.childrenColumn, children: node.children.map((child, idx) => (0, react_jsx_runtime.jsx)("div", {
645
+ key: child.id,
646
+ ref: (el) => {
647
+ childRefs.current[idx] = el;
648
+ },
649
+ children: (0, react_jsx_runtime.jsx)(TreeRow, { node: child }),
650
+ }, child.id)) })
651
+ : null,
652
+ ] });
653
+ }
654
+
655
+ function MindmapDetailsPanel(props) {
656
+ // 014:面板与 M 按钮同槽位(conversation.session.header.actions),
657
+ // 会话能力(sessionId/inputActions/nodes)与开合回调全部由 MindmapSlot
658
+ // 经 props 直给(无桥、无 useSyncExternalStore)。
659
+ const { mindmapFace, open, sessionId, inputActions, nodes, onOpen, onClose } = props;
660
+ const docs = react.useMemo(() => reduceDocuments(nodes), [nodes]);
661
+ // 013:本地加载占位文档(左键点 .md 秒建 tab、内容为空),与快照文档
662
+ // 合并显示;快照优先(AI 结果覆盖占位)。
663
+ const [localDocs, setLocalDocs] = react.useState({});
664
+ const merged = react.useMemo(() => mergeDocuments(docs, localDocs), [docs, localDocs]);
665
+ // 014 overlay 宽度:localStorage 持久化,拖拽钳制 [280, 视口 80%]。
666
+ const WIDTH_KEY = "dsh-mindmap.overlay-width";
667
+ const [panelWidth, setPanelWidth] = react.useState(() => {
668
+ try {
669
+ const saved = Number(localStorage.getItem(WIDTH_KEY));
670
+ if (Number.isFinite(saved) && saved >= 280) return Math.min(saved, Math.round(window.innerWidth * 0.8));
671
+ } catch {
672
+ // localStorage 不可用:走默认
673
+ }
674
+ return Math.round(window.innerWidth * 0.42);
675
+ });
676
+ react.useEffect(() => {
677
+ if (panelWidth > Math.round(window.innerWidth * 0.8)) {
678
+ setPanelWidth(Math.round(window.innerWidth * 0.8));
679
+ }
680
+ }, []);
681
+ const dragStateRef = react.useRef(null);
682
+ function startResize(e) {
683
+ e.preventDefault();
684
+ dragStateRef.current = { startX: e.clientX, startWidth: panelWidth, latestWidth: panelWidth };
685
+ const onMove = (ev) => {
686
+ if (!dragStateRef.current) return;
687
+ const max = Math.round(window.innerWidth * 0.8);
688
+ const next = Math.min(max, Math.max(280, dragStateRef.current.startWidth + (dragStateRef.current.startX - ev.clientX)));
689
+ dragStateRef.current.latestWidth = next;
690
+ setPanelWidth(next);
691
+ };
692
+ const onUp = () => {
693
+ try {
694
+ localStorage.setItem(WIDTH_KEY, String(dragStateRef.current ? dragStateRef.current.latestWidth : panelWidth));
695
+ } catch {
696
+ // localStorage 不可用:忽略
697
+ }
698
+ dragStateRef.current = null;
699
+ window.removeEventListener("mousemove", onMove);
700
+ window.removeEventListener("mouseup", onUp);
701
+ };
702
+ window.addEventListener("mousemove", onMove);
703
+ window.addEventListener("mouseup", onUp);
704
+ }
705
+ // 013 目录树 tab:常驻第一个 tab(TREE_TAB 哨兵,永不与绝对路径撞名)。
706
+ const TREE_TAB = "__tree__";
707
+ // 013 作者拍板「单脑图模式」:面板只有「目录」与「脑图」两个 tab,
708
+ // 打开新脑图替换掉旧的(覆盖 001 决策 3 的多标签形态,记录见 013)。
709
+ const [view, setView] = react.useState("tree");
710
+ const [currentPath, setCurrentPath] = react.useState(null);
711
+ const [hiddenPath, setHiddenPath] = react.useState(null);
712
+ const [exporting, setExporting] = react.useState(false);
713
+ const [exportError, setExportError] = react.useState("");
714
+ const [filledHint, setFilledHint] = react.useState("");
715
+ // fsTree:nodes = {path → 节点}, expanded = {path → true}, loading = {path → true}。
716
+ const [fsTree, setFsTree] = react.useState({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
717
+ // 013 右键菜单:{x, y, kind: "root"|"dir", rel};null = 关闭。
718
+ const [treeMenu, setTreeMenu] = react.useState(null);
719
+ // tab 右键菜单:{x, y, path}(path === TREE_TAB 时是「刷新目录树」)。
720
+ const [tabMenu, setTabMenu] = react.useState(null);
721
+ // 悬停高亮键:树行用 entry.path / node.path,tab 用 TREE_TAB / 文档路径。
722
+ const [hoverKey, setHoverKey] = react.useState(null);
723
+
724
+ // 007~010 头线对齐(overlay 版回归):面板头部高度动态跟随聊天区头部,
725
+ // 让两者的底部分隔线像素对齐。面板贴视口顶(fixed 宿主层),故
726
+ // 头部高度 = 聊天头部 rect.bottom - 1 - 面板顶(面板顶 ≈ 视口顶)。
727
+ // 主选 wSkVaW_header;结构链回退;合法性钳制 [40,200];失败回退 74(75-1)。
728
+ const panelRootRef = react.useRef(null);
729
+ const FALLBACK_HEADER_HEIGHT = 74;
730
+ const [headerHeight, setHeaderHeight] = react.useState(FALLBACK_HEADER_HEIGHT);
731
+ react.useLayoutEffect(() => {
732
+ const HEADER_MIN = 40;
733
+ const HEADER_MAX = 200;
734
+ const tryPaths = [
735
+ () => document.querySelector('[class*="wSkVaW_header"]'),
736
+ () => {
737
+ const frame = document.querySelector("[data-dsh-frame]");
738
+ if (!frame) return null;
739
+ const center = frame.querySelector('[data-pane="conversation"]');
740
+ return center ? center.firstElementChild : null;
741
+ },
742
+ ];
743
+ const measure = () => {
744
+ for (const path of tryPaths) {
745
+ const el = path();
746
+ if (!el) continue;
747
+ const rect = el.getBoundingClientRect();
748
+ const panelTop = panelRootRef.current
749
+ ? panelRootRef.current.getBoundingClientRect().top
750
+ : rect.top;
751
+ const h = rect.bottom - 1 - panelTop;
752
+ if (h >= HEADER_MIN && h <= HEADER_MAX) {
753
+ setHeaderHeight(Math.round(h * 10) / 10);
754
+ return;
755
+ }
756
+ }
757
+ setHeaderHeight(FALLBACK_HEADER_HEIGHT);
758
+ };
759
+ measure();
760
+ const target = tryPaths[0]() || tryPaths[1]();
761
+ let observer = null;
762
+ if (target && typeof ResizeObserver !== "undefined") {
763
+ observer = new ResizeObserver(measure);
764
+ observer.observe(target);
765
+ }
766
+ window.addEventListener("resize", measure);
767
+ return () => {
768
+ if (observer) observer.disconnect();
769
+ window.removeEventListener("resize", measure);
770
+ };
771
+ }, []);
772
+
773
+ // 单脑图模式:可见脑图 = 用户当前点选(且未被关闭)的快照/本地文档,
774
+ // 否则跟随最新工具结果;隐藏过的路径不自动回弹(重新点树里文件才恢复)。
775
+ const lastPath = merged.order.length > 0 ? merged.order[merged.order.length - 1] : null;
776
+ const shown = currentPath && currentPath !== hiddenPath && merged.byPath[currentPath]
777
+ ? currentPath
778
+ : (lastPath && lastPath !== hiddenPath ? lastPath : null);
779
+ const active = view === "mindmap" && shown ? shown : TREE_TAB;
780
+ const doc = active !== TREE_TAB && merged.byPath[active] ? merged.byPath[active] : null;
781
+ const tree = react.useMemo(
782
+ () => (doc ? parseMarkdownToTree(doc.content, doc.rootTitle) : null),
783
+ [doc && doc.content, doc && doc.rootTitle],
784
+ );
785
+
786
+ // AI 自动打开(001 决策 5):新到达的 create/open 结果自动展开悬浮面板;
787
+ // 首次挂载只登记不打开(刷新后面板保持收起,003 实测行为)。
788
+ const seen = react.useRef(null);
789
+ react.useEffect(() => {
790
+ const ids = new Set(merged.order.map((p) => merged.byPath[p].callId));
791
+ if (seen.current === null) {
792
+ seen.current = ids;
793
+ return;
794
+ }
795
+ let shouldOpen = false;
796
+ for (const p of merged.order) {
797
+ const d = merged.byPath[p];
798
+ if (OPENING_OPS.has(d.op) && !seen.current.has(d.callId)) shouldOpen = true;
799
+ }
800
+ seen.current = ids;
801
+ if (shouldOpen) {
802
+ onOpen();
803
+ // 单脑图模式:新打开的脑图顶替旧视图(001 场景 1「一句开脑图」)。
804
+ setHiddenPath(null);
805
+ setCurrentPath(null);
806
+ setView("mindmap");
807
+ }
808
+ }, [merged]);
809
+
810
+ // 013「所见即所编」焦点同步:AI 焦点 = 快照里最新工具结果的文档路径;
811
+ // 脑图视图激活且其文档 ≠ 焦点时,自动填「用 mindmap_open 打开 <它>」
812
+ // 并发送,让 AI 跟上用户眼睛看的那颗脑图。
813
+ const focusPath = docs.order.length > 0 ? docs.order[docs.order.length - 1] : null;
814
+ const focusSentRef = react.useRef(null);
815
+ react.useEffect(() => {
816
+ if (!open) return; // 面板收起时不自动发消息(014 overlay 形态守卫)
817
+ if (!sessionId) return;
818
+ if (!active || active === TREE_TAB) return;
819
+ if (!docs.byPath[active]) return; // 本地占位:它的 open 请求已在途
820
+ if (focusPath === active) return;
821
+ if (focusSentRef.current === active) return; // 已发过,等 AI 结果追平
822
+ if (!inputActions || typeof inputActions.setDraft !== "function") return;
823
+ const rel = fsTree.cwd ? relPathWithin(fsTree.cwd, active, stemOf(active)) : active;
824
+ try {
825
+ inputActions.setDraft(`用 mindmap_open 打开 ${rel}`);
826
+ if (typeof inputActions.submit === "function") inputActions.submit();
827
+ focusSentRef.current = active;
828
+ } catch {
829
+ // 发送失败:下次 active/focus 变化会再试;也可手动在聊天里说。
830
+ }
831
+ }, [active, focusPath, fsTree.cwd, docs, open]);
832
+
833
+ async function onExport() {
834
+ if (!tree || !doc || exporting) return;
835
+ setExporting(true);
836
+ setExportError("");
837
+ try {
838
+ await exportPng(tree, doc.rootTitle);
839
+ } catch (error) {
840
+ setExportError(String(error?.message ?? error));
841
+ } finally {
842
+ setExporting(false);
843
+ }
844
+ }
845
+
846
+ //#region 013 目录树 tab:懒加载树 + 把指令填进聊天输入框
847
+ // 主路径 = inputActions.setDraft(官方公共面,整串替换草稿);
848
+ // 无则降级剪贴板复制 + 面板内提示。
849
+ function fillDraft(text) {
850
+ try {
851
+ if (inputActions && typeof inputActions.setDraft === "function") {
852
+ inputActions.setDraft(text);
853
+ setFilledHint("指令已填入聊天输入框");
854
+ return;
855
+ }
856
+ } catch {
857
+ // 落剪贴板降级
858
+ }
859
+ if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
860
+ navigator.clipboard.writeText(text).catch(() => {});
861
+ setFilledHint("已复制指令到剪贴板,请粘贴到聊天输入框");
862
+ return;
863
+ }
864
+ setFilledHint(text);
865
+ }
866
+
867
+ /** 关闭脑图视图:✕ 后回到只有「目录」的状态;快照结果不自动弹回。 */
868
+ function closeMindmap(path) {
869
+ setHiddenPath(path);
870
+ setCurrentPath(null);
871
+ setView("tree");
872
+ setLocalDocs((prev) => {
873
+ const next = { ...prev };
874
+ delete next[path];
875
+ return next;
876
+ });
877
+ }
878
+
879
+ // 左键点 .md(013 作者定稿):① tab 秒建(本地占位,不显示内容,body
880
+ // 显示加载动效);② 同时填「用 mindmap_open 打开 <rel>」并 submit 让 AI
881
+ // 就位——AI 工具结果到达后同 path 覆盖占位,节点才渲染;随后用户接着
882
+ // 说即可继续编辑(002 数据流不变:内容只来自 AI 工具结果)。
883
+ function openMindmap(entry) {
884
+ const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
885
+ // ① 本地占位:脑图 tab 立即切过去、内容为空(op:"local" 触发加载态);
886
+ // 新打开的脑图替换旧的那颗(单脑图模式)。
887
+ setHiddenPath(null);
888
+ setCurrentPath(entry.path);
889
+ setView("mindmap");
890
+ setLocalDocs((prev) => ({
891
+ ...prev,
892
+ [entry.path]: {
893
+ path: entry.path,
894
+ rootTitle: stemOf(entry.name),
895
+ content: "",
896
+ op: "local",
897
+ callId: null,
898
+ renamedFrom: null,
899
+ },
900
+ }));
901
+ // ② AI 就位:填指令并直接提交(失败降级剪贴板)。
902
+ let sent = false;
903
+ if (inputActions && typeof inputActions.setDraft === "function") {
904
+ try {
905
+ inputActions.setDraft(text);
906
+ if (typeof inputActions.submit === "function") {
907
+ inputActions.submit();
908
+ sent = true;
909
+ }
910
+ } catch {
911
+ // 落剪贴板降级
912
+ }
913
+ }
914
+ if (sent) {
915
+ // 标记已发,避免焦点同步 effect 对同一路径重复发送。
916
+ focusSentRef.current = entry.path;
917
+ setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
918
+ return;
919
+ }
920
+ if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
921
+ navigator.clipboard.writeText(text).catch(() => {});
922
+ setFilledHint("已复制指令到剪贴板,请粘贴到聊天输入框");
923
+ return;
924
+ }
925
+ setFilledHint(text);
926
+ }
927
+
928
+ // 拉取一层目录(path 缺省 = 会话 cwd 根)。host 路由 /mindmap/api/tree
929
+ // 只读;返回 {path, cwd, entries:[{name,path,isDir,hidden}], truncated}。
930
+ async function loadTree(path) {
931
+ const key = path === undefined || path === null ? "" : path;
932
+ setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: true }, error: null }));
933
+ try {
934
+ if (!mindmapFace || typeof mindmapFace.listTree !== "function") throw new Error("目录树能力不可用");
935
+ const listing = await mindmapFace.listTree(sessionId, typeof path === "string" && path ? path : undefined);
936
+ setFsTree((prev) => {
937
+ const nodes = { ...prev.nodes };
938
+ nodes[listing.path] = {
939
+ path: listing.path,
940
+ name: String(listing.path).split(/[\\/]/).pop() || listing.path,
941
+ parentPath: path === undefined || path === null ? null : path,
942
+ entries: listing.entries ?? [],
943
+ truncated: listing.truncated === true,
944
+ };
945
+ return {
946
+ ...prev,
947
+ nodes,
948
+ cwd: typeof listing.cwd === "string" && listing.cwd ? listing.cwd : (prev.cwd ?? listing.path),
949
+ // 根默认自动展开(一打开就看到第一层);刷新不会收掉已展开的子目录。
950
+ expanded: path === undefined || path === null
951
+ ? { ...prev.expanded, [listing.path]: true }
952
+ : prev.expanded,
953
+ loading: { ...prev.loading, [key]: false },
954
+ };
955
+ });
956
+ } catch (error) {
957
+ setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: false }, error: String(error?.message ?? error) }));
958
+ }
959
+ }
960
+
961
+ async function toggleDir(entry) {
962
+ if (fsTree.expanded[entry.path]) {
963
+ setFsTree((prev) => {
964
+ const expanded = { ...prev.expanded };
965
+ delete expanded[entry.path];
966
+ return { ...prev, expanded };
967
+ });
968
+ return;
969
+ }
970
+ if (!fsTree.nodes[entry.path]) await loadTree(entry.path);
971
+ setFsTree((prev) => ({ ...prev, expanded: { ...prev.expanded, [entry.path]: true } }));
972
+ }
973
+
974
+ /** 目录节点(含根)展开/折叠;根总在本地节点表里。 */
975
+ function togglePath(path) {
976
+ if (fsTree.expanded[path]) {
977
+ setFsTree((prev) => {
978
+ const expanded = { ...prev.expanded };
979
+ delete expanded[path];
980
+ return { ...prev, expanded };
981
+ });
982
+ return;
983
+ }
984
+ setFsTree((prev) => ({ ...prev, expanded: { ...prev.expanded, [path]: true } }));
985
+ }
986
+
987
+ // 首次挂载:拉根目录(会话 cwd)。
988
+ react.useEffect(() => {
989
+ if (!sessionId) return;
990
+ setFsTree({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
991
+ loadTree(undefined);
992
+ }, [sessionId]);
993
+
994
+ const treeRows = react.useMemo(
995
+ () => visibleTreeRows(fsTree.nodes, fsTree.expanded),
996
+ [fsTree.nodes, fsTree.expanded],
997
+ );
998
+
999
+ function renderTreeRow(row) {
1000
+ if (row.kind === "dir") {
1001
+ const node = row.node;
1002
+ const isRoot = node.parentPath === null;
1003
+ const expandedNow = Boolean(fsTree.expanded[node.path]);
1004
+ const hovered = hoverKey === node.path;
1005
+ // 缩进从根行 0 起,每层 +16;树容器已左移到 tab 左缘。
1006
+ const depthPad = row.depth * 16;
1007
+ return (0, react_jsx_runtime.jsxs)("div", {
1008
+ key: node.path,
1009
+ style: { ...S.treeRow, ...S.treeRowClickable, paddingLeft: depthPad, ...(isRoot ? S.treeRootRow : {}), ...(hovered ? S.treeRowHover : {}) },
1010
+ title: node.path,
1011
+ onClick: () => togglePath(node.path),
1012
+ onMouseEnter: () => setHoverKey(node.path),
1013
+ onMouseLeave: () => setHoverKey((k) => (k === node.path ? null : k)),
1014
+ onContextMenu: (e) => {
1015
+ e.preventDefault();
1016
+ e.stopPropagation();
1017
+ setTreeMenu({
1018
+ x: e.clientX,
1019
+ y: e.clientY,
1020
+ kind: isRoot ? "root" : "dir",
1021
+ rel: isRoot ? "" : relPathWithin(fsTree.cwd, node.path, node.name),
1022
+ });
1023
+ },
1024
+ children: [
1025
+ (0, react_jsx_runtime.jsx)("span", { style: S.treeCaret, children: expandedNow ? "▾" : "▸" }),
1026
+ (0, react_jsx_runtime.jsx)("span", {
1027
+ style: { flex: "0 1 auto", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 },
1028
+ children: expandedNow ? `📂 ${node.name}` : `📁 ${node.name}`,
1029
+ }),
1030
+ node.truncated ? (0, react_jsx_runtime.jsx)("span", { style: S.treeCaret, children: "…" }) : null,
1031
+ // 根行行内右侧的「刷新」(013:不占独立一行)。
1032
+ isRoot ? (0, react_jsx_runtime.jsx)("span", { style: S.spacer }) : null,
1033
+ isRoot ? (0, react_jsx_runtime.jsx)("button", {
1034
+ type: "button",
1035
+ style: hoverKey === `refresh:${node.path}` ? { ...S.treeRefresh, ...S.treeRefreshHover } : S.treeRefresh,
1036
+ disabled: Boolean(fsTree.loading[""]),
1037
+ onClick: (e) => {
1038
+ // 阻断冒泡,避免触发根行的折叠 toggle。
1039
+ e.stopPropagation();
1040
+ loadTree(undefined);
1041
+ },
1042
+ onMouseEnter: () => setHoverKey(`refresh:${node.path}`),
1043
+ onMouseLeave: () => setHoverKey((k) => (k === `refresh:${node.path}` ? null : k)),
1044
+ children: Boolean(fsTree.loading[""]) ? "读取中…" : "刷新",
1045
+ }) : null,
1046
+ ],
1047
+ });
1048
+ }
1049
+ const entry = row.entry;
1050
+ const depthPad = row.depth * 16;
1051
+ const isMd = /\.md$/i.test(entry.name);
1052
+ const expandedNow = entry.isDir && Boolean(fsTree.expanded[entry.path]);
1053
+ const hovered = hoverKey === entry.path;
1054
+ const style = {
1055
+ ...S.treeRow,
1056
+ paddingLeft: depthPad,
1057
+ ...(entry.isDir || isMd ? S.treeRowClickable : {}),
1058
+ ...(isMd ? S.treeRowMd : entry.isDir ? {} : S.treeRowOther),
1059
+ ...(entry.hidden ? { opacity: 0.6 } : {}),
1060
+ ...(hovered ? S.treeRowHover : {}),
1061
+ };
1062
+ if (entry.isDir) {
1063
+ return (0, react_jsx_runtime.jsxs)("div", {
1064
+ key: entry.path,
1065
+ style,
1066
+ title: entry.path,
1067
+ onClick: () => toggleDir(entry),
1068
+ onMouseEnter: () => setHoverKey(entry.path),
1069
+ onMouseLeave: () => setHoverKey((k) => (k === entry.path ? null : k)),
1070
+ onContextMenu: (e) => {
1071
+ e.preventDefault();
1072
+ e.stopPropagation();
1073
+ setTreeMenu({
1074
+ x: e.clientX,
1075
+ y: e.clientY,
1076
+ kind: "dir",
1077
+ rel: relPathWithin(fsTree.cwd, entry.path, entry.name),
1078
+ });
1079
+ },
1080
+ children: [
1081
+ (0, react_jsx_runtime.jsx)("span", { style: S.treeCaret, children: expandedNow ? "▾" : "▸" }),
1082
+ (0, react_jsx_runtime.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 }, children: expandedNow ? `📂 ${entry.name}` : `📁 ${entry.name}` }),
1083
+ ],
1084
+ });
1085
+ }
1086
+ return (0, react_jsx_runtime.jsxs)("div", {
1087
+ key: entry.path,
1088
+ style,
1089
+ title: isMd ? `打开脑图:${entry.path}` : entry.path,
1090
+ onClick: isMd ? () => openMindmap(entry) : undefined,
1091
+ onMouseEnter: () => setHoverKey(entry.path),
1092
+ onMouseLeave: () => setHoverKey((k) => (k === entry.path ? null : k)),
1093
+ // 右键:.md 不弹菜单(左键即打开);非 .md 只拦掉默认菜单。
1094
+ onContextMenu: (e) => {
1095
+ e.preventDefault();
1096
+ e.stopPropagation();
1097
+ },
1098
+ children: [
1099
+ isMd
1100
+ ? (0, react_jsx_runtime.jsx)("span", { style: S.mdBadge, children: "M" })
1101
+ : (0, react_jsx_runtime.jsx)("span", { style: S.fileDot, children: (0, react_jsx_runtime.jsx)("span", { style: S.fileDotCore }) }),
1102
+ (0, react_jsx_runtime.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 }, children: entry.name }),
1103
+ ],
1104
+ });
1105
+ }
1106
+
1107
+ // 右键菜单(树/tab):点其它地方/失焦/改窗口即关闭。
1108
+ react.useEffect(() => {
1109
+ if (!treeMenu && !tabMenu) return;
1110
+ const close = () => {
1111
+ setTreeMenu(null);
1112
+ setTabMenu(null);
1113
+ };
1114
+ window.addEventListener("click", close);
1115
+ window.addEventListener("blur", close);
1116
+ window.addEventListener("resize", close);
1117
+ return () => {
1118
+ window.removeEventListener("click", close);
1119
+ window.removeEventListener("blur", close);
1120
+ window.removeEventListener("resize", close);
1121
+ };
1122
+ }, [treeMenu, tabMenu]);
1123
+
1124
+ function renderLoading() {
1125
+ return (0, react_jsx_runtime.jsxs)("div", { style: S.loadingWrap, children: [
1126
+ (0, react_jsx_runtime.jsxs)("svg", { width: 22, height: 22, viewBox: "0 0 22 22", children: [
1127
+ (0, react_jsx_runtime.jsx)("circle", { cx: 11, cy: 11, r: 8, fill: "none", stroke: "var(--dsw-alias-border-l2)", strokeWidth: 2 }),
1128
+ (0, react_jsx_runtime.jsx)("circle", {
1129
+ cx: 11,
1130
+ cy: 11,
1131
+ r: 8,
1132
+ fill: "none",
1133
+ stroke: "var(--dsw-alias-state-business-primary)",
1134
+ strokeWidth: 2,
1135
+ strokeLinecap: "round",
1136
+ strokeDasharray: "12 38",
1137
+ children: (0, react_jsx_runtime.jsx)("animateTransform", {
1138
+ attributeName: "transform",
1139
+ type: "rotate",
1140
+ from: "0 11 11",
1141
+ to: "360 11 11",
1142
+ dur: "0.9s",
1143
+ repeatCount: "indefinite",
1144
+ }),
1145
+ }),
1146
+ ] }),
1147
+ (0, react_jsx_runtime.jsx)("p", { style: S.loadingText, children: "AI 正在打开脑图…" }),
1148
+ (0, react_jsx_runtime.jsx)("p", { style: S.emptyHint, children: "若长时间未打开,可直接在聊天里说「打开 <文件名>」" }),
1149
+ ] });
1150
+ }
1151
+
1152
+ function renderTree() {
1153
+ const rootLoading = Boolean(fsTree.loading[""]);
1154
+ return (0, react_jsx_runtime.jsxs)("div", {
1155
+ style: S.treeWrap,
1156
+ // 空白处右键 = 在根目录新建(人类操作习惯,013)。
1157
+ onContextMenu: (e) => {
1158
+ e.preventDefault();
1159
+ setTreeMenu({ x: e.clientX, y: e.clientY, kind: "root", rel: "" });
1160
+ },
1161
+ children: [
1162
+ filledHint ? (0, react_jsx_runtime.jsx)("p", { style: S.emptyHint, children: filledHint }) : null,
1163
+ fsTree.error ? (0, react_jsx_runtime.jsxs)("p", { style: S.treeError, children: [
1164
+ `目录树读取失败:${fsTree.error} `,
1165
+ (0, react_jsx_runtime.jsx)("button", {
1166
+ type: "button",
1167
+ style: S.treeRefresh,
1168
+ onClick: () => loadTree(undefined),
1169
+ children: "刷新",
1170
+ }),
1171
+ ] }) : null,
1172
+ treeRows.length === 0
1173
+ ? (0, react_jsx_runtime.jsxs)("p", { style: S.empty, children: [
1174
+ rootLoading ? "正在读取工作目录…" : "目录树为空。右键空白处新建脑图,或直接对 AI 说「打开一个脑图」。",
1175
+ !rootLoading ? (0, react_jsx_runtime.jsx)("button", {
1176
+ type: "button",
1177
+ style: S.treeRefresh,
1178
+ onClick: () => loadTree(undefined),
1179
+ children: "刷新",
1180
+ }) : null,
1181
+ ] })
1182
+ : (0, react_jsx_runtime.jsx)("div", { style: S.treeList, children: treeRows.map((row) => renderTreeRow(row)) }),
1183
+ treeMenu ? (0, react_jsx_runtime.jsxs)("div", {
1184
+ style: { ...S.treeMenu, left: treeMenu.x, top: treeMenu.y },
1185
+ onContextMenu: (e) => e.preventDefault(),
1186
+ children: [
1187
+ (0, react_jsx_runtime.jsx)("button", {
1188
+ type: "button",
1189
+ style: S.treeMenuItem,
1190
+ onClick: () => {
1191
+ setTreeMenu(null);
1192
+ fillDraft(treeMenu.kind === "dir"
1193
+ ? `我想在 ${treeMenu.rel} 目录里创建一个 Markdown 脑图`
1194
+ : "我想创建一个脑图");
1195
+ },
1196
+ children: treeMenu.kind === "dir" ? "在此目录新建 Markdown 脑图" : "新建 Markdown 脑图",
1197
+ }),
1198
+ ],
1199
+ }) : null,
1200
+ ] });
1201
+ }
1202
+ //#endregion
1203
+
1204
+ // 014 布局让位:面板打开/拖宽时把宽度写进 CSS 变量,挤窄 #root 推走
1205
+ // 聊天区(better-sidebar 同款);关闭/卸载时移除变量恢复全宽。
1206
+ react.useLayoutEffect(() => {
1207
+ if (typeof document === "undefined") return;
1208
+ if (open) {
1209
+ document.documentElement.style.setProperty("--dsh-mindmap-width", `${panelWidth}px`);
1210
+ } else {
1211
+ document.documentElement.style.removeProperty("--dsh-mindmap-width");
1212
+ }
1213
+ return () => {
1214
+ document.documentElement.style.removeProperty("--dsh-mindmap-width");
1215
+ };
1216
+ }, [open, panelWidth]);
1217
+
1218
+ if (!open) return null;
1219
+ // 014 overlay 外壳:fixed 宿主层(点击穿透)套右缘贴边全高悬浮面板,
1220
+ // 左缘拖拽调宽([280, 视口 80%],localStorage 持久化);遮盖聊天区是
1221
+ // 该形态的已知代价(作者拍板,见 docs/014)。宿主层挂在 header 槽位里,
1222
+ // better-sidebar 同款「fixed 自举」思路。
1223
+ return (0, react_jsx_runtime.jsx)("div", { style: S.panelHost, children: (0, react_jsx_runtime.jsxs)("div", { ref: panelRootRef, style: { ...S.overlayRoot, width: panelWidth }, children: [
1224
+ (0, react_jsx_runtime.jsx)("div", { style: S.overlayHandle, onMouseDown: startResize }),
1225
+ (0, react_jsx_runtime.jsxs)("div", { style: { ...S.header, height: `${headerHeight - 1}px` }, children: [
1226
+ (0, react_jsx_runtime.jsxs)("div", { style: S.headerTop, children: [
1227
+ (0, react_jsx_runtime.jsx)("span", { style: S.spacer }),
1228
+ (0, react_jsx_runtime.jsx)("button", {
1229
+ type: "button",
1230
+ style: S.action,
1231
+ disabled: !tree || exporting || (doc && doc.op === "local"),
1232
+ onClick: onExport,
1233
+ children: exporting ? "导出中…" : "导出图片",
1234
+ }),
1235
+ exportError ? (0, react_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-label-error)", fontSize: "12px" } , children: exportError }) : null,
1236
+ (0, react_jsx_runtime.jsx)("button", {
1237
+ type: "button",
1238
+ style: S.action,
1239
+ title: "收起脑图面板",
1240
+ onClick: () => onClose(),
1241
+ children: "✕",
1242
+ }),
1243
+ ] }),
1244
+ (0, react_jsx_runtime.jsxs)("div", { style: S.tabRow, children: [
1245
+ (0, react_jsx_runtime.jsx)("button", {
1246
+ type: "button",
1247
+ style: active === TREE_TAB ? { ...S.tab, ...S.tabActive } : (hoverKey === TREE_TAB ? { ...S.tab, ...S.tabHover } : S.tab),
1248
+ title: fsTree.cwd ?? "工作目录",
1249
+ onClick: () => setView("tree"),
1250
+ onMouseEnter: () => setHoverKey(TREE_TAB),
1251
+ onMouseLeave: () => setHoverKey((k) => (k === TREE_TAB ? null : k)),
1252
+ onContextMenu: (e) => {
1253
+ e.preventDefault();
1254
+ e.stopPropagation();
1255
+ setTabMenu({ x: e.clientX, y: e.clientY, path: TREE_TAB });
1256
+ },
1257
+ children: "目录",
1258
+ }, TREE_TAB),
1259
+ shown ? (0, react_jsx_runtime.jsxs)("span", {
1260
+ key: shown,
1261
+ style: { ...S.tabWrap, ...(active !== TREE_TAB ? S.tabActive : {}), ...(active === TREE_TAB && hoverKey === shown ? S.tabHover : {}) },
1262
+ onMouseEnter: () => setHoverKey(shown),
1263
+ onMouseLeave: () => setHoverKey((k) => (k === shown ? null : k)),
1264
+ onContextMenu: (e) => {
1265
+ e.preventDefault();
1266
+ e.stopPropagation();
1267
+ setTabMenu({ x: e.clientX, y: e.clientY, path: shown });
1268
+ },
1269
+ children: [
1270
+ (0, react_jsx_runtime.jsx)("button", {
1271
+ type: "button",
1272
+ style: S.tabTitle,
1273
+ title: shown,
1274
+ onClick: () => setView("mindmap"),
1275
+ children: merged.byPath[shown].rootTitle,
1276
+ }),
1277
+ (0, react_jsx_runtime.jsx)("button", {
1278
+ type: "button",
1279
+ style: S.tabClose,
1280
+ title: "关闭脑图",
1281
+ onClick: () => closeMindmap(shown),
1282
+ children: "✕",
1283
+ }),
1284
+ ],
1285
+ }, shown) : null,
1286
+ ] }),
1287
+ ] }),
1288
+ (0, react_jsx_runtime.jsx)("div", { style: S.body, children: active === TREE_TAB
1289
+ ? renderTree()
1290
+ : (doc && doc.op === "local")
1291
+ ? renderLoading()
1292
+ : tree
1293
+ ? (0, react_jsx_runtime.jsx)(TreeRow, { node: tree })
1294
+ : renderTree() }),
1295
+ tabMenu ? (0, react_jsx_runtime.jsxs)("div", {
1296
+ style: { ...S.treeMenu, left: tabMenu.x, top: tabMenu.y },
1297
+ onContextMenu: (e) => e.preventDefault(),
1298
+ children: [
1299
+ (0, react_jsx_runtime.jsx)("button", {
1300
+ type: "button",
1301
+ style: S.treeMenuItem,
1302
+ onClick: () => {
1303
+ setTabMenu(null);
1304
+ if (tabMenu.path === TREE_TAB) loadTree(undefined);
1305
+ else closeMindmap(tabMenu.path);
1306
+ },
1307
+ children: tabMenu.path === TREE_TAB ? "刷新目录树" : "关闭脑图",
1308
+ }),
1309
+ ],
1310
+ }) : null,
1311
+ ] }) });
1312
+ }
1313
+ //#endregion
1314
+
1315
+ function apply(ctx) {
1316
+ const face = {};
1317
+
1318
+ // 014「布局让位」CSS(better-sidebar 同款机制):面板打开时给 #root 挂
1319
+ // margin-right + 宽度挤压,把聊天区推到左边、面板占右侧腾出的空间,
1320
+ // 互不遮挡。用插件自己的变量 --dsh-mindmap-width,避免与它家
1321
+ // --dsh-sidebar-width 冲突(同属性同优先级时后注入者胜,双开插件并存
1322
+ // 属已知边界,见 docs/014)。
1323
+ if (typeof document !== "undefined") {
1324
+ const style = document.createElement("style");
1325
+ style.setAttribute("data-dsh-mindmap", "layout-push");
1326
+ style.textContent = [
1327
+ "#root{",
1328
+ "margin-right:var(--dsh-mindmap-width,0px);",
1329
+ "width:calc(100% - var(--dsh-mindmap-width,0px));",
1330
+ "transition:margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out),width var(--ds-transition-duration-slow) var(--ds-ease-in-out);",
1331
+ "}",
1332
+ ].join("");
1333
+ document.head.appendChild(style);
1334
+ }
1335
+
1336
+ // 013 目录树 tab:host 自建只读路由 /mindmap/api/tree(dsh-better-sidebar
1337
+ // 同款机制——官方 host.listDirectory 在 native picker 环境必挂,见 013)。
1338
+ // 客户端只读目录,仍无任何写文件通道。
1339
+ face.listTree = async (sessionId, path) => {
1340
+ const response = await fetch("/mindmap/api/tree", {
1341
+ method: "POST",
1342
+ headers: { "content-type": "application/json" },
1343
+ body: JSON.stringify(typeof path === "string" && path ? { sessionId, path } : { sessionId }),
1344
+ });
1345
+ const parsed = await response.json().catch(() => null);
1346
+ if (!response.ok || parsed === null || parsed.ok !== true || !parsed.value) {
1347
+ throw new Error(parsed?.error?.message ?? `HTTP ${response.status}`);
1348
+ }
1349
+ return parsed.value;
1350
+ };
1351
+
1352
+ // 014 overlay 形态(作者拍板,见 docs/014):面板宿主层(position:fixed)
1353
+ // 与 M 按钮一起渲染在 conversation.session.header.actions 槽位里——
1354
+ // better-sidebar 同款「fixed 宿主层自举」思路(它的宿主层挂在
1355
+ // conversation.chat.turnTail);session scope 全套 props 直给,无需跨槽。
1356
+ // details 槽已归还官方(原生「工具详情」栏恢复);shell.overlay 方案
1357
+ // 实测未渲染,已弃用(见 docs/014 排障)。
1358
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1359
+ name: "conversation.session.header.actions",
1360
+ id: "dsh-mindmap",
1361
+ order: 100,
1362
+ inject: () => ({ mindmapFace: face }),
1363
+ }, MindmapSlot));
1364
+ }
1365
+
1366
+ exports.apply = apply;
1367
+ exports.inject = inject;
1368
+ exports.internals = Object.freeze({
1369
+ parseMarkdownToTree,
1370
+ reduceDocuments,
1371
+ mergeDocuments,
1372
+ resultTextOfBlocks,
1373
+ stemOf,
1374
+ buildExportSvg,
1375
+ createIdFactory,
1376
+ relPathWithin,
1377
+ visibleTreeRows,
1378
+ TOOL_NAMES,
1379
+ OPENING_OPS,
1380
+ });
1381
+ return module.exports;
1382
+ }
1383
+ });