finch-markdown-editor 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import { createHash } from "node:crypto";
3
- import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { watch } from "node:fs";
5
5
  import { spawn } from "node:child_process";
6
6
  import path from "node:path";
@@ -101,6 +101,69 @@ async function writeStyleSlot(ctx, slot, value) {
101
101
  await writeFile(styleSlotsFile(ctx), JSON.stringify(slots), "utf8");
102
102
  return slots;
103
103
  }
104
+ function draftPathFor(ctx, sourcePath) {
105
+ const digest = createHash("sha256").update(sourcePath).digest("hex").slice(0, 32);
106
+ return path.join(ctx.storagePath, "drafts", `${digest}.json`);
107
+ }
108
+ function hashText(text) {
109
+ return createHash("sha256").update(text).digest("hex");
110
+ }
111
+ async function readDraft(ctx, sourcePath) {
112
+ try {
113
+ const raw = JSON.parse(await readFile(draftPathFor(ctx, sourcePath), "utf8"));
114
+ return raw && raw.path === sourcePath && typeof raw.markdown === "string" ? raw : void 0;
115
+ } catch {
116
+ return void 0;
117
+ }
118
+ }
119
+ async function writeDraft(ctx, sourcePath, markdown, base) {
120
+ try {
121
+ const file = draftPathFor(ctx, sourcePath);
122
+ await mkdir(path.dirname(file), { recursive: true });
123
+ const entry = { path: sourcePath, markdown, savedAt: Date.now() };
124
+ if (typeof base === "string") entry.baseHash = hashText(base);
125
+ await writeFile(file, JSON.stringify(entry), "utf8");
126
+ } catch (error) {
127
+ ctx.logger.warn(`Could not persist draft for ${sourcePath}: ${String(error)}`);
128
+ }
129
+ }
130
+ async function deleteDraft(ctx, sourcePath) {
131
+ await rm(draftPathFor(ctx, sourcePath), { force: true }).catch(() => {
132
+ });
133
+ }
134
+ var DRAFT_WRITE_DEBOUNCE_MS = 600;
135
+ var pendingDraftWrites = /* @__PURE__ */ new Map();
136
+ function scheduleDraftWrite(ctx, panelId, sourcePath, markdown, base) {
137
+ const existing = pendingDraftWrites.get(panelId);
138
+ if (existing?.timer) clearTimeout(existing.timer);
139
+ const entry = { path: sourcePath, markdown, base, timer: null };
140
+ entry.timer = setTimeout(() => {
141
+ entry.timer = null;
142
+ pendingDraftWrites.delete(panelId);
143
+ void writeDraft(ctx, sourcePath, markdown, base);
144
+ }, DRAFT_WRITE_DEBOUNCE_MS);
145
+ pendingDraftWrites.set(panelId, entry);
146
+ }
147
+ function flushPendingDraftWrite(ctx, panelId) {
148
+ const entry = pendingDraftWrites.get(panelId);
149
+ if (!entry) return;
150
+ if (entry.timer) clearTimeout(entry.timer);
151
+ pendingDraftWrites.delete(panelId);
152
+ void writeDraft(ctx, entry.path, entry.markdown, entry.base);
153
+ }
154
+ function cancelPendingDraftWrite(panelId) {
155
+ const entry = pendingDraftWrites.get(panelId);
156
+ if (entry?.timer) clearTimeout(entry.timer);
157
+ pendingDraftWrites.delete(panelId);
158
+ }
159
+ async function readFileWithDraft(ctx, sourcePath) {
160
+ const diskMarkdown = await readFile(sourcePath, "utf8");
161
+ const draft = await readDraft(ctx, sourcePath);
162
+ if (!draft || draft.markdown === diskMarkdown) return { markdown: diskMarkdown, diskMarkdown, draftRestored: false, draftConflict: false };
163
+ const diskMatchesBaseline = draft.baseHash === void 0 || draft.baseHash === hashText(diskMarkdown);
164
+ if (diskMatchesBaseline) return { markdown: draft.markdown, diskMarkdown, draftRestored: true, draftConflict: false };
165
+ return { markdown: diskMarkdown, diskMarkdown, draftRestored: false, draftConflict: true };
166
+ }
104
167
  function sessionBucketKey(panel) {
105
168
  return panel.sessionId || "__global__";
106
169
  }
