diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 859cc852..0b3ce9e4 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -1716,7 +1716,11 @@ export const WorkspaceApp = ({ setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; + let resumeDesktopSync: (() => void) | null = null; try { + if (isDesktopResourceRuntime()) { + resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport(); + } const preparedFile = imageCompressionEnabled ? (await compressImageForUpload(file)).file : file; const memo = await createScreenshotMemo({ notebookId, @@ -1762,6 +1766,8 @@ export const WorkspaceApp = ({ title: t("memoList.importScreenshotFailedTitle"), description: t("memoList.importScreenshotFailed"), }); + } finally { + resumeDesktopSync?.(); } }, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); @@ -1809,7 +1815,11 @@ export const WorkspaceApp = ({ creatingMemoSelectionRef.current = true; setWeChatImportsInProgress((count) => count + 1); let savedMemo: MemoDetail | null = null; + let resumeDesktopSync: (() => void) | null = null; try { + if (isDesktopResourceRuntime()) { + resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport(); + } const memo = await createWeChatChatMemo({ notebookId, title: payload.title?.trim() || "", @@ -1867,6 +1877,7 @@ export const WorkspaceApp = ({ } creatingMemoSelectionRef.current = false; } finally { + resumeDesktopSync?.(); setWeChatImportsInProgress((count) => Math.max(0, count - 1)); } }, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs index b3ffe436..531f1b23 100644 --- a/apps/web/src/lib/desktop-sync.test.mjs +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -11,18 +11,37 @@ const { mergeMemoIdMappings, mergeSyncedMemos, normalizeDesktopMemoPayload, + pauseDesktopSyncForImport, orderBootstrapNotebooks, orderDesktopSyncChanges, resolveDesktopMemoSyncBase, resolveDesktopStaleMemoUpdate, desktopLocalRevisionWitnessesRemote, rewriteStagedResource, + syncDesktopData, shouldAttemptDesktopRecoveryPull, shouldPullDesktopChanges, } = await import("./desktop-sync.ts"); const { ApiRequestError } = await import("./api.ts"); describe("desktop staged resource sync", () => { + test("holds cloud sync until an imported screenshot is saved", async () => { + const originalWindow = globalThis.window; + const events = []; + globalThis.window = { dispatchEvent: (event) => { events.push(event.type); return true; } }; + try { + const release = await pauseDesktopSyncForImport(); + const result = await syncDesktopData(); + expect(result.attempted).toBe(0); + expect(events).not.toContain("edgeever:sync-queue-changed"); + release(); + release(); + expect(events.filter((event) => event === "edgeever:sync-queue-changed")).toHaveLength(1); + } finally { + globalThis.window = originalWindow; + } + }); + test("rewrites placeholders in memo JSON and markdown", () => { const rewrites = [{ memoId: "memo-1", placeholder: "edgeever-staged://stage-1", url: "/api/v1/resources/resource-1/blob" }]; const value = { diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index 004a661e..b0e60a53 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -19,7 +19,7 @@ import { api, ApiRequestError } from "@/lib/api"; import { isDesktopResourceRuntime, mapMarkdownResourceUrls, mapTiptapResourceUrls, toApiResourceUrl } from "@/lib/desktop-resources"; import { readEmergencyDraft } from "@/lib/emergency-draft"; import { notebookDeleteIdsFromPayload } from "@/lib/notebook-delete"; -import { notifyMemoIdRemapped, notifyMemoSyncAcknowledged } from "@/lib/sync-events"; +import { notifyMemoIdRemapped, notifyMemoSyncAcknowledged, notifySyncQueueChanged } from "@/lib/sync-events"; type StagedResourceRewrite = { memoId: string; placeholder: string; url: string }; @@ -767,9 +767,33 @@ export const mergeSyncedMemos = (target: Map, source: ReadonlyMap< }; let activeSync: Promise | null = null; +let pausedImports = 0; + +// Importers create a local memo before its attachment can be staged. Keep that +// short sequence together so cloud acknowledgement cannot remap the memo ID +// between create and update (or leave an empty cloud memo on failure). +export const pauseDesktopSyncForImport = async () => { + pausedImports += 1; + try { + if (activeSync) await activeSync; + } catch (error) { + pausedImports -= 1; + throw error; + } + let released = false; + return () => { + if (released) return; + released = true; + pausedImports -= 1; + if (pausedImports === 0) notifySyncQueueChanged(); + }; +}; export const syncDesktopData = () => { if (activeSync) return activeSync; + if (pausedImports > 0) { + return Promise.resolve({ attempted: 0, synced: 0, failed: 0, conflicted: 0, memoIdMappings: new Map(), syncedMemos: new Map() }); + } activeSync = (async () => { const memoIdMappings = new Map(); const syncedMemos = new Map();