Stop transient file conflict callouts (#2694)

* fix(app): prevent transient file conflict callouts

Watcher versions are candidates until a matching disk read settles them. The editor now classifies the settled bytes once, preserving dirty buffers and keeping reload content paired with its revision.

* fix(app): preserve external BOM changes
This commit is contained in:
Mohamed Boudra
2026-07-31 19:03:11 +08:00
committed by GitHub
parent 91da0bac4d
commit 8e983c5d27
13 changed files with 753 additions and 187 deletions
+99 -2
View File
@@ -4,6 +4,11 @@ import { expect, test, type Page } from "./fixtures";
import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
import type { WithWorkspace } from "./helpers/with-workspace";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
expectFileCalloutWasRendered,
expectNoFileCalloutWasRendered,
recordFileCallouts,
} from "./helpers/file-callouts";
function visibleEditor(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
@@ -61,10 +66,11 @@ async function replaceFileAfterDeletionWasObserved(input: {
gate.holdFileReads(relativePath);
await unlink(filePath);
await gate.waitForHeldFileRead();
await expect(fileCallout(page)).toHaveCount(0);
gate.releaseHeldFileRead();
await expectOnlyFileCallout(page, "File deleted on disk");
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(filePath, content, "utf8");
gate.releaseHeldFileRead();
await gate.waitForHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
}
@@ -103,7 +109,96 @@ async function expectDirtyConflictCanReload(page: Page): Promise<void> {
await expect(fileCallout(page)).toHaveCount(0);
}
async function restoreFileAfterWatcherObservedTemporaryAbsence(input: {
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
}): Promise<void> {
const parkedPath = `${input.filePath}.saving`;
input.gate.holdFileReads(input.relativePath);
await rename(input.filePath, parkedPath);
await input.gate.waitForFileUpdate(input.relativePath, "missing");
await input.gate.waitForHeldFileRead();
await writeFile(input.filePath, await readFile(parkedPath));
await unlink(parkedPath);
input.gate.releaseHeldFileRead();
}
test.describe("Workspace file change conflicts", () => {
test("a temporary write gap never renders a disk-change callout", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "temporary-gap.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-temporary-gap-",
relativePath,
content: "const preserved = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
await replaceEditorText(page, "const local = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
});
test("an identical atomic rewrite never renders a disk-change callout", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "identical-rewrite.ts";
const content = "const preserved = true;\n";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-identical-rewrite-",
relativePath,
content,
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(`${filePath}.tmp`, content, "utf8");
await rename(`${filePath}.tmp`, filePath);
await gate.waitForHeldReadyFileUpdate();
await replaceEditorText(page, "const local = true;\n");
gate.releaseHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
});
test("a save watcher refresh never replays the pre-save buffer", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "save-refresh.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-save-refresh-",
relativePath,
content: "const before = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdFileReads(relativePath);
await replaceEditorText(page, "const saved = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
await gate.waitForHeldFileRead();
await expect(visibleEditor(page)).toContainText("const saved = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
gate.releaseHeldFileRead();
});
test("a clean file replaced on disk offers one working reload action", async ({
page,
withWorkspace,
@@ -122,8 +217,8 @@ test.describe("Workspace file change conflicts", () => {
relativePath: "inventory.md",
content: "# After\n",
});
await expectCleanReplacementCanReload(page);
gate.releaseHeldReadyFileUpdate();
await expectCleanReplacementCanReload(page);
});
test("a deleted file shows one explanatory notice and no resolution actions", async ({
@@ -136,8 +231,10 @@ test.describe("Workspace file change conflicts", () => {
content: "# Present\n",
});
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
await recordFileCallouts(page);
await unlink(filePath);
await expectDeletedFileNotice(page);
await expectFileCalloutWasRendered(page, "File deleted on disk");
});
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
@@ -76,6 +76,8 @@ export async function installDaemonWebSocketGate(page: Page) {
const clientRequestCounts = new Map<string, number>();
const subscribedFilePaths = new Set<string>();
const fileSubscriptionWaiters = new Map<string, () => void>();
const observedFileUpdates = new Set<string>();
const fileUpdateWaiters = new Map<string, () => void>();
let fileReadPathToHold: string | null = null;
let heldFileReads: Array<() => void> = [];
let resolveHeldFileRead: (() => void) | null = null;
@@ -84,6 +86,19 @@ export async function installDaemonWebSocketGate(page: Page) {
let heldReadyFileUpdate: (() => void) | null = null;
let resolveHeldReadyFileUpdate: (() => void) | null = null;
let heldReadyFileUpdatePromise = Promise.resolve();
const recordFileUpdate = (message: ServerMessage): void => {
if (
message.type !== "fs.file.update" ||
typeof message.payload?.version?.status !== "string" ||
typeof message.payload.version.path !== "string"
) {
return;
}
const key = `${message.payload.version.path}:${message.payload.version.status}`;
observedFileUpdates.add(key);
fileUpdateWaiters.get(key)?.();
fileUpdateWaiters.delete(key);
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
@@ -134,6 +149,7 @@ export async function installDaemonWebSocketGate(page: Page) {
fileSubscriptionWaiters.get(path)?.();
fileSubscriptionWaiters.delete(path);
}
if (serverMessage) recordFileUpdate(serverMessage);
if (
serverMessage?.type === "fs.file.update" &&
serverMessage.payload?.version?.status === "ready" &&
@@ -157,6 +173,11 @@ export async function installDaemonWebSocketGate(page: Page) {
if (subscribedFilePaths.has(path)) return Promise.resolve();
return new Promise((resolve) => fileSubscriptionWaiters.set(path, resolve));
},
waitForFileUpdate(path: string, status: "ready" | "missing" | "error"): Promise<void> {
const key = `${path}:${status}`;
if (observedFileUpdates.has(key)) return Promise.resolve();
return new Promise((resolve) => fileUpdateWaiters.set(key, resolve));
},
holdFileReads(path: string): void {
fileReadPathToHold = path;
heldFileReads = [];
+48
View File
@@ -0,0 +1,48 @@
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
export async function recordFileCallouts(page: Page): Promise<void> {
await page.evaluate(() => {
const callouts: string[] = [];
const recordAlert = (alert: Element) => {
const text = alert.textContent?.trim();
if (text && !callouts.includes(text)) callouts.push(text);
};
const record = (records: MutationRecord[]) => {
for (const mutation of records) {
if (
!(mutation.target instanceof Element) ||
!mutation.target.closest('[data-testid="workspace-file-pane"]')
) {
continue;
}
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches('[role="alert"]')) recordAlert(node);
for (const alert of node.querySelectorAll('[role="alert"]')) recordAlert(alert);
}
}
};
const observer = new MutationObserver(record);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
Object.assign(globalThis, { __PASEO_RECORDED_FILE_CALLOUTS__: callouts });
});
}
export async function expectFileCalloutWasRendered(page: Page, title: string): Promise<void> {
await expect
.poll(() => recordedFileCallouts(page))
.toEqual(expect.arrayContaining([expect.stringContaining(title)]));
}
export async function expectNoFileCalloutWasRendered(page: Page): Promise<void> {
await expect.poll(() => recordedFileCallouts(page)).toEqual([]);
}
function recordedFileCallouts(page: Page): Promise<string[]> {
return page.evaluate(
() =>
(globalThis as typeof globalThis & { __PASEO_RECORDED_FILE_CALLOUTS__?: string[] })
.__PASEO_RECORDED_FILE_CALLOUTS__ ?? [],
);
}