Harden desktop disk-link sync against data loss and self-writes

This commit is contained in:
Anthony Stirling
2026-08-30 10:53:41 +01:00
parent 10e1fac00b
commit b1ae80186d
10 changed files with 535 additions and 12 deletions
@@ -0,0 +1,116 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId, ToolOperation } from "@app/types/file";
// A conflict marker must not outlive its file, nor precede one: the modal would
// name a file that is gone, and an inherited marker suppresses the child's own.
vi.mock("@app/services/desktopFileLink", () => ({
desktopFileLinkingSupported: true,
getDiskFileState: vi.fn(async () => ({
exists: true,
size: 1,
modifiedMs: 1,
})),
pathExistsOnDisk: vi.fn(async () => true),
readFileFromDisk: vi.fn(async () => null),
}));
vi.mock("@app/services/fileStorage", () => ({
fileStorage: {
getStirlingFile: vi.fn(),
updateFileMetadata: vi.fn(async () => true),
},
}));
vi.mock("@app/utils/thumbnailUtils", () => ({
generateThumbnailPairWithMetadata: () => new Promise(() => {}),
}));
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
import { FileLifecycleManager } from "@app/contexts/file/lifecycle";
import { createChildStub } from "@app/contexts/file/fileActions";
import {
requestDiskConflictChoice,
subscribeDiskConflicts,
__resetDiskConflicts,
} from "@app/services/diskConflictPrompt";
beforeEach(() => __resetDiskConflicts());
function manager() {
const filesRef = { current: new Map<FileId, File>() };
return new FileLifecycleManager(filesRef, vi.fn());
}
function queuedIds(): FileId[] {
let seen: { fileId: FileId }[] = [];
const off = subscribeDiskConflicts((queue) => {
seen = queue;
});
off();
return seen.map((q) => q.fileId);
}
describe("closing a file drops its queued conflict", () => {
test("removeFiles cancels the prompt", () => {
requestDiskConflictChoice({
fileId: "a" as FileId,
name: "a.pdf",
onUseDisk: vi.fn(),
});
requestDiskConflictChoice({
fileId: "b" as FileId,
name: "b.pdf",
onUseDisk: vi.fn(),
});
manager().removeFiles(["a" as FileId]);
expect(queuedIds()).toEqual(["b"]);
});
test("the delayed-cleanup path cancels it too", () => {
requestDiskConflictChoice({
fileId: "a" as FileId,
name: "a.pdf",
onUseDisk: vi.fn(),
});
manager().cleanupFile("a" as FileId);
expect(queuedIds()).toEqual([]);
});
});
describe("a tool output does not inherit its parent's disk markers", () => {
const parent = {
id: "p1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 10,
lastModified: 0,
isLeaf: true,
versionNumber: 1,
localFilePath: "C:/docs/report.pdf",
diskSyncedSize: 500,
diskSyncedModifiedMs: 400,
diskConflictAt: 123,
diskReloadedAt: 456,
} as StirlingFileStub;
const operation: ToolOperation = { toolId: "split", timestamp: 1 };
test("clears the markers but keeps the link and its baseline", () => {
const child = createChildStub(
parent,
operation,
new File(["%PDF-1.7"], "report_split.pdf", { type: "application/pdf" }),
);
// Inheriting these shows a badge the child never earned, and the conflict
// short-circuit in resyncFilesFromDisk would suppress its own prompt.
expect(child.diskConflictAt).toBeUndefined();
expect(child.diskReloadedAt).toBeUndefined();
// The link and baseline must survive: dropping them would raise a bogus
// conflict on every open, and Ctrl+S would stop writing back.
expect(child.localFilePath).toBe("C:/docs/report.pdf");
expect(child.diskSyncedSize).toBe(500);
expect(child.diskSyncedModifiedMs).toBe(400);
expect(child.isDirty).toBe(true);
});
});
@@ -0,0 +1,148 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { expectConsole } from "@app/tests/failOnConsole";
import type {
FileContextState,
StirlingFileStub,
} from "@app/types/fileContext";
import type { FileId, ToolOperation } from "@app/types/file";
// A vanished original must not take unsaved work or version history with it:
// only an unedited v1 passthrough holds nothing the disk file did not.
vi.mock("@app/services/desktopFileLink", () => ({
desktopFileLinkingSupported: true,
getDiskFileState: vi.fn(async () => ({
exists: false,
size: 0,
modifiedMs: 0,
})),
pathExistsOnDisk: vi.fn(async () => false),
readFileFromDisk: vi.fn(async () => null),
}));
const getStirlingFile = vi.hoisted(() => vi.fn());
const deleteStirlingFile = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@app/services/fileStorage", () => ({
fileStorage: {
getStirlingFile,
deleteStirlingFile,
updateFileMetadata: vi.fn(async () => true),
},
}));
vi.mock("@app/utils/thumbnailUtils", () => ({
generateThumbnailPairWithMetadata: () => new Promise(() => {}),
}));
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
const LOST_PATH = "C:/docs/report.pdf";
const linkedStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub =>
({
id: "f1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 10,
lastModified: 0,
isLeaf: true,
versionNumber: 1,
localFilePath: LOST_PATH,
diskSyncedSize: 10,
diskSyncedModifiedMs: 1_000,
...overrides,
}) as StirlingFileStub;
beforeEach(() => {
vi.clearAllMocks();
getStirlingFile.mockImplementation(
async () =>
new File(["%PDF-1.7"], "report.pdf", { type: "application/pdf" }),
);
});
async function hydrate(stub: StirlingFileStub) {
vi.resetModules();
const { addStirlingFileStubs } =
await import("@app/contexts/file/fileActions");
const state = {
files: { ids: [], byId: {} },
pinnedFiles: new Set(),
ui: { selectedFileIds: [], selectedPageNumbers: [] },
} as unknown as FileContextState;
const stateRef = { current: state };
const filesRef = { current: new Map<FileId, File>() };
const updates: Partial<StirlingFileStub>[] = [];
const removed: FileId[] = [];
const lifecycleManager = {
// Mirrors the real guard: an update for a file absent from filesRef is
// dropped, so a detach recorded too early would be lost.
updateStirlingFileStub: (
fileId: FileId,
patch: Partial<StirlingFileStub>,
) => {
if (!filesRef.current.has(fileId)) return;
updates.push(patch);
},
removeFiles: (fileIds: FileId[]) => removed.push(...fileIds),
trackBlobUrl: () => {},
};
await addStirlingFileStubs(
[stub],
{},
stateRef,
filesRef,
() => {},
lifecycleManager as never,
);
return { updates, removed, filesRef };
}
const operation: ToolOperation = { toolId: "split", timestamp: 1 };
// The link is cut, but the old path is kept so the badge can still say where
// the original was.
const detachUpdate = () =>
expect.objectContaining({
localFilePath: undefined,
orphanedFilePath: LOST_PATH,
});
describe("a linked file whose original vanished", () => {
test("detaches an edited version instead of destroying its only copy", async () => {
const { updates, removed, filesRef } = await hydrate(
linkedStub({
isDirty: true,
versionNumber: 2,
toolHistory: [operation],
}),
);
await vi.waitFor(() => expect(updates).toContainEqual(detachUpdate()));
expect(removed).toHaveLength(0);
expect(deleteStirlingFile).not.toHaveBeenCalled();
// Still served from the stored copy, so the document stays on screen.
expect(filesRef.current.has("f1" as FileId)).toBe(true);
});
test("still deletes an unedited passthrough, which holds nothing extra", async () => {
expectConsole.warn(/no longer exists at/);
const { removed } = await hydrate(linkedStub());
await vi.waitFor(() => expect(removed).toContain("f1" as FileId));
await vi.waitFor(() =>
expect(deleteStirlingFile).toHaveBeenCalledWith("f1"),
);
});
test("detaches a non-leaf root, which version history still needs", async () => {
const { updates, removed } = await hydrate(linkedStub({ isLeaf: false }));
await vi.waitFor(() => expect(updates).toContainEqual(detachUpdate()));
expect(removed).toHaveLength(0);
expect(deleteStirlingFile).not.toHaveBeenCalled();
});
});
@@ -37,6 +37,7 @@ import {
notifyOpenFileDeleted,
saveOrphanAsCopy,
} from "@app/services/diskFileSync";
import { isPristineLocalPassthrough } from "@app/services/pruneMissingRecentFiles";
import { getDiskFileState } from "@app/services/desktopFileLink";
import { requestDiskConflictChoice } from "@app/services/diskConflictPrompt";
const DEBUG = process.env.NODE_ENV === "development";
@@ -239,6 +240,11 @@ export function createChildStub(
// Mark as dirty if parent has a localFilePath (modified file not yet saved to disk)
isDirty: parentStub.localFilePath ? true : undefined,
// Disk markers describe the parent's relationship with disk at conflict
// time; inheriting diskConflictAt also suppresses the child's own prompt.
diskConflictAt: undefined,
diskReloadedAt: undefined,
};
if (DEBUG) {
@@ -894,6 +900,16 @@ export async function resyncFilesFromDisk(
}
if (outcome.status === "updated") {
// The stat, the read and this commit are all awaited, so the decision was
// made against a snapshot. Re-check before overwriting the user's bytes.
const latest = stateRef.current.files.byId[fileId];
if (
!latest ||
latest.isDirty ||
latest.localFilePath !== stub.localFilePath
) {
continue;
}
const { file, state } = outcome;
const reloadedAt = Date.now();
filesRef.current.set(fileId, createStirlingFile(file, fileId));
@@ -1043,7 +1059,11 @@ export async function addStirlingFileStubs(
// A desktop file only caches disk, so reconcile BEFORE serving it, or
// external edits stay invisible and deleted files still open.
const diskSync = await syncLinkedFileFromDisk(stub);
if (diskSync.status === "missing") {
// Only an unedited v1 passthrough holds nothing the disk file did not;
// anything else is detached below, never deleted.
const lostPath =
diskSync.status === "missing" ? stub.localFilePath : undefined;
if (diskSync.status === "missing" && isPristineLocalPassthrough(stub)) {
// Deleted between the list being drawn and this open; remove it rather
// than serving a copy of a file the user deleted.
console.warn(
@@ -1086,9 +1106,24 @@ export async function addStirlingFileStubs(
filesRef.current.set(fileId, stirlingFile);
if (lostPath) {
// The original is gone but this record is not a pristine passthrough,
// so it holds work only we have. Cut the link, never delete it.
lifecycleManager.updateStirlingFileStub(
fileId,
detachedFields(lostPath),
stateRef,
);
notifyOpenFileDeleted([stub.name]);
}
// An edit committed while we were reading disk must not be discarded by
// a decision taken before it existed.
const stillClean = !stateRef.current.files.byId[fileId]?.isDirty;
// Workbench selectors only see the file once something dispatches; must
// follow the filesRef write or the update is dropped.
if (diskSync.status === "updated") {
if (diskSync.status === "updated" && stillClean) {
const { file, state } = diskSync;
const reloadedAt = Date.now();
void persistDiskUpdate(fileId, file, state, reloadedAt).catch(
@@ -9,6 +9,7 @@ import {
StirlingFileStub,
ProcessedFilePage,
} from "@app/types/fileContext";
import { cancelDiskConflict } from "@app/services/diskConflictPrompt";
const DEBUG = process.env.NODE_ENV === "development";
@@ -69,6 +70,7 @@ export class FileLifecycleManager {
fileId: FileId,
stateRef?: React.MutableRefObject<FileContextState>,
): void => {
cancelDiskConflict(fileId);
// Use comprehensive cleanup (same as removeFiles)
this.cleanupAllResourcesForFile(fileId, stateRef);
@@ -147,6 +149,9 @@ export class FileLifecycleManager {
stateRef?: React.MutableRefObject<FileContextState>,
): void => {
fileIds.forEach((fileId) => {
// A queued conflict for a file that is gone would name it in a blocking
// modal whose Use-disk button then no-ops against the filesRef guard.
cancelDiskConflict(fileId);
// Clean up all resources for this file
this.cleanupAllResourcesForFile(fileId, stateRef);
});
+4
View File
@@ -206,4 +206,8 @@ function applyDefaultLocale(defaultLocale: string) {
setLanguageWithPriority(defaultLocale, LanguageSource.ServerDefault);
}
// Non-React modules off the hydration path (diskFileSync's toasts) read the
// translator from globalThis; the ESM build does not register itself.
(globalThis as Record<string, unknown>).i18next = i18n;
export default i18n;
@@ -43,6 +43,7 @@ vi.mock("@app/services/exportWithPolicy", () => ({ downloadFileWithPolicy }));
const alertMock = vi.hoisted(() => vi.fn());
vi.mock("@app/components/toast", () => ({ alert: alertMock }));
import { getDiskFileState } from "@app/services/desktopFileLink";
import {
syncLinkedFileFromDisk,
hasDiskChanged,
@@ -56,6 +57,9 @@ import {
notifyDiskConflict,
notifyDiskReloaded,
notifyOpenFileDeleted,
beginSelfWrite,
endSelfWrite,
__resetSelfWrites,
} from "@app/services/diskFileSync";
function stub(overrides: Partial<StirlingFileStub> = {}): StirlingFileStub {
@@ -79,6 +83,7 @@ beforeEach(() => {
diskState.supported = true;
diskState.state = { exists: true, size: 100, modifiedMs: 5000 };
diskState.bytes = new Uint8Array([1, 2, 3]).buffer;
__resetSelfWrites();
vi.clearAllMocks();
downloadFileWithPolicy.mockResolvedValue({
savedPath: "C:/elsewhere/report.pdf",
@@ -188,11 +193,65 @@ describe("syncLinkedFileFromDisk", () => {
});
it("re-reads a legacy record that has no baseline", async () => {
diskState.state = { exists: true, size: 3, modifiedMs: 5000 };
const result = await syncLinkedFileFromDisk(
stub({ diskSyncedSize: undefined, diskSyncedModifiedMs: undefined }),
);
expect(result.status).toBe("updated");
});
it("refuses bytes that moved on disk while we were reading them", async () => {
// Another process was mid-write: committing this read would install a
// truncated PDF and then persist it over the cached copy.
const stat = vi.mocked(getDiskFileState);
stat.mockResolvedValueOnce({ exists: true, size: 200, modifiedMs: 9000 });
stat.mockResolvedValueOnce({ exists: true, size: 400, modifiedMs: 9500 });
const result = await syncLinkedFileFromDisk(stub());
expect(result.status).toBe("unchanged");
});
it("refuses a read shorter than the file it was stat'd against", async () => {
const stat = vi.mocked(getDiskFileState);
stat.mockResolvedValueOnce({ exists: true, size: 400, modifiedMs: 9000 });
stat.mockResolvedValueOnce({ exists: true, size: 400, modifiedMs: 9000 });
const result = await syncLinkedFileFromDisk(stub());
expect(result.status).toBe("unchanged");
});
});
describe("self-write muting", () => {
const path = "C:/docs/report.pdf";
const moved = () => stub({ diskSyncedSize: 1, diskSyncedModifiedMs: 1 });
it("does not report our own save as an external change", async () => {
diskState.state = { exists: true, size: 3, modifiedMs: 9000 };
beginSelfWrite(path);
const result = await syncLinkedFileFromDisk(moved());
expect(result.status).toBe("unchanged");
// Muted before the stat, so a half-written file is never even read.
expect(vi.mocked(getDiskFileState)).not.toHaveBeenCalled();
});
it("picks the file up again once the write is accounted for", async () => {
diskState.state = { exists: true, size: 3, modifiedMs: 9000 };
beginSelfWrite(path);
endSelfWrite(path);
const result = await syncLinkedFileFromDisk(moved());
expect(result.status).toBe("updated");
});
it("releases itself if the save never re-baselines", async () => {
vi.useFakeTimers();
try {
diskState.state = { exists: true, size: 3, modifiedMs: 9000 };
beginSelfWrite(path);
vi.advanceTimersByTime(11_000);
const result = await syncLinkedFileFromDisk(moved());
expect(result.status).toBe("updated");
} finally {
vi.useRealTimers();
}
});
});
describe("diskLinkState", () => {
@@ -348,6 +407,20 @@ describe("refreshDiskBaselineAfterSave", () => {
await refreshDiskBaselineAfterSave("file-1" as FileId, "/x.pdf"),
).toBeNull();
});
it("releases the self-write mute, even when it stamps nothing", async () => {
diskState.state = { exists: false, size: 0, modifiedMs: 0 };
beginSelfWrite("C:/docs/report.pdf");
await refreshDiskBaselineAfterSave(
"file-1" as FileId,
"C:/docs/report.pdf",
);
diskState.state = { exists: true, size: 3, modifiedMs: 9000 };
const result = await syncLinkedFileFromDisk(
stub({ diskSyncedSize: 1, diskSyncedModifiedMs: 1 }),
);
expect(result.status).toBe("updated");
});
});
describe("user-facing notifications", () => {
@@ -92,6 +92,37 @@ export function detachedFields(
};
}
// A save writes onto the watched path, so the watcher reports our own write as
// an external change. Paths are muted until the post-save re-baseline lands.
const SELF_WRITE_MAX_MS = 10_000;
const selfWrites = new Map<string, number>();
/** Mute disk checks for a path we are about to write ourselves. */
export function beginSelfWrite(path: string): void {
selfWrites.set(path, Date.now() + SELF_WRITE_MAX_MS);
}
/** Unmute once the write is accounted for. Safe for an unknown path. */
export function endSelfWrite(path: string): void {
selfWrites.delete(path);
}
function isSelfWrite(path: string): boolean {
const until = selfWrites.get(path);
if (until == null) return false;
// A save that never re-baselined must not blind us forever.
if (Date.now() > until) {
selfWrites.delete(path);
return false;
}
return true;
}
/** Test seam: the register is module state and would leak between cases. */
export function __resetSelfWrites(): void {
selfWrites.clear();
}
/** Compare a linked stub against disk and read live bytes when it moved on.
* Unsaved edits win (conflict); an unreadable file reports unchanged. */
export async function syncLinkedFileFromDisk(
@@ -101,6 +132,10 @@ export async function syncLinkedFileFromDisk(
return { status: "not-linked" };
}
// Our own write is in flight: reporting it as an external change would accuse
// the user of conflicting with themselves.
if (isSelfWrite(stub.localFilePath)) return { status: "unchanged" };
const state = await getDiskFileState(stub.localFilePath);
if (!state.exists) return { status: "missing" };
if (!hasDiskChanged(stub, state)) return { status: "unchanged" };
@@ -109,6 +144,19 @@ export async function syncLinkedFileFromDisk(
const bytes = await readFileFromDisk(stub.localFilePath);
if (!bytes) return { status: "unchanged" };
// The file may still have been being written while we read it. Only commit
// bytes whose size and mtime held still across the read.
const after = await getDiskFileState(stub.localFilePath);
if (
!after.exists ||
after.size !== state.size ||
after.modifiedMs !== state.modifiedMs ||
bytes.byteLength !== state.size
) {
// Baseline deliberately left un-stamped, so the next event re-reads.
return { status: "unchanged" };
}
const file = new File([bytes], stub.name, {
type: stub.type || "application/pdf",
lastModified: state.modifiedMs || Date.now(),
@@ -117,7 +165,8 @@ export async function syncLinkedFileFromDisk(
}
/** Read the disk version, discarding unsaved in-app edits. Only from the "Use
* disk version" action - losing unsaved work must be the user's choice. */
* disk version" action - losing unsaved work must be the user's choice. No
* settle check here: declining silently would make the button look dead. */
export async function loadDiskVersion(
stub: StirlingFileStub,
): Promise<{ file: File; state: DiskFileState } | null> {
@@ -166,14 +215,19 @@ export async function refreshDiskBaselineAfterSave(
StirlingFileStub,
"diskSyncedSize" | "diskSyncedModifiedMs" | "diskConflictAt"
> | null> {
if (!desktopFileLinkingSupported) return null;
const state = await getDiskFileState(path);
if (!state.exists) return null;
// Writing our version out is one way of resolving a divergence, so the
// conflict marker goes with it.
const baseline = { ...diskBaseline(state), diskConflictAt: undefined };
await fileStorage.updateFileMetadata(fileId, baseline);
return baseline;
try {
if (!desktopFileLinkingSupported) return null;
const state = await getDiskFileState(path);
if (!state.exists) return null;
// Writing our version out is one way of resolving a divergence, so the
// conflict marker goes with it.
const baseline = { ...diskBaseline(state), diskConflictAt: undefined };
await fileStorage.updateFileMetadata(fileId, baseline);
return baseline;
} finally {
// The write is now accounted for, however it went.
endSelfWrite(path);
}
}
/** Drop a linked file whose disk original is gone, copy and all. */
@@ -16,7 +16,7 @@ export function hasLocalRecord(stub: StirlingFileStub): boolean {
/** Unedited v1 holds the same bytes as disk, so losing it loses nothing unique.
* Non-leaf is excluded: it is the version-history root "revert to original" needs. */
function isPristineLocalPassthrough(stub: StirlingFileStub): boolean {
export function isPristineLocalPassthrough(stub: StirlingFileStub): boolean {
return (
!stub.isDirty &&
stub.isLeaf !== false &&
@@ -0,0 +1,83 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { expectConsole } from "@app/tests/failOnConsole";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
// A save writes onto the watched path, so the watcher reports our own write as
// an external change. The save must mute the path, and unmute if it failed.
const writeFile = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@tauri-apps/plugin-fs", () => ({ writeFile }));
const getDiskFileState = vi.hoisted(() =>
vi.fn(async () => ({ exists: true, size: 3, modifiedMs: 9000 })),
);
vi.mock("@app/services/desktopFileLink", () => ({
desktopFileLinkingSupported: true,
getDiskFileState,
pathExistsOnDisk: vi.fn(async () => true),
readFileFromDisk: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
}));
vi.mock("@app/services/fileStorage", () => ({
fileStorage: { updateFileMetadata: vi.fn(async () => true) },
}));
import { saveToLocalPath } from "@app/services/localFileSaveService";
import {
syncLinkedFileFromDisk,
__resetSelfWrites,
} from "@app/services/diskFileSync";
const PATH = "C:/docs/report.pdf";
const bytes = new Uint8Array([1, 2, 3]);
// Baseline deliberately stale, so anything but a mute reports "updated".
const linked = () =>
({
id: "f1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 3,
lastModified: 0,
localFilePath: PATH,
diskSyncedSize: 1,
diskSyncedModifiedMs: 1,
}) as StirlingFileStub;
beforeEach(() => {
__resetSelfWrites();
vi.clearAllMocks();
writeFile.mockResolvedValue(undefined);
});
describe("saveToLocalPath", () => {
test("writes the bytes straight to the path it was given", async () => {
const file = new File([bytes], "report.pdf");
// jsdom mangles binary Blob parts, so compare against what this very Blob
// yields: what matters is that the save passes it through untouched.
const expected = new Uint8Array(await file.arrayBuffer());
const result = await saveToLocalPath(file, PATH);
expect(result).toEqual({ success: true });
expect(writeFile).toHaveBeenCalledWith(PATH, expected);
});
test("mutes the path so the watcher cannot re-read our own write", async () => {
await saveToLocalPath(new File([bytes], "report.pdf"), PATH);
const outcome = await syncLinkedFileFromDisk(linked());
expect(outcome.status).toBe("unchanged");
// Muted before the stat, so a half-written file is never even read.
expect(getDiskFileState).not.toHaveBeenCalled();
});
test("unmutes when the write failed, so a real change is not missed", async () => {
expectConsole.error(/Failed to save/);
writeFile.mockRejectedValueOnce(new Error("EACCES"));
const result = await saveToLocalPath(new File([bytes], "report.pdf"), PATH);
expect(result.success).toBe(false);
const outcome = await syncLinkedFileFromDisk(linked());
expect(outcome.status).toBe("updated");
});
});
@@ -2,6 +2,7 @@ import type {
SaveResult,
MultiFileSaveResult,
} from "@core/services/localFileSaveService";
import { beginSelfWrite, endSelfWrite } from "@app/services/diskFileSync";
export type { SaveResult, MultiFileSaveResult };
/**
@@ -15,12 +16,16 @@ export async function saveToLocalPath(
data: Blob | File,
filePath: string,
): Promise<SaveResult> {
// Muted before the first byte so the watcher cannot report our own write as an
// external change. Released by the post-save re-baseline, or by its deadline.
beginSelfWrite(filePath);
try {
const { writeFile } = await import("@tauri-apps/plugin-fs");
const arrayBuffer = await data.arrayBuffer();
await writeFile(filePath, new Uint8Array(arrayBuffer));
return { success: true };
} catch (error) {
endSelfWrite(filePath);
const message = error instanceof Error ? error.message : String(error);
console.error("[LocalFileSave] Failed to save:", message);
return { success: false, error: message };