Edit workspace files directly on web (#2270)

* feat(files): edit workspace files on web

Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.

* feat(panels): surface and protect modified tabs

Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.

* fix(tests): use portable fake timeout handle

* fix(files): harden editor conflict handling

Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.

* fix(files): close editor concurrency gaps

Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.

* test(files): expect read revision metadata
This commit is contained in:
Mohamed Boudra
2026-07-20 22:41:19 +02:00
committed by GitHub
parent 9292f58896
commit 4bda2dfea9
69 changed files with 3678 additions and 241 deletions

View File

@@ -26,6 +26,8 @@ import type {
FileDownloadTokenResponse,
FileUploadResponse,
FileExplorerResponse,
FileVersion,
FileWriteResult,
FetchAgentTimelineResponseMessage,
AgentForkContextResponseMessage,
GitSetupOptions,
@@ -416,6 +418,7 @@ export interface FileReadResult {
path: string;
kind: LegacyFileExplorerFilePayload["kind"];
modifiedAt: string;
revision?: string;
}
export interface FileUploadInput {
fileName: string;
@@ -875,6 +878,7 @@ interface BinaryFileTransferState extends PendingBinaryFileRead {
{ opcode: typeof FileTransferOpcode.FileBegin }
>["metadata"]["encoding"];
modifiedAt: string;
revision?: string;
chunks: Uint8Array[];
}
@@ -995,6 +999,7 @@ function legacyExplorerFileToBytes(file: LegacyFileExplorerFilePayload): FileRea
path: file.path,
kind: file.kind,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
@@ -1098,6 +1103,10 @@ export class DaemonClient {
}
>();
private terminalDirectorySubscriptions = new Map<string, { cwd: string; workspaceId?: string }>();
private fileSubscriptions = new Map<
string,
{ cwd: string; path: string; onUpdate: (version: FileVersion) => void }
>();
private readonly terminalStreams = new TerminalStreamRouter();
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
@@ -1378,6 +1387,7 @@ export class DaemonClient {
this.rejectPendingSendQueue(new Error("Daemon client closed"));
this.rejectPingProbe(new Error("Daemon client closed"));
this.terminalStreams.clearSlots();
this.fileSubscriptions.clear();
this.lastServerInfoMessage = null;
if (this.runtimeMetricsInterval) {
clearInterval(this.runtimeMetricsInterval);
@@ -2269,6 +2279,22 @@ export class DaemonClient {
}
}
private resubscribeFileSubscriptions(): void {
for (const [subscriptionId, subscription] of this.fileSubscriptions) {
void this.sendCorrelatedSessionRequest({
message: {
type: "fs.file.subscribe.request",
cwd: subscription.cwd,
path: subscription.path,
subscriptionId,
},
responseType: "fs.file.subscribe.response",
})
.then((payload) => subscription.onUpdate(payload.initial))
.catch(() => undefined);
}
}
// ============================================================================
// Agent Lifecycle
// ============================================================================
@@ -4074,6 +4100,52 @@ export class DaemonClient {
}
}
async subscribeFile(
input: { cwd: string; path: string },
onUpdate: (version: FileVersion) => void,
): Promise<{ initial: FileVersion; unsubscribe: () => void }> {
const subscriptionId = this.createRequestId();
this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate });
try {
const payload = await this.sendCorrelatedSessionRequest({
message: {
type: "fs.file.subscribe.request",
cwd: input.cwd,
path: input.path,
subscriptionId,
},
responseType: "fs.file.subscribe.response",
});
return {
initial: payload.initial,
unsubscribe: () => {
if (!this.fileSubscriptions.delete(subscriptionId)) return;
void this.sendCorrelatedSessionRequest({
message: { type: "fs.file.unsubscribe.request", subscriptionId },
responseType: "fs.file.unsubscribe.response",
}).catch(() => undefined);
},
};
} catch (error) {
this.fileSubscriptions.delete(subscriptionId);
throw error;
}
}
async writeFile(input: {
cwd: string;
path: string;
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
const payload = await this.sendCorrelatedSessionRequest({
message: { type: "fs.file.write.request", ...input },
responseType: "fs.file.write.response",
});
return payload.result;
}
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
const bytes = asUint8Array(input.bytes);
if (!bytes) {
@@ -5320,6 +5392,7 @@ export class DaemonClient {
size: frame.metadata.size,
encoding: frame.metadata.encoding,
modifiedAt: frame.metadata.modifiedAt,
revision: frame.metadata.revision,
chunks: [],
});
return;
@@ -5344,6 +5417,7 @@ export class DaemonClient {
path: transfer.path,
kind: binaryFileKind(transfer.mime, transfer.encoding),
modifiedAt: transfer.modifiedAt,
revision: transfer.revision,
});
this.handleSessionMessage({
type: "file_explorer_response",
@@ -5523,6 +5597,7 @@ export class DaemonClient {
this.startLivenessHeartbeat();
this.resubscribeCheckoutDiffSubscriptions();
this.resubscribeTerminalDirectorySubscriptions();
this.resubscribeFileSubscriptions();
this.flushPendingSendQueue();
this.resolveConnect();
}
@@ -5533,6 +5608,12 @@ export class DaemonClient {
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
}
if (consumerMessage.type === "fs.file.update") {
this.fileSubscriptions
.get(consumerMessage.payload.subscriptionId)
?.onUpdate(consumerMessage.payload.version);
}
if (this.rawMessageListeners.size > 0) {
for (const handler of this.rawMessageListeners) {
try {