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/dist/index.js CHANGED
@@ -1,11 +1,186 @@
1
1
  // src/index.ts
2
- import { createHash } from "node:crypto";
3
- import { mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
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
- import os from "node:os";
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(path.join(ctx.storagePath, "assets"));
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 = path.relative(assetsRoot, target);
38
- const mimeType = IMAGE_MIME_BY_EXT[path.extname(target).toLowerCase()];
39
- if ((!relative || !relative.startsWith(".." + path.sep) && relative !== "..") && mimeType) {
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 readFile(target)).toString("base64")}`;
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 (!path.isAbsolute(filePath) || !IMAGE_MIME_BY_EXT[path.extname(filePath).toLowerCase()]) {
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 path.join(ctx.storagePath, "state.json");
243
+ return path3.join(ctx.storagePath, "state.json");
73
244
  }
74
245
  function styleSlotsFile(ctx) {
75
- return path.join(ctx.storagePath, "style-slots.json");
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 readFile(styleSlotsFile(ctx), "utf8");
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 mkdir(ctx.storagePath, { recursive: true });
102
- await writeFile(styleSlotsFile(ctx), JSON.stringify(slots), "utf8");
272
+ await mkdir2(ctx.storagePath, { recursive: true });
273
+ await writeFile2(styleSlotsFile(ctx), JSON.stringify(slots), "utf8");
103
274
  return slots;
104
275
  }
105
- function draftPathFor(ctx, sourcePath) {
106
- const digest = createHash("sha256").update(sourcePath).digest("hex").slice(0, 32);
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 readDraft(ctx, sourcePath) {
279
+ async function readLastPathState(ctx) {
113
280
  try {
114
- const raw = JSON.parse(await readFile(draftPathFor(ctx, sourcePath), "utf8"));
115
- return raw && raw.path === sourcePath && typeof raw.markdown === "string" ? raw : void 0;
281
+ const raw = await readFile2(stateFile(ctx), "utf8");
282
+ return JSON.parse(raw);
116
283
  } catch {
117
- return void 0;
284
+ return {};
118
285
  }
119
286
  }
120
- async function writeDraft(ctx, sourcePath, markdown, base) {
121
- try {
122
- const file = draftPathFor(ctx, sourcePath);
123
- await mkdir(path.dirname(file), { recursive: true });
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
- async function deleteDraft(ctx, sourcePath) {
132
- await rm(draftPathFor(ctx, sourcePath), { force: true }).catch(() => {
133
- });
134
- }
135
- var DRAFT_WRITE_DEBOUNCE_MS = 600;
136
- var pendingDraftWrites = /* @__PURE__ */ new Map();
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 flushPendingDraftWrite(ctx, panelId) {
149
- const entry = pendingDraftWrites.get(panelId);
150
- if (!entry) return;
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 cancelPendingDraftWrite(panelId) {
156
- const entry = pendingDraftWrites.get(panelId);
157
- if (entry?.timer) clearTimeout(entry.timer);
158
- pendingDraftWrites.delete(panelId);
159
- }
160
- async function readFileWithDraft(ctx, sourcePath) {
161
- const diskMarkdown = await readFile(sourcePath, "utf8");
162
- const draft = await readDraft(ctx, sourcePath);
163
- if (!draft || draft.markdown === diskMarkdown) return { markdown: diskMarkdown, diskMarkdown, draftRestored: false, draftConflict: false };
164
- const diskMatchesBaseline = draft.baseHash === void 0 || draft.baseHash === hashText(diskMarkdown);
165
- if (diskMatchesBaseline) return { markdown: draft.markdown, diskMarkdown, draftRestored: true, draftConflict: false };
166
- return { markdown: diskMarkdown, diskMarkdown, draftRestored: false, draftConflict: true };
167
- }
168
- function sessionBucketKey(panel) {
169
- return panel.sessionId || "__global__";
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 readLastPathState(ctx) {
325
+ async function rememberPanelRecentScope(ctx, panel, cwd, sessionId, spaceId) {
172
326
  try {
173
- const raw = await readFile(stateFile(ctx), "utf8");
174
- return JSON.parse(raw);
175
- } catch {
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 mkdir(ctx.storagePath, { recursive: true });
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
- await writeFile(stateFile(ctx), JSON.stringify(state), "utf8");
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, cwd) {
226
- if (!cwd || !path.isAbsolute(cwd)) return [];
399
+ async function collectRecentDocuments(ctx, requestedCwd, sessionId, spaceId) {
400
+ const cwd = requestedCwd;
227
401
  const state = await readLastPathState(ctx);
228
- const candidates = [
229
- ...state.recentPaths ?? [],
230
- ...Object.values(state.panels ?? {}),
231
- ...Object.values(state.sessions ?? {})
232
- ].filter(
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 readFile(filePath, "utf8");
241
- const fileName = path.basename(filePath);
413
+ const markdown = await readFile2(filePath, "utf8");
414
+ const fileName = path3.basename(filePath);
242
415
  return {
243
416
  path: filePath,
244
- relativePath: path.relative(cwd, filePath),
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 && path.isAbsolute(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" && path.isAbsolute(value) ? value : void 0;
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 readFile(sourcePath, "utf8");
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
- spawn("open", isFile ? ["-R", targetPath] : [targetPath], { stdio: "ignore", detached: true }).unref();
564
+ spawn2("open", isFile ? ["-R", targetPath] : [targetPath], { stdio: "ignore", detached: true }).unref();
406
565
  } else if (process.platform === "win32") {
407
- spawn("explorer", isFile ? [`/select,${targetPath}`] : [targetPath], { stdio: "ignore", detached: true }).unref();
566
+ spawn2("explorer", isFile ? [`/select,${targetPath}`] : [targetPath], { stdio: "ignore", detached: true }).unref();
408
567
  } else {
409
- spawn("xdg-open", [isFile ? path.dirname(targetPath) : targetPath], { stdio: "ignore", detached: true }).unref();
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 (!path.isAbsolute(sourcePath)) {
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 (!path.isAbsolute(sourcePath)) return;
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 && path.isAbsolute(targetPath)) await revealInFileManager(ctx, 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
- await panel.postMessage({ type: "recentDocuments", cwd, documents: await collectRecentDocuments(ctx, cwd) });
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 (!path.isAbsolute(sourcePath)) return;
798
+ if (!path3.isAbsolute(sourcePath)) return;
544
799
  try {
545
- await writeFile(sourcePath, String(message.markdown ?? ""), "utf8");
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 (!path.isAbsolute(sourcePath)) return;
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 (!path.isAbsolute(sourcePath)) return;
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 || !path.isAbsolute(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 writeFile(sourcePath, markdown, "utf8");
605
- await panel.postMessage({ type: "applied", path: sourcePath, title: documentTitle(markdown, sourcePath) });
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 = createHash("sha256").update(buffer).digest("hex").slice(0, 20);
630
- const dir = path.join(ctx.storagePath, "assets");
631
- await mkdir(dir, { recursive: true });
632
- const targetPath = path.join(dir, `${digest}.${ext}`);
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 writeFile(targetPath, buffer);
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 = path.isAbsolute(sourcePath) ? path.dirname(sourcePath) : "";
925
+ let dir = path3.isAbsolute(sourcePath) ? path3.dirname(sourcePath) : "";
668
926
  if (!dir) {
669
- const downloads = path.join(os.homedir(), "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 = path.join(dir, `${safeName}.${ext}`);
675
- await writeFile(targetPath, Buffer.from(dataBase64, "base64"));
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 replace a source document with reviewed Markdown (requires path and markdown); the open panel refreshes in place, no Diff window
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, required for create and apply." },
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 (!path.isAbsolute(sourcePath)) return result("`path` must be an absolute local path.", true);
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 ${path.basename(sourcePath)}.`);
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 mkdir(path.dirname(sourcePath), { recursive: true });
776
- await writeFile(sourcePath, markdown2, "utf8");
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 ${path.basename(sourcePath)} and opened it in Markdown Editor.`);
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`.", true);
1086
+ if (!markdown) return result("`apply` requires either `edits` (targeted replacements) or non-empty `markdown` (full document).", true);
789
1087
  try {
790
- await writeFile(sourcePath, markdown, "utf8");
791
- return result(`Applied reviewed Markdown to ${path.basename(sourcePath)}.`);
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
  }