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 }) => {