mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user