dsh-obsidian-notes 0.0.0-stage → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +285 -2
- package/README.zh.md +201 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +4330 -0
- package/lib/types/index.d.ts +3 -0
- package/package.json +70 -4
- package/src/client.js +3229 -0
- package/src/index.js +1293 -0
- package/src/markdown.js +1089 -0
- package/src/session-store.js +146 -0
- package/src/vault-store.js +141 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,4330 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-obsidian-notes",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
const React = require("react");
|
|
7
|
+
void require("react/jsx-runtime");
|
|
8
|
+
/**
|
|
9
|
+
* 轻量 Markdown 渲染器(无第三方依赖,纯 React.createElement),两个出口、
|
|
10
|
+
* 两套块级能力,共同复用行内解析 renderInline(粗斜体/行内代码/链接/图片/双链):
|
|
11
|
+
*
|
|
12
|
+
* 1) renderNumbered —— 笔记面板预览**实际使用**的渲染器(一行源码 = 一行 + 行号)。
|
|
13
|
+
* 块级支持:引用(逐行、嵌套,不再吞行)、callout(类型/别名/标题,正文里的
|
|
14
|
+
* 列表与标题)、代码围栏(```/~~~、info string、未闭合到文末,行号保留)、
|
|
15
|
+
* 列表(无序/有序/嵌套,按实际缩进,tab=4)、任务列表(只读复选框)、
|
|
16
|
+
* 表格(整块真表格、行号在首列、对齐、\| 转义)、标题分级(#~######)、
|
|
17
|
+
* 分隔线、setext 标题(=== / ---)。frontmatter 由服务端读时剥离,不进入预览。
|
|
18
|
+
* 2) renderMarkdown —— 旧的块渲染器,**当前没有产品调用点**;能力比行号路径旧
|
|
19
|
+
* (标题降级、无 setext/任务列表等)。保留但不要再往它上面补面板语法,
|
|
20
|
+
* 面板行为一律以 renderNumbered 为准。
|
|
21
|
+
*
|
|
22
|
+
* 未支持(P2,保持原文显示):数学、mermaid、脚注、删除线/高亮/转义/双反引号、
|
|
23
|
+
* 自动链接、标签、HTML、笔记 transclusion、音频/PDF 嵌入、块引用 ^id、
|
|
24
|
+
* callout 折叠/未知类型/嵌套、aliases 解析。行内强调仍沿用旧解析器(`_` 相邻文本
|
|
25
|
+
* 的边界处理有已知限制),统一留到 P2。
|
|
26
|
+
*
|
|
27
|
+
* 偏移记账(供「选中片段提问」使用):
|
|
28
|
+
* 当 handlers.onText 为函数时,每个文本片段会被包成 <span className="dsh-obs-src"
|
|
29
|
+
* data-obs-src="start,end">,其中 start/end 是该片段在源 markdown 字符串中的绝对
|
|
30
|
+
* 偏移,满足 MD.slice(start,end) === 片段内容。宿主可据此把 DOM 选区反查回源码。
|
|
31
|
+
* 未传 onText 时行为与旧版完全一致(纯 React 文本节点,不产生额外元素)。
|
|
32
|
+
* 依赖工厂闭包内的 `React`(require("react"))。
|
|
33
|
+
*/
|
|
34
|
+
const renderMarkdownModule = (() => {
|
|
35
|
+
const createElement = React.createElement;
|
|
36
|
+
const Fragment = React.Fragment;
|
|
37
|
+
|
|
38
|
+
function escapeHtml(s) {
|
|
39
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeUrl(url) {
|
|
43
|
+
const trimmed = url.trim();
|
|
44
|
+
if (/^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i.test(trimmed)) return trimmed;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 取扩展名(小写带点,无则返回 "")
|
|
49
|
+
function extOf(p) {
|
|
50
|
+
const m = /\.([a-z0-9]+)$/i.exec(p);
|
|
51
|
+
return m ? `.${m[1].toLowerCase()}` : "";
|
|
52
|
+
}
|
|
53
|
+
// 取路径最后一段(POSIX)
|
|
54
|
+
function baseOf(p) {
|
|
55
|
+
const i = p.lastIndexOf("/");
|
|
56
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 图片扩展名白名单(用于 embed 目标判定)
|
|
60
|
+
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Obsidian 的图片尺寸语法:`![[a.png|100]]`(宽)/ `![[a.png|100x200]]`(宽x高)。
|
|
64
|
+
* 非纯数字的别名不是尺寸(Obsidian 里当 alt 文本用),返回 null。
|
|
65
|
+
*/
|
|
66
|
+
function parseImageSize(alias) {
|
|
67
|
+
const m = /^(\d+)(?:x(\d+))?$/.exec(alias);
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
const size = { width: Number(m[1]) };
|
|
70
|
+
if (m[2] !== undefined) size.height = Number(m[2]);
|
|
71
|
+
return size;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 把「图片 src / embed 目标」映射为可显示的 URL:
|
|
76
|
+
* 外部 URL、mailto / # / data: / 服务端绝对路径 → 直接可用;
|
|
77
|
+
* 其它(Vault 相对路径,如 attachments/x.png)→ 交给 handlers.assetUrl 转成
|
|
78
|
+
* `/obsidian/file?path=...`;无 assetUrl 时返回 null(退化为文本,不渲染成图)。
|
|
79
|
+
*/
|
|
80
|
+
function resolveSrc(src, handlers) {
|
|
81
|
+
if (typeof src !== "string") return null;
|
|
82
|
+
const t = src.trim();
|
|
83
|
+
if (/^(https?:|mailto:|#|\/|data:)/i.test(t)) return t;
|
|
84
|
+
if (handlers && typeof handlers.assetUrl === "function") return handlers.assetUrl(t);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── 行内解析(code > img > embed > link > wikilink > strong > em,取最左匹配)──
|
|
89
|
+
// M7:双链/embed 分开捕获 target(m[1])、章节(m[2])、别名(m[3]),
|
|
90
|
+
// 显示时别名优先、其次「target › 章节」,偏移各自映射到源码切片。
|
|
91
|
+
const INLINE_PATTERNS = [
|
|
92
|
+
["code", /`([^`\n]+)`/],
|
|
93
|
+
["img", /!\[([^\]]*)\]\(([^)\s]+)\)/],
|
|
94
|
+
["embed", /!\[\[([^\]|#\n]*)(?:#([^\]|\n]*))?(?:\|([^\]\n]*))?\]\]/],
|
|
95
|
+
["link", /\[([^\]]+)\]\(([^)\s]+)\)/],
|
|
96
|
+
["wiki", /\[\[([^\]|#\n]*)(?:#([^\]|\n]*))?(?:\|([^\]\n]*))?\]\]/],
|
|
97
|
+
["strong", /\*\*([^*\n]+)\*\*|__([^_\n]+)__/],
|
|
98
|
+
["em", /(^|[^*])\*([^*\n]+)\*(?!\*)|(^|[^_])_([^_\n]+)_(?!_)/],
|
|
99
|
+
];
|
|
100
|
+
const patternIndex = (name) => INLINE_PATTERNS.findIndex((p) => p[0] === name);
|
|
101
|
+
|
|
102
|
+
/** 文本片段 → React 节点;handlers.onText 存在时包成带偏移的 span */
|
|
103
|
+
function textNode(text, start, end, key, handlers) {
|
|
104
|
+
if (text === "") return null;
|
|
105
|
+
if (handlers && typeof handlers.onText === "function") {
|
|
106
|
+
return createElement("span", { key, className: "dsh-obs-src", "data-obs-src": `${start},${end}` }, text);
|
|
107
|
+
}
|
|
108
|
+
return createElement(Fragment, { key }, text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 双链 / embed 的源码切分(M7):m 为 INLINE_PATTERNS 的匹配结果,
|
|
113
|
+
* mStart 为 m[0] 在源码里的绝对起点。返回各段的「去掉首尾空白后的文本 + 精确起点」:
|
|
114
|
+
* target(m[1])、section(m[2],`#` 之后)、alias(m[3],`|` 之后)。
|
|
115
|
+
* has* 记录分隔符是否出现(空 section/alias 也保留语义)。
|
|
116
|
+
*/
|
|
117
|
+
function wikilinkParts(m, mStart, embed) {
|
|
118
|
+
const innerStart = mStart + (embed ? 3 : 2); // 跳过 "![[" / "[["
|
|
119
|
+
const targetRaw = m[1];
|
|
120
|
+
const targetLead = targetRaw.length - targetRaw.trimStart().length;
|
|
121
|
+
const target = targetRaw.trim();
|
|
122
|
+
const afterTarget = innerStart + targetRaw.length;
|
|
123
|
+
const secRaw = m[2];
|
|
124
|
+
const section = secRaw === undefined ? "" : secRaw.trim();
|
|
125
|
+
const sectionLead = secRaw === undefined ? 0 : secRaw.length - secRaw.trimStart().length;
|
|
126
|
+
const sectionStart = secRaw === undefined ? -1 : afterTarget + 1 + sectionLead;
|
|
127
|
+
const aliasRaw = m[3];
|
|
128
|
+
const alias = aliasRaw === undefined ? "" : aliasRaw.trim();
|
|
129
|
+
const aliasLead = aliasRaw === undefined ? 0 : aliasRaw.length - aliasRaw.trimStart().length;
|
|
130
|
+
const aliasStart = aliasRaw === undefined
|
|
131
|
+
? -1
|
|
132
|
+
: afterTarget + (secRaw === undefined ? 0 : 1 + secRaw.length) + 1 + aliasLead;
|
|
133
|
+
return {
|
|
134
|
+
target,
|
|
135
|
+
targetStart: innerStart + targetLead,
|
|
136
|
+
section,
|
|
137
|
+
sectionStart,
|
|
138
|
+
hasSection: secRaw !== undefined,
|
|
139
|
+
alias,
|
|
140
|
+
aliasStart,
|
|
141
|
+
hasAlias: aliasRaw !== undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 双链显示节点(M7,契约 §4.7):[[A]] → A;[[A|别名]] → 别名;
|
|
147
|
+
* [[A#标题]] → A › 标题(两个源码 span,中间 ` › ` 是非 span 装饰文本);[[A#^块]] → A › ^块。
|
|
148
|
+
* 可见文本都落在 span.dsh-obs-src 上,` › ` 只是装饰,不包 data-obs-src。
|
|
149
|
+
*/
|
|
150
|
+
function wikilinkDisplayNodes(parts, m, mStart, keyBase, handlers) {
|
|
151
|
+
const out = [];
|
|
152
|
+
if (parts.hasAlias && parts.alias !== "") {
|
|
153
|
+
const n = textNode(parts.alias, parts.aliasStart, parts.aliasStart + parts.alias.length, `${keyBase}-a`, handlers);
|
|
154
|
+
if (n) out.push(n);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
if (parts.hasSection && parts.section !== "") {
|
|
158
|
+
if (parts.target !== "") {
|
|
159
|
+
const tn = textNode(parts.target, parts.targetStart, parts.targetStart + parts.target.length, `${keyBase}-t`, handlers);
|
|
160
|
+
if (tn) out.push(tn);
|
|
161
|
+
out.push(createElement(Fragment, { key: `${keyBase}-sep` }, " › "));
|
|
162
|
+
}
|
|
163
|
+
const sn = textNode(parts.section, parts.sectionStart, parts.sectionStart + parts.section.length, `${keyBase}-s`, handlers);
|
|
164
|
+
if (sn) out.push(sn);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
const n = textNode(parts.target, parts.targetStart, parts.targetStart + parts.target.length, `${keyBase}-t`, handlers);
|
|
168
|
+
if (n) out.push(n);
|
|
169
|
+
// 极端输入(如 [[]])退化为源码原文,避免出现空按钮
|
|
170
|
+
if (out.length === 0) {
|
|
171
|
+
const raw = textNode(m[0], mStart, mStart + m[0].length, `${keyBase}-raw`, handlers);
|
|
172
|
+
if (raw) out.push(raw);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 行内解析。text 对应源码区间 [srcStart, srcStart + text.length)。
|
|
179
|
+
* 返回节点数组。
|
|
180
|
+
*/
|
|
181
|
+
function renderInline(text, handlers, keyBase, srcStart) {
|
|
182
|
+
const nodes = [];
|
|
183
|
+
let cur = srcStart;
|
|
184
|
+
let rest = text;
|
|
185
|
+
let keyIndex = 0;
|
|
186
|
+
const pushText = (s) => {
|
|
187
|
+
const n = textNode(s, cur, cur + s.length, `${keyBase}-t${keyIndex++}`, handlers);
|
|
188
|
+
if (n) nodes.push(n);
|
|
189
|
+
cur += s.length;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
while (rest !== "") {
|
|
193
|
+
const matches = [];
|
|
194
|
+
for (const [name, re] of INLINE_PATTERNS) {
|
|
195
|
+
const m = re.exec(rest);
|
|
196
|
+
if (m) matches.push({ name, m });
|
|
197
|
+
}
|
|
198
|
+
if (matches.length === 0) { pushText(rest); return nodes; }
|
|
199
|
+
matches.sort((a, b) => (a.m.index - b.m.index) || (patternIndex(a.name) - patternIndex(b.name)));
|
|
200
|
+
const { name, m } = matches[0];
|
|
201
|
+
if (m.index > 0) pushText(rest.slice(0, m.index));
|
|
202
|
+
|
|
203
|
+
const contentLen = (() => {
|
|
204
|
+
if (name === "code") return m[1].length;
|
|
205
|
+
if (name === "img") return 0; // alt 不渲染为可选中文本
|
|
206
|
+
if (name === "embed") return m[0].length; // 按钮/图占整个 ![[...]] 源码
|
|
207
|
+
if (name === "link" || name === "wiki") return m[1].length;
|
|
208
|
+
if (name === "strong") return (m[1] ?? m[2]).length;
|
|
209
|
+
return (m[2] ?? m[4]).length; // em(m[0] 含前缀组)
|
|
210
|
+
})();
|
|
211
|
+
const openLen = (() => {
|
|
212
|
+
if (name === "code") return 1; // `
|
|
213
|
+
if (name === "link") return 1; // [
|
|
214
|
+
if (name === "wiki") return 2; // [[
|
|
215
|
+
if (name === "embed") return 0; // 以整段 ![[...]] 作为可选中源码
|
|
216
|
+
if (name === "strong") return 2; // ** 或 __
|
|
217
|
+
if (name === "em") return m[0].length - contentLen - 1; // 前缀 + *
|
|
218
|
+
return 0;
|
|
219
|
+
})();
|
|
220
|
+
const bodyStart = cur + openLen; // cur 已含 m.index
|
|
221
|
+
|
|
222
|
+
if (name === "code") {
|
|
223
|
+
const n = textNode(m[1], bodyStart, bodyStart + m[1].length, `${keyBase}-c${keyIndex++}`, handlers);
|
|
224
|
+
if (n) nodes.push(n);
|
|
225
|
+
} else if (name === "img") {
|
|
226
|
+
const src = resolveSrc(m[2], handlers);
|
|
227
|
+
nodes.push(src === null
|
|
228
|
+
? textNode(m[0], cur, cur + m[0].length, `${keyBase}-i${keyIndex++}`, handlers)
|
|
229
|
+
: createElement("img", { key: `${keyBase}-i${keyIndex++}`, src, alt: m[1], className: "dsh-obs-md-img" }));
|
|
230
|
+
} else if (name === "embed") {
|
|
231
|
+
const parts = wikilinkParts(m, cur, true);
|
|
232
|
+
const target = parts.target;
|
|
233
|
+
const alias = parts.alias;
|
|
234
|
+
// 图片 embed → <img src=/obsidian/file?path=...>;其它 → 退回双链按钮(不做笔记内容嵌入)
|
|
235
|
+
if (IMAGE_EXT.has(extOf(target))) {
|
|
236
|
+
const src = resolveSrc(target, handlers);
|
|
237
|
+
const size = parseImageSize(alias);
|
|
238
|
+
nodes.push(src === null
|
|
239
|
+
? textNode(m[0], cur, cur + m[0].length, `${keyBase}-e${keyIndex++}`, handlers)
|
|
240
|
+
: createElement("img", {
|
|
241
|
+
key: `${keyBase}-e${keyIndex++}`,
|
|
242
|
+
src,
|
|
243
|
+
// Obsidian:`|100` / `|100x200` 是尺寸,其余别名当 alt
|
|
244
|
+
alt: size === null && alias !== "" ? alias : baseOf(target),
|
|
245
|
+
className: "dsh-obs-md-img",
|
|
246
|
+
...(size === null ? {} : size),
|
|
247
|
+
}));
|
|
248
|
+
} else {
|
|
249
|
+
// M7:非图片 embed 显示目标/别名/章节,不再显示 ![[ ]] 原文
|
|
250
|
+
const key = `${keyBase}-e${keyIndex++}`;
|
|
251
|
+
nodes.push(createElement(
|
|
252
|
+
"button",
|
|
253
|
+
{
|
|
254
|
+
key,
|
|
255
|
+
type: "button",
|
|
256
|
+
className: "dsh-obs-wikilink",
|
|
257
|
+
title: `[[${target}]]`,
|
|
258
|
+
onClick: () => {
|
|
259
|
+
if (handlers.onWikilink) handlers.onWikilink(target);
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
wikilinkDisplayNodes(parts, m, cur, key, handlers),
|
|
263
|
+
));
|
|
264
|
+
}
|
|
265
|
+
} else if (name === "link") {
|
|
266
|
+
const href = safeUrl(m[2]);
|
|
267
|
+
if (href === null) {
|
|
268
|
+
nodes.push(textNode(m[0], cur, cur + m[0].length, `${keyBase}-l${keyIndex++}`, handlers));
|
|
269
|
+
} else {
|
|
270
|
+
nodes.push(createElement("a", { key: `${keyBase}-l${keyIndex++}`, href, target: "_blank", rel: "noreferrer" },
|
|
271
|
+
renderInline(m[1], handlers, `${keyBase}-l${keyIndex}`, bodyStart)));
|
|
272
|
+
}
|
|
273
|
+
} else if (name === "wiki") {
|
|
274
|
+
// M7:显示 target / 别名 / target › 章节;title 与 onClick 参数仍是 target(不含 # 章节)
|
|
275
|
+
const parts = wikilinkParts(m, cur, false);
|
|
276
|
+
const target = parts.target;
|
|
277
|
+
const key = `${keyBase}-w${keyIndex++}`;
|
|
278
|
+
nodes.push(createElement(
|
|
279
|
+
"button",
|
|
280
|
+
{
|
|
281
|
+
key,
|
|
282
|
+
type: "button",
|
|
283
|
+
className: "dsh-obs-wikilink",
|
|
284
|
+
title: `[[${target}]]`,
|
|
285
|
+
onClick: () => {
|
|
286
|
+
if (handlers.onWikilink) handlers.onWikilink(target);
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
wikilinkDisplayNodes(parts, m, cur, key, handlers),
|
|
290
|
+
));
|
|
291
|
+
} else {
|
|
292
|
+
const body = name === "strong" ? (m[1] ?? m[2]) : (m[2] ?? m[4]);
|
|
293
|
+
nodes.push(createElement(
|
|
294
|
+
name === "strong" ? "strong" : "em",
|
|
295
|
+
{ key: `${keyBase}-s${keyIndex++}` },
|
|
296
|
+
renderInline(body, handlers, `${keyBase}-s${keyIndex}`, bodyStart),
|
|
297
|
+
));
|
|
298
|
+
}
|
|
299
|
+
cur += m[0].length;
|
|
300
|
+
rest = rest.slice(m.index + m[0].length);
|
|
301
|
+
}
|
|
302
|
+
return nodes;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 单元格源码 → 渲染片段:按源码连续切段,`\|` 单独成 {type:"pipe"} 原子
|
|
307
|
+
* (显示 `|`,偏移只覆盖 `|` 那一个字符,不含反斜杠)。
|
|
308
|
+
*/
|
|
309
|
+
function buildCellSegments(text, start) {
|
|
310
|
+
const segs = [];
|
|
311
|
+
let buf = "";
|
|
312
|
+
let bufStart = 0;
|
|
313
|
+
let i = 0;
|
|
314
|
+
while (i < text.length) {
|
|
315
|
+
if (text[i] === "\\" && text[i + 1] === "|") {
|
|
316
|
+
if (buf !== "") segs.push({ type: "text", text: buf, start: start + bufStart });
|
|
317
|
+
segs.push({ type: "pipe", start: start + i + 1 });
|
|
318
|
+
buf = "";
|
|
319
|
+
bufStart = i + 2;
|
|
320
|
+
i += 2;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
buf += text[i];
|
|
324
|
+
i += 1;
|
|
325
|
+
}
|
|
326
|
+
if (buf !== "") segs.push({ type: "text", text: buf, start: start + bufStart });
|
|
327
|
+
return segs;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 表格行 → [{text, start, segments}]:
|
|
332
|
+
* - 修旧 bug:行首/行尾 `|` 只作分隔,不再多推一个空单元格(`a || b` 中间空列保留);
|
|
333
|
+
* - `\|` 不切列,显示 `|`,偏移只记 `|`(segments 交给 renderInlineCell 精确记账);
|
|
334
|
+
* - `[[ ]]` 内的 `|` 不切列;
|
|
335
|
+
* - text/start 保持旧形状,块渲染器继续可用。
|
|
336
|
+
*/
|
|
337
|
+
function splitTableRow(line, lineStart) {
|
|
338
|
+
const parts = [];
|
|
339
|
+
let bufStart = 0;
|
|
340
|
+
let j = 0;
|
|
341
|
+
let depth = 0;
|
|
342
|
+
while (j < line.length) {
|
|
343
|
+
const ch = line[j];
|
|
344
|
+
if (ch === "\\" && line[j + 1] === "|") { j += 2; continue; }
|
|
345
|
+
if (ch === "|" && depth === 0) {
|
|
346
|
+
parts.push({ raw: line.slice(bufStart, j), start: lineStart + bufStart });
|
|
347
|
+
j += 1;
|
|
348
|
+
bufStart = j;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (ch === "[" && line[j + 1] === "[") { depth = 1; j += 2; continue; }
|
|
352
|
+
if (ch === "]" && line[j + 1] === "]" && depth === 1) { depth = 0; j += 2; continue; }
|
|
353
|
+
j += 1;
|
|
354
|
+
}
|
|
355
|
+
parts.push({ raw: line.slice(bufStart), start: lineStart + bufStart });
|
|
356
|
+
if (parts.length > 1 && parts[parts.length - 1].raw.trim() === "") parts.pop();
|
|
357
|
+
if (parts.length > 1 && parts[0].raw.trim() === "") parts.shift();
|
|
358
|
+
return parts.map((p) => {
|
|
359
|
+
const lead = p.raw.length - p.raw.trimStart().length;
|
|
360
|
+
const text = p.raw.trim();
|
|
361
|
+
const start = p.start + lead;
|
|
362
|
+
return { text, start, segments: buildCellSegments(text, start) };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 单元格内容渲染:无 `\|` 时与 renderInline 完全一致。 */
|
|
367
|
+
function renderInlineCell(cell, handlers, keyBase) {
|
|
368
|
+
const segs = cell.segments;
|
|
369
|
+
if (!segs || segs.length === 0) return [];
|
|
370
|
+
if (segs.length === 1 && segs[0].type === "text") {
|
|
371
|
+
return renderInline(segs[0].text, handlers, keyBase, segs[0].start);
|
|
372
|
+
}
|
|
373
|
+
const out = [];
|
|
374
|
+
let si = 0;
|
|
375
|
+
for (const seg of segs) {
|
|
376
|
+
if (seg.type === "pipe") {
|
|
377
|
+
const n = textNode("|", seg.start, seg.start + 1, `${keyBase}-e${si++}`, handlers);
|
|
378
|
+
if (n) out.push(n);
|
|
379
|
+
} else if (seg.text !== "") {
|
|
380
|
+
out.push(...renderInline(seg.text, handlers, `${keyBase}-e${si++}`, seg.start));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 表格分隔行 → 每列对齐数组("left" / "center" / "right" / "none");
|
|
388
|
+
* 不是分隔行返回 null。分隔行必须含 `|`,每格只由 `-` 与两侧 `:` 组成。
|
|
389
|
+
*/
|
|
390
|
+
function parseTableAlignLine(line) {
|
|
391
|
+
if (!line.includes("|")) return null;
|
|
392
|
+
if (!/^[|\-: \t]+$/.test(line)) return null;
|
|
393
|
+
const cells = splitTableRow(line, 0);
|
|
394
|
+
if (cells.length === 0) return null;
|
|
395
|
+
const aligns = [];
|
|
396
|
+
for (const cell of cells) {
|
|
397
|
+
const t = cell.text;
|
|
398
|
+
if (!/^:?-+:?$/.test(t)) return null;
|
|
399
|
+
if (t.startsWith(":") && t.endsWith(":")) aligns.push("center");
|
|
400
|
+
else if (t.startsWith(":")) aligns.push("left");
|
|
401
|
+
else if (t.endsWith(":")) aligns.push("right");
|
|
402
|
+
else aligns.push("none");
|
|
403
|
+
}
|
|
404
|
+
return aligns;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Obsidian callout(`> [!type] 标题`)────────────────────────────────────
|
|
408
|
+
// 类型 → 展示名 + 图标(emoji,避免依赖 DSH 主题里未必存在的图标集);
|
|
409
|
+
// 配色不用内联色值,改由 CSS 加 [data-callout] 选择器统一给出。
|
|
410
|
+
const CALLOUT_INFO = {
|
|
411
|
+
note: { label: "笔记", icon: "📝" },
|
|
412
|
+
abstract: { label: "摘要", icon: "📋" },
|
|
413
|
+
info: { label: "信息", icon: "ℹ️" },
|
|
414
|
+
todo: { label: "待办", icon: "✅" },
|
|
415
|
+
tip: { label: "提示", icon: "💡" },
|
|
416
|
+
success: { label: "成功", icon: "✔️" },
|
|
417
|
+
question: { label: "疑问", icon: "❓" },
|
|
418
|
+
warning: { label: "注意", icon: "⚠️" },
|
|
419
|
+
failure: { label: "失败", icon: "❌" },
|
|
420
|
+
danger: { label: "危险", icon: "⚡" },
|
|
421
|
+
bug: { label: "缺陷", icon: "🐛" },
|
|
422
|
+
example: { label: "示例", icon: "🧪" },
|
|
423
|
+
quote: { label: "引用", icon: "❝" },
|
|
424
|
+
};
|
|
425
|
+
// Obsidian 内置别名 → 规范类型
|
|
426
|
+
const CALLOUT_ALIAS = {
|
|
427
|
+
summary: "abstract", tldr: "abstract",
|
|
428
|
+
hint: "tip", important: "tip",
|
|
429
|
+
check: "success", done: "success",
|
|
430
|
+
help: "question", faq: "question",
|
|
431
|
+
caution: "warning", attention: "warning",
|
|
432
|
+
fail: "failure", missing: "failure",
|
|
433
|
+
error: "danger",
|
|
434
|
+
cite: "quote",
|
|
435
|
+
none: "note",
|
|
436
|
+
};
|
|
437
|
+
// 整个 `[!type]` 标记 + 可选折叠符 + 可选标题:不区分大小写
|
|
438
|
+
const CALLOUT_RE = /^\[!\s*([A-Za-z][\w-]*)\s*\][+-]?(?:[ \t]+([\s\S]*))?$/;
|
|
439
|
+
|
|
440
|
+
function calloutCanonical(type) {
|
|
441
|
+
const t = String(type).toLowerCase();
|
|
442
|
+
return CALLOUT_INFO[t] ? t : (CALLOUT_ALIAS[t] ?? "note");
|
|
443
|
+
}
|
|
444
|
+
function calloutSpec(type) {
|
|
445
|
+
const canon = calloutCanonical(type);
|
|
446
|
+
return { canon, ...CALLOUT_INFO[canon] };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 引用块首行 → callout 描述;不是 callout 返回 null。
|
|
451
|
+
* srcStart 为「> 」前缀之后内容的源码起点,用于标题文本的偏移记账。
|
|
452
|
+
* 返回 { spec, title, titleStart }:title 为空串表示没有显式标题;
|
|
453
|
+
* titleStart 是标题在源码里的绝对起点(已按实际前后空白修正,供 renderInline 精确记账)。
|
|
454
|
+
*/
|
|
455
|
+
function parseCalloutHeader(header, srcStart) {
|
|
456
|
+
const m = CALLOUT_RE.exec(header);
|
|
457
|
+
if (!m) return null;
|
|
458
|
+
const spec = calloutSpec(m[1]);
|
|
459
|
+
const restRaw = m[2] ?? "";
|
|
460
|
+
const restStart = header.indexOf(m[0]) + m[0].length - restRaw.length;
|
|
461
|
+
const lead = restRaw.length - restRaw.trimStart().length;
|
|
462
|
+
const title = restRaw.trim();
|
|
463
|
+
return {
|
|
464
|
+
spec,
|
|
465
|
+
title,
|
|
466
|
+
titleStart: title === "" ? null : srcStart + restStart + lead,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 单行块起始判定(供段落换行识别) */
|
|
471
|
+
function isBlockStart(l) {
|
|
472
|
+
return /^(#{1,6})\s+/.test(l) || /^```/.test(l) || /^>\s?/.test(l) ||
|
|
473
|
+
/^\s*(?:[-*+]\s|\d+[.)]\s+)/.test(l) || /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(l);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 解析一个块(block)为 React 节点;blockStart 为该块在源码中的绝对起点。 */
|
|
477
|
+
function renderBlock(block, blockStart, handlers, keyBase) {
|
|
478
|
+
const lines = block.split("\n");
|
|
479
|
+
const lineStarts = [];
|
|
480
|
+
{
|
|
481
|
+
let ps = blockStart;
|
|
482
|
+
for (const l of lines) { lineStarts.push(ps); ps += l.length + 1; }
|
|
483
|
+
}
|
|
484
|
+
const first = lines[0];
|
|
485
|
+
|
|
486
|
+
// 围栏代码块:主体整体一个文本节点(含换行,偏移连续)
|
|
487
|
+
const fence = /^```([\w+-]*)\s*$/.exec(first);
|
|
488
|
+
if (fence) {
|
|
489
|
+
const closing = lines.findIndex((l, i) => i > 0 && /^```\s*$/.test(l));
|
|
490
|
+
const bodyLines = closing > 0 ? lines.slice(1, closing) : lines.slice(1);
|
|
491
|
+
const body = bodyLines.join("\n");
|
|
492
|
+
const codeNode = body === ""
|
|
493
|
+
? createElement("code", null)
|
|
494
|
+
: createElement("code", null, textNode(body, lineStarts[1], lineStarts[1] + body.length, `${keyBase}-pre`, handlers));
|
|
495
|
+
return createElement("pre", { key: keyBase, className: "dsh-obs-code" }, codeNode);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 标题
|
|
499
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(first);
|
|
500
|
+
if (heading) {
|
|
501
|
+
const level = Math.min(heading[1].length + 2, 6);
|
|
502
|
+
const content = heading[2];
|
|
503
|
+
return createElement(`h${level}`, { key: keyBase },
|
|
504
|
+
renderInline(content, handlers, keyBase, lineStarts[0] + (first.length - content.length)));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 分隔线
|
|
508
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(first)) {
|
|
509
|
+
return createElement("hr", { key: keyBase });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 引用块(连续 > 行;逐行渲染 + <br> 保证文本节点偏移连续)
|
|
513
|
+
if (lines.every((l) => l === "" || /^>\s?/.test(l))) {
|
|
514
|
+
const firstQuote = /^>\s?/.exec(first);
|
|
515
|
+
const headerContent = firstQuote ? first.slice(firstQuote[0].length) : first;
|
|
516
|
+
const headerStart = lineStarts[0] + (firstQuote ? firstQuote[0].length : 0);
|
|
517
|
+
const callout = parseCalloutHeader(headerContent, headerStart);
|
|
518
|
+
if (callout) {
|
|
519
|
+
// callout 主体:去掉每行 `>` 前缀,正文行内语法照常解析
|
|
520
|
+
const bodyLines = [];
|
|
521
|
+
for (let i = 1; i < lines.length; i++) {
|
|
522
|
+
const l = lines[i];
|
|
523
|
+
if (l === "") continue;
|
|
524
|
+
const q = /^>\s?/.exec(l);
|
|
525
|
+
const prefix = q ? q[0].length : 0;
|
|
526
|
+
const text = l.slice(prefix);
|
|
527
|
+
if (text === "") continue;
|
|
528
|
+
bodyLines.push({
|
|
529
|
+
text,
|
|
530
|
+
start: lineStarts[i] + prefix,
|
|
531
|
+
key: `${keyBase}-cb${i}`,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const body = [];
|
|
535
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
536
|
+
const bl = bodyLines[i];
|
|
537
|
+
body.push(...renderInline(bl.text, handlers, bl.key, bl.start));
|
|
538
|
+
if (i < bodyLines.length - 1) body.push(createElement(Fragment, { key: `${bl.key}-br` }, "\n"));
|
|
539
|
+
}
|
|
540
|
+
// 严格口径(契约 §4.6):有显式标题时徽标只留图标、标题文本单独显示;
|
|
541
|
+
// 没有显式标题时才回落到「图标 + 类型名」。
|
|
542
|
+
const headChildren = [
|
|
543
|
+
createElement("span", { key: `${keyBase}-ci`, className: "dsh-obs-callout-icon" }, callout.spec.icon),
|
|
544
|
+
];
|
|
545
|
+
if (callout.title !== "") {
|
|
546
|
+
headChildren.push(createElement("span", { key: `${keyBase}-ct`, className: "dsh-obs-callout-title" },
|
|
547
|
+
renderInline(callout.title, handlers, `${keyBase}-ct`, callout.titleStart)));
|
|
548
|
+
} else {
|
|
549
|
+
headChildren.push(createElement("span", { key: `${keyBase}-ctl`, className: "dsh-obs-callout-title" }, callout.spec.label));
|
|
550
|
+
}
|
|
551
|
+
return createElement(
|
|
552
|
+
"div",
|
|
553
|
+
{
|
|
554
|
+
key: keyBase,
|
|
555
|
+
className: "dsh-obs-callout",
|
|
556
|
+
"data-callout": callout.spec.canon,
|
|
557
|
+
},
|
|
558
|
+
createElement(
|
|
559
|
+
"div",
|
|
560
|
+
{ key: `${keyBase}-ch`, className: "dsh-obs-callout-head" },
|
|
561
|
+
...headChildren,
|
|
562
|
+
),
|
|
563
|
+
createElement("div", { key: `${keyBase}-cbd`, className: "dsh-obs-callout-body" }, body),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const inner = [];
|
|
567
|
+
let ki = 0;
|
|
568
|
+
for (let i = 0; i < lines.length; i++) {
|
|
569
|
+
const l = lines[i];
|
|
570
|
+
if (l === "") continue;
|
|
571
|
+
const m = /^>\s?/.exec(l);
|
|
572
|
+
const content = l.slice(m ? m[0].length : 0);
|
|
573
|
+
inner.push(...renderInline(content, handlers, `${keyBase}-q${ki}`, lineStarts[i] + (m ? m[0].length : 0)));
|
|
574
|
+
ki++;
|
|
575
|
+
if (i < lines.length - 1 && lines[i + 1] !== "") inner.push(createElement(Fragment, { key: `${keyBase}-qbr${ki}` }, "\n"));
|
|
576
|
+
}
|
|
577
|
+
return createElement("blockquote", { key: keyBase }, inner);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 列表(无序/有序)
|
|
581
|
+
if (lines.every((l) => /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(l) || l === "")) {
|
|
582
|
+
const ordered = /^\s*\d+[.)]\s+/.test(lines.find((l) => l !== "") ?? "");
|
|
583
|
+
const ul = createElement(ordered ? "ol" : "ul", { key: keyBase });
|
|
584
|
+
let li = 0;
|
|
585
|
+
for (let i = 0; i < lines.length; i++) {
|
|
586
|
+
const l = lines[i];
|
|
587
|
+
if (l === "") continue;
|
|
588
|
+
const m = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.exec(l);
|
|
589
|
+
ul.props.children = (ul.props.children ?? []).concat(
|
|
590
|
+
createElement("li", { key: `${keyBase}-li${li++}` },
|
|
591
|
+
renderInline(l.slice(m[0].length), handlers, `${keyBase}-li${li}`, lineStarts[i] + m[0].length)));
|
|
592
|
+
}
|
|
593
|
+
return ul;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 表格(首行含 | 且第二行是分隔行)
|
|
597
|
+
if (lines.length >= 2 && lines[0].includes("|") && /^\s*\|?\s*:?-{3,}/.test(lines[1] ?? "")) {
|
|
598
|
+
const table = createElement("table", { key: keyBase });
|
|
599
|
+
const thead = createElement("thead", null);
|
|
600
|
+
const htr = createElement("tr", null);
|
|
601
|
+
htr.props.children = splitTableRow(lines[0], lineStarts[0]).map((cell, j) =>
|
|
602
|
+
createElement("th", { key: `${keyBase}-th${j}` }, renderInlineCell(cell, handlers, `${keyBase}-th${j}`)));
|
|
603
|
+
thead.props.children = htr;
|
|
604
|
+
const tbody = createElement("tbody", null);
|
|
605
|
+
let trIdx = 0;
|
|
606
|
+
for (let i = 2; i < lines.length; i++) {
|
|
607
|
+
if (!lines[i].includes("|")) continue;
|
|
608
|
+
const tr = createElement("tr", { key: `${keyBase}-tr${trIdx}` });
|
|
609
|
+
tr.props.children = splitTableRow(lines[i], lineStarts[i]).map((cell, j) =>
|
|
610
|
+
createElement("td", { key: `${keyBase}-td${trIdx}-${j}` }, renderInlineCell(cell, handlers, `${keyBase}-td${trIdx}-${j}`)));
|
|
611
|
+
tbody.props.children = (tbody.props.children ?? []).concat(tr);
|
|
612
|
+
trIdx++;
|
|
613
|
+
}
|
|
614
|
+
table.props.children = [thead, tbody];
|
|
615
|
+
return table;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// 段落:逐行渲染(每行一个文本节点 + <br>),保证文本节点偏移连续
|
|
619
|
+
const inner = [];
|
|
620
|
+
let ki = 0;
|
|
621
|
+
const nonEmpty = [];
|
|
622
|
+
for (let i = 0; i < lines.length; i++) if (lines[i] !== "") nonEmpty.push(i);
|
|
623
|
+
for (let idx = 0; idx < nonEmpty.length; idx++) {
|
|
624
|
+
const i = nonEmpty[idx];
|
|
625
|
+
inner.push(...renderInline(lines[i], handlers, `${keyBase}-p${ki}`, lineStarts[i]));
|
|
626
|
+
ki++;
|
|
627
|
+
if (idx < nonEmpty.length - 1) inner.push(createElement(Fragment, { key: `${keyBase}-pbr${ki}` }, "\n"));
|
|
628
|
+
}
|
|
629
|
+
return createElement("p", { key: keyBase }, inner);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 将 markdown 正文渲染为一组块级 React 节点。 */
|
|
633
|
+
function renderMarkdown(markdown, handlers = {}) {
|
|
634
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
635
|
+
const blocks = normalized.split(/\n\s*\n/).filter((b) => b.trim() !== "");
|
|
636
|
+
if (blocks.length === 0) {
|
|
637
|
+
return createElement("p", { className: "dsh-obs-empty" }, "(空笔记)");
|
|
638
|
+
}
|
|
639
|
+
const nodes = [];
|
|
640
|
+
let pos = 0;
|
|
641
|
+
for (const block of blocks) {
|
|
642
|
+
const idx = normalized.indexOf(block, pos);
|
|
643
|
+
const blockStart = idx >= 0 ? idx : pos;
|
|
644
|
+
nodes.push(renderBlock(block, blockStart, handlers, `b${nodes.length}`));
|
|
645
|
+
pos = blockStart + block.length;
|
|
646
|
+
}
|
|
647
|
+
return createElement(Fragment, null, nodes);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── 行号路径(renderNumbered)的块级识别 helper(P0:M1 引用 / M2 围栏 / M3 列表 / M4 表格)──
|
|
651
|
+
|
|
652
|
+
/** 缩进折算成列数:tab 按 4 空格;不写死每级宽度,层级由调用方按实际缩进比较。 */
|
|
653
|
+
function indentCols(s) {
|
|
654
|
+
let n = 0;
|
|
655
|
+
for (let k = 0; k < s.length; k++) n += s[k] === "\t" ? 4 : 1;
|
|
656
|
+
return n;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** 水平线行(列表识别前先排除):`---` / `***` / `___`,两侧允许空白。 */
|
|
660
|
+
function isHrLine(line) {
|
|
661
|
+
return /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* 围栏开行:行首 ≤3 空格 + 连续 ≥3 个 ` 或 ~。
|
|
666
|
+
* 返回 { ch, len, lang, langStart }:lang 为 info string 第一个词(无则空串),
|
|
667
|
+
* langStart 为语言词在该行内的偏移;不是开围栏返回 null。
|
|
668
|
+
*/
|
|
669
|
+
function matchFenceOpen(line) {
|
|
670
|
+
const m = /^( {0,3})(`{3,}|~{3,})([\s\S]*)$/.exec(line);
|
|
671
|
+
if (!m) return null;
|
|
672
|
+
const marks = m[2];
|
|
673
|
+
const info = m[3];
|
|
674
|
+
// 反引号围栏的 info string 里不能再出现反引号(CommonMark),否则当普通行
|
|
675
|
+
if (marks[0] === "`" && info.includes("`")) return null;
|
|
676
|
+
const infoTrim = info.length - info.trimStart().length;
|
|
677
|
+
const rest = info.slice(infoTrim);
|
|
678
|
+
const lang = rest === "" ? "" : rest.split(/[ \t]+/)[0];
|
|
679
|
+
return { ch: marks[0], len: marks.length, lang, langStart: m[1].length + marks.length + infoTrim };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** 围栏闭行:同字符、长度不短于开围栏、行内只有空白。 */
|
|
683
|
+
function isFenceClose(line, ch, minLen) {
|
|
684
|
+
const m = /^( {0,3})(`+|~+)[ \t]*$/.exec(line);
|
|
685
|
+
return !!m && m[2][0] === ch && m[2].length >= minLen;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 引用行前缀:行首 ≤3 空格 + 连续 `>` 标记(标记之间允许一个空格/tab,兼容 `> >`)。
|
|
690
|
+
* 返回 { prefixLen, depth }(depth = 引用标记个数,1 起);不是引用行返回 null。
|
|
691
|
+
*/
|
|
692
|
+
function quotePrefixInfo(line) {
|
|
693
|
+
const m = /^( {0,3}(?:>[ \t]?)+)/.exec(line);
|
|
694
|
+
if (!m) return null;
|
|
695
|
+
const prefix = m[1];
|
|
696
|
+
let depth = 0;
|
|
697
|
+
for (let k = 0; k < prefix.length; k++) if (prefix[k] === ">") depth += 1;
|
|
698
|
+
return { prefixLen: prefix.length, depth };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 列表行:缩进 + 无序/有序标记 + 至少一个空白 + 正文(任务标记在正文里再拆)
|
|
702
|
+
const LIST_RE = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)([\s\S]*)$/;
|
|
703
|
+
const TASK_RE = /^\[([ xX])\](?:[ \t]+|$)/;
|
|
704
|
+
// 层级 → 无序符号轮换(0/1/2 起再循环)
|
|
705
|
+
const UL_MARKERS = ["•", "◦", "▪"];
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 列表行识别(单行):返回 { indent, ordered, num, body, bodyStart, task };
|
|
709
|
+
* 不是列表行返回 null。先排除水平线;任务标记 `[ ]`/`[x]` 单独拆出,
|
|
710
|
+
* bodyStart 是行内偏移(相对行首),任务从 `[x] ` 之后算正文。
|
|
711
|
+
*/
|
|
712
|
+
function parseListLine(line) {
|
|
713
|
+
if (line.trim() === "" || isHrLine(line)) return null;
|
|
714
|
+
const m = LIST_RE.exec(line);
|
|
715
|
+
if (!m) return null;
|
|
716
|
+
const ordered = /\d/.test(m[2].charAt(0));
|
|
717
|
+
const prefixLen = m[1].length + m[2].length + m[3].length;
|
|
718
|
+
const rawBody = m[4];
|
|
719
|
+
// 任务标记不区分有序/无序(方案 M3:「内容以 ^\[([ xX])\]\s+ 开头时」)
|
|
720
|
+
const tm = TASK_RE.exec(rawBody);
|
|
721
|
+
const task = tm === null
|
|
722
|
+
? null
|
|
723
|
+
: {
|
|
724
|
+
checked: tm[1].toLowerCase() === "x",
|
|
725
|
+
body: rawBody.slice(tm[0].length),
|
|
726
|
+
bodyStart: prefixLen + tm[0].length,
|
|
727
|
+
};
|
|
728
|
+
return {
|
|
729
|
+
indent: indentCols(m[1]),
|
|
730
|
+
ordered,
|
|
731
|
+
num: ordered ? parseInt(m[2], 10) : null,
|
|
732
|
+
body: task ? task.body : rawBody,
|
|
733
|
+
bodyStart: task ? task.bodyStart : prefixLen,
|
|
734
|
+
task,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* M5 标题行:`#{1,6}` + 至少一个空白 + 内容。
|
|
740
|
+
* 返回 { level, content, contentOffset }(contentOffset 相对行首,按实际匹配长度算,
|
|
741
|
+
* `# 多空格` 也对);不是标题返回 null。行号路径与 callout 正文共用。
|
|
742
|
+
*/
|
|
743
|
+
function splitHeadingLine(line) {
|
|
744
|
+
const m = /^(#{1,6})[ \t]+([\s\S]*)$/.exec(line);
|
|
745
|
+
if (!m) return null;
|
|
746
|
+
const content = m[2];
|
|
747
|
+
return { level: m[1].length, content, contentOffset: m[0].length - content.length };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* M5 setext 内容行的排除条件:当前行本身已是别的块起始(标题/列表/引用/围栏/hr)时,
|
|
752
|
+
* 下一行的 `===` / `---` 不能把它变成 setext 标题(先 setext 后 hr 的前提)。
|
|
753
|
+
*/
|
|
754
|
+
function isBlockStartLine(line) {
|
|
755
|
+
return splitHeadingLine(line) !== null ||
|
|
756
|
+
/^ {0,3}(?:`{3,}|~{3,})/.test(line) ||
|
|
757
|
+
/^ {0,3}>/.test(line) ||
|
|
758
|
+
parseListLine(line) !== null ||
|
|
759
|
+
isHrLine(line);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* 行号预览模式(预览态「每行一个行标号」):
|
|
764
|
+
* 每个源码行渲染为一行 <div class="dsh-obs-md-row">,左侧行号 .dsh-obs-md-ln、
|
|
765
|
+
* 内容 .dsh-obs-md-lc(行内 markdown 保留,且允许自然折行)。行号与源码行一一对应,
|
|
766
|
+
* 不依赖固定宽度/不折行,长行折行也不影响后续行号对齐。选中映射(.dsh-obs-src 偏移)
|
|
767
|
+
* 仍由 renderInline 的 onText 记账保留。
|
|
768
|
+
* 块级语法(P0):引用逐行(M1)、围栏代码逐行(M2)、列表/任务逐行(M3)、表格整块(M4)。
|
|
769
|
+
* 表格是唯一不按行拆的块:行号放在每个 tr 的首列(.dsh-obs-md-tln),保证 1..N 连续。
|
|
770
|
+
*/
|
|
771
|
+
function renderNumbered(markdown, handlers = {}) {
|
|
772
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
773
|
+
const rows = [];
|
|
774
|
+
const lines = normalized.split("\n");
|
|
775
|
+
let pos = 0;
|
|
776
|
+
let lineNo = 0;
|
|
777
|
+
let i = 0;
|
|
778
|
+
let keyIdx = 0;
|
|
779
|
+
|
|
780
|
+
const nextLine = () => {
|
|
781
|
+
const text = lines[i];
|
|
782
|
+
const start = pos;
|
|
783
|
+
lineNo += 1;
|
|
784
|
+
pos += text.length + 1;
|
|
785
|
+
i += 1;
|
|
786
|
+
return { text, start, num: lineNo };
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// 同一个 callout 的连续行共用一个 groupId → 首尾行加样式类(圆角收尾)
|
|
790
|
+
let groupId = -1;
|
|
791
|
+
let inCallout = false;
|
|
792
|
+
let calloutFirst = true;
|
|
793
|
+
|
|
794
|
+
const makeRow = (num, rowClass, lcInner, lcClass, extraProps) =>
|
|
795
|
+
createElement("div", {
|
|
796
|
+
key: `n${keyIdx++}`,
|
|
797
|
+
className: `dsh-obs-md-row${rowClass ? ` ${rowClass}` : ""}`,
|
|
798
|
+
...(extraProps ?? {}),
|
|
799
|
+
},
|
|
800
|
+
createElement("span", { className: "dsh-obs-md-ln" }, String(num)),
|
|
801
|
+
createElement("span", { className: `dsh-obs-md-lc${lcClass ? ` ${lcClass}` : ""}` }, lcInner),
|
|
802
|
+
);
|
|
803
|
+
// "code code-head" → "dsh-obs-md-code dsh-obs-md-code-head"(块级样式类统一前缀)
|
|
804
|
+
const rowClassOf = (cls) =>
|
|
805
|
+
cls ? cls.split(/\s+/).filter(Boolean).map((c) => `dsh-obs-md-${c}`).join(" ") : "";
|
|
806
|
+
const closeCallout = () => {
|
|
807
|
+
if (!inCallout) return;
|
|
808
|
+
const last = rows[rows.length - 1];
|
|
809
|
+
if (last) last.props.className += " dsh-obs-md-callout-end";
|
|
810
|
+
inCallout = false;
|
|
811
|
+
};
|
|
812
|
+
/**
|
|
813
|
+
* gid === null → 普通行;否则为 callout 的某一行(同一 callout 共用一个 gid)。
|
|
814
|
+
* extraProps 承载契约属性:data-quote-depth / data-code-group / data-list-* 等。
|
|
815
|
+
*/
|
|
816
|
+
const pushRow = (num, cls, inner, gid, extraProps) => {
|
|
817
|
+
const rowClass = rowClassOf(cls);
|
|
818
|
+
if (gid === null) {
|
|
819
|
+
closeCallout();
|
|
820
|
+
rows.push(makeRow(num, rowClass, inner, null, extraProps));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (!inCallout || gid !== groupId) { closeCallout(); groupId = gid; inCallout = true; calloutFirst = true; }
|
|
824
|
+
const lcClass = calloutFirst ? "dsh-obs-md-callout-head" : "dsh-obs-md-callout-body";
|
|
825
|
+
calloutFirst = false;
|
|
826
|
+
rows.push(makeRow(num, rowClass, inner, lcClass, { "data-callout-group": String(gid), ...(extraProps ?? {}) }));
|
|
827
|
+
};
|
|
828
|
+
// 块级节点(表格)直接进 rows 序列:先收尾上一个 callout,避免末行样式打错行
|
|
829
|
+
const pushBlock = (node) => { closeCallout(); rows.push(node); };
|
|
830
|
+
|
|
831
|
+
// 列表层级栈:按实际缩进比较(tab=4 列),不写死每级宽度;有序编号按层计数
|
|
832
|
+
let listIndentStack = [];
|
|
833
|
+
let listSeq = [];
|
|
834
|
+
let listKindAtDepth = [];
|
|
835
|
+
const resetListState = () => { listIndentStack = []; listSeq = []; listKindAtDepth = []; };
|
|
836
|
+
const listDepthFor = (indent) => {
|
|
837
|
+
if (listIndentStack.length === 0) { listIndentStack.push(indent); return 0; }
|
|
838
|
+
let top = listIndentStack.length - 1;
|
|
839
|
+
while (top > 0 && indent < listIndentStack[top]) top -= 1;
|
|
840
|
+
if (indent > listIndentStack[top]) {
|
|
841
|
+
listIndentStack.length = top + 1;
|
|
842
|
+
listIndentStack.push(indent);
|
|
843
|
+
const depth = top + 1;
|
|
844
|
+
listKindAtDepth[depth] = undefined;
|
|
845
|
+
listSeq[depth] = undefined;
|
|
846
|
+
return depth;
|
|
847
|
+
}
|
|
848
|
+
listIndentStack.length = top + 1;
|
|
849
|
+
if (indent < listIndentStack[top]) listIndentStack[top] = indent;
|
|
850
|
+
return top;
|
|
851
|
+
};
|
|
852
|
+
const listMarkerOf = (info, depth) => {
|
|
853
|
+
if (!info.ordered) {
|
|
854
|
+
listKindAtDepth[depth] = "ul";
|
|
855
|
+
return UL_MARKERS[depth % UL_MARKERS.length];
|
|
856
|
+
}
|
|
857
|
+
if (listKindAtDepth[depth] === "ol" && typeof listSeq[depth] === "number") listSeq[depth] += 1;
|
|
858
|
+
else listSeq[depth] = info.num;
|
|
859
|
+
listKindAtDepth[depth] = "ol";
|
|
860
|
+
return `${listSeq[depth]}.`;
|
|
861
|
+
};
|
|
862
|
+
/**
|
|
863
|
+
* 列表行 → lc 子节点 + data-list-* 属性(普通行与 callout 正文共用,M6 复用 M3)。
|
|
864
|
+
* srcStart 为该行「正文内容」的源码起点(任务行已跳过 [x] 标记)。
|
|
865
|
+
*/
|
|
866
|
+
const listRowContent = (info, keyBase, srcStart) => {
|
|
867
|
+
const depth = listDepthFor(info.indent);
|
|
868
|
+
const inner = [createElement("span", { key: `${keyBase}-mk`, className: "dsh-obs-md-list-marker" }, listMarkerOf(info, depth))];
|
|
869
|
+
if (info.task) {
|
|
870
|
+
inner.push(createElement("input", {
|
|
871
|
+
key: `${keyBase}-cb`,
|
|
872
|
+
type: "checkbox",
|
|
873
|
+
className: "dsh-obs-md-task-check",
|
|
874
|
+
disabled: true,
|
|
875
|
+
checked: info.task.checked,
|
|
876
|
+
}));
|
|
877
|
+
}
|
|
878
|
+
inner.push(createElement("span", { key: `${keyBase}-bd`, className: "dsh-obs-md-list-body" },
|
|
879
|
+
renderInline(info.body, handlers, `${keyBase}-bd`, srcStart + info.bodyStart)));
|
|
880
|
+
const props = {
|
|
881
|
+
"data-list-depth": String(depth),
|
|
882
|
+
"data-list-kind": info.task ? "task" : info.ordered ? "ol" : "ul",
|
|
883
|
+
};
|
|
884
|
+
if (info.task) props["data-checked"] = info.task.checked ? "1" : "0";
|
|
885
|
+
return { inner, props };
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
while (i < lines.length) {
|
|
889
|
+
const cur = nextLine();
|
|
890
|
+
|
|
891
|
+
// ── M2 围栏代码块(``` 与 ~~~ 等价):整块每行占号,正文不解析行内 ──
|
|
892
|
+
const fence = matchFenceOpen(cur.text);
|
|
893
|
+
if (fence) {
|
|
894
|
+
resetListState();
|
|
895
|
+
const group = `f${cur.start}`;
|
|
896
|
+
const lang = fence.lang;
|
|
897
|
+
const langNode = lang === ""
|
|
898
|
+
? null
|
|
899
|
+
: textNode(lang, cur.start + fence.langStart, cur.start + fence.langStart + lang.length, `n${keyIdx}lang`, handlers);
|
|
900
|
+
pushRow(cur.num, "code code-head",
|
|
901
|
+
createElement("span", { key: `n${keyIdx}lang`, className: "dsh-obs-md-code-lang" }, langNode),
|
|
902
|
+
null, { "data-code-group": group });
|
|
903
|
+
let closed = false;
|
|
904
|
+
while (i < lines.length) {
|
|
905
|
+
if (isFenceClose(lines[i], fence.ch, fence.len)) {
|
|
906
|
+
const close = nextLine();
|
|
907
|
+
pushRow(close.num, "code code-end", null, null, { "data-code-group": group });
|
|
908
|
+
closed = true;
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
const body = nextLine();
|
|
912
|
+
pushRow(body.num, "code",
|
|
913
|
+
body.text === "" ? null : textNode(body.text, body.start, body.start + body.text.length, `n${keyIdx}body`, handlers),
|
|
914
|
+
null, { "data-code-group": group });
|
|
915
|
+
}
|
|
916
|
+
if (!closed) {
|
|
917
|
+
// 未闭合吃到文末:最后一行加 code-end(只有开围栏行时它同时是 head 和 end)
|
|
918
|
+
const last = rows[rows.length - 1];
|
|
919
|
+
if (last) last.props.className += " dsh-obs-md-code-end";
|
|
920
|
+
}
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ── M1 引用:连续 `>` 行逐行成像(旧实现会吞掉后续行);首行是 callout 才走分组 ──
|
|
925
|
+
const quote = quotePrefixInfo(cur.text);
|
|
926
|
+
if (quote) {
|
|
927
|
+
const block = [cur];
|
|
928
|
+
while (i < lines.length && quotePrefixInfo(lines[i])) block.push(nextLine());
|
|
929
|
+
resetListState();
|
|
930
|
+
const callout = parseCalloutHeader(
|
|
931
|
+
block[0].text.slice(quote.prefixLen),
|
|
932
|
+
block[0].start + quote.prefixLen,
|
|
933
|
+
);
|
|
934
|
+
if (callout) {
|
|
935
|
+
const gid = `c${block[0].start}`;
|
|
936
|
+
const spec = callout.spec;
|
|
937
|
+
// 严格口径(契约 §4.6,Lead 裁决):有显式标题时徽标只显示图标、标题文本单独显示;
|
|
938
|
+
// 没有显式标题时才显示「图标 + 类型名」。
|
|
939
|
+
const badgeChildren = [
|
|
940
|
+
createElement("span", { key: "i", className: "dsh-obs-md-callout-ico" }, spec.icon),
|
|
941
|
+
];
|
|
942
|
+
if (callout.title === "") {
|
|
943
|
+
badgeChildren.push(createElement("span", { key: "l", className: "dsh-obs-md-callout-label" }, spec.label));
|
|
944
|
+
}
|
|
945
|
+
const head = [createElement("span", { key: "badge", className: "dsh-obs-md-callout-badge" }, badgeChildren)];
|
|
946
|
+
if (callout.title !== "") {
|
|
947
|
+
head.push(createElement("span", { key: "t", className: "dsh-obs-md-callout-title" },
|
|
948
|
+
renderInline(callout.title, handlers, `n${keyIdx}ct`, callout.titleStart)));
|
|
949
|
+
}
|
|
950
|
+
pushRow(block[0].num, "quote", head, gid, { "data-callout": spec.canon });
|
|
951
|
+
// 正文块(契约 §4.6):去掉 `>` 前缀后分类为 列表/任务、标题、空行、段落。
|
|
952
|
+
for (let k = 1; k < block.length; k++) {
|
|
953
|
+
const b = block[k];
|
|
954
|
+
const qi = quotePrefixInfo(b.text);
|
|
955
|
+
const prefixLen = qi ? qi.prefixLen : 0;
|
|
956
|
+
const bodyText = b.text.slice(prefixLen);
|
|
957
|
+
const bodyStart = b.start + prefixLen;
|
|
958
|
+
const keyBase = `n${keyIdx}c${k}`;
|
|
959
|
+
const baseProps = { "data-callout": spec.canon };
|
|
960
|
+
if (bodyText === "") {
|
|
961
|
+
// `>` 空行:行号与分组样式保留,内容为空
|
|
962
|
+
pushRow(b.num, "quote", null, gid, baseProps);
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
const bodyList = parseListLine(bodyText);
|
|
966
|
+
if (bodyList) {
|
|
967
|
+
const { inner, props } = listRowContent(bodyList, keyBase, bodyStart);
|
|
968
|
+
pushRow(b.num, "quote list", inner, gid, { ...baseProps, ...props });
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const bodyHeading = splitHeadingLine(bodyText);
|
|
972
|
+
if (bodyHeading) {
|
|
973
|
+
pushRow(b.num, `quote heading h${bodyHeading.level}`,
|
|
974
|
+
renderInline(bodyHeading.content, handlers, keyBase, bodyStart + bodyHeading.contentOffset), gid,
|
|
975
|
+
{ ...baseProps, "data-heading-level": String(bodyHeading.level) });
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
pushRow(b.num, "quote", renderInline(bodyText, handlers, keyBase, bodyStart), gid, baseProps);
|
|
979
|
+
}
|
|
980
|
+
resetListState();
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
for (let k = 0; k < block.length; k++) {
|
|
984
|
+
const qi = quotePrefixInfo(block[k].text);
|
|
985
|
+
pushRow(block[k].num, "quote",
|
|
986
|
+
renderInline(block[k].text.slice(qi.prefixLen), handlers, `n${keyIdx}q${k}`, block[k].start + qi.prefixLen),
|
|
987
|
+
null, { "data-quote-depth": String(qi.depth) });
|
|
988
|
+
}
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ── M4 表格:整块 <table>,行号放每行首列(表头/分隔/正文行都占号)──
|
|
993
|
+
const headerCells = cur.text.includes("|") ? splitTableRow(cur.text, cur.start) : null;
|
|
994
|
+
const aligns = headerCells && headerCells.length > 0 && i < lines.length
|
|
995
|
+
? parseTableAlignLine(lines[i])
|
|
996
|
+
: null;
|
|
997
|
+
if (aligns) {
|
|
998
|
+
resetListState();
|
|
999
|
+
const sep = nextLine();
|
|
1000
|
+
const bodyLines = [];
|
|
1001
|
+
while (i < lines.length && lines[i].includes("|")) bodyLines.push(nextLine());
|
|
1002
|
+
const gid = `t${cur.start}`;
|
|
1003
|
+
const colCount = headerCells.length;
|
|
1004
|
+
const alignOf = (j) => aligns[j] ?? "none";
|
|
1005
|
+
// 表头/正文列数不齐时按表头补齐(多出的截掉)
|
|
1006
|
+
const padCells = (cells, line) => {
|
|
1007
|
+
const out = cells.slice(0, colCount);
|
|
1008
|
+
while (out.length < colCount) out.push({ text: "", start: line.start + line.text.length, segments: [] });
|
|
1009
|
+
return out;
|
|
1010
|
+
};
|
|
1011
|
+
const thead = createElement("thead", null,
|
|
1012
|
+
createElement("tr", null,
|
|
1013
|
+
createElement("th", { key: `${gid}-th-ln`, className: "dsh-obs-md-tln" }, String(cur.num)),
|
|
1014
|
+
...headerCells.map((cell, j) => createElement(
|
|
1015
|
+
"th",
|
|
1016
|
+
{ key: `${gid}-th${j}`, "data-align": alignOf(j) },
|
|
1017
|
+
renderInlineCell(cell, handlers, `${gid}-th${j}`),
|
|
1018
|
+
)),
|
|
1019
|
+
));
|
|
1020
|
+
const tbodyChildren = [
|
|
1021
|
+
createElement("tr", { key: `${gid}-sep`, className: "dsh-obs-md-tsep" },
|
|
1022
|
+
createElement("td", { key: `${gid}-sep-ln`, className: "dsh-obs-md-tln" }, String(sep.num)),
|
|
1023
|
+
createElement("td", { key: `${gid}-sep-cell`, className: "dsh-obs-md-tsep-cell", colspan: colCount }, null),
|
|
1024
|
+
),
|
|
1025
|
+
];
|
|
1026
|
+
for (const bodyLine of bodyLines) {
|
|
1027
|
+
const cells = padCells(splitTableRow(bodyLine.text, bodyLine.start), bodyLine);
|
|
1028
|
+
tbodyChildren.push(createElement("tr", { key: `${gid}-row${bodyLine.num}`, className: "dsh-obs-md-trow" },
|
|
1029
|
+
createElement("td", { key: `${gid}-row${bodyLine.num}-ln`, className: "dsh-obs-md-tln" }, String(bodyLine.num)),
|
|
1030
|
+
...cells.map((cell, j) => createElement(
|
|
1031
|
+
"td",
|
|
1032
|
+
{ key: `${gid}-row${bodyLine.num}-c${j}`, "data-align": alignOf(j) },
|
|
1033
|
+
renderInlineCell(cell, handlers, `${gid}-row${bodyLine.num}-c${j}`),
|
|
1034
|
+
)),
|
|
1035
|
+
));
|
|
1036
|
+
}
|
|
1037
|
+
const tbody = createElement("tbody", null, ...tbodyChildren);
|
|
1038
|
+
pushBlock(createElement("div", {
|
|
1039
|
+
key: `n${keyIdx++}`,
|
|
1040
|
+
className: "dsh-obs-md-tablewrap",
|
|
1041
|
+
"data-table-group": gid,
|
|
1042
|
+
}, createElement("table", { className: "dsh-obs-md-table" }, thead, tbody)));
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ── M3 列表(无序/有序/嵌套)+ 任务列表(只读复选框)──
|
|
1047
|
+
const listInfo = parseListLine(cur.text);
|
|
1048
|
+
if (listInfo) {
|
|
1049
|
+
const { inner, props } = listRowContent(listInfo, `n${keyIdx}`, cur.start);
|
|
1050
|
+
pushRow(cur.num, "list", inner, null, props);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
resetListState();
|
|
1054
|
+
|
|
1055
|
+
// ── M5 setext:当前行非块起始、下一行是 `=+` / `-{3,}` 下划线 → 当前行 h1/h2;
|
|
1056
|
+
// 下划线行 dsh-obs-md-setext-underline 空内容占号(不显示 === / ---);先 setext 后 hr。──
|
|
1057
|
+
const setextUnderline = i < lines.length ? lines[i] : null;
|
|
1058
|
+
if (cur.text.trim() !== "" && setextUnderline !== null &&
|
|
1059
|
+
/^(?:=+|-{3,})\s*$/.test(setextUnderline) && !isBlockStartLine(cur.text)) {
|
|
1060
|
+
const level = setextUnderline.trimStart().startsWith("=") ? 1 : 2;
|
|
1061
|
+
pushRow(cur.num, `heading h${level}`,
|
|
1062
|
+
renderInline(cur.text, handlers, `n${keyIdx}`, cur.start), null,
|
|
1063
|
+
{ "data-heading-level": String(level) });
|
|
1064
|
+
const underline = nextLine();
|
|
1065
|
+
pushRow(underline.num, "setext-underline", null, null);
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ── M5 标题分级(# ~ ######):内容偏移按实际匹配长度(# 后多空格也对)──
|
|
1070
|
+
const headingMatch = splitHeadingLine(cur.text);
|
|
1071
|
+
if (headingMatch) {
|
|
1072
|
+
pushRow(cur.num, `heading h${headingMatch.level}`,
|
|
1073
|
+
renderInline(headingMatch.content, handlers, `n${keyIdx}`, cur.start + headingMatch.contentOffset), null,
|
|
1074
|
+
{ "data-heading-level": String(headingMatch.level) });
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ── M5 分隔线:行 class dsh-obs-md-hr,lc 里放 hr.dsh-obs-md-hr-line,行号保留 ──
|
|
1079
|
+
if (isHrLine(cur.text)) {
|
|
1080
|
+
pushRow(cur.num, "hr",
|
|
1081
|
+
[createElement("hr", { key: `n${keyIdx}hr`, className: "dsh-obs-md-hr-line" })], null);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// 其余按普通段落行渲染
|
|
1086
|
+
pushRow(cur.num, null, renderInline(cur.text, handlers, `n${keyIdx}`, cur.start), null);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
closeCallout();
|
|
1090
|
+
return createElement(Fragment, null, rows);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
return { renderMarkdown, renderNumbered };
|
|
1094
|
+
})();
|
|
1095
|
+
|
|
1096
|
+
const { renderMarkdown, renderNumbered } = renderMarkdownModule;
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* dsh-obsidian-vault client 半边(侧边栏重构版):
|
|
1100
|
+
* - 常驻接管 sidebar.workspaces(priority:-100 阴影官方 WorkspaceBrowser):
|
|
1101
|
+
* · 核心会话浏览器:工作区分组 + 展开/收起 + 打开会话 + 新建会话 + 会话搜索
|
|
1102
|
+
* · 「📚 笔记」顶级行(与工作区目录并列)+ 行下展开的笔记树(复用 TreeView)
|
|
1103
|
+
* - 右侧可拖拽/调宽笔记 Dock(shell.overlay),预览 + 编辑 + 保存
|
|
1104
|
+
* - 「引用到对话」按钮:把 @绝对路径 注入对话输入框(兜底:复制到剪贴板)
|
|
1105
|
+
* - Vault 目录选择(ctx.uiWorkspace.pickDirectory + 粘贴绝对路径)
|
|
1106
|
+
* 注意:新建会话 / 选择目录由官方 ui-workspace 插件注册的 ctx.uiWorkspace
|
|
1107
|
+
* 提供,纯控制器 ctx.workspaces(IWorkspaces)上没有 startSession /
|
|
1108
|
+
* pickDirectory(见 navigationFacade 注释)。
|
|
1109
|
+
* 依赖工厂闭包内的 React、renderMarkdown / renderNumbered(由 build 拼接在同一 factory 内)。
|
|
1110
|
+
*/
|
|
1111
|
+
(() => {
|
|
1112
|
+
const { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } = React;
|
|
1113
|
+
const createElement = React.createElement;
|
|
1114
|
+
const Fragment = React.Fragment;
|
|
1115
|
+
|
|
1116
|
+
// ── 本地化 ─────────────────────────────────────────────────────────────────
|
|
1117
|
+
const NS = "dsh-obsidian";
|
|
1118
|
+
const zh = {
|
|
1119
|
+
wsSection: "工作区",
|
|
1120
|
+
addWorkspace: "添加工作区",
|
|
1121
|
+
searchSessions: "搜索会话",
|
|
1122
|
+
sessionSearchPlaceholder: "搜索会话…",
|
|
1123
|
+
newSession: "新建会话",
|
|
1124
|
+
ungrouped: "未分组",
|
|
1125
|
+
noSessions: "暂无会话",
|
|
1126
|
+
renameWorkspacePrompt: "工作区新名称",
|
|
1127
|
+
deleteWorkspaceConfirm: "删除工作区「{name}」?目录与文件保留。",
|
|
1128
|
+
deleteWorkspaceSessionsConfirm: "删除工作区「{name}」?目录与文件保留,但其中的 {n} 个会话将一并彻底删除(含子代理会话,日志从磁盘删除,不可恢复)。",
|
|
1129
|
+
renameSessionPrompt: "会话新标题",
|
|
1130
|
+
deleteSession: "彻底删除会话",
|
|
1131
|
+
deleteSessionConfirm: "彻底删除会话「{name}」?将从磁盘删除其日志{caveat},不可恢复。想保留历史请改用「归档」。",
|
|
1132
|
+
deleteSessionMore: ",以及它的 {n} 个子代理会话",
|
|
1133
|
+
archiveSession: "归档会话",
|
|
1134
|
+
archiveSessionConfirm: "归档会话「{name}」?仅从列表移除,日志保留在磁盘上。",
|
|
1135
|
+
archiveUngrouped: "归档全部未分组会话",
|
|
1136
|
+
archiveUngroupedConfirm: "归档「{name}」中的 {n} 个会话?仅从列表移除,日志保留。",
|
|
1137
|
+
deleteUngrouped: "彻底删除全部未分组会话",
|
|
1138
|
+
deleteUngroupedConfirm: "彻底删除「{name}」中的 {n} 个会话?将从磁盘删除日志,不可恢复。",
|
|
1139
|
+
archived: "已归档",
|
|
1140
|
+
deletedPermanently: "已彻底删除 {n} 个会话",
|
|
1141
|
+
deletedPermanentlyPartial: "已彻底删除 {n} 个会话,{m} 个失败",
|
|
1142
|
+
deletedPermanentlyStickySuffix: ";其中 {m} 个仍被 DSH 进程持有内存副本,已从列表隐藏,重启 DSH 后彻底消失",
|
|
1143
|
+
deletedPermanentlyMissingSuffix: ";另有 {m} 个日志本就不在磁盘上,已一并从列表移除",
|
|
1144
|
+
deletedPermanentlyFailedSuffix: ";{m} 个删除失败",
|
|
1145
|
+
staleSessionsRemoved: "已从列表移除 {n} 条日志已不存在的会话记录",
|
|
1146
|
+
sessionDeleteUnavailable: "删除接口未生效:请重启 DSH(桌面版:退出后重新打开)再重试",
|
|
1147
|
+
sessionRunning: "该会话正在运行(含正在运行的子代理),无法彻底删除:请等它结束后重试",
|
|
1148
|
+
sessionLivenessUnavailable: "无法确认该会话是否在运行,已拒绝删除:请重启 DSH 后重试",
|
|
1149
|
+
nothingDeleted: "没有找到可删除的会话",
|
|
1150
|
+
openUnavailable: "当前环境无法打开会话:DSH 侧没有 uiWorkspace.openSession(版本过旧或服务缺失)",
|
|
1151
|
+
subagentsRunning: "{n} 个子代理运行中",
|
|
1152
|
+
renamed: "已重命名",
|
|
1153
|
+
deleted: "已删除",
|
|
1154
|
+
// ── 批量操作(工作区 / 会话)──────────────────────────────────────────────
|
|
1155
|
+
// 标题行按钮只批量工作区(勾选后删除);工作区行按钮只批量该工作区的会话
|
|
1156
|
+
// (归档 / 彻底删除)。两者互斥,工具条不提供其它动作。
|
|
1157
|
+
batchWorkspaces: "批量操作工作区",
|
|
1158
|
+
batchSessions: "批量操作会话",
|
|
1159
|
+
batchWorkspacesLabel: "已选 {n} 个工作区",
|
|
1160
|
+
batchSessionsLabel: "已选 {n} 个会话",
|
|
1161
|
+
batchSelectAll: "全选",
|
|
1162
|
+
batchInvert: "反选",
|
|
1163
|
+
batchClear: "清空勾选",
|
|
1164
|
+
batchExit: "取消批量",
|
|
1165
|
+
batchPickWorkspace: "选择该工作区",
|
|
1166
|
+
batchPickSession: "选择该会话",
|
|
1167
|
+
batchArchiveShort: "归档",
|
|
1168
|
+
batchDeleteShort: "彻底删除",
|
|
1169
|
+
batchDeleteWsShort: "删除工作区",
|
|
1170
|
+
archivePicked: "归档选中会话",
|
|
1171
|
+
archivePickedConfirm: "归档选中的 {n} 个会话?仅从列表移除,日志保留在磁盘上。",
|
|
1172
|
+
archivedPicked: "已归档 {n} 个会话",
|
|
1173
|
+
archivedPickedPartial: "已归档 {n} 个会话,{m} 个失败",
|
|
1174
|
+
deletePicked: "彻底删除选中会话",
|
|
1175
|
+
deletePickedConfirm: "彻底删除选中的 {n} 个会话?(含其子代理会话)将从磁盘删除日志,不可恢复。",
|
|
1176
|
+
deletePickedWorkspaces: "删除选中工作区",
|
|
1177
|
+
deletePickedWorkspacesConfirm: "删除选中的 {n} 个工作区?目录与文件保留,但其中的 {m} 个会话将一并彻底删除(含子代理会话,日志从磁盘删除,不可恢复)。",
|
|
1178
|
+
deletePickedWorkspacesConfirmNoSessions: "删除选中的 {n} 个工作区?目录与文件保留。",
|
|
1179
|
+
deletedWorkspaceWithSessions: "已删除工作区「{name}」,并彻底删除 {n} 个会话",
|
|
1180
|
+
deletedWorkspaces: "已删除 {n} 个工作区",
|
|
1181
|
+
deletedWorkspacesWithSessions: "已删除 {n} 个工作区,并彻底删除 {m} 个会话",
|
|
1182
|
+
deletedWorkspacesPartial: "已删除 {n} 个工作区,{m} 个未删除;已彻底删除 {k} 个会话",
|
|
1183
|
+
deleteWorkspaceSessionsFailed: "工作区未删除:其中的会话未能全部彻底删除(可能正在运行),请稍后重试",
|
|
1184
|
+
confirm: "确定",
|
|
1185
|
+
collapse: "收起",
|
|
1186
|
+
"time.justNow": "刚刚",
|
|
1187
|
+
"time.minutes": "{n}分钟",
|
|
1188
|
+
"time.hours": "{n}小时",
|
|
1189
|
+
"time.days": "{n}天",
|
|
1190
|
+
notes: "笔记",
|
|
1191
|
+
vaultRoot: "Vault 目录",
|
|
1192
|
+
pickVault: "选择 Vault 目录",
|
|
1193
|
+
pickVaultTitle: "选择文件路径",
|
|
1194
|
+
pasteVault: "粘贴路径",
|
|
1195
|
+
pasteVaultTitle: "直接粘贴绝对路径",
|
|
1196
|
+
newNote: "新建笔记",
|
|
1197
|
+
newFolder: "新建文件夹",
|
|
1198
|
+
newFolderPrompt: "文件夹名称",
|
|
1199
|
+
refresh: "刷新",
|
|
1200
|
+
search: "搜索笔记",
|
|
1201
|
+
searchPlaceholder: "搜索标题或正文…",
|
|
1202
|
+
close: "关闭",
|
|
1203
|
+
noVault: "Vault 目录不存在,请先选择目录",
|
|
1204
|
+
empty: "暂无笔记",
|
|
1205
|
+
loading: "加载中…",
|
|
1206
|
+
edit: "编辑",
|
|
1207
|
+
save: "保存",
|
|
1208
|
+
saving: "保存中…",
|
|
1209
|
+
cancel: "取消",
|
|
1210
|
+
discard: "放弃未保存的修改?",
|
|
1211
|
+
cite: "引用到对话",
|
|
1212
|
+
cited: "已引用到对话",
|
|
1213
|
+
copied: "已复制引用,可粘贴到输入框",
|
|
1214
|
+
noInput: "未找到对话输入框,引用已复制到剪贴板",
|
|
1215
|
+
citeFail: "引用失败",
|
|
1216
|
+
ask: "提问",
|
|
1217
|
+
explain: "解释",
|
|
1218
|
+
askSend: "发送",
|
|
1219
|
+
askCancel: "Esc 取消",
|
|
1220
|
+
askCap: "针对选中内容提问…({n} 字符)",
|
|
1221
|
+
askChars: "{n} 字符",
|
|
1222
|
+
askCited: "已引用选中片段({n} 字符)",
|
|
1223
|
+
promptLine: "第 {a} 行",
|
|
1224
|
+
promptLines: "第 {a}–{b} 行",
|
|
1225
|
+
promptDefaultQ: "请解释这段内容",
|
|
1226
|
+
promptLong: "(片段较长,共 {n} 字符,请阅读 @文件 第 {a}–{b} 行)",
|
|
1227
|
+
promptQuestion: "【问题】",
|
|
1228
|
+
rename: "重命名",
|
|
1229
|
+
delete: "删除",
|
|
1230
|
+
deleteConfirm: "确认删除该笔记?此操作不可恢复。",
|
|
1231
|
+
deleteNoteConfirm: "确认删除笔记「{name}」?此操作不可恢复。",
|
|
1232
|
+
deleteFolderConfirm: "确认删除文件夹「{name}」及其中的所有内容?此操作不可恢复。",
|
|
1233
|
+
resize: "拖动调整宽度",
|
|
1234
|
+
move: "拖动移动窗口",
|
|
1235
|
+
noteNamePrompt: "笔记名称(.md)",
|
|
1236
|
+
renamePrompt: "新名称(.md)",
|
|
1237
|
+
wikilinkNotFound: "未找到笔记",
|
|
1238
|
+
error: "出错了",
|
|
1239
|
+
imageInserted: "已插入图片",
|
|
1240
|
+
attachDir: "附件目录",
|
|
1241
|
+
attachDirPick: "选择附件目录(Vault 内)",
|
|
1242
|
+
attachDirEdit: "手写附件目录",
|
|
1243
|
+
attachDirClear: "清空(回到 Vault 根)",
|
|
1244
|
+
attachDirPlaceholder: "Vault 相对路径,如 docs/images",
|
|
1245
|
+
imageRemoved: "已同步删除未引用的图片",
|
|
1246
|
+
attachDirConfirm: "更改截图落盘目录:{from} → {to}。会**自动把原目录下的截图移动到新目录**、删除原目录(若空),并同步更新笔记里的引用路径。确认更改?",
|
|
1247
|
+
attachSettings: "附件设置(位置 / 显示 / 清理)",
|
|
1248
|
+
attachLocation: "附件位置",
|
|
1249
|
+
attachMode_root: "Vault 根",
|
|
1250
|
+
attachMode_fixed: "固定目录",
|
|
1251
|
+
attachMode_note: "与笔记同目录",
|
|
1252
|
+
attachMode_sub: "笔记目录下子目录",
|
|
1253
|
+
attachSubfolder: "子目录名(如 attachments)",
|
|
1254
|
+
attachHideDir: "隐藏图片目录(只含图片、没有笔记的目录)",
|
|
1255
|
+
attachDeleteOrphans: "保存笔记时自动删除无用附件(默认关)",
|
|
1256
|
+
migrated: "迁移 {n} 张截图,更新 {m} 篇笔记",
|
|
1257
|
+
};
|
|
1258
|
+
const en = {
|
|
1259
|
+
wsSection: "Workspaces",
|
|
1260
|
+
addWorkspace: "Add workspace",
|
|
1261
|
+
searchSessions: "Search sessions",
|
|
1262
|
+
sessionSearchPlaceholder: "Search sessions…",
|
|
1263
|
+
newSession: "New session",
|
|
1264
|
+
ungrouped: "Ungrouped",
|
|
1265
|
+
noSessions: "No sessions",
|
|
1266
|
+
renameWorkspacePrompt: "New workspace title",
|
|
1267
|
+
deleteWorkspaceConfirm: "Delete workspace “{name}”? The directory and files stay.",
|
|
1268
|
+
deleteWorkspaceSessionsConfirm: "Delete workspace “{name}”? The directory and files stay, but its {n} session(s) will be permanently deleted too (including subagent sessions; their logs are erased from disk and cannot be recovered).",
|
|
1269
|
+
renameSessionPrompt: "New session title",
|
|
1270
|
+
deleteSession: "Delete session permanently",
|
|
1271
|
+
deleteSessionConfirm: "Permanently delete session “{name}”? Its log{caveat} will be erased from disk and cannot be recovered. Use Archive to keep the history.",
|
|
1272
|
+
deleteSessionMore: " and its {n} subagent sessions",
|
|
1273
|
+
archiveSession: "Archive session",
|
|
1274
|
+
archiveSessionConfirm: "Archive session “{name}”? It leaves the list; the log stays on disk.",
|
|
1275
|
+
archiveUngrouped: "Archive all ungrouped sessions",
|
|
1276
|
+
archiveUngroupedConfirm: "Archive all {n} sessions in “{name}”? They leave the list; logs are kept.",
|
|
1277
|
+
deleteUngrouped: "Permanently delete all ungrouped sessions",
|
|
1278
|
+
deleteUngroupedConfirm: "Permanently delete all {n} sessions in “{name}”? Their logs will be erased from disk and cannot be recovered.",
|
|
1279
|
+
archived: "Archived",
|
|
1280
|
+
deletedPermanently: "Permanently deleted {n} session(s)",
|
|
1281
|
+
deletedPermanentlyPartial: "Permanently deleted {n} session(s), {m} failed",
|
|
1282
|
+
deletedPermanentlyStickySuffix: "; {m} still cached in the running DSH process — hidden from the list now, gone for good after a DSH restart",
|
|
1283
|
+
deletedPermanentlyMissingSuffix: "; {m} more had no log left on disk and were removed from the list",
|
|
1284
|
+
deletedPermanentlyFailedSuffix: "; {m} failed",
|
|
1285
|
+
staleSessionsRemoved: "Removed {n} session record(s) whose logs no longer exist",
|
|
1286
|
+
sessionDeleteUnavailable: "Delete endpoint unavailable: restart DSH (desktop: quit and reopen) and retry",
|
|
1287
|
+
sessionRunning: "This session is running (including any running subagent), so it cannot be deleted: wait for it to finish and retry",
|
|
1288
|
+
sessionLivenessUnavailable: "Could not determine whether this session is running, so deletion was refused: restart DSH and retry",
|
|
1289
|
+
nothingDeleted: "No session found to delete",
|
|
1290
|
+
openUnavailable: "This DSH build cannot open sessions: uiWorkspace.openSession is unavailable",
|
|
1291
|
+
subagentsRunning: "{n} subagent(s) running",
|
|
1292
|
+
renamed: "Renamed",
|
|
1293
|
+
deleted: "Deleted",
|
|
1294
|
+
batchWorkspaces: "Batch workspaces",
|
|
1295
|
+
batchSessions: "Batch sessions",
|
|
1296
|
+
batchWorkspacesLabel: "{n} workspace(s) selected",
|
|
1297
|
+
batchSessionsLabel: "{n} session(s) selected",
|
|
1298
|
+
batchSelectAll: "Select all",
|
|
1299
|
+
batchInvert: "Invert",
|
|
1300
|
+
batchClear: "Clear selection",
|
|
1301
|
+
batchExit: "Exit batch",
|
|
1302
|
+
batchPickWorkspace: "Select this workspace",
|
|
1303
|
+
batchPickSession: "Select this session",
|
|
1304
|
+
batchArchiveShort: "Archive",
|
|
1305
|
+
batchDeleteShort: "Delete",
|
|
1306
|
+
batchDeleteWsShort: "Delete workspaces",
|
|
1307
|
+
archivePicked: "Archive selected sessions",
|
|
1308
|
+
archivePickedConfirm: "Archive the {n} selected session(s)? They leave the list; logs stay on disk.",
|
|
1309
|
+
archivedPicked: "Archived {n} session(s)",
|
|
1310
|
+
archivedPickedPartial: "Archived {n} session(s), {m} failed",
|
|
1311
|
+
deletePicked: "Permanently delete selected sessions",
|
|
1312
|
+
deletePickedConfirm: "Permanently delete the {n} selected session(s) (including their subagent sessions)? Their logs will be erased from disk and cannot be recovered.",
|
|
1313
|
+
deletePickedWorkspaces: "Delete selected workspaces",
|
|
1314
|
+
deletePickedWorkspacesConfirm: "Delete the {n} selected workspace(s)? The directories and files stay, but their {m} session(s) will be permanently deleted too (including subagent sessions; their logs are erased from disk and cannot be recovered).",
|
|
1315
|
+
deletePickedWorkspacesConfirmNoSessions: "Delete the {n} selected workspace(s)? The directories and files stay.",
|
|
1316
|
+
deletedWorkspaceWithSessions: "Deleted workspace “{name}” and permanently deleted {n} session(s)",
|
|
1317
|
+
deletedWorkspaces: "Deleted {n} workspace(s)",
|
|
1318
|
+
deletedWorkspacesWithSessions: "Deleted {n} workspace(s) and permanently deleted {m} session(s)",
|
|
1319
|
+
deletedWorkspacesPartial: "Deleted {n} workspace(s), {m} not deleted; permanently deleted {k} session(s)",
|
|
1320
|
+
deleteWorkspaceSessionsFailed: "Workspace not deleted: its sessions could not all be permanently deleted (they may be running) — retry later",
|
|
1321
|
+
confirm: "OK",
|
|
1322
|
+
collapse: "Collapse",
|
|
1323
|
+
"time.justNow": "now",
|
|
1324
|
+
"time.minutes": "{n}m",
|
|
1325
|
+
"time.hours": "{n}h",
|
|
1326
|
+
"time.days": "{n}d",
|
|
1327
|
+
notes: "Notes",
|
|
1328
|
+
vaultRoot: "Vault root",
|
|
1329
|
+
pickVault: "Choose vault directory",
|
|
1330
|
+
pickVaultTitle: "Choose a folder path",
|
|
1331
|
+
pasteVault: "Paste path",
|
|
1332
|
+
pasteVaultTitle: "Paste an absolute path directly",
|
|
1333
|
+
newNote: "New note",
|
|
1334
|
+
newFolder: "New folder",
|
|
1335
|
+
newFolderPrompt: "Folder name",
|
|
1336
|
+
refresh: "Refresh",
|
|
1337
|
+
search: "Search notes",
|
|
1338
|
+
searchPlaceholder: "Search titles or content…",
|
|
1339
|
+
close: "Close",
|
|
1340
|
+
noVault: "Vault directory does not exist — choose a directory first",
|
|
1341
|
+
empty: "No notes yet",
|
|
1342
|
+
loading: "Loading…",
|
|
1343
|
+
edit: "Edit",
|
|
1344
|
+
save: "Save",
|
|
1345
|
+
saving: "Saving…",
|
|
1346
|
+
cancel: "Cancel",
|
|
1347
|
+
discard: "Discard unsaved changes?",
|
|
1348
|
+
cite: "Cite to chat",
|
|
1349
|
+
cited: "Cited to chat",
|
|
1350
|
+
copied: "Reference copied — paste it into the input",
|
|
1351
|
+
noInput: "No chat input found — reference copied to clipboard",
|
|
1352
|
+
citeFail: "Cite failed",
|
|
1353
|
+
ask: "Ask",
|
|
1354
|
+
explain: "Explain",
|
|
1355
|
+
askSend: "Send",
|
|
1356
|
+
askCancel: "Esc to cancel",
|
|
1357
|
+
askCap: "Ask about the selection…({n} chars)",
|
|
1358
|
+
askChars: "{n} chars",
|
|
1359
|
+
askCited: "Cited the selected passage ({n} chars)",
|
|
1360
|
+
promptLine: "Line {a}",
|
|
1361
|
+
promptLines: "Lines {a}–{b}",
|
|
1362
|
+
promptDefaultQ: "Please explain this passage",
|
|
1363
|
+
promptLong: "(long selection: {n} chars; read @file lines {a}–{b})",
|
|
1364
|
+
promptQuestion: "Question:",
|
|
1365
|
+
rename: "Rename",
|
|
1366
|
+
delete: "Delete",
|
|
1367
|
+
deleteConfirm: "Delete this note? This cannot be undone.",
|
|
1368
|
+
deleteNoteConfirm: "Delete note “{name}”? This cannot be undone.",
|
|
1369
|
+
deleteFolderConfirm: "Delete folder “{name}” and everything inside it? This cannot be undone.",
|
|
1370
|
+
resize: "Drag to resize",
|
|
1371
|
+
move: "Drag to move",
|
|
1372
|
+
noteNamePrompt: "Note name (.md)",
|
|
1373
|
+
renamePrompt: "New name (.md)",
|
|
1374
|
+
wikilinkNotFound: "Note not found",
|
|
1375
|
+
error: "Error",
|
|
1376
|
+
imageInserted: "Image inserted",
|
|
1377
|
+
attachDir: "Attachment dir",
|
|
1378
|
+
attachDirPick: "Choose attachment dir (inside vault)",
|
|
1379
|
+
attachDirEdit: "Type an attachment dir",
|
|
1380
|
+
attachDirClear: "Clear (back to vault root)",
|
|
1381
|
+
attachDirPlaceholder: "Vault-relative path, e.g. docs/images",
|
|
1382
|
+
imageRemoved: "Deleted unreferenced image(s)",
|
|
1383
|
+
attachDirConfirm: "Change screenshot dir: {from} → {to}. This will automatically move existing screenshots to the new dir, remove the old dir (if empty), and update note references. Continue?",
|
|
1384
|
+
attachSettings: "Attachment settings (location / visibility / cleanup)",
|
|
1385
|
+
attachLocation: "Attachment location",
|
|
1386
|
+
attachMode_root: "Vault root",
|
|
1387
|
+
attachMode_fixed: "Fixed folder",
|
|
1388
|
+
attachMode_note: "Same folder as note",
|
|
1389
|
+
attachMode_sub: "Subfolder under note",
|
|
1390
|
+
attachSubfolder: "Subfolder name (e.g. attachments)",
|
|
1391
|
+
attachHideDir: "Hide image folders (folders without any notes)",
|
|
1392
|
+
attachDeleteOrphans: "Auto-delete orphaned attachments on save (off by default)",
|
|
1393
|
+
migrated: "moved {n} image(s), updated {m} note(s)",
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// ── 共享外部 store(跨槽位:侧边栏 / Dock / Toast)──────────────────────────
|
|
1397
|
+
const LS_NOTES_OPEN = "dsh-obsidian:notesOpen";
|
|
1398
|
+
const LS_DOCK_WIDTH = "dsh-obsidian:dockWidth";
|
|
1399
|
+
|
|
1400
|
+
function readNotesOpen() {
|
|
1401
|
+
try {
|
|
1402
|
+
return localStorage.getItem(LS_NOTES_OPEN) === "1";
|
|
1403
|
+
} catch {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
let shared = { notesOpen: readNotesOpen(), openNote: null, vaultRev: 0, toast: null, noteCount: 0, prompt: null };
|
|
1409
|
+
const sharedListeners = new Set();
|
|
1410
|
+
|
|
1411
|
+
function patchShared(patch) {
|
|
1412
|
+
shared = { ...shared, ...patch };
|
|
1413
|
+
try {
|
|
1414
|
+
if (patch.notesOpen !== undefined) localStorage.setItem(LS_NOTES_OPEN, patch.notesOpen ? "1" : "0");
|
|
1415
|
+
} catch { /* ignore */ }
|
|
1416
|
+
for (const fn of sharedListeners) fn();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function subscribeShared(fn) {
|
|
1420
|
+
sharedListeners.add(fn);
|
|
1421
|
+
return () => {
|
|
1422
|
+
sharedListeners.delete(fn);
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function useShared() {
|
|
1427
|
+
return useSyncExternalStore(subscribeShared, () => shared);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
let toastSeq = 0;
|
|
1431
|
+
function showToast(message) {
|
|
1432
|
+
// 用序号而不是文案判归属:同一条提示连着弹两次(如两次重命名成功)时,
|
|
1433
|
+
// 旧写法会让第一条的定时器提前把第二条清掉。
|
|
1434
|
+
const seq = ++toastSeq;
|
|
1435
|
+
patchShared({ toast: message });
|
|
1436
|
+
window.setTimeout(() => {
|
|
1437
|
+
if (toastSeq === seq) patchShared({ toast: null });
|
|
1438
|
+
}, 2600);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* 应用内单行文本输入(`window.prompt` 的替代)。
|
|
1443
|
+
*
|
|
1444
|
+
* **桌面版硬约束**:Electron 的 renderer 把 `window.prompt` 实现成
|
|
1445
|
+
* `throw new Error("prompt() is not supported.")`,所以桌面版里任何
|
|
1446
|
+
* `window.prompt(...)` 都会直接抛异常 —— 表现为「点 ✎ / + / 📁 / 📋 毫无
|
|
1447
|
+
* 反应」。这里统一走 shell.overlay 里的输入卡片,web 与桌面版行为一致。
|
|
1448
|
+
* @param title - 输入框上方的标题(同时也是输入框 placeholder)。
|
|
1449
|
+
* @param initial - 初始值。
|
|
1450
|
+
* @returns 确认时返回输入文本,取消/关闭返回 null。
|
|
1451
|
+
*/
|
|
1452
|
+
function askText(title, initial = "") {
|
|
1453
|
+
// 同一时刻只留一个输入卡片:上一个未决的按「取消」收尾,避免 Promise 悬挂。
|
|
1454
|
+
if (shared.prompt) shared.prompt.resolve(null);
|
|
1455
|
+
return new Promise((resolve) => {
|
|
1456
|
+
patchShared({ prompt: { title, initial, resolve } });
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** 关闭输入卡片并把结果交回 {@link askText} 的 Promise。 */
|
|
1461
|
+
function closePrompt(value) {
|
|
1462
|
+
const current = shared.prompt;
|
|
1463
|
+
patchShared({ prompt: null });
|
|
1464
|
+
if (current) current.resolve(value);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// ── HTTP 封装 ─────────────────────────────────────────────────────────────
|
|
1468
|
+
async function api(path, init) {
|
|
1469
|
+
const res = await fetch(`/obsidian${path}`, {
|
|
1470
|
+
...init,
|
|
1471
|
+
headers: { "x-dsh-obsidian": "1", ...(init && init.headers ? init.headers : {}) },
|
|
1472
|
+
});
|
|
1473
|
+
let json = {};
|
|
1474
|
+
try {
|
|
1475
|
+
json = await res.json();
|
|
1476
|
+
} catch { /* ignore */ }
|
|
1477
|
+
if (!res.ok) {
|
|
1478
|
+
const err = new Error(json && json.error ? json.error : `HTTP ${res.status}`);
|
|
1479
|
+
// 保留状态码:调用方需要区分「接口不存在」(host 未重启)与业务失败,
|
|
1480
|
+
// 否则两者会一起被当成「没找到会话」这类正常空结果。
|
|
1481
|
+
err.status = res.status;
|
|
1482
|
+
throw err;
|
|
1483
|
+
}
|
|
1484
|
+
return json;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function mentionFor(absPath) {
|
|
1488
|
+
return /[\s"]/.test(absPath) ? `@"${absPath}"` : `@${absPath}`;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// ── 注入对话输入框 ─────────────────────────────────────────────────────────
|
|
1492
|
+
/**
|
|
1493
|
+
* 当前会话的官方输入动作面(`InputActions`),由 session 作用域的
|
|
1494
|
+
* `conversation.composer.dock` 桥接组件({@link ComposerBridge})捕获。
|
|
1495
|
+
*
|
|
1496
|
+
* DSH 0.2.0 起 composer 由 textarea 换成 Lexical contenteditable
|
|
1497
|
+
* (`div[data-input-scroll] > [data-composer-input]`):只查 textarea 必然落空,
|
|
1498
|
+
* 表现为点「引用到对话」/「提问·发送」却提示「未找到对话输入框,引用已复制到剪贴板」。
|
|
1499
|
+
* 官方给每个 session 槽位组件发的 `inputActions.insertText(text, captureInsertion())`
|
|
1500
|
+
* 才是稳定入口(官方语音输入插件同款);DOM 注入降级为拿不到动作面时的兜底。
|
|
1501
|
+
*/
|
|
1502
|
+
let composerActions = null;
|
|
1503
|
+
|
|
1504
|
+
/** 无渲染桥接:把所在会话的 inputActions 记到模块作用域,供 Dock 按钮取用。 */
|
|
1505
|
+
function ComposerBridge(props) {
|
|
1506
|
+
const actions = props ? props.inputActions : null;
|
|
1507
|
+
useEffect(() => {
|
|
1508
|
+
if (!actions) return undefined;
|
|
1509
|
+
composerActions = actions;
|
|
1510
|
+
// 切换会话时旧桥先卸载:只有仍指向自己时才清空,避免清掉新会话的动作面。
|
|
1511
|
+
return () => { if (composerActions === actions) composerActions = null; };
|
|
1512
|
+
}, [actions]);
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* 找对话输入框:0.2+ 为 Lexical contenteditable,0.1.x 为 textarea。
|
|
1518
|
+
* @returns {{el: object, kind: "textarea"|"editable"}|null}
|
|
1519
|
+
*/
|
|
1520
|
+
function findComposer() {
|
|
1521
|
+
if (typeof document === "undefined") return null;
|
|
1522
|
+
const scroll = document.querySelector("div[data-input-scroll]");
|
|
1523
|
+
if (!scroll) return null;
|
|
1524
|
+
const textarea = scroll.querySelector("textarea");
|
|
1525
|
+
if (textarea && !textarea.disabled && !textarea.readOnly) return { el: textarea, kind: "textarea" };
|
|
1526
|
+
const editable = scroll.querySelector("[data-composer-input]") || scroll.querySelector('[contenteditable="true"]');
|
|
1527
|
+
if (!editable || editable.isContentEditable === false) return null;
|
|
1528
|
+
return { el: editable, kind: "editable" };
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** 输入框里是否已有内容需要隔开(已有内容且不以换行结尾才补一个换行)。 */
|
|
1532
|
+
function composerNeedsSeparator(target) {
|
|
1533
|
+
const el = target ? target.el : null;
|
|
1534
|
+
if (!el) return false;
|
|
1535
|
+
if (target.kind === "textarea") {
|
|
1536
|
+
return typeof el.value === "string" && el.value !== "" && !el.value.endsWith("\n");
|
|
1537
|
+
}
|
|
1538
|
+
return typeof el.textContent === "string" && el.textContent.trim() !== "";
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/** 官方 inputActions 注入;draftRev CAS 失败会返回 false,重取一次 span 再试。 */
|
|
1542
|
+
function injectViaActions(text) {
|
|
1543
|
+
const actions = composerActions;
|
|
1544
|
+
if (!actions || typeof actions.insertText !== "function" || typeof actions.captureInsertion !== "function") return false;
|
|
1545
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1546
|
+
try {
|
|
1547
|
+
if (actions.insertText(text, actions.captureInsertion()) === true) return true;
|
|
1548
|
+
} catch {
|
|
1549
|
+
return false;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** contenteditable(Lexical)兜底:focus → 光标移到末尾 → 原生 insertText。 */
|
|
1556
|
+
function insertIntoEditable(el, text) {
|
|
1557
|
+
const doc = el.ownerDocument || (typeof document === "undefined" ? null : document);
|
|
1558
|
+
if (!doc) return false;
|
|
1559
|
+
const before = typeof el.textContent === "string" ? el.textContent : "";
|
|
1560
|
+
try { el.focus(); } catch { /* 忽略:拿不到焦点也要继续尝试插入 */ }
|
|
1561
|
+
try {
|
|
1562
|
+
const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
1563
|
+
if (selection && typeof doc.createRange === "function") {
|
|
1564
|
+
const range = doc.createRange();
|
|
1565
|
+
range.selectNodeContents(el);
|
|
1566
|
+
range.collapse(false);
|
|
1567
|
+
selection.removeAllRanges();
|
|
1568
|
+
selection.addRange(range);
|
|
1569
|
+
}
|
|
1570
|
+
} catch { /* 选区不可用时交给 execCommand 的默认插入点 */ }
|
|
1571
|
+
let applied = false;
|
|
1572
|
+
try {
|
|
1573
|
+
applied = typeof doc.execCommand === "function" && doc.execCommand("insertText", false, text) === true;
|
|
1574
|
+
} catch {
|
|
1575
|
+
applied = false;
|
|
1576
|
+
}
|
|
1577
|
+
if (!applied) {
|
|
1578
|
+
// 合成 beforeinput:Lexical 的插入路径只认事件(不校验 isTrusted),
|
|
1579
|
+
// 拿不到 execCommand 时仍能走它自己的编辑事务。
|
|
1580
|
+
try {
|
|
1581
|
+
const Ctor = typeof window === "undefined" ? null : window.InputEvent;
|
|
1582
|
+
if (Ctor) el.dispatchEvent(new Ctor("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: text }));
|
|
1583
|
+
} catch { /* 事件构造失败即放弃 DOM 路径 */ }
|
|
1584
|
+
}
|
|
1585
|
+
const after = typeof el.textContent === "string" ? el.textContent : "";
|
|
1586
|
+
return applied || after !== before;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** 写入 textarea(DSH 0.1.x 及更早):React 受控 textarea 的 native setter + input 事件。 */
|
|
1590
|
+
function insertIntoTextarea(textarea, text) {
|
|
1591
|
+
const next = `${textarea.value}${text}`;
|
|
1592
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
|
1593
|
+
try {
|
|
1594
|
+
if (setter) setter.call(textarea, next);
|
|
1595
|
+
else textarea.value = next;
|
|
1596
|
+
} catch {
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1600
|
+
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1601
|
+
const end = next.length;
|
|
1602
|
+
textarea.setSelectionRange(end, end);
|
|
1603
|
+
textarea.focus();
|
|
1604
|
+
return true;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* 把文本注入对话输入框(前面补换行、末尾留换行,便于继续输入)。
|
|
1609
|
+
* 顺序:官方 inputActions → DOM(Lexical contenteditable / textarea)。
|
|
1610
|
+
* @param text - 要注入的纯文本。
|
|
1611
|
+
* @returns 是否注入成功。
|
|
1612
|
+
*/
|
|
1613
|
+
function injectComposerText(text) {
|
|
1614
|
+
if (typeof text !== "string" || text === "") return false;
|
|
1615
|
+
const target = findComposer();
|
|
1616
|
+
const sep = composerNeedsSeparator(target) ? "\n" : "";
|
|
1617
|
+
const payload = `${sep}${text}\n`;
|
|
1618
|
+
if (injectViaActions(payload)) return true;
|
|
1619
|
+
if (!target) return false;
|
|
1620
|
+
return target.kind === "textarea" ? insertIntoTextarea(target.el, payload) : insertIntoEditable(target.el, payload);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/** 把 @路径 注入对话输入框。 */
|
|
1624
|
+
function insertIntoComposer(absPath) {
|
|
1625
|
+
return injectComposerText(mentionFor(absPath));
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
async function citeToChat(absPath, t) {
|
|
1629
|
+
const mention = mentionFor(absPath);
|
|
1630
|
+
try {
|
|
1631
|
+
if (insertIntoComposer(absPath)) {
|
|
1632
|
+
showToast(`${t("cited")}:${mention}`);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
try {
|
|
1636
|
+
await navigator.clipboard.writeText(mention);
|
|
1637
|
+
showToast(t("noInput"));
|
|
1638
|
+
} catch {
|
|
1639
|
+
showToast(`${t("citeFail")}:${mention}`);
|
|
1640
|
+
}
|
|
1641
|
+
} catch {
|
|
1642
|
+
showToast(t("citeFail"));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// ── 「选中片段提问」通用工具(预览态 / 编辑态共用)──────────────────────────
|
|
1647
|
+
const SEL_CHAR_LIMIT = 1500;
|
|
1648
|
+
|
|
1649
|
+
/** 内容长度阈值内的换行计数(仅用于行号展示) */
|
|
1650
|
+
function offsetToLine(content, off) {
|
|
1651
|
+
let line = 1;
|
|
1652
|
+
const limit = Math.max(0, Math.min(off, content.length));
|
|
1653
|
+
for (let i = 0; i < limit; i++) if (content.charCodeAt(i) === 10) line++;
|
|
1654
|
+
return line;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** 生成「@路径 + 行数 + > 选中内容 + 问题标签」的注入 prompt(无多余空行)。 */
|
|
1658
|
+
function buildPrompt(t, absPath, content, span, question) {
|
|
1659
|
+
const lineA = offsetToLine(content, span.start);
|
|
1660
|
+
const lineB = offsetToLine(content, span.end - 1);
|
|
1661
|
+
const range = lineA === lineB ? t("promptLine", { a: lineA }) : t("promptLines", { a: lineA, b: lineB });
|
|
1662
|
+
const q = (question || "").trim() || t("promptDefaultQ");
|
|
1663
|
+
const lines = [`@${absPath}`, range];
|
|
1664
|
+
if (span.text.length <= SEL_CHAR_LIMIT) {
|
|
1665
|
+
for (const l of span.text.split("\n")) lines.push(`> ${l}`);
|
|
1666
|
+
} else {
|
|
1667
|
+
lines.push(`> ${t("promptLong", { n: span.text.length, a: lineA, b: lineB })}`);
|
|
1668
|
+
}
|
|
1669
|
+
lines.push(`${t("promptQuestion")} ${q}`);
|
|
1670
|
+
return lines.join("\n");
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/** 预览容器中收集带偏移的可见 span(.dsh-obs-src),返回 [{node,start,end}] */
|
|
1674
|
+
function computeLeafSpans(container) {
|
|
1675
|
+
if (!container || typeof document === "undefined" || typeof document.createTreeWalker !== "function") return [];
|
|
1676
|
+
const leaves = [];
|
|
1677
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
|
|
1678
|
+
while (walker.nextNode()) {
|
|
1679
|
+
const el = walker.currentNode;
|
|
1680
|
+
if (el.nodeType === 1 && el.firstChild && el.firstChild.nodeType === 3) {
|
|
1681
|
+
const cls = el.getAttribute ? el.getAttribute("class") || "" : "";
|
|
1682
|
+
if (cls.split(/\s+/).includes("dsh-obs-src")) {
|
|
1683
|
+
const src = el.getAttribute("data-obs-src");
|
|
1684
|
+
if (src) {
|
|
1685
|
+
const parts = src.split(",").map(Number);
|
|
1686
|
+
if (parts.length === 2 && Number.isFinite(parts[0]) && Number.isFinite(parts[1])) {
|
|
1687
|
+
leaves.push({ node: el.firstChild, start: parts[0], end: parts[1] });
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return leaves;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/** DOM 位置 (node, offset) → 源码绝对偏移 */
|
|
1697
|
+
function posToOffset(leaves, node, offset) {
|
|
1698
|
+
if (node.nodeType === 3) {
|
|
1699
|
+
for (const leaf of leaves) if (leaf.node === node) return Math.max(0, Math.min(leaf.start + offset, leaf.end));
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
if (node.nodeType === 1) {
|
|
1703
|
+
let after = null;
|
|
1704
|
+
for (const leaf of leaves) {
|
|
1705
|
+
const rel = leaf.node.compareDocumentPosition ? leaf.node.compareDocumentPosition(node) : 0;
|
|
1706
|
+
if (rel & Node.DOCUMENT_POSITION_FOLLOWING) { after = leaf; break; }
|
|
1707
|
+
}
|
|
1708
|
+
return after ? after.start : (leaves.length ? leaves[leaves.length - 1].end : null);
|
|
1709
|
+
}
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/** 预览态:当前 DOM 选区 → {text,start,end,mode} 或 null */
|
|
1714
|
+
function spanFromSelection(container, leaves, content) {
|
|
1715
|
+
if (typeof window === "undefined" || typeof window.getSelection !== "function") return null;
|
|
1716
|
+
const sel = window.getSelection();
|
|
1717
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
1718
|
+
const range = sel.getRangeAt(0);
|
|
1719
|
+
if (range.collapsed) return null;
|
|
1720
|
+
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return null;
|
|
1721
|
+
const start = posToOffset(leaves, range.startContainer, range.startOffset);
|
|
1722
|
+
const end = posToOffset(leaves, range.endContainer, range.endOffset);
|
|
1723
|
+
if (start === null || end === null || end <= start || end > content.length) return null;
|
|
1724
|
+
return { text: content.slice(start, end), start, end, mode: "preview" };
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** 当前选区包围盒(浮层定位用) */
|
|
1728
|
+
function selRect() {
|
|
1729
|
+
if (typeof window === "undefined" || typeof window.getSelection !== "function") return null;
|
|
1730
|
+
const sel = window.getSelection();
|
|
1731
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
1732
|
+
const range = sel.getRangeAt(0);
|
|
1733
|
+
if (range.collapsed) return null;
|
|
1734
|
+
const r = range.getBoundingClientRect ? range.getBoundingClientRect() : null;
|
|
1735
|
+
return r ? { top: r.top, bottom: r.bottom, left: r.left } : null;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/** 把任意文本(不只 @路径)注入对话输入框:与「引用到对话」同一条链路。 */
|
|
1739
|
+
function injectPrompt(text) {
|
|
1740
|
+
return injectComposerText(text);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// ── 样式 ───────────────────────────────────────────────────────────────────
|
|
1744
|
+
const CSS = [
|
|
1745
|
+
// WorkspaceSidebar:常驻 sidebar.workspaces 的整体接管
|
|
1746
|
+
`.dsh-obs-sidebar{display:flex;flex-direction:column;height:100%;min-height:0;font-size:13px;color:var(--dsw-alias-label-primary)}`,
|
|
1747
|
+
`.dsh-obs-ws-head{display:flex;align-items:center;gap:4px;padding:8px 10px 4px}`,
|
|
1748
|
+
`.dsh-obs-ws-head-label{flex:none;font-size:13px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}`,
|
|
1749
|
+
`.dsh-obs-head-btn{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;padding:0 6px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:14px;line-height:1}`,
|
|
1750
|
+
`.dsh-obs-head-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1751
|
+
`.dsh-obs-head-btn.dsh-obs-on{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-primary)}`,
|
|
1752
|
+
`.dsh-obs-search{display:flex;gap:4px;padding:4px 10px 6px}`,
|
|
1753
|
+
`.dsh-obs-search input{flex:1;min-width:0;height:26px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;outline:none}`,
|
|
1754
|
+
`.dsh-obs-search input:focus{border-color:var(--dsw-alias-brand-primary)}`,
|
|
1755
|
+
`.dsh-obs-ws-body{flex:1;min-height:0;overflow:auto;padding:2px 6px 10px}`,
|
|
1756
|
+
// 工作区组行
|
|
1757
|
+
`.dsh-obs-ws-row{display:flex;align-items:center;gap:6px;height:32px;padding:0 8px;border-radius:8px;cursor:pointer;white-space:nowrap;color:var(--dsw-alias-label-primary);user-select:none}`,
|
|
1758
|
+
`.dsh-obs-ws-row:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1759
|
+
`.dsh-obs-chev{width:12px;flex:none;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:10px}`,
|
|
1760
|
+
`.dsh-obs-ws-icon{flex:none;color:var(--dsw-alias-label-secondary)}`,
|
|
1761
|
+
`.dsh-obs-ws-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:500}`,
|
|
1762
|
+
`.dsh-obs-mini{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:1;flex:none}`,
|
|
1763
|
+
`.dsh-obs-mini:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}`,
|
|
1764
|
+
// 行尾管理动作(工作区/会话 的 重命名 / 删除,常显)
|
|
1765
|
+
`.dsh-obs-actions{display:flex;align-items:center;gap:2px;flex:none}`,
|
|
1766
|
+
`.dsh-obs-actions button{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:12px;line-height:1}`,
|
|
1767
|
+
`.dsh-obs-actions button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}`,
|
|
1768
|
+
// 破坏性动作(彻底删除):悬停转为危险色,与同排的「归档」明确区分
|
|
1769
|
+
`.dsh-obs-actions button.dsh-obs-danger:hover{color:#e5484d}`,
|
|
1770
|
+
// ── 批量操作(工作区 / 会话)─────────────────────────────────────────────
|
|
1771
|
+
// 勾选框:工作区行与目标工作区的会话行在批量模式下出现
|
|
1772
|
+
`.dsh-obs-check{flex:none;width:14px;height:14px;margin:0 2px 0 0;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}`,
|
|
1773
|
+
// 批量工具条:两行(说明 + 动作),窄侧边栏里自动换行
|
|
1774
|
+
`.dsh-obs-batchbar{display:flex;flex-wrap:wrap;align-items:center;gap:4px;padding:4px 10px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}`,
|
|
1775
|
+
`.dsh-obs-batchbar-info{flex:1 0 100%;font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}`,
|
|
1776
|
+
`.dsh-obs-batchbar button{display:inline-flex;align-items:center;justify-content:center;height:22px;padding:0 7px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:11px;line-height:1;white-space:nowrap}`,
|
|
1777
|
+
`.dsh-obs-batchbar button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}`,
|
|
1778
|
+
`.dsh-obs-batchbar button:disabled{opacity:.45;cursor:default}`,
|
|
1779
|
+
`.dsh-obs-batchbar button.dsh-obs-danger{color:#e5484d;border-color:rgba(229,72,77,.45)}`,
|
|
1780
|
+
`.dsh-obs-batchbar button.dsh-obs-danger:hover:not(:disabled){background:rgba(229,72,77,.12);color:#e5484d}`,
|
|
1781
|
+
// 已勾选的行 / 当前会话批量的目标工作区行
|
|
1782
|
+
`.dsh-obs-ws-row.dsh-obs-picked,.dsh-obs-session.dsh-obs-picked{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-label-primary)}`,
|
|
1783
|
+
`.dsh-obs-ws-row.dsh-obs-batch-target{box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary)}`,
|
|
1784
|
+
// 会话行(缩进子项)
|
|
1785
|
+
`.dsh-obs-session{display:flex;align-items:center;gap:6px;height:30px;margin:1px 0 1px 20px;padding:0 8px;border-radius:8px;cursor:pointer;white-space:nowrap;color:var(--dsw-alias-label-secondary)}`,
|
|
1786
|
+
`.dsh-obs-session:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1787
|
+
`.dsh-obs-session.dsh-obs-selected{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-label-primary)}`,
|
|
1788
|
+
`.dsh-obs-dot{width:10px;flex:none;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:11px}`,
|
|
1789
|
+
// 有子代理在跑时的状态点(对应官方会话行的「N 个子代理运行中」状态)
|
|
1790
|
+
`.dsh-obs-dot-subs{color:var(--dsw-alias-brand-primary)}`,
|
|
1791
|
+
`.dsh-obs-session-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}`,
|
|
1792
|
+
`.dsh-obs-time{flex:none;color:var(--dsw-alias-label-dimmed);font-size:11px}`,
|
|
1793
|
+
`.dsh-obs-ws-empty{padding:4px 8px 4px 34px;color:var(--dsw-alias-label-tertiary);font-size:12px}`,
|
|
1794
|
+
// 「📚 笔记」顶级行(与工作区目录并列)
|
|
1795
|
+
`.dsh-obs-notes-row{display:flex;align-items:center;gap:6px;height:32px;margin-top:6px;padding:0 8px;border-radius:8px;cursor:pointer;white-space:nowrap;color:var(--dsw-alias-label-primary);user-select:none}`,
|
|
1796
|
+
`.dsh-obs-notes-row:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1797
|
+
`.dsh-obs-notes-row.dsh-obs-notes-row-on{background:var(--dsw-alias-interactive-bg-hover-accent)}`,
|
|
1798
|
+
`.dsh-obs-notes-icon{flex:none;color:var(--dsw-alias-brand-primary)}`,
|
|
1799
|
+
`.dsh-obs-notes-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:500}`,
|
|
1800
|
+
`.dsh-obs-notes-count{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px}`,
|
|
1801
|
+
`.dsh-obs-notes-pane{margin-left:20px;padding:2px 0 6px}`,
|
|
1802
|
+
// 折叠轨道(wide=false)
|
|
1803
|
+
`.dsh-obs-rail{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;padding-top:8px}`,
|
|
1804
|
+
`.dsh-obs-rail button{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border:0;border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:16px}`,
|
|
1805
|
+
`.dsh-obs-rail button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}`,
|
|
1806
|
+
// 行下笔记树(TreeView,嵌入侧边栏滚动区内)
|
|
1807
|
+
`.dsh-obs-tree{display:block;font-size:13px;color:var(--dsw-alias-label-primary)}`,
|
|
1808
|
+
`.dsh-obs-tree-head{display:flex;align-items:center;gap:2px;padding:2px 4px 6px}`,
|
|
1809
|
+
`.dsh-obs-tree-head button{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;padding:0 6px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:14px;line-height:1}`,
|
|
1810
|
+
`.dsh-obs-tree-head button:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1811
|
+
`.dsh-obs-tree-head button.dsh-obs-on{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-primary)}`,
|
|
1812
|
+
`.dsh-obs-attachcfg{display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:2px 6px 6px}`,
|
|
1813
|
+
`.dsh-obs-attachcfg input:not([type=checkbox]){flex:1;min-width:110px;height:24px;padding:0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px}`,
|
|
1814
|
+
`.dsh-obs-cfgline{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}`,
|
|
1815
|
+
`.dsh-obs-root-row{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
|
|
1816
|
+
`.dsh-obs-root-row .dsh-obs-root-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
|
|
1817
|
+
`.dsh-obs-root-row .dsh-obs-mini,.dsh-obs-row .dsh-obs-mini{flex:none;width:22px;height:22px}`,
|
|
1818
|
+
`.dsh-obs-body{overflow:visible;padding:2px 0}`,
|
|
1819
|
+
`.dsh-obs-row{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:7px;cursor:pointer;white-space:nowrap;color:var(--dsw-alias-label-secondary)}`,
|
|
1820
|
+
`.dsh-obs-row:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1821
|
+
`.dsh-obs-row.dsh-obs-selected{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-label-primary)}`,
|
|
1822
|
+
`.dsh-obs-row .dsh-obs-chev{width:12px;flex:none;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:10px}`,
|
|
1823
|
+
`.dsh-obs-row .dsh-obs-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}`,
|
|
1824
|
+
`.dsh-obs-row .dsh-obs-size{flex:none;color:var(--dsw-alias-label-dimmed);font-size:11px}`,
|
|
1825
|
+
`.dsh-obs-snippet{color:var(--dsw-alias-label-tertiary);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
|
|
1826
|
+
`.dsh-obs-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;color:var(--dsw-alias-label-tertiary);font-size:12px;text-align:center}`,
|
|
1827
|
+
`.dsh-obs-empty button{margin-top:4px;padding:6px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font-size:12px}`,
|
|
1828
|
+
`.dsh-obs-empty button:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1829
|
+
// 右侧 Dock
|
|
1830
|
+
`.dsh-obs-dock{pointer-events:auto;position:fixed;z-index:32;display:flex;flex-direction:column;min-width:380px;max-width:92vw;min-height:280px;max-height:88vh;overflow:hidden;border-radius:16px;border:1px solid var(--dsw-alias-border-l2);box-shadow:0 16px 48px rgba(0,0,0,0.35);background:var(--dsw-alias-bg-overlay);font-size:13px;color:var(--dsw-alias-label-primary)}`,
|
|
1831
|
+
`.dsh-obs-dock[hidden]{display:none}`,
|
|
1832
|
+
`.dsh-obs-dock-head{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);cursor:grab;user-select:none}`,
|
|
1833
|
+
`.dsh-obs-dock-head:active{cursor:grabbing}`,
|
|
1834
|
+
`.dsh-obs-dock-head .dsh-obs-dock-title{flex:1;min-width:0;display:flex;align-items:center;gap:6px;font-weight:600;overflow:hidden}`,
|
|
1835
|
+
`.dsh-obs-dock-head .dsh-obs-dock-title span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
|
|
1836
|
+
`.dsh-obs-dock-head button{display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:26px;padding:0 8px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;line-height:1}`,
|
|
1837
|
+
`.dsh-obs-dock-head button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}`,
|
|
1838
|
+
`.dsh-obs-dock-head button.dsh-obs-primary{background:var(--dsw-alias-button-primary-fill);border-color:transparent;color:var(--dsw-alias-label-primary-foreground)}`,
|
|
1839
|
+
`.dsh-obs-dock-head button.dsh-obs-primary:hover{background:var(--dsw-alias-button-primary-hover);color:var(--dsw-alias-label-primary-foreground)}`,
|
|
1840
|
+
`.dsh-obs-dock-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:6px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
|
|
1841
|
+
`.dsh-obs-tag{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);font-size:11px}`,
|
|
1842
|
+
`.dsh-obs-dock-body{flex:1;min-height:0;overflow:auto;padding:12px 16px}`,
|
|
1843
|
+
`.dsh-obs-dock-body .dsh-obs-md{line-height:1.7;color:var(--dsw-alias-label-primary);font-size:13.5px}`,
|
|
1844
|
+
// 预览态「每行一个行号」:每个源码行为一行(行号在行首,内容可自然折行)
|
|
1845
|
+
`.dsh-obs-md-row{display:flex;align-items:flex-start;gap:10px}`,
|
|
1846
|
+
`.dsh-obs-md-ln{flex:none;min-width:2ch;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;user-select:none;-webkit-user-select:none}`,
|
|
1847
|
+
`.dsh-obs-md-lc{flex:1;min-width:0}`,
|
|
1848
|
+
`.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc p,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h1,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h2,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h3,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h4,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h5,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h6,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc blockquote{margin:0;padding:0}`,
|
|
1849
|
+
`.dsh-obs-md-row.dsh-obs-md-heading .dsh-obs-md-lc{font-weight:600}`,
|
|
1850
|
+
`.dsh-obs-md-row.dsh-obs-md-quote .dsh-obs-md-lc{border-left:3px solid var(--dsw-alias-brand-primary);padding-left:10px;color:var(--dsw-alias-label-secondary)}`,
|
|
1851
|
+
// ── Obsidian callout(`> [!type] 标题`)配色:类型在根节点上带 data-callout ──
|
|
1852
|
+
`[data-callout="note"]{--dsh-obs-callout-color:#448aff}`,
|
|
1853
|
+
`[data-callout="abstract"]{--dsh-obs-callout-color:#00b8d4}`,
|
|
1854
|
+
`[data-callout="info"]{--dsh-obs-callout-color:#00b8d4}`,
|
|
1855
|
+
`[data-callout="todo"]{--dsh-obs-callout-color:#448aff}`,
|
|
1856
|
+
`[data-callout="tip"]{--dsh-obs-callout-color:#00bfa5}`,
|
|
1857
|
+
`[data-callout="success"]{--dsh-obs-callout-color:#00c853}`,
|
|
1858
|
+
`[data-callout="question"]{--dsh-obs-callout-color:#ffab00}`,
|
|
1859
|
+
`[data-callout="warning"]{--dsh-obs-callout-color:#ff9100}`,
|
|
1860
|
+
`[data-callout="failure"]{--dsh-obs-callout-color:#ff5252}`,
|
|
1861
|
+
`[data-callout="danger"]{--dsh-obs-callout-color:#ff1744}`,
|
|
1862
|
+
`[data-callout="bug"]{--dsh-obs-callout-color:#f50057}`,
|
|
1863
|
+
`[data-callout="example"]{--dsh-obs-callout-color:#7c4dff}`,
|
|
1864
|
+
`[data-callout="quote"]{--dsh-obs-callout-color:#9e9e9e}`,
|
|
1865
|
+
// 行号预览态:callout 连续行共用竖线与底色,首/末行在 .dsh-obs-md-lc 上收圆角
|
|
1866
|
+
`.dsh-obs-md-row.dsh-obs-md-quote .dsh-obs-md-lc.dsh-obs-md-callout-head,.dsh-obs-md-row.dsh-obs-md-quote .dsh-obs-md-lc.dsh-obs-md-callout-body{border-left:3px solid var(--dsh-obs-callout-color,var(--dsw-alias-brand-primary));background:color-mix(in srgb,var(--dsh-obs-callout-color,var(--dsw-alias-brand-primary)) 12%,transparent);padding:2px 10px;color:var(--dsw-alias-label-primary)}`,
|
|
1867
|
+
`.dsh-obs-md-row.dsh-obs-md-quote .dsh-obs-md-lc.dsh-obs-md-callout-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border-radius:6px 6px 0 0;padding-top:5px}`,
|
|
1868
|
+
`.dsh-obs-md-row.dsh-obs-md-quote.dsh-obs-md-callout-end .dsh-obs-md-lc{border-radius:0 0 6px 6px;padding-bottom:5px}`,
|
|
1869
|
+
`.dsh-obs-md-callout-badge{display:inline-flex;align-items:center;gap:3px;flex:none;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--dsh-obs-callout-color,var(--dsw-alias-brand-primary)) 22%,transparent);color:var(--dsw-alias-label-primary);font-size:11px;font-weight:600;line-height:16px}`,
|
|
1870
|
+
`.dsh-obs-md-callout-ico{font-size:11px}`,
|
|
1871
|
+
`.dsh-obs-md-callout-label{font-size:11px;line-height:16px;white-space:nowrap}`,
|
|
1872
|
+
`.dsh-obs-md-callout-title{font-weight:600}`,
|
|
1873
|
+
`.dsh-obs-md-row.dsh-obs-md-list .dsh-obs-md-lc{list-style:none}`,
|
|
1874
|
+
`.dsh-obs-md-row.dsh-obs-md-code .dsh-obs-md-lc{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:var(--dsw-alias-interactive-bg-hover);border-radius:4px;padding:1px 5px}`,
|
|
1875
|
+
|
|
1876
|
+
`.dsh-obs-md h3,.dsh-obs-md h4,.dsh-obs-md h5,.dsh-obs-md h6{margin:14px 0 6px;line-height:1.4}`,
|
|
1877
|
+
`.dsh-obs-md p{margin:8px 0}`,
|
|
1878
|
+
`.dsh-obs-md ul,.dsh-obs-md ol{margin:8px 0;padding-left:22px}`,
|
|
1879
|
+
`.dsh-obs-md li{margin:3px 0}`,
|
|
1880
|
+
`.dsh-obs-md blockquote{margin:8px 0;padding:4px 12px;border-left:3px solid var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);border-radius:0 6px 6px 0}`,
|
|
1881
|
+
// callout(块渲染路径)
|
|
1882
|
+
`.dsh-obs-callout{margin:10px 0;border-left:3px solid var(--dsh-obs-callout-color,var(--dsw-alias-brand-primary));background:color-mix(in srgb,var(--dsh-obs-callout-color,var(--dsw-alias-brand-primary)) 12%,transparent);border-radius:0 6px 6px 0;padding:6px 12px}`,
|
|
1883
|
+
`.dsh-obs-callout-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-weight:600;margin-bottom:2px}`,
|
|
1884
|
+
`.dsh-obs-callout-icon{flex:none;font-size:13px;line-height:1}`,
|
|
1885
|
+
`.dsh-obs-callout-title{color:var(--dsw-alias-label-primary)}`,
|
|
1886
|
+
`.dsh-obs-callout-body{color:var(--dsw-alias-label-primary)}`,
|
|
1887
|
+
`.dsh-obs-callout-body p{margin:2px 0}`,
|
|
1888
|
+
`.dsh-obs-callout-body ul,.dsh-obs-callout-body ol{margin:2px 0;padding-left:20px}`,
|
|
1889
|
+
`.dsh-obs-md code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:var(--dsw-alias-interactive-bg-hover);padding:1px 5px;border-radius:4px}`,
|
|
1890
|
+
`.dsh-obs-code{margin:8px 0;padding:10px 12px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover);overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.6}`,
|
|
1891
|
+
`.dsh-obs-md table{border-collapse:collapse;margin:8px 0}`,
|
|
1892
|
+
`.dsh-obs-md th,.dsh-obs-md td{border:1px solid var(--dsw-alias-border-l2);padding:4px 10px;font-size:12.5px}`,
|
|
1893
|
+
`.dsh-obs-md hr{border:0;border-top:1px solid var(--dsw-alias-border-l1);margin:12px 0}`,
|
|
1894
|
+
`.dsh-obs-md img{max-width:100%;border-radius:8px}`,
|
|
1895
|
+
`.dsh-obs-md a{color:var(--dsw-alias-brand-primary)}`,
|
|
1896
|
+
`.dsh-obs-wikilink{display:inline;padding:0;border:0;background:transparent;color:var(--dsw-alias-brand-primary);cursor:pointer;font-size:inherit;text-decoration:underline;text-decoration-style:dotted}`,
|
|
1897
|
+
`.dsh-obs-wikilink:hover{color:var(--dsw-alias-label-primary)}`,
|
|
1898
|
+
// ── 行号预览的新块级语法(M1–M7):标题分级 / 嵌套引用 / 围栏代码 / 列表+任务 / 表格 / 分隔线 / setext / 双链分隔 ──
|
|
1899
|
+
// 标题:字号按级递减;行号与标题首行按基线对齐,否则大字号会让行号漂在文字上方
|
|
1900
|
+
`.dsh-obs-md-row:is(.dsh-obs-md-heading,.dsh-obs-md-h1,.dsh-obs-md-h2,.dsh-obs-md-h3,.dsh-obs-md-h4,.dsh-obs-md-h5,.dsh-obs-md-h6){align-items:baseline}`,
|
|
1901
|
+
`.dsh-obs-md-row.dsh-obs-md-h1 .dsh-obs-md-lc{font-size:20px;font-weight:700;line-height:1.45}`,
|
|
1902
|
+
`.dsh-obs-md-row.dsh-obs-md-h2 .dsh-obs-md-lc{font-size:18px;font-weight:700;line-height:1.45}`,
|
|
1903
|
+
`.dsh-obs-md-row.dsh-obs-md-h3 .dsh-obs-md-lc{font-size:16px;font-weight:600;line-height:1.5}`,
|
|
1904
|
+
`.dsh-obs-md-row.dsh-obs-md-h4 .dsh-obs-md-lc{font-size:14.5px;font-weight:600;line-height:1.55}`,
|
|
1905
|
+
`.dsh-obs-md-row.dsh-obs-md-h5 .dsh-obs-md-lc{font-size:13.5px;font-weight:600;line-height:1.6}`,
|
|
1906
|
+
`.dsh-obs-md-row.dsh-obs-md-h6 .dsh-obs-md-lc{font-size:13px;font-weight:600;line-height:1.6;color:var(--dsw-alias-label-secondary)}`,
|
|
1907
|
+
// 标题里的行内代码跟着标题字号缩,别在 20px 的标题里留一块 12px 的小方块
|
|
1908
|
+
`.dsh-obs-md-row:is(.dsh-obs-md-heading,.dsh-obs-md-h1,.dsh-obs-md-h2,.dsh-obs-md-h3,.dsh-obs-md-h4,.dsh-obs-md-h5,.dsh-obs-md-h6) .dsh-obs-md-lc code{font-size:.75em}`,
|
|
1909
|
+
// 引用:按 data-quote-depth 逐层右移(1 起);callout 行不缩进,避免和分组竖线打架
|
|
1910
|
+
// 空的引用/正文行也要把竖线、底色拉满整行高度,否则 callout 中间会漏出一段缺口
|
|
1911
|
+
`.dsh-obs-md-row.dsh-obs-md-quote .dsh-obs-md-lc{align-self:stretch}`,
|
|
1912
|
+
`.dsh-obs-md-row.dsh-obs-md-quote[data-quote-depth]:not([data-callout-group]) .dsh-obs-md-lc{border-left-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 55%,transparent)}`,
|
|
1913
|
+
`.dsh-obs-md-row.dsh-obs-md-quote[data-quote-depth="2"]:not([data-callout-group]) .dsh-obs-md-lc{margin-left:16px}`,
|
|
1914
|
+
`.dsh-obs-md-row.dsh-obs-md-quote[data-quote-depth="3"]:not([data-callout-group]) .dsh-obs-md-lc{margin-left:32px}`,
|
|
1915
|
+
`.dsh-obs-md-row.dsh-obs-md-quote[data-quote-depth="4"]:not([data-callout-group]) .dsh-obs-md-lc{margin-left:48px}`,
|
|
1916
|
+
// 围栏代码:同一 data-code-group 的连续行拼成一整块底色,首尾行收圆角;长行换行,不按行横滚
|
|
1917
|
+
`.dsh-obs-md-row.dsh-obs-md-code{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1918
|
+
`.dsh-obs-md-row.dsh-obs-md-code .dsh-obs-md-lc{background:transparent;border-radius:0;padding:1px 10px 1px 0;white-space:pre-wrap;overflow-wrap:anywhere;tab-size:4;color:var(--dsw-alias-label-secondary)}`,
|
|
1919
|
+
`.dsh-obs-md-row.dsh-obs-md-code-head{border-radius:7px 7px 0 0}`,
|
|
1920
|
+
`.dsh-obs-md-row.dsh-obs-md-code-end{border-radius:0 0 7px 7px}`,
|
|
1921
|
+
// 单行代码块(开闭围栏是同一行)没有正文,四角都收
|
|
1922
|
+
`.dsh-obs-md-row.dsh-obs-md-code-head.dsh-obs-md-code-end{border-radius:7px}`,
|
|
1923
|
+
// 开围栏行只显示语言名(无语言时这一行只剩行号与块底色)
|
|
1924
|
+
`.dsh-obs-md-code-lang{display:inline-block;padding:0 6px;border-radius:5px;background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:16px;letter-spacing:.02em}`,
|
|
1925
|
+
// 列表:行号列不动;缩进由行上的 data-list-depth 折算成 --dsh-obs-list-indent,marker 定宽右对齐、正文悬挂缩进
|
|
1926
|
+
`.dsh-obs-md-row.dsh-obs-md-list .dsh-obs-md-lc{display:flex;align-items:flex-start;gap:6px;padding-left:var(--dsh-obs-list-indent,0px)}`,
|
|
1927
|
+
`.dsh-obs-md-row.dsh-obs-md-list[data-list-depth="1"]{--dsh-obs-list-indent:16px}`,
|
|
1928
|
+
`.dsh-obs-md-row.dsh-obs-md-list[data-list-depth="2"]{--dsh-obs-list-indent:32px}`,
|
|
1929
|
+
`.dsh-obs-md-row.dsh-obs-md-list[data-list-depth="3"]{--dsh-obs-list-indent:48px}`,
|
|
1930
|
+
`.dsh-obs-md-row.dsh-obs-md-list[data-list-depth="4"]{--dsh-obs-list-indent:64px}`,
|
|
1931
|
+
`.dsh-obs-md-row.dsh-obs-md-list[data-list-depth="5"]{--dsh-obs-list-indent:80px}`,
|
|
1932
|
+
`.dsh-obs-md-list-marker{flex:none;min-width:2em;text-align:right;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;user-select:none;-webkit-user-select:none}`,
|
|
1933
|
+
`.dsh-obs-md-list-body{flex:1;min-width:0}`,
|
|
1934
|
+
// 任务列表:复选框替代符号(marker 隐藏),只读;已完成项正文弱化
|
|
1935
|
+
`.dsh-obs-md-row.dsh-obs-md-list[data-list-kind="task"] .dsh-obs-md-list-marker{display:none}`,
|
|
1936
|
+
`.dsh-obs-md-task-check{flex:none;width:13px;height:13px;margin:5px 0 0 calc(2em - 13px);accent-color:var(--dsw-alias-brand-primary);cursor:default}`,
|
|
1937
|
+
`.dsh-obs-md-row.dsh-obs-md-list[data-checked="1"] .dsh-obs-md-list-body{color:var(--dsw-alias-label-tertiary)}`,
|
|
1938
|
+
// callout 正文里的列表:callout 自己已有 10px 内边距,列表缩进要叠上去(写在 callout 规则之后才能覆盖 padding-left)
|
|
1939
|
+
`.dsh-obs-md-row.dsh-obs-md-list .dsh-obs-md-lc.dsh-obs-md-callout-body{padding-left:calc(10px + var(--dsh-obs-list-indent,0px))}`,
|
|
1940
|
+
// 表格:整块横向滚动(最窄 380px 也不撑裂面板);行号列 sticky 在左,配不透明底色挡住滚动内容
|
|
1941
|
+
`.dsh-obs-md-tablewrap{max-width:100%;margin:8px 0;overflow-x:auto;overflow-y:hidden;border:1px solid var(--dsw-alias-border-l1);border-radius:7px}`,
|
|
1942
|
+
`.dsh-obs-md .dsh-obs-md-table{border-collapse:separate;border-spacing:0;width:max-content;min-width:100%;font-size:12.5px;line-height:1.55}`,
|
|
1943
|
+
`.dsh-obs-md-table th,.dsh-obs-md-table td{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);border-right:1px solid var(--dsw-alias-border-l1);vertical-align:top}`,
|
|
1944
|
+
`.dsh-obs-md-table tr>*:last-child{border-right:0}`,
|
|
1945
|
+
`.dsh-obs-md-table thead th{background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,var(--dsw-alias-bg-overlay));font-weight:600;color:var(--dsw-alias-label-primary);border-bottom:1px solid var(--dsw-alias-border-l2)}`,
|
|
1946
|
+
`.dsh-obs-md-table tbody tr:last-child>*{border-bottom:0}`,
|
|
1947
|
+
// 正文行(.dsh-obs-md-trow)悬停高亮只铺数据列,行号列保持不透明,横向滚动时不透字
|
|
1948
|
+
`.dsh-obs-md-table tr.dsh-obs-md-trow:hover>td:not(.dsh-obs-md-tln){background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1949
|
+
`.dsh-obs-md-table tr.dsh-obs-md-trow>td:not(.dsh-obs-md-tln){background:transparent}`,
|
|
1950
|
+
`.dsh-obs-md-table .dsh-obs-md-tln{position:sticky;left:0;z-index:1;width:2ch;min-width:2ch;padding:4px 10px 4px 0;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.55;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;user-select:none;-webkit-user-select:none}`,
|
|
1951
|
+
`.dsh-obs-md-table thead .dsh-obs-md-tln{background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,var(--dsw-alias-bg-overlay))}`,
|
|
1952
|
+
`.dsh-obs-md-table [data-align="center"]{text-align:center}`,
|
|
1953
|
+
`.dsh-obs-md-table [data-align="right"]{text-align:right}`,
|
|
1954
|
+
// 分隔行(源码的 |---|---|)视觉上折叠进表头行号格:对照原生 Markdown/Obsidian(表格=表头+正文,没有分隔行),
|
|
1955
|
+
// 行本身压成 1px 透明行(line-height:0 让 10px 行号不占布局高度),行号 38 用 transform 上移 7px 提到表头行号格 37 下方;
|
|
1956
|
+
// 分隔线沿用表头 th 既有的 border-bottom,不再单独画线;sticky 仍继承通用 tln 规则,横滚时 37/38 一起固定在左侧
|
|
1957
|
+
`.dsh-obs-md-table tr.dsh-obs-md-tsep td{height:1px;padding:0;border:0;background:transparent;line-height:0}`,
|
|
1958
|
+
`.dsh-obs-md-table tr.dsh-obs-md-tsep td.dsh-obs-md-tsep-cell{height:1px;background:transparent}`,
|
|
1959
|
+
`.dsh-obs-md-table tr.dsh-obs-md-tsep td.dsh-obs-md-tln{height:1px;padding:0 10px 0 0;background:transparent;font-size:10px;line-height:0;color:var(--dsw-alias-label-tertiary);transform:translateY(-7px)}`,
|
|
1960
|
+
`.dsh-obs-md-table code{font-size:11px;padding:0 4px;border-radius:4px}`,
|
|
1961
|
+
`.dsh-obs-md-tablewrap::-webkit-scrollbar{height:8px}`,
|
|
1962
|
+
`.dsh-obs-md-tablewrap::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2);border-radius:4px}`,
|
|
1963
|
+
// 分隔线:行号保留,横线在内容区垂直居中
|
|
1964
|
+
`.dsh-obs-md-row.dsh-obs-md-hr{align-items:center;padding:7px 0}`,
|
|
1965
|
+
`.dsh-obs-md-row .dsh-obs-md-hr-line{display:block;width:100%;height:0;margin:0;border:0;border-top:1px solid var(--dsw-alias-border-l2)}`,
|
|
1966
|
+
// setext 下划线行(=== / ---)不显示原文:保留正常行高与正常行号(不再压透明度,浅/深主题下都别让人误以为跳行)
|
|
1967
|
+
`.dsh-obs-md-row.dsh-obs-md-setext-underline .dsh-obs-md-lc{display:none}`,
|
|
1968
|
+
// 双链:可见文本用品牌色,` › ` 是按钮里的非 span 文本,继承按钮的次要色,和两侧文字区分开
|
|
1969
|
+
`.dsh-obs-wikilink{color:var(--dsw-alias-label-tertiary)}`,
|
|
1970
|
+
`.dsh-obs-wikilink [data-obs-src]{color:var(--dsw-alias-brand-primary)}`,
|
|
1971
|
+
`.dsh-obs-wikilink:hover [data-obs-src]{color:var(--dsw-alias-label-primary)}`,
|
|
1972
|
+
`.dsh-obs-editor{flex:1;display:flex;min-height:0}`,
|
|
1973
|
+
`.dsh-obs-editor-gutter{flex:none;box-sizing:content-box;min-width:2ch;padding:12px 8px 12px 10px;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.7;white-space:pre;overflow:hidden;user-select:none;-webkit-user-select:none;border-right:1px solid var(--dsw-alias-border-l1);background:transparent}`,
|
|
1974
|
+
`.dsh-obs-editor textarea{flex:1;resize:none;border:0;outline:none;padding:12px 16px 12px 12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.7;white-space:pre;overflow:auto}`,
|
|
1975
|
+
`.dsh-obs-error{padding:8px 12px;font-size:12px;color:var(--dsw-alias-state-error-primary);border-top:1px solid var(--dsw-alias-border-l1)}`,
|
|
1976
|
+
`.dsh-obs-handle{position:absolute;left:-6px;top:14px;bottom:14px;width:12px;cursor:col-resize;border-radius:6px;z-index:2}`,
|
|
1977
|
+
`.dsh-obs-handle:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1978
|
+
`body.dsh-obs-resizing{user-select:none;cursor:col-resize}`,
|
|
1979
|
+
// Toast
|
|
1980
|
+
// Toast:必须用 DSH 主题里**真实存在**的 toast 变量(--dsw-alias-toast-bg /
|
|
1981
|
+
// -toast-label,浅色与深色主题都是深底白字)。旧写法用的是主题里根本没定义的
|
|
1982
|
+
// --dsw-alias-bg-inverted:background 整条声明失效变透明,文字色却是
|
|
1983
|
+
// --dsw-alias-label-primary-foreground(浅色主题=白)—— 结果只剩一个
|
|
1984
|
+
// 「底部白框、没有文字」,删除成功/失败的所有提示都是看不见的。
|
|
1985
|
+
// 位置同时改成顶部居中,与官方 toast 一致,不再压住输入框与权限选择器。
|
|
1986
|
+
`.dsh-obs-toast{pointer-events:none;position:fixed;left:50%;top:40px;transform:translateX(-50%);z-index:60;width:max-content;max-width:min(640px,calc(100vw - 48px));padding:10px 16px;border-radius:var(--dsw-radius-lg,10px);background:var(--dsw-alias-toast-bg,#353638);color:var(--dsw-alias-toast-label,#fff);font-size:13px;line-height:20px;box-shadow:0 8px 24px rgba(0,0,0,0.3)}`,
|
|
1987
|
+
// 文本输入卡片(window.prompt 的替代;Electron 桌面版没有 prompt)
|
|
1988
|
+
`.dsh-obs-prompt-layer{position:fixed;inset:0;z-index:58;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.28)}`,
|
|
1989
|
+
`.dsh-obs-prompt{display:flex;flex-direction:column;gap:8px;width:320px;max-width:calc(100vw - 32px);padding:12px 14px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 16px 48px rgba(0,0,0,0.35)}`,
|
|
1990
|
+
`.dsh-obs-prompt-title{font-size:12.5px;color:var(--dsw-alias-label-secondary)}`,
|
|
1991
|
+
`.dsh-obs-prompt-input{width:100%;height:28px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12.5px;outline:none}`,
|
|
1992
|
+
`.dsh-obs-prompt-input:focus{border-color:var(--dsw-alias-brand-primary)}`,
|
|
1993
|
+
`.dsh-obs-prompt-cancel{flex:none;height:30px;padding:0 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font-size:12.5px}`,
|
|
1994
|
+
`.dsh-obs-prompt-cancel:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
1995
|
+
`.dsh-obs-ws-body::-webkit-scrollbar,.dsh-obs-dock-body::-webkit-scrollbar,.dsh-obs-editor textarea::-webkit-scrollbar{width:10px}`,
|
|
1996
|
+
`.dsh-obs-ws-body::-webkit-scrollbar-thumb,.dsh-obs-dock-body::-webkit-scrollbar-thumb,.dsh-obs-editor textarea::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2);border-radius:5px}`,
|
|
1997
|
+
// 「选中片段提问」浮层
|
|
1998
|
+
`.dsh-obs-askpop, .dsh-obs-askpop *{-webkit-box-sizing:border-box;box-sizing:border-box}`,
|
|
1999
|
+
`.dsh-obs-askpop{position:fixed;z-index:41;pointer-events:auto;display:flex}`,
|
|
2000
|
+
`.dsh-obs-askbar{display:flex;align-items:center;gap:2px;padding:3px;background:#202024;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,0.35)}`,
|
|
2001
|
+
`.dsh-obs-askbar button{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 10px;border:0;border-radius:7px;background:transparent;color:#fff;cursor:pointer;font-size:12.5px;white-space:nowrap}`,
|
|
2002
|
+
`.dsh-obs-askbar button:hover{background:rgba(255,255,255,0.16);color:#fff}`,
|
|
2003
|
+
`.dsh-obs-askbar .dsh-obs-asklen{padding:0 8px;font-size:11px;color:rgba(255,255,255,0.75)}`,
|
|
2004
|
+
`.dsh-obs-askinput{flex-direction:column;width:300px;max-width:calc(100vw - 32px);padding:8px 10px;gap:6px;background:var(--dsw-alias-bg-overlay,#fff);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,0.35);color:var(--dsw-alias-label-primary)}`,
|
|
2005
|
+
`.dsh-obs-askcap{font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
|
|
2006
|
+
`.dsh-obs-askta{width:100%;min-height:50px;max-height:132px;padding:7px 9px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;resize:vertical;font-size:12.5px;line-height:1.5;background:transparent;color:var(--dsw-alias-label-primary,#18181b);font-family:inherit}`,
|
|
2007
|
+
`.dsh-obs-askta:focus{border-color:var(--dsw-alias-brand-primary)}`,
|
|
2008
|
+
`.dsh-obs-askrow{display:flex;align-items:center;gap:8px}`,
|
|
2009
|
+
`.dsh-obs-askhint{flex:1;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}`,
|
|
2010
|
+
`.dsh-obs-asksend{flex:none;height:30px;padding:0 14px;border:0;border-radius:8px;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);cursor:pointer;font-size:12.5px}`,
|
|
2011
|
+
`.dsh-obs-asksend:hover{background:var(--dsw-alias-button-primary-hover)}`,
|
|
2012
|
+
].join("\n");
|
|
2013
|
+
|
|
2014
|
+
function injectCss() {
|
|
2015
|
+
if (typeof document === "undefined") return;
|
|
2016
|
+
if (document.querySelector('style[data-plugin-css="dsh-obsidian-vault/core.css"]')) return;
|
|
2017
|
+
const tag = document.createElement("style");
|
|
2018
|
+
tag.dataset.plugin = "dsh-obsidian-vault";
|
|
2019
|
+
tag.dataset.pluginCss = "dsh-obsidian-vault/core.css";
|
|
2020
|
+
tag.textContent = CSS;
|
|
2021
|
+
document.head.appendChild(tag);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// ── 通用工具 ───────────────────────────────────────────────────────────────
|
|
2025
|
+
function formatSize(bytes) {
|
|
2026
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
2027
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2028
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** 相对时间(zh/en 随 t):刚刚 / N分钟 / N小时 / N天 / 日期。 */
|
|
2032
|
+
function formatRelativeTime(ts, t) {
|
|
2033
|
+
const now = Date.now();
|
|
2034
|
+
const delta = Math.max(0, now - (typeof ts === "number" ? ts : 0));
|
|
2035
|
+
const minutes = Math.floor(delta / 60000);
|
|
2036
|
+
if (minutes < 1) return t("time.justNow");
|
|
2037
|
+
if (minutes < 60) return t("time.minutes", { n: minutes });
|
|
2038
|
+
const hours = Math.floor(minutes / 60);
|
|
2039
|
+
if (hours < 24) return t("time.hours", { n: hours });
|
|
2040
|
+
const days = Math.floor(hours / 24);
|
|
2041
|
+
if (days < 7) return t("time.days", { n: days });
|
|
2042
|
+
const d = new Date(ts);
|
|
2043
|
+
return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* 一个会话及其全部子代理后代(闭包,含自身)。
|
|
2048
|
+
* 删父会话必须连子代理日志一起删:父级日志消失后,子会话会变成无法归属的
|
|
2049
|
+
* 悬空记录(列表里不再有父行可以承载它的血缘与状态)。
|
|
2050
|
+
* @param sid - 根会话 id。
|
|
2051
|
+
* @param byId - 会话 id → 会话记录。
|
|
2052
|
+
* @returns 需要一并删除的 id 集合。
|
|
2053
|
+
*/
|
|
2054
|
+
function sessionClosure(sid, byId) {
|
|
2055
|
+
const childrenOf = new Map();
|
|
2056
|
+
for (const s of Object.values(byId ?? {})) {
|
|
2057
|
+
if (!s || s.parentId === undefined || s.parentId === null) continue;
|
|
2058
|
+
const kids = childrenOf.get(s.parentId);
|
|
2059
|
+
if (kids === undefined) childrenOf.set(s.parentId, [s.id]);
|
|
2060
|
+
else kids.push(s.id);
|
|
2061
|
+
}
|
|
2062
|
+
const out = new Set();
|
|
2063
|
+
const queue = [sid];
|
|
2064
|
+
while (queue.length > 0) {
|
|
2065
|
+
const id = queue.pop();
|
|
2066
|
+
if (out.has(id)) continue;
|
|
2067
|
+
out.add(id);
|
|
2068
|
+
for (const child of childrenOf.get(id) ?? []) queue.push(child);
|
|
2069
|
+
}
|
|
2070
|
+
return out;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* 「已彻底删除」墓碑:日志已从磁盘删掉、但 DSH 进程仍持有该会话的内存副本时,
|
|
2075
|
+
* Host 的会话列表依然会列出它(列表优先用内存里的会话),直到 DSH 重启。
|
|
2076
|
+
* 只靠 `sessions.refresh()` 无法让行消失,用户会看到「删了还在」,与「彻底
|
|
2077
|
+
* 删除」矛盾。所以前端自己记住这些 id 并隐藏对应行,等 Host 不再列出它们
|
|
2078
|
+
* (重启后内存副本随进程消失)再摘掉墓碑。
|
|
2079
|
+
*
|
|
2080
|
+
* 落在 localStorage:页面刷新不该让删掉的行复活 —— 磁盘上已经没有任何东西了。
|
|
2081
|
+
* 存储不可用(隐私模式/配额)时退化为「本次页面会话内隐藏」,不影响删除本身。
|
|
2082
|
+
*/
|
|
2083
|
+
const PURGED_IDS_KEY = "dsh-obsidian-vault:purged-sessions";
|
|
2084
|
+
|
|
2085
|
+
function readPurgedIds() {
|
|
2086
|
+
try {
|
|
2087
|
+
const raw = (typeof localStorage !== "undefined" && localStorage) ? localStorage.getItem(PURGED_IDS_KEY) : null;
|
|
2088
|
+
if (!raw) return new Set();
|
|
2089
|
+
const list = JSON.parse(raw);
|
|
2090
|
+
return new Set(Array.isArray(list) ? list.filter((id) => typeof id === "string") : []);
|
|
2091
|
+
} catch {
|
|
2092
|
+
return new Set();
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function writePurgedIds(ids) {
|
|
2097
|
+
try {
|
|
2098
|
+
if (typeof localStorage !== "undefined" && localStorage) {
|
|
2099
|
+
localStorage.setItem(PURGED_IDS_KEY, JSON.stringify([...ids]));
|
|
2100
|
+
}
|
|
2101
|
+
} catch { /* 存储不可用:退化为本次页面会话内隐藏,删除结果本身不受影响 */ }
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
const treeApi = {
|
|
2105
|
+
tree: (path = "") => api(`/tree?path=${encodeURIComponent(path)}`),
|
|
2106
|
+
search: (q) => api(`/search?q=${encodeURIComponent(q)}`),
|
|
2107
|
+
root: () => api("/root"),
|
|
2108
|
+
setRoot: (root) => api("/root", {
|
|
2109
|
+
method: "POST",
|
|
2110
|
+
headers: { "content-type": "application/json" },
|
|
2111
|
+
body: JSON.stringify({ root }),
|
|
2112
|
+
}),
|
|
2113
|
+
// 上传粘贴的图片(base64)→ { path }(Obsidian 风格的链接目标)。
|
|
2114
|
+
// 带 `note`:位置模式为 note/sub 时要落到「当前笔记目录」下。
|
|
2115
|
+
attach: (data, note) => api("/attach", {
|
|
2116
|
+
method: "POST",
|
|
2117
|
+
headers: { "content-type": "application/json" },
|
|
2118
|
+
body: JSON.stringify(note ? { data, note } : { data }),
|
|
2119
|
+
}),
|
|
2120
|
+
// 设置固定附件目录(POSIX 相对路径;"" = Vault 根)——会把位置模式切到 fixed/root
|
|
2121
|
+
setAttachDir: (dir) => api("/attach-dir", {
|
|
2122
|
+
method: "POST",
|
|
2123
|
+
headers: { "content-type": "application/json" },
|
|
2124
|
+
body: JSON.stringify({ dir }),
|
|
2125
|
+
}),
|
|
2126
|
+
// 附件位置模式 / 子目录名 / 隐藏目录 / 自动清理孤儿
|
|
2127
|
+
setAttachSettings: (patch) => api("/attach-settings", {
|
|
2128
|
+
method: "POST",
|
|
2129
|
+
headers: { "content-type": "application/json" },
|
|
2130
|
+
body: JSON.stringify(patch),
|
|
2131
|
+
}),
|
|
2132
|
+
// embed/相对路径 → 图片字节流地址(预览用)。
|
|
2133
|
+
// 必须带上 `from`(引用它的笔记路径):图片目标要按 Obsidian 语义解析
|
|
2134
|
+
// (精确路径 → 笔记相对 → 后缀/文件名匹配),否则子目录笔记里的
|
|
2135
|
+
// `![[附件/a.png]]` 会被当成 Vault 根下的 `附件/a.png` → 403 → 只剩断图。
|
|
2136
|
+
assetUrl: (rel, from) => `/obsidian/file?path=${encodeURIComponent(rel)}`
|
|
2137
|
+
+ (from ? `&from=${encodeURIComponent(from)}` : ""),
|
|
2138
|
+
note: (rel) => api(`/note?path=${encodeURIComponent(rel)}`),
|
|
2139
|
+
save: (rel, content) => api("/note", {
|
|
2140
|
+
method: "POST",
|
|
2141
|
+
headers: { "content-type": "application/json" },
|
|
2142
|
+
body: JSON.stringify({ path: rel, content }),
|
|
2143
|
+
}),
|
|
2144
|
+
newNote: (dirRel, name) => api("/file", {
|
|
2145
|
+
method: "POST",
|
|
2146
|
+
headers: { "content-type": "application/json" },
|
|
2147
|
+
body: JSON.stringify({ action: "new", path: dirRel, name }),
|
|
2148
|
+
}),
|
|
2149
|
+
newFolder: (dirRel, name) => api("/file", {
|
|
2150
|
+
method: "POST",
|
|
2151
|
+
headers: { "content-type": "application/json" },
|
|
2152
|
+
body: JSON.stringify({ action: "mkdir", path: dirRel, name }),
|
|
2153
|
+
}),
|
|
2154
|
+
renameNote: (rel, newName) => api("/file", {
|
|
2155
|
+
method: "POST",
|
|
2156
|
+
headers: { "content-type": "application/json" },
|
|
2157
|
+
body: JSON.stringify({ action: "rename", path: rel, newName }),
|
|
2158
|
+
}),
|
|
2159
|
+
deleteNote: (rel) => api("/file", {
|
|
2160
|
+
method: "POST",
|
|
2161
|
+
headers: { "content-type": "application/json" },
|
|
2162
|
+
body: JSON.stringify({ action: "delete", path: rel }),
|
|
2163
|
+
}),
|
|
2164
|
+
// 彻底删除会话日志(host 侧带外维护:DSH 持久化层没有删除接口)。
|
|
2165
|
+
// 必须一次把「父会话 + 全部子代理会话」的 id 一起送过去。
|
|
2166
|
+
deleteSessions: (ids) => api("/session-delete", {
|
|
2167
|
+
method: "POST",
|
|
2168
|
+
headers: { "content-type": "application/json" },
|
|
2169
|
+
body: JSON.stringify({ ids }),
|
|
2170
|
+
}),
|
|
2171
|
+
// 只读探测:Host 权威判定这些 id 在磁盘上还有没有 durable 日志(`persisted`)、
|
|
2172
|
+
// 内存里有没有副本(`live`)、是不是在跑。用于隐藏「日志早就不在了、只剩内存
|
|
2173
|
+
// 副本」的僵死行 —— 这类历史删除没有本地墓碑可查(见 readPurgedIds)。
|
|
2174
|
+
sessionState: (ids) => api("/session-state", {
|
|
2175
|
+
method: "POST",
|
|
2176
|
+
headers: { "content-type": "application/json" },
|
|
2177
|
+
body: JSON.stringify({ ids }),
|
|
2178
|
+
}),
|
|
2179
|
+
/**
|
|
2180
|
+
* 拼接绝对路径(用于 `@路径` 引用与提问 prompt)。
|
|
2181
|
+
*
|
|
2182
|
+
* 分隔符必须跟随 root 的形态:写死 `\` 会让 Linux/macOS 上的
|
|
2183
|
+
* `@/home/…\笔记.md` 变成混合分隔符,DSH 解析不到该引用。
|
|
2184
|
+
* @param rel - Vault 相对路径(内部统一用 `/`)。
|
|
2185
|
+
* @param root - Vault 根目录(可能以分隔符结尾)。
|
|
2186
|
+
* @returns 绝对路径。
|
|
2187
|
+
*/
|
|
2188
|
+
absPath: (rel, root) => {
|
|
2189
|
+
const parts = rel.split("/").filter((p) => p !== "");
|
|
2190
|
+
const windows = /^[a-zA-Z]:/.test(root) || (root.includes("\\") && !root.includes("/"));
|
|
2191
|
+
if (windows) return [root, ...parts].join("\\").replace(/^([a-zA-Z]):\\\\/, "$1:\\");
|
|
2192
|
+
return [root.replace(/\/+$/, ""), ...parts].join("/");
|
|
2193
|
+
},
|
|
2194
|
+
};
|
|
2195
|
+
|
|
2196
|
+
function joinRel(dir, name) {
|
|
2197
|
+
return dir === "" ? name : `${dir}/${name}`;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/** 在源码 draft 的 [start,end) 处插入 embed,返回新 draft(粘贴与测试共用)。 */
|
|
2201
|
+
function insertEmbedAtSelection(draft, start, end, embed) {
|
|
2202
|
+
const len = draft.length;
|
|
2203
|
+
const s = Math.max(0, Math.min(typeof start === "number" ? start : len, len));
|
|
2204
|
+
const e = Math.max(s, Math.min(typeof end === "number" ? end : s, len));
|
|
2205
|
+
return draft.slice(0, s) + embed + draft.slice(e);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/** 读剪贴板图片 File 为 dataURL(base64)。 */
|
|
2209
|
+
function fileToDataUrl(file, W) {
|
|
2210
|
+
const win = W || (typeof window !== "undefined" ? window : null);
|
|
2211
|
+
return new Promise((resolve, reject) => {
|
|
2212
|
+
if (!win || !win.FileReader) { reject(new Error("FileReader unavailable")); return; }
|
|
2213
|
+
const fr = new win.FileReader();
|
|
2214
|
+
fr.onload = () => resolve(String(fr.result || ""));
|
|
2215
|
+
fr.onerror = () => reject(new Error("read image failed"));
|
|
2216
|
+
fr.readAsDataURL(file);
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// ── 笔记树视图(「📚 笔记」行下展开,嵌入侧边栏滚动区)──────────────────────
|
|
2221
|
+
function TreeView({ t, uiWorkspace }) {
|
|
2222
|
+
const { openNote, vaultRev } = useShared();
|
|
2223
|
+
const [rootInfo, setRootInfo] = useState(null);
|
|
2224
|
+
const [dirs, setDirs] = useState({});
|
|
2225
|
+
const [expanded, setExpanded] = useState(() => new Set());
|
|
2226
|
+
const [loading, setLoading] = useState(false);
|
|
2227
|
+
const [error, setError] = useState("");
|
|
2228
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
2229
|
+
const [query, setQuery] = useState("");
|
|
2230
|
+
const [results, setResults] = useState([]);
|
|
2231
|
+
const [searching, setSearching] = useState(false);
|
|
2232
|
+
const searchTimer = useRef(null);
|
|
2233
|
+
// 「附件目录」设置(POSIX 相对路径,"" = Vault 根;初始 null 表示尚未加载)
|
|
2234
|
+
const [attachDir, setAttachDirState] = useState(null);
|
|
2235
|
+
const [attachOpen, setAttachOpen] = useState(false);
|
|
2236
|
+
const [attachInput, setAttachInput] = useState("");
|
|
2237
|
+
// 附件位置模式 / 子目录 / 两个开关(对齐 Obsidian 的附件位置设置)
|
|
2238
|
+
const [attachCfg, setAttachCfg] = useState(null);
|
|
2239
|
+
|
|
2240
|
+
// 已展开目录的镜像:loadRoot 的 useCallback 依赖要保持稳定(否则每次展开都会
|
|
2241
|
+
// 重建整棵树),刷新时又要读到最新的展开集合,故用 ref 传递。
|
|
2242
|
+
const expandedRef = useRef(expanded);
|
|
2243
|
+
expandedRef.current = expanded;
|
|
2244
|
+
|
|
2245
|
+
const loadRoot = useCallback(async () => {
|
|
2246
|
+
setLoading(true);
|
|
2247
|
+
setError("");
|
|
2248
|
+
try {
|
|
2249
|
+
const info = await treeApi.root();
|
|
2250
|
+
setRootInfo(info);
|
|
2251
|
+
setAttachDirState(typeof info.attachmentDir === "string" ? info.attachmentDir : "attachments");
|
|
2252
|
+
setAttachCfg({
|
|
2253
|
+
mode: typeof info.attachmentMode === "string" ? info.attachmentMode : "fixed",
|
|
2254
|
+
subfolder: typeof info.attachmentSubfolder === "string" ? info.attachmentSubfolder : "attachments",
|
|
2255
|
+
hideDir: info.hideAttachmentDir !== false,
|
|
2256
|
+
deleteOrphans: info.deleteOrphanAttachments === true,
|
|
2257
|
+
});
|
|
2258
|
+
if (!info.exists) {
|
|
2259
|
+
setDirs({});
|
|
2260
|
+
setExpanded(new Set());
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
const list = await treeApi.tree("");
|
|
2264
|
+
const next = { "": list.entries };
|
|
2265
|
+
// 刷新(⟳ / vaultRev)保留展开状态:把展开中的目录一并重取,否则每次刷新都
|
|
2266
|
+
// 把整棵树折叠回去,而已展开目录里显示的仍是刷新前的旧内容。
|
|
2267
|
+
const open = [...expandedRef.current];
|
|
2268
|
+
await Promise.all(open.map(async (rel) => {
|
|
2269
|
+
try {
|
|
2270
|
+
next[rel] = (await treeApi.tree(rel)).entries;
|
|
2271
|
+
} catch { /* 目录已消失:下面从展开集合里剔除 */ }
|
|
2272
|
+
}));
|
|
2273
|
+
setDirs(next);
|
|
2274
|
+
setExpanded(new Set(open.filter((rel) => next[rel] !== undefined)));
|
|
2275
|
+
} catch (e) {
|
|
2276
|
+
setError(String(e && e.message ? e.message : e));
|
|
2277
|
+
} finally {
|
|
2278
|
+
setLoading(false);
|
|
2279
|
+
}
|
|
2280
|
+
}, []);
|
|
2281
|
+
|
|
2282
|
+
useEffect(() => {
|
|
2283
|
+
void loadRoot();
|
|
2284
|
+
}, [loadRoot, vaultRev]);
|
|
2285
|
+
|
|
2286
|
+
const toggleDir = useCallback(async (rel) => {
|
|
2287
|
+
const willOpen = !expanded.has(rel);
|
|
2288
|
+
setExpanded((prev) => {
|
|
2289
|
+
const next = new Set(prev);
|
|
2290
|
+
if (next.has(rel)) next.delete(rel);
|
|
2291
|
+
else next.add(rel);
|
|
2292
|
+
return next;
|
|
2293
|
+
});
|
|
2294
|
+
// 展开时一律重新拉取:目录内容可能被外部改动(DSH/代理写入、外部编辑器、
|
|
2295
|
+
// 同步工具)。只按 `dirs[rel] === undefined` 判缓存会漏掉一个要命的等价类 ——
|
|
2296
|
+
// **空数组也不算 undefined**,于是「曾经展开过一次的空目录」被永久钉死,
|
|
2297
|
+
// 之后再怎么折叠/展开都不再请求,新写进来的笔记永远不显示。
|
|
2298
|
+
if (willOpen) {
|
|
2299
|
+
try {
|
|
2300
|
+
const list = await treeApi.tree(rel);
|
|
2301
|
+
setDirs((d) => ({ ...d, [rel]: list.entries }));
|
|
2302
|
+
} catch (e) {
|
|
2303
|
+
setError(String(e && e.message ? e.message : e));
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
}, [expanded]);
|
|
2307
|
+
|
|
2308
|
+
const openNoteByRel = useCallback((rel, name) => {
|
|
2309
|
+
patchShared({ openNote: { rel, name } });
|
|
2310
|
+
}, []);
|
|
2311
|
+
|
|
2312
|
+
// 「📚 笔记」行的笔记数徽标:统计已加载目录中的笔记
|
|
2313
|
+
useEffect(() => {
|
|
2314
|
+
let n = 0;
|
|
2315
|
+
for (const entries of Object.values(dirs)) {
|
|
2316
|
+
for (const entry of entries) {
|
|
2317
|
+
if (entry.type === "note") n += 1;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
if (n !== shared.noteCount) patchShared({ noteCount: n });
|
|
2321
|
+
}, [dirs]);
|
|
2322
|
+
|
|
2323
|
+
useEffect(() => {
|
|
2324
|
+
if (searchTimer.current !== null) window.clearTimeout(searchTimer.current);
|
|
2325
|
+
const q = query.trim();
|
|
2326
|
+
if (q === "") {
|
|
2327
|
+
setResults([]);
|
|
2328
|
+
setSearching(false);
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
setSearching(true);
|
|
2332
|
+
searchTimer.current = window.setTimeout(async () => {
|
|
2333
|
+
try {
|
|
2334
|
+
const r = await treeApi.search(q);
|
|
2335
|
+
setResults(r.results);
|
|
2336
|
+
} catch (e) {
|
|
2337
|
+
setError(String(e && e.message ? e.message : e));
|
|
2338
|
+
} finally {
|
|
2339
|
+
setSearching(false);
|
|
2340
|
+
}
|
|
2341
|
+
}, 250);
|
|
2342
|
+
}, [query]);
|
|
2343
|
+
|
|
2344
|
+
// 改附件位置/开关:写回设置后重载 root(重载会保留展开状态)
|
|
2345
|
+
const applyAttachSettings = useCallback(async (patch) => {
|
|
2346
|
+
try {
|
|
2347
|
+
await treeApi.setAttachSettings(patch);
|
|
2348
|
+
await loadRoot();
|
|
2349
|
+
} catch (e) {
|
|
2350
|
+
setError(String(e && e.message ? e.message : e));
|
|
2351
|
+
}
|
|
2352
|
+
}, [loadRoot]);
|
|
2353
|
+
|
|
2354
|
+
const chooseVault = useCallback(async () => {
|
|
2355
|
+
if (!uiWorkspace || typeof uiWorkspace.pickDirectory !== "function") return;
|
|
2356
|
+
try {
|
|
2357
|
+
const picked = await uiWorkspace.pickDirectory();
|
|
2358
|
+
if (picked === null) return;
|
|
2359
|
+
await treeApi.setRoot(picked);
|
|
2360
|
+
patchShared({ vaultRev: shared.vaultRev + 1, openNote: null });
|
|
2361
|
+
showToast(`${t("vaultRoot")} → ${picked}`);
|
|
2362
|
+
} catch (e) {
|
|
2363
|
+
setError(String(e && e.message ? e.message : e));
|
|
2364
|
+
}
|
|
2365
|
+
}, [uiWorkspace, t]);
|
|
2366
|
+
|
|
2367
|
+
const pasteVault = useCallback(async () => {
|
|
2368
|
+
const value = await askText(t("pasteVaultTitle"));
|
|
2369
|
+
if (value === null) return;
|
|
2370
|
+
try {
|
|
2371
|
+
await treeApi.setRoot(value.trim());
|
|
2372
|
+
patchShared({ vaultRev: shared.vaultRev + 1, openNote: null });
|
|
2373
|
+
showToast(`${t("vaultRoot")} → ${value.trim()}`);
|
|
2374
|
+
} catch (e) {
|
|
2375
|
+
setError(String(e && e.message ? e.message : e));
|
|
2376
|
+
}
|
|
2377
|
+
}, [t]);
|
|
2378
|
+
|
|
2379
|
+
// 附件目录:手填相对路径 / 目录选择器(Vault 内,交由 host 换算)/ 清空回根
|
|
2380
|
+
const applyAttachDir = useCallback(async (value) => {
|
|
2381
|
+
const v = (value ?? "").trim();
|
|
2382
|
+
const current = typeof attachDir === "string" ? attachDir : "attachments";
|
|
2383
|
+
// 变更目录会迁移已有截图并更新笔记引用:确认后才执行
|
|
2384
|
+
if (v !== current) {
|
|
2385
|
+
const from = current === "" ? t("vaultRoot") : current;
|
|
2386
|
+
const to = v === "" ? t("vaultRoot") : v;
|
|
2387
|
+
if (!window.confirm(t("attachDirConfirm", { from, to }))) return;
|
|
2388
|
+
}
|
|
2389
|
+
try {
|
|
2390
|
+
const r = await treeApi.setAttachDir(v);
|
|
2391
|
+
setAttachDirState(typeof r.attachmentDir === "string" ? r.attachmentDir : "");
|
|
2392
|
+
setAttachOpen(false);
|
|
2393
|
+
patchShared({ vaultRev: shared.vaultRev + 1 });
|
|
2394
|
+
const moved = Array.isArray(r.moved) ? r.moved.length : 0;
|
|
2395
|
+
const notes = Array.isArray(r.notesUpdated) ? r.notesUpdated.length : 0;
|
|
2396
|
+
showToast(moved > 0 || notes > 0
|
|
2397
|
+
? `${t("attachDir")} → ${(r.attachmentDir || "") === "" ? t("vaultRoot") : r.attachmentDir}(${t("migrated", { n: moved, m: notes })})`
|
|
2398
|
+
: `${t("attachDir")} → ${(r.attachmentDir || "") === "" ? t("vaultRoot") : r.attachmentDir}`);
|
|
2399
|
+
} catch (e) {
|
|
2400
|
+
setError(String(e && e.message ? e.message : e));
|
|
2401
|
+
}
|
|
2402
|
+
}, [t, attachDir]);
|
|
2403
|
+
|
|
2404
|
+
const pickAttachDir = useCallback(async () => {
|
|
2405
|
+
if (!uiWorkspace || typeof uiWorkspace.pickDirectory !== "function") return;
|
|
2406
|
+
try {
|
|
2407
|
+
const picked = await uiWorkspace.pickDirectory();
|
|
2408
|
+
if (picked === null) return;
|
|
2409
|
+
await applyAttachDir(picked); // host 会做 Vault 内包含性校验并换算相对路径
|
|
2410
|
+
} catch (e) {
|
|
2411
|
+
setError(String(e && e.message ? e.message : e));
|
|
2412
|
+
}
|
|
2413
|
+
}, [uiWorkspace, applyAttachDir]);
|
|
2414
|
+
|
|
2415
|
+
const newNote = useCallback(async (dirRel) => {
|
|
2416
|
+
const name = await askText(t("noteNamePrompt"));
|
|
2417
|
+
if (name === null || name.trim() === "") return;
|
|
2418
|
+
try {
|
|
2419
|
+
const created = await treeApi.newNote(dirRel, name.trim());
|
|
2420
|
+
patchShared({ vaultRev: shared.vaultRev + 1 });
|
|
2421
|
+
openNoteByRel(created.path, created.path.split("/").pop() ?? created.path);
|
|
2422
|
+
} catch (e) {
|
|
2423
|
+
setError(String(e && e.message ? e.message : e));
|
|
2424
|
+
}
|
|
2425
|
+
}, [t, openNoteByRel]);
|
|
2426
|
+
|
|
2427
|
+
const newFolder = useCallback(async () => {
|
|
2428
|
+
const name = await askText(t("newFolderPrompt"));
|
|
2429
|
+
if (name === null || name.trim() === "") return;
|
|
2430
|
+
try {
|
|
2431
|
+
await treeApi.newFolder("", name.trim());
|
|
2432
|
+
patchShared({ vaultRev: shared.vaultRev + 1 });
|
|
2433
|
+
} catch (e) {
|
|
2434
|
+
setError(String(e && e.message ? e.message : e));
|
|
2435
|
+
}
|
|
2436
|
+
}, [t]);
|
|
2437
|
+
|
|
2438
|
+
const deleteEntry = useCallback(async (rel, name, kind) => {
|
|
2439
|
+
const ok = kind === "dir"
|
|
2440
|
+
? window.confirm(t("deleteFolderConfirm", { name }))
|
|
2441
|
+
: window.confirm(t("deleteNoteConfirm", { name }));
|
|
2442
|
+
if (!ok) return;
|
|
2443
|
+
try {
|
|
2444
|
+
await treeApi.deleteNote(rel);
|
|
2445
|
+
if (openNote && (openNote.rel === rel || openNote.rel.startsWith(rel + "/"))) patchShared({ openNote: null });
|
|
2446
|
+
patchShared({ vaultRev: shared.vaultRev + 1 });
|
|
2447
|
+
showToast(t("deleted"));
|
|
2448
|
+
} catch (e) {
|
|
2449
|
+
setError(String(e && e.message ? e.message : e));
|
|
2450
|
+
}
|
|
2451
|
+
}, [t, openNote]);
|
|
2452
|
+
|
|
2453
|
+
// 附件位置的一行摘要(Obsidian 的四种位置模式)
|
|
2454
|
+
const attachLocationLabel = (cfg, dir, tt) => {
|
|
2455
|
+
if (cfg === null) return dir === "" ? tt("vaultRoot") : dir;
|
|
2456
|
+
if (cfg.mode === "root") return tt("attachMode_root");
|
|
2457
|
+
if (cfg.mode === "note") return tt("attachMode_note");
|
|
2458
|
+
if (cfg.mode === "sub") return `${tt("attachMode_note")} / ${cfg.subfolder}`;
|
|
2459
|
+
return dir === "" ? tt("vaultRoot") : dir;
|
|
2460
|
+
};
|
|
2461
|
+
|
|
2462
|
+
const renderEntries = (rel, depth) => {
|
|
2463
|
+
const entries = dirs[rel] ?? [];
|
|
2464
|
+
return entries.map((entry) => {
|
|
2465
|
+
const childRel = joinRel(rel, entry.name);
|
|
2466
|
+
if (entry.type === "dir") {
|
|
2467
|
+
const isOpen = expanded.has(childRel);
|
|
2468
|
+
return createElement(Fragment, { key: childRel },
|
|
2469
|
+
createElement("div", {
|
|
2470
|
+
className: "dsh-obs-row",
|
|
2471
|
+
style: { paddingLeft: `${6 + depth * 14}px` },
|
|
2472
|
+
onClick: () => void toggleDir(childRel),
|
|
2473
|
+
},
|
|
2474
|
+
createElement("span", { className: "dsh-obs-chev" }, isOpen ? "▾" : "▸"),
|
|
2475
|
+
createElement("span", { className: "dsh-obs-name", style: { flex: "none" } }, isOpen ? "📂" : "📁"),
|
|
2476
|
+
createElement("span", { className: "dsh-obs-name" }, entry.name),
|
|
2477
|
+
createElement("button", {
|
|
2478
|
+
className: "dsh-obs-mini",
|
|
2479
|
+
title: t("newNote"),
|
|
2480
|
+
onClick: (e) => { e.stopPropagation(); void newNote(childRel); },
|
|
2481
|
+
}, "+"),
|
|
2482
|
+
createElement("button", {
|
|
2483
|
+
className: "dsh-obs-mini",
|
|
2484
|
+
title: t("delete"),
|
|
2485
|
+
onClick: (e) => { e.stopPropagation(); void deleteEntry(childRel, entry.name, "dir"); },
|
|
2486
|
+
}, "🗑"),
|
|
2487
|
+
),
|
|
2488
|
+
isOpen && createElement("div", null, renderEntries(childRel, depth + 1)),
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
const selected = openNote && openNote.rel === childRel;
|
|
2492
|
+
return createElement("div", {
|
|
2493
|
+
key: childRel,
|
|
2494
|
+
className: `dsh-obs-row${selected ? " dsh-obs-selected" : ""}`,
|
|
2495
|
+
style: { paddingLeft: `${6 + depth * 14}px` },
|
|
2496
|
+
onClick: () => openNoteByRel(childRel, entry.name),
|
|
2497
|
+
},
|
|
2498
|
+
createElement("span", { className: "dsh-obs-chev" }, "📝"),
|
|
2499
|
+
createElement("span", { className: "dsh-obs-name" }, entry.name),
|
|
2500
|
+
createElement("span", { className: "dsh-obs-size" }, formatSize(entry.size)),
|
|
2501
|
+
createElement("button", {
|
|
2502
|
+
className: "dsh-obs-mini",
|
|
2503
|
+
title: t("delete"),
|
|
2504
|
+
onClick: (e) => { e.stopPropagation(); void deleteEntry(childRel, entry.name, "note"); },
|
|
2505
|
+
}, "🗑"),
|
|
2506
|
+
);
|
|
2507
|
+
});
|
|
2508
|
+
};
|
|
2509
|
+
|
|
2510
|
+
let body;
|
|
2511
|
+
if (rootInfo === null) {
|
|
2512
|
+
body = createElement("div", { className: "dsh-obs-empty" }, t("loading"));
|
|
2513
|
+
} else if (!rootInfo.exists) {
|
|
2514
|
+
body = createElement("div", { className: "dsh-obs-empty" },
|
|
2515
|
+
createElement("div", null, t("noVault")),
|
|
2516
|
+
createElement("div", { style: { fontSize: 11 } }, rootInfo.root),
|
|
2517
|
+
createElement("button", { onClick: () => void chooseVault() }, t("pickVault")),
|
|
2518
|
+
);
|
|
2519
|
+
} else if (query.trim() !== "") {
|
|
2520
|
+
body = searching
|
|
2521
|
+
? createElement("div", { className: "dsh-obs-empty" }, t("loading"))
|
|
2522
|
+
: results.length === 0
|
|
2523
|
+
? createElement("div", { className: "dsh-obs-empty" }, t("empty"))
|
|
2524
|
+
: createElement("div", { className: "dsh-obs-body" },
|
|
2525
|
+
results.map((r) => createElement("div", {
|
|
2526
|
+
key: r.path,
|
|
2527
|
+
className: "dsh-obs-row",
|
|
2528
|
+
onClick: () => openNoteByRel(r.path, r.name),
|
|
2529
|
+
},
|
|
2530
|
+
createElement("span", { className: "dsh-obs-chev" }, "📝"),
|
|
2531
|
+
createElement("div", { style: { flex: 1, minWidth: 0 } },
|
|
2532
|
+
createElement("div", { className: "dsh-obs-name" }, r.name),
|
|
2533
|
+
r.snippet !== "" && createElement("div", { className: "dsh-obs-snippet" }, r.snippet),
|
|
2534
|
+
),
|
|
2535
|
+
)),
|
|
2536
|
+
);
|
|
2537
|
+
} else if (Object.keys(dirs).length === 0) {
|
|
2538
|
+
body = createElement("div", { className: "dsh-obs-empty" }, loading ? t("loading") : t("empty"));
|
|
2539
|
+
} else {
|
|
2540
|
+
body = createElement("div", { className: "dsh-obs-body" },
|
|
2541
|
+
(dirs[""] ?? []).length === 0 && createElement("div", { className: "dsh-obs-empty" }, t("empty")),
|
|
2542
|
+
renderEntries("", 0),
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
return createElement("div", { className: "dsh-obs-tree" },
|
|
2547
|
+
createElement("div", { className: "dsh-obs-tree-head" },
|
|
2548
|
+
// 目录选择器由 uiWorkspace 提供:ctx.workspaces 上没有 pickDirectory
|
|
2549
|
+
uiWorkspace && createElement("button", { title: t("pickVaultTitle"), onClick: () => void chooseVault() }, "📂"),
|
|
2550
|
+
uiWorkspace && createElement("button", { title: t("pasteVaultTitle"), onClick: () => void pasteVault() }, "📋"),
|
|
2551
|
+
createElement("button", { title: t("newFolder"), onClick: () => void newFolder() }, "📁"),
|
|
2552
|
+
createElement("button", { title: t("refresh"), onClick: () => void loadRoot() }, "⟳"),
|
|
2553
|
+
createElement("button", {
|
|
2554
|
+
title: t("search"),
|
|
2555
|
+
className: searchOpen ? "dsh-obs-on" : "",
|
|
2556
|
+
onClick: () => { setSearchOpen((v) => !v); setQuery(""); },
|
|
2557
|
+
}, "🔍"),
|
|
2558
|
+
createElement("button", { title: t("collapse"), onClick: () => patchShared({ notesOpen: false }) }, "✕"),
|
|
2559
|
+
),
|
|
2560
|
+
searchOpen && createElement("div", { className: "dsh-obs-search" },
|
|
2561
|
+
createElement("input", {
|
|
2562
|
+
autoFocus: true,
|
|
2563
|
+
placeholder: t("searchPlaceholder"),
|
|
2564
|
+
value: query,
|
|
2565
|
+
onChange: (e) => setQuery(e.target.value),
|
|
2566
|
+
onKeyDown: (e) => {
|
|
2567
|
+
if (e.key === "Escape") { setSearchOpen(false); setQuery(""); }
|
|
2568
|
+
},
|
|
2569
|
+
}),
|
|
2570
|
+
),
|
|
2571
|
+
rootInfo && rootInfo.exists && createElement("div", { className: "dsh-obs-root-row", title: rootInfo.root },
|
|
2572
|
+
createElement("span", { className: "dsh-obs-root-path" }, rootInfo.root),
|
|
2573
|
+
createElement("button", {
|
|
2574
|
+
className: "dsh-obs-mini",
|
|
2575
|
+
title: t("newNote"),
|
|
2576
|
+
onClick: (e) => { e.stopPropagation(); void newNote(""); },
|
|
2577
|
+
}, "+"),
|
|
2578
|
+
),
|
|
2579
|
+
rootInfo && rootInfo.exists && attachDir !== null && createElement("div", { className: "dsh-obs-root-row", title: t("attachSettings") },
|
|
2580
|
+
createElement("span", { className: "dsh-obs-root-path" },
|
|
2581
|
+
`📎 ${t("attachLocation")}: ${attachLocationLabel(attachCfg, attachDir, t)}`),
|
|
2582
|
+
attachCfg !== null && attachCfg.mode === "fixed" && createElement("button", {
|
|
2583
|
+
className: "dsh-obs-mini",
|
|
2584
|
+
title: t("attachDirPick"),
|
|
2585
|
+
onClick: (e) => { e.stopPropagation(); void pickAttachDir(); },
|
|
2586
|
+
}, "📂"),
|
|
2587
|
+
attachCfg !== null && attachCfg.mode === "fixed" && createElement("button", {
|
|
2588
|
+
className: "dsh-obs-mini",
|
|
2589
|
+
title: t("attachDirEdit"),
|
|
2590
|
+
onClick: (e) => { e.stopPropagation(); setAttachInput(attachDir); setAttachOpen((v) => !v); },
|
|
2591
|
+
}, "✎"),
|
|
2592
|
+
createElement("button", {
|
|
2593
|
+
className: "dsh-obs-mini",
|
|
2594
|
+
title: t("attachSettings"),
|
|
2595
|
+
onClick: (e) => { e.stopPropagation(); setAttachOpen((v) => !v); },
|
|
2596
|
+
}, "⚙"),
|
|
2597
|
+
),
|
|
2598
|
+
// 附件设置面板:位置模式(Obsidian 的四种)+ 子目录名 + 两个开关
|
|
2599
|
+
attachOpen && rootInfo && rootInfo.exists && attachCfg !== null && createElement("div", { className: "dsh-obs-attachcfg" },
|
|
2600
|
+
...["root", "fixed", "note", "sub"].map((m) => createElement("button", {
|
|
2601
|
+
key: m,
|
|
2602
|
+
className: `dsh-obs-mini${attachCfg.mode === m ? " dsh-obs-on" : ""}`,
|
|
2603
|
+
title: t(`attachMode_${m}`),
|
|
2604
|
+
onClick: (e) => { e.stopPropagation(); void applyAttachSettings({ mode: m }); },
|
|
2605
|
+
}, t(`attachMode_${m}`))),
|
|
2606
|
+
attachCfg.mode === "fixed" && createElement("input", {
|
|
2607
|
+
autoFocus: true,
|
|
2608
|
+
placeholder: t("attachDirPlaceholder"),
|
|
2609
|
+
value: attachInput,
|
|
2610
|
+
onChange: (e) => setAttachInput(e.target.value),
|
|
2611
|
+
onKeyDown: (e) => {
|
|
2612
|
+
if (e.key === "Enter") void applyAttachDir(attachInput);
|
|
2613
|
+
if (e.key === "Escape") setAttachOpen(false);
|
|
2614
|
+
},
|
|
2615
|
+
}),
|
|
2616
|
+
attachCfg.mode === "sub" && createElement("input", {
|
|
2617
|
+
placeholder: t("attachSubfolder"),
|
|
2618
|
+
defaultValue: attachCfg.subfolder,
|
|
2619
|
+
onKeyDown: (e) => { if (e.key === "Enter") void applyAttachSettings({ subfolder: e.target.value }); },
|
|
2620
|
+
}),
|
|
2621
|
+
createElement("label", { className: "dsh-obs-cfgline" },
|
|
2622
|
+
createElement("input", {
|
|
2623
|
+
type: "checkbox",
|
|
2624
|
+
checked: attachCfg.hideDir,
|
|
2625
|
+
onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ hideAttachmentDir: e.target.checked === true }); },
|
|
2626
|
+
}),
|
|
2627
|
+
t("attachHideDir"),
|
|
2628
|
+
),
|
|
2629
|
+
createElement("label", { className: "dsh-obs-cfgline" },
|
|
2630
|
+
createElement("input", {
|
|
2631
|
+
type: "checkbox",
|
|
2632
|
+
checked: attachCfg.deleteOrphans,
|
|
2633
|
+
onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ deleteOrphanAttachments: e.target.checked === true }); },
|
|
2634
|
+
}),
|
|
2635
|
+
t("attachDeleteOrphans"),
|
|
2636
|
+
),
|
|
2637
|
+
),
|
|
2638
|
+
body,
|
|
2639
|
+
error !== "" && createElement("div", { className: "dsh-obs-error" }, error),
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// ── 常驻 WorkspaceSidebar(sidebar.workspaces 接管)─────────────────────────
|
|
2644
|
+
// `phase: "pending"` 必须与真实 Store 的初始快照一致(`@deepseek-ai/
|
|
2645
|
+
// dsh-api-session-controller` 的 `createSnapshotStore({ ids: [], byId: {},
|
|
2646
|
+
// phase: "pending" })`):这就是「列表还没到」的判据,墓碑 GC 靠它避免把
|
|
2647
|
+
// 「还没有数据」误读成「Host 不再列出这些会话」。
|
|
2648
|
+
const EMPTY_SESSIONS = { ids: [], byId: {}, current: undefined, phase: "pending" };
|
|
2649
|
+
const EMPTY_WORKSPACES = { items: [], archivedSessionIds: [], state: "ready", phase: "ready", error: null, baselinesReady: true, recentWorkspaceId: undefined };
|
|
2650
|
+
const UNGROUPED_KEY = "";
|
|
2651
|
+
|
|
2652
|
+
function WorkspaceSidebar({ wide, expandSidebar, useSessions, useWorkspaces, workspaces, sessions, uiWorkspace, t }) {
|
|
2653
|
+
const { notesOpen, noteCount } = useShared();
|
|
2654
|
+
const sessionsState = typeof useSessions === "function" ? useSessions((s) => s) : EMPTY_SESSIONS;
|
|
2655
|
+
const wsState = typeof useWorkspaces === "function" ? useWorkspaces((s) => s) : EMPTY_WORKSPACES;
|
|
2656
|
+
|
|
2657
|
+
const items = wsState.items ?? [];
|
|
2658
|
+
const archived = useMemo(() => new Set(wsState.archivedSessionIds ?? []), [wsState.archivedSessionIds]);
|
|
2659
|
+
const byId = sessionsState.byId ?? {};
|
|
2660
|
+
const ids = sessionsState.ids ?? [];
|
|
2661
|
+
// 当前会话:DSH 0.1.7 起列表快照**不再有 `current`**(0.1.5 有)。
|
|
2662
|
+
// 新版判据同官方 `mainSessionId(list)`:被主视图持有的那一行
|
|
2663
|
+
// (`retainedBy.mainView > 0`)。缺了它,选中高亮、跟随当前会话展开分组
|
|
2664
|
+
// 都会失效。
|
|
2665
|
+
const current = sessionsState.current
|
|
2666
|
+
?? Object.values(byId).find((s) => (s.retainedBy?.mainView ?? 0) > 0)?.id;
|
|
2667
|
+
|
|
2668
|
+
// 已彻底删除、但 Host 进程仍持有内存副本的会话 id(见 readPurgedIds)。
|
|
2669
|
+
const [purged, setPurged] = useState(readPurgedIds);
|
|
2670
|
+
|
|
2671
|
+
// 会话列表快照是否「可作证据」。
|
|
2672
|
+
//
|
|
2673
|
+
// **墓碑 GC 的前置条件**:DSH 的 ClientSessions 初始快照就是
|
|
2674
|
+
// `{ ids: [], byId: {}, phase: "pending" }`(见 @deepseek-ai/
|
|
2675
|
+
// dsh-api-session-controller 的 createSnapshotStore)。页面刷新时组件先挂载、
|
|
2676
|
+
// 列表后到;若此时跑 GC,`byId` 是空的,每个墓碑都会被判成「Host 不再列出」
|
|
2677
|
+
// 而被清掉 —— 而 Host 进程里的内存副本还在,列表一到,已删的行立刻复活:
|
|
2678
|
+
// 「不重启 dsh web,只刷新页面,删过的记录又出现了」。所以列表没到位之前
|
|
2679
|
+
// 一律不动墓碑。
|
|
2680
|
+
//
|
|
2681
|
+
// 除了 `phase === "ready"`,还要求快照里**至少有一行**:Host 的第一次成功
|
|
2682
|
+
// 拉取本身可能就是空列表(phase 已到 "ready" 但确实一行都没有),空快照同样
|
|
2683
|
+
// 不能用来证明「Host 不再列出某个 id」。代价只是「一个会话都没有」时墓碑多留
|
|
2684
|
+
// 一会儿(那会儿本来也没有行可显示),而它换来的是刷新后不复活。
|
|
2685
|
+
const sessionsReady = ids.length > 0
|
|
2686
|
+
&& (sessionsState.phase === "ready" || sessionsState.phase === undefined);
|
|
2687
|
+
|
|
2688
|
+
// Host 不再列出某个 id(重启后内存副本随进程消失,或它本来就是冷会话)→ 摘掉
|
|
2689
|
+
// 墓碑,避免无限增长。已删会话若仍被列出,正是「内存副本还在」,继续隐藏。
|
|
2690
|
+
useEffect(() => {
|
|
2691
|
+
if (!sessionsReady) return;
|
|
2692
|
+
setPurged((prev) => {
|
|
2693
|
+
if (prev.size === 0) return prev;
|
|
2694
|
+
const next = new Set();
|
|
2695
|
+
for (const id of prev) if (byId[id] !== undefined) next.add(id);
|
|
2696
|
+
if (next.size === prev.size) return prev;
|
|
2697
|
+
writePurgedIds(next);
|
|
2698
|
+
return next;
|
|
2699
|
+
});
|
|
2700
|
+
}, [byId, sessionsReady]);
|
|
2701
|
+
|
|
2702
|
+
const visibleSession = (sid) => {
|
|
2703
|
+
const s = byId[sid];
|
|
2704
|
+
if (!s || archived.has(sid)) return false;
|
|
2705
|
+
// 已彻底删除:日志没了,Host 只是还持有内存副本。必须自己隐藏,否则用户
|
|
2706
|
+
// 看到「提示删除成功、行还在」。
|
|
2707
|
+
if (purged.has(sid)) return false;
|
|
2708
|
+
// 子代理会话不列入列表:与官方 @deepseek-ai/dsh-client-ui-workspace 的
|
|
2709
|
+
// sessionVisible 一致(`session.origin !== "subagent"`)。子代理不是用户
|
|
2710
|
+
// 会话,官方只把它们呈现在父会话行的「N 个子代理运行中」状态与正文
|
|
2711
|
+
// 血缘面包屑里;列成顶层会话会让人误以为是自己的对话。
|
|
2712
|
+
if (s.origin === "subagent") return false;
|
|
2713
|
+
// 空白会话不列入列表(新建会话的占位:host 侧 title 为工作区 basename,
|
|
2714
|
+
// 直接显示会像一条与工作区同名的「会话记录」);有内容后才显示。
|
|
2715
|
+
// 当前空白会话同样不列出 —— 新建会话的可见反馈由主区的新会话空态页
|
|
2716
|
+
// (「探索未至之境」)承担,侧边栏保持干净。
|
|
2717
|
+
if (s.blank) return false;
|
|
2718
|
+
return true;
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
// 分组:工作区 sessionIds 账户为主,cwd === path 兜底;其余入「未分组」
|
|
2722
|
+
const groups = useMemo(() => {
|
|
2723
|
+
const accounted = new Set();
|
|
2724
|
+
const list = [];
|
|
2725
|
+
for (const w of items) {
|
|
2726
|
+
const wsIds = [];
|
|
2727
|
+
for (const sid of (w.sessionIds ?? [])) {
|
|
2728
|
+
if (!visibleSession(sid)) continue;
|
|
2729
|
+
wsIds.push(sid);
|
|
2730
|
+
accounted.add(sid);
|
|
2731
|
+
}
|
|
2732
|
+
for (const sid of ids) {
|
|
2733
|
+
if (accounted.has(sid)) continue;
|
|
2734
|
+
const s = byId[sid];
|
|
2735
|
+
if (s && s.cwd === w.path && visibleSession(sid)) {
|
|
2736
|
+
wsIds.push(sid);
|
|
2737
|
+
accounted.add(sid);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
list.push({ key: w.workspaceId, workspace: w, sessionIds: wsIds });
|
|
2741
|
+
}
|
|
2742
|
+
const ungrouped = ids
|
|
2743
|
+
.filter((sid) => !accounted.has(sid) && visibleSession(sid))
|
|
2744
|
+
.sort((a, b) => (byId[b].updatedAt ?? 0) - (byId[a].updatedAt ?? 0));
|
|
2745
|
+
return { list, ungrouped };
|
|
2746
|
+
}, [items, ids, byId, archived, current, purged]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2747
|
+
|
|
2748
|
+
// 运行中的子代理计数(同官方 subagent-lineage 的 indexSubagentDescendants):
|
|
2749
|
+
// 沿 parentId 链把每个运行中的子代理累加到它的每一级祖先,父会话行据此
|
|
2750
|
+
// 显示「N 个子代理运行中」—— 子代理不单独成行,活动状态挂在父行上。
|
|
2751
|
+
const runningSubagents = useMemo(() => {
|
|
2752
|
+
const counts = new Map();
|
|
2753
|
+
for (const s of Object.values(byId)) {
|
|
2754
|
+
if (s.origin !== "subagent" || s.running !== true) continue;
|
|
2755
|
+
const seen = new Set();
|
|
2756
|
+
let node = s;
|
|
2757
|
+
while (node && node.origin === "subagent" && node.parentId !== undefined && !seen.has(node.id)) {
|
|
2758
|
+
seen.add(node.id);
|
|
2759
|
+
counts.set(node.parentId, (counts.get(node.parentId) ?? 0) + 1);
|
|
2760
|
+
node = byId[node.parentId];
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
return counts;
|
|
2764
|
+
}, [byId]);
|
|
2765
|
+
|
|
2766
|
+
// 展开状态:默认展开当前会话所在工作区(未分组时展开未分组组),其余收起
|
|
2767
|
+
const [expanded, setExpanded] = useState(() => {
|
|
2768
|
+
const initial = new Set();
|
|
2769
|
+
if (current !== undefined && byId[current] !== undefined) {
|
|
2770
|
+
const w = items.find((x) => (x.sessionIds ?? []).includes(current) || byId[current].cwd === x.path);
|
|
2771
|
+
if (w) initial.add(w.workspaceId);
|
|
2772
|
+
else initial.add(UNGROUPED_KEY);
|
|
2773
|
+
}
|
|
2774
|
+
return initial;
|
|
2775
|
+
});
|
|
2776
|
+
|
|
2777
|
+
useEffect(() => {
|
|
2778
|
+
if (current === undefined || byId[current] === undefined) return;
|
|
2779
|
+
const w = items.find((x) => (x.sessionIds ?? []).includes(current) || byId[current].cwd === x.path);
|
|
2780
|
+
const key = w ? w.workspaceId : UNGROUPED_KEY;
|
|
2781
|
+
setExpanded((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
|
2782
|
+
}, [current, items]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2783
|
+
|
|
2784
|
+
const toggleGroup = useCallback((key) => {
|
|
2785
|
+
setExpanded((prev) => {
|
|
2786
|
+
const next = new Set(prev);
|
|
2787
|
+
if (next.has(key)) next.delete(key);
|
|
2788
|
+
else next.add(key);
|
|
2789
|
+
return next;
|
|
2790
|
+
});
|
|
2791
|
+
}, []);
|
|
2792
|
+
|
|
2793
|
+
// 会话搜索(前端过滤 displayTitle/title)
|
|
2794
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
2795
|
+
const [query, setQuery] = useState("");
|
|
2796
|
+
const q = query.trim().toLowerCase();
|
|
2797
|
+
const matched = useMemo(() => {
|
|
2798
|
+
if (q === "") return [];
|
|
2799
|
+
return ids
|
|
2800
|
+
.filter((sid) => {
|
|
2801
|
+
if (!visibleSession(sid)) return false;
|
|
2802
|
+
const s = byId[sid];
|
|
2803
|
+
const title = (s.title ?? "").toLowerCase();
|
|
2804
|
+
const display = (s.displayTitle ?? "").toLowerCase();
|
|
2805
|
+
return title.includes(q) || display.includes(q);
|
|
2806
|
+
})
|
|
2807
|
+
.sort((a, b) => (byId[b].updatedAt ?? 0) - (byId[a].updatedAt ?? 0));
|
|
2808
|
+
}, [ids, byId, archived, q, current, purged]);
|
|
2809
|
+
|
|
2810
|
+
// ── 批量操作 ──────────────────────────────────────────────────────────────
|
|
2811
|
+
// 两种互斥的批量模式,范围由产品约定钉死:
|
|
2812
|
+
// { kind: "workspaces" } 标题行按钮进入:勾选多个工作区 → 批量删除
|
|
2813
|
+
// { kind: "sessions", key } 工作区行按钮进入:只勾选该工作区的会话 →
|
|
2814
|
+
// 批量归档 / 批量彻底删除
|
|
2815
|
+
// 批量模式与搜索互斥(进入批量时收起搜索),避免搜索结果里出现没有勾选框的行。
|
|
2816
|
+
const [batch, setBatch] = useState(null);
|
|
2817
|
+
const [picked, setPicked] = useState(() => new Set());
|
|
2818
|
+
// 可勾选对象:工作区模式=真实工作区(「未分组」不是工作区,不可删);
|
|
2819
|
+
// 会话模式=目标工作区当前可见的会话(分组结果已过滤归档/子代理/空白)。
|
|
2820
|
+
const batchCandidates = batch === null
|
|
2821
|
+
? []
|
|
2822
|
+
: batch.kind === "workspaces"
|
|
2823
|
+
? items.map((w) => w.workspaceId)
|
|
2824
|
+
: (groups.list.find((g) => g.key === batch.key) ?? { sessionIds: [] }).sessionIds;
|
|
2825
|
+
// 每帧按当前候选集过滤勾选:被归档/删除/移出分组的行不会留下幽灵勾选。
|
|
2826
|
+
const pickedList = batchCandidates.filter((id) => picked.has(id));
|
|
2827
|
+
|
|
2828
|
+
const addWorkspace = useCallback(async () => {
|
|
2829
|
+
if (!workspaces || typeof workspaces.create !== "function") return;
|
|
2830
|
+
try {
|
|
2831
|
+
const picked = typeof uiWorkspace.pickDirectory === "function" ? await uiWorkspace.pickDirectory() : null;
|
|
2832
|
+
if (picked === null || picked === "") return;
|
|
2833
|
+
const view = await workspaces.create({ path: picked });
|
|
2834
|
+
if (view && view.workspaceId !== undefined) {
|
|
2835
|
+
setExpanded((prev) => (prev.has(view.workspaceId) ? prev : new Set(prev).add(view.workspaceId)));
|
|
2836
|
+
uiWorkspace.startSession(view.workspaceId, (e) => {
|
|
2837
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
} catch (e) {
|
|
2841
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
2842
|
+
}
|
|
2843
|
+
}, [workspaces, uiWorkspace, t]);
|
|
2844
|
+
|
|
2845
|
+
// 工作区行的「+」:新建(或复用该工作区的空白)会话并打开。
|
|
2846
|
+
// 必须走 ctx.uiWorkspace —— ctx.workspaces 上没有 startSession,
|
|
2847
|
+
// 早期版本用 typeof 守卫直接 return,表现为「点+毫无反应」。
|
|
2848
|
+
const startSession = useCallback((workspaceId) => {
|
|
2849
|
+
setExpanded((prev) => (prev.has(workspaceId) ? prev : new Set(prev).add(workspaceId)));
|
|
2850
|
+
uiWorkspace.startSession(workspaceId, (e) => {
|
|
2851
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
2852
|
+
});
|
|
2853
|
+
}, [uiWorkspace, t]);
|
|
2854
|
+
|
|
2855
|
+
// 打开会话:必须走 ctx.uiWorkspace.openSession(0.1.7+),
|
|
2856
|
+
// `ctx.sessions.open` 只在对旧版(0.1.5 及更早)兜底时才有意义。
|
|
2857
|
+
const openSession = useCallback((sid) => {
|
|
2858
|
+
// 没有可用通路、或官方因陈旧 id 抛错时都必须报出来:
|
|
2859
|
+
// 静默 no-op 正是这次桌面版事故的形态。
|
|
2860
|
+
uiWorkspace.openSession(sid, (error) => {
|
|
2861
|
+
showToast(`${t("error")}:${error && error.message ? error.message : t("openUnavailable")}`);
|
|
2862
|
+
});
|
|
2863
|
+
}, [uiWorkspace, t]);
|
|
2864
|
+
|
|
2865
|
+
// ── 重命名 ────────────────────────────────────────────────────────────────
|
|
2866
|
+
// Electron(桌面版)的 window.prompt 直接抛 `prompt() is not supported.`,
|
|
2867
|
+
// 所以两个重命名都走应用内输入卡片(askText),web / 桌面版行为一致。
|
|
2868
|
+
const renameWorkspace = useCallback(async (workspaceId, currentTitle) => {
|
|
2869
|
+
if (!workspaces || typeof workspaces.rename !== "function") return;
|
|
2870
|
+
const name = await askText(t("renameWorkspacePrompt"), currentTitle);
|
|
2871
|
+
if (name === null || name.trim() === "" || name.trim() === currentTitle) return;
|
|
2872
|
+
try {
|
|
2873
|
+
await workspaces.rename(workspaceId, name.trim());
|
|
2874
|
+
showToast(t("renamed"));
|
|
2875
|
+
} catch (e) {
|
|
2876
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
2877
|
+
}
|
|
2878
|
+
}, [workspaces, t]);
|
|
2879
|
+
|
|
2880
|
+
const renameSession = useCallback(async (sid, currentTitle) => {
|
|
2881
|
+
if (!sessions || typeof sessions.binding !== "function") return;
|
|
2882
|
+
const binding = sessions.binding(sid);
|
|
2883
|
+
if (!binding || !binding.session || typeof binding.session.rename !== "function") {
|
|
2884
|
+
showToast(`${t("error")}:${t("noSessions")}`);
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
const name = await askText(t("renameSessionPrompt"), currentTitle);
|
|
2888
|
+
if (name === null || name.trim() === "" || name.trim() === currentTitle) return;
|
|
2889
|
+
try {
|
|
2890
|
+
const result = await binding.session.rename(name.trim());
|
|
2891
|
+
if (!result.ok) throw new Error(result && result.error && result.error.message ? result.error.message : "rename failed");
|
|
2892
|
+
showToast(t("renamed"));
|
|
2893
|
+
} catch (e) {
|
|
2894
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
2895
|
+
}
|
|
2896
|
+
}, [sessions, t]);
|
|
2897
|
+
|
|
2898
|
+
// 删除成功后必须让 Host 重新下发权威会话列表(ctx.sessions.refresh)。
|
|
2899
|
+
// 不刷新的话被删的会话仍留在前端列表里,甚至还能从客户端缓存打开它的
|
|
2900
|
+
// 记录 —— 表现为「删了还在、而且还有聊天记录」,与「彻底删除」矛盾。
|
|
2901
|
+
const refreshSessions = useCallback(async () => {
|
|
2902
|
+
if (sessions && typeof sessions.refresh === "function") {
|
|
2903
|
+
try {
|
|
2904
|
+
await sessions.refresh();
|
|
2905
|
+
} catch {
|
|
2906
|
+
// 列表刷新失败不影响删除本身的结果,下次刷新会自然纠正。
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
}, [sessions]);
|
|
2910
|
+
|
|
2911
|
+
// 把彻底删除的失败原因翻成用户可行动的提示:接口不存在 ≠ 会话不存在。
|
|
2912
|
+
const reportDeleteFailure = useCallback((error) => {
|
|
2913
|
+
const status = error && error.status;
|
|
2914
|
+
if (status === 404 || status === 405 || status === 501) {
|
|
2915
|
+
showToast(t("sessionDeleteUnavailable"));
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
if (status === 409) {
|
|
2919
|
+
showToast(t("sessionRunning"));
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
if (status === 503) {
|
|
2923
|
+
showToast(t("sessionLivenessUnavailable"));
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
showToast(`${t("error")}:${error && error.message ? error.message : error}`);
|
|
2927
|
+
}, [t]);
|
|
2928
|
+
|
|
2929
|
+
// 记录「已彻底删除」:隐藏对应行(Host 可能仍持有内存副本,列表不会自己去掉),
|
|
2930
|
+
// 并在删掉的是当前正在查看的会话时把主视图切走 —— 否则用户还盯着一条日志已经
|
|
2931
|
+
// 不存在的会话。
|
|
2932
|
+
const forgetDeleted = useCallback((deletedIds) => {
|
|
2933
|
+
if (deletedIds.length === 0) return;
|
|
2934
|
+
setPurged((prev) => {
|
|
2935
|
+
const next = new Set(prev);
|
|
2936
|
+
for (const id of deletedIds) next.add(id);
|
|
2937
|
+
writePurgedIds(next);
|
|
2938
|
+
return next;
|
|
2939
|
+
});
|
|
2940
|
+
}, []);
|
|
2941
|
+
|
|
2942
|
+
/**
|
|
2943
|
+
* 成功删掉当前会话后离开它。
|
|
2944
|
+
*
|
|
2945
|
+
* 优先换到「还在的、可见的」最近一个会话;一个都不剩时**清空主视图**。
|
|
2946
|
+
* 后者不能省:否则聊天框会继续显示一条日志已经删掉的对话,用户看到
|
|
2947
|
+
* 「提示彻底删除成功、记录还在」——与「彻底删除」直接矛盾。
|
|
2948
|
+
* 清空(而不是新建会话)也守住了「新建会话是用户的决定」这条边界。
|
|
2949
|
+
* @param gone - 本次删掉的 id。
|
|
2950
|
+
*/
|
|
2951
|
+
const leaveDeletedCurrent = useCallback((gone) => {
|
|
2952
|
+
if (current === undefined || !gone.has(current)) return;
|
|
2953
|
+
const next = ids.find((id) => !gone.has(id) && visibleSession(id));
|
|
2954
|
+
if (next !== undefined) uiWorkspace.openSession(next);
|
|
2955
|
+
else uiWorkspace.clearMain();
|
|
2956
|
+
}, [current, ids, byId, purged, archived, uiWorkspace]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* 删除成功后的统一收尾:隐藏已删行、必要时切走主视图、报出结果。
|
|
2960
|
+
*
|
|
2961
|
+
* 先反馈再刷新:刷新是「让列表跟上」,不是删除成功的前提 —— 反过来的话,
|
|
2962
|
+
* 一次挂起/失败的 refresh 会让用户看不到任何成功反馈,而日志其实已经删了。
|
|
2963
|
+
*
|
|
2964
|
+
* `missing`(Host 报「磁盘上本来就没有这个会话目录」)必须与 `deleted` 同等
|
|
2965
|
+
* 对待:两者都证明这条会话在磁盘上已经不存在,行只可能靠 Host 进程里的内存
|
|
2966
|
+
* 副本撑着。只把 `deleted` 当成功,用户重删一条历史遗留记录时看到的就是
|
|
2967
|
+
* 「行还在 → 点删除 → 没有找到可删除的会话 → 行还在」的死循环。
|
|
2968
|
+
* @param result - host 响应(用 `attached` 说明哪些仍被进程持有内存副本)。
|
|
2969
|
+
* @param deleted - 确认从磁盘删掉的 id。
|
|
2970
|
+
* @param missing - Host 报「磁盘上本就没有日志」的 id(同样隐藏)。
|
|
2971
|
+
* @param failed - host 报失败的条目。
|
|
2972
|
+
*/
|
|
2973
|
+
const reportDeleteSuccess = useCallback((result, deleted, missing, failed) => {
|
|
2974
|
+
const attached = result && Array.isArray(result.attached) ? result.attached : [];
|
|
2975
|
+
const gone = [...deleted, ...missing];
|
|
2976
|
+
forgetDeleted(gone);
|
|
2977
|
+
leaveDeletedCurrent(new Set(gone));
|
|
2978
|
+
let message = deleted.length > 0
|
|
2979
|
+
? (failed.length > 0
|
|
2980
|
+
? t("deletedPermanentlyPartial", { n: deleted.length, m: failed.length })
|
|
2981
|
+
: t("deletedPermanently", { n: deleted.length }))
|
|
2982
|
+
// 一条都没删掉、但 Host 确认磁盘上早就没有日志:报「已移除失效记录」,
|
|
2983
|
+
// 不报「已彻底删除 0 个」,更不能报「没有找到可删除的会话」。
|
|
2984
|
+
: t("staleSessionsRemoved", { n: missing.length });
|
|
2985
|
+
if (deleted.length > 0 && missing.length > 0) {
|
|
2986
|
+
message += t("deletedPermanentlyMissingSuffix", { m: missing.length });
|
|
2987
|
+
}
|
|
2988
|
+
if (deleted.length === 0 && failed.length > 0) {
|
|
2989
|
+
message += t("deletedPermanentlyFailedSuffix", { m: failed.length });
|
|
2990
|
+
}
|
|
2991
|
+
if (attached.length > 0) message += t("deletedPermanentlyStickySuffix", { m: attached.length });
|
|
2992
|
+
showToast(message);
|
|
2993
|
+
}, [forgetDeleted, leaveDeletedCurrent, t]);
|
|
2994
|
+
|
|
2995
|
+
/**
|
|
2996
|
+
* 只读僵死行探测的缓存:`answered` 记「已拿到 Host 权威答复(且不在跑)」的 id,
|
|
2997
|
+
* `pending` 记正在飞的请求 key —— 每个 id 只问一次,列表每次变动不会重复探测。
|
|
2998
|
+
*/
|
|
2999
|
+
const staleProbe = useRef({ answered: new Set(), pending: "" });
|
|
3000
|
+
|
|
3001
|
+
/**
|
|
3002
|
+
* 僵死行自愈(只读探测,绝不删除)。
|
|
3003
|
+
*
|
|
3004
|
+
* 本地墓碑(PURGED_IDS_KEY)只能记住「本浏览器、装了这个墓碑机制之后」删掉的
|
|
3005
|
+
* 会话;更早删的(或换个浏览器/桌面版删的)没有任何墓碑,而行会一直列在侧边
|
|
3006
|
+
* 栏里,直到 DSH 进程重启 —— 这就是「以前点了删除的记录还在前端显示,再点
|
|
3007
|
+
* 删除却说没有可删除的会话」。
|
|
3008
|
+
*
|
|
3009
|
+
* 所以这里直接问 Host:列表里仍在、磁盘上已无 durable 日志、且不在运行的会话
|
|
3010
|
+
* 一律隐藏。Host 侧用 `sessionQuery.listSessions()` 的 `persisted` 标记判定
|
|
3011
|
+
* (与 DSH 自己的列表同源,不依赖本插件镜像的路径布局),服务缺失/探测失败时
|
|
3012
|
+
* 返回 `available: false`,这边就什么都不做 —— 只读优化不该有误隐藏的风险。
|
|
3013
|
+
*/
|
|
3014
|
+
useEffect(() => {
|
|
3015
|
+
if (!sessionsReady) return;
|
|
3016
|
+
const ask = ids.filter((sid) => {
|
|
3017
|
+
if (staleProbe.current.answered.has(sid)) return false; // 已问过,答案权威
|
|
3018
|
+
if (purged.has(sid)) return false; // 已隐藏,无需再问
|
|
3019
|
+
const s = byId[sid];
|
|
3020
|
+
// 在跑、空白会话不参与:在跑的会话可能还没落盘,隐藏它属于误伤。
|
|
3021
|
+
if (s === undefined || s.running === true || s.blank === true) return false;
|
|
3022
|
+
return visibleSession(sid);
|
|
3023
|
+
});
|
|
3024
|
+
if (ask.length === 0) return;
|
|
3025
|
+
const key = [...ask].sort().join(",");
|
|
3026
|
+
if (staleProbe.current.pending === key) return;
|
|
3027
|
+
staleProbe.current.pending = key;
|
|
3028
|
+
treeApi.sessionState(ask).then((result) => {
|
|
3029
|
+
staleProbe.current.pending = "";
|
|
3030
|
+
const items = result && Array.isArray(result.items) ? result.items : [];
|
|
3031
|
+
if (!result || result.available !== true) return;
|
|
3032
|
+
const stale = [];
|
|
3033
|
+
for (const it of items) {
|
|
3034
|
+
if (!it || typeof it.id !== "string" || it.known !== true) continue;
|
|
3035
|
+
// 判不准 / 在跑:不缓存,等它不跑了再问一次(行状态变化会重新触发本效应)。
|
|
3036
|
+
if (it.running === true) continue;
|
|
3037
|
+
staleProbe.current.answered.add(it.id);
|
|
3038
|
+
if (it.persisted !== true) stale.push(it.id);
|
|
3039
|
+
}
|
|
3040
|
+
if (stale.length === 0) return;
|
|
3041
|
+
forgetDeleted(stale);
|
|
3042
|
+
leaveDeletedCurrent(new Set(stale));
|
|
3043
|
+
showToast(t("staleSessionsRemoved", { n: stale.length }));
|
|
3044
|
+
}, () => {
|
|
3045
|
+
// 旧 Host 没有这条路由(404)或网络失败:清掉 pending,下次列表变动再试。
|
|
3046
|
+
staleProbe.current.pending = "";
|
|
3047
|
+
});
|
|
3048
|
+
}, [ids, byId, purged, archived, sessionsReady, forgetDeleted, leaveDeletedCurrent, t]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3049
|
+
/**
|
|
3050
|
+
* 工作区删除 = 注册项 + **其中全部会话**一起彻底删除。
|
|
3051
|
+
*
|
|
3052
|
+
* 产品约定(本轮需求):删除工作区时把它里面的会话一并在磁盘上删掉;
|
|
3053
|
+
* 这是不可恢复的破坏性操作,所以确认框必须写清会话数量。
|
|
3054
|
+
*
|
|
3055
|
+
* 顺序必须是「先删会话、再删工作区」:反过来的话 `workspaces.delete` 会先把
|
|
3056
|
+
* 会话移入「未分组」,失去归属后就没法再按工作区定位它们了。会话没能全部
|
|
3057
|
+
* 删成功时**不删工作区**(fail closed),否则会留下一堆无主会话。
|
|
3058
|
+
*
|
|
3059
|
+
* @param workspaceId - 工作区 id。
|
|
3060
|
+
* @param title - 工作区标题(确认框用)。
|
|
3061
|
+
* @param sessionIds - 该工作区当前可见的会话 id(父+子代理闭包在此展开)。
|
|
3062
|
+
*/
|
|
3063
|
+
const deleteWorkspace = async (workspaceId, title, sessionIds = []) => {
|
|
3064
|
+
if (!workspaces || typeof workspaces.delete !== "function") return;
|
|
3065
|
+
const all = new Set();
|
|
3066
|
+
for (const sid of sessionIds) for (const id of sessionClosure(sid, byId)) all.add(id);
|
|
3067
|
+
const confirmText = all.size === 0
|
|
3068
|
+
? t("deleteWorkspaceConfirm", { name: title })
|
|
3069
|
+
: t("deleteWorkspaceSessionsConfirm", { name: title, n: all.size });
|
|
3070
|
+
if (!window.confirm(confirmText)) return;
|
|
3071
|
+
let deleted = [];
|
|
3072
|
+
if (all.size > 0) {
|
|
3073
|
+
let result;
|
|
3074
|
+
try {
|
|
3075
|
+
result = await treeApi.deleteSessions([...all]);
|
|
3076
|
+
} catch (e) {
|
|
3077
|
+
// 会话没删掉 → 工作区也不删,避免会话被移入「未分组」。
|
|
3078
|
+
reportDeleteFailure(e);
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
deleted = result && Array.isArray(result.deleted) ? result.deleted : [];
|
|
3082
|
+
const missing = result && Array.isArray(result.missing) ? result.missing : [];
|
|
3083
|
+
const failed = result && Array.isArray(result.failed) ? result.failed : [];
|
|
3084
|
+
// missing = 磁盘上早就没有日志:和 deleted 一样证明会话已不存在,同样隐藏/计数
|
|
3085
|
+
deleted = [...deleted, ...missing];
|
|
3086
|
+
if (deleted.length < all.size) {
|
|
3087
|
+
if (deleted.length > 0) {
|
|
3088
|
+
forgetDeleted(deleted);
|
|
3089
|
+
void refreshSessions();
|
|
3090
|
+
}
|
|
3091
|
+
showToast(failed.length > 0 ? `${t("error")}:${failed[0].error}` : t("deleteWorkspaceSessionsFailed"));
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
try {
|
|
3096
|
+
await workspaces.delete(workspaceId);
|
|
3097
|
+
if (deleted.length > 0) {
|
|
3098
|
+
forgetDeleted(deleted);
|
|
3099
|
+
void refreshSessions();
|
|
3100
|
+
showToast(t("deletedWorkspaceWithSessions", { name: title, n: deleted.length }));
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
showToast(t("deleted"));
|
|
3104
|
+
} catch (e) {
|
|
3105
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
3106
|
+
}
|
|
3107
|
+
};
|
|
3108
|
+
|
|
3109
|
+
// 会话「归档」= 平台归档语义(移出列表,日志保留,官方浏览器同款行为)
|
|
3110
|
+
const archiveSession = useCallback(async (sid, title) => {
|
|
3111
|
+
if (!workspaces || typeof workspaces.archiveSession !== "function") return;
|
|
3112
|
+
if (!window.confirm(t("archiveSessionConfirm", { name: title }))) return;
|
|
3113
|
+
try {
|
|
3114
|
+
await workspaces.archiveSession(sid);
|
|
3115
|
+
showToast(t("archived"));
|
|
3116
|
+
} catch (e) {
|
|
3117
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
3118
|
+
}
|
|
3119
|
+
}, [workspaces, t]);
|
|
3120
|
+
|
|
3121
|
+
// 会话「彻底删除」= 删除磁盘日志(host /obsidian/session-delete)。
|
|
3122
|
+
// 与归档相反且不可恢复,所以:**正在运行的**会话由 Host 拒绝(含闭包内正在跑的
|
|
3123
|
+
// 子代理);父会话连同其全部子代理日志一起删;确认框里写清子会话数量与被删的
|
|
3124
|
+
// 东西。
|
|
3125
|
+
//
|
|
3126
|
+
// 活性判定**只在 Host 侧做**,前端不本地预判。原因:前端快照里的 `running`
|
|
3127
|
+
// 会停旧 —— 子代理跑完时的 idle 转换不一定推给前端,缓存里那条子代理会一直
|
|
3128
|
+
// 显示「运行中」。用它当硬闸门会让「子代理明明已结束」的会话永远删不掉,而且
|
|
3129
|
+
// 前端根本不发请求,Host 的权威判定永远没机会纠正它。Host 侧的判据是
|
|
3130
|
+
// `agents.get(id)?.status === "running"`(per-turn:空闲即 idle),判不准时
|
|
3131
|
+
// fail closed,所以放行请求不会牺牲安全性。
|
|
3132
|
+
const deleteSession = useCallback(async (sid, title) => {
|
|
3133
|
+
const ids = [...sessionClosure(sid, byId)];
|
|
3134
|
+
const subs = ids.length - 1;
|
|
3135
|
+
const caveat = subs > 0 ? t("deleteSessionMore", { n: subs }) : "";
|
|
3136
|
+
if (!window.confirm(t("deleteSessionConfirm", { name: title, caveat }))) return;
|
|
3137
|
+
try {
|
|
3138
|
+
const result = await treeApi.deleteSessions(ids);
|
|
3139
|
+
const deleted = result && Array.isArray(result.deleted) ? result.deleted : [];
|
|
3140
|
+
const missing = result && Array.isArray(result.missing) ? result.missing : [];
|
|
3141
|
+
const failed = result && Array.isArray(result.failed) ? result.failed : [];
|
|
3142
|
+
if (deleted.length === 0 && missing.length === 0) {
|
|
3143
|
+
// 区分「确实没找到日志」与「host 报了失败」:后者需要让人看到原因。
|
|
3144
|
+
if (failed.length > 0) {
|
|
3145
|
+
showToast(`${t("error")}:${failed[0].error}`);
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
showToast(t("nothingDeleted"));
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
reportDeleteSuccess(result, deleted, missing, failed);
|
|
3152
|
+
void refreshSessions();
|
|
3153
|
+
} catch (e) {
|
|
3154
|
+
reportDeleteFailure(e);
|
|
3155
|
+
}
|
|
3156
|
+
}, [byId, refreshSessions, reportDeleteFailure, reportDeleteSuccess, t]);
|
|
3157
|
+
|
|
3158
|
+
// 「未分组」整组归档 / 整组彻底删除(两者共用父+子代理闭包)
|
|
3159
|
+
const archiveUngrouped = useCallback(async (sessionIds, name) => {
|
|
3160
|
+
if (!workspaces || typeof workspaces.archiveSession !== "function") return;
|
|
3161
|
+
if (sessionIds.length === 0) return;
|
|
3162
|
+
if (!window.confirm(t("archiveUngroupedConfirm", { n: sessionIds.length, name }))) return;
|
|
3163
|
+
try {
|
|
3164
|
+
await Promise.all(sessionIds.map((sid) => workspaces.archiveSession(sid)));
|
|
3165
|
+
showToast(t("archived"));
|
|
3166
|
+
} catch (e) {
|
|
3167
|
+
showToast(`${t("error")}:${e && e.message ? e.message : e}`);
|
|
3168
|
+
}
|
|
3169
|
+
}, [workspaces, t]);
|
|
3170
|
+
|
|
3171
|
+
const deleteUngrouped = useCallback(async (sessionIds, name) => {
|
|
3172
|
+
if (sessionIds.length === 0) return;
|
|
3173
|
+
const all = new Set();
|
|
3174
|
+
for (const sid of sessionIds) for (const id of sessionClosure(sid, byId)) all.add(id);
|
|
3175
|
+
// 同上:不在前端预判「在跑」,交给 Host 的权威判定(前端快照会停旧)。
|
|
3176
|
+
if (!window.confirm(t("deleteUngroupedConfirm", { n: all.size, name }))) return;
|
|
3177
|
+
try {
|
|
3178
|
+
const result = await treeApi.deleteSessions([...all]);
|
|
3179
|
+
const deleted = result && Array.isArray(result.deleted) ? result.deleted : [];
|
|
3180
|
+
const missing = result && Array.isArray(result.missing) ? result.missing : [];
|
|
3181
|
+
const failed = result && Array.isArray(result.failed) ? result.failed : [];
|
|
3182
|
+
if (deleted.length === 0 && missing.length === 0) {
|
|
3183
|
+
if (failed.length > 0) {
|
|
3184
|
+
showToast(`${t("error")}:${failed[0].error}`);
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
showToast(t("nothingDeleted"));
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
reportDeleteSuccess(result, deleted, missing, failed);
|
|
3191
|
+
void refreshSessions();
|
|
3192
|
+
} catch (e) {
|
|
3193
|
+
reportDeleteFailure(e);
|
|
3194
|
+
}
|
|
3195
|
+
}, [byId, refreshSessions, reportDeleteFailure, reportDeleteSuccess, t]);
|
|
3196
|
+
|
|
3197
|
+
// ── 批量操作处理 ──────────────────────────────────────────────────────────
|
|
3198
|
+
const enterWorkspaceBatch = () => {
|
|
3199
|
+
setSearchOpen(false);
|
|
3200
|
+
setQuery("");
|
|
3201
|
+
setBatch({ kind: "workspaces" });
|
|
3202
|
+
setPicked(new Set());
|
|
3203
|
+
};
|
|
3204
|
+
|
|
3205
|
+
const enterSessionBatch = (key) => {
|
|
3206
|
+
setSearchOpen(false);
|
|
3207
|
+
setQuery("");
|
|
3208
|
+
setExpanded((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
|
3209
|
+
setBatch({ kind: "sessions", key });
|
|
3210
|
+
setPicked(new Set());
|
|
3211
|
+
};
|
|
3212
|
+
|
|
3213
|
+
const exitBatch = () => {
|
|
3214
|
+
setBatch(null);
|
|
3215
|
+
setPicked(new Set());
|
|
3216
|
+
};
|
|
3217
|
+
|
|
3218
|
+
const togglePicked = (id) => {
|
|
3219
|
+
setPicked((prev) => {
|
|
3220
|
+
const next = new Set(prev);
|
|
3221
|
+
if (next.has(id)) next.delete(id);
|
|
3222
|
+
else next.add(id);
|
|
3223
|
+
return next;
|
|
3224
|
+
});
|
|
3225
|
+
};
|
|
3226
|
+
|
|
3227
|
+
const selectAllPicked = () => setPicked(new Set(batchCandidates));
|
|
3228
|
+
const invertPicked = () => setPicked(new Set(batchCandidates.filter((id) => !picked.has(id))));
|
|
3229
|
+
const clearPicked = () => setPicked(new Set());
|
|
3230
|
+
|
|
3231
|
+
// 批量归档:一次确认,逐个走平台归档(ctx.workspaces.archiveSession)。
|
|
3232
|
+
// 单个失败不影响其余;提示里报成功/失败数量。
|
|
3233
|
+
//
|
|
3234
|
+
// 只要至少归档成功一条就退出批量模式:工具条是「批量执行一次」的入口,
|
|
3235
|
+
// 不该在动作完成后常驻(用户反馈「归档/删除之后批量按键还在」)。一条都
|
|
3236
|
+
// 没成功时保留勾选和工具条,方便直接重试。
|
|
3237
|
+
const archivePicked = async () => {
|
|
3238
|
+
const list = [...pickedList];
|
|
3239
|
+
if (list.length === 0) return;
|
|
3240
|
+
if (!workspaces || typeof workspaces.archiveSession !== "function") return;
|
|
3241
|
+
if (!window.confirm(t("archivePickedConfirm", { n: list.length }))) return;
|
|
3242
|
+
const results = await Promise.allSettled(list.map((sid) => workspaces.archiveSession(sid)));
|
|
3243
|
+
const failed = results.filter((r) => r.status === "rejected").length;
|
|
3244
|
+
if (failed === list.length) {
|
|
3245
|
+
showToast(t("archivedPickedPartial", { n: 0, m: failed }));
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
exitBatch();
|
|
3249
|
+
showToast(failed === 0
|
|
3250
|
+
? t("archivedPicked", { n: list.length })
|
|
3251
|
+
: t("archivedPickedPartial", { n: list.length - failed, m: failed }));
|
|
3252
|
+
};
|
|
3253
|
+
|
|
3254
|
+
// 批量彻底删除:与单条/整组删除走同一条 host 路由,一次把全部 id 送过去
|
|
3255
|
+
// (父会话连同子代理闭包;正在运行的由 Host 权威判定拒绝)。
|
|
3256
|
+
const deletePicked = async () => {
|
|
3257
|
+
const list = [...pickedList];
|
|
3258
|
+
if (list.length === 0) return;
|
|
3259
|
+
const all = new Set();
|
|
3260
|
+
for (const sid of list) for (const id of sessionClosure(sid, byId)) all.add(id);
|
|
3261
|
+
if (!window.confirm(t("deletePickedConfirm", { n: all.size }))) return;
|
|
3262
|
+
try {
|
|
3263
|
+
const result = await treeApi.deleteSessions([...all]);
|
|
3264
|
+
const deleted = result && Array.isArray(result.deleted) ? result.deleted : [];
|
|
3265
|
+
const missing = result && Array.isArray(result.missing) ? result.missing : [];
|
|
3266
|
+
const failed = result && Array.isArray(result.failed) ? result.failed : [];
|
|
3267
|
+
if (deleted.length === 0 && missing.length === 0) {
|
|
3268
|
+
if (failed.length > 0) {
|
|
3269
|
+
showToast(`${t("error")}:${failed[0].error}`);
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
showToast(t("nothingDeleted"));
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
setPicked(new Set());
|
|
3276
|
+
reportDeleteSuccess(result, deleted, missing, failed);
|
|
3277
|
+
// 删除动作已执行 → 退出批量模式,别让工具条和勾选框留在侧边栏
|
|
3278
|
+
exitBatch();
|
|
3279
|
+
void refreshSessions();
|
|
3280
|
+
} catch (e) {
|
|
3281
|
+
reportDeleteFailure(e);
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
|
|
3285
|
+
// 批量删除工作区 = 注册项 + 其中全部会话一起彻底删除(与单行删除同语义)。
|
|
3286
|
+
// 逐个串行:会话没能全部删掉的工作区保持原样(fail closed),其余继续,
|
|
3287
|
+
// 最后汇总成功/失败数量并刷新列表。
|
|
3288
|
+
const deletePickedWorkspaces = async () => {
|
|
3289
|
+
const list = [...pickedList];
|
|
3290
|
+
if (list.length === 0) return;
|
|
3291
|
+
if (!workspaces || typeof workspaces.delete !== "function") return;
|
|
3292
|
+
const plans = list.map((id) => {
|
|
3293
|
+
const sessionIds = (groups.list.find((g) => g.key === id) ?? { sessionIds: [] }).sessionIds;
|
|
3294
|
+
const all = new Set();
|
|
3295
|
+
for (const sid of sessionIds) for (const cid of sessionClosure(sid, byId)) all.add(cid);
|
|
3296
|
+
return { id, all };
|
|
3297
|
+
});
|
|
3298
|
+
const totalSessions = plans.reduce((n, p) => n + p.all.size, 0);
|
|
3299
|
+
// 全都没会话时用短文案:避免「其中的 0 个会话将被彻底删除」这种空警告
|
|
3300
|
+
const confirmText = totalSessions === 0
|
|
3301
|
+
? t("deletePickedWorkspacesConfirmNoSessions", { n: plans.length })
|
|
3302
|
+
: t("deletePickedWorkspacesConfirm", { n: plans.length, m: totalSessions });
|
|
3303
|
+
if (!window.confirm(confirmText)) return;
|
|
3304
|
+
const gone = [];
|
|
3305
|
+
const deletedSessions = [];
|
|
3306
|
+
for (const plan of plans) {
|
|
3307
|
+
if (plan.all.size > 0) {
|
|
3308
|
+
let result;
|
|
3309
|
+
try {
|
|
3310
|
+
result = await treeApi.deleteSessions([...plan.all]);
|
|
3311
|
+
} catch {
|
|
3312
|
+
// 会话删不掉(在跑 / 接口不可用)→ 该工作区保持不变,避免会话被移入未分组
|
|
3313
|
+
continue;
|
|
3314
|
+
}
|
|
3315
|
+
const deleted = result && Array.isArray(result.deleted) ? result.deleted : [];
|
|
3316
|
+
const missing = result && Array.isArray(result.missing) ? result.missing : [];
|
|
3317
|
+
const removed = [...deleted, ...missing];
|
|
3318
|
+
deletedSessions.push(...removed);
|
|
3319
|
+
if (removed.length < plan.all.size) continue;
|
|
3320
|
+
}
|
|
3321
|
+
try {
|
|
3322
|
+
await workspaces.delete(plan.id);
|
|
3323
|
+
gone.push(plan.id);
|
|
3324
|
+
} catch {
|
|
3325
|
+
// 工作区删除失败:保留该行,留给下次刷新纠正
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
if (deletedSessions.length > 0) {
|
|
3329
|
+
forgetDeleted(deletedSessions);
|
|
3330
|
+
// 当前会话恰好属于被删的工作区时也要离开它:有别的会话就切过去,
|
|
3331
|
+
// 一个都不剩就清空主视图 —— 不能继续显示日志已删掉的聊天记录。
|
|
3332
|
+
leaveDeletedCurrent(new Set(deletedSessions));
|
|
3333
|
+
void refreshSessions();
|
|
3334
|
+
}
|
|
3335
|
+
if (gone.length === 0) {
|
|
3336
|
+
showToast(t("deleteWorkspaceSessionsFailed"));
|
|
3337
|
+
return;
|
|
3338
|
+
}
|
|
3339
|
+
if (gone.length === plans.length) {
|
|
3340
|
+
exitBatch();
|
|
3341
|
+
showToast(deletedSessions.length > 0
|
|
3342
|
+
? t("deletedWorkspacesWithSessions", { n: gone.length, m: deletedSessions.length })
|
|
3343
|
+
: t("deletedWorkspaces", { n: gone.length }));
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
setPicked((prev) => {
|
|
3347
|
+
const next = new Set(prev);
|
|
3348
|
+
for (const id of gone) next.delete(id);
|
|
3349
|
+
return next;
|
|
3350
|
+
});
|
|
3351
|
+
showToast(t("deletedWorkspacesPartial", {
|
|
3352
|
+
n: gone.length,
|
|
3353
|
+
m: plans.length - gone.length,
|
|
3354
|
+
k: deletedSessions.length,
|
|
3355
|
+
}));
|
|
3356
|
+
};
|
|
3357
|
+
|
|
3358
|
+
/**
|
|
3359
|
+
* 会话行。
|
|
3360
|
+
* @param sid - 会话 id。
|
|
3361
|
+
* @param groupKey - 所属分组(工作区 id 或 UNGROUPED_KEY):批量模式只作用于
|
|
3362
|
+
* 当前目标工作区的行,其它分组的会话行保持普通行为。
|
|
3363
|
+
*/
|
|
3364
|
+
const sessionRow = (sid, groupKey) => {
|
|
3365
|
+
const s = byId[sid];
|
|
3366
|
+
if (!s) return null;
|
|
3367
|
+
const isCurrent = s.id === current;
|
|
3368
|
+
const subs = runningSubagents.get(sid) ?? 0;
|
|
3369
|
+
const inBatch = batch !== null && batch.kind === "sessions" && batch.key === groupKey;
|
|
3370
|
+
const isPicked = inBatch && picked.has(sid);
|
|
3371
|
+
return createElement("div", {
|
|
3372
|
+
key: sid,
|
|
3373
|
+
className: `dsh-obs-session${isCurrent ? " dsh-obs-selected" : ""}${isPicked ? " dsh-obs-picked" : ""}`,
|
|
3374
|
+
"data-session-id": sid,
|
|
3375
|
+
onClick: () => {
|
|
3376
|
+
if (inBatch) togglePicked(sid);
|
|
3377
|
+
else openSession(sid);
|
|
3378
|
+
},
|
|
3379
|
+
},
|
|
3380
|
+
inBatch && createElement("input", {
|
|
3381
|
+
type: "checkbox",
|
|
3382
|
+
className: "dsh-obs-check",
|
|
3383
|
+
"data-id": sid,
|
|
3384
|
+
checked: isPicked,
|
|
3385
|
+
title: t("batchPickSession"),
|
|
3386
|
+
onClick: (e) => e.stopPropagation(),
|
|
3387
|
+
onChange: () => togglePicked(sid),
|
|
3388
|
+
}),
|
|
3389
|
+
subs > 0
|
|
3390
|
+
? createElement("span", {
|
|
3391
|
+
className: "dsh-obs-dot dsh-obs-dot-subs",
|
|
3392
|
+
title: t("subagentsRunning", { n: subs }),
|
|
3393
|
+
"aria-label": t("subagentsRunning", { n: subs }),
|
|
3394
|
+
}, "◍")
|
|
3395
|
+
: createElement("span", { className: "dsh-obs-dot" }, "·"),
|
|
3396
|
+
createElement("span", { className: "dsh-obs-session-title", title: s.displayTitle || s.title || s.id }, s.displayTitle || s.title || s.id),
|
|
3397
|
+
createElement("span", { className: "dsh-obs-time" }, formatRelativeTime(s.updatedAt, t)),
|
|
3398
|
+
// 批量模式下隐藏单行动作:避免把「批量勾选」和「单行归档/删除」混在一排
|
|
3399
|
+
!inBatch && createElement("span", { className: "dsh-obs-actions" },
|
|
3400
|
+
createElement("button", {
|
|
3401
|
+
title: t("rename"),
|
|
3402
|
+
onClick: (e) => {
|
|
3403
|
+
e.stopPropagation();
|
|
3404
|
+
void renameSession(sid, s.displayTitle || s.title || s.id);
|
|
3405
|
+
},
|
|
3406
|
+
}, "✎"),
|
|
3407
|
+
createElement("button", {
|
|
3408
|
+
title: t("archiveSession"),
|
|
3409
|
+
onClick: (e) => {
|
|
3410
|
+
e.stopPropagation();
|
|
3411
|
+
void archiveSession(sid, s.displayTitle || s.title || s.id);
|
|
3412
|
+
},
|
|
3413
|
+
}, "🗄"),
|
|
3414
|
+
createElement("button", {
|
|
3415
|
+
className: "dsh-obs-danger",
|
|
3416
|
+
title: t("deleteSession"),
|
|
3417
|
+
onClick: (e) => {
|
|
3418
|
+
e.stopPropagation();
|
|
3419
|
+
void deleteSession(sid, s.displayTitle || s.title || s.id);
|
|
3420
|
+
},
|
|
3421
|
+
}, "🗑"),
|
|
3422
|
+
),
|
|
3423
|
+
);
|
|
3424
|
+
};
|
|
3425
|
+
|
|
3426
|
+
/**
|
|
3427
|
+
* 工作区 / 未分组组行。
|
|
3428
|
+
* 批量工作区模式下:真实工作区行左侧出现勾选框,点行=勾选(不再展开/收起),
|
|
3429
|
+
* 行尾管理动作隐藏;工具条负责全选/反选/清空/删除。
|
|
3430
|
+
* 普通模式下:行尾「☑」是进入该工作区会话批量的入口(截图绿框位置)。
|
|
3431
|
+
*/
|
|
3432
|
+
const groupRow = (key, title, path, sessionIds) => {
|
|
3433
|
+
const isOpen = expanded.has(key);
|
|
3434
|
+
const isReal = key !== UNGROUPED_KEY;
|
|
3435
|
+
const anyBatch = batch !== null;
|
|
3436
|
+
const wsBatching = batch !== null && batch.kind === "workspaces";
|
|
3437
|
+
const sessionBatching = batch !== null && batch.kind === "sessions" && batch.key === key;
|
|
3438
|
+
const wsPicked = wsBatching && isReal && picked.has(key);
|
|
3439
|
+
return createElement(Fragment, { key: key },
|
|
3440
|
+
createElement("div", {
|
|
3441
|
+
className: `dsh-obs-ws-row${wsPicked ? " dsh-obs-picked" : ""}${sessionBatching ? " dsh-obs-batch-target" : ""}`,
|
|
3442
|
+
"data-workspace-id": isReal ? key : undefined,
|
|
3443
|
+
onClick: () => {
|
|
3444
|
+
if (wsBatching && isReal) togglePicked(key);
|
|
3445
|
+
else toggleGroup(key);
|
|
3446
|
+
},
|
|
3447
|
+
},
|
|
3448
|
+
wsBatching && isReal && createElement("input", {
|
|
3449
|
+
type: "checkbox",
|
|
3450
|
+
className: "dsh-obs-check",
|
|
3451
|
+
"data-id": key,
|
|
3452
|
+
checked: wsPicked,
|
|
3453
|
+
title: t("batchPickWorkspace"),
|
|
3454
|
+
onClick: (e) => e.stopPropagation(),
|
|
3455
|
+
onChange: () => togglePicked(key),
|
|
3456
|
+
}),
|
|
3457
|
+
createElement("span", { className: "dsh-obs-chev" }, isOpen ? "▾" : "▸"),
|
|
3458
|
+
createElement("span", { className: "dsh-obs-ws-icon" }, isOpen ? "📂" : "📁"),
|
|
3459
|
+
createElement("span", { className: "dsh-obs-ws-title", title: path }, title),
|
|
3460
|
+
!anyBatch && isReal && createElement("span", { className: "dsh-obs-actions" },
|
|
3461
|
+
createElement("button", {
|
|
3462
|
+
title: t("rename"),
|
|
3463
|
+
onClick: (e) => {
|
|
3464
|
+
e.stopPropagation();
|
|
3465
|
+
void renameWorkspace(key, title);
|
|
3466
|
+
},
|
|
3467
|
+
}, "✎"),
|
|
3468
|
+
createElement("button", {
|
|
3469
|
+
title: t("delete"),
|
|
3470
|
+
onClick: (e) => {
|
|
3471
|
+
e.stopPropagation();
|
|
3472
|
+
void deleteWorkspace(key, title, sessionIds);
|
|
3473
|
+
},
|
|
3474
|
+
}, "🗑"),
|
|
3475
|
+
),
|
|
3476
|
+
!anyBatch && !isReal && sessionIds.length > 0 && createElement("span", { className: "dsh-obs-actions" },
|
|
3477
|
+
createElement("button", {
|
|
3478
|
+
title: t("archiveUngrouped"),
|
|
3479
|
+
onClick: (e) => {
|
|
3480
|
+
e.stopPropagation();
|
|
3481
|
+
void archiveUngrouped(sessionIds, title);
|
|
3482
|
+
},
|
|
3483
|
+
}, "🗄"),
|
|
3484
|
+
createElement("button", {
|
|
3485
|
+
className: "dsh-obs-danger",
|
|
3486
|
+
title: t("deleteUngrouped"),
|
|
3487
|
+
onClick: (e) => {
|
|
3488
|
+
e.stopPropagation();
|
|
3489
|
+
void deleteUngrouped(sessionIds, title);
|
|
3490
|
+
},
|
|
3491
|
+
}, "🗑"),
|
|
3492
|
+
),
|
|
3493
|
+
// 工作区行批量入口:紧贴行尾管理动作左侧(与标题行「批量工作区」呼应)
|
|
3494
|
+
!anyBatch && isReal && sessionIds.length > 0 && createElement("button", {
|
|
3495
|
+
className: "dsh-obs-mini",
|
|
3496
|
+
title: t("batchSessions"),
|
|
3497
|
+
"aria-label": t("batchSessions"),
|
|
3498
|
+
onClick: (e) => {
|
|
3499
|
+
e.stopPropagation();
|
|
3500
|
+
enterSessionBatch(key);
|
|
3501
|
+
},
|
|
3502
|
+
}, "☑"),
|
|
3503
|
+
!anyBatch && isReal && createElement("button", {
|
|
3504
|
+
className: "dsh-obs-mini",
|
|
3505
|
+
title: t("newSession"),
|
|
3506
|
+
onClick: (e) => {
|
|
3507
|
+
e.stopPropagation();
|
|
3508
|
+
startSession(key);
|
|
3509
|
+
},
|
|
3510
|
+
}, "+"),
|
|
3511
|
+
),
|
|
3512
|
+
isOpen && createElement("div", null,
|
|
3513
|
+
sessionIds.length === 0 && createElement("div", { className: "dsh-obs-ws-empty" }, t("noSessions")),
|
|
3514
|
+
sessionIds.map((sid) => sessionRow(sid, key)),
|
|
3515
|
+
),
|
|
3516
|
+
);
|
|
3517
|
+
};
|
|
3518
|
+
|
|
3519
|
+
// 折叠轨道(wide=false):紧凑图标列,点击展开侧边栏
|
|
3520
|
+
if (wide !== true) {
|
|
3521
|
+
return createElement("div", { className: "dsh-obs-rail" },
|
|
3522
|
+
createElement("button", {
|
|
3523
|
+
type: "button",
|
|
3524
|
+
title: t("notes"),
|
|
3525
|
+
onClick: () => {
|
|
3526
|
+
if (expandSidebar) expandSidebar();
|
|
3527
|
+
patchShared({ notesOpen: true });
|
|
3528
|
+
},
|
|
3529
|
+
}, "📚"),
|
|
3530
|
+
createElement("button", {
|
|
3531
|
+
type: "button",
|
|
3532
|
+
title: t("searchSessions"),
|
|
3533
|
+
onClick: () => {
|
|
3534
|
+
if (expandSidebar) expandSidebar();
|
|
3535
|
+
},
|
|
3536
|
+
}, "🔍"),
|
|
3537
|
+
);
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
const header = createElement("div", { className: "dsh-obs-ws-head" },
|
|
3541
|
+
createElement("span", { className: "dsh-obs-ws-head-label" }, t("wsSection")),
|
|
3542
|
+
createElement("span", { style: { flex: 1 } }),
|
|
3543
|
+
// 标题行批量入口(截图绿框位置:搜索按钮左侧):只批量工作区(勾选后删除)
|
|
3544
|
+
createElement("button", {
|
|
3545
|
+
className: `dsh-obs-head-btn${batch !== null && batch.kind === "workspaces" ? " dsh-obs-on" : ""}`,
|
|
3546
|
+
title: t("batchWorkspaces"),
|
|
3547
|
+
"aria-label": t("batchWorkspaces"),
|
|
3548
|
+
onClick: () => {
|
|
3549
|
+
if (batch !== null && batch.kind === "workspaces") exitBatch();
|
|
3550
|
+
else enterWorkspaceBatch();
|
|
3551
|
+
},
|
|
3552
|
+
}, "☑"),
|
|
3553
|
+
createElement("button", {
|
|
3554
|
+
className: `dsh-obs-head-btn${searchOpen ? " dsh-obs-on" : ""}`,
|
|
3555
|
+
title: t("searchSessions"),
|
|
3556
|
+
onClick: () => {
|
|
3557
|
+
if (batch !== null) exitBatch();
|
|
3558
|
+
setSearchOpen((v) => !v);
|
|
3559
|
+
setQuery("");
|
|
3560
|
+
},
|
|
3561
|
+
}, "🔍"),
|
|
3562
|
+
createElement("button", {
|
|
3563
|
+
className: "dsh-obs-head-btn",
|
|
3564
|
+
title: t("addWorkspace"),
|
|
3565
|
+
onClick: () => void addWorkspace(),
|
|
3566
|
+
}, "+"),
|
|
3567
|
+
);
|
|
3568
|
+
|
|
3569
|
+
const searchRow = searchOpen && createElement("div", { className: "dsh-obs-search" },
|
|
3570
|
+
createElement("input", {
|
|
3571
|
+
autoFocus: true,
|
|
3572
|
+
placeholder: t("sessionSearchPlaceholder"),
|
|
3573
|
+
value: query,
|
|
3574
|
+
onChange: (e) => setQuery(e.target.value),
|
|
3575
|
+
onKeyDown: (e) => {
|
|
3576
|
+
if (e.key === "Escape") { setSearchOpen(false); setQuery(""); }
|
|
3577
|
+
},
|
|
3578
|
+
}),
|
|
3579
|
+
);
|
|
3580
|
+
|
|
3581
|
+
// 批量工具条:说明 + 全选/反选/清空 + 模式专属动作 + 取消。
|
|
3582
|
+
// 动作按钮在「未勾选」时禁用,避免对空集合发确认框。
|
|
3583
|
+
const batchBar = batch !== null && createElement("div", { className: "dsh-obs-batchbar" },
|
|
3584
|
+
createElement("span", { className: "dsh-obs-batchbar-info" },
|
|
3585
|
+
t(batch.kind === "workspaces" ? "batchWorkspacesLabel" : "batchSessionsLabel", { n: pickedList.length })),
|
|
3586
|
+
createElement("button", { title: t("batchSelectAll"), onClick: selectAllPicked }, t("batchSelectAll")),
|
|
3587
|
+
createElement("button", { title: t("batchInvert"), onClick: invertPicked }, t("batchInvert")),
|
|
3588
|
+
createElement("button", { title: t("batchClear"), disabled: pickedList.length === 0, onClick: clearPicked }, t("batchClear")),
|
|
3589
|
+
batch.kind === "sessions" && createElement("button", {
|
|
3590
|
+
title: t("archivePicked"),
|
|
3591
|
+
disabled: pickedList.length === 0,
|
|
3592
|
+
onClick: () => void archivePicked(),
|
|
3593
|
+
}, t("batchArchiveShort")),
|
|
3594
|
+
batch.kind === "sessions" && createElement("button", {
|
|
3595
|
+
className: "dsh-obs-danger",
|
|
3596
|
+
title: t("deletePicked"),
|
|
3597
|
+
disabled: pickedList.length === 0,
|
|
3598
|
+
onClick: () => void deletePicked(),
|
|
3599
|
+
}, t("batchDeleteShort")),
|
|
3600
|
+
batch.kind === "workspaces" && createElement("button", {
|
|
3601
|
+
className: "dsh-obs-danger",
|
|
3602
|
+
title: t("deletePickedWorkspaces"),
|
|
3603
|
+
disabled: pickedList.length === 0,
|
|
3604
|
+
onClick: () => void deletePickedWorkspaces(),
|
|
3605
|
+
}, t("batchDeleteWsShort")),
|
|
3606
|
+
createElement("button", { title: t("batchExit"), onClick: exitBatch }, t("batchExit")),
|
|
3607
|
+
);
|
|
3608
|
+
|
|
3609
|
+
let body;
|
|
3610
|
+
if (q !== "") {
|
|
3611
|
+
body = matched.length === 0
|
|
3612
|
+
? createElement("div", { className: "dsh-obs-empty" }, t("noSessions"))
|
|
3613
|
+
: matched.map((sid) => sessionRow(sid));
|
|
3614
|
+
} else {
|
|
3615
|
+
const parts = [];
|
|
3616
|
+
for (const g of groups.list) {
|
|
3617
|
+
parts.push(groupRow(g.key, g.workspace.title, g.workspace.path, g.sessionIds));
|
|
3618
|
+
}
|
|
3619
|
+
if (groups.ungrouped.length > 0) {
|
|
3620
|
+
parts.push(groupRow(UNGROUPED_KEY, t("ungrouped"), "", groups.ungrouped));
|
|
3621
|
+
}
|
|
3622
|
+
parts.push(
|
|
3623
|
+
createElement("div", {
|
|
3624
|
+
key: "notes",
|
|
3625
|
+
className: `dsh-obs-notes-row${notesOpen ? " dsh-obs-notes-row-on" : ""}`,
|
|
3626
|
+
onClick: () => patchShared({ notesOpen: !shared.notesOpen }),
|
|
3627
|
+
},
|
|
3628
|
+
createElement("span", { className: "dsh-obs-chev" }, notesOpen ? "▾" : "▸"),
|
|
3629
|
+
createElement("span", { className: "dsh-obs-notes-icon" }, "📚"),
|
|
3630
|
+
createElement("span", { className: "dsh-obs-notes-title" }, t("notes")),
|
|
3631
|
+
noteCount > 0 && createElement("span", { className: "dsh-obs-notes-count" }, String(noteCount)),
|
|
3632
|
+
),
|
|
3633
|
+
);
|
|
3634
|
+
if (notesOpen) {
|
|
3635
|
+
parts.push(createElement("div", { key: "notes-pane", className: "dsh-obs-notes-pane" },
|
|
3636
|
+
createElement(TreeView, { t, uiWorkspace }),
|
|
3637
|
+
));
|
|
3638
|
+
}
|
|
3639
|
+
body = parts;
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
return createElement("div", { className: "dsh-obs-sidebar" },
|
|
3643
|
+
header,
|
|
3644
|
+
searchRow,
|
|
3645
|
+
batchBar,
|
|
3646
|
+
createElement("div", { className: "dsh-obs-ws-body" }, body),
|
|
3647
|
+
);
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
// ── 右侧笔记 Dock(shell.overlay)──────────────────────────────────────────
|
|
3651
|
+
function Dock({ t, workspaces }) {
|
|
3652
|
+
const { openNote, vaultRev } = useShared();
|
|
3653
|
+
const [note, setNote] = useState(null);
|
|
3654
|
+
const [root, setRoot] = useState("");
|
|
3655
|
+
const [editing, setEditing] = useState(false);
|
|
3656
|
+
const [draft, setDraft] = useState("");
|
|
3657
|
+
const [saving, setSaving] = useState(false);
|
|
3658
|
+
const [error, setError] = useState("");
|
|
3659
|
+
const [width, setWidth] = useState(() => {
|
|
3660
|
+
try {
|
|
3661
|
+
const v = parseInt(localStorage.getItem(LS_DOCK_WIDTH) ?? "", 10);
|
|
3662
|
+
if (Number.isFinite(v) && v >= 380) return Math.min(v, 900);
|
|
3663
|
+
} catch { /* ignore */ }
|
|
3664
|
+
return 520;
|
|
3665
|
+
});
|
|
3666
|
+
const [pos, setPos] = useState(null);
|
|
3667
|
+
const dragState = useRef(null);
|
|
3668
|
+
const resizeState = useRef(null);
|
|
3669
|
+
// 「选中片段提问」(预览 / 编辑态共用)
|
|
3670
|
+
const previewRef = useRef(null);
|
|
3671
|
+
const editorRef = useRef(null);
|
|
3672
|
+
const gutterRef = useRef(null);
|
|
3673
|
+
const dockRef = useRef(null);
|
|
3674
|
+
const leavesRef = useRef([]);
|
|
3675
|
+
const askOpenRef = useRef(false);
|
|
3676
|
+
const [ask, setAsk] = useState(null); // {span, rect, basePath, content}
|
|
3677
|
+
const [askOpen, setAskOpen] = useState(false);
|
|
3678
|
+
const [draftQ, setDraftQ] = useState("");
|
|
3679
|
+
|
|
3680
|
+
// 加载笔记
|
|
3681
|
+
useEffect(() => {
|
|
3682
|
+
if (!openNote) {
|
|
3683
|
+
setNote(null);
|
|
3684
|
+
setEditing(false);
|
|
3685
|
+
setError("");
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3688
|
+
let cancelled = false;
|
|
3689
|
+
setEditing(false);
|
|
3690
|
+
setError("");
|
|
3691
|
+
setNote(null);
|
|
3692
|
+
Promise.all([treeApi.root(), treeApi.note(openNote.rel)]).then(([info, payload]) => {
|
|
3693
|
+
if (cancelled) return;
|
|
3694
|
+
setRoot(info.root);
|
|
3695
|
+
setNote(payload);
|
|
3696
|
+
}).catch((e) => {
|
|
3697
|
+
if (!cancelled) setError(String(e && e.message ? e.message : e));
|
|
3698
|
+
});
|
|
3699
|
+
return () => {
|
|
3700
|
+
cancelled = true;
|
|
3701
|
+
};
|
|
3702
|
+
}, [openNote, vaultRev]);
|
|
3703
|
+
|
|
3704
|
+
// 双工刷新:Dock 打开且未编辑时轮询 mtime,Agent 修改 vault 后自动反映
|
|
3705
|
+
useEffect(() => {
|
|
3706
|
+
if (!openNote || editing || !note) return;
|
|
3707
|
+
const timer = window.setInterval(async () => {
|
|
3708
|
+
try {
|
|
3709
|
+
const fresh = await treeApi.note(openNote.rel);
|
|
3710
|
+
if (fresh.mtimeMs !== note.mtimeMs) {
|
|
3711
|
+
setNote((prev) => prev ? {
|
|
3712
|
+
...prev,
|
|
3713
|
+
content: fresh.content,
|
|
3714
|
+
frontmatter: fresh.frontmatter,
|
|
3715
|
+
links: fresh.links,
|
|
3716
|
+
size: fresh.size,
|
|
3717
|
+
mtimeMs: fresh.mtimeMs,
|
|
3718
|
+
} : prev);
|
|
3719
|
+
}
|
|
3720
|
+
} catch { /* 文件可能被外部删除,保持现状 */ }
|
|
3721
|
+
}, 3000);
|
|
3722
|
+
return () => window.clearInterval(timer);
|
|
3723
|
+
}, [openNote, editing, note]);
|
|
3724
|
+
|
|
3725
|
+
useEffect(() => {
|
|
3726
|
+
try {
|
|
3727
|
+
localStorage.setItem(LS_DOCK_WIDTH, String(width));
|
|
3728
|
+
} catch { /* ignore */ }
|
|
3729
|
+
}, [width]);
|
|
3730
|
+
|
|
3731
|
+
// 预览渲染后重建偏移叶子表(「选中片段提问」用)
|
|
3732
|
+
useEffect(() => {
|
|
3733
|
+
if (editing || !note || !previewRef.current) { leavesRef.current = []; return; }
|
|
3734
|
+
leavesRef.current = computeLeafSpans(previewRef.current);
|
|
3735
|
+
}, [note, editing, vaultRev]);
|
|
3736
|
+
|
|
3737
|
+
// 同步浮层展开态到 ref(供 mousedown/mouseup 判定,避免聚焦输入框清空选区后误关)
|
|
3738
|
+
useEffect(() => { askOpenRef.current = askOpen; }, [askOpen]);
|
|
3739
|
+
|
|
3740
|
+
// 编辑态行号栏:内容/滚动后保持行号与 textarea 文本垂直对齐(textarea 为 white-space:pre,无折行)
|
|
3741
|
+
useEffect(() => {
|
|
3742
|
+
const g = gutterRef.current;
|
|
3743
|
+
const ed = editorRef.current;
|
|
3744
|
+
if (g && ed) g.scrollTop = ed.scrollTop;
|
|
3745
|
+
}, [editing, draft]);
|
|
3746
|
+
|
|
3747
|
+
// 预览态选中监听:mouseup / mousedown(测试桩无 addEventListener 则跳过)
|
|
3748
|
+
useEffect(() => {
|
|
3749
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return;
|
|
3750
|
+
const onUp = () => {
|
|
3751
|
+
if (editing || !note || !previewRef.current) return;
|
|
3752
|
+
const span = spanFromSelection(previewRef.current, leavesRef.current, note.content);
|
|
3753
|
+
if (span && !askOpenRef.current) {
|
|
3754
|
+
setAsk({ span, rect: selRect() || { top: 64, bottom: 96, left: 40 }, basePath: treeApi.absPath(note.path, root), content: note.content });
|
|
3755
|
+
setAskOpen(false);
|
|
3756
|
+
} else if (!askOpenRef.current) {
|
|
3757
|
+
setAsk(null);
|
|
3758
|
+
}
|
|
3759
|
+
};
|
|
3760
|
+
const onDown = (e) => {
|
|
3761
|
+
if (askOpenRef.current) return;
|
|
3762
|
+
const tg = e.target;
|
|
3763
|
+
if (tg && tg.closest && tg.closest(".dsh-obs-askpop")) return;
|
|
3764
|
+
setAsk(null);
|
|
3765
|
+
};
|
|
3766
|
+
document.addEventListener("mouseup", onUp);
|
|
3767
|
+
document.addEventListener("mousedown", onDown);
|
|
3768
|
+
return () => {
|
|
3769
|
+
document.removeEventListener("mouseup", onUp);
|
|
3770
|
+
document.removeEventListener("mousedown", onDown);
|
|
3771
|
+
};
|
|
3772
|
+
}, [editing, note, root, vaultRev]);
|
|
3773
|
+
|
|
3774
|
+
const submitAsk = useCallback((question) => {
|
|
3775
|
+
if (!ask) return;
|
|
3776
|
+
const prompt = buildPrompt(t, ask.basePath, ask.content, ask.span, question);
|
|
3777
|
+
if (injectPrompt(prompt)) {
|
|
3778
|
+
showToast(t("askCited", { n: ask.span.text.length }));
|
|
3779
|
+
} else {
|
|
3780
|
+
try {
|
|
3781
|
+
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
|
3782
|
+
navigator.clipboard.writeText(prompt).then(() => showToast(t("noInput"))).catch(() => showToast(t("citeFail")));
|
|
3783
|
+
} else {
|
|
3784
|
+
showToast(t("citeFail"));
|
|
3785
|
+
}
|
|
3786
|
+
} catch {
|
|
3787
|
+
showToast(t("citeFail"));
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
setAsk(null); setAskOpen(false); setDraftQ("");
|
|
3791
|
+
}, [ask, t]);
|
|
3792
|
+
|
|
3793
|
+
const closeAsk = useCallback(() => {
|
|
3794
|
+
setAskOpen(false); setAsk(null); setDraftQ("");
|
|
3795
|
+
}, []);
|
|
3796
|
+
|
|
3797
|
+
// 编辑态选区(textarea 原生 selectionStart/End 直接切片)
|
|
3798
|
+
const onEditorSel = useCallback(() => {
|
|
3799
|
+
const ed = editorRef.current;
|
|
3800
|
+
if (!ed || !note) return;
|
|
3801
|
+
if (ed.selectionStart === ed.selectionEnd) { setAsk(null); return; }
|
|
3802
|
+
const start = ed.selectionStart, end = ed.selectionEnd;
|
|
3803
|
+
const r = ed.getBoundingClientRect ? ed.getBoundingClientRect() : null;
|
|
3804
|
+
setAsk({
|
|
3805
|
+
span: { text: note.content.slice(start, end), start, end, mode: "edit" },
|
|
3806
|
+
rect: r ? { top: r.top - 6, bottom: r.top + 32, left: r.left } : { top: 64, bottom: 96, left: 40 },
|
|
3807
|
+
basePath: treeApi.absPath(note.path, root),
|
|
3808
|
+
content: note.content,
|
|
3809
|
+
});
|
|
3810
|
+
setAskOpen(false);
|
|
3811
|
+
}, [note, root]);
|
|
3812
|
+
|
|
3813
|
+
// 编辑态粘贴图片:捕获 image/* → base64 → 落盘附件目录 → 光标处插入 ![[...]]
|
|
3814
|
+
const onPasteImage = useCallback(async (e) => {
|
|
3815
|
+
const items = e && e.clipboardData && e.clipboardData.items;
|
|
3816
|
+
if (!items) return;
|
|
3817
|
+
for (const item of items) {
|
|
3818
|
+
if (item && item.kind === "file" && item.type && item.type.startsWith("image/")) {
|
|
3819
|
+
const file = item.getAsFile ? item.getAsFile() : null;
|
|
3820
|
+
if (!file) continue;
|
|
3821
|
+
e.preventDefault();
|
|
3822
|
+
try {
|
|
3823
|
+
const dataUrl = await fileToDataUrl(file);
|
|
3824
|
+
const data = dataUrl.slice(dataUrl.indexOf(",") + 1);
|
|
3825
|
+
if (data === "") throw new Error("empty image data");
|
|
3826
|
+
const r = await treeApi.attach(data, note ? note.path : "");
|
|
3827
|
+
const embed = `![[${r.path}]]`;
|
|
3828
|
+
const ed = editorRef.current;
|
|
3829
|
+
const cur = ed ? ed.value : draft; // 受控 textarea:DOM 值即当前 draft
|
|
3830
|
+
const start = ed ? (typeof ed.selectionStart === "number" ? ed.selectionStart : cur.length) : cur.length;
|
|
3831
|
+
const end = ed ? (typeof ed.selectionEnd === "number" ? ed.selectionEnd : start) : start;
|
|
3832
|
+
const next = insertEmbedAtSelection(cur, start, end, embed);
|
|
3833
|
+
setDraft(next);
|
|
3834
|
+
if (ed && typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
|
3835
|
+
window.requestAnimationFrame(() => {
|
|
3836
|
+
ed.focus();
|
|
3837
|
+
const p = start + embed.length;
|
|
3838
|
+
if (typeof ed.setSelectionRange === "function") ed.setSelectionRange(p, p);
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
showToast(t("imageInserted"));
|
|
3842
|
+
} catch (err) {
|
|
3843
|
+
showToast(String(err && err.message ? err.message : err));
|
|
3844
|
+
}
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
}, [t, draft]);
|
|
3849
|
+
|
|
3850
|
+
const beginDrag = useCallback((e) => {
|
|
3851
|
+
e.preventDefault();
|
|
3852
|
+
const base = pos ?? { x: Math.max(12, window.innerWidth - width - 24), y: 64 };
|
|
3853
|
+
dragState.current = { startX: e.clientX, startY: e.clientY, base };
|
|
3854
|
+
const onMove = (ev) => {
|
|
3855
|
+
const st = dragState.current;
|
|
3856
|
+
if (!st) return;
|
|
3857
|
+
setPos({
|
|
3858
|
+
x: Math.min(Math.max(0, st.base.x + ev.clientX - st.startX), Math.max(0, window.innerWidth - 160)),
|
|
3859
|
+
y: Math.min(Math.max(8, st.base.y + ev.clientY - st.startY), Math.max(8, window.innerHeight - 80)),
|
|
3860
|
+
});
|
|
3861
|
+
};
|
|
3862
|
+
const onUp = () => {
|
|
3863
|
+
dragState.current = null;
|
|
3864
|
+
window.removeEventListener("mousemove", onMove);
|
|
3865
|
+
window.removeEventListener("mouseup", onUp);
|
|
3866
|
+
};
|
|
3867
|
+
window.addEventListener("mousemove", onMove);
|
|
3868
|
+
window.addEventListener("mouseup", onUp);
|
|
3869
|
+
}, [pos, width]);
|
|
3870
|
+
|
|
3871
|
+
const beginResize = useCallback((e) => {
|
|
3872
|
+
e.preventDefault();
|
|
3873
|
+
e.stopPropagation();
|
|
3874
|
+
resizeState.current = { startX: e.clientX, baseW: width };
|
|
3875
|
+
document.body.classList.add("dsh-obs-resizing");
|
|
3876
|
+
const onMove = (ev) => {
|
|
3877
|
+
const st = resizeState.current;
|
|
3878
|
+
if (!st) return;
|
|
3879
|
+
setWidth(Math.min(Math.max(380, st.baseW - (ev.clientX - st.startX)), Math.round(window.innerWidth * 0.92)));
|
|
3880
|
+
};
|
|
3881
|
+
const onUp = () => {
|
|
3882
|
+
resizeState.current = null;
|
|
3883
|
+
document.body.classList.remove("dsh-obs-resizing");
|
|
3884
|
+
window.removeEventListener("mousemove", onMove);
|
|
3885
|
+
window.removeEventListener("mouseup", onUp);
|
|
3886
|
+
};
|
|
3887
|
+
window.addEventListener("mousemove", onMove);
|
|
3888
|
+
window.addEventListener("mouseup", onUp);
|
|
3889
|
+
}, [width]);
|
|
3890
|
+
|
|
3891
|
+
const save = useCallback(async () => {
|
|
3892
|
+
if (!note) return;
|
|
3893
|
+
setSaving(true);
|
|
3894
|
+
setError("");
|
|
3895
|
+
try {
|
|
3896
|
+
const r = await treeApi.save(note.path, draft);
|
|
3897
|
+
const fresh = await treeApi.note(note.path);
|
|
3898
|
+
setNote(fresh);
|
|
3899
|
+
setEditing(false);
|
|
3900
|
+
patchShared({ vaultRev: shared.vaultRev + 1 });
|
|
3901
|
+
if (r && Array.isArray(r.deletedAttachments) && r.deletedAttachments.length > 0) {
|
|
3902
|
+
showToast(`${t("imageRemoved")}:${r.deletedAttachments.length} 张 → ${r.deletedAttachments.join("、")}`);
|
|
3903
|
+
}
|
|
3904
|
+
} catch (e) {
|
|
3905
|
+
setError(String(e && e.message ? e.message : e));
|
|
3906
|
+
} finally {
|
|
3907
|
+
setSaving(false);
|
|
3908
|
+
}
|
|
3909
|
+
}, [note, draft, t]);
|
|
3910
|
+
|
|
3911
|
+
const remove = useCallback(async () => {
|
|
3912
|
+
if (!note) return;
|
|
3913
|
+
if (!window.confirm(t("deleteConfirm"))) return;
|
|
3914
|
+
try {
|
|
3915
|
+
await treeApi.deleteNote(note.path);
|
|
3916
|
+
patchShared({ openNote: null, vaultRev: shared.vaultRev + 1 });
|
|
3917
|
+
} catch (e) {
|
|
3918
|
+
setError(String(e && e.message ? e.message : e));
|
|
3919
|
+
}
|
|
3920
|
+
}, [note, t]);
|
|
3921
|
+
|
|
3922
|
+
const rename = useCallback(async () => {
|
|
3923
|
+
if (!note) return;
|
|
3924
|
+
const name = await askText(t("renamePrompt"), note.name);
|
|
3925
|
+
if (name === null || name.trim() === "") return;
|
|
3926
|
+
try {
|
|
3927
|
+
const r = await treeApi.renameNote(note.path, name.trim());
|
|
3928
|
+
const rel = note.path.split("/").slice(0, -1).concat(r.name).join("/");
|
|
3929
|
+
patchShared({ openNote: { rel, name: r.name }, vaultRev: shared.vaultRev + 1 });
|
|
3930
|
+
} catch (e) {
|
|
3931
|
+
setError(String(e && e.message ? e.message : e));
|
|
3932
|
+
}
|
|
3933
|
+
}, [note, t]);
|
|
3934
|
+
|
|
3935
|
+
const cite = useCallback(async () => {
|
|
3936
|
+
if (!note) return;
|
|
3937
|
+
await citeToChat(treeApi.absPath(note.path, root), t);
|
|
3938
|
+
}, [note, root, t]);
|
|
3939
|
+
|
|
3940
|
+
const openWikilink = useCallback(async (target) => {
|
|
3941
|
+
if (editing && draft !== note && draft !== note.content && !window.confirm(t("discard"))) return;
|
|
3942
|
+
const r = await treeApi.search(target);
|
|
3943
|
+
const exact = r.results.find((x) => x.name.toLowerCase() === `${target.toLowerCase()}.md` || x.name.toLowerCase() === target.toLowerCase());
|
|
3944
|
+
const found = exact ?? r.results[0];
|
|
3945
|
+
if (!found) {
|
|
3946
|
+
showToast(`${t("wikilinkNotFound")}:[[${target}]]`);
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
patchShared({ openNote: { rel: found.path, name: found.name } });
|
|
3950
|
+
}, [editing, draft, note, t]);
|
|
3951
|
+
|
|
3952
|
+
const dirty = editing && note !== null && draft !== note.content;
|
|
3953
|
+
|
|
3954
|
+
// 编辑态行号(每行一个,对应源码行数;textarea 为 white-space:pre 无折行,行号与文本行一一对齐)
|
|
3955
|
+
const gutterLineCount = draft.split("\n").length;
|
|
3956
|
+
const gutterDigits = Math.max(2, String(gutterLineCount).length);
|
|
3957
|
+
const gutterNumbers = Array.from({ length: gutterLineCount }, (_, i) => i + 1).join("\n");
|
|
3958
|
+
|
|
3959
|
+
if (!openNote) return null;
|
|
3960
|
+
const style = pos
|
|
3961
|
+
? { left: `${pos.x}px`, top: `${pos.y}px`, width: `${width}px` }
|
|
3962
|
+
: { right: 16, top: 60, width: `${width}px` };
|
|
3963
|
+
|
|
3964
|
+
const tags = note && note.frontmatter && note.frontmatter.tags
|
|
3965
|
+
? String(note.frontmatter.tags).split(/[,\s]+/).filter((x) => x !== "")
|
|
3966
|
+
: [];
|
|
3967
|
+
|
|
3968
|
+
return createElement("div", { ref: dockRef, className: "dsh-obs-dock", style },
|
|
3969
|
+
createElement("div", { className: "dsh-obs-handle", title: t("resize"), onMouseDown: beginResize }),
|
|
3970
|
+
createElement("div", { className: "dsh-obs-dock-head", title: t("move"), onMouseDown: beginDrag },
|
|
3971
|
+
createElement("div", { className: "dsh-obs-dock-title" },
|
|
3972
|
+
createElement("span", null, "📝"),
|
|
3973
|
+
createElement("span", null, note ? note.name : openNote.name),
|
|
3974
|
+
),
|
|
3975
|
+
!editing && note && createElement("button", { onClick: () => void cite() }, `⤴ ${t("cite")}`),
|
|
3976
|
+
!editing && note && createElement("button", { onClick: () => { setDraft(note.content); setEditing(true); } }, t("edit")),
|
|
3977
|
+
editing && createElement("button", {
|
|
3978
|
+
className: "dsh-obs-primary",
|
|
3979
|
+
disabled: saving,
|
|
3980
|
+
onClick: () => void save(),
|
|
3981
|
+
}, saving ? t("saving") : `${t("save")}${dirty ? " ●" : ""}`),
|
|
3982
|
+
editing && createElement("button", {
|
|
3983
|
+
onClick: () => {
|
|
3984
|
+
if (!dirty || window.confirm(t("discard"))) setEditing(false);
|
|
3985
|
+
},
|
|
3986
|
+
}, t("cancel")),
|
|
3987
|
+
!editing && note && createElement("button", { title: t("rename"), onClick: () => void rename() }, "✎"),
|
|
3988
|
+
!editing && note && createElement("button", { title: t("delete"), onClick: () => void remove() }, "🗑"),
|
|
3989
|
+
createElement("button", { title: t("close"), onClick: () => patchShared({ openNote: null }) }, "✕"),
|
|
3990
|
+
),
|
|
3991
|
+
note && createElement("div", { className: "dsh-obs-dock-meta" },
|
|
3992
|
+
note.path,
|
|
3993
|
+
createElement("span", null, formatSize(note.size)),
|
|
3994
|
+
tags.map((tag) => createElement("span", { key: tag, className: "dsh-obs-tag" }, `#${tag}`)),
|
|
3995
|
+
),
|
|
3996
|
+
note === null
|
|
3997
|
+
? error !== ""
|
|
3998
|
+
? createElement("div", { className: "dsh-obs-error" }, error)
|
|
3999
|
+
: createElement("div", { className: "dsh-obs-empty" }, t("loading"))
|
|
4000
|
+
: editing
|
|
4001
|
+
? createElement("div", { className: "dsh-obs-editor" },
|
|
4002
|
+
createElement("div", {
|
|
4003
|
+
ref: gutterRef,
|
|
4004
|
+
className: "dsh-obs-editor-gutter",
|
|
4005
|
+
"aria-hidden": "true",
|
|
4006
|
+
style: { width: `${gutterDigits}ch` },
|
|
4007
|
+
}, gutterNumbers),
|
|
4008
|
+
createElement("textarea", {
|
|
4009
|
+
ref: editorRef,
|
|
4010
|
+
autoFocus: true,
|
|
4011
|
+
value: draft,
|
|
4012
|
+
onChange: (e) => setDraft(e.target.value),
|
|
4013
|
+
spellCheck: false,
|
|
4014
|
+
onScroll: (e) => { if (gutterRef.current) gutterRef.current.scrollTop = e.currentTarget.scrollTop; },
|
|
4015
|
+
onPaste: (e) => void onPasteImage(e),
|
|
4016
|
+
onMouseUp: () => onEditorSel(),
|
|
4017
|
+
onKeyUp: (e) => { if (!["Shift", "Control", "Meta", "Alt"].includes(e.key)) onEditorSel(); },
|
|
4018
|
+
onKeyDown: (e) => {
|
|
4019
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
4020
|
+
e.preventDefault();
|
|
4021
|
+
void save();
|
|
4022
|
+
}
|
|
4023
|
+
},
|
|
4024
|
+
}),
|
|
4025
|
+
)
|
|
4026
|
+
: createElement("div", { className: "dsh-obs-dock-body" },
|
|
4027
|
+
createElement("div", { ref: previewRef, className: "dsh-obs-md" },
|
|
4028
|
+
renderNumbered(note.content, {
|
|
4029
|
+
onWikilink: (target) => void openWikilink(target),
|
|
4030
|
+
onText: () => {},
|
|
4031
|
+
assetUrl: (rel) => treeApi.assetUrl(rel, note.path),
|
|
4032
|
+
}),
|
|
4033
|
+
),
|
|
4034
|
+
),
|
|
4035
|
+
error !== "" && note !== null && createElement("div", { className: "dsh-obs-error" }, error),
|
|
4036
|
+
ask && createElement("div", {
|
|
4037
|
+
className: "dsh-obs-askpop",
|
|
4038
|
+
style: (() => {
|
|
4039
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
4040
|
+
const panelW = 300; // 输入面板宽度(工具条更窄,沿用此界做边界)
|
|
4041
|
+
const panelH = askOpen ? 150 : 40;
|
|
4042
|
+
let top = ask.rect.top - panelH - 8;
|
|
4043
|
+
if (top < 6) top = ask.rect.bottom + 8; // 上方放不下 → 翻到选区下方
|
|
4044
|
+
top = Math.max(8, Math.min(top, vh - panelH - 8)); // 纵向夹紧视口
|
|
4045
|
+
// 以 Dock 卡片左右界为准夹紧,避免横向超出灰色卡片
|
|
4046
|
+
let left;
|
|
4047
|
+
const dockEl = dockRef.current;
|
|
4048
|
+
if (dockEl && typeof dockEl.getBoundingClientRect === "function") {
|
|
4049
|
+
const dr = dockEl.getBoundingClientRect();
|
|
4050
|
+
const lo = dr.left + 8;
|
|
4051
|
+
const hi = dr.right - panelW - 8;
|
|
4052
|
+
left = hi >= lo ? Math.max(lo, Math.min(ask.rect.left, hi)) : lo;
|
|
4053
|
+
} else {
|
|
4054
|
+
left = Math.max(8, Math.min(ask.rect.left, vw - panelW - 8));
|
|
4055
|
+
}
|
|
4056
|
+
return { left: `${left}px`, top: `${top}px` };
|
|
4057
|
+
})(),
|
|
4058
|
+
},
|
|
4059
|
+
askOpen
|
|
4060
|
+
? createElement("div", { className: "dsh-obs-askinput" },
|
|
4061
|
+
createElement("div", { className: "dsh-obs-askcap" }, t("askCap", { n: ask.span.text.length })),
|
|
4062
|
+
createElement("textarea", {
|
|
4063
|
+
autoFocus: true,
|
|
4064
|
+
className: "dsh-obs-askta",
|
|
4065
|
+
value: draftQ,
|
|
4066
|
+
onChange: (e) => setDraftQ(e.target.value),
|
|
4067
|
+
spellCheck: false,
|
|
4068
|
+
onKeyDown: (e) => {
|
|
4069
|
+
if (e.isComposing || e.keyCode === 229) return; // 输入法组合期间不发送
|
|
4070
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submitAsk(draftQ); }
|
|
4071
|
+
else if (e.key === "Escape") { e.preventDefault(); closeAsk(); }
|
|
4072
|
+
},
|
|
4073
|
+
}),
|
|
4074
|
+
createElement("div", { className: "dsh-obs-askrow" },
|
|
4075
|
+
createElement("span", { className: "dsh-obs-askhint" }, t("askCancel")),
|
|
4076
|
+
createElement("button", { className: "dsh-obs-asksend", onMouseDown: (e) => e.preventDefault(), onClick: () => submitAsk(draftQ) }, t("askSend")),
|
|
4077
|
+
),
|
|
4078
|
+
)
|
|
4079
|
+
: createElement("div", { className: "dsh-obs-askbar" },
|
|
4080
|
+
createElement("button", { onMouseDown: (e) => e.preventDefault(), onClick: () => { setAskOpen(true); setDraftQ(""); } }, `💬 ${t("ask")}`),
|
|
4081
|
+
createElement("button", { onMouseDown: (e) => e.preventDefault(), onClick: () => submitAsk(t("promptDefaultQ")) }, `⚡ ${t("explain")}`),
|
|
4082
|
+
createElement("span", { className: "dsh-obs-asklen" }, t("askChars", { n: ask.span.text.length })),
|
|
4083
|
+
),
|
|
4084
|
+
),
|
|
4085
|
+
);
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
// ── Toast(shell.overlay)──────────────────────────────────────────────────
|
|
4089
|
+
function Toast() {
|
|
4090
|
+
const { toast } = useShared();
|
|
4091
|
+
if (!toast) return null;
|
|
4092
|
+
return createElement("div", { className: "dsh-obs-toast" }, toast);
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
// ── 文本输入卡片(shell.overlay):替代 window.prompt ───────────────────────
|
|
4096
|
+
function Prompt({ t }) {
|
|
4097
|
+
const { prompt } = useShared();
|
|
4098
|
+
const [value, setValue] = useState("");
|
|
4099
|
+
useEffect(() => {
|
|
4100
|
+
if (prompt) setValue(prompt.initial ?? "");
|
|
4101
|
+
}, [prompt]);
|
|
4102
|
+
if (!prompt) return null;
|
|
4103
|
+
const commit = () => closePrompt(value);
|
|
4104
|
+
return createElement("div", {
|
|
4105
|
+
className: "dsh-obs-prompt-layer",
|
|
4106
|
+
onMouseDown: (e) => { if (e.target === e.currentTarget) closePrompt(null); },
|
|
4107
|
+
},
|
|
4108
|
+
createElement("div", { className: "dsh-obs-prompt" },
|
|
4109
|
+
createElement("div", { className: "dsh-obs-prompt-title" }, prompt.title),
|
|
4110
|
+
createElement("input", {
|
|
4111
|
+
autoFocus: true,
|
|
4112
|
+
className: "dsh-obs-prompt-input",
|
|
4113
|
+
placeholder: prompt.title,
|
|
4114
|
+
value,
|
|
4115
|
+
onChange: (e) => setValue(e.target.value),
|
|
4116
|
+
onKeyDown: (e) => {
|
|
4117
|
+
if (e.key === "Enter") commit();
|
|
4118
|
+
if (e.key === "Escape") closePrompt(null);
|
|
4119
|
+
},
|
|
4120
|
+
}),
|
|
4121
|
+
createElement("div", { className: "dsh-obs-askrow" },
|
|
4122
|
+
createElement("button", {
|
|
4123
|
+
className: "dsh-obs-asksend",
|
|
4124
|
+
onClick: () => commit(),
|
|
4125
|
+
}, t("confirm")),
|
|
4126
|
+
createElement("button", {
|
|
4127
|
+
className: "dsh-obs-prompt-cancel",
|
|
4128
|
+
onClick: () => closePrompt(null),
|
|
4129
|
+
}, t("cancel")),
|
|
4130
|
+
),
|
|
4131
|
+
),
|
|
4132
|
+
);
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
// ── 插件入口 ───────────────────────────────────────────────────────────────
|
|
4136
|
+
const inject = ["slots", "locale", "workspaces", "sessions"];
|
|
4137
|
+
|
|
4138
|
+
function apply(ctx) {
|
|
4139
|
+
injectCss();
|
|
4140
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-obsidian-vault: locale dictionaries");
|
|
4141
|
+
|
|
4142
|
+
const sessions = ctx.sessions;
|
|
4143
|
+
const workspaces = ctx.workspaces;
|
|
4144
|
+
|
|
4145
|
+
// ── UI 导航能力(新建会话 / 选择目录)─────────────────────────────────
|
|
4146
|
+
// 「新建会话」startSession 与「选择目录」pickDirectory 属于官方
|
|
4147
|
+
// @deepseek-ai/dsh-client-ui-workspace 注册的 ctx.uiWorkspace(UI 导航
|
|
4148
|
+
// 能力);纯控制器 ctx.workspaces(IWorkspaces)只有 create / rename /
|
|
4149
|
+
// delete / archiveSession / insert* —— 在上面直接调 workspaces.startSession()
|
|
4150
|
+
// 只会被 `typeof ... !== "function"` 守卫静默 return,表现为「点+没反应」。
|
|
4151
|
+
// 用 ctx.get 惰性解析:slots 的 inject 工厂结果会被渲染器缓存到注册期,
|
|
4152
|
+
// 且官方服务可能晚于本插件 apply;服务缺失时保留对旧式 workspaces.* 的兜底。
|
|
4153
|
+
const resolveUiWorkspace = () => {
|
|
4154
|
+
if (typeof ctx.get !== "function") return undefined;
|
|
4155
|
+
const svc = ctx.get("uiWorkspace");
|
|
4156
|
+
return svc === undefined || svc === null ? undefined : svc;
|
|
4157
|
+
};
|
|
4158
|
+
const uiWorkspace = {
|
|
4159
|
+
/**
|
|
4160
|
+
* 新建(或复用该工作区已有空白)会话并打开。
|
|
4161
|
+
* @param workspaceId - 目标工作区。
|
|
4162
|
+
* @param onError - 失败回调(官方实现自身只 console.warn,插件需要给用户反馈)。
|
|
4163
|
+
* @returns 是否已派发。
|
|
4164
|
+
*/
|
|
4165
|
+
startSession(workspaceId, onError) {
|
|
4166
|
+
const svc = resolveUiWorkspace();
|
|
4167
|
+
if (svc && typeof svc.startSession === "function") {
|
|
4168
|
+
svc.startSession(workspaceId);
|
|
4169
|
+
return true;
|
|
4170
|
+
}
|
|
4171
|
+
// 兜底:uiWorkspace 不可用时自行 create + open(不复用空白会话)。
|
|
4172
|
+
if (sessions !== undefined && typeof sessions.create === "function") {
|
|
4173
|
+
const opts = workspaceId === undefined || workspaceId === "" ? undefined : { workspaceId };
|
|
4174
|
+
Promise.resolve(sessions.create(opts)).then((id) => {
|
|
4175
|
+
// 打开同样走 openSession(0.1.7+ 没有 sessions.open)。
|
|
4176
|
+
uiWorkspace.openSession(id, (error) => {
|
|
4177
|
+
if (typeof onError === "function") onError(error ?? new Error("uiWorkspace.openSession unavailable"));
|
|
4178
|
+
});
|
|
4179
|
+
}, (e) => {
|
|
4180
|
+
if (typeof onError === "function") onError(e);
|
|
4181
|
+
});
|
|
4182
|
+
return true;
|
|
4183
|
+
}
|
|
4184
|
+
if (typeof onError === "function") onError(new Error("uiWorkspace / sessions unavailable"));
|
|
4185
|
+
return false;
|
|
4186
|
+
},
|
|
4187
|
+
/**
|
|
4188
|
+
* 打开(选中)一个会话。
|
|
4189
|
+
*
|
|
4190
|
+
* DSH 0.1.7 起 `ctx.sessions`(ClientSessions)**不再有 `open`**:会话
|
|
4191
|
+
* 选中/打开属于 UI 导航能力,由 `ctx.uiWorkspace.openSession()` 承担
|
|
4192
|
+
* (官方侧边栏的 `open` 就是它)。桌面版内嵌的 dsh 是 0.1.7-rc.2,所以
|
|
4193
|
+
* 只调用 `sessions.open()` 会让每一条会话行都「点了没反应」——typeof
|
|
4194
|
+
* 守卫把缺失的方法静默吞掉,连报错都没有。旧版(如 dsh web 0.1.5)仍
|
|
4195
|
+
* 保留 `sessions.open`,这里作为兜底。
|
|
4196
|
+
* @param sessionId - 目标会话 id。
|
|
4197
|
+
* @returns 是否已派发打开请求。
|
|
4198
|
+
*/
|
|
4199
|
+
openSession(sessionId, onError) {
|
|
4200
|
+
/** 失败统一出口:把原因交给调用方提示,绝不让异常炸在 onClick 里。 */
|
|
4201
|
+
const fail = (error) => {
|
|
4202
|
+
if (typeof onError === "function") onError(error ?? null);
|
|
4203
|
+
return false;
|
|
4204
|
+
};
|
|
4205
|
+
const svc = resolveUiWorkspace();
|
|
4206
|
+
if (svc && typeof svc.openSession === "function") {
|
|
4207
|
+
// 官方 openSession 对未知/陈旧 id 会同步抛
|
|
4208
|
+
// `sessions.retain: unknown session <id>`。
|
|
4209
|
+
try {
|
|
4210
|
+
svc.openSession(sessionId);
|
|
4211
|
+
return true;
|
|
4212
|
+
} catch (error) {
|
|
4213
|
+
return fail(error);
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
if (sessions !== undefined && typeof sessions.open === "function") {
|
|
4217
|
+
try {
|
|
4218
|
+
sessions.open(sessionId);
|
|
4219
|
+
return true;
|
|
4220
|
+
} catch (error) {
|
|
4221
|
+
return fail(error);
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
return fail(null);
|
|
4225
|
+
},
|
|
4226
|
+
/**
|
|
4227
|
+
* 把主视图切回「无会话」空态。
|
|
4228
|
+
*
|
|
4229
|
+
* 用在「彻底删除的正是当前会话,且已经没有别的会话可切」时:Host 进程
|
|
4230
|
+
* 可能还持有该会话的内存副本,光靠 sessions.refresh() 不会让聊天框自己
|
|
4231
|
+
* 退出,用户会继续看到一条日志已从磁盘删掉的记录。
|
|
4232
|
+
*
|
|
4233
|
+
* DSH 0.2 的 uiWorkspace 内部用 `clearMain()` 做这件事(归档当前会话时
|
|
4234
|
+
* 它自己也在调:release mainView 保留引用 + `layout.selectPanel(null)`),
|
|
4235
|
+
* 但它没有写进公开类型面;这里按能力探测调用,旧版 ClientSessions 的
|
|
4236
|
+
* `clear()` 作为兜底。两者都缺失时静默放弃 —— 删除本身已经完成。
|
|
4237
|
+
* @returns 是否已清空主视图。
|
|
4238
|
+
*/
|
|
4239
|
+
clearMain() {
|
|
4240
|
+
const svc = resolveUiWorkspace();
|
|
4241
|
+
if (svc && typeof svc.clearMain === "function") {
|
|
4242
|
+
try {
|
|
4243
|
+
svc.clearMain();
|
|
4244
|
+
return true;
|
|
4245
|
+
} catch { /* 退回下一层兜底 */ }
|
|
4246
|
+
}
|
|
4247
|
+
if (sessions !== undefined && typeof sessions.clear === "function") {
|
|
4248
|
+
try {
|
|
4249
|
+
sessions.clear();
|
|
4250
|
+
return true;
|
|
4251
|
+
} catch { /* ignore */ }
|
|
4252
|
+
}
|
|
4253
|
+
return false;
|
|
4254
|
+
},
|
|
4255
|
+
/**
|
|
4256
|
+
* 打开 Host 原生目录选择器。
|
|
4257
|
+
* @returns 选中目录,取消返回 null。
|
|
4258
|
+
*/
|
|
4259
|
+
async pickDirectory() {
|
|
4260
|
+
const svc = resolveUiWorkspace();
|
|
4261
|
+
if (svc && typeof svc.pickDirectory === "function") return svc.pickDirectory();
|
|
4262
|
+
if (workspaces !== undefined && typeof workspaces.pickDirectory === "function") {
|
|
4263
|
+
return workspaces.pickDirectory();
|
|
4264
|
+
}
|
|
4265
|
+
throw new Error("uiWorkspace.pickDirectory unavailable");
|
|
4266
|
+
},
|
|
4267
|
+
};
|
|
4268
|
+
|
|
4269
|
+
// 常驻接管 sidebar.workspaces(single 槽位,priority:-100 阴影官方
|
|
4270
|
+
// WorkspaceBrowser):核心会话浏览器 + 「📚 笔记」顶级行 + 行下笔记树。
|
|
4271
|
+
ctx.slots.inject("sidebar.workspaces", () => {
|
|
4272
|
+
const dispose = ctx.slots.register(
|
|
4273
|
+
{
|
|
4274
|
+
name: "sidebar.workspaces",
|
|
4275
|
+
id: "obsidian-workspace-sidebar",
|
|
4276
|
+
priority: -100,
|
|
4277
|
+
locale: NS,
|
|
4278
|
+
inject: () => ({ workspaces, sessions, uiWorkspace }),
|
|
4279
|
+
},
|
|
4280
|
+
WorkspaceSidebar,
|
|
4281
|
+
);
|
|
4282
|
+
return () => dispose();
|
|
4283
|
+
});
|
|
4284
|
+
|
|
4285
|
+
// 官方输入动作面桥接(见 ComposerBridge):只有 session 作用域槽位才拿得到
|
|
4286
|
+
// `inputActions`,DSH 0.2+ 的 composer 是 Lexical contenteditable,DOM 注入
|
|
4287
|
+
// 已不是稳定入口。槽位或服务缺失(旧版 DSH)时该注册不生效,注入自动退回 DOM。
|
|
4288
|
+
try {
|
|
4289
|
+
ctx.slots.inject("conversation.composer.dock", () => {
|
|
4290
|
+
try {
|
|
4291
|
+
const disposeBridge = ctx.slots.register(
|
|
4292
|
+
{ name: "conversation.composer.dock", id: "obsidian-composer-bridge", order: 1, label: "Obsidian composer bridge" },
|
|
4293
|
+
ComposerBridge,
|
|
4294
|
+
);
|
|
4295
|
+
return () => disposeBridge();
|
|
4296
|
+
} catch {
|
|
4297
|
+
return () => { };
|
|
4298
|
+
}
|
|
4299
|
+
});
|
|
4300
|
+
} catch { /* 旧版 DSH 无该槽位:忽略,走 DOM 兜底 */ }
|
|
4301
|
+
|
|
4302
|
+
// 右侧 Dock + Toast + 输入卡片(shell.overlay 为 list 槽位,可叠加)
|
|
4303
|
+
ctx.slots.inject("shell.overlay", () => {
|
|
4304
|
+
const d1 = ctx.slots.register(
|
|
4305
|
+
{ name: "shell.overlay", id: "obsidian-dock", order: 30, label: "Obsidian note dock", locale: NS, inject: () => ({ workspaces }) },
|
|
4306
|
+
Dock,
|
|
4307
|
+
);
|
|
4308
|
+
const d2 = ctx.slots.register(
|
|
4309
|
+
{ name: "shell.overlay", id: "obsidian-toast", order: 5, label: "Obsidian toast", locale: NS },
|
|
4310
|
+
Toast,
|
|
4311
|
+
);
|
|
4312
|
+
const d3 = ctx.slots.register(
|
|
4313
|
+
{ name: "shell.overlay", id: "obsidian-prompt", order: 6, label: "Obsidian text prompt", locale: NS },
|
|
4314
|
+
Prompt,
|
|
4315
|
+
);
|
|
4316
|
+
return () => {
|
|
4317
|
+
d1();
|
|
4318
|
+
d2();
|
|
4319
|
+
d3();
|
|
4320
|
+
};
|
|
4321
|
+
});
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
exports.apply = apply;
|
|
4325
|
+
exports.inject = inject;
|
|
4326
|
+
})();
|
|
4327
|
+
|
|
4328
|
+
return module.exports;
|
|
4329
|
+
}
|
|
4330
|
+
});
|