mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Harden desktop disk-link sync against data loss and self-writes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user