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:
@@ -4,6 +4,11 @@ import { expect, test, type Page } from "./fixtures";
|
|||||||
import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
|
import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
|
||||||
import type { WithWorkspace } from "./helpers/with-workspace";
|
import type { WithWorkspace } from "./helpers/with-workspace";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
|
import {
|
||||||
|
expectFileCalloutWasRendered,
|
||||||
|
expectNoFileCalloutWasRendered,
|
||||||
|
recordFileCallouts,
|
||||||
|
} from "./helpers/file-callouts";
|
||||||
|
|
||||||
function visibleEditor(page: Page) {
|
function visibleEditor(page: Page) {
|
||||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||||
@@ -61,10 +66,11 @@ async function replaceFileAfterDeletionWasObserved(input: {
|
|||||||
gate.holdFileReads(relativePath);
|
gate.holdFileReads(relativePath);
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
await gate.waitForHeldFileRead();
|
await gate.waitForHeldFileRead();
|
||||||
|
await expect(fileCallout(page)).toHaveCount(0);
|
||||||
|
gate.releaseHeldFileRead();
|
||||||
await expectOnlyFileCallout(page, "File deleted on disk");
|
await expectOnlyFileCallout(page, "File deleted on disk");
|
||||||
gate.holdNextReadyFileUpdate(relativePath);
|
gate.holdNextReadyFileUpdate(relativePath);
|
||||||
await writeFile(filePath, content, "utf8");
|
await writeFile(filePath, content, "utf8");
|
||||||
gate.releaseHeldFileRead();
|
|
||||||
await gate.waitForHeldReadyFileUpdate();
|
await gate.waitForHeldReadyFileUpdate();
|
||||||
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
|
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);
|
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.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 ({
|
test("a clean file replaced on disk offers one working reload action", async ({
|
||||||
page,
|
page,
|
||||||
withWorkspace,
|
withWorkspace,
|
||||||
@@ -122,8 +217,8 @@ test.describe("Workspace file change conflicts", () => {
|
|||||||
relativePath: "inventory.md",
|
relativePath: "inventory.md",
|
||||||
content: "# After\n",
|
content: "# After\n",
|
||||||
});
|
});
|
||||||
await expectCleanReplacementCanReload(page);
|
|
||||||
gate.releaseHeldReadyFileUpdate();
|
gate.releaseHeldReadyFileUpdate();
|
||||||
|
await expectCleanReplacementCanReload(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a deleted file shows one explanatory notice and no resolution actions", async ({
|
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",
|
content: "# Present\n",
|
||||||
});
|
});
|
||||||
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
|
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
|
||||||
|
await recordFileCallouts(page);
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
await expectDeletedFileNotice(page);
|
await expectDeletedFileNotice(page);
|
||||||
|
await expectFileCalloutWasRendered(page, "File deleted on disk");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
|
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 clientRequestCounts = new Map<string, number>();
|
||||||
const subscribedFilePaths = new Set<string>();
|
const subscribedFilePaths = new Set<string>();
|
||||||
const fileSubscriptionWaiters = new Map<string, () => void>();
|
const fileSubscriptionWaiters = new Map<string, () => void>();
|
||||||
|
const observedFileUpdates = new Set<string>();
|
||||||
|
const fileUpdateWaiters = new Map<string, () => void>();
|
||||||
let fileReadPathToHold: string | null = null;
|
let fileReadPathToHold: string | null = null;
|
||||||
let heldFileReads: Array<() => void> = [];
|
let heldFileReads: Array<() => void> = [];
|
||||||
let resolveHeldFileRead: (() => void) | null = null;
|
let resolveHeldFileRead: (() => void) | null = null;
|
||||||
@@ -84,6 +86,19 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
let heldReadyFileUpdate: (() => void) | null = null;
|
let heldReadyFileUpdate: (() => void) | null = null;
|
||||||
let resolveHeldReadyFileUpdate: (() => void) | null = null;
|
let resolveHeldReadyFileUpdate: (() => void) | null = null;
|
||||||
let heldReadyFileUpdatePromise = Promise.resolve();
|
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) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
if (!acceptingConnections) {
|
if (!acceptingConnections) {
|
||||||
@@ -134,6 +149,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
fileSubscriptionWaiters.get(path)?.();
|
fileSubscriptionWaiters.get(path)?.();
|
||||||
fileSubscriptionWaiters.delete(path);
|
fileSubscriptionWaiters.delete(path);
|
||||||
}
|
}
|
||||||
|
if (serverMessage) recordFileUpdate(serverMessage);
|
||||||
if (
|
if (
|
||||||
serverMessage?.type === "fs.file.update" &&
|
serverMessage?.type === "fs.file.update" &&
|
||||||
serverMessage.payload?.version?.status === "ready" &&
|
serverMessage.payload?.version?.status === "ready" &&
|
||||||
@@ -157,6 +173,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
if (subscribedFilePaths.has(path)) return Promise.resolve();
|
if (subscribedFilePaths.has(path)) return Promise.resolve();
|
||||||
return new Promise((resolve) => fileSubscriptionWaiters.set(path, 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 {
|
holdFileReads(path: string): void {
|
||||||
fileReadPathToHold = path;
|
fileReadPathToHold = path;
|
||||||
heldFileReads = [];
|
heldFileReads = [];
|
||||||
|
|||||||
@@ -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__ ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
|
|||||||
mimeType: file.mime,
|
mimeType: file.mime,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
|
revision: file.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
getFileConflictCallout,
|
getFileConflictCallout,
|
||||||
type FileEditorClock,
|
type FileEditorClock,
|
||||||
type FileEditorFile,
|
type FileEditorFile,
|
||||||
|
type FileEditorObservation,
|
||||||
type FileEditorSession,
|
type FileEditorSession,
|
||||||
|
type FileObservationSource,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
|
|
||||||
class TestClock implements FileEditorClock {
|
class TestClock implements FileEditorClock {
|
||||||
@@ -38,10 +40,6 @@ class FileSession implements FileEditorSession {
|
|||||||
this.file = file;
|
this.file = file;
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(): Promise<FileEditorFile> {
|
|
||||||
return this.file;
|
|
||||||
}
|
|
||||||
|
|
||||||
async write(input: {
|
async write(input: {
|
||||||
content: string;
|
content: string;
|
||||||
expectedModifiedAt: 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(
|
function ready(
|
||||||
modifiedAt = "2026-07-18T00:00:00.000Z",
|
modifiedAt = "2026-07-18T00:00:00.000Z",
|
||||||
size = 3,
|
size = 3,
|
||||||
@@ -94,6 +120,14 @@ function makeModel(input: MakeModelInput = {}) {
|
|||||||
return { model: new FileEditorModel({ file, session, clock }), session, clock };
|
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", () => {
|
describe("FileEditorModel", () => {
|
||||||
test("tracks whether the current buffer differs from persisted content", async () => {
|
test("tracks whether the current buffer differs from persisted content", async () => {
|
||||||
const { model } = makeModel();
|
const { model } = makeModel();
|
||||||
@@ -112,11 +146,194 @@ describe("FileEditorModel", () => {
|
|||||||
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
|
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
|
||||||
const { model } = makeModel();
|
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" });
|
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 () => {
|
test("keeps a newer edit modified when an older save finishes", async () => {
|
||||||
const { model, session } = makeModel();
|
const { model, session } = makeModel();
|
||||||
session.holdNextWrite();
|
session.holdNextWrite();
|
||||||
@@ -204,8 +421,7 @@ describe("FileEditorModel", () => {
|
|||||||
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
|
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
|
||||||
};
|
};
|
||||||
|
|
||||||
model.receiveFileVersion(session.file.version);
|
observeFile(model, session.file);
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
|
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<FileVersion, { status: "ready" }>,
|
version: ready("2026-07-18T00:00:02.000Z", 7) as Extract<FileVersion, { status: "ready" }>,
|
||||||
};
|
};
|
||||||
|
|
||||||
model.receiveFileVersion(session.file.version);
|
observeFile(model, session.file);
|
||||||
await Promise.resolve();
|
|
||||||
expect(model.getSnapshot().lineSeparator).toBe("\n");
|
expect(model.getSnapshot().lineSeparator).toBe("\n");
|
||||||
model.edit("saved\n");
|
model.edit("saved\n");
|
||||||
await model.save();
|
await model.save();
|
||||||
@@ -230,19 +445,13 @@ describe("FileEditorModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
|
test("applies consecutive clean disk observations", () => {
|
||||||
const { model, session } = makeModel();
|
const { model } = makeModel();
|
||||||
const reads: Array<(file: FileEditorFile) => void> = [];
|
|
||||||
session.read = () => new Promise((resolve) => reads.push(resolve));
|
|
||||||
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
|
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
|
||||||
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
|
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
|
||||||
|
|
||||||
model.receiveFileVersion(firstVersion);
|
observeFile(model, { content: "first", hasBom: false, version: firstVersion });
|
||||||
model.receiveFileVersion(latestVersion);
|
observeFile(model, { content: "latest", hasBom: false, version: latestVersion });
|
||||||
reads[0]?.({ content: "first", hasBom: false, version: firstVersion });
|
|
||||||
await Promise.resolve();
|
|
||||||
reads[1]?.({ content: "latest", hasBom: false, version: latestVersion });
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
|
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 () => {
|
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
|
||||||
const { model, session } = makeModel();
|
const { model, session } = makeModel();
|
||||||
model.edit("local");
|
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" });
|
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
|
||||||
await model.overwrite();
|
await model.overwrite();
|
||||||
@@ -264,7 +477,11 @@ describe("FileEditorModel", () => {
|
|||||||
test("keeps the local CRLF and BOM when overwriting a conflict", async () => {
|
test("keeps the local CRLF and BOM when overwriting a conflict", async () => {
|
||||||
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
||||||
model.edit("local\r\n");
|
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();
|
await model.overwrite();
|
||||||
|
|
||||||
@@ -284,7 +501,7 @@ describe("FileEditorModel", () => {
|
|||||||
{ status: "ready" }
|
{ status: "ready" }
|
||||||
>;
|
>;
|
||||||
session.file = { content: "disk", hasBom: false, version: diskVersion };
|
session.file = { content: "disk", hasBom: false, version: diskVersion };
|
||||||
model.receiveFileVersion(diskVersion);
|
observeFile(model, session.file);
|
||||||
|
|
||||||
await model.reload();
|
await model.reload();
|
||||||
|
|
||||||
@@ -299,7 +516,7 @@ describe("FileEditorModel", () => {
|
|||||||
{ status: "ready" }
|
{ status: "ready" }
|
||||||
>;
|
>;
|
||||||
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
|
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
|
||||||
model.receiveFileVersion(diskVersion);
|
observeFile(model, session.file);
|
||||||
|
|
||||||
await model.reload();
|
await model.reload();
|
||||||
model.edit("saved\n");
|
model.edit("saved\n");
|
||||||
@@ -328,7 +545,7 @@ describe("FileEditorModel", () => {
|
|||||||
test("a deletion conflicts with local changes and stops autosave", () => {
|
test("a deletion conflicts with local changes and stops autosave", () => {
|
||||||
const { model, session, clock } = makeModel();
|
const { model, session, clock } = makeModel();
|
||||||
model.edit("local");
|
model.edit("local");
|
||||||
model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||||
|
|
||||||
clock.fire();
|
clock.fire();
|
||||||
|
|
||||||
@@ -338,14 +555,14 @@ describe("FileEditorModel", () => {
|
|||||||
|
|
||||||
test("clears a transient check error when the recovered file is unchanged", () => {
|
test("clears a transient check error when the recovered file is unchanged", () => {
|
||||||
const { model } = makeModel();
|
const { model } = makeModel();
|
||||||
model.receiveFileVersion({
|
observeVersion(model, {
|
||||||
status: "error",
|
status: "error",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
path: "file.ts",
|
path: "file.ts",
|
||||||
error: "Requested path is not a file",
|
error: "Requested path is not a file",
|
||||||
});
|
});
|
||||||
|
|
||||||
model.receiveFileVersion(ready());
|
observeFile(model, { content: "one", hasBom: false, version: ready() });
|
||||||
|
|
||||||
expect(model.getSnapshot()).toMatchObject({
|
expect(model.getSnapshot()).toMatchObject({
|
||||||
status: "clean",
|
status: "clean",
|
||||||
@@ -358,14 +575,14 @@ describe("FileEditorModel", () => {
|
|||||||
test("resumes autosave when a dirty file recovers unchanged from a check error", async () => {
|
test("resumes autosave when a dirty file recovers unchanged from a check error", async () => {
|
||||||
const { model, session, clock } = makeModel();
|
const { model, session, clock } = makeModel();
|
||||||
model.edit("local");
|
model.edit("local");
|
||||||
model.receiveFileVersion({
|
observeVersion(model, {
|
||||||
status: "error",
|
status: "error",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
path: "file.ts",
|
path: "file.ts",
|
||||||
error: "Requested path is not a file",
|
error: "Requested path is not a file",
|
||||||
});
|
});
|
||||||
|
|
||||||
model.receiveFileVersion(ready());
|
observeFile(model, { content: "one", hasBom: false, version: ready() });
|
||||||
clock.fire();
|
clock.fire();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
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 type FileLineSeparator = "\n" | "\r\n" | "\r";
|
||||||
|
|
||||||
export interface FileEditorSnapshot {
|
export interface FileEditorSnapshot {
|
||||||
@@ -25,7 +25,6 @@ export interface FileEditorFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FileEditorSession {
|
export interface FileEditorSession {
|
||||||
read(): Promise<FileEditorFile>;
|
|
||||||
write(input: {
|
write(input: {
|
||||||
content: string;
|
content: string;
|
||||||
expectedModifiedAt: string;
|
expectedModifiedAt: string;
|
||||||
@@ -33,11 +32,18 @@ export interface FileEditorSession {
|
|||||||
}): Promise<FileWriteResult>;
|
}): Promise<FileWriteResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileVersionSource {
|
export type FileEditorObservation =
|
||||||
|
| { status: "ready"; file: FileEditorFile }
|
||||||
|
| Extract<FileVersion, { status: "missing" | "error" }>;
|
||||||
|
|
||||||
|
export interface FileObservationSource {
|
||||||
subscribe(listener: () => void): () => void;
|
subscribe(listener: () => void): () => void;
|
||||||
getVersion(): FileVersion | null;
|
getObservation(): FileEditorObservation | null;
|
||||||
|
refresh(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion };
|
||||||
|
|
||||||
export interface FileEditorClock {
|
export interface FileEditorClock {
|
||||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||||
@@ -60,10 +66,14 @@ export class FileEditorModel {
|
|||||||
private autosave: ReturnType<typeof setTimeout> | null = null;
|
private autosave: ReturnType<typeof setTimeout> | null = null;
|
||||||
private saveSequence = 0;
|
private saveSequence = 0;
|
||||||
private disposed = false;
|
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 persistedContent: string;
|
||||||
private hasBom: boolean;
|
private hasBom: boolean;
|
||||||
private unsubscribeVersionSource: (() => void) | null = null;
|
private unsubscribeObservationSource: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(input: {
|
constructor(input: {
|
||||||
file: FileEditorFile;
|
file: FileEditorFile;
|
||||||
@@ -74,6 +84,7 @@ export class FileEditorModel {
|
|||||||
this.clock = input.clock ?? systemClock;
|
this.clock = input.clock ?? systemClock;
|
||||||
this.persistedContent = input.file.content;
|
this.persistedContent = input.file.content;
|
||||||
this.hasBom = input.file.hasBom;
|
this.hasBom = input.file.hasBom;
|
||||||
|
this.observed = { status: "ready", file: input.file };
|
||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
status: "clean",
|
status: "clean",
|
||||||
content: input.file.content,
|
content: input.file.content,
|
||||||
@@ -92,26 +103,29 @@ export class FileEditorModel {
|
|||||||
|
|
||||||
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
||||||
|
|
||||||
connectFileVersions(source: FileVersionSource): void {
|
connectFileObservations(source: FileObservationSource): void {
|
||||||
this.disconnectFileVersions();
|
this.disconnectFileObservations();
|
||||||
const receiveVersion = () => {
|
this.refreshObservation = source.refresh;
|
||||||
const version = source.getVersion();
|
const receiveObservation = () => {
|
||||||
if (version) this.receiveFileVersion(version);
|
const observation = source.getObservation();
|
||||||
|
if (observation) this.receiveFileObservation(observation);
|
||||||
};
|
};
|
||||||
this.unsubscribeVersionSource = source.subscribe(receiveVersion);
|
this.unsubscribeObservationSource = source.subscribe(receiveObservation);
|
||||||
receiveVersion();
|
receiveObservation();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectFileVersions(): void {
|
disconnectFileObservations(): void {
|
||||||
this.unsubscribeVersionSource?.();
|
this.unsubscribeObservationSource?.();
|
||||||
this.unsubscribeVersionSource = null;
|
this.unsubscribeObservationSource = null;
|
||||||
|
this.refreshObservation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
edit(content: string): void {
|
edit(content: string): void {
|
||||||
if (this.disposed || content === this.snapshot.content) return;
|
if (this.disposed || content === this.snapshot.content) return;
|
||||||
|
this.reloadRequested = false;
|
||||||
const modified = content !== this.persistedContent;
|
const modified = content !== this.persistedContent;
|
||||||
let status: FileEditorStatus = modified ? "dirty" : "clean";
|
let status: FileEditorStatus = modified ? "dirty" : "clean";
|
||||||
if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") {
|
if (this.snapshot.status === "conflict") {
|
||||||
status = "conflict";
|
status = "conflict";
|
||||||
}
|
}
|
||||||
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
|
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
|
||||||
@@ -130,34 +144,32 @@ export class FileEditorModel {
|
|||||||
await this.performWrite(this.snapshot.observedVersion);
|
await this.performWrite(this.snapshot.observedVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
receiveFileVersion(version: FileVersion): void {
|
receiveFileObservation(observation: FileEditorObservation): void {
|
||||||
if (this.disposed) return;
|
if (this.disposed || observation === this.lastReceivedObservation) return;
|
||||||
if (sameVersion(version, this.snapshot.observedVersion)) {
|
this.lastReceivedObservation = observation;
|
||||||
if (
|
const version = observationVersion(observation);
|
||||||
version.status === "ready" &&
|
this.observed = observation;
|
||||||
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;
|
|
||||||
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
||||||
if (this.snapshot.status === "saving") {
|
if (this.snapshot.status === "saving") {
|
||||||
this.observedWhileSaving = version;
|
this.observedWhileSaving = observation;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.snapshot.status === "clean" || this.snapshot.status === "loading") {
|
if (observation.status !== "ready") {
|
||||||
void this.reloadFromDisk(version);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.enterConflict(version);
|
this.enterConflict(version);
|
||||||
@@ -171,14 +183,20 @@ export class FileEditorModel {
|
|||||||
|
|
||||||
async reload(): Promise<void> {
|
async reload(): Promise<void> {
|
||||||
if (this.disposed) return;
|
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 {
|
dispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
|
this.reloadRequested = false;
|
||||||
this.saveSequence += 1;
|
this.saveSequence += 1;
|
||||||
this.clearAutosave();
|
this.clearAutosave();
|
||||||
this.disconnectFileVersions();
|
this.disconnectFileObservations();
|
||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,9 +217,10 @@ export class FileEditorModel {
|
|||||||
this.clearAutosave();
|
this.clearAutosave();
|
||||||
const sequence = ++this.saveSequence;
|
const sequence = ++this.saveSequence;
|
||||||
const content = this.snapshot.content;
|
const content = this.snapshot.content;
|
||||||
|
const hasBom = this.hasBom;
|
||||||
this.observedWhileSaving = null;
|
this.observedWhileSaving = null;
|
||||||
this.setSnapshot({ ...this.snapshot, status: "saving", error: 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;
|
let result: FileWriteResult;
|
||||||
try {
|
try {
|
||||||
result = await this.session.write({
|
result = await this.session.write({
|
||||||
@@ -224,11 +243,12 @@ export class FileEditorModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.status === "conflict") {
|
if (result.status === "conflict") {
|
||||||
|
this.observed = { status: "unsettled", version: result.version };
|
||||||
this.enterConflict(result.version);
|
this.enterConflict(result.version);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const writtenVersion: FileVersion = {
|
const writtenVersion: Extract<FileVersion, { status: "ready" }> = {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
cwd: this.snapshot.version.cwd,
|
cwd: this.snapshot.version.cwd,
|
||||||
path: this.snapshot.version.path,
|
path: this.snapshot.version.path,
|
||||||
@@ -236,64 +256,56 @@ export class FileEditorModel {
|
|||||||
modifiedAt: result.modifiedAt,
|
modifiedAt: result.modifiedAt,
|
||||||
revision: result.revision,
|
revision: result.revision,
|
||||||
};
|
};
|
||||||
const pending = this.observedWhileSaving;
|
const pending = this.takeObservedWhileSaving();
|
||||||
this.observedWhileSaving = null;
|
|
||||||
this.persistedContent = content;
|
this.persistedContent = content;
|
||||||
if (pending && !sameVersion(pending, writtenVersion)) {
|
if (pending && !observationMatchesWrite(pending, content, hasBom)) {
|
||||||
|
const pendingVersion = observationVersion(pending);
|
||||||
|
this.observed = pending;
|
||||||
this.setSnapshot({
|
this.setSnapshot({
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
status: "conflict",
|
status: "conflict",
|
||||||
modified: this.snapshot.content !== this.persistedContent,
|
modified: this.snapshot.content !== this.persistedContent,
|
||||||
version: writtenVersion,
|
version: writtenVersion,
|
||||||
observedVersion: pending,
|
observedVersion: pendingVersion,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion;
|
||||||
|
this.observed = pending ?? { status: "unsettled", version: writtenVersion };
|
||||||
const modified = this.snapshot.content !== this.persistedContent;
|
const modified = this.snapshot.content !== this.persistedContent;
|
||||||
this.setSnapshot({
|
this.setSnapshot({
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
status: modified ? "dirty" : "clean",
|
status: modified ? "dirty" : "clean",
|
||||||
modified,
|
modified,
|
||||||
version: writtenVersion,
|
version: settledVersion,
|
||||||
observedVersion: writtenVersion,
|
observedVersion: settledVersion,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
if (modified) this.scheduleAutosave();
|
if (modified) this.scheduleAutosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reloadFromDisk(version: FileVersion): Promise<void> {
|
private applyFile(file: FileEditorFile): void {
|
||||||
this.clearAutosave();
|
this.clearAutosave();
|
||||||
if (version.status !== "ready") {
|
this.saveSequence += 1;
|
||||||
this.enterConflict(version);
|
this.persistedContent = file.content;
|
||||||
return;
|
this.hasBom = file.hasBom;
|
||||||
}
|
this.observed = { status: "ready", file };
|
||||||
const sequence = ++this.saveSequence;
|
this.setSnapshot({
|
||||||
this.setSnapshot({ ...this.snapshot, status: "loading", error: null });
|
status: "clean",
|
||||||
try {
|
content: file.content,
|
||||||
const file = await this.session.read();
|
lineSeparator: detectLineSeparator(file.content),
|
||||||
if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") {
|
modified: false,
|
||||||
return;
|
version: file.version,
|
||||||
}
|
observedVersion: file.version,
|
||||||
this.persistedContent = file.content;
|
error: null,
|
||||||
this.hasBom = file.hasBom;
|
});
|
||||||
this.setSnapshot({
|
}
|
||||||
status: "clean",
|
|
||||||
content: file.content,
|
private takeObservedWhileSaving(): FileEditorObservation | null {
|
||||||
lineSeparator: detectLineSeparator(file.content),
|
const observation = this.observedWhileSaving;
|
||||||
modified: false,
|
this.observedWhileSaving = null;
|
||||||
version: file.version,
|
return observation;
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enterConflict(version: FileVersion): void {
|
private enterConflict(version: FileVersion): void {
|
||||||
@@ -307,27 +319,23 @@ export class FileEditorModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreUnchangedConflict(version: FileVersion): boolean {
|
private adoptUnchangedFile(file: FileEditorFile): void {
|
||||||
if (
|
this.hasBom = file.hasBom;
|
||||||
this.snapshot.status !== "conflict" ||
|
this.observed = { status: "ready", file };
|
||||||
version.status !== "ready" ||
|
|
||||||
this.snapshot.version.status !== "ready" ||
|
|
||||||
!sameVersion(version, this.snapshot.version)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const modified = this.snapshot.content !== this.persistedContent;
|
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.setSnapshot({
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
status: modified ? "dirty" : "clean",
|
status,
|
||||||
modified,
|
modified,
|
||||||
version,
|
version: file.version,
|
||||||
observedVersion: version,
|
observedVersion: file.version,
|
||||||
error: null,
|
error: recovering ? null : this.snapshot.error,
|
||||||
});
|
});
|
||||||
if (modified) this.scheduleAutosave();
|
if (status === "dirty") this.scheduleAutosave();
|
||||||
else this.clearAutosave();
|
else this.clearAutosave();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleAutosave(): void {
|
private scheduleAutosave(): void {
|
||||||
@@ -377,13 +385,18 @@ function detectLineSeparator(content: string): FileLineSeparator {
|
|||||||
return "\n";
|
return "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameVersion(left: FileVersion, right: FileVersion): boolean {
|
function observationVersion(observation: FileEditorObservation): FileVersion {
|
||||||
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
|
return observation.status === "ready" ? observation.file.version : observation;
|
||||||
return false;
|
}
|
||||||
if (left.status === "ready" && right.status === "ready") {
|
|
||||||
if (left.revision && right.revision) return left.revision === right.revision;
|
function observationMatchesWrite(
|
||||||
return left.modifiedAt === right.modifiedAt && left.size === right.size;
|
observation: FileEditorObservation,
|
||||||
}
|
content: string,
|
||||||
if (left.status === "error" && right.status === "error") return left.error === right.error;
|
hasBom: boolean,
|
||||||
return true;
|
): boolean {
|
||||||
|
return (
|
||||||
|
observation.status === "ready" &&
|
||||||
|
observation.file.content === content &&
|
||||||
|
observation.file.hasBom === hasBom
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export function useLiveFile(input: {
|
|||||||
|
|
||||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||||
return {
|
return {
|
||||||
file: snapshot.file,
|
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
|
||||||
error: snapshot.read.status === "error" ? snapshot.read.error : null,
|
error: snapshot.read.status === "error" ? snapshot.read.error : null,
|
||||||
isFetching: snapshot.read.status === "pending",
|
isFetching: snapshot.read.status === "pending",
|
||||||
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
|
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
|
||||||
|
|||||||
@@ -117,6 +117,68 @@ async function waitForRead(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("LiveFileModel", () => {
|
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 () => {
|
it("recovers only from a read begun after a missing observation", async () => {
|
||||||
const session = new TestLiveFileSession();
|
const session = new TestLiveFileSession();
|
||||||
const model = new LiveFileModel();
|
const model = new LiveFileModel();
|
||||||
@@ -127,7 +189,7 @@ describe("LiveFileModel", () => {
|
|||||||
});
|
});
|
||||||
await waitForRead(session, 1);
|
await waitForRead(session, 1);
|
||||||
session.reads[0].resolve(file());
|
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();
|
model.refresh();
|
||||||
await waitForRead(session, 2);
|
await waitForRead(session, 2);
|
||||||
@@ -135,14 +197,17 @@ describe("LiveFileModel", () => {
|
|||||||
session.reads[1].resolve(file("revision-2", "two"));
|
session.reads[1].resolve(file("revision-2", "two"));
|
||||||
|
|
||||||
await waitForRead(session, 3);
|
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 });
|
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
|
||||||
|
|
||||||
session.reads[2].resolve(file("revision-2", "two"));
|
session.reads[2].resolve(file("revision-2", "two"));
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(model.getSnapshot()).toMatchObject({
|
expect(model.getSnapshot()).toMatchObject({
|
||||||
version: { status: "ready", revision: "revision-2" },
|
observation: {
|
||||||
file: { revision: "revision-2" },
|
status: "ready",
|
||||||
|
version: { revision: "revision-2" },
|
||||||
|
file: { revision: "revision-2" },
|
||||||
|
},
|
||||||
read: { status: "idle" },
|
read: { status: "idle" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -158,7 +223,7 @@ describe("LiveFileModel", () => {
|
|||||||
});
|
});
|
||||||
await waitForRead(session, 1);
|
await waitForRead(session, 1);
|
||||||
session.reads[0].resolve(file());
|
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({
|
session.emit({
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -172,7 +237,7 @@ describe("LiveFileModel", () => {
|
|||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(model.getSnapshot()).toMatchObject({
|
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." },
|
read: { status: "error", error: "File unavailable." },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -195,7 +260,7 @@ describe("LiveFileModel", () => {
|
|||||||
session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} });
|
session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} });
|
||||||
|
|
||||||
await waitForRead(session, 1);
|
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 () => {
|
it("retries a failed subscription when the user refreshes", async () => {
|
||||||
@@ -210,7 +275,7 @@ describe("LiveFileModel", () => {
|
|||||||
expect(session.subscribeAttempts).toBe(1);
|
expect(session.subscribeAttempts).toBe(1);
|
||||||
|
|
||||||
session.reads[0].resolve(file());
|
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();
|
model.refresh();
|
||||||
|
|
||||||
await waitForRead(session, 2);
|
await waitForRead(session, 2);
|
||||||
@@ -247,8 +312,7 @@ describe("LiveFileModel", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(model.getSnapshot()).toEqual({
|
expect(model.getSnapshot()).toEqual({
|
||||||
file: null,
|
observation: null,
|
||||||
version: null,
|
|
||||||
read: { status: "idle" },
|
read: { status: "idle" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,15 +24,21 @@ export type LiveFileReadState =
|
|||||||
| { status: "pending"; requested: boolean }
|
| { status: "pending"; requested: boolean }
|
||||||
| { status: "error"; error: string };
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
|
export type LiveFileObservation =
|
||||||
|
| {
|
||||||
|
status: "ready";
|
||||||
|
version: Extract<FileVersion, { status: "ready" }>;
|
||||||
|
file: FileReadResult;
|
||||||
|
}
|
||||||
|
| Extract<FileVersion, { status: "missing" | "error" }>;
|
||||||
|
|
||||||
export interface LiveFileSnapshot {
|
export interface LiveFileSnapshot {
|
||||||
file: FileReadResult | null;
|
observation: LiveFileObservation | null;
|
||||||
version: FileVersion | null;
|
|
||||||
read: LiveFileReadState;
|
read: LiveFileReadState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idleSnapshot: LiveFileSnapshot = {
|
const idleSnapshot: LiveFileSnapshot = {
|
||||||
file: null,
|
observation: null,
|
||||||
version: null,
|
|
||||||
read: { status: "idle" },
|
read: { status: "idle" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ export class LiveFileModel {
|
|||||||
private activeReadSequence: number | null = null;
|
private activeReadSequence: number | null = null;
|
||||||
private activeReadRequested = false;
|
private activeReadRequested = false;
|
||||||
private observedAfterReadSequence: number | null = null;
|
private observedAfterReadSequence: number | null = null;
|
||||||
|
private candidateVersion: FileVersion | null = null;
|
||||||
private refreshQueued = false;
|
private refreshQueued = false;
|
||||||
private requestedRefreshQueued = false;
|
private requestedRefreshQueued = false;
|
||||||
|
|
||||||
@@ -59,7 +66,7 @@ export class LiveFileModel {
|
|||||||
|
|
||||||
getSnapshot = (): LiveFileSnapshot => this.snapshot;
|
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 {
|
open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void {
|
||||||
this.reset();
|
this.reset();
|
||||||
@@ -111,6 +118,7 @@ export class LiveFileModel {
|
|||||||
this.activeReadSequence = null;
|
this.activeReadSequence = null;
|
||||||
this.activeReadRequested = false;
|
this.activeReadRequested = false;
|
||||||
this.observedAfterReadSequence = null;
|
this.observedAfterReadSequence = null;
|
||||||
|
this.candidateVersion = null;
|
||||||
this.refreshQueued = false;
|
this.refreshQueued = false;
|
||||||
this.requestedRefreshQueued = false;
|
this.requestedRefreshQueued = false;
|
||||||
this.setSnapshot(idleSnapshot);
|
this.setSnapshot(idleSnapshot);
|
||||||
@@ -144,7 +152,7 @@ export class LiveFileModel {
|
|||||||
|
|
||||||
private observeVersion(version: FileVersion): void {
|
private observeVersion(version: FileVersion): void {
|
||||||
this.observedAfterReadSequence = this.issuedReadSequence;
|
this.observedAfterReadSequence = this.issuedReadSequence;
|
||||||
this.setSnapshot({ ...this.snapshot, version });
|
this.candidateVersion = version;
|
||||||
this.scheduleRead(false);
|
this.scheduleRead(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,9 +188,13 @@ export class LiveFileModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.observedAfterReadSequence = null;
|
this.observedAfterReadSequence = null;
|
||||||
|
this.candidateVersion = null;
|
||||||
this.setSnapshot({
|
this.setSnapshot({
|
||||||
file: input.file,
|
observation: {
|
||||||
version: readyVersion(input.target, input.file),
|
status: "ready",
|
||||||
|
file: input.file,
|
||||||
|
version: readyVersion(input.target, input.file),
|
||||||
|
},
|
||||||
read: { status: "idle" },
|
read: { status: "idle" },
|
||||||
});
|
});
|
||||||
this.startQueuedRead();
|
this.startQueuedRead();
|
||||||
@@ -203,11 +215,14 @@ export class LiveFileModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = errorMessage(input.error);
|
const message = errorMessage(input.error);
|
||||||
let version = this.snapshot.version;
|
const candidate = this.candidateVersion;
|
||||||
if (version?.status !== "missing" && version?.status !== "error") {
|
this.candidateVersion = null;
|
||||||
version = { status: "error", cwd: input.target.cwd, path: input.target.path, error: message };
|
const candidateDescribesFailure =
|
||||||
}
|
candidate?.status === "missing" || candidate?.status === "error";
|
||||||
this.setSnapshot({ ...this.snapshot, version, read: { status: "error", error: message } });
|
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();
|
this.startQueuedRead();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,8 @@ import { isWeb } from "@/constants/platform";
|
|||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useLiveFile } from "./live-file/hook";
|
import { useLiveFile } from "./live-file/hook";
|
||||||
import { FilePanelBar } from "./bar";
|
import { FilePanelBar } from "./bar";
|
||||||
import {
|
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||||
FileEditorModel,
|
import { createFileObservationSource } from "./editor/observation-source";
|
||||||
getFileConflictCallout,
|
|
||||||
type FileConflictCallout,
|
|
||||||
type FileEditorFile,
|
|
||||||
} from "./editor/model";
|
|
||||||
import { FileEditorView } from "./editor/view";
|
import { FileEditorView } from "./editor/view";
|
||||||
import type { FileConflictAlertState } from "./conflict-alert";
|
import type { FileConflictAlertState } from "./conflict-alert";
|
||||||
import type { LiveFileModel } from "./live-file/model";
|
import type { LiveFileModel } from "./live-file/model";
|
||||||
@@ -667,25 +663,6 @@ function EditableFilePane({
|
|||||||
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||||
const session = useMemo(
|
const session = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
async read(): Promise<FileEditorFile> {
|
|
||||||
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 }) {
|
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
|
||||||
return client.writeFile({ cwd, path, ...input });
|
return client.writeFile({ cwd, path, ...input });
|
||||||
},
|
},
|
||||||
@@ -703,14 +680,16 @@ function EditableFilePane({
|
|||||||
path,
|
path,
|
||||||
size: preview.size,
|
size: preview.size,
|
||||||
modifiedAt: preview.modifiedAt,
|
modifiedAt: preview.modifiedAt,
|
||||||
|
revision: preview.revision,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
model.connectFileVersions(liveFile);
|
const source = createFileObservationSource(liveFile);
|
||||||
return () => model.disconnectFileVersions();
|
model.connectFileObservations(source);
|
||||||
|
return () => model.disconnectFileObservations();
|
||||||
}, [liveFile, model]);
|
}, [liveFile, model]);
|
||||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||||
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ export interface ExplorerFile {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
size: number;
|
size: number;
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
|
revision?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplorerDirectory {
|
export interface ExplorerDirectory {
|
||||||
|
|||||||
Reference in New Issue
Block a user