diff --git a/packages/app/e2e/file-change-conflicts.spec.ts b/packages/app/e2e/file-change-conflicts.spec.ts index 202d273cc..af031974b 100644 --- a/packages/app/e2e/file-change-conflicts.spec.ts +++ b/packages/app/e2e/file-change-conflicts.spec.ts @@ -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 { await expect(fileCallout(page)).toHaveCount(0); } +async function restoreFileAfterWatcherObservedTemporaryAbsence(input: { + gate: Awaited>; + filePath: string; + relativePath: string; +}): Promise { + 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 }) => { diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index 860dae5be..ec0d37ea1 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -76,6 +76,8 @@ export async function installDaemonWebSocketGate(page: Page) { const clientRequestCounts = new Map(); const subscribedFilePaths = new Set(); const fileSubscriptionWaiters = new Map void>(); + const observedFileUpdates = new Set(); + const fileUpdateWaiters = new Map 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 { + 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 = []; diff --git a/packages/app/e2e/helpers/file-callouts.ts b/packages/app/e2e/helpers/file-callouts.ts new file mode 100644 index 000000000..0ca7d6493 --- /dev/null +++ b/packages/app/e2e/helpers/file-callouts.ts @@ -0,0 +1,48 @@ +import type { Page } from "@playwright/test"; +import { expect } from "../fixtures"; + +export async function recordFileCallouts(page: Page): Promise { + 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 { + await expect + .poll(() => recordedFileCallouts(page)) + .toEqual(expect.arrayContaining([expect.stringContaining(title)])); +} + +export async function expectNoFileCalloutWasRendered(page: Page): Promise { + await expect.poll(() => recordedFileCallouts(page)).toEqual([]); +} + +function recordedFileCallouts(page: Page): Promise { + return page.evaluate( + () => + (globalThis as typeof globalThis & { __PASEO_RECORDED_FILE_CALLOUTS__?: string[] }) + .__PASEO_RECORDED_FILE_CALLOUTS__ ?? [], + ); +} diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts index 51b8ee10a..2e36add95 100644 --- a/packages/app/src/file-explorer/read-result.ts +++ b/packages/app/src/file-explorer/read-result.ts @@ -12,6 +12,7 @@ export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile { mimeType: file.mime, size: file.size, modifiedAt: file.modifiedAt, + revision: file.revision, }; } diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts index 966537c85..e2f6ea4db 100644 --- a/packages/app/src/file-pane/editor/model.test.ts +++ b/packages/app/src/file-pane/editor/model.test.ts @@ -5,7 +5,9 @@ import { getFileConflictCallout, type FileEditorClock, type FileEditorFile, + type FileEditorObservation, type FileEditorSession, + type FileObservationSource, } from "./model"; class TestClock implements FileEditorClock { @@ -38,10 +40,6 @@ class FileSession implements FileEditorSession { this.file = file; } - async read(): Promise { - return this.file; - } - async write(input: { content: string; expectedModifiedAt: string; @@ -71,6 +69,34 @@ class FileSession implements FileEditorSession { } } +class ObservationSource implements FileObservationSource { + observation: FileEditorObservation | null; + refreshes = 0; + private readonly listeners = new Set<() => void>(); + + constructor(observation: FileEditorObservation | null) { + this.observation = observation; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getObservation(): FileEditorObservation | null { + return this.observation; + } + + refresh = (): void => { + this.refreshes += 1; + }; + + emit(observation: FileEditorObservation): void { + this.observation = observation; + for (const listener of this.listeners) listener(); + } +} + function ready( modifiedAt = "2026-07-18T00:00:00.000Z", size = 3, @@ -94,6 +120,14 @@ function makeModel(input: MakeModelInput = {}) { return { model: new FileEditorModel({ file, session, clock }), session, clock }; } +function observeFile(model: FileEditorModel, file: FileEditorFile): void { + model.receiveFileObservation({ status: "ready", file }); +} + +function observeVersion(model: FileEditorModel, version: FileEditorObservation): void { + model.receiveFileObservation(version); +} + describe("FileEditorModel", () => { test("tracks whether the current buffer differs from persisted content", async () => { const { model } = makeModel(); @@ -112,11 +146,194 @@ describe("FileEditorModel", () => { test("adopts a precise revision for otherwise unchanged initial metadata", () => { const { model } = makeModel(); - model.receiveFileVersion({ ...ready(), revision: "precise-revision" }); + observeFile(model, { + content: "one", + hasBom: false, + version: { ...ready(), revision: "precise-revision" }, + }); expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" }); }); + test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => { + const { model, session } = makeModel(); + model.edit("local"); + observeFile(model, { + content: "one", + hasBom: false, + version: { ...ready(), revision: "replacement-revision" }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" }); + await model.save(); + + expect(session.writes).toEqual([ + { + content: "local", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + expectedRevision: "replacement-revision", + }, + ]); + }); + + test("adopts an external BOM change before saving a dirty buffer", async () => { + const { model, session } = makeModel(); + model.edit("local"); + observeFile(model, { + content: "one", + hasBom: true, + version: { ...ready(), revision: "replacement-revision" }, + }); + + await model.save(); + + expect(session.writes).toEqual([ + { + content: "\uFEFFlocal", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + expectedRevision: "replacement-revision", + }, + ]); + }); + + test("conflicts when a same-content observation changes the BOM during a save", async () => { + const { model, session } = makeModel(); + session.holdNextWrite(); + model.edit("saved"); + const save = model.save(); + observeFile(model, { + content: "saved", + hasBom: true, + version: ready("2026-07-18T00:00:02.000Z", 6), + }); + session.finishHeldWrite({ + status: "written", + modifiedAt: "2026-07-18T00:00:01.000Z", + size: 5, + }); + + await save; + + expect(model.getSnapshot()).toMatchObject({ + status: "conflict", + observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, + }); + }); + + test("ignores a settled observation that was already consumed", () => { + const { model } = makeModel(); + const observation: FileEditorObservation = { + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }; + let emissions = 0; + model.subscribe(() => { + emissions += 1; + }); + model.receiveFileObservation(observation); + const emissionsAfterFirstDelivery = emissions; + + model.receiveFileObservation(observation); + + expect(emissions).toBe(emissionsAfterFirstDelivery); + }); + + test("does not replay the pre-save observation while its refresh is pending", async () => { + const { model } = makeModel(); + const observation: FileEditorObservation = { + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }; + model.receiveFileObservation(observation); + model.edit("saved"); + await model.save(); + + model.receiveFileObservation(observation); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" }); + }); + + test("does not reload stale bytes after a write conflict", async () => { + const { model, session } = makeModel(); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 8), + }; + model.edit("important local work"); + await model.save(); + + await model.reload(); + + expect(model.getSnapshot()).toMatchObject({ + status: "conflict", + content: "important local work", + observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, + }); + }); + + test("reloads a write conflict only after refreshed bytes arrive", async () => { + const { model, session } = makeModel(); + const source = new ObservationSource({ + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }); + model.connectFileObservations(source); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 4), + }; + model.edit("local"); + await model.save(); + + await model.reload(); + expect(source.refreshes).toBe(1); + expect(model.getSnapshot().content).toBe("local"); + source.emit({ + status: "ready", + file: { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); + }); + + test("abandons a deferred reload when its refresh fails", async () => { + const { model, session } = makeModel(); + const source = new ObservationSource({ + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }); + model.connectFileObservations(source); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 4), + }; + model.edit("local"); + await model.save(); + await model.reload(); + source.emit({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "File unavailable.", + }); + model.edit("new local work"); + + source.emit({ + status: "ready", + file: { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:03.000Z", 4), + }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" }); + }); + test("keeps a newer edit modified when an older save finishes", async () => { const { model, session } = makeModel(); session.holdNextWrite(); @@ -204,8 +421,7 @@ describe("FileEditorModel", () => { version: ready("2026-07-18T00:00:02.000Z", 8) as Extract, }; - model.receiveFileVersion(session.file.version); - await Promise.resolve(); + observeFile(model, session.file); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" }); }); @@ -218,8 +434,7 @@ describe("FileEditorModel", () => { version: ready("2026-07-18T00:00:02.000Z", 7) as Extract, }; - model.receiveFileVersion(session.file.version); - await Promise.resolve(); + observeFile(model, session.file); expect(model.getSnapshot().lineSeparator).toBe("\n"); model.edit("saved\n"); await model.save(); @@ -230,19 +445,13 @@ describe("FileEditorModel", () => { }); }); - test("coalesces consecutive clean disk updates onto the latest reload", async () => { - const { model, session } = makeModel(); - const reads: Array<(file: FileEditorFile) => void> = []; - session.read = () => new Promise((resolve) => reads.push(resolve)); + test("applies consecutive clean disk observations", () => { + const { model } = makeModel(); const firstVersion = ready("2026-07-18T00:00:02.000Z", 5); const latestVersion = ready("2026-07-18T00:00:03.000Z", 6); - model.receiveFileVersion(firstVersion); - model.receiveFileVersion(latestVersion); - reads[0]?.({ content: "first", hasBom: false, version: firstVersion }); - await Promise.resolve(); - reads[1]?.({ content: "latest", hasBom: false, version: latestVersion }); - await Promise.resolve(); + observeFile(model, { content: "first", hasBom: false, version: firstVersion }); + observeFile(model, { content: "latest", hasBom: false, version: latestVersion }); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" }); }); @@ -250,7 +459,11 @@ describe("FileEditorModel", () => { test("preserves a dirty buffer and overwrites against the newest disk revision", async () => { const { model, session } = makeModel(); model.edit("local"); - model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4)); + observeFile(model, { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }); expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" }); await model.overwrite(); @@ -264,7 +477,11 @@ describe("FileEditorModel", () => { test("keeps the local CRLF and BOM when overwriting a conflict", async () => { const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); model.edit("local\r\n"); - model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4)); + observeFile(model, { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }); await model.overwrite(); @@ -284,7 +501,7 @@ describe("FileEditorModel", () => { { status: "ready" } >; session.file = { content: "disk", hasBom: false, version: diskVersion }; - model.receiveFileVersion(diskVersion); + observeFile(model, session.file); await model.reload(); @@ -299,7 +516,7 @@ describe("FileEditorModel", () => { { status: "ready" } >; session.file = { content: "disk\n", hasBom: false, version: diskVersion }; - model.receiveFileVersion(diskVersion); + observeFile(model, session.file); await model.reload(); model.edit("saved\n"); @@ -328,7 +545,7 @@ describe("FileEditorModel", () => { test("a deletion conflicts with local changes and stops autosave", () => { const { model, session, clock } = makeModel(); model.edit("local"); - model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" }); + observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" }); clock.fire(); @@ -338,14 +555,14 @@ describe("FileEditorModel", () => { test("clears a transient check error when the recovered file is unchanged", () => { const { model } = makeModel(); - model.receiveFileVersion({ + observeVersion(model, { status: "error", cwd: "/workspace", path: "file.ts", error: "Requested path is not a file", }); - model.receiveFileVersion(ready()); + observeFile(model, { content: "one", hasBom: false, version: ready() }); expect(model.getSnapshot()).toMatchObject({ status: "clean", @@ -358,14 +575,14 @@ describe("FileEditorModel", () => { test("resumes autosave when a dirty file recovers unchanged from a check error", async () => { const { model, session, clock } = makeModel(); model.edit("local"); - model.receiveFileVersion({ + observeVersion(model, { status: "error", cwd: "/workspace", path: "file.ts", error: "Requested path is not a file", }); - model.receiveFileVersion(ready()); + observeFile(model, { content: "one", hasBom: false, version: ready() }); clock.fire(); await Promise.resolve(); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts index 79b736899..6207e2889 100644 --- a/packages/app/src/file-pane/editor/model.ts +++ b/packages/app/src/file-pane/editor/model.ts @@ -1,6 +1,6 @@ import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; -export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error"; +export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error"; export type FileLineSeparator = "\n" | "\r\n" | "\r"; export interface FileEditorSnapshot { @@ -25,7 +25,6 @@ export interface FileEditorFile { } export interface FileEditorSession { - read(): Promise; write(input: { content: string; expectedModifiedAt: string; @@ -33,11 +32,18 @@ export interface FileEditorSession { }): Promise; } -export interface FileVersionSource { +export type FileEditorObservation = + | { status: "ready"; file: FileEditorFile } + | Extract; + +export interface FileObservationSource { subscribe(listener: () => void): () => void; - getVersion(): FileVersion | null; + getObservation(): FileEditorObservation | null; + refresh(): void; } +type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion }; + export interface FileEditorClock { setTimeout(callback: () => void, delayMs: number): ReturnType; clearTimeout(handle: ReturnType): void; @@ -60,10 +66,14 @@ export class FileEditorModel { private autosave: ReturnType | null = null; private saveSequence = 0; private disposed = false; - private observedWhileSaving: FileVersion | null = null; + private observedWhileSaving: FileEditorObservation | null = null; + private observed: ObservedDiskState; + private lastReceivedObservation: FileEditorObservation | null = null; + private refreshObservation: (() => void) | null = null; + private reloadRequested = false; private persistedContent: string; private hasBom: boolean; - private unsubscribeVersionSource: (() => void) | null = null; + private unsubscribeObservationSource: (() => void) | null = null; constructor(input: { file: FileEditorFile; @@ -74,6 +84,7 @@ export class FileEditorModel { this.clock = input.clock ?? systemClock; this.persistedContent = input.file.content; this.hasBom = input.file.hasBom; + this.observed = { status: "ready", file: input.file }; this.snapshot = { status: "clean", content: input.file.content, @@ -92,26 +103,29 @@ export class FileEditorModel { getSnapshot = (): FileEditorSnapshot => this.snapshot; - connectFileVersions(source: FileVersionSource): void { - this.disconnectFileVersions(); - const receiveVersion = () => { - const version = source.getVersion(); - if (version) this.receiveFileVersion(version); + connectFileObservations(source: FileObservationSource): void { + this.disconnectFileObservations(); + this.refreshObservation = source.refresh; + const receiveObservation = () => { + const observation = source.getObservation(); + if (observation) this.receiveFileObservation(observation); }; - this.unsubscribeVersionSource = source.subscribe(receiveVersion); - receiveVersion(); + this.unsubscribeObservationSource = source.subscribe(receiveObservation); + receiveObservation(); } - disconnectFileVersions(): void { - this.unsubscribeVersionSource?.(); - this.unsubscribeVersionSource = null; + disconnectFileObservations(): void { + this.unsubscribeObservationSource?.(); + this.unsubscribeObservationSource = null; + this.refreshObservation = null; } edit(content: string): void { if (this.disposed || content === this.snapshot.content) return; + this.reloadRequested = false; const modified = content !== this.persistedContent; let status: FileEditorStatus = modified ? "dirty" : "clean"; - if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") { + if (this.snapshot.status === "conflict") { status = "conflict"; } this.setSnapshot({ ...this.snapshot, status, content, modified, error: null }); @@ -130,34 +144,32 @@ export class FileEditorModel { await this.performWrite(this.snapshot.observedVersion); } - receiveFileVersion(version: FileVersion): void { - if (this.disposed) return; - if (sameVersion(version, this.snapshot.observedVersion)) { - if ( - version.status === "ready" && - this.snapshot.observedVersion.status === "ready" && - version.revision && - !this.snapshot.observedVersion.revision - ) { - this.setSnapshot({ - ...this.snapshot, - version: - this.snapshot.version.status === "ready" - ? { ...this.snapshot.version, revision: version.revision } - : this.snapshot.version, - observedVersion: version, - }); - } - return; - } - if (this.restoreUnchangedConflict(version)) return; + receiveFileObservation(observation: FileEditorObservation): void { + if (this.disposed || observation === this.lastReceivedObservation) return; + this.lastReceivedObservation = observation; + const version = observationVersion(observation); + this.observed = observation; this.setSnapshot({ ...this.snapshot, observedVersion: version }); if (this.snapshot.status === "saving") { - this.observedWhileSaving = version; + this.observedWhileSaving = observation; return; } - if (this.snapshot.status === "clean" || this.snapshot.status === "loading") { - void this.reloadFromDisk(version); + if (observation.status !== "ready") { + this.reloadRequested = false; + this.enterConflict(version); + return; + } + if (this.reloadRequested) { + this.reloadRequested = false; + this.applyFile(observation.file); + return; + } + if (observation.file.content === this.persistedContent) { + this.adoptUnchangedFile(observation.file); + return; + } + if (this.snapshot.status === "clean") { + this.applyFile(observation.file); return; } this.enterConflict(version); @@ -171,14 +183,20 @@ export class FileEditorModel { async reload(): Promise { if (this.disposed) return; - await this.reloadFromDisk(this.snapshot.observedVersion); + if (this.observed.status !== "ready") { + this.reloadRequested = true; + this.refreshObservation?.(); + return; + } + this.applyFile(this.observed.file); } dispose(): void { this.disposed = true; + this.reloadRequested = false; this.saveSequence += 1; this.clearAutosave(); - this.disconnectFileVersions(); + this.disconnectFileObservations(); this.listeners.clear(); } @@ -199,9 +217,10 @@ export class FileEditorModel { this.clearAutosave(); const sequence = ++this.saveSequence; const content = this.snapshot.content; + const hasBom = this.hasBom; this.observedWhileSaving = null; this.setSnapshot({ ...this.snapshot, status: "saving", error: null }); - const serializedContent = this.hasBom ? `\uFEFF${content}` : content; + const serializedContent = hasBom ? `\uFEFF${content}` : content; let result: FileWriteResult; try { result = await this.session.write({ @@ -224,11 +243,12 @@ export class FileEditorModel { return; } if (result.status === "conflict") { + this.observed = { status: "unsettled", version: result.version }; this.enterConflict(result.version); return; } - const writtenVersion: FileVersion = { + const writtenVersion: Extract = { status: "ready", cwd: this.snapshot.version.cwd, path: this.snapshot.version.path, @@ -236,64 +256,56 @@ export class FileEditorModel { modifiedAt: result.modifiedAt, revision: result.revision, }; - const pending = this.observedWhileSaving; - this.observedWhileSaving = null; + const pending = this.takeObservedWhileSaving(); this.persistedContent = content; - if (pending && !sameVersion(pending, writtenVersion)) { + if (pending && !observationMatchesWrite(pending, content, hasBom)) { + const pendingVersion = observationVersion(pending); + this.observed = pending; this.setSnapshot({ ...this.snapshot, status: "conflict", modified: this.snapshot.content !== this.persistedContent, version: writtenVersion, - observedVersion: pending, + observedVersion: pendingVersion, error: null, }); return; } + const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion; + this.observed = pending ?? { status: "unsettled", version: writtenVersion }; const modified = this.snapshot.content !== this.persistedContent; this.setSnapshot({ ...this.snapshot, status: modified ? "dirty" : "clean", modified, - version: writtenVersion, - observedVersion: writtenVersion, + version: settledVersion, + observedVersion: settledVersion, error: null, }); if (modified) this.scheduleAutosave(); } - private async reloadFromDisk(version: FileVersion): Promise { + private applyFile(file: FileEditorFile): void { this.clearAutosave(); - if (version.status !== "ready") { - this.enterConflict(version); - return; - } - const sequence = ++this.saveSequence; - this.setSnapshot({ ...this.snapshot, status: "loading", error: null }); - try { - const file = await this.session.read(); - if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") { - return; - } - this.persistedContent = file.content; - this.hasBom = file.hasBom; - this.setSnapshot({ - status: "clean", - content: file.content, - lineSeparator: detectLineSeparator(file.content), - modified: false, - version: file.version, - observedVersion: file.version, - error: null, - }); - } catch (error) { - if (this.disposed || sequence !== this.saveSequence) return; - this.setSnapshot({ - ...this.snapshot, - status: "error", - error: error instanceof Error ? error.message : String(error), - }); - } + this.saveSequence += 1; + this.persistedContent = file.content; + this.hasBom = file.hasBom; + this.observed = { status: "ready", file }; + this.setSnapshot({ + status: "clean", + content: file.content, + lineSeparator: detectLineSeparator(file.content), + modified: false, + version: file.version, + observedVersion: file.version, + error: null, + }); + } + + private takeObservedWhileSaving(): FileEditorObservation | null { + const observation = this.observedWhileSaving; + this.observedWhileSaving = null; + return observation; } private enterConflict(version: FileVersion): void { @@ -307,27 +319,23 @@ export class FileEditorModel { }); } - private restoreUnchangedConflict(version: FileVersion): boolean { - if ( - this.snapshot.status !== "conflict" || - version.status !== "ready" || - this.snapshot.version.status !== "ready" || - !sameVersion(version, this.snapshot.version) - ) { - return false; - } + private adoptUnchangedFile(file: FileEditorFile): void { + this.hasBom = file.hasBom; + this.observed = { status: "ready", file }; const modified = this.snapshot.content !== this.persistedContent; + const recovering = this.snapshot.status === "conflict"; + let status = this.snapshot.status; + if (recovering) status = modified ? "dirty" : "clean"; this.setSnapshot({ ...this.snapshot, - status: modified ? "dirty" : "clean", + status, modified, - version, - observedVersion: version, - error: null, + version: file.version, + observedVersion: file.version, + error: recovering ? null : this.snapshot.error, }); - if (modified) this.scheduleAutosave(); + if (status === "dirty") this.scheduleAutosave(); else this.clearAutosave(); - return true; } private scheduleAutosave(): void { @@ -377,13 +385,18 @@ function detectLineSeparator(content: string): FileLineSeparator { return "\n"; } -function sameVersion(left: FileVersion, right: FileVersion): boolean { - if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path) - return false; - if (left.status === "ready" && right.status === "ready") { - if (left.revision && right.revision) return left.revision === right.revision; - return left.modifiedAt === right.modifiedAt && left.size === right.size; - } - if (left.status === "error" && right.status === "error") return left.error === right.error; - return true; +function observationVersion(observation: FileEditorObservation): FileVersion { + return observation.status === "ready" ? observation.file.version : observation; +} + +function observationMatchesWrite( + observation: FileEditorObservation, + content: string, + hasBom: boolean, +): boolean { + return ( + observation.status === "ready" && + observation.file.content === content && + observation.file.hasBom === hasBom + ); } diff --git a/packages/app/src/file-pane/editor/observation-source.test.ts b/packages/app/src/file-pane/editor/observation-source.test.ts new file mode 100644 index 000000000..30a751b5c --- /dev/null +++ b/packages/app/src/file-pane/editor/observation-source.test.ts @@ -0,0 +1,60 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import { describe, expect, test } from "vitest"; +import type { LiveFileObservation } from "../live-file/model"; +import { createFileObservationSource, type LiveObservationSource } from "./observation-source"; + +class TestLiveObservationSource implements LiveObservationSource { + observation: LiveFileObservation | null; + + constructor(observation: LiveFileObservation | null) { + this.observation = observation; + } + + subscribe(): () => void { + return () => undefined; + } + + getObservation(): LiveFileObservation | null { + return this.observation; + } + + refresh(): void {} +} + +function readyObservation(): LiveFileObservation { + const file: FileReadResult = { + bytes: new TextEncoder().encode("one"), + mime: "text/plain", + size: 3, + path: "file.ts", + kind: "text", + modifiedAt: "2026-07-18T00:00:00.000Z", + revision: "revision-1", + }; + return { + status: "ready", + file, + version: { + status: "ready", + cwd: "/workspace", + path: "file.ts", + size: file.size, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, + }; +} + +describe("createFileObservationSource", () => { + test("returns the same editor observation until the live observation changes", () => { + const liveFile = new TestLiveObservationSource(readyObservation()); + const source = createFileObservationSource(liveFile); + + const first = source.getObservation(); + const repeated = source.getObservation(); + + expect(repeated).toBe(first); + liveFile.observation = readyObservation(); + expect(source.getObservation()).not.toBe(first); + }); +}); diff --git a/packages/app/src/file-pane/editor/observation-source.ts b/packages/app/src/file-pane/editor/observation-source.ts new file mode 100644 index 000000000..478c5d3e7 --- /dev/null +++ b/packages/app/src/file-pane/editor/observation-source.ts @@ -0,0 +1,50 @@ +import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import type { LiveFileObservation } from "../live-file/model"; +import type { FileEditorObservation, FileObservationSource } from "./model"; + +export interface LiveObservationSource { + subscribe(listener: () => void): () => void; + getObservation(): LiveFileObservation | null; + refresh(): void; +} + +export function createFileObservationSource( + liveFile: LiveObservationSource, +): FileObservationSource { + let liveObservation: LiveFileObservation | null = null; + let editorObservation: FileEditorObservation | null = null; + return { + subscribe: liveFile.subscribe, + refresh: liveFile.refresh, + getObservation() { + const nextLiveObservation = liveFile.getObservation(); + if (nextLiveObservation === liveObservation) return editorObservation; + liveObservation = nextLiveObservation; + editorObservation = fileEditorObservation(nextLiveObservation); + return editorObservation; + }, + }; +} + +function fileEditorObservation( + observation: LiveFileObservation | null, +): FileEditorObservation | null { + if (!observation || observation.status !== "ready") return observation; + const decodedFile = explorerFileFromReadResult(observation.file); + if (decodedFile.kind !== "text" || decodedFile.content === undefined) { + return { + status: "error", + cwd: observation.version.cwd, + path: observation.version.path, + error: "File is no longer text.", + }; + } + return { + status: "ready", + file: { + content: decodedFile.content, + hasBom: decodedFile.hasBom, + version: observation.version, + }, + }; +} diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts index b8b4bf025..e318f7c0c 100644 --- a/packages/app/src/file-pane/live-file/hook.ts +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -38,7 +38,7 @@ export function useLiveFile(input: { const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); return { - file: snapshot.file, + file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null, error: snapshot.read.status === "error" ? snapshot.read.error : null, isFetching: snapshot.read.status === "pending", isRetrying: snapshot.read.status === "pending" && snapshot.read.requested, diff --git a/packages/app/src/file-pane/live-file/model.test.ts b/packages/app/src/file-pane/live-file/model.test.ts index d29a7d5f2..c8b450ed1 100644 --- a/packages/app/src/file-pane/live-file/model.test.ts +++ b/packages/app/src/file-pane/live-file/model.test.ts @@ -117,6 +117,68 @@ async function waitForRead( } describe("LiveFileModel", () => { + it("does not publish a missing candidate when the following read succeeds", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); + const publishedStatuses: string[] = []; + model.subscribe(() => { + const observation = model.getObservation(); + if (observation) publishedStatuses.push(observation.status); + }); + + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + await waitForRead(session, 2); + session.reads[1].resolve(file()); + await vi.waitFor(() => expect(model.getSnapshot().read).toEqual({ status: "idle" })); + + expect(publishedStatuses).not.toContain("missing"); + }); + + it("publishes missing when the following read also fails", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready")); + + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + await waitForRead(session, 2); + session.reads[1].reject(new Error("File not found.")); + + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing")); + }); + + it("preserves a missing candidate for its queued confirming read", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + session.reads[0].resolve(file()); + await waitForRead(session, 2); + session.reads[1].reject(new Error("File not found.")); + + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing")); + }); + it("recovers only from a read begun after a missing observation", async () => { const session = new TestLiveFileSession(); const model = new LiveFileModel(); @@ -127,7 +189,7 @@ describe("LiveFileModel", () => { }); await waitForRead(session, 1); session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); model.refresh(); await waitForRead(session, 2); @@ -135,14 +197,17 @@ describe("LiveFileModel", () => { session.reads[1].resolve(file("revision-2", "two")); await waitForRead(session, 3); - expect(model.getSnapshot().version?.status).toBe("missing"); + expect(model.getObservation()?.status).toBe("ready"); expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); session.reads[2].resolve(file("revision-2", "two")); await vi.waitFor(() => { expect(model.getSnapshot()).toMatchObject({ - version: { status: "ready", revision: "revision-2" }, - file: { revision: "revision-2" }, + observation: { + status: "ready", + version: { revision: "revision-2" }, + file: { revision: "revision-2" }, + }, read: { status: "idle" }, }); }); @@ -158,7 +223,7 @@ describe("LiveFileModel", () => { }); await waitForRead(session, 1); session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); session.emit({ status: "error", @@ -172,7 +237,7 @@ describe("LiveFileModel", () => { await vi.waitFor(() => { expect(model.getSnapshot()).toMatchObject({ - version: { status: "error", error: "Requested path is not a file" }, + observation: { status: "error", error: "Requested path is not a file" }, read: { status: "error", error: "File unavailable." }, }); }); @@ -195,7 +260,7 @@ describe("LiveFileModel", () => { session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} }); await waitForRead(session, 1); - expect(model.getVersion()).toMatchObject({ status: "ready", revision: "revision-2" }); + expect(model.getObservation()).toBeNull(); }); it("retries a failed subscription when the user refreshes", async () => { @@ -210,7 +275,7 @@ describe("LiveFileModel", () => { expect(session.subscribeAttempts).toBe(1); session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getVersion()?.status).toBe("ready")); + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready")); model.refresh(); await waitForRead(session, 2); @@ -247,8 +312,7 @@ describe("LiveFileModel", () => { await Promise.resolve(); expect(model.getSnapshot()).toEqual({ - file: null, - version: null, + observation: null, read: { status: "idle" }, }); }); diff --git a/packages/app/src/file-pane/live-file/model.ts b/packages/app/src/file-pane/live-file/model.ts index 478742329..cbd075749 100644 --- a/packages/app/src/file-pane/live-file/model.ts +++ b/packages/app/src/file-pane/live-file/model.ts @@ -24,15 +24,21 @@ export type LiveFileReadState = | { status: "pending"; requested: boolean } | { status: "error"; error: string }; +export type LiveFileObservation = + | { + status: "ready"; + version: Extract; + file: FileReadResult; + } + | Extract; + export interface LiveFileSnapshot { - file: FileReadResult | null; - version: FileVersion | null; + observation: LiveFileObservation | null; read: LiveFileReadState; } const idleSnapshot: LiveFileSnapshot = { - file: null, - version: null, + observation: null, read: { status: "idle" }, }; @@ -49,6 +55,7 @@ export class LiveFileModel { private activeReadSequence: number | null = null; private activeReadRequested = false; private observedAfterReadSequence: number | null = null; + private candidateVersion: FileVersion | null = null; private refreshQueued = false; private requestedRefreshQueued = false; @@ -59,7 +66,7 @@ export class LiveFileModel { getSnapshot = (): LiveFileSnapshot => this.snapshot; - getVersion = (): FileVersion | null => this.snapshot.version; + getObservation = (): LiveFileObservation | null => this.snapshot.observation; open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void { this.reset(); @@ -111,6 +118,7 @@ export class LiveFileModel { this.activeReadSequence = null; this.activeReadRequested = false; this.observedAfterReadSequence = null; + this.candidateVersion = null; this.refreshQueued = false; this.requestedRefreshQueued = false; this.setSnapshot(idleSnapshot); @@ -144,7 +152,7 @@ export class LiveFileModel { private observeVersion(version: FileVersion): void { this.observedAfterReadSequence = this.issuedReadSequence; - this.setSnapshot({ ...this.snapshot, version }); + this.candidateVersion = version; this.scheduleRead(false); } @@ -180,9 +188,13 @@ export class LiveFileModel { return; } this.observedAfterReadSequence = null; + this.candidateVersion = null; this.setSnapshot({ - file: input.file, - version: readyVersion(input.target, input.file), + observation: { + status: "ready", + file: input.file, + version: readyVersion(input.target, input.file), + }, read: { status: "idle" }, }); this.startQueuedRead(); @@ -203,11 +215,14 @@ export class LiveFileModel { return; } const message = errorMessage(input.error); - let version = this.snapshot.version; - if (version?.status !== "missing" && version?.status !== "error") { - version = { status: "error", cwd: input.target.cwd, path: input.target.path, error: message }; - } - this.setSnapshot({ ...this.snapshot, version, read: { status: "error", error: message } }); + const candidate = this.candidateVersion; + this.candidateVersion = null; + const candidateDescribesFailure = + candidate?.status === "missing" || candidate?.status === "error"; + const observation: LiveFileObservation = candidateDescribesFailure + ? candidate + : { status: "error", cwd: input.target.cwd, path: input.target.path, error: message }; + this.setSnapshot({ ...this.snapshot, observation, read: { status: "error", error: message } }); this.startQueuedRead(); } diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index f042d6628..ab7e3c35d 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -35,12 +35,8 @@ import { isWeb } from "@/constants/platform"; import { useAppSettings } from "@/hooks/use-settings"; import { useLiveFile } from "./live-file/hook"; import { FilePanelBar } from "./bar"; -import { - FileEditorModel, - getFileConflictCallout, - type FileConflictCallout, - type FileEditorFile, -} from "./editor/model"; +import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; +import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; import type { FileConflictAlertState } from "./conflict-alert"; import type { LiveFileModel } from "./live-file/model"; @@ -667,25 +663,6 @@ function EditableFilePane({ const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); const session = useMemo( () => ({ - async read(): Promise { - const file = await client.readFile(cwd, path); - const decodedFile = explorerFileFromReadResult(file); - if (decodedFile.kind !== "text" || decodedFile.content === undefined) { - throw new Error("File is no longer text."); - } - return { - content: decodedFile.content, - hasBom: decodedFile.hasBom, - version: { - status: "ready", - cwd, - path, - size: file.size, - modifiedAt: file.modifiedAt, - revision: file.revision, - }, - }; - }, write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) { return client.writeFile({ cwd, path, ...input }); }, @@ -703,14 +680,16 @@ function EditableFilePane({ path, size: preview.size, modifiedAt: preview.modifiedAt, + revision: preview.revision, }, }, session, }); }); useEffect(() => { - model.connectFileVersions(liveFile); - return () => model.disconnectFileVersions(); + const source = createFileObservationSource(liveFile); + model.connectFileObservations(source); + return () => model.disconnectFileObservations(); }, [liveFile, model]); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c0c39293a..50ad450d6 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -272,6 +272,7 @@ export interface ExplorerFile { mimeType?: string; size: number; modifiedAt: string; + revision?: string; } export interface ExplorerDirectory {