Clarify changed and deleted files in the file viewer (#2670)

* fix(app): distinguish changed and deleted files

Successful reads now reconcile transient missing events without letting stale
cached data mask a deletion. File notices expose only actions valid for the
current buffer state.

* fix(app): preserve file state ordering

* fix(app): allow retrying file checks

* fix(app): clear unchanged file conflicts

* fix(app): keep file conflict recovery consistent

Own disk observation and read ordering in one model so stale reads and failed subscriptions cannot produce contradictory actions.

* fix(app): allow retrying file preview errors

Preserve daemon read errors and expose retry for initial and non-editable file loads.
This commit is contained in:
Mohamed Boudra
2026-07-30 22:50:58 +02:00
committed by GitHub
parent 240d7f366d
commit b6f1274f47
19 changed files with 1282 additions and 194 deletions
@@ -16,6 +16,21 @@ interface ClientRequest {
type?: unknown;
subscribe?: unknown;
page?: { cursor?: unknown };
mode?: unknown;
path?: unknown;
}
interface ServerMessage {
type?: unknown;
payload?: {
initial?: {
path?: unknown;
};
version?: {
status?: unknown;
path?: unknown;
};
};
}
function readClientRequest(message: string | Buffer): ClientRequest | null {
@@ -31,6 +46,18 @@ function readClientRequest(message: string | Buffer): ClientRequest | null {
}
}
function readServerMessage(message: string | Buffer): ServerMessage | null {
try {
const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as {
type?: unknown;
message?: ServerMessage;
};
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
} catch {
return null;
}
}
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
if (request.page?.cursor) return null;
if (request.type === "fetch_agents_request") return "agents";
@@ -47,6 +74,16 @@ export async function installDaemonWebSocketGate(page: Page) {
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
const subscribedFilePaths = new Set<string>();
const fileSubscriptionWaiters = new Map<string, () => void>();
let fileReadPathToHold: string | null = null;
let heldFileReads: Array<() => void> = [];
let resolveHeldFileRead: (() => void) | null = null;
let heldFileReadPromise = Promise.resolve();
let readyFileUpdatePathToHold: string | null = null;
let heldReadyFileUpdate: (() => void) | null = null;
let resolveHeldReadyFileUpdate: (() => void) | null = null;
let heldReadyFileUpdatePromise = Promise.resolve();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
@@ -69,6 +106,15 @@ export async function installDaemonWebSocketGate(page: Page) {
directoryStarts.total[directory] += 1;
}
}
if (
request?.type === "file_explorer_request" &&
request.mode === "file" &&
request.path === fileReadPathToHold
) {
heldFileReads.push(() => server.send(message));
resolveHeldFileRead?.();
return;
}
try {
server.send(message);
} catch {
@@ -78,6 +124,26 @@ export async function installDaemonWebSocketGate(page: Page) {
server.onMessage((message) => {
if (!acceptingConnections) return;
const serverMessage = readServerMessage(message);
if (
serverMessage?.type === "fs.file.subscribe.response" &&
typeof serverMessage.payload?.initial?.path === "string"
) {
const path = serverMessage.payload.initial.path;
subscribedFilePaths.add(path);
fileSubscriptionWaiters.get(path)?.();
fileSubscriptionWaiters.delete(path);
}
if (
serverMessage?.type === "fs.file.update" &&
serverMessage.payload?.version?.status === "ready" &&
serverMessage.payload.version.path === readyFileUpdatePathToHold &&
!heldReadyFileUpdate
) {
heldReadyFileUpdate = () => ws.send(message);
resolveHeldReadyFileUpdate?.();
return;
}
try {
ws.send(message);
} catch {
@@ -87,6 +153,44 @@ export async function installDaemonWebSocketGate(page: Page) {
});
return {
waitForFileSubscription(path: string): Promise<void> {
if (subscribedFilePaths.has(path)) return Promise.resolve();
return new Promise((resolve) => fileSubscriptionWaiters.set(path, resolve));
},
holdFileReads(path: string): void {
fileReadPathToHold = path;
heldFileReads = [];
heldFileReadPromise = new Promise((resolve) => {
resolveHeldFileRead = resolve;
});
},
waitForHeldFileRead(): Promise<void> {
return heldFileReadPromise;
},
releaseHeldFileRead(): void {
const forwards = heldFileReads;
heldFileReads = [];
fileReadPathToHold = null;
resolveHeldFileRead = null;
for (const forward of forwards) forward();
},
holdNextReadyFileUpdate(path: string): void {
readyFileUpdatePathToHold = path;
heldReadyFileUpdate = null;
heldReadyFileUpdatePromise = new Promise((resolve) => {
resolveHeldReadyFileUpdate = resolve;
});
},
waitForHeldReadyFileUpdate(): Promise<void> {
return heldReadyFileUpdatePromise;
},
releaseHeldReadyFileUpdate(): void {
const forward = heldReadyFileUpdate;
heldReadyFileUpdate = null;
readyFileUpdatePathToHold = null;
resolveHeldReadyFileUpdate = null;
forward?.();
},
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);