finch-markdown-editor 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/codemirror.js +37 -29
- package/dist/index.js +480 -180
- package/dist/panel.css +207 -0
- package/dist/panel.html +33 -2539
- package/dist/panel.js +43 -0
- package/i18n/en-US.json +3 -0
- package/i18n/zh-CN.json +4 -1
- package/package.json +13 -4
package/dist/index.js
CHANGED
|
@@ -1,11 +1,186 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdir, readFile, realpath,
|
|
2
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3
|
+
import { mkdir as mkdir2, readFile as readFile2, realpath, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
4
4
|
import { watch } from "node:fs";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
6
|
+
import path3 from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
|
|
9
|
+
// src/document.ts
|
|
6
10
|
import path from "node:path";
|
|
11
|
+
function unwrapTextEnvelope(content) {
|
|
12
|
+
const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
13
|
+
const text = bom ? content.slice(1) : content;
|
|
14
|
+
const firstCrLf = text.indexOf("\r\n");
|
|
15
|
+
const firstLf = text.indexOf("\n");
|
|
16
|
+
const lineEnding = firstCrLf !== -1 && (firstLf === -1 || firstCrLf <= firstLf) ? "\r\n" : "\n";
|
|
17
|
+
return { bom, lineEnding, text: text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") };
|
|
18
|
+
}
|
|
19
|
+
function normalizeToLf(text) {
|
|
20
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
21
|
+
}
|
|
22
|
+
function rewrapTextEnvelope(envelope, normalizedText) {
|
|
23
|
+
const restored = envelope.lineEnding === "\r\n" ? normalizedText.replace(/\n/g, "\r\n") : normalizedText;
|
|
24
|
+
return envelope.bom + restored;
|
|
25
|
+
}
|
|
26
|
+
function preserveTextEnvelope(existingContent, replacement) {
|
|
27
|
+
const envelope = unwrapTextEnvelope(existingContent);
|
|
28
|
+
return rewrapTextEnvelope(envelope, normalizeToLf(replacement.replace(/^\ufeff/, "")));
|
|
29
|
+
}
|
|
30
|
+
function applyEditSpecs(content, edits) {
|
|
31
|
+
const envelope = unwrapTextEnvelope(content);
|
|
32
|
+
let working = envelope.text;
|
|
33
|
+
for (let i = 0; i < edits.length; i++) {
|
|
34
|
+
const { old_string: rawOldString, new_string: rawNewString, replace_all: replaceAll } = edits[i];
|
|
35
|
+
if (typeof rawOldString !== "string" || rawOldString.length === 0) {
|
|
36
|
+
return { ok: false, error: `edits[${i}]: 'old_string' must be a non-empty string.` };
|
|
37
|
+
}
|
|
38
|
+
if (typeof rawNewString !== "string") {
|
|
39
|
+
return { ok: false, error: `edits[${i}]: 'new_string' must be a string.` };
|
|
40
|
+
}
|
|
41
|
+
const oldString = normalizeToLf(rawOldString);
|
|
42
|
+
const newString = normalizeToLf(rawNewString);
|
|
43
|
+
if (oldString === newString) {
|
|
44
|
+
return { ok: false, error: `edits[${i}]: 'old_string' and 'new_string' are identical \u2014 nothing to change.` };
|
|
45
|
+
}
|
|
46
|
+
const occurrences = working.split(oldString).length - 1;
|
|
47
|
+
if (occurrences === 0) {
|
|
48
|
+
return { ok: false, error: `edits[${i}]: 'old_string' was not found in the file's current content. Check the exact text (including whitespace/line breaks) \u2014 the file may differ from what you last saw.` };
|
|
49
|
+
}
|
|
50
|
+
if (occurrences > 1 && !replaceAll) {
|
|
51
|
+
return { ok: false, error: `edits[${i}]: 'old_string' matches ${occurrences} places in the file. Include more surrounding context to make it unique, or set 'replace_all: true' to replace every match.` };
|
|
52
|
+
}
|
|
53
|
+
if (replaceAll) {
|
|
54
|
+
working = working.split(oldString).join(newString);
|
|
55
|
+
} else {
|
|
56
|
+
const index = working.indexOf(oldString);
|
|
57
|
+
working = working.slice(0, index) + newString + working.slice(index + oldString.length);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, content: rewrapTextEnvelope(envelope, working) };
|
|
61
|
+
}
|
|
62
|
+
function documentTitle(markdown, filePath) {
|
|
63
|
+
const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
64
|
+
return heading || (filePath ? path.basename(filePath, path.extname(filePath)) : "Untitled article");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/drafts.ts
|
|
68
|
+
import { createHash } from "node:crypto";
|
|
69
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
70
|
+
import path2 from "node:path";
|
|
71
|
+
function draftPathFor(ctx, sourcePath) {
|
|
72
|
+
const digest = createHash("sha256").update(sourcePath).digest("hex").slice(0, 32);
|
|
73
|
+
return path2.join(ctx.storagePath, "drafts", `${digest}.json`);
|
|
74
|
+
}
|
|
75
|
+
function hashText(text) {
|
|
76
|
+
return createHash("sha256").update(text).digest("hex");
|
|
77
|
+
}
|
|
78
|
+
async function readDraft(ctx, sourcePath) {
|
|
79
|
+
try {
|
|
80
|
+
const raw = JSON.parse(await readFile(draftPathFor(ctx, sourcePath), "utf8"));
|
|
81
|
+
return raw && raw.path === sourcePath && typeof raw.markdown === "string" ? raw : void 0;
|
|
82
|
+
} catch {
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function writeDraft(ctx, sourcePath, markdown, base) {
|
|
87
|
+
try {
|
|
88
|
+
const file = draftPathFor(ctx, sourcePath);
|
|
89
|
+
await mkdir(path2.dirname(file), { recursive: true });
|
|
90
|
+
const entry = { path: sourcePath, markdown, savedAt: Date.now() };
|
|
91
|
+
if (typeof base === "string") entry.baseHash = hashText(base);
|
|
92
|
+
await writeFile(file, JSON.stringify(entry), "utf8");
|
|
93
|
+
} catch (error) {
|
|
94
|
+
ctx.logger.warn(`Could not persist draft for ${sourcePath}: ${String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function deleteDraft(ctx, sourcePath) {
|
|
98
|
+
await rm(draftPathFor(ctx, sourcePath), { force: true }).catch(() => {
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
var DRAFT_WRITE_DEBOUNCE_MS = 600;
|
|
102
|
+
var pendingDraftWrites = /* @__PURE__ */ new Map();
|
|
103
|
+
function scheduleDraftWrite(ctx, panelId, sourcePath, markdown, base) {
|
|
104
|
+
const existing = pendingDraftWrites.get(panelId);
|
|
105
|
+
if (existing?.timer) clearTimeout(existing.timer);
|
|
106
|
+
const entry = { path: sourcePath, markdown, base, timer: null };
|
|
107
|
+
entry.timer = setTimeout(() => {
|
|
108
|
+
entry.timer = null;
|
|
109
|
+
pendingDraftWrites.delete(panelId);
|
|
110
|
+
void writeDraft(ctx, sourcePath, markdown, base);
|
|
111
|
+
}, DRAFT_WRITE_DEBOUNCE_MS);
|
|
112
|
+
pendingDraftWrites.set(panelId, entry);
|
|
113
|
+
}
|
|
114
|
+
function flushPendingDraftWrite(ctx, panelId) {
|
|
115
|
+
const entry = pendingDraftWrites.get(panelId);
|
|
116
|
+
if (!entry) return;
|
|
117
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
118
|
+
pendingDraftWrites.delete(panelId);
|
|
119
|
+
void writeDraft(ctx, entry.path, entry.markdown, entry.base);
|
|
120
|
+
}
|
|
121
|
+
function cancelPendingDraftWrite(panelId) {
|
|
122
|
+
const entry = pendingDraftWrites.get(panelId);
|
|
123
|
+
if (entry?.timer) clearTimeout(entry.timer);
|
|
124
|
+
pendingDraftWrites.delete(panelId);
|
|
125
|
+
}
|
|
126
|
+
async function readFileWithDraft(ctx, sourcePath) {
|
|
127
|
+
const diskMarkdown = await readFile(sourcePath, "utf8");
|
|
128
|
+
const draft = await readDraft(ctx, sourcePath);
|
|
129
|
+
if (!draft || draft.markdown === diskMarkdown) return { markdown: diskMarkdown, diskMarkdown, draftRestored: false, draftConflict: false };
|
|
130
|
+
const diskMatchesBaseline = draft.baseHash === void 0 || draft.baseHash === hashText(diskMarkdown);
|
|
131
|
+
if (diskMatchesBaseline) return { markdown: draft.markdown, diskMarkdown, draftRestored: true, draftConflict: false };
|
|
132
|
+
return { markdown: diskMarkdown, diskMarkdown, draftRestored: false, draftConflict: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/renderer.ts
|
|
136
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
137
|
+
import { spawn } from "node:child_process";
|
|
7
138
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
|
|
139
|
+
var BMMD_BIN_PATH = fileURLToPath(new URL("./bmmd/bin/bmmd.mjs", import.meta.url));
|
|
140
|
+
async function runBmmd(args, input) {
|
|
141
|
+
const binPath = BMMD_BIN_PATH;
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const child = spawn(process.execPath, [binPath, ...args], { stdio: ["pipe", "pipe", "pipe"] });
|
|
144
|
+
let output = "";
|
|
145
|
+
let errors = "";
|
|
146
|
+
child.stdout.setEncoding("utf8");
|
|
147
|
+
child.stderr.setEncoding("utf8");
|
|
148
|
+
child.stdout.on("data", (chunk) => {
|
|
149
|
+
output += chunk;
|
|
150
|
+
});
|
|
151
|
+
child.stderr.on("data", (chunk) => {
|
|
152
|
+
errors += chunk;
|
|
153
|
+
});
|
|
154
|
+
child.on("error", reject);
|
|
155
|
+
child.on("close", (code) => {
|
|
156
|
+
if (code === 0) resolve(output);
|
|
157
|
+
else reject(new Error(errors.trim() || `bmmd exited with code ${code}`));
|
|
158
|
+
});
|
|
159
|
+
child.stdin.end(input, "utf8");
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
var FINCH_FILE_IMAGE_RE = /finch-file:\/\/local\?path=[^\s)"']+/g;
|
|
163
|
+
var FINCH_IMAGE_PLACEHOLDER_ORIGIN = "https://finch-local.invalid/markdown-image/";
|
|
164
|
+
function substituteFinchFileImagesForBm(markdown) {
|
|
165
|
+
const urls = /* @__PURE__ */ new Map();
|
|
166
|
+
let sequence = 0;
|
|
167
|
+
const substituted = markdown.replace(FINCH_FILE_IMAGE_RE, (originalUrl) => {
|
|
168
|
+
const placeholder = `${FINCH_IMAGE_PLACEHOLDER_ORIGIN}${createHash2("sha256").update(`${originalUrl}:${sequence++}`).digest("hex")}`;
|
|
169
|
+
urls.set(placeholder, originalUrl);
|
|
170
|
+
return placeholder;
|
|
171
|
+
});
|
|
172
|
+
return { markdown: substituted, urls };
|
|
173
|
+
}
|
|
174
|
+
async function renderWithBm(markdown, markdownStyle, customCss) {
|
|
175
|
+
const args = ["render", "--platform", "wechat", "--markdown-style", markdownStyle || "kami"];
|
|
176
|
+
if (customCss && customCss.trim()) args.push("--custom-css", customCss);
|
|
177
|
+
const prepared = substituteFinchFileImagesForBm(markdown);
|
|
178
|
+
let html = await runBmmd(args, prepared.markdown);
|
|
179
|
+
for (const [placeholder, originalUrl] of prepared.urls) html = html.split(placeholder).join(originalUrl);
|
|
180
|
+
return html;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/index.ts
|
|
9
184
|
var PASTE_IMAGE_EXT = {
|
|
10
185
|
"image/png": "png",
|
|
11
186
|
"image/jpeg": "jpg",
|
|
@@ -27,19 +202,19 @@ var IMAGE_MIME_BY_EXT = {
|
|
|
27
202
|
var MAX_CLIPBOARD_INLINE_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
28
203
|
async function readClipboardImageDataUrls(ctx, urls) {
|
|
29
204
|
const result2 = {};
|
|
30
|
-
const assetsRoot = await realpath(
|
|
205
|
+
const assetsRoot = await realpath(path3.join(ctx.storagePath, "assets"));
|
|
31
206
|
for (const originalUrl of urls.slice(0, 30)) {
|
|
32
207
|
try {
|
|
33
208
|
const parsed = new URL(originalUrl);
|
|
34
209
|
const requested = parsed.protocol === "finch-file:" && parsed.hostname === "local" ? parsed.searchParams.get("path") : null;
|
|
35
210
|
if (!requested) continue;
|
|
36
211
|
const target = await realpath(requested);
|
|
37
|
-
const relative =
|
|
38
|
-
const mimeType = IMAGE_MIME_BY_EXT[
|
|
39
|
-
if ((!relative || !relative.startsWith(".." +
|
|
212
|
+
const relative = path3.relative(assetsRoot, target);
|
|
213
|
+
const mimeType = IMAGE_MIME_BY_EXT[path3.extname(target).toLowerCase()];
|
|
214
|
+
if ((!relative || !relative.startsWith(".." + path3.sep) && relative !== "..") && mimeType) {
|
|
40
215
|
const info = await stat(target);
|
|
41
216
|
if (info.size > MAX_CLIPBOARD_INLINE_IMAGE_BYTES) continue;
|
|
42
|
-
result2[originalUrl] = `data:${mimeType};base64,${(await
|
|
217
|
+
result2[originalUrl] = `data:${mimeType};base64,${(await readFile2(target)).toString("base64")}`;
|
|
43
218
|
}
|
|
44
219
|
} catch {
|
|
45
220
|
}
|
|
@@ -47,7 +222,7 @@ async function readClipboardImageDataUrls(ctx, urls) {
|
|
|
47
222
|
return result2;
|
|
48
223
|
}
|
|
49
224
|
async function openLocalImagePreview(ctx, filePath) {
|
|
50
|
-
if (!
|
|
225
|
+
if (!path3.isAbsolute(filePath) || !IMAGE_MIME_BY_EXT[path3.extname(filePath).toLowerCase()]) {
|
|
51
226
|
throw new Error("Unsupported local image file.");
|
|
52
227
|
}
|
|
53
228
|
await ctx.ui.openFilePreview(filePath);
|
|
@@ -64,15 +239,11 @@ var STYLE_SLOT_COUNT = 3;
|
|
|
64
239
|
function result(message, isError = false) {
|
|
65
240
|
return { content: [{ type: "text", text: message }], isError };
|
|
66
241
|
}
|
|
67
|
-
function documentTitle(markdown, filePath) {
|
|
68
|
-
const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
69
|
-
return heading || (filePath ? path.basename(filePath, path.extname(filePath)) : "Untitled article");
|
|
70
|
-
}
|
|
71
242
|
function stateFile(ctx) {
|
|
72
|
-
return
|
|
243
|
+
return path3.join(ctx.storagePath, "state.json");
|
|
73
244
|
}
|
|
74
245
|
function styleSlotsFile(ctx) {
|
|
75
|
-
return
|
|
246
|
+
return path3.join(ctx.storagePath, "style-slots.json");
|
|
76
247
|
}
|
|
77
248
|
function normalizeStyleSlots(raw) {
|
|
78
249
|
const arr = Array.isArray(raw) ? raw : [];
|
|
@@ -89,7 +260,7 @@ function normalizeStyleSlots(raw) {
|
|
|
89
260
|
}
|
|
90
261
|
async function readStyleSlots(ctx) {
|
|
91
262
|
try {
|
|
92
|
-
const raw = await
|
|
263
|
+
const raw = await readFile2(styleSlotsFile(ctx), "utf8");
|
|
93
264
|
return normalizeStyleSlots(JSON.parse(raw));
|
|
94
265
|
} catch {
|
|
95
266
|
return normalizeStyleSlots([]);
|
|
@@ -98,97 +269,104 @@ async function readStyleSlots(ctx) {
|
|
|
98
269
|
async function writeStyleSlot(ctx, slot, value) {
|
|
99
270
|
const slots = await readStyleSlots(ctx);
|
|
100
271
|
slots[slot] = value;
|
|
101
|
-
await
|
|
102
|
-
await
|
|
272
|
+
await mkdir2(ctx.storagePath, { recursive: true });
|
|
273
|
+
await writeFile2(styleSlotsFile(ctx), JSON.stringify(slots), "utf8");
|
|
103
274
|
return slots;
|
|
104
275
|
}
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
return path.join(ctx.storagePath, "drafts", `${digest}.json`);
|
|
108
|
-
}
|
|
109
|
-
function hashText(text) {
|
|
110
|
-
return createHash("sha256").update(text).digest("hex");
|
|
276
|
+
function sessionBucketKey(panel) {
|
|
277
|
+
return panel.sessionId || "__global__";
|
|
111
278
|
}
|
|
112
|
-
async function
|
|
279
|
+
async function readLastPathState(ctx) {
|
|
113
280
|
try {
|
|
114
|
-
const raw =
|
|
115
|
-
return
|
|
281
|
+
const raw = await readFile2(stateFile(ctx), "utf8");
|
|
282
|
+
return JSON.parse(raw);
|
|
116
283
|
} catch {
|
|
117
|
-
return
|
|
284
|
+
return {};
|
|
118
285
|
}
|
|
119
286
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const entry = { path: sourcePath, markdown, savedAt: Date.now() };
|
|
125
|
-
if (typeof base === "string") entry.baseHash = hashText(base);
|
|
126
|
-
await writeFile(file, JSON.stringify(entry), "utf8");
|
|
127
|
-
} catch (error) {
|
|
128
|
-
ctx.logger.warn(`Could not persist draft for ${sourcePath}: ${String(error)}`);
|
|
287
|
+
function resolveRecentScope(state, cwd, sessionId, spaceId) {
|
|
288
|
+
if (path3.isAbsolute(cwd)) {
|
|
289
|
+
if (sessionId && !spaceId) state.homePath = cwd;
|
|
290
|
+
return { scope: cwd };
|
|
129
291
|
}
|
|
292
|
+
if (!cwd && !sessionId && !spaceId && path3.isAbsolute(state.homePath ?? "")) {
|
|
293
|
+
return { scope: state.homePath, fallbackCwd: state.homePath };
|
|
294
|
+
}
|
|
295
|
+
return {};
|
|
130
296
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
function scheduleDraftWrite(ctx, panelId, sourcePath, markdown, base) {
|
|
138
|
-
const existing = pendingDraftWrites.get(panelId);
|
|
139
|
-
if (existing?.timer) clearTimeout(existing.timer);
|
|
140
|
-
const entry = { path: sourcePath, markdown, base, timer: null };
|
|
141
|
-
entry.timer = setTimeout(() => {
|
|
142
|
-
entry.timer = null;
|
|
143
|
-
pendingDraftWrites.delete(panelId);
|
|
144
|
-
void writeDraft(ctx, sourcePath, markdown, base);
|
|
145
|
-
}, DRAFT_WRITE_DEBOUNCE_MS);
|
|
146
|
-
pendingDraftWrites.set(panelId, entry);
|
|
297
|
+
function addRecentPath(state, scope, sourcePath) {
|
|
298
|
+
const existing = state.recentPathsByScope?.[scope] ?? [];
|
|
299
|
+
state.recentPathsByScope = {
|
|
300
|
+
...state.recentPathsByScope,
|
|
301
|
+
[scope]: [sourcePath, ...existing].filter((value, index, values) => path3.isAbsolute(value) && values.indexOf(value) === index).slice(0, 50)
|
|
302
|
+
};
|
|
147
303
|
}
|
|
148
|
-
function
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
if (entry.timer) clearTimeout(entry.timer);
|
|
152
|
-
pendingDraftWrites.delete(panelId);
|
|
153
|
-
void writeDraft(ctx, entry.path, entry.markdown, entry.base);
|
|
304
|
+
function pathBelongsTo(root, target) {
|
|
305
|
+
const relative = path3.relative(root, target);
|
|
306
|
+
return relative === "" || !relative.startsWith(`..${path3.sep}`) && relative !== ".." && !path3.isAbsolute(relative);
|
|
154
307
|
}
|
|
155
|
-
function
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
308
|
+
async function resolveDocumentScope(ctx, sourcePath, fallbackCwd) {
|
|
309
|
+
const spaces = await ctx.spaces.list().catch(() => []);
|
|
310
|
+
const matches = spaces.filter((space2) => path3.isAbsolute(space2.directoryPath ?? "") && pathBelongsTo(space2.directoryPath, sourcePath)).sort((a, b) => (b.directoryPath?.length ?? 0) - (a.directoryPath?.length ?? 0));
|
|
311
|
+
const space = matches[0];
|
|
312
|
+
if (space?.directoryPath) {
|
|
313
|
+
const spaceName = space.name || space.alias || path3.basename(space.directoryPath);
|
|
314
|
+
return { scope: space.directoryPath, spaceId: space.id, spaceName, scopeLabel: spaceName, scopeKind: "space" };
|
|
315
|
+
}
|
|
316
|
+
const workspaceRoot = ctx.workspace.projectPath;
|
|
317
|
+
if (workspaceRoot && pathBelongsTo(workspaceRoot, sourcePath)) {
|
|
318
|
+
return { scope: workspaceRoot, scopeKind: "workspace" };
|
|
319
|
+
}
|
|
320
|
+
if (fallbackCwd && path3.isAbsolute(fallbackCwd) && pathBelongsTo(fallbackCwd, sourcePath)) {
|
|
321
|
+
return { scope: fallbackCwd, scopeKind: "workspace" };
|
|
322
|
+
}
|
|
323
|
+
return { scope: path3.dirname(sourcePath), scopeKind: "external" };
|
|
170
324
|
}
|
|
171
|
-
async function
|
|
325
|
+
async function rememberPanelRecentScope(ctx, panel, cwd, sessionId, spaceId) {
|
|
172
326
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
327
|
+
await mkdir2(ctx.storagePath, { recursive: true });
|
|
328
|
+
const state = await readLastPathState(ctx);
|
|
329
|
+
const resolved = resolveRecentScope(state, cwd, sessionId, spaceId);
|
|
330
|
+
if (resolved.scope) {
|
|
331
|
+
state.panelRecentScopes = { ...state.panelRecentScopes, [panel.id]: resolved.scope };
|
|
332
|
+
const panelPath = state.panels?.[panel.id];
|
|
333
|
+
if (panelPath) addRecentPath(state, resolved.scope, panelPath);
|
|
334
|
+
}
|
|
335
|
+
await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
|
|
336
|
+
return resolved;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
ctx.logger.warn(`Could not persist panel recent scope: ${String(error)}`);
|
|
176
339
|
return {};
|
|
177
340
|
}
|
|
178
341
|
}
|
|
179
342
|
async function rememberLastPath(ctx, panel, sourcePath) {
|
|
180
343
|
try {
|
|
181
|
-
await
|
|
344
|
+
await mkdir2(ctx.storagePath, { recursive: true });
|
|
182
345
|
const state = await readLastPathState(ctx);
|
|
183
|
-
const legacyPaths = [...Object.values(state.panels ?? {}), ...Object.values(state.sessions ?? {})];
|
|
184
|
-
state.recentPaths = [sourcePath, ...state.recentPaths ?? [], ...legacyPaths].filter((value, index, values) => path.isAbsolute(value) && values.indexOf(value) === index).slice(0, 50);
|
|
185
346
|
state.panels = { ...state.panels, [panel.id]: sourcePath };
|
|
186
347
|
state.sessions = { ...state.sessions, [sessionBucketKey(panel)]: sourcePath };
|
|
187
|
-
|
|
348
|
+
let scope = state.panelRecentScopes?.[panel.id];
|
|
349
|
+
if (!scope && panel.view === "appView") {
|
|
350
|
+
scope = (await resolveDocumentScope(ctx, sourcePath)).scope;
|
|
351
|
+
state.panelRecentScopes = { ...state.panelRecentScopes, [panel.id]: scope };
|
|
352
|
+
}
|
|
353
|
+
if (scope) addRecentPath(state, scope, sourcePath);
|
|
354
|
+
await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
|
|
188
355
|
} catch (error) {
|
|
189
356
|
ctx.logger.warn(`Could not persist last-opened path: ${String(error)}`);
|
|
190
357
|
}
|
|
191
358
|
}
|
|
359
|
+
async function rememberRecentPath(ctx, sourcePath, panel) {
|
|
360
|
+
try {
|
|
361
|
+
await mkdir2(ctx.storagePath, { recursive: true });
|
|
362
|
+
const state = await readLastPathState(ctx);
|
|
363
|
+
const scope = panel ? state.panelRecentScopes?.[panel.id] : resolveRecentScope(state, ctx.session.cwd ?? "", ctx.session.id ?? "", ctx.session.spaceId ?? "").scope;
|
|
364
|
+
if (scope) addRecentPath(state, scope, sourcePath);
|
|
365
|
+
await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
|
|
366
|
+
} catch (error) {
|
|
367
|
+
ctx.logger.warn(`Could not persist recent path: ${String(error)}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
192
370
|
async function readLastPath(ctx, panel) {
|
|
193
371
|
const state = await readLastPathState(ctx);
|
|
194
372
|
const perPanel = state.panels?.[panel.id];
|
|
@@ -204,10 +382,6 @@ var RECENT_PREVIEW_CHARS = 220;
|
|
|
204
382
|
function isMarkdownPath(filePath) {
|
|
205
383
|
return /\.(md|markdown|mdown|mkd)$/i.test(filePath);
|
|
206
384
|
}
|
|
207
|
-
function isInsideDirectory(filePath, directory) {
|
|
208
|
-
const relative = path.relative(directory, filePath);
|
|
209
|
-
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
210
|
-
}
|
|
211
385
|
function deriveTitle(markdown, fallback) {
|
|
212
386
|
for (const line of markdown.split("\n", 60)) {
|
|
213
387
|
const heading = line.match(/^\s{0,3}#{1,6}\s+(.*\S)\s*$/);
|
|
@@ -222,26 +396,25 @@ function deriveTitle(markdown, fallback) {
|
|
|
222
396
|
function derivePreview(markdown) {
|
|
223
397
|
return markdown.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/```[\s\S]*?```/g, " ").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^\s{0,3}#{1,6}\s+/gm, "").replace(/[*_`>~]/g, "").replace(/\s+/g, " ").trim().slice(0, RECENT_PREVIEW_CHARS);
|
|
224
398
|
}
|
|
225
|
-
async function collectRecentDocuments(ctx,
|
|
226
|
-
|
|
399
|
+
async function collectRecentDocuments(ctx, requestedCwd, sessionId, spaceId) {
|
|
400
|
+
const cwd = requestedCwd;
|
|
227
401
|
const state = await readLastPathState(ctx);
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
(value, index, values) => typeof value === "string" && path.isAbsolute(value) && isMarkdownPath(value) && isInsideDirectory(value, cwd) && values.indexOf(value) === index
|
|
402
|
+
const resolved = resolveRecentScope(state, cwd, sessionId, spaceId);
|
|
403
|
+
if (!resolved.scope) return { documents: [] };
|
|
404
|
+
const scope = resolved.scope;
|
|
405
|
+
const candidates = (state.recentPathsByScope?.[scope] ?? []).filter(
|
|
406
|
+
(value, index, values) => typeof value === "string" && path3.isAbsolute(value) && isMarkdownPath(value) && values.indexOf(value) === index
|
|
234
407
|
);
|
|
235
408
|
const documents = await Promise.all(
|
|
236
409
|
candidates.map(async (filePath) => {
|
|
237
410
|
try {
|
|
238
411
|
const info = await stat(filePath);
|
|
239
412
|
if (!info.isFile()) return void 0;
|
|
240
|
-
const markdown = await
|
|
241
|
-
const fileName =
|
|
413
|
+
const markdown = await readFile2(filePath, "utf8");
|
|
414
|
+
const fileName = path3.basename(filePath);
|
|
242
415
|
return {
|
|
243
416
|
path: filePath,
|
|
244
|
-
relativePath:
|
|
417
|
+
relativePath: path3.relative(scope, filePath),
|
|
245
418
|
fileName,
|
|
246
419
|
title: deriveTitle(markdown, fileName.replace(/\.[^.]+$/, "")),
|
|
247
420
|
preview: derivePreview(markdown),
|
|
@@ -252,6 +425,35 @@ async function collectRecentDocuments(ctx, cwd) {
|
|
|
252
425
|
}
|
|
253
426
|
})
|
|
254
427
|
);
|
|
428
|
+
return {
|
|
429
|
+
documents: documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, RECENT_LIMIT),
|
|
430
|
+
fallbackCwd: resolved.fallbackCwd
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
async function collectLibraryDocuments(ctx) {
|
|
434
|
+
const state = await readLastPathState(ctx);
|
|
435
|
+
const candidates = Object.values(state.recentPathsByScope ?? {}).flat().concat(Object.values(state.panels ?? {})).filter((value, index, values) => path3.isAbsolute(value) && isMarkdownPath(value) && values.indexOf(value) === index);
|
|
436
|
+
const documents = await Promise.all(candidates.map(async (filePath) => {
|
|
437
|
+
try {
|
|
438
|
+
const [info, markdown, scope] = await Promise.all([stat(filePath), readFile2(filePath, "utf8"), resolveDocumentScope(ctx, filePath, state.homePath)]);
|
|
439
|
+
if (!info.isFile()) return void 0;
|
|
440
|
+
const fileName = path3.basename(filePath);
|
|
441
|
+
return {
|
|
442
|
+
path: filePath,
|
|
443
|
+
relativePath: path3.relative(scope.scope, filePath),
|
|
444
|
+
fileName,
|
|
445
|
+
title: deriveTitle(markdown, fileName.replace(/\.[^.]+$/, "")),
|
|
446
|
+
preview: derivePreview(markdown),
|
|
447
|
+
modifiedAt: info.mtimeMs,
|
|
448
|
+
spaceId: scope.spaceId,
|
|
449
|
+
spaceName: scope.spaceName,
|
|
450
|
+
scopeLabel: scope.scopeLabel,
|
|
451
|
+
scopeKind: scope.scopeKind
|
|
452
|
+
};
|
|
453
|
+
} catch {
|
|
454
|
+
return void 0;
|
|
455
|
+
}
|
|
456
|
+
}));
|
|
255
457
|
return documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, RECENT_LIMIT);
|
|
256
458
|
}
|
|
257
459
|
var livePanelDocuments = /* @__PURE__ */ new Map();
|
|
@@ -260,7 +462,7 @@ async function sendDocument(panel, state) {
|
|
|
260
462
|
await panel.postMessage({ type: "document", ...state });
|
|
261
463
|
}
|
|
262
464
|
async function sendLiveDocument(ctx, panel, liveDocument) {
|
|
263
|
-
if (liveDocument.path &&
|
|
465
|
+
if (liveDocument.path && path3.isAbsolute(liveDocument.path)) {
|
|
264
466
|
try {
|
|
265
467
|
const { markdown, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, liveDocument.path);
|
|
266
468
|
await sendDocument(panel, { ...liveDocument, markdown, title: documentTitle(markdown, liveDocument.path), draftRestored, draftConflict, diskMarkdown });
|
|
@@ -275,10 +477,10 @@ function payloadPath(panel) {
|
|
|
275
477
|
const payload = panel.payload;
|
|
276
478
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return void 0;
|
|
277
479
|
const value = payload.path;
|
|
278
|
-
return typeof value === "string" &&
|
|
480
|
+
return typeof value === "string" && path3.isAbsolute(value) ? value : void 0;
|
|
279
481
|
}
|
|
280
482
|
async function restoreDocument(ctx, panel) {
|
|
281
|
-
if (!panel.sessionId) return false;
|
|
483
|
+
if (!panel.sessionId && panel.view !== "appView") return false;
|
|
282
484
|
const sourcePath = await readLastPath(ctx, panel) ?? payloadPath(panel);
|
|
283
485
|
if (!sourcePath) return false;
|
|
284
486
|
try {
|
|
@@ -292,49 +494,6 @@ async function restoreDocument(ctx, panel) {
|
|
|
292
494
|
return false;
|
|
293
495
|
}
|
|
294
496
|
}
|
|
295
|
-
var BMMD_BIN_PATH = fileURLToPath(new URL("./bmmd/bin/bmmd.mjs", import.meta.url));
|
|
296
|
-
async function runBmmd(args, input) {
|
|
297
|
-
const binPath = BMMD_BIN_PATH;
|
|
298
|
-
return new Promise((resolve, reject) => {
|
|
299
|
-
const child = spawn(process.execPath, [binPath, ...args], { stdio: ["pipe", "pipe", "pipe"] });
|
|
300
|
-
let output = "";
|
|
301
|
-
let errors = "";
|
|
302
|
-
child.stdout.setEncoding("utf8");
|
|
303
|
-
child.stderr.setEncoding("utf8");
|
|
304
|
-
child.stdout.on("data", (chunk) => {
|
|
305
|
-
output += chunk;
|
|
306
|
-
});
|
|
307
|
-
child.stderr.on("data", (chunk) => {
|
|
308
|
-
errors += chunk;
|
|
309
|
-
});
|
|
310
|
-
child.on("error", reject);
|
|
311
|
-
child.on("close", (code) => {
|
|
312
|
-
if (code === 0) resolve(output);
|
|
313
|
-
else reject(new Error(errors.trim() || `bmmd exited with code ${code}`));
|
|
314
|
-
});
|
|
315
|
-
child.stdin.end(input, "utf8");
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
var FINCH_FILE_IMAGE_RE = /finch-file:\/\/local\?path=[^\s)"']+/g;
|
|
319
|
-
var FINCH_IMAGE_PLACEHOLDER_ORIGIN = "https://finch-local.invalid/markdown-image/";
|
|
320
|
-
function substituteFinchFileImagesForBm(markdown) {
|
|
321
|
-
const urls = /* @__PURE__ */ new Map();
|
|
322
|
-
let sequence = 0;
|
|
323
|
-
const substituted = markdown.replace(FINCH_FILE_IMAGE_RE, (originalUrl) => {
|
|
324
|
-
const placeholder = `${FINCH_IMAGE_PLACEHOLDER_ORIGIN}${createHash("sha256").update(`${originalUrl}:${sequence++}`).digest("hex")}`;
|
|
325
|
-
urls.set(placeholder, originalUrl);
|
|
326
|
-
return placeholder;
|
|
327
|
-
});
|
|
328
|
-
return { markdown: substituted, urls };
|
|
329
|
-
}
|
|
330
|
-
async function renderWithBm(markdown, markdownStyle, customCss) {
|
|
331
|
-
const args = ["render", "--platform", "wechat", "--markdown-style", markdownStyle || "kami"];
|
|
332
|
-
if (customCss && customCss.trim()) args.push("--custom-css", customCss);
|
|
333
|
-
const prepared = substituteFinchFileImagesForBm(markdown);
|
|
334
|
-
let html = await runBmmd(args, prepared.markdown);
|
|
335
|
-
for (const [placeholder, originalUrl] of prepared.urls) html = html.split(placeholder).join(originalUrl);
|
|
336
|
-
return html;
|
|
337
|
-
}
|
|
338
497
|
var panelWatchers = /* @__PURE__ */ new Map();
|
|
339
498
|
var lastPanel;
|
|
340
499
|
function stopWatching(panelId) {
|
|
@@ -353,7 +512,7 @@ function watchSource(ctx, panel, sourcePath) {
|
|
|
353
512
|
if (entry.timer) clearTimeout(entry.timer);
|
|
354
513
|
entry.timer = setTimeout(async () => {
|
|
355
514
|
try {
|
|
356
|
-
const markdown = await
|
|
515
|
+
const markdown = await readFile2(sourcePath, "utf8");
|
|
357
516
|
await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
|
|
358
517
|
} catch (error) {
|
|
359
518
|
ctx.logger.warn(`Source refresh failed: ${String(error)}`);
|
|
@@ -402,16 +561,98 @@ async function revealInFileManager(ctx, targetPath) {
|
|
|
402
561
|
const info = await stat(targetPath).catch(() => void 0);
|
|
403
562
|
const isFile = info?.isFile() ?? false;
|
|
404
563
|
if (process.platform === "darwin") {
|
|
405
|
-
|
|
564
|
+
spawn2("open", isFile ? ["-R", targetPath] : [targetPath], { stdio: "ignore", detached: true }).unref();
|
|
406
565
|
} else if (process.platform === "win32") {
|
|
407
|
-
|
|
566
|
+
spawn2("explorer", isFile ? [`/select,${targetPath}`] : [targetPath], { stdio: "ignore", detached: true }).unref();
|
|
408
567
|
} else {
|
|
409
|
-
|
|
568
|
+
spawn2("xdg-open", [isFile ? path3.dirname(targetPath) : targetPath], { stdio: "ignore", detached: true }).unref();
|
|
410
569
|
}
|
|
411
570
|
} catch (error) {
|
|
412
571
|
ctx.logger.warn(`Could not open file manager for ${targetPath}: ${String(error)}`);
|
|
413
572
|
}
|
|
414
573
|
}
|
|
574
|
+
async function readRewriteSession(ctx, sourcePath) {
|
|
575
|
+
const state = await readLastPathState(ctx);
|
|
576
|
+
const id = state.rewriteSessions?.[sourcePath];
|
|
577
|
+
if (!id) return void 0;
|
|
578
|
+
const session = await ctx.sessions.get(id).catch(() => void 0);
|
|
579
|
+
return session ? id : void 0;
|
|
580
|
+
}
|
|
581
|
+
async function rememberRewriteSession(ctx, sourcePath, sessionId) {
|
|
582
|
+
try {
|
|
583
|
+
const state = await readLastPathState(ctx);
|
|
584
|
+
state.rewriteSessions = { ...state.rewriteSessions, [sourcePath]: sessionId };
|
|
585
|
+
await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
|
|
586
|
+
} catch (error) {
|
|
587
|
+
ctx.logger.warn(`Could not persist rewrite session: ${String(error)}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async function startRewriteSession(ctx, panel, message) {
|
|
591
|
+
const sourcePath = String(message.path ?? "").trim();
|
|
592
|
+
const selectedText = String(message.selectedText ?? "").trim();
|
|
593
|
+
const rewriteMode = message.rewriteMode === "continue" ? "continue" : "replace";
|
|
594
|
+
const requirement = String(message.requirement ?? "").trim() || (rewriteMode === "continue" ? "\u81EA\u7136\u5730\u627F\u63A5\u4E0A\u4E0B\u6587\u7EE7\u7EED\u5199\u4F5C" : "\u8BA9\u8868\u8FBE\u66F4\u6E05\u6670\u3001\u81EA\u7136\uFF0C\u5E76\u4FDD\u6301\u539F\u610F");
|
|
595
|
+
const hasTarget = rewriteMode === "continue" ? !!message.startLine : !!selectedText;
|
|
596
|
+
if (panel.view !== "appView" || !path3.isAbsolute(sourcePath) || !hasTarget) {
|
|
597
|
+
await panel.postMessage({ type: "rewriteSessionFailed", message: "\u6539\u5199\u9700\u8981 App View \u4E2D\u5DF2\u4FDD\u5B58\u7684\u672C\u5730\u6587\u6863\u548C\u9009\u4E2D\u6587\u672C\u6216\u7EED\u5199\u4F4D\u7F6E\u3002" });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const scope = await resolveDocumentScope(ctx, sourcePath);
|
|
601
|
+
let sessionId = await readRewriteSession(ctx, sourcePath);
|
|
602
|
+
if (!sessionId) {
|
|
603
|
+
const session = await ctx.sessions.create({
|
|
604
|
+
...scope.spaceId ? { space: { spaceId: scope.spaceId } } : {},
|
|
605
|
+
title: `\u6539\u5199\uFF1A${path3.basename(sourcePath)}`,
|
|
606
|
+
activity: "interactive",
|
|
607
|
+
permissionMode: "acceptCalls"
|
|
608
|
+
});
|
|
609
|
+
sessionId = session.sessionId;
|
|
610
|
+
await rememberRewriteSession(ctx, sourcePath, sessionId);
|
|
611
|
+
}
|
|
612
|
+
const lineText = message.startLine ? `\u4F4D\u7F6E\uFF1A\u7B2C ${message.startLine}${message.endLine && message.endLine !== message.startLine ? `\u2013${message.endLine}` : ""} \u884C\u3002` : "";
|
|
613
|
+
const prompt = rewriteMode === "continue" ? `\u8BF7\u5728\u4E0B\u9762\u8FD9\u4EFD Markdown \u6587\u4EF6\u7684\u6307\u5B9A\u4F4D\u7F6E\u7EED\u5199\u5185\u5BB9\uFF0C\u5E76\u628A\u7ED3\u679C\u5199\u56DE\u6587\u4EF6\u3002
|
|
614
|
+
|
|
615
|
+
\u6587\u4EF6\uFF1A${sourcePath}
|
|
616
|
+
${lineText}
|
|
617
|
+
\u8981\u6C42\uFF1A${requirement}
|
|
618
|
+
|
|
619
|
+
\u8BF7\u8BFB\u53D6\u6587\u4EF6\u5F53\u524D\u5185\u5BB9\uFF1A\u7B2C ${message.startLine} \u884C\u5F53\u524D\u662F\u4E00\u4E2A\u7A7A\u884C\uFF0C\u8BF7\u628A\u7EED\u5199\u7684\u65B0\u5185\u5BB9\u76F4\u63A5\u5199\u5165\u8FD9\u4E00\u884C\u672C\u8EAB\uFF08\u628A\u8FD9\u4E2A\u7A7A\u884C\u66FF\u6362\u6210\u65B0\u5185\u5BB9\uFF09\uFF0C\u4E0D\u8981\u5728\u5B83\u524D\u540E\u989D\u5916\u63D2\u5165\u65B0\u7684\u7A7A\u884C\uFF0C\u4E5F\u4E0D\u8981\u6539\u52A8\u7B2C ${message.startLine} \u884C\u4E4B\u5916\u7684\u539F\u6709\u5185\u5BB9\uFF1B\u8C03\u7528 markdown_editor_document \u7684 apply\uFF0C\u4EE5 edits \u505A\u7CBE\u786E\u7684\u5C40\u90E8\u66FF\u6362\u3002\u4E0D\u8981\u53EA\u7ED9\u5EFA\u8BAE\uFF0C\u4E0D\u8981\u91CD\u53D1\u5168\u6587\uFF1B\u5B8C\u6210\u5199\u56DE\u540E\u7B80\u77ED\u8BF4\u660E\u3002` : `\u8BF7\u76F4\u63A5\u6539\u5199\u4E0B\u9762\u8FD9\u6BB5 Markdown\uFF0C\u5E76\u628A\u7ED3\u679C\u5199\u56DE\u6587\u4EF6\u3002
|
|
620
|
+
|
|
621
|
+
\u6587\u4EF6\uFF1A${sourcePath}
|
|
622
|
+
${lineText}
|
|
623
|
+
\u8981\u6C42\uFF1A${requirement}
|
|
624
|
+
|
|
625
|
+
\u539F\u6587\uFF1A
|
|
626
|
+
${selectedText}
|
|
627
|
+
|
|
628
|
+
\u8BF7\u8BFB\u53D6\u6587\u4EF6\u5F53\u524D\u5185\u5BB9\uFF0C\u8C03\u7528 markdown_editor_document \u7684 apply\uFF0C\u4EE5 edits \u505A\u552F\u4E00\u3001\u7CBE\u786E\u7684\u5C40\u90E8\u66FF\u6362\u3002\u4E0D\u8981\u53EA\u7ED9\u5EFA\u8BAE\uFF0C\u4E0D\u8981\u91CD\u53D1\u5168\u6587\uFF1B\u5B8C\u6210\u5199\u56DE\u540E\u7B80\u77ED\u8BF4\u660E\u3002`;
|
|
629
|
+
const receipt = await ctx.sessions.send(sessionId, {
|
|
630
|
+
text: prompt,
|
|
631
|
+
idempotencyKey: `rewrite-${createHash3("sha256").update(`${sourcePath}:${rewriteMode}:${selectedText}:${requirement}:${message.startLine ?? ""}:${Date.now()}`).digest("hex")}`
|
|
632
|
+
});
|
|
633
|
+
if (receipt.state === "rejected") {
|
|
634
|
+
await panel.postMessage({ type: "rewriteSessionFailed", message: "\u6539\u5199\u4F1A\u8BDD\u961F\u5217\u7E41\u5FD9\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
await panel.postMessage({
|
|
638
|
+
type: "rewriteSessionStarted",
|
|
639
|
+
sessionId,
|
|
640
|
+
spaceName: scope.spaceName,
|
|
641
|
+
title: `${rewriteMode === "continue" ? "\u7EED\u5199" : "\u6539\u5199"}\uFF1A${path3.basename(sourcePath)}`,
|
|
642
|
+
startLine: message.startLine,
|
|
643
|
+
endLine: message.endLine ?? message.startLine,
|
|
644
|
+
rewriteMode
|
|
645
|
+
});
|
|
646
|
+
void ctx.sessions.waitForTurn(sessionId, receipt.turnId, { timeoutMs: 6e5 }).then(async (result2) => {
|
|
647
|
+
const verb = rewriteMode === "continue" ? "\u7EED\u5199" : "\u6539\u5199";
|
|
648
|
+
await panel.postMessage({
|
|
649
|
+
type: result2.state === "completed" ? "rewriteSessionFinished" : "rewriteSessionFailed",
|
|
650
|
+
sessionId,
|
|
651
|
+
message: result2.state === "completed" ? `${verb}\u5DF2\u5B8C\u6210\u3002` : result2.state === "timeout" ? `${verb}\u4ECD\u5728\u4F1A\u8BDD\u4E2D\u7EE7\u7EED\u3002` : `${verb}\u4F1A\u8BDD\u672A\u5B8C\u6210\u3002`
|
|
652
|
+
}).catch(() => {
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
415
656
|
async function handleMessage(ctx, panel, raw) {
|
|
416
657
|
const message = raw;
|
|
417
658
|
switch (message.type) {
|
|
@@ -461,7 +702,8 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
461
702
|
try {
|
|
462
703
|
const handle = ctx.ui.pickFile({
|
|
463
704
|
title: "\u9009\u62E9Markdown\u6587\u4EF6",
|
|
464
|
-
filter: { extensions: [".md", ".markdown"] }
|
|
705
|
+
filter: { extensions: [".md", ".markdown"] },
|
|
706
|
+
allowSpaceSwitch: panel.view === "appView"
|
|
465
707
|
});
|
|
466
708
|
ctx.logger.info("ctx.ui.pickFile() call returned a handle, awaiting resolution\u2026");
|
|
467
709
|
const picked = await handle;
|
|
@@ -487,7 +729,7 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
487
729
|
}
|
|
488
730
|
case "loadPath": {
|
|
489
731
|
const sourcePath = String(message.path ?? "").trim();
|
|
490
|
-
if (!
|
|
732
|
+
if (!path3.isAbsolute(sourcePath)) {
|
|
491
733
|
await panel.postMessage({ type: "error", message: "Please provide an absolute Markdown path." });
|
|
492
734
|
return;
|
|
493
735
|
}
|
|
@@ -503,7 +745,7 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
503
745
|
}
|
|
504
746
|
case "watchPath": {
|
|
505
747
|
const sourcePath = String(message.path ?? "").trim();
|
|
506
|
-
if (!
|
|
748
|
+
if (!path3.isAbsolute(sourcePath)) return;
|
|
507
749
|
watchSource(ctx, panel, sourcePath);
|
|
508
750
|
await rememberLastPath(ctx, panel, sourcePath);
|
|
509
751
|
await panel.postMessage({ type: "watchStarted", path: sourcePath });
|
|
@@ -520,7 +762,7 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
520
762
|
}
|
|
521
763
|
case "openPath": {
|
|
522
764
|
const targetPath = String(message.path ?? "").trim();
|
|
523
|
-
if (targetPath &&
|
|
765
|
+
if (targetPath && path3.isAbsolute(targetPath)) await revealInFileManager(ctx, targetPath);
|
|
524
766
|
return;
|
|
525
767
|
}
|
|
526
768
|
case "goHome": {
|
|
@@ -530,19 +772,32 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
530
772
|
}
|
|
531
773
|
case "requestRecentDocuments": {
|
|
532
774
|
const cwd = String(message.cwd ?? "").trim();
|
|
775
|
+
const sessionId = String(message.sessionId ?? "").trim();
|
|
776
|
+
const spaceId = String(message.spaceId ?? "").trim();
|
|
533
777
|
try {
|
|
534
|
-
|
|
778
|
+
if (panel.view === "appView") {
|
|
779
|
+
const documents = await collectLibraryDocuments(ctx);
|
|
780
|
+
await panel.postMessage({ type: "recentDocuments", cwd, documents, library: true });
|
|
781
|
+
} else {
|
|
782
|
+
await rememberPanelRecentScope(ctx, panel, cwd, sessionId, spaceId);
|
|
783
|
+
const recent = await collectRecentDocuments(ctx, cwd, sessionId, spaceId);
|
|
784
|
+
await panel.postMessage({ type: "recentDocuments", cwd, documents: recent.documents, fallbackCwd: recent.fallbackCwd });
|
|
785
|
+
}
|
|
535
786
|
} catch (error) {
|
|
536
787
|
ctx.logger.warn(`Could not collect recent documents: ${String(error)}`);
|
|
537
|
-
await panel.postMessage({ type: "recentDocuments", cwd, documents: [] });
|
|
788
|
+
await panel.postMessage({ type: "recentDocuments", cwd, documents: [], library: panel.view === "appView" });
|
|
538
789
|
}
|
|
539
790
|
return;
|
|
540
791
|
}
|
|
792
|
+
case "requestRewrite": {
|
|
793
|
+
await startRewriteSession(ctx, panel, message);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
541
796
|
case "saveMarkdown": {
|
|
542
797
|
const sourcePath = String(message.path ?? "").trim();
|
|
543
|
-
if (!
|
|
798
|
+
if (!path3.isAbsolute(sourcePath)) return;
|
|
544
799
|
try {
|
|
545
|
-
await
|
|
800
|
+
await writeFile2(sourcePath, String(message.markdown ?? ""), "utf8");
|
|
546
801
|
cancelPendingDraftWrite(panel.id);
|
|
547
802
|
await deleteDraft(ctx, sourcePath);
|
|
548
803
|
await panel.postMessage({ type: "savedMarkdown", path: sourcePath, requestId: message.requestId });
|
|
@@ -553,14 +808,14 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
553
808
|
}
|
|
554
809
|
case "saveDraft": {
|
|
555
810
|
const sourcePath = String(message.path ?? "").trim();
|
|
556
|
-
if (!
|
|
811
|
+
if (!path3.isAbsolute(sourcePath)) return;
|
|
557
812
|
const base = typeof message.base === "string" ? message.base : void 0;
|
|
558
813
|
scheduleDraftWrite(ctx, panel.id, sourcePath, String(message.markdown ?? ""), base);
|
|
559
814
|
return;
|
|
560
815
|
}
|
|
561
816
|
case "discardDraft": {
|
|
562
817
|
const sourcePath = String(message.path ?? "").trim();
|
|
563
|
-
if (!
|
|
818
|
+
if (!path3.isAbsolute(sourcePath)) return;
|
|
564
819
|
cancelPendingDraftWrite(panel.id);
|
|
565
820
|
await deleteDraft(ctx, sourcePath);
|
|
566
821
|
return;
|
|
@@ -596,13 +851,16 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
596
851
|
case "applyReplacement": {
|
|
597
852
|
const sourcePath = String(message.path ?? "").trim();
|
|
598
853
|
const markdown = String(message.markdown ?? "");
|
|
599
|
-
if (!sourcePath || !
|
|
854
|
+
if (!sourcePath || !path3.isAbsolute(sourcePath)) {
|
|
600
855
|
await panel.postMessage({ type: "error", message: "Pasted documents can be revised in the editor, but source-file apply needs an absolute path." });
|
|
601
856
|
return;
|
|
602
857
|
}
|
|
603
858
|
try {
|
|
604
|
-
await
|
|
605
|
-
|
|
859
|
+
const current = await readFile2(sourcePath, "utf8");
|
|
860
|
+
const appliedMarkdown = preserveTextEnvelope(current, markdown);
|
|
861
|
+
await writeFile2(sourcePath, appliedMarkdown, "utf8");
|
|
862
|
+
await rememberRecentPath(ctx, sourcePath, panel);
|
|
863
|
+
await panel.postMessage({ type: "applied", path: sourcePath, title: documentTitle(appliedMarkdown, sourcePath) });
|
|
606
864
|
} catch (error) {
|
|
607
865
|
await panel.postMessage({ type: "error", message: `Could not apply revision: ${error instanceof Error ? error.message : String(error)}` });
|
|
608
866
|
}
|
|
@@ -626,12 +884,12 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
626
884
|
try {
|
|
627
885
|
const ext = PASTE_IMAGE_EXT[String(message.mimeType ?? "").toLowerCase()] ?? "png";
|
|
628
886
|
const buffer = Buffer.from(dataBase64, "base64");
|
|
629
|
-
const digest =
|
|
630
|
-
const dir =
|
|
631
|
-
await
|
|
632
|
-
const targetPath =
|
|
887
|
+
const digest = createHash3("sha256").update(buffer).digest("hex").slice(0, 20);
|
|
888
|
+
const dir = path3.join(ctx.storagePath, "assets");
|
|
889
|
+
await mkdir2(dir, { recursive: true });
|
|
890
|
+
const targetPath = path3.join(dir, `${digest}.${ext}`);
|
|
633
891
|
const alreadyExists = await stat(targetPath).then(() => true).catch(() => false);
|
|
634
|
-
if (!alreadyExists) await
|
|
892
|
+
if (!alreadyExists) await writeFile2(targetPath, buffer);
|
|
635
893
|
const url = `finch-file://local?path=${encodeURIComponent(targetPath)}`;
|
|
636
894
|
await panel.postMessage({ type: "pastedImage", requestId: message.requestId, url, path: targetPath });
|
|
637
895
|
} catch (error) {
|
|
@@ -664,15 +922,15 @@ async function handleMessage(ctx, panel, raw) {
|
|
|
664
922
|
}
|
|
665
923
|
try {
|
|
666
924
|
const sourcePath = String(message.path ?? "").trim();
|
|
667
|
-
let dir =
|
|
925
|
+
let dir = path3.isAbsolute(sourcePath) ? path3.dirname(sourcePath) : "";
|
|
668
926
|
if (!dir) {
|
|
669
|
-
const downloads =
|
|
927
|
+
const downloads = path3.join(os.homedir(), "Downloads");
|
|
670
928
|
dir = await stat(downloads).then((s) => s.isDirectory()).catch(() => false) ? downloads : ctx.storagePath;
|
|
671
929
|
}
|
|
672
930
|
const rawName = String(message.fileName ?? "").trim() || documentTitle(String(message.markdown ?? ""), sourcePath || void 0);
|
|
673
931
|
const safeName = rawName.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 120) || "article";
|
|
674
|
-
const targetPath =
|
|
675
|
-
await
|
|
932
|
+
const targetPath = path3.join(dir, `${safeName}.${ext}`);
|
|
933
|
+
await writeFile2(targetPath, Buffer.from(dataBase64, "base64"));
|
|
676
934
|
await panel.postMessage({ type: "exported", path: targetPath, requestId: message.requestId });
|
|
677
935
|
} catch (error) {
|
|
678
936
|
await panel.postMessage({ type: "error", message: `Could not export file: ${error instanceof Error ? error.message : String(error)}` });
|
|
@@ -706,6 +964,12 @@ function activate(ctx) {
|
|
|
706
964
|
},
|
|
707
965
|
"swatch-book": {
|
|
708
966
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 17a4 4 0 0 1-8 0V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2Z"/><path d="M16.7 13H19a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H7"/><path d="M 7 17h.01"/><path d="m11 8 2.3-2.3a2.4 2.4 0 0 1 3.404.004L18.6 7.6a2.4 2.4 0 0 1 .026 3.434L9.9 19.8"/></svg>'
|
|
967
|
+
},
|
|
968
|
+
// Static hourglass shown on the Preview button while bm.md rendering is
|
|
969
|
+
// in flight — a plain status hint, deliberately not animated (SMIL does
|
|
970
|
+
// not run on host-rendered SVGs and frame-swapping felt janky).
|
|
971
|
+
"hourglass": {
|
|
972
|
+
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>'
|
|
709
973
|
}
|
|
710
974
|
}));
|
|
711
975
|
ctx.subscriptions.push(ctx.ui.onDidOpenPanel((panel) => {
|
|
@@ -734,14 +998,27 @@ function activate(ctx) {
|
|
|
734
998
|
action:
|
|
735
999
|
open \u2014 read an absolute local Markdown path and open it as an editable WeChat article preview
|
|
736
1000
|
create \u2014 write brand-new Markdown content to an absolute path that does not exist yet, then open it in Markdown Editor. Use this whenever the user asks to write an article, start writing, write a post, create, or draft a new document \u2014 even if they do not mention Markdown. If title/topic or destination is missing, guide the user to provide it; once known, create and open the document rather than returning prose only. If they only want to begin, create a minimal titled starter document. Markdown Editor's own UI has no "new file" button on purpose \u2014 this tool action is the intended way to start a new document
|
|
737
|
-
apply \u2014
|
|
1001
|
+
apply \u2014 revise a source document (requires path). For a small, targeted change, pass edits instead of markdown: an array of {old_string, new_string} replacements matched against the file's current on-disk content, the same find-and-replace contract as a code editor's Edit tool \u2014 this avoids resending the whole document and keeps the on-screen highlight scoped to what actually changed. Reserve markdown (the full updated document) for a genuine full rewrite. Once this conversation has started editing a .md document through Markdown Editor, always use this apply/edits path for subsequent changes to that same file before considering the built-in Edit tool: it refreshes the panel and highlights the exact change. Fall back to the built-in Edit tool only after this apply actually fails. The open panel refreshes in place, no Diff window. Whenever you propose a rewrite and wait for approval before applying it, calling Session action=suggest with 1-3 one-tap confirmations is MANDATORY, not optional, and part of that same turn \u2014 sending the proposal text alone does not complete the confirmation step, so do not end the turn without also calling it
|
|
738
1002
|
set_style \u2014 apply an AI-designed custom CSS layout to the currently open Markdown Editor preview (requires css). Write plain CSS scoped under #bm-md using tag/id selectors (no classes), use !important where needed to override the base style, and take inspiration from bm.md's built-in styles: kami (warm paper), bauhaus (geometric primary colors), blueprint (technical grid), botanical (soft green), newsprint (editorial serif), retro (nostalgic), sketch (hand-drawn), terminal (monospace dark).`,
|
|
739
1003
|
inputSchema: {
|
|
740
1004
|
type: "object",
|
|
741
1005
|
properties: {
|
|
742
1006
|
action: { type: "string", enum: ["open", "create", "apply", "set_style"], description: "Operation to perform." },
|
|
743
1007
|
path: { type: "string", description: "Absolute path to the Markdown file. Required for open, create, and apply. For create, the file must not already exist." },
|
|
744
|
-
markdown: { type: "string", description: "Full Markdown content
|
|
1008
|
+
markdown: { type: "string", description: "Full Markdown content. Required for create. For apply, use this only for a genuine full rewrite \u2014 prefer `edits` for a small, targeted change." },
|
|
1009
|
+
edits: {
|
|
1010
|
+
type: "array",
|
|
1011
|
+
description: "For apply only: targeted local replacements instead of resending the whole document. Each old_string is matched against the file's current on-disk content (already reflecting earlier items in this same array) and must match exactly once unless replace_all is set. Prefer this over `markdown` for anything short of a full rewrite.",
|
|
1012
|
+
items: {
|
|
1013
|
+
type: "object",
|
|
1014
|
+
properties: {
|
|
1015
|
+
old_string: { type: "string", description: "Exact existing text to replace; must be unique in the file unless replace_all is true." },
|
|
1016
|
+
new_string: { type: "string", description: "Replacement text." },
|
|
1017
|
+
replace_all: { type: "boolean", description: "Replace every occurrence of old_string instead of requiring it to be unique." }
|
|
1018
|
+
},
|
|
1019
|
+
required: ["old_string", "new_string"]
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
745
1022
|
css: { type: "string", description: "Custom CSS to layer on top of the current base style, required for set_style." },
|
|
746
1023
|
label: { type: "string", description: "Short label describing the custom style, optional for set_style." },
|
|
747
1024
|
slot: { type: "number", enum: [1, 2, 3], description: "Required for AI-designed styles: user-selected reusable custom style slot to overwrite." }
|
|
@@ -753,7 +1030,7 @@ action:
|
|
|
753
1030
|
const action = String(input.action ?? "");
|
|
754
1031
|
if (action === "open" || action === "create" || action === "apply") {
|
|
755
1032
|
const sourcePath = String(input.path ?? "").trim();
|
|
756
|
-
if (!
|
|
1033
|
+
if (!path3.isAbsolute(sourcePath)) return result("`path` must be an absolute local path.", true);
|
|
757
1034
|
if (action === "open") {
|
|
758
1035
|
try {
|
|
759
1036
|
const { markdown: markdown2, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, sourcePath);
|
|
@@ -762,7 +1039,7 @@ action:
|
|
|
762
1039
|
watchSource(ctx, panel, sourcePath);
|
|
763
1040
|
await rememberLastPath(ctx, panel, sourcePath);
|
|
764
1041
|
await sendDocument(panel, { path: sourcePath, markdown: markdown2, title: documentTitle(markdown2, sourcePath), draftRestored, draftConflict, diskMarkdown });
|
|
765
|
-
return result(`Opened Markdown Editor for ${
|
|
1042
|
+
return result(`Opened Markdown Editor for ${path3.basename(sourcePath)}.`);
|
|
766
1043
|
} catch (error) {
|
|
767
1044
|
return result(`Could not read ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
768
1045
|
}
|
|
@@ -772,23 +1049,46 @@ action:
|
|
|
772
1049
|
try {
|
|
773
1050
|
const alreadyExists = await stat(sourcePath).then(() => true).catch(() => false);
|
|
774
1051
|
if (alreadyExists) return result(`${sourcePath} already exists. Use action 'open' to view it or 'apply' to revise it instead.`, true);
|
|
775
|
-
await
|
|
776
|
-
await
|
|
1052
|
+
await mkdir2(path3.dirname(sourcePath), { recursive: true });
|
|
1053
|
+
await writeFile2(sourcePath, markdown2, "utf8");
|
|
777
1054
|
const panel = ctx.ui.createPanel({ instanceMode: "single", payload: { path: sourcePath } });
|
|
778
1055
|
await panel.reveal();
|
|
779
1056
|
watchSource(ctx, panel, sourcePath);
|
|
780
1057
|
await rememberLastPath(ctx, panel, sourcePath);
|
|
781
1058
|
await sendDocument(panel, { path: sourcePath, markdown: markdown2, title: documentTitle(markdown2, sourcePath) });
|
|
782
|
-
return result(`Created ${
|
|
1059
|
+
return result(`Created ${path3.basename(sourcePath)} and opened it in Markdown Editor.`);
|
|
783
1060
|
} catch (error) {
|
|
784
1061
|
return result(`Could not create ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
785
1062
|
}
|
|
786
1063
|
}
|
|
1064
|
+
const rawEdits = Array.isArray(input.edits) ? input.edits : void 0;
|
|
1065
|
+
if (rawEdits && rawEdits.length > 0) {
|
|
1066
|
+
const edits = rawEdits.map((entry) => {
|
|
1067
|
+
const item = entry && typeof entry === "object" ? entry : {};
|
|
1068
|
+
return {
|
|
1069
|
+
old_string: typeof item.old_string === "string" ? item.old_string : "",
|
|
1070
|
+
new_string: typeof item.new_string === "string" ? item.new_string : "",
|
|
1071
|
+
replace_all: Boolean(item.replace_all)
|
|
1072
|
+
};
|
|
1073
|
+
});
|
|
1074
|
+
try {
|
|
1075
|
+
const current = await readFile2(sourcePath, "utf8");
|
|
1076
|
+
const applied = applyEditSpecs(current, edits);
|
|
1077
|
+
if (!applied.ok) return result(applied.error, true);
|
|
1078
|
+
await writeFile2(sourcePath, applied.content, "utf8");
|
|
1079
|
+
await rememberRecentPath(ctx, sourcePath);
|
|
1080
|
+
return result(`Applied ${edits.length} targeted edit${edits.length > 1 ? "s" : ""} to ${path3.basename(sourcePath)}.`);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
return result(`Could not apply edits: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
787
1085
|
const markdown = String(input.markdown ?? "");
|
|
788
|
-
if (!markdown) return result("`apply` requires non-empty `markdown
|
|
1086
|
+
if (!markdown) return result("`apply` requires either `edits` (targeted replacements) or non-empty `markdown` (full document).", true);
|
|
789
1087
|
try {
|
|
790
|
-
await
|
|
791
|
-
|
|
1088
|
+
const current = await readFile2(sourcePath, "utf8");
|
|
1089
|
+
await writeFile2(sourcePath, preserveTextEnvelope(current, markdown), "utf8");
|
|
1090
|
+
await rememberRecentPath(ctx, sourcePath);
|
|
1091
|
+
return result(`Applied reviewed Markdown to ${path3.basename(sourcePath)}.`);
|
|
792
1092
|
} catch (error) {
|
|
793
1093
|
return result(`Could not apply revision: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
794
1094
|
}
|