From 06dc99e02e91ad440a4c3ee40f9ed2999176ffe2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 10 Sep 2026 17:33:33 -0500 Subject: [PATCH 1/6] Fix(cloud): a conflict decision saves one note, stays saved, and queued conflicts merge when they can Resolving a Cloud conflict downloaded the content manifest of the whole vault before it would save one note, so on a vault with many attachments the decision could time out with nothing written. The server already checks the base revision and the destination path atomically, and a vault-wide download cannot strengthen that check. Save against the reviewed revision alone; for the other device's version or keep-both, check freshness from metadata and fetch only that file's retained bytes when the snapshot lacks them. Hosts without a revision reader keep the content-manifest path. The whole-vault sync that follows a decision used to own its outcome: a slow or failed run left the review open on a note that was already resolved and invited a second save. Retire the decision the moment the save succeeds, drop it from the queue, run the vault sync in the background, and report a failure as "Note saved. Remaining vault sync failed:" so the note is known to be safe. A resolver that has saved ignores further clicks. Settings adopts the shared summary when the later run replaces it. A queued content conflict that could not merge when it was found stayed queued until opened, even once the two sides had become a clean three-way merge. Reconsider those on every sync when the Cloud side is unchanged, all three paths agree, there is no draft, and the current local file merges cleanly; write and upload the result and clear the conflict. Overlaps, drafts, renames, paused paths and a file changing mid-write stay queued for the user. Identical files keep the stricter convergence check from 2.47.0. Claude-Session: https://claude.ai/code/session_01YK5tkUJtKXq3vh5mPmtcYT --- .../src/components/CloudConflictDialog.tsx | 1 + .../CloudPendingConflictResolver.test.ts | 43 ++++ .../CloudPendingConflictResolver.tsx | 16 +- .../src/components/CloudSettings.test.ts | 97 ++++++++ .../app-core/src/components/CloudSettings.tsx | 10 + .../app-core/src/lib/cloud-auto-sync.test.ts | 42 ++++ packages/app-core/src/lib/cloud-auto-sync.ts | 49 +++- .../src/cloud-sync-coordinator.test.ts | 224 ++++++++++++++++++ .../src/cloud-sync-coordinator.ts | 110 +++++++-- .../src/cloud-sync-resolution.test.ts | 213 +++++++++++++++++ 10 files changed, 782 insertions(+), 23 deletions(-) create mode 100644 packages/shared-domain/src/cloud-sync-resolution.test.ts diff --git a/packages/app-core/src/components/CloudConflictDialog.tsx b/packages/app-core/src/components/CloudConflictDialog.tsx index c457f1ec..4a05c700 100644 --- a/packages/app-core/src/components/CloudConflictDialog.tsx +++ b/packages/app-core/src/components/CloudConflictDialog.tsx @@ -91,6 +91,7 @@ export function CloudConflictDialog({ {selected && ( { }); describe("CloudPendingConflictResolver", () => { + it("finishes the saved decision before the remaining vault sync completes", async () => { + let finishSync!: (summary: CloudSyncRunSummary) => void; + bridge.syncCloudVault.mockImplementationOnce(() => new Promise((resolve) => { + finishSync = resolve; + })); + const onResolved = vi.fn(); + const pending = { ...synced, pending_conflicts: [conflict] }; + useCloudSyncStatusStore.setState({ lastSummary: pending, lastSyncedAt: 123 }); + const view = mount({ onResolved }); + await act(async () => Promise.resolve()); + await act(async () => button(view.host, "Use other device").click()); + await act(async () => button(view.host, "Save combined note").click()); + + expect(onResolved).toHaveBeenCalledWith(synced); + expect(useCloudSyncStatusStore.getState()).toMatchObject({ + phase: "syncing", lastSyncedAt: 123, lastSummary: synced, + }); + view.unmount(); + await act(async () => finishSync(synced)); + }); + + it("does not reopen a saved conflict when follow-up sync times out", async () => { + bridge.syncCloudVault.mockRejectedValueOnce(new Error("Request timed out")); + const onResolved = vi.fn(); + useCloudSyncStatusStore.setState({ + lastSummary: { ...synced, pending_conflicts: [conflict] }, + lastSyncedAt: 123, + }); + const view = mount({ onResolved }); + await act(async () => Promise.resolve()); + await act(async () => button(view.host, "Use other device").click()); + await act(async () => button(view.host, "Save combined note").click()); + + expect(onResolved).toHaveBeenCalledWith(synced); + expect(bridge.getCloudConflict).toHaveBeenCalledTimes(1); + expect(useCloudSyncStatusStore.getState()).toMatchObject({ + phase: "error", lastSyncedAt: 123, lastSummary: synced, + error: "Note saved. Remaining vault sync failed: Request timed out", + }); + view.unmount(); + }); + it("releases its own review session after unmount even if the final draft save fails", async () => { const view = mount({}); await act(async () => Promise.resolve()); @@ -494,6 +536,7 @@ function mount(overrides: { root.render( createElement(CloudPendingConflictResolver, { conflict: overrides.conflict ?? conflict, + summary: { ...synced, pending_conflicts: [overrides.conflict ?? conflict] }, vaultName: "Cloud Notes", onResolved: overrides.onResolved ?? vi.fn(), onClose: overrides.onClose ?? vi.fn(), diff --git a/packages/app-core/src/components/CloudPendingConflictResolver.tsx b/packages/app-core/src/components/CloudPendingConflictResolver.tsx index b77b2fec..8c0da021 100644 --- a/packages/app-core/src/components/CloudPendingConflictResolver.tsx +++ b/packages/app-core/src/components/CloudPendingConflictResolver.tsx @@ -8,6 +8,7 @@ import type { } from "@zennotes/bridge-contract/cloud-sync"; import { getZenBridge } from "@zennotes/bridge-contract/bridge"; import { + acknowledgeCloudConflictResolution, registerCloudConflictDraftFlusher, syncCloudVaultWithStatus, useCloudSyncStatusStore, @@ -19,11 +20,13 @@ type WholeVersionChoice = "local" | "cloud"; export function CloudPendingConflictResolver({ conflict, + summary, vaultName, onResolved, onClose, }: { conflict: CloudSyncPendingConflict; + summary: CloudSyncRunSummary; vaultName: string; onResolved: (summary: CloudSyncRunSummary) => void; onClose: () => void; @@ -57,6 +60,7 @@ export function CloudPendingConflictResolver({ const [resolvedPath, setResolvedPath] = useState(conflict.path); const loadedDraft = useRef(null); const latestDraft = useRef(""); + const resolved = useRef(false); const reviewId = useRef(crypto.randomUUID()); const reviewGeneration = useRef(0); const finishLaterButton = useRef(null); @@ -106,6 +110,7 @@ export function CloudPendingConflictResolver({ }, [bridge, conflict.id, reload]); async function flushDraft(): Promise { + if (resolved.current) return; const value = latestDraft.current; if (loadedDraft.current === null || value === loadedDraft.current) return; setSaveState("saving"); @@ -173,7 +178,7 @@ export function CloudPendingConflictResolver({ ); const chooseChange = (changeId: string, choice: ChangeChoice): void => { - if (!details) return; + if (!details || resolved.current) return; setWholeVersionChoice(null); const nextChoices = { ...choices, [changeId]: choice }; setChoices(nextChoices); @@ -202,7 +207,7 @@ export function CloudPendingConflictResolver({ "choice" | "keep_both_path" | "merged_text" | "resolved_path" >, ): Promise => { - if (!details) return; + if (!details || resolved.current) return; setBusy(true); setError(null); try { @@ -213,7 +218,12 @@ export function CloudPendingConflictResolver({ expected_cloud_revision: details.cloud.revision, ...resolution, }); - onResolved(await syncCloudVaultWithStatus(bridge, vaultName)); + resolved.current = true; + const remaining = acknowledgeCloudConflictResolution(conflict.id, summary); + // Saving this note already succeeded. A slow or failed follow-up run + // must not keep its decision open or invite a duplicate save. + void syncCloudVaultWithStatus(bridge, vaultName).catch(() => {}); + onResolved(remaining); } catch (cause) { setError(message(cause)); setReload((current) => ({ nonce: current.nonce + 1, keepError: true })); diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index 6355b481..d09cac08 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -10,6 +10,7 @@ import type { } from "@zennotes/bridge-contract/cloud-sync"; import { CloudSettings } from "./CloudSettings"; import { subscribePublishedNoteChanges } from "../lib/published-note-events"; +import { clearCloudSyncStatus, useCloudSyncStatusStore } from "../lib/cloud-auto-sync"; const mocks = vi.hoisted(() => ({ getCloudAccountStatus: vi.fn(), @@ -27,6 +28,10 @@ const mocks = vi.hoisted(() => ({ unlinkCloudVault: vi.fn(), deleteCloudVault: vi.fn(), syncCloudVault: vi.fn(), + getCloudConflict: vi.fn(), + saveCloudConflictDraft: vi.fn(), + resolveCloudConflict: vi.fn(), + releaseCloudConflictReview: vi.fn(), getCloudSettingsConflict: vi.fn(), resolveCloudSettingsConflict: vi.fn(), listCloudBackups: vi.fn(), @@ -124,6 +129,10 @@ describe("CloudSettings", () => { beforeEach(() => { vi.clearAllMocks(); + clearCloudSyncStatus(); + mocks.saveCloudConflictDraft.mockResolvedValue(undefined); + mocks.resolveCloudConflict.mockResolvedValue(undefined); + mocks.releaseCloudConflictReview.mockResolvedValue(undefined); mocks.listCloudPublishedNotes.mockResolvedValue([]); mocks.getCloudBackupSchedule.mockResolvedValue({ enabled: false, @@ -514,6 +523,94 @@ describe("CloudSettings", () => { } }); + it("refreshes Settings when follow-up sync clears the next conflict after a saved decision", async () => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", vault_id: "vault-1", vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + const pending: CloudSyncRunSummary = { + cursor: 7, pulled: 0, pushed: 0, conflicts: [], bootstrap_conflicts: [], local_conflicts: [], + pending_conflicts: ["First.md", "Second.md"].map((path) => ({ + id: path, item_id: path, path, cloud_path: path, kind: "content", can_merge: true, has_base: true, + })), + }; + const synced = { ...pending, cursor: 8, pending_conflicts: [] }; + mocks.getCloudConflict.mockImplementation(async (id: string) => { + const conflict = pending.pending_conflicts!.find((item) => item.id === id)!; + const version = { path: id, revision: 7, sha256: "agreed", byte_length: 6, + media_type: "text/markdown", text: "agreed", deleted: false }; + return { conflict, base: version, local: version, cloud: version, + suggested_text: "agreed", draft_text: null, changes: [], parts: [] }; + }); + let finishSync!: () => void; + mocks.syncCloudVaultWithStatus.mockImplementationOnce(() => new Promise((resolve) => { + finishSync = () => { + useCloudSyncStatusStore.setState({ lastSummary: synced }); + resolve(synced); + }; + })); + useCloudSyncStatusStore.setState({ lastSummary: pending }); + await act(async () => root.render(createElement(CloudSettings, { + localVaultAvailable: true, localVaultName: "Notes", + }))); + const button = (text: string) => [...host.querySelectorAll("button")] + .find((item) => item.textContent?.trim() === text)!; + + await act(async () => button("Resolve").click()); + await act(async () => button("Save combined note").click()); + expect(host.querySelector('[data-cloud-pending-conflict="Second.md"]')).not.toBeNull(); + expect(host.textContent).not.toContain("First.md"); + + await act(async () => finishSync()); + + expect(host.textContent).not.toContain("Second.md"); + expect(host.querySelector("[data-cloud-pending-conflict]")).toBeNull(); + expect(host.textContent).toContain("Everything is up to date"); + }); + + it("preserves explicit summaries between sync results and clears them when the active vault status resets", async () => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", vault_id: "vault-1", vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + const prior: CloudSyncRunSummary = { + cursor: 2, pulled: 1, pushed: 0, conflicts: [], bootstrap_conflicts: [], local_conflicts: [], + }; + const manual = { ...prior, cursor: 3, pulled: 2, pushed: 3 }; + useCloudSyncStatusStore.setState({ lastSummary: prior }); + mocks.syncCloudVault.mockResolvedValue(manual); + await act(async () => root.render(createElement(CloudSettings, { + localVaultAvailable: true, localVaultName: "Notes", + }))); + expect(host.textContent).toContain("Downloaded 1 · Uploaded 0"); + const sync = [...host.querySelectorAll("button")] + .find((item) => item.textContent?.trim() === "Sync now")!; + await act(async () => sync.click()); + expect(host.textContent).toContain("Downloaded 2 · Uploaded 3"); + + // A readiness or error update is not a newer sync result and must not + // replace an explicit summary returned by restore/bootstrap/manual work. + await act(async () => useCloudSyncStatusStore.setState({ phase: "ready" })); + expect(host.textContent).toContain("Downloaded 2 · Uploaded 3"); + await act(async () => clearCloudSyncStatus()); + expect(host.textContent).not.toContain("Downloaded 2 · Uploaded 3"); + expect(host.textContent).not.toContain("Everything is up to date"); + + await act(async () => root.render(null)); + useCloudSyncStatusStore.setState({ lastSummary: prior }); + await act(async () => root.render(createElement(CloudSettings, { + localVaultAvailable: true, localVaultName: "Another vault", + }))); + expect(host.textContent).toContain("Downloaded 1 · Uploaded 0"); + expect(host.textContent).not.toContain("Downloaded 2 · Uploaded 3"); + }); + it("clears a stale successful summary when a later manual sync times out", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index d2ecce3e..1211555d 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -86,6 +86,15 @@ export function CloudSettings({ const [action, setAction] = useState(null); const [error, setError] = useState(null); + useEffect(() => { + return useCloudSyncStatusStore.subscribe((next, previous) => { + // A saved decision updates this panel immediately, then the remaining + // vault sync may finish later. Adopt that result (or a vault reset), but + // keep explicit restore/manual summaries through unrelated status changes. + if (next.lastSummary !== previous.lastSummary) setSummary(next.lastSummary); + }); + }, []); + const loadStatus = useCallback( async (nextStatus?: CloudAccountStatus): Promise => { const next = nextStatus ?? (await bridge.getCloudAccountStatus()); @@ -2023,6 +2032,7 @@ function CloudSyncSummary({ ) && (
{ vi.useRealTimers(); }); + it("keeps the rest of a newly linked review queue when the shared summary is older", () => { + const oldSummary: CloudSyncRunSummary = { + cursor: 1, pulled: 0, pushed: 0, conflicts: [], bootstrap_conflicts: [], local_conflicts: [], + pending_conflicts: [], + }; + const conflict = { + id: "saved-note", item_id: "saved-note", path: "Note.md", cloud_path: "Note.md", + kind: "content" as const, can_merge: true, has_base: true, + }; + const other = { ...conflict, id: "other-note", item_id: "other-note", path: "Other.md" }; + useCloudSyncStatusStore.setState({ lastSummary: oldSummary }); + const next = acknowledgeCloudConflictResolution("saved-note", { + ...oldSummary, cursor: 2, pending_conflicts: [conflict, other], + }); + expect(next.pending_conflicts).toEqual([other]); + }); + + it("preserves the saved-note context when another sync listener reports the same failure", async () => { + const host = setup(); + let handlers!: CloudSyncWindowHandlers; + const runtime = startCloudAutoSync({ + ...host.bridge, + onCloudSyncWindow(next) { handlers = next; return () => {}; }, + }, host.environment); + await vi.advanceTimersByTimeAsync(1); + await flushPromises(); + const summary = useCloudSyncStatusStore.getState().lastSummary!; + acknowledgeCloudConflictResolution("saved-note", summary); + host.syncCloudVault.mockRejectedValueOnce(new Error("Connection timed out")); + try { + await expect(syncCloudVaultWithStatus(host.bridge)).rejects.toThrow("Connection timed out"); + handlers.finished(null, "Connection timed out"); + expect(useCloudSyncStatusStore.getState().error).toBe( + "Note saved. Remaining vault sync failed: Connection timed out", + ); + handlers.finished(summary, null); + handlers.finished(null, "A later unrelated error"); + expect(useCloudSyncStatusStore.getState().error).toBe("A later unrelated error"); + } finally { runtime.stop(); } + }); + it("flushes and locks a sibling window review, then closes it from the host's matching result", async () => { const host = setup(); let handlers!: CloudSyncWindowHandlers; diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 5465d71e..9855fa6d 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -58,6 +58,8 @@ interface CloudSyncStatusStore { conflictReviewOpen: boolean; /** Remains locked even if this window's own controller refreshes its status. */ syncWindowLocked: boolean; + /** A note was saved, but the following whole-vault sync has not completed. */ + resolutionSaved: boolean; } const emptyCloudSyncStatus: CloudSyncStatusStore = { @@ -68,6 +70,7 @@ const emptyCloudSyncStatus: CloudSyncStatusStore = { lastSummary: null, conflictReviewOpen: false, syncWindowLocked: false, + resolutionSaved: false, }; export const useCloudSyncStatusStore = create(() => ({ @@ -163,7 +166,12 @@ export function startCloudAutoSync( }, finished(summary, error) { if (summary) applyCloudSyncSummary(summary); - else if (error) useCloudSyncStatusStore.setState({ phase: "error", error }); + else if (error) { + useCloudSyncStatusStore.setState({ + phase: "error", + error: syncFailureMessage(error), + }); + } useCloudSyncStatusStore.setState({ syncWindowLocked: false }); }, }); @@ -237,12 +245,37 @@ export async function syncCloudVaultWithStatus( useCloudSyncStatusStore.setState({ phase: "error", vaultName: nextVaultName, - error: cloudSyncErrorMessage(error), + error: syncFailureMessage(error), }); throw error; } } +/** Retire only the acknowledged decision, not the status of the whole vault. */ +export function acknowledgeCloudConflictResolution( + conflictId: string, + fallbackSummary: CloudSyncRunSummary, +): CloudSyncRunSummary { + const current = useCloudSyncStatusStore.getState(); + // Linking or restoring can present a new queue before the shared run status + // catches up. Never replace that queue with an older, unrelated summary. + const previous = current.lastSummary?.pending_conflicts?.some( + (item) => item.id === conflictId, + ) ? current.lastSummary : fallbackSummary; + const summary = { + ...previous, + pending_conflicts: + previous.pending_conflicts?.filter((item) => item.id !== conflictId) ?? [], + }; + useCloudSyncStatusStore.setState({ + lastSummary: summary, + resolutionSaved: true, + conflictReviewOpen: + current.conflictReviewOpen && resolvableCloudConflictCount(summary) > 0, + }); + return summary; +} + function applyCloudSyncSummary(summary: CloudSyncRunSummary, vaultName?: string | null): void { const current = useCloudSyncStatusStore.getState(); const attention = cloudSyncAttentionMessage(summary); @@ -252,6 +285,7 @@ function applyCloudSyncSummary(summary: CloudSyncRunSummary, vaultName?: string lastSyncedAt: attention === null ? Date.now() : current.lastSyncedAt, error: attention, lastSummary: summary, + resolutionSaved: false, // Do not reopen a finished review on the next unrelated conflict. conflictReviewOpen: current.conflictReviewOpen && resolvableCloudConflictCount(summary) > 0, }); @@ -314,6 +348,7 @@ function markCloudSyncReady(vaultName: string): void { phase: "ready", vaultName, lastSyncedAt: current.vaultName === vaultName ? current.lastSyncedAt : null, + resolutionSaved: current.vaultName === vaultName && current.resolutionSaved, error: null, }); } @@ -323,6 +358,7 @@ function markCloudSyncDisconnected(error: string | null = null): void { phase: "disconnected", vaultName: null, lastSyncedAt: null, + resolutionSaved: false, error, }); } @@ -332,6 +368,7 @@ function markCloudSyncConnecting(): void { phase: "connecting", vaultName: null, lastSyncedAt: null, + resolutionSaved: false, error: null, }); } @@ -341,6 +378,7 @@ function markCloudSyncUnlinked(): void { phase: "unlinked", vaultName: null, lastSyncedAt: null, + resolutionSaved: false, error: null, }); } @@ -388,6 +426,13 @@ function logAutomaticSyncError(error: unknown, retryInMs: number): void { ); } +function syncFailureMessage(error: unknown): string { + const prefix = useCloudSyncStatusStore.getState().resolutionSaved + ? "Note saved. Remaining vault sync failed: " + : ""; + return prefix + cloudSyncErrorMessage(error); +} + function cloudSyncErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/shared-domain/src/cloud-sync-coordinator.test.ts b/packages/shared-domain/src/cloud-sync-coordinator.test.ts index 68fad3d0..c833709d 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.test.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.test.ts @@ -1605,6 +1605,230 @@ describe('CloudSyncCoordinator: converged pending conflicts', () => { }) }) +describe('CloudSyncCoordinator: safely merging queued conflicts', () => { + const path = 'inbox/Daily.md' + const baseText = '# Daily\nDate: 2026-09-09\n\nExisting notes.\n' + const localText = `${baseText}\nNew desktop content.\n` + const cloudText = baseText.replace('2026-09-09', '2026-09-10') + const mergedText = `${cloudText}\nNew desktop content.\n` + + function pendingState(): CloudSyncState & { + pending_conflicts: Record + } { + return { + version: 1, + vault_id: 'vault-1', + cursor: 2, + items: { daily: tracked('daily', path, 2, cloudText) }, + pending_conflicts: { + daily: { + id: 'daily', + item_id: 'daily', + kind: 'content', + sequence: 2, + base: { + path, + revision: 1, + kind: 'text', + content: realContent(baseText) + }, + local: { + path, + revision: null, + kind: 'text', + content: realContent(localText) + }, + cloud: { + path, + revision: 2, + kind: 'text', + content: realContent(cloudText) + } + } + } + } + } + + function acceptingRemote(changes: CloudSyncChange[] = []) { + return remote({ + changes, + mutate: (body) => ({ + acknowledged: body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, + item_id: mutation.item_id, + revision: 4, + sequence: 4 + })), + conflicts: [], + cursor: 4 + }) + }) + } + + it('reconsiders a persisted conflict and uploads safe top and bottom edits without a new remote change', async () => { + const fs = memoryFileSystem({ [path]: localText }) + const states = memoryState(pendingState()) + const server = acceptingRemote() + const coordinator = new CloudSyncCoordinator( + 'vault-1', + server, + new PortableCloudSyncRepository(fs), + states, + ids() + ) + + const result = await coordinator.sync() + + expect(result.pendingConflicts).toEqual([]) + expect(result.pushed).toBe(1) + expect(fs.files.get(path)).toBe(mergedText) + expect([...fs.files.keys()]).toEqual([path]) + expect(states.current?.pending_conflicts).toEqual({}) + expect(states.current?.items.daily).toMatchObject(tracked('daily', path, 4, mergedText)) + expect(server.mutations.flatMap((request) => request.mutations)).toEqual([ + expect.objectContaining({ + type: 'upsert', + item_id: 'daily', + path, + base_revision: 2, + content: expect.objectContaining({ data: mergedText }) + }) + ]) + await expect(coordinator.getConflict('daily')).rejects.toThrow('no longer waiting') + }) + + it('rechecks a queued conflict after a new Cloud revision makes its edits non-overlapping', async () => { + const initial = pendingState() + const overlappingText = `${baseText}\nDifferent mobile content.\n` + initial.items.daily = tracked('daily', path, 2, overlappingText) + initial.pending_conflicts.daily.cloud.content = realContent(overlappingText) + const states = memoryState(initial) + const fs = memoryFileSystem({ [path]: localText }) + const server = acceptingRemote([upsert(3, 'daily', path, cloudText)]) + + const result = await new CloudSyncCoordinator( + 'vault-1', + server, + new PortableCloudSyncRepository(fs), + states, + ids() + ).sync() + + expect(result.pendingConflicts).toEqual([]) + expect(result.pushed).toBe(1) + expect(fs.files.get(path)).toBe(mergedText) + expect(states.current?.pending_conflicts).toEqual({}) + expect(server.mutations.flatMap((request) => request.mutations)).toEqual([ + expect.objectContaining({ + type: 'upsert', + item_id: 'daily', + base_revision: 3, + content: expect.objectContaining({ data: mergedText }) + }) + ]) + }) + + it('uses the current local file rather than a queued snapshot when combining safe edits', async () => { + const latestLocalText = `${localText}Another desktop task.\n` + const latestMergedText = `${mergedText}Another desktop task.\n` + const fs = memoryFileSystem({ [path]: latestLocalText }) + const states = memoryState(pendingState()) + const server = acceptingRemote() + + const result = await new CloudSyncCoordinator( + 'vault-1', + server, + new PortableCloudSyncRepository(fs), + states, + ids() + ).sync() + + expect(result.pendingConflicts).toEqual([]) + expect(fs.files.get(path)).toBe(latestMergedText) + expect(server.mutations.flatMap((request) => request.mutations)).toEqual([ + expect.objectContaining({ + content: expect.objectContaining({ data: latestMergedText }) + }) + ]) + }) + + it('preserves a user-written resolution draft even when the queued edits can now merge safely', async () => { + const initial = pendingState() + const draftText = `${mergedText}My carefully edited resolution.\n` + initial.pending_conflicts.daily.draft_text = draftText + const fs = memoryFileSystem({ [path]: localText }) + const states = memoryState(initial) + const server = acceptingRemote() + const coordinator = new CloudSyncCoordinator( + 'vault-1', + server, + new PortableCloudSyncRepository(fs), + states, + ids() + ) + + const result = await coordinator.sync() + + expect(result.pendingConflicts).toEqual([expect.objectContaining({ id: 'daily' })]) + expect(states.current?.pending_conflicts?.daily.draft_text).toBe(draftText) + await expect(coordinator.getConflict('daily')).resolves.toMatchObject({ + draft_text: draftText, + suggested_text: mergedText, + changes: [] + }) + expect(fs.files.get(path)).toBe(localText) + expect(server.mutations).toEqual([]) + }) + + it('keeps genuinely overlapping edits queued for a user decision', async () => { + const initial = pendingState() + const overlappingLocalText = baseText.replace('2026-09-09', '2026-09-11') + initial.pending_conflicts.daily.local.content = realContent(overlappingLocalText) + const fs = memoryFileSystem({ [path]: overlappingLocalText }) + const states = memoryState(initial) + const server = acceptingRemote() + const coordinator = new CloudSyncCoordinator( + 'vault-1', + server, + new PortableCloudSyncRepository(fs), + states, + ids() + ) + + const result = await coordinator.sync() + + expect(result.pendingConflicts).toEqual([expect.objectContaining({ id: 'daily' })]) + expect((await coordinator.getConflict('daily')).changes.length).toBeGreaterThan(0) + expect(fs.files.get(path)).toBe(overlappingLocalText) + expect(server.mutations).toEqual([]) + }) + + it('preserves an edit racing the queued automatic merge and keeps the conflict pending', async () => { + const latestLocalText = `${localText}Typed while sync was running.\n` + const repository = memoryRepository([{ path, kind: 'text', content: realContent(localText) }]) + const replace = repository.replaceConflictFile!.bind(repository) + repository.replaceConflictFile = async (input) => { + repository.items[0]!.content = realContent(latestLocalText) + await replace(input) + } + const states = memoryState(pendingState()) + const server = acceptingRemote() + + const result = await new CloudSyncCoordinator( + 'vault-1', + server, + repository, + states, + ids() + ).sync() + + expect(repository.items[0]!.content.data).toBe(latestLocalText) + expect(result.pendingConflicts).toEqual([expect.objectContaining({ id: 'daily' })]) + expect(states.current?.pending_conflicts?.daily).toBeDefined() + expect(server.mutations).toEqual([]) + }) +}) + describe('CloudSyncCoordinator: catching up on a file this device never touched', () => { const path = 'inbox/Plan.md' diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index 806222e2..5bb6ac06 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -199,21 +199,19 @@ export class CloudSyncCoordinator { if (stored.cloud.revision !== resolution.expected_cloud_revision) { throw new Error('The Cloud version changed. Sync again before choosing a version.') } - const manifest = await this.stableManifest() - assertCloudSnapshotIsCurrent(stored, manifest) - - if (resolution.choice === 'cloud') { - await this.applyCloudChoice(await this.withCloudBytes(stored, manifest), local) - await this.saveWithoutConflict(state, stored.id) - return - } - - if (resolution.choice === 'both') { - await this.applyKeepBothChoice( - await this.withCloudBytes(stored, manifest), - local, - resolution.keep_both_path - ) + if (resolution.choice === 'cloud' || resolution.choice === 'both') { + // These choices consume Cloud bytes locally rather than writing a + // revision. Check freshness using metadata, fetching only this file's + // retained bytes when they are not already in the conflict snapshot. + // Older hosts without a revision reader still need the content manifest. + const needsContentFallback = + !this.remote.revision && stored.cloud.content !== null && + !hasInlineData(stored.cloud.content) + const manifest = await this.stableManifest(needsContentFallback) + assertCloudSnapshotIsCurrent(stored, manifest) + const current = await this.withCloudBytes(stored, manifest) + if (resolution.choice === 'cloud') await this.applyCloudChoice(current, local) + else await this.applyKeepBothChoice(current, local, resolution.keep_both_path) await this.saveWithoutConflict(state, stored.id) return } @@ -264,6 +262,9 @@ export class CloudSyncCoordinator { throw new Error('The resolved file path is no longer available.') } const request = { mutations: [mutation] } + // The server atomically checks base_revision and destination ownership. + // A full-vault content download cannot strengthen that check and makes + // resolving one small note depend on every unrelated asset in the vault. const response = await this.remote.mutate(this.vaultId, request) const conflict = response.conflicts.find((item) => item.operation_id === operationId) if (conflict) { @@ -401,13 +402,20 @@ export class CloudSyncCoordinator { pulled += initialPull.pulled localConflicts.push(...initialPull.localConflicts) - const localItems = await this.repository.scan() + let localItems = await this.repository.scan() const repositoryPendingPaths = (await this.repository.pendingConflictPaths?.()) ?? [] const reconciled = clearConvergedConflicts(state, localItems, repositoryPendingPaths) if (reconciled !== state) { state = reconciled await this.states.save(state) } + const merged = await this.retryPendingMerges(state, localItems, repositoryPendingPaths) + if (merged !== state) { + state = merged + await this.states.save(state) + // Plan from the merged bytes, never from the scan preceding the write. + localItems = await this.repository.scan() + } const pendingPathKeys = new Set([ ...repositoryPendingPaths.map(cloudSyncPathKey), ...pendingConflictPaths(state).map(cloudSyncPathKey) @@ -607,6 +615,72 @@ export class CloudSyncCoordinator { return { state, pulled, localConflicts } } + private async retryPendingMerges( + initialState: CloudSyncState, + localItems: CloudSyncLocalItem[], + repositoryPendingPaths: string[] + ): Promise { + let state = initialState + const blocked = new Set(repositoryPendingPaths.map(cloudSyncPathKey)) + const locals = new Map() + for (const item of localItems) { + const key = cloudSyncPathKey(item.path) + if (locals.has(key)) blocked.add(key) + locals.set(key, item) + } + const pendingPaths = new Set() + for (const conflict of Object.values(state.pending_conflicts ?? {})) { + const keys = new Set( + pendingConflictPaths({ + ...state, + pending_conflicts: { [conflict.id]: conflict } + }).map(cloudSyncPathKey) + ) + for (const key of keys) { + if (pendingPaths.has(key)) blocked.add(key) + pendingPaths.add(key) + } + } + for (const conflict of Object.values(state.pending_conflicts ?? {})) { + const cloud = conflict.cloud + const tracked = state.items[conflict.item_id] + if ( + conflict.kind !== 'content' || + conflict.draft_text !== undefined || + (conflict.paused_paths?.length ?? 0) > 0 || + cloud.path === null || + cloud.path !== conflict.local.path || + cloud.path !== conflict.base.path || + cloud.kind !== 'text' || + !cloud.content || + !tracked || + tracked.item_id !== conflict.item_id || + tracked.path !== cloud.path || + tracked.revision !== cloud.revision || + tracked.kind !== cloud.kind || + tracked.sha256 !== cloud.content.sha256 || + tracked.byte_length !== cloud.content.byte_length + ) { + continue + } + const key = cloudSyncPathKey(cloud.path) + if (blocked.has(key)) continue + const local = locals.get(key) + if (!local || local.path !== cloud.path || local.kind !== 'text') continue + // Identical bytes have their own stricter convergence checks above. + if (inlineText(local.content) === inlineText(cloud.content)) continue + if ( + await this.applyAutomaticMerge({ + ...conflict, + local: { path: local.path, kind: local.kind, revision: null, content: local.content } + }) + ) { + state = withoutConflict(state, conflict.id) + } + } + return state + } + private async applyAutomaticMerge(conflict: CloudSyncStoredConflict): Promise { const base = conflict.base.content const local = conflict.local.content @@ -955,7 +1029,7 @@ export class CloudSyncCoordinator { } } - private async stableManifest(): Promise<{ + private async stableManifest(includeContent = true): Promise<{ cursor: number items: CloudSyncManifestItem[] }> { @@ -967,7 +1041,7 @@ export class CloudSyncCoordinator { for (;;) { const response = await this.remote.manifest(this.vaultId, { - includeContent: true, + includeContent, page, perPage: MANIFEST_PAGE_SIZE }) diff --git a/packages/shared-domain/src/cloud-sync-resolution.test.ts b/packages/shared-domain/src/cloud-sync-resolution.test.ts new file mode 100644 index 00000000..99e082e1 --- /dev/null +++ b/packages/shared-domain/src/cloud-sync-resolution.test.ts @@ -0,0 +1,213 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import type { CloudSyncContent } from '@zennotes/bridge-contract/cloud-sync' +import { CloudSyncCoordinator, type CloudSyncRemote } from './cloud-sync-coordinator' +import type { CloudSyncState, CloudSyncStoredConflict } from './cloud-sync-engine' + +function content(data: string): CloudSyncContent { + return { + encoding: 'utf8', + data, + sha256: createHash('sha256').update(data).digest('hex'), + byte_length: Buffer.byteLength(data), + media_type: 'text/markdown' + } +} + +function fixture(withRevision = true) { + const cloud = content('cloud'), + local = content('local') + const pending: CloudSyncStoredConflict = { + id: 'note', + item_id: 'note', + kind: 'content', + sequence: 2, + base: { path: 'Note.md', revision: 1, kind: 'text', content: content('base') }, + local: { path: 'Note.md', revision: null, kind: 'text', content: local }, + cloud: { path: 'Note.md', revision: 2, kind: 'text', content: cloud } + } + let state: CloudSyncState = { + version: 1, + vault_id: 'vault', + cursor: 2, + items: { + note: { + item_id: 'note', + path: 'Note.md', + kind: 'text', + revision: 2, + sha256: cloud.sha256, + byte_length: cloud.byte_length, + media_type: cloud.media_type + } + }, + pending_conflicts: { note: pending } + } + const manifest = vi.fn().mockRejectedValue(new Error('Full vault download timed out')) + const mutate = vi.fn().mockResolvedValue({ + acknowledged: [{ operation_id: 'save-note', item_id: 'note', revision: 3, sequence: 3 }], + conflicts: [], + cursor: 3 + }) + const write = vi.fn().mockResolvedValue(undefined) + const revision = vi.fn>() + const remote: CloudSyncRemote = { + manifest, + mutate, + changes: vi.fn(), + ...(withRevision ? { revision } : {}) + } + const coordinator = new CloudSyncCoordinator( + 'vault', + remote, + { + scan: async () => [{ path: 'Note.md', kind: 'text', content: local }], + apply: vi.fn(), + applyConflictResolutionFiles: write + }, + { + load: async () => state, + save: async (next) => { + state = next + } + }, + { operationId: () => 'save-note', itemId: () => 'new-note' } + ) + const resolution = { + conflict_id: 'note', + choice: 'merged' as const, + merged_text: 'combined', + expected_local_sha256: local.sha256, + expected_cloud_revision: 2 + } + return { coordinator, resolution, manifest, mutate, revision, write, state: () => state } +} + +describe('single-note Cloud resolution', () => { + it('saves against the reviewed revision without downloading the vault', async () => { + const f = fixture() + await f.coordinator.resolveConflict(f.resolution) + expect(f.manifest).not.toHaveBeenCalled() + expect(f.mutate).toHaveBeenCalledWith('vault', { + mutations: [ + expect.objectContaining({ + item_id: 'note', + base_revision: 2, + content: content('combined') + }) + ] + }) + expect(f.write).toHaveBeenCalledOnce() + expect(f.state().pending_conflicts).toEqual({}) + expect(f.state().items.note.revision).toBe(3) + }) + + it('preserves the local file and decision when the server rejects a stale revision', async () => { + const f = fixture() + f.mutate.mockResolvedValue({ + acknowledged: [], + cursor: 3, + conflicts: [ + { + operation_id: 'save-note', + code: 'REVISION_CONFLICT', + item_id: 'note', + current_revision: 3, + current_path: 'Note.md' + } + ] + }) + await expect(f.coordinator.resolveConflict(f.resolution)).rejects.toThrow( + 'Cloud version changed while saving' + ) + expect(f.mutate).toHaveBeenCalledOnce() + expect(f.write).not.toHaveBeenCalled() + expect(f.state().pending_conflicts?.note).toBeDefined() + }) + + it('keeps the decision retryable if the save itself times out', async () => { + const f = fixture() + f.mutate.mockRejectedValue(new Error('Save request timed out')) + await expect(f.coordinator.resolveConflict(f.resolution)).rejects.toThrow( + 'Save request timed out' + ) + expect(f.write).not.toHaveBeenCalled() + expect(f.state().pending_conflicts?.note).toBeDefined() + }) + + it.each(['cloud', 'both'] as const)( + 'fetches only the selected revision when keeping %s', + async (choice) => { + const f = fixture() + f.state().pending_conflicts!.note.cloud.content = { ...content('cloud'), data: '' } + f.manifest.mockResolvedValue({ data: [f.state().items.note], cursor: 2, next_page: null }) + f.revision.mockResolvedValue({ + data: { + item_id: 'note', + path: 'Note.md', + revision: 2, + kind: 'text', + deleted: false, + content: content('cloud') + } + }) + await f.coordinator.resolveConflict({ ...f.resolution, choice, keep_both_path: 'My copy.md' }) + expect(f.manifest).toHaveBeenCalledWith('vault', { + includeContent: false, + page: 1, + perPage: 250 + }) + expect(f.revision).toHaveBeenCalledExactlyOnceWith('vault', 'note', 2) + expect(f.mutate).not.toHaveBeenCalled() + expect(f.write).toHaveBeenCalledWith( + expect.objectContaining({ + files: expect.arrayContaining([{ path: 'Note.md', content: content('cloud') }]) + }) + ) + expect(f.state().pending_conflicts).toEqual({}) + } + ) + + it('rejects a retained revision whose hash does not match the reviewed file', async () => { + const f = fixture() + f.state().pending_conflicts!.note.cloud.content = { ...content('cloud'), data: '' } + f.manifest.mockResolvedValue({ data: [f.state().items.note], cursor: 2, next_page: null }) + f.revision.mockResolvedValue({ + data: { + item_id: 'note', + path: 'Note.md', + revision: 2, + kind: 'text', + deleted: false, + content: content('wrong bytes') + } + }) + await expect( + f.coordinator.resolveConflict({ ...f.resolution, choice: 'cloud' }) + ).rejects.toThrow('not available') + expect(f.write).not.toHaveBeenCalled() + expect(f.state().pending_conflicts?.note).toBeDefined() + }) + + it('retains the content-manifest fallback for hosts without revision reads', async () => { + const f = fixture(false) + f.state().pending_conflicts!.note.cloud.content = { ...content('cloud'), data: '' } + f.manifest.mockImplementation(async (_vault, options) => ({ + data: [ + { + ...f.state().items.note, + ...(options.includeContent ? { content: content('cloud') } : {}) + } + ], + cursor: 2, + next_page: null + })) + await f.coordinator.resolveConflict({ ...f.resolution, choice: 'cloud' }) + expect(f.manifest).toHaveBeenCalledWith('vault', { + includeContent: true, + page: 1, + perPage: 250 + }) + expect(f.write).toHaveBeenCalledOnce() + }) +}) From 55cafa087f4b5a6b128cad11c5120a604c85871c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 09:21:05 -0500 Subject: [PATCH 2/6] fix(cloud): close upload streams and keep sync status current Dispose native upload readers before reservation cleanup on failures and early responses. Keep Cloud settings aligned with shared sync progress, errors, and recovery without losing conflict drafts. Add real HTTP upload, retry, restart, and UI status regression coverage. --- .../src/main/cloud-sync-client.test.ts | 53 +++- apps/desktop/src/main/cloud-sync-client.ts | 39 ++- .../main/cloud-sync-upload-network.test.ts | 226 +++++++++++++++++ .../src/components/CloudSettings.test.ts | 230 +++++++++++++++++- .../app-core/src/components/CloudSettings.tsx | 70 ++++-- 5 files changed, 579 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/main/cloud-sync-upload-network.test.ts diff --git a/apps/desktop/src/main/cloud-sync-client.test.ts b/apps/desktop/src/main/cloud-sync-client.test.ts index d85f2fcc..7adc6534 100644 --- a/apps/desktop/src/main/cloud-sync-client.test.ts +++ b/apps/desktop/src/main/cloud-sync-client.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import * as nodeFs from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -6,10 +7,13 @@ import type { CloudSyncUpsertMutation } from '@zennotes/bridge-contract/cloud-sy import { CloudServiceRequestError, createCloudSyncClient } from './cloud-sync-client' import { rememberCloudSyncUploadSource } from './cloud-sync-upload-source' +vi.mock('node:fs', { spy: true }) + const INLINE_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 const temporaryDirectories: string[] = [] afterEach(async () => { + vi.restoreAllMocks() await Promise.all( temporaryDirectories .splice(0) @@ -18,6 +22,51 @@ afterEach(async () => { }) describe('createCloudSyncClient', () => { + it.each(['reject', 'early response'] as const)( + 'closes the file source before releasing the reservation after an upload %s', + async (failure) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-upload-failure-')) + temporaryDirectories.push(directory) + const sourcePath = path.join(directory, 'image.jpg') + const bytes = Buffer.alloc(8_100_000, 7) + await writeFile(sourcePath, bytes) + const mutation = upsertMutation(bytes.length, '') + rememberCloudSyncUploadSource(mutation.content, sourcePath) + const openFile = vi.spyOn(nodeFs, 'createReadStream') + let destroyedBeforeAbort = false + const fetchImplementation = vi.fn(async (input, options) => { + const url = String(input) + if (url.endsWith('/uploads') && options?.method === 'POST') { + return jsonResponse({ + data: { + id: 'interrupted', + operation_id: mutation.operation_id, + expected_bytes: bytes.length, + upload: { method: 'PUT', url: 'https://objects.example.test/image', headers: {} } + } + }) + } + if (url === 'https://objects.example.test/image') { + if (failure === 'reject') throw new TypeError('fetch failed') + return new Response(null, { status: 503 }) + } + if (options?.method === 'DELETE') { + destroyedBeforeAbort = openFile.mock.results[0]?.value.destroyed === true + return new Response(null, { status: 204 }) + } + throw new Error(`Unexpected request: ${url}`) + }) + try { + const client = createCloudSyncClient('https://zennotes.test', 'token', fetchImplementation) + await expect(client.mutate('vault', { mutations: [mutation] })).rejects.toThrow() + expect(destroyedBeforeAbort).toBe(true) + } finally { + // Keep the regression itself from leaking the old implementation's reader. + for (const result of openFile.mock.results) result.value?.destroy() + } + } + ) + it('authenticates requests without exposing the token in the URL', async () => { const fetchImplementation = vi.fn().mockResolvedValue( new Response(JSON.stringify({ data: [] }), { @@ -212,7 +261,9 @@ describe('createCloudSyncClient', () => { ) } if (url.startsWith('https://objects.example.test/')) { - uploadedBodies.push(Buffer.from(await new Response(options?.body).arrayBuffer())) + const chunks: Uint8Array[] = [] + for await (const chunk of options?.body as AsyncIterable) chunks.push(chunk) + uploadedBodies.push(Buffer.concat(chunks)) return new Response(null, { status: 200 }) } if (url.endsWith('/complete')) { diff --git a/apps/desktop/src/main/cloud-sync-client.ts b/apps/desktop/src/main/cloud-sync-client.ts index e3fffcf8..3d1e9a3a 100644 --- a/apps/desktop/src/main/cloud-sync-client.ts +++ b/apps/desktop/src/main/cloud-sync-client.ts @@ -3,9 +3,8 @@ import { type CloudSyncHttpRequest, type CloudSyncHttpTransport } from '@zennotes/shared-domain/cloud-sync-api' -import { createReadStream } from 'node:fs' +import { createReadStream, type ReadStream } from 'node:fs' import { stat } from 'node:fs/promises' -import { Readable } from 'node:stream' import type { CloudSyncCapacityConflict, CloudSyncConflict, @@ -124,18 +123,25 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { let response: Response try { - response = await this.fetchImplementation(uploadUrl, { - method: upload.method, - headers: upload.headers, - body: uploadBody.createBody(), - signal: AbortSignal.timeout(DIRECT_UPLOAD_TIMEOUT_MS), - redirect: 'error', - ...(uploadBody.stream ? { duplex: 'half' } : {}) - }) + try { + response = await this.fetchImplementation(uploadUrl, { + method: upload.method, + headers: upload.headers, + body: uploadBody.createBody(), + signal: AbortSignal.timeout(DIRECT_UPLOAD_TIMEOUT_MS), + redirect: 'error', + ...(uploadBody.stream ? { duplex: 'half' } : {}) + }) + } finally { + // A server can reject the PUT before reading the file. Stop its reader + // before waiting for reservation cleanup (including on timeout/abort). + uploadBody.dispose() + } } catch (error) { await this.abortQuietly(vaultId, instruction.id) throw error } + await response.body?.cancel().catch(() => {}) if (!response.ok) { await this.abortQuietly(vaultId, instruction.id) @@ -296,22 +302,29 @@ function uploadRequest(mutation: CloudSyncUpsertMutation): CloudSyncUploadReques async function prepareDirectUploadBody( mutation: CloudSyncUpsertMutation -): Promise<{ createBody(): FetchBody; stream: boolean }> { +): Promise<{ createBody(): FetchBody; dispose(): void; stream: boolean }> { const sourcePath = cloudSyncUploadSource(mutation.content) if (sourcePath) { const sourceStats = await stat(sourcePath) if (!sourceStats.isFile() || sourceStats.size !== mutation.content.byte_length) { throw directUploadSizeMismatch() } + let reader: ReadStream | undefined return { - createBody: () => Readable.toWeb(createReadStream(sourcePath)) as unknown as FetchBody, + // Node fetch accepts async iterables directly. Avoid the event-based + // toWeb adapter: late file events after cancellation can enqueue into + // a closed WebStream controller outside the fetch promise's catch. + createBody: () => (reader = createReadStream(sourcePath)) as unknown as FetchBody, + dispose: () => { + reader?.destroy() + }, stream: true } } const bytes = uploadBytes(mutation) if (bytes.byteLength !== mutation.content.byte_length) throw directUploadSizeMismatch() - return { createBody: () => bytes as FetchBody, stream: false } + return { createBody: () => bytes as FetchBody, dispose: () => {}, stream: false } } function uploadBytes(mutation: CloudSyncUpsertMutation): Uint8Array { diff --git a/apps/desktop/src/main/cloud-sync-upload-network.test.ts b/apps/desktop/src/main/cloud-sync-upload-network.test.ts new file mode 100644 index 00000000..dd3f8edd --- /dev/null +++ b/apps/desktop/src/main/cloud-sync-upload-network.test.ts @@ -0,0 +1,226 @@ +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { CloudSyncUpsertMutation } from '@zennotes/bridge-contract/cloud-sync' +import { createCloudSyncClient } from './cloud-sync-client' +import { rememberCloudSyncUploadSource } from './cloud-sync-upload-source' +import { createDesktopCloudSyncCoordinator } from './cloud-sync-filesystem' + +const servers: Server[] = [] +const directories: string[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map(async (server) => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + }) + ) + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('disk-backed Cloud uploads over HTTP', () => { + it('rebuilds an interrupted upload from disk after recreating the sync coordinator', async () => { + const fixture = await setup(8_100_000, 'disconnect') + const coordinator = () => + createDesktopCloudSyncCoordinator({ + root: fixture.localRoot, + stateDirectory: fixture.stateDirectory, + vaultId: 'vault', + remote: fixture.client() + }) + await expect(coordinator().sync()).rejects.toThrow() + fixture.recover() + // Recreate the repository, state store and client: no prior content object + // or WeakMap upload source is reused, just the on-disk vault and sync state. + expect((await coordinator().sync()).pushed).toBe(1) + expect((await coordinator().sync()).pushed).toBe(0) + expect(fixture.completions()).toBe(1) + expect(sha256(await readFile(path.join(fixture.localRoot, 'assets/image.jpg')))).toBe( + fixture.mutation.content.sha256 + ) + const downloaded = Buffer.from(await (await fetch(`${fixture.url}/object`)).arrayBuffer()) + expect(sha256(downloaded)).toBe(fixture.mutation.content.sha256) + }) + + it.each([8_100_000, 10_000_000])( + 'round-trips all %i bytes through a real upload and download', + async (size) => { + const fixture = await setup(size) + const result = await fixture.client().mutate('vault', { mutations: [fixture.mutation] }) + expect(result.acknowledged).toHaveLength(1) + const downloaded = Buffer.from(await (await fetch(`${fixture.url}/object`)).arrayBuffer()) + expect(downloaded.length).toBe(size) + expect(sha256(downloaded)).toBe(fixture.mutation.content.sha256) + } + ) + + it.each(['reject', 'disconnect', 'timeout'] as const)( + 'survives an upload %s and retries the same file with a fresh client', + async (failure) => { + const fixture = await setup(8_100_000, failure) + await expect( + fixture.client().mutate('vault', { mutations: [fixture.mutation] }) + ).rejects.toThrow() + expect(fixture.aborts()).toBe(1) + expect(fixture.completions()).toBe(0) + fixture.recover() + // A new client has no in-memory upload state, as after an app restart. + const result = await fixture.client().mutate('vault', { mutations: [fixture.mutation] }) + expect(result.acknowledged).toHaveLength(1) + const downloaded = Buffer.from(await (await fetch(`${fixture.url}/object`)).arrayBuffer()) + expect(sha256(downloaded)).toBe(fixture.mutation.content.sha256) + } + ) +}) + +async function setup( + size: number, + initialFailure: 'reject' | 'disconnect' | 'timeout' | null = null +) { + const directory = await mkdtemp(path.join(tmpdir(), 'zennotes-upload-network-')) + directories.push(directory) + const localRoot = path.join(directory, 'vault') + await mkdir(path.join(localRoot, 'assets'), { recursive: true }) + const source = path.join(localRoot, 'assets/image.jpg') + const bytes = Buffer.alloc(size) + for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251 + await writeFile(source, bytes) + const mutation: CloudSyncUpsertMutation = { + type: 'upsert', + operation_id: 'upload-operation', + item_id: 'image', + base_revision: 0, + path: 'assets/image.jpg', + kind: 'binary', + content: rememberCloudSyncUploadSource( + { + encoding: 'base64', + data: '', + byte_length: bytes.length, + sha256: sha256(bytes), + media_type: 'image/jpeg' + }, + source + ) + } + let failure = initialFailure + let stored: Buffer | null = null + let aborts = 0 + let completions = 0 + let uploadMutation = mutation + const server = createServer((request, response) => { + request.on('error', () => {}) + const json = (status: number, body: unknown) => { + response.writeHead(status, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(body)) + } + if (request.url?.endsWith('/uploads') && request.method === 'POST') { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + request.on('end', () => { + uploadMutation = JSON.parse(Buffer.concat(chunks).toString()) as CloudSyncUpsertMutation + json(201, { + data: { + id: 'session', + operation_id: uploadMutation.operation_id, + expected_bytes: bytes.length, + upload: { + method: 'PUT', + url: `${url}/object`, + headers: { 'Content-Length': String(bytes.length) } + } + } + }) + }) + } else if (request.url === '/object' && request.method === 'PUT') { + if (failure === 'reject') { + response.writeHead(403, { Connection: 'close' }) + response.end() + } else if (failure === 'disconnect') { + request.once('data', () => request.socket.destroy()) + } else if (failure === 'timeout') { + request.pause() + } else { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + request.on('end', () => { + stored = Buffer.concat(chunks) + response.writeHead(200) + response.end() + }) + } + } else if (request.method === 'DELETE') { + aborts++ + response.writeHead(204) + response.end() + } else if (request.url?.endsWith('/complete')) { + completions++ + request.resume() + if (!stored || sha256(stored) !== mutation.content.sha256) { + json(422, { error: { message: 'Upload is incomplete' } }) + } else { + json(200, { + data: { + result: { + acknowledged: [ + { + operation_id: uploadMutation.operation_id, + item_id: uploadMutation.item_id, + revision: 1, + sequence: 1 + } + ], + conflicts: [], + cursor: 1 + } + } + }) + } + } else if (request.url === '/object' && request.method === 'GET' && stored) { + response.end(stored) + } else if (request.url?.includes('/manifest')) { + json(200, { data: [], cursor: 0, next_page: null }) + } else if (request.url?.includes('/changes')) { + json(200, { data: [], cursor: stored ? 1 : 0, has_more: false }) + } else { + json(404, {}) + } + }) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing test server address') + const url = `http://127.0.0.1:${address.port}` + // Exercise the real fetch cancellation path without waiting the production five-minute timeout. + const transport: typeof fetch = (input, options) => + fetch(input, { + ...options, + ...(failure === 'timeout' && options?.method === 'PUT' + ? { signal: AbortSignal.timeout(100) } + : {}) + }) + return { + url, + localRoot, + stateDirectory: path.join(directory, 'state'), + mutation, + client: () => createCloudSyncClient(url, 'test-token', transport), + recover: () => { + failure = null + }, + aborts: () => aborts, + completions: () => completions + } +} + +function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex') +} diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index d09cac08..0e201505 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -127,7 +127,7 @@ describe("CloudSettings", () => { let host: HTMLDivElement; let root: Root; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); clearCloudSyncStatus(); mocks.saveCloudConflictDraft.mockResolvedValue(undefined); @@ -140,9 +140,8 @@ describe("CloudSettings", () => { next_backup_at: null, last_backup_at: null, }); - mocks.syncCloudVaultWithStatus.mockImplementation(() => - mocks.syncCloudVault(), - ); + const actual = await vi.importActual("../lib/cloud-auto-sync"); + mocks.syncCloudVaultWithStatus.mockImplementation(actual.syncCloudVaultWithStatus); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -611,6 +610,229 @@ describe("CloudSettings", () => { expect(host.textContent).not.toContain("Downloaded 2 · Uploaded 3"); }); + describe("live sync status", () => { + let syncWithStatus: typeof import("../lib/cloud-auto-sync").syncCloudVaultWithStatus; + const completed: CloudSyncRunSummary = { + cursor: 7, + pulled: 0, + pushed: 0, + conflicts: [], + bootstrap_conflicts: [], + local_conflicts: [], + }; + + beforeEach(async () => { + const actual = await vi.importActual( + "../lib/cloud-auto-sync", + ); + syncWithStatus = actual.syncCloudVaultWithStatus; + mocks.syncCloudVaultWithStatus.mockImplementation(syncWithStatus); + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + mocks.getCloudSettingsConflict.mockResolvedValue(null); + mocks.syncCloudVault.mockResolvedValue(completed); + await act(async () => root.render(createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }))); + }); + + it.each(["Settings", "editor"] as const)( + "replaces an earlier successful result when a sync from %s fails", + async (source) => { + await act(async () => { await syncWithStatus(mocks, "Cloud Notes"); }); + expect(host.textContent).toContain("All changes are synced."); + mocks.syncCloudVault.mockRejectedValueOnce(new Error("Cloud sync timed out.")); + + await act(async () => { + if (source === "Settings") { + [...host.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "Sync now")!.click(); + } else { + await expect(syncWithStatus(mocks, "Cloud Notes")) + .rejects.toThrow("Cloud sync timed out."); + } + }); + + expect(useCloudSyncStatusStore.getState().phase).toBe("error"); + expect(host.textContent).toContain("Cloud sync timed out."); + expect(host.textContent).not.toContain("Everything is up to date"); + expect(host.textContent).not.toContain("All changes are synced."); + }, + ); + + it("removes a Settings sync failure when a later editor retry succeeds", async () => { + mocks.syncCloudVault.mockRejectedValueOnce(new Error("Cloud sync timed out.")); + await act(async () => { + [...host.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "Sync now")!.click(); + }); + expect(host.textContent).toContain("Cloud sync timed out."); + + await act(async () => { await syncWithStatus(mocks, "Cloud Notes"); }); + + expect(useCloudSyncStatusStore.getState().phase).toBe("ready"); + expect(host.textContent).toContain("Everything is up to date"); + expect(host.textContent).not.toContain("Cloud sync timed out."); + }); + + it("does not claim all changes are synced while an editor retry is still uploading", async () => { + await act(async () => { await syncWithStatus(mocks, "Cloud Notes"); }); + let finishUpload!: (summary: CloudSyncRunSummary) => void; + mocks.syncCloudVault.mockImplementationOnce(() => new Promise((resolve) => { + finishUpload = resolve; + })); + let retry!: Promise; + await act(async () => { retry = syncWithStatus(mocks, "Cloud Notes"); }); + try { + expect(useCloudSyncStatusStore.getState().phase).toBe("syncing"); + expect(host.textContent).not.toContain("Everything is up to date"); + expect(host.textContent).not.toContain("All changes are synced."); + expect(host.textContent).toContain("Syncing"); + } finally { + await act(async () => { + finishUpload({ ...completed, cursor: 8, pushed: 1 }); + await retry; + }); + } + expect(host.textContent).toContain("Downloaded 0 · Uploaded 1"); + expect(host.textContent).toContain("All changes are synced."); + }); + + it.each(["publishing", "backup"] as const)( + "preserves an unrelated %s error when background sync fails and recovers", + async (operation) => { + const actionError = operation === "publishing" + ? "Could not load published notes." + : "Could not create the backup."; + if (operation === "backup") { + await act(async () => root.render(null)); + mocks.getCloudServiceAccount.mockResolvedValue({ + ...serviceAccount, + features: { + ...serviceAccount.features, + backup: { active: true, limits: { max_snapshots: 30 } }, + }, + }); + mocks.listCloudBackups.mockResolvedValue([]); + mocks.createCloudBackup.mockRejectedValueOnce(new Error(actionError)); + await act(async () => root.render(createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }))); + } else { + mocks.listCloudPublishedNotes.mockRejectedValueOnce(new Error(actionError)); + } + await act(async () => { + [...host.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === ( + operation === "publishing" ? "Refresh" : "Create backup" + ))!.click(); + }); + expect(host.textContent).toContain(actionError); + + mocks.syncCloudVault.mockRejectedValueOnce(new Error("Cloud sync timed out.")); + await act(async () => { + await expect(syncWithStatus(mocks, "Cloud Notes")) + .rejects.toThrow("Cloud sync timed out."); + }); + expect(host.textContent).toContain("Cloud sync timed out."); + expect(host.textContent).toContain(actionError); + + await act(async () => { await syncWithStatus(mocks, "Cloud Notes"); }); + + expect(host.textContent).toContain("All changes are synced."); + expect(host.textContent).not.toContain("Cloud sync timed out."); + expect(host.textContent).toContain(actionError); + }, + ); + + it("keeps an open conflict and its draft available during an upload and after a sync failure", async () => { + const conflict = { + id: "note-1", + item_id: "note-1", + path: "Daily.md", + cloud_path: "Daily.md", + kind: "content" as const, + can_merge: true, + has_base: true, + }; + const pending: CloudSyncRunSummary = { + ...completed, + pending_conflicts: [conflict], + }; + const version = { + path: "Daily.md", + revision: 7, + sha256: "local-version", + byte_length: 10, + media_type: "text/markdown", + text: "Local note", + deleted: false, + }; + mocks.getCloudConflict.mockResolvedValue({ + conflict, + base: { ...version, text: "Base note" }, + local: version, + cloud: { ...version, sha256: "cloud-version", text: "Cloud note" }, + suggested_text: "Combined note", + draft_text: null, + changes: [], + parts: [], + }); + mocks.syncCloudVault.mockResolvedValueOnce(pending); + await act(async () => { await syncWithStatus(mocks, "Cloud Notes"); }); + await act(async () => { + [...host.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "Resolve")!.click(); + }); + const draftSelector = '[data-cloud-pending-conflict="note-1"] textarea'; + const draft = host.querySelector(draftSelector)!; + const userDraft = "My unfinished conflict review"; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")! + .set!.call(draft, userDraft); + draft.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(draft.value).toBe(userDraft); + + let failUpload!: (cause: Error) => void; + mocks.syncCloudVault.mockImplementationOnce(() => new Promise((_, reject) => { + failUpload = reject; + })); + let retry!: Promise; + await act(async () => { + retry = syncWithStatus(mocks, "Cloud Notes").catch((cause: unknown) => cause); + }); + try { + expect(host.textContent).toContain("Waiting for all changes to finish."); + expect(host.querySelector('[aria-label="Files that need attention"]')?.textContent) + .toContain("Daily.md"); + expect(host.querySelector(draftSelector)?.value).toBe(userDraft); + expect(host.querySelector(draftSelector)?.disabled).toBe(true); + } finally { + await act(async () => { + failUpload(new Error("Cloud sync timed out.")); + await retry; + }); + } + + expect(host.textContent).toContain("Cloud sync timed out."); + expect(host.querySelector('[aria-label="Files that need attention"]')?.textContent) + .toContain("Daily.md"); + expect(host.querySelector(draftSelector)?.value).toBe(userDraft); + expect(host.querySelector(draftSelector)?.disabled).toBe(false); + expect(host.textContent).not.toContain("All changes are synced."); + }); + }); + it("clears a stale successful summary when a later manual sync times out", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 1211555d..3320bf73 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -254,9 +254,13 @@ export function CloudSettings({ try { await operation(); } catch (cause) { - setError( - errorMessage(cause, "ZenNotes Cloud could not complete that action."), - ); + // Sync errors already live in the shared status store. Duplicating one + // here leaves it visible after a successful editor/background retry. + if (nextAction !== "sync") { + setError( + errorMessage(cause, "ZenNotes Cloud could not complete that action."), + ); + } } finally { setAction(null); } @@ -1034,6 +1038,12 @@ function CloudVaultPanel({ onSummaryChange: (summary: CloudSyncRunSummary) => void; }): JSX.Element { const lastSummary = useCloudSyncStatusStore((s) => s.lastSummary); + const syncPhase = useCloudSyncStatusStore((s) => s.phase); + const syncError = useCloudSyncStatusStore((s) => s.error); + const syncing = syncPhase === "syncing" || action === "sync"; + const syncFailed = syncPhase === "error"; + const currentResult = !syncing && !syncFailed; + const displayedSummary = summary ?? lastSummary; if (!syncIncluded) { return ( Sync is not included in this subscription. @@ -1140,10 +1150,10 @@ function CloudVaultPanel({
@@ -1153,13 +1163,25 @@ function CloudVaultPanel({ onResolve={onResolveSettingsConflict} /> )} - {(summary ?? lastSummary) && ( - + {syncing && ( +
+ Syncing… Waiting for all changes to finish. +
+ )} + {syncFailed && !syncing && ( +
+ {syncError ?? "Sync failed. Please try again."} +
)} + {displayedSummary && + (currentResult || cloudSyncAttentionMessage(displayedSummary)) && ( + + )} ) : ( void; + showStatus?: boolean; }): JSX.Element { const [selectedPendingConflictId, setSelectedPendingConflictId] = useState< string | null @@ -1927,16 +1951,20 @@ function CloudSyncSummary({ : "rounded-xl border border-accent/25 bg-accent/5 px-4 py-3 text-sm text-ink-700" } > -
- {attention - ? "Sync incomplete" - : summary.pulled === 0 && summary.pushed === 0 - ? "Everything is up to date" - : `Downloaded ${summary.pulled} · Uploaded ${summary.pushed}`} -
-
- {attention ?? "All changes are synced."} -
+ {showStatus && ( + <> +
+ {attention + ? "Sync incomplete" + : summary.pulled === 0 && summary.pushed === 0 + ? "Everything is up to date" + : `Downloaded ${summary.pulled} · Uploaded ${summary.pushed}`} +
+
+ {attention ?? "All changes are synced."} +
+ + )} {capacityConflictCount > 0 && (
{capacityConflictCount}{" "} From edd55e03a78353bf271e55f28a7cdadaddac71f3 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 09:21:12 -0500 Subject: [PATCH 3/6] fix: keep Quick Capture open when Escape cancels selection (#765) Respect Escape events already handled by CodeMirror before saving or hiding Quick Capture. Preserve the subsequent unhandled Escape action and explicit save shortcut. Cover Vim visual modes, insert mode, non-Vim selections, picker and completion dismissal, and save/close behavior. --- .../src/components/QuickCaptureApp.test.ts | 205 ++++++++++++++++++ .../src/components/QuickCaptureApp.tsx | 5 +- 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 packages/app-core/src/components/QuickCaptureApp.test.ts diff --git a/packages/app-core/src/components/QuickCaptureApp.test.ts b/packages/app-core/src/components/QuickCaptureApp.test.ts new file mode 100644 index 00000000..dd438ef0 --- /dev/null +++ b/packages/app-core/src/components/QuickCaptureApp.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { EditorView } from '@codemirror/view' +import { completionStatus, startCompletion } from '@codemirror/autocomplete' +import { getCM } from '@replit/codemirror-vim' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QuickCaptureApp } from './QuickCaptureApp' + +describe('Quick Capture Escape handling (#765)', () => { + let host: HTMLDivElement + let root: Root + const windowClose = vi.fn() + const createNote = vi.fn() + const writeNote = vi.fn() + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + localStorage.clear() + // jsdom has no layout; Vim/CodeMirror still request caret rectangles. + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [] + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect() + }) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + }) + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + listNotes: vi.fn(async () => []), + onVaultChange: vi.fn(() => vi.fn()), + getQuickCapturePinned: vi.fn(async () => false), + platformSync: () => 'linux', + windowClose, + createNote, + writeNote + } + }) + createNote.mockResolvedValue({ path: 'Quick/Draft.md', title: 'Draft', folder: 'quick' }) + writeNote.mockResolvedValue(undefined) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + localStorage.clear() + }) + + async function mount(): Promise { + await act(async () => root.render(createElement(QuickCaptureApp))) + const view = EditorView.findFromDOM(host.querySelector('.cm-editor')!)! + act(() => { + view.dispatch({ changes: { from: 0, insert: 'Draft\nSome text to select' } }) + view.focus() + }) + return view + } + + async function press( + view: EditorView, key: string, keyCode: number, modifiers: KeyboardEventInit = {} + ): Promise { + await act(async () => { + view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { + key, keyCode, bubbles: true, cancelable: true, ...modifiers + })) + }) + } + + it.each([ + ['character', 'v', {}], + ['line', 'V', { shiftKey: true }], + ['block', 'v', { ctrlKey: true }] + ] as const)('cancels a Vim %s selection without saving or closing the window', async (_, key, modifiers) => { + const view = await mount() + await press(view, key, 86, modifiers) + await press(view, 'l', 76) + expect(getCM(view)?.state.vim?.visualMode).toBe(true) + expect(view.state.selection.main.empty).toBe(false) + + await press(view, 'Escape', 27) + + expect(getCM(view)?.state.vim?.visualMode).toBe(false) + expect(view.state.doc.toString()).toBe('Draft\nSome text to select') + expect(createNote).not.toHaveBeenCalled() + expect(windowClose).not.toHaveBeenCalled() + + // A second Escape, now in normal mode, retains the save-and-hide shortcut. + await press(view, 'Escape', 27) + expect(writeNote).toHaveBeenCalledWith('Quick/Draft.md', 'Draft\nSome text to select\n') + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('leaves Vim insert mode without closing the window', async () => { + const view = await mount() + await press(view, 'i', 73) + expect(getCM(view)?.state.vim?.insertMode).toBe(true) + + await press(view, 'Escape', 27) + + expect(getCM(view)?.state.vim?.insertMode).toBe(false) + expect(createNote).not.toHaveBeenCalled() + expect(windowClose).not.toHaveBeenCalled() + }) + + it('still saves and hides on an unhandled Escape with Vim disabled', async () => { + localStorage.setItem('zen:prefs:v2', JSON.stringify({ vimMode: false })) + const view = await mount() + + await press(view, 'Escape', 27) + + expect(writeNote).toHaveBeenCalledWith('Quick/Draft.md', 'Draft\nSome text to select\n') + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('collapses a non-Vim selection before a second Escape saves and hides', async () => { + localStorage.setItem('zen:prefs:v2', JSON.stringify({ vimMode: false })) + const view = await mount() + act(() => view.dispatch({ selection: { anchor: 0, head: 5 } })) + + await press(view, 'Escape', 27) + + expect(view.state.selection.main.empty).toBe(true) + expect(createNote).not.toHaveBeenCalled() + expect(windowClose).not.toHaveBeenCalled() + await press(view, 'Escape', 27) + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('dismisses the note picker without saving or closing Quick Capture', async () => { + await mount() + await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { + key: 'p', ctrlKey: true, bubbles: true, cancelable: true + }))) + const input = host.querySelector('input')! + expect(input.placeholder).toContain('Search notes') + + await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', keyCode: 27, bubbles: true, cancelable: true + }))) + + expect(host.querySelector('input')).toBeNull() + expect(createNote).not.toHaveBeenCalled() + expect(windowClose).not.toHaveBeenCalled() + }) + + it('keeps the explicit save-and-close chord independent of editor default prevention', async () => { + const view = await mount() + await press(view, 'v', 86) + await press(view, 'l', 76) + + // Only Escape should defer at the window boundary, not every shortcut. + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', ctrlKey: true, bubbles: true, cancelable: true + }) + event.preventDefault() + window.dispatchEvent(event) + }) + + expect(writeNote).toHaveBeenCalledWith('Quick/Draft.md', 'Draft\nSome text to select\n') + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('dismisses slash completions without saving or hiding the window', async () => { + localStorage.setItem('zen:prefs:v2', JSON.stringify({ vimMode: false })) + const view = await mount() + await act(async () => { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: '/' }, + selection: { anchor: 1 } + }) + startCompletion(view) + await vi.waitFor(() => expect(completionStatus(view.state)).toBe('active')) + }) + + await press(view, 'Escape', 27) + + expect(completionStatus(view.state)).toBeNull() + expect(view.state.doc.toString()).toBe('/') + expect(createNote).not.toHaveBeenCalled() + expect(windowClose).not.toHaveBeenCalled() + }) + + it('still hides an empty capture without creating a note', async () => { + localStorage.setItem('zen:prefs:v2', JSON.stringify({ vimMode: false })) + const view = await mount() + act(() => view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: '' } })) + + await press(view, 'Escape', 27) + + expect(createNote).not.toHaveBeenCalled() + expect(windowClose).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 27f285d9..531832de 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -21,7 +21,7 @@ * ⌘N / Ctrl+N — save the current note and start a new one. * ⌘P / Ctrl+P — open the note picker. * ⌘⇧P / Ctrl+Shift+P — open the command palette. - * Esc — close the open overlay, else hide window. + * Esc — cancel editor selection/mode or overlay, else hide window. * * Vim ex commands (when vim mode is on): * :w — save without closing. @@ -591,6 +591,9 @@ export function QuickCaptureApp(): JSX.Element { return } if (e.key === 'Escape') { + // CodeMirror can consume Esc (e.g. collapsing a visual selection) + // without stopping propagation. Do not also save and hide. (#765) + if (e.defaultPrevented) return if (overlayRef.current !== 'none') { // Overlay open — first Esc just dismisses it. The overlay's // own input handler also stops propagation, so this branch From ba4ae683df37a54a53db7543ba902345b8d4b8ab Mon Sep 17 00:00:00 2001 From: "blueagle.dev" Date: Fri, 11 Sep 2026 07:45:08 -0700 Subject: [PATCH 4/6] packaging(nix): use the top-level X11 libraries, not the deprecated xorg set (#763) nixpkgs 26.05 deprecated the xorg package set in favour of the top-level names, which exist from 25.11 on. Each xorg package now emits an evaluation warning when instantiated, and package-desktop.nix uses 12 of them; with allowAliases = false the set is absent and the file fails to evaluate. This takes the same 12 libraries as top-level arguments instead. The resulting derivation is unchanged: same drvPath before and after on the flake's pinned nixpkgs. Requires nixpkgs >= 25.11; drops 25.05, which has no lowercase top-level libx11. That affects the copy-the-file route in packaging/nix/README.md and flake consumers that make zennotes follow a 25.05 nixpkgs; the flake's own lock is unaffected. 25.05 has been end-of-life since 2025-12-31. --- packaging/nix/package-desktop.nix | 37 ++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/packaging/nix/package-desktop.nix b/packaging/nix/package-desktop.nix index 73e3542c..2c68e5db 100644 --- a/packaging/nix/package-desktop.nix +++ b/packaging/nix/package-desktop.nix @@ -35,7 +35,18 @@ pango, systemd, wayland, - xorg, + libx11, + libxcomposite, + libxcursor, + libxdamage, + libxext, + libxfixes, + libxi, + libxrandr, + libxrender, + libxscrnsaver, + libxtst, + libxcb, commandLineArgs ? "", }: @@ -86,18 +97,18 @@ stdenv.mkDerivation (finalAttrs: { nss pango stdenv.cc.cc # libstdc++ - xorg.libX11 - xorg.libXcomposite - xorg.libXcursor - xorg.libXdamage - xorg.libXext - xorg.libXfixes - xorg.libXi - xorg.libXrandr - xorg.libXrender - xorg.libXScrnSaver - xorg.libXtst - xorg.libxcb + libx11 + libxcomposite + libxcursor + libxdamage + libxext + libxfixes + libxi + libxrandr + libxrender + libxscrnsaver + libxtst + libxcb ]; # dlopen'd at runtime (not in DT_NEEDED), so keep them on the wrapper's path. From 443c34cdceaea87d4a4eb5922d73419f043bf6d1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 10:11:00 -0500 Subject: [PATCH 5/6] docs: draft the 2.48.0 release notes and post Release notes and the social post for 2.48.0: the three Cloud conflict fixes, the upload stream and sync status fixes, Escape cancelling a Quick Capture selection (#765) and the Nix packaging update (#763). No demo clip was recorded for this release. Claude-Session: https://claude.ai/code/session_01Pa8u7FTkwZD3zZ5AoFxKC8 --- docs/releases/v2.48.0/RELEASE_NOTES.md | 42 +++++++++++++++++++ docs/releases/v2.48.0/twitter-post.md | 56 ++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 docs/releases/v2.48.0/RELEASE_NOTES.md create mode 100644 docs/releases/v2.48.0/twitter-post.md diff --git a/docs/releases/v2.48.0/RELEASE_NOTES.md b/docs/releases/v2.48.0/RELEASE_NOTES.md new file mode 100644 index 00000000..ec7357c2 --- /dev/null +++ b/docs/releases/v2.48.0/RELEASE_NOTES.md @@ -0,0 +1,42 @@ +ZenNotes 2.48.0: a Cloud conflict decision saves one note and stays saved, queued conflicts merge on their own when they safely can, interrupted uploads clean up, and Escape cancels a Quick Capture selection without closing the window + +> A follow-up to the 2.47.0 Cloud conflict work from Unyanda's reports: choosing a version no longer waits on a download of the whole vault, a saved decision stays saved when the sync that follows it is slow or fails, a queued conflict that has become a clean three-way merge resolves itself on the next sync, an interrupted large upload closes its file reader instead of reporting "Controller is already closed", and Cloud Settings follows the live sync state. Also: Escape cancels a Quick Capture selection without closing the window ([#765](https://github.com/ZenNotes/zennotes/issues/765)), and the Nix package builds against nixpkgs 25.11 and later ([#763](https://github.com/ZenNotes/zennotes/pull/763)). Release PR: [#766](https://github.com/ZenNotes/zennotes/pull/766). + +## 🐛 Fixes + +- **Resolving a Cloud conflict no longer downloads the whole vault first.** Choosing your version, the other device's version, both, or a combined note used to fetch the content manifest of the entire vault before it would save one note, so on a vault with many attachments the decision could time out with nothing written. The server already checks the base revision and the destination path atomically, and a vault-wide download cannot strengthen that check, so the save now goes against the reviewed revision alone. Taking the Cloud version, or keeping both, checks freshness from metadata and fetches only that file's retained bytes when the snapshot lacks them. Hosts without revision reads keep the content-manifest path. A save that times out stays retryable with the local file untouched; a stale revision is reported as **The Cloud version changed. Sync again before choosing a version.** + + How to test locally: launch the isolated desktop against the local Cloud fixture as described below, on a vault with a few dozen attachments, and create a conflict by editing the same line on both test devices. Open **Review now** and pick a version. Before: the decision waits on every unrelated asset and can time out with the conflict still open. After: the note saves at once. Edit the Cloud side again before deciding: the review reports the stale revision and asks for a fresh sync instead of writing over it. + +- **A saved conflict decision stays saved.** The whole-vault sync that runs after a decision used to own the outcome: a slow or failed run left the review open on a note that was already resolved and invited a second save, which asked the server to resolve it twice. The review now moves to the next conflict, or closes, the moment the save succeeds; the remaining vault sync runs in the background, and a failure is reported as **Note saved. Remaining vault sync failed:** plus the reason, so the note is known to be safe. A resolver that has saved ignores further clicks, and Settings adopts the shared summary when the later run replaces it. + + How to test locally: create a conflict as above, then stop the local Cloud server and choose a version. Before: the review stays open on the resolved note and pressing the same choice again sends a second resolution. After: the review advances and the status reads **Note saved. Remaining vault sync failed:** with the network error; the local file holds the chosen content, and the next **Sync now** after restarting the server completes cleanly. + +- **Queued conflicts merge on their own once they safely can.** A content conflict that could not be merged when it was found (one side's bytes not yet retained, for example) stayed queued until opened, even after the two sides had become a clean three-way merge, such as a daily note where one device changed the date at the top and the other appended at the bottom. Every sync now reconsiders those conflicts when the Cloud side is unchanged since the conflict was recorded, all three paths agree, there is no draft and no paused path, and the current local file merges cleanly against base and Cloud; the merged text is written to disk and uploaded, and the conflict clears. Overlapping edits, unfinished drafts, renames, paused paths and a file changing mid-write stay queued for you. Identical files keep the stricter convergence check from 2.47.0. + + How to test locally: on two linked test devices, change the first line of the same note on one and append a paragraph on the other, sync both, and leave the conflict queued without opening it. Press **Sync now**. Before: the conflict waits until you open it. After: the note holds both edits, the merge is uploaded, and the queue is empty. Repeat with both devices editing the same line: the conflict stays queued and the review still asks. + +- **Interrupted large-asset uploads clean up properly.** A failed, cancelled or early-rejected upload now disposes its native file reader before releasing the upload reservation. That removes the stream-adapter path behind the **Controller is already closed** error Unyanda hit on a large image, and a retry starts from a clean reservation. The existing per-file limit is unchanged. + + How to test locally: paste an image of several megabytes into a synced note, then stop the local Cloud server while the upload is in flight. Before: the sync error reads "Controller is already closed" and the retry fails the same way. After: the error names the network failure, and **Sync now** after restarting the server uploads the file with matching bytes. + +- **Cloud Settings shows the current sync state.** An upload or a failure no longer leaves an old **Everything is up to date** result on screen, and a successful retry clears the previous sync error, including a retry started from the editor. Unrelated backup and publishing errors, and unfinished conflict drafts, stay visible. + + How to test locally: open **Settings > Cloud** and press **Sync now** with the local Cloud server stopped. Before: the summary from the previous run stays on screen. After: the failure shows immediately; restart the server, retry from the editor status, and the panel reports the successful run with the old error gone. + +- **Escape cancels a Quick Capture selection without closing the window** ([#765](https://github.com/ZenNotes/zennotes/issues/765), reported by Unyanda). Quick Capture treated every Escape as "save and hide", so leaving a Vim visual selection, or an ordinary editor selection, closed the window and saved a draft you were still writing. When the editor handles the Escape, Quick Capture now leaves the draft open; the next unhandled Escape still saves and hides the window, and the explicit save shortcut is unchanged. Insert mode, the completion popup and the picker dismiss the same way. + + How to test locally: open Quick Capture with its hotkey, type a few words, and select some of them (`v` and a motion in Vim mode, or Shift+Left otherwise). Press Escape. Before: the window saves and hides. After: the selection clears and the window stays open; a second Escape saves and hides it as before. + +## 🧰 For contributors + +- **Nix packaging:** [#763](https://github.com/ZenNotes/zennotes/pull/763), by @blueagledev, replaces the 12 deprecated `xorg.*` names in `package-desktop.nix` with their top-level libraries; the derivation is unchanged on the flake's pinned nixpkgs. Requires **nixpkgs 25.11 or later**; 25.05, end-of-life since December 2025, is no longer supported by the copy-the-file route. +- **Dependencies:** [#760](https://github.com/ZenNotes/zennotes/pull/760) moved `smol-toml` to 1.8.0 and `hono` to 4.13.7 for the day's advisories. `npm audit --omit=dev --audit-level=high` is clean; the only remaining finding is a moderate, development-only `vitest` advisory. +- **Sources:** `06dc99e0` (conflict decisions and queued merges), `55cafa08` (upload streams and sync status), `edd55e03` (Quick Capture Escape), `ba4ae683` (Nix). New tests: `cloud-sync-resolution.test.ts`, the "safely merging queued conflicts" block in `cloud-sync-coordinator.test.ts`, `cloud-sync-upload-network.test.ts` (real HTTP uploads of 8.1 MB and 10 MB with byte and hash checks, disconnects, timeouts, retries and restart recovery), and the Quick Capture Escape cases in `QuickCaptureApp.test.ts`. +- **Verified:** the Cloud fixes and the Quick Capture change were driven in the built desktop app on macOS against the local fixture. The Arch Linux upload report was reproduced through the synthetic upload fixtures only; a retest on the reporter's device is welcome. +- Build with `npm run build --workspace @zennotes/desktop`. Create a scratch root with `mktemp -d /tmp/zennotes-248.XXXXXX`, then launch `ZEN_PERF=1 ZENNOTES_USER_DATA_PATH=/userdata ZENNOTES_CONFIG_DIR=/config ZENNOTES_CLOUD_BASE_URL=http://127.0.0.1:43183 apps/desktop/node_modules/.bin/electron apps/desktop/out/main/index.js --remote-debugging-port=9326`. Use a scratch vault and a local test account only. `node tooling/scripts/cloud-conflict-demo-fixture.mjs` supplies the local Cloud server. +- Release gates run fresh at the cut: `apps/desktop` `build:prod` (typecheck, tests, build, packaged CLI isolation), Go vet and tests, the app-core suite (2,003 tests) and the shared-domain suite (1,593 tests), the signed packaged app launched in an isolated profile and reaching a CDP page target, and the website suite with the release page entry in place. + +--- + +Local-first and keyboard-first, as always. diff --git a/docs/releases/v2.48.0/twitter-post.md b/docs/releases/v2.48.0/twitter-post.md new file mode 100644 index 00000000..799d4e47 --- /dev/null +++ b/docs/releases/v2.48.0/twitter-post.md @@ -0,0 +1,56 @@ +# Twitter/X post for ZenNotes 2.48.0 + +Post once the GitHub release has all its assets and the channels are verified. + +## Single post (bullet list, preferred) + +ZenNotes 2.48.0 is out: + +• Resolving a Cloud conflict saves one note, without downloading the whole vault first +• A saved decision stays saved, even when the sync after it is slow or fails +• Queued conflicts merge on their own once both sides can be combined safely +• Interrupted large uploads clean up properly, no more "Controller is already closed" +• Cloud Settings shows the current sync state, not the last one +• Escape cancels a Quick Capture selection without closing the window (#765) +• Nix package builds against nixpkgs 25.11+ (#763, thanks @blueagledev) + +Download: zennotes.org + +## Thread + +### Tweet 1 + +ZenNotes 2.48.0 is out. It follows up on the 2.47.0 Cloud conflict work: choosing a version for a conflicted note now saves that one note right away, instead of waiting on a download of the whole vault. On a vault with many attachments that wait could time out with nothing saved. + +### Tweet 2 + +A saved decision now stays saved. The review moves on the moment the note is safe, and the rest of the vault syncs in the background. If that later sync fails you see "Note saved. Remaining vault sync failed:" with the reason, instead of an open review on a note that was already resolved. + +### Tweet 3 + +Edited different parts of the same note on two devices? Sync now rechecks queued conflicts and merges the ones that combine cleanly, such as a new date at the top on one device and a paragraph added at the bottom on the other. Real overlaps, unfinished drafts and renames still wait for you. + +### Tweet 4 + +Interrupted large uploads clean up after themselves (no more "Controller is already closed"), and Cloud Settings follows the live sync state: no stale "up to date" during a failure, no old error after a successful retry. + +### Tweet 5 + +Escape now cancels a Quick Capture selection without closing the window (#765). The Nix package uses the top-level X11 libraries and builds on nixpkgs 25.11 and later (#763). Thanks @uNyanda for the reports and @blueagledev for the Nix PR. + +Free, open source, local-first Markdown notes. +https://github.com/ZenNotes/zennotes/releases/tag/v2.48.0 + +Arch: yay -S zennotes-bin + +## Short alt + +ZenNotes 2.48.0: Cloud conflict decisions save one note and stay saved, queued conflicts merge on their own when they safely can, interrupted uploads clean up, and Escape cancels a Quick Capture selection without closing the window. zennotes.org + +## Notes + +- Release PR: https://github.com/ZenNotes/zennotes/pull/766 +- Issues closed: #765. PRs merged: #763 (Nix), #760 (dependencies). +- Task visibility during conflicts and identical-file convergence shipped in 2.47.0; do not present them as new. +- Desktop and shared-package work only; no website deployment or mobile release. +- No demo clip was recorded for this release. From 71bafa153787447e760dd6b813eadc4c00345ca9 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 10:11:11 -0500 Subject: [PATCH 6/6] chore(release): 2.48.0 Claude-Session: https://claude.ai/code/session_01Pa8u7FTkwZD3zZ5AoFxKC8 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9cfe4b27..4533f0c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.47.0", + "version": "2.48.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/server/package.json b/apps/server/package.json index 19ac9c4d..0ce846a9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.47.0", + "version": "2.48.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 4759e1fd..5ecd1cbc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.47.0", + "version": "2.48.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 5fdc309a..3de5d604 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.47.0", + "version": "2.48.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.47.0", + "version": "2.48.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.47.0", + "version": "2.48.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,11 +874,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.47.0" + "version": "2.48.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.47.0", + "version": "2.48.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16286,7 +16286,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.47.0", + "version": "2.48.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16363,11 +16363,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.47.0" + "version": "2.48.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.47.0", + "version": "2.48.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16378,7 +16378,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.47.0" + "version": "2.48.0" } } } diff --git a/package.json b/package.json index 13a92d91..37f47f66 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.47.0", + "version": "2.48.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 4a410394..3388c92e 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.47.0", + "version": "2.48.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 82da4273..1e70ee64 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.47.0", + "version": "2.48.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 3e94b010..a54b1977 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.47.0", + "version": "2.48.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index ada5aea9..5bdc39ee 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.47.0", + "version": "2.48.0", "type": "module", "exports": { ".": "./src/index.ts"