@@ -195,6 +258,18 @@ async function sendDocument(panel, state) {
195
258
  livePanelDocuments.set(panel.id, state);
196
259
  await panel.postMessage({ type: "document", ...state });
197
260
  }
261
+ async function sendLiveDocument(ctx, panel, liveDocument) {
262
+ if (liveDocument.path && path.isAbsolute(liveDocument.path)) {
263
+ try {
264
+ const { markdown, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, liveDocument.path);
265
+ await sendDocument(panel, { ...liveDocument, markdown, title: documentTitle(markdown, liveDocument.path), draftRestored, draftConflict, diskMarkdown });
266
+ return;
267
+ } catch (error) {
268
+ ctx.logger.warn(`Could not re-read ${liveDocument.path} for a reconnecting panel, resending cached copy: ${String(error)}`);
269
+ }
270
+ }
271
+ await sendDocument(panel, liveDocument);
272
+ }
198
273
  function payloadPath(panel) {
199
274
  const payload = panel.payload;
200
275
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return void 0;
@@ -206,10 +281,10 @@ async function restoreDocument(ctx, panel) {
206
281
  const sourcePath = await readLastPath(ctx, panel) ?? payloadPath(panel);
207
282
  if (!sourcePath) return false;
208
283
  try {
209
- const markdown = await readFile(sourcePath, "utf8");
284
+ const { markdown, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, sourcePath);
210
285
  watchSource(ctx, panel, sourcePath);
211
286
  await rememberLastPath(ctx, panel, sourcePath);
212
- await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
287
+ await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath), draftRestored, draftConflict, diskMarkdown });
213
288
  return true;
214
289
  } catch (error) {
215
290
  ctx.logger.warn(`Could not restore ${sourcePath}: ${String(error)}`);
@@ -318,6 +393,11 @@ function watchSource(ctx, panel, sourcePath) {
318
393
  await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
319
394
  } catch (error) {
320
395
  ctx.logger.warn(`Source refresh failed: ${String(error)}`);
396
+ const code = error?.code;
397
+ if (code === "ENOENT") {
398
+ await panel.postMessage({ type: "sourceMissing", path: sourcePath }).catch(() => {
399
+ });
400
+ }
321
401
  }
322
402
  }, 150);
323
403
  });
@@ -353,13 +433,19 @@ async function sendReady(ctx, panel) {
353
433
  homeDir: os.homedir()
354
434
  });
355
435
  }
356
- function revealInFileManager(ctx, directory) {
436
+ async function revealInFileManager(ctx, targetPath) {
357
437
  try {
358
- if (process.platform === "darwin") spawn("open", [directory], { stdio: "ignore", detached: true }).unref();
359
- else if (process.platform === "win32") spawn("explorer", [directory], { stdio: "ignore", detached: true }).unref();
360
- else spawn("xdg-open", [directory], { stdio: "ignore", detached: true }).unref();
438
+ const info = await stat(targetPath).catch(() => void 0);
439
+ const isFile = info?.isFile() ?? false;
440
+ if (process.platform === "darwin") {
441
+ spawn("open", isFile ? ["-R", targetPath] : [targetPath], { stdio: "ignore", detached: true }).unref();
442
+ } else if (process.platform === "win32") {
443
+ spawn("explorer", isFile ? [`/select,${targetPath}`] : [targetPath], { stdio: "ignore", detached: true }).unref();
444
+ } else {
445
+ spawn("xdg-open", [isFile ? path.dirname(targetPath) : targetPath], { stdio: "ignore", detached: true }).unref();
446
+ }
361
447
  } catch (error) {
362
- ctx.logger.warn(`Could not open file manager for ${directory}: ${String(error)}`);
448
+ ctx.logger.warn(`Could not open file manager for ${targetPath}: ${String(error)}`);
363
449
  }
364
450
  }
365
451
  async function handleMessage(ctx, panel, raw) {
@@ -373,7 +459,7 @@ async function handleMessage(ctx, panel, raw) {
373
459
  await sendReady(ctx, panel);
374
460
  const liveDocument = livePanelDocuments.get(panel.id);
375
461
  if (liveDocument) {
376
- await sendDocument(panel, liveDocument);
462
+ await sendLiveDocument(ctx, panel, liveDocument);
377
463
  } else if (!await restoreDocument(ctx, panel)) {
378
464
  await panel.postMessage({ type: "lastFileUnavailable" });
379
465
  }
@@ -421,10 +507,10 @@ async function handleMessage(ctx, panel, raw) {
421
507
  return;
422
508
  }
423
509
  const sourcePath = picked.files[0].path;
424
- const markdown = await readFile(sourcePath, "utf8");
510
+ const { markdown, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, sourcePath);
425
511
  watchSource(ctx, panel, sourcePath);
426
512
  await rememberLastPath(ctx, panel, sourcePath);
427
- await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
513
+ await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath), draftRestored, draftConflict, diskMarkdown });
428
514
  } catch (error) {
429
515
  ctx.logger.error(`pickFile() threw: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
430
516
  await panel.postMessage({
@@ -442,10 +528,10 @@ async function handleMessage(ctx, panel, raw) {
442
528
  return;
443
529
  }
444
530
  try {
445
- const markdown = await readFile(sourcePath, "utf8");
531
+ const { markdown, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, sourcePath);
446
532
  watchSource(ctx, panel, sourcePath);
447
533
  await rememberLastPath(ctx, panel, sourcePath);
448
- await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
534
+ await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath), draftRestored, draftConflict, diskMarkdown });
449
535
  } catch (error) {
450
536
  await panel.postMessage({ type: "error", message: `Cannot read file: ${error instanceof Error ? error.message : String(error)}` });
451
537
  }
@@ -462,7 +548,7 @@ async function handleMessage(ctx, panel, raw) {
462
548
  case "requestLastFile": {
463
549
  const liveDocument = livePanelDocuments.get(panel.id);
464
550
  if (liveDocument) {
465
- await sendDocument(panel, liveDocument);
551
+ await sendLiveDocument(ctx, panel, liveDocument);
466
552
  } else if (!await restoreDocument(ctx, panel)) {
467
553
  await panel.postMessage({ type: "lastFileUnavailable" });
468
554
  }
@@ -470,7 +556,7 @@ async function handleMessage(ctx, panel, raw) {
470
556
  }
471
557
  case "openPath": {
472
558
  const targetPath = String(message.path ?? "").trim();
473
- if (targetPath && path.isAbsolute(targetPath)) revealInFileManager(ctx, targetPath);
559
+ if (targetPath && path.isAbsolute(targetPath)) await revealInFileManager(ctx, targetPath);
474
560
  return;
475
561
  }
476
562
  case "goHome": {
@@ -493,12 +579,28 @@ async function handleMessage(ctx, panel, raw) {
493
579
  if (!path.isAbsolute(sourcePath)) return;
494
580
  try {
495
581
  await writeFile(sourcePath, String(message.markdown ?? ""), "utf8");
582
+ cancelPendingDraftWrite(panel.id);
583
+ await deleteDraft(ctx, sourcePath);
496
584
  await panel.postMessage({ type: "savedMarkdown", path: sourcePath, requestId: message.requestId });
497
585
  } catch (error) {
498
586
  await panel.postMessage({ type: "error", message: `Could not save Markdown: ${error instanceof Error ? error.message : String(error)}` });
499
587
  }
500
588
  return;
501
589
  }
590
+ case "saveDraft": {
591
+ const sourcePath = String(message.path ?? "").trim();
592
+ if (!path.isAbsolute(sourcePath)) return;
593
+ const base = typeof message.base === "string" ? message.base : void 0;
594
+ scheduleDraftWrite(ctx, panel.id, sourcePath, String(message.markdown ?? ""), base);
595
+ return;
596
+ }
597
+ case "discardDraft": {
598
+ const sourcePath = String(message.path ?? "").trim();
599
+ if (!path.isAbsolute(sourcePath)) return;
600
+ cancelPendingDraftWrite(panel.id);
601
+ await deleteDraft(ctx, sourcePath);
602
+ return;
603
+ }
502
604
  case "renderBm": {
503
605
  try {
504
606
  const html = await renderWithBm(ctx, String(message.markdown ?? ""), String(message.markdownStyle ?? "kami"), message.customCss, () => {
@@ -637,6 +739,9 @@ function activate(ctx) {
637
739
  },
638
740
  "wechat-copy": {
639
741
  svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path stroke-linecap="round" stroke-width="2" d="M7 7h.009m5.982 0H13m4.991 7.5H18m-4 0h.009"></path><path stroke-width="2" d="M10 16c0 2.761 2.686 5 6 5c.907 0 1.767-.168 2.538-.468c.189-.073.393-.1.592-.063L22 21l-.652-2.03a1.13 1.13 0 0 1 .11-.89A4.3 4.3 0 0 0 22 16c0-2.761-2.686-5-6-5s-6 2.239-6 5Z"></path><path stroke-width="2" d="M17.873 11.249Q18 10.639 18 10c0-3.866-3.582-7-8-7s-8 3.134-8 7c0 1.112.297 2.164.824 3.098c.147.26.196.567.108.853L2 17l3.914-.76c.208-.041.422-.013.617.07a9 9 0 0 0 3.589.69"></path></svg>'
742
+ },
743
+ feather: {
744
+ svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.086 18.412A2 2 0 0 1 12.67 19H5v-7.672a2 2 0 0 1 .586-1.414L11.75 3.75a6 6 0 1 1 8.49 8.49z"/><path d="M16 8 2 22"/><path d="M17.488 15H9"/></svg>'
640
745
  }
641
746
  }));
642
747
  ctx.subscriptions.push(ctx.ui.onDidOpenPanel((panel) => {
@@ -653,13 +758,14 @@ function activate(ctx) {
653
758
  ctx.subscriptions.push(panel.onDidDispose(() => {
654
759
  stopWatching(panel.id);
655
760
  livePanelDocuments.delete(panel.id);
761
+ flushPendingDraftWrite(ctx, panel.id);
656
762
  if (lastPanel === panel) lastPanel = void 0;
657
763
  }));
658
764
  void sendReady(ctx, panel).catch((error) => ctx.logger.warn(String(error)));
659
765
  }));
660
766
  ctx.subscriptions.push(ctx.tools.register({
661
767
  name: "markdown_editor_document",
662
- title: "Markdown \u7F16\u8F91",
768
+ title: "\u5199\u4F5C",
663
769
  description: `Open, create, revise, or restyle a Markdown document in Markdown Editor.
664
770
  action:
665
771
  open \u2014 read an absolute local Markdown path and open it as an editable WeChat article preview
@@ -686,12 +792,12 @@ action:
686
792
  if (!path.isAbsolute(sourcePath)) return result("`path` must be an absolute local path.", true);
687
793
  if (action === "open") {
688
794
  try {
689
- const markdown2 = await readFile(sourcePath, "utf8");
795
+ const { markdown: markdown2, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, sourcePath);
690
796
  const panel = ctx.ui.createPanel({ instanceMode: "single", payload: { path: sourcePath } });
691
797
  await panel.reveal();
692
798
  watchSource(ctx, panel, sourcePath);
693
799
  await rememberLastPath(ctx, panel, sourcePath);
694
- await sendDocument(panel, { path: sourcePath, markdown: markdown2, title: documentTitle(markdown2, sourcePath) });
800
+ await sendDocument(panel, { path: sourcePath, markdown: markdown2, title: documentTitle(markdown2, sourcePath), draftRestored, draftConflict, diskMarkdown });
695
801
  return result(`Opened Markdown Editor for ${path.basename(sourcePath)}.`);
696
802
  } catch (error) {
697
803
  return result(`Could not read ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`, true);