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-21 04:41:19 +08:00
committed by GitHub
parent 9292f58896
commit 4bda2dfea9
69 changed files with 3678 additions and 241 deletions
@@ -0,0 +1,139 @@
import { mkdtemp, rm, symlink, unlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { FileObserver, type FileObserverDependencies } from "./observer.js";
class ObservationControls implements FileObserverDependencies {
watches = 0;
closes = 0;
private change: ((filename: string | null) => void) | null = null;
private timeout: (() => void | Promise<void>) | null = null;
private interval: (() => void | Promise<void>) | null = null;
watchDirectory(_directory: string, onChange: (filename: string | null) => void) {
this.watches += 1;
this.change = onChange;
return { close: () => (this.closes += 1) };
}
setTimeout(callback: () => void | Promise<void>): ReturnType<typeof setTimeout> {
this.timeout = callback;
return 1 as ReturnType<typeof setTimeout>;
}
clearTimeout(): void {
this.timeout = null;
}
setInterval(callback: () => void | Promise<void>): ReturnType<typeof setInterval> {
this.interval = callback;
return 2 as ReturnType<typeof setInterval>;
}
clearInterval(): void {
this.interval = null;
}
async fileChanged(filename: string): Promise<void> {
this.change?.(filename);
const timeout = this.timeout;
this.timeout = null;
await timeout?.();
}
}
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function workspace() {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-file-observer-"));
roots.push(root);
await writeFile(path.join(root, "file.txt"), "one", "utf8");
return root;
}
describe("FileObserver", () => {
test("shares one parent watcher and publishes each real version once", async () => {
const root = await workspace();
const controls = new ObservationControls();
const observer = new FileObserver(controls);
const firstUpdates: string[] = [];
const secondUpdates: string[] = [];
const first = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) =>
firstUpdates.push(version.status),
);
const second = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) =>
secondUpdates.push(version.status),
);
await writeFile(path.join(root, "file.txt"), "changed content", "utf8");
await controls.fileChanged("file.txt");
await controls.fileChanged("file.txt");
expect(controls.watches).toBe(1);
expect(firstUpdates).toEqual(["ready"]);
expect(secondUpdates).toEqual(["ready"]);
first.unsubscribe();
expect(controls.closes).toBe(0);
second.unsubscribe();
expect(controls.closes).toBe(1);
});
test("coalesces concurrent subscriptions onto one watcher", async () => {
const root = await workspace();
const controls = new ObservationControls();
const observer = new FileObserver(controls);
const [first, second] = await Promise.all([
observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined),
observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined),
]);
expect(controls.watches).toBe(1);
first.unsubscribe();
second.unsubscribe();
expect(controls.closes).toBe(1);
});
test("publishes shared watcher updates in each subscriber's path coordinates", async () => {
const root = await workspace();
const aliasParent = await mkdtemp(path.join(os.tmpdir(), "paseo-file-observer-alias-"));
roots.push(aliasParent);
const aliasRoot = path.join(aliasParent, "workspace-link");
await symlink(root, aliasRoot, "dir");
const controls = new ObservationControls();
const observer = new FileObserver(controls);
const updates: Array<{ cwd: string; path: string }> = [];
const direct = await observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined);
const alias = await observer.subscribe({ cwd: aliasRoot, path: "file.txt" }, (version) =>
updates.push({ cwd: version.cwd, path: version.path }),
);
await writeFile(path.join(root, "file.txt"), "changed content", "utf8");
await controls.fileChanged("file.txt");
expect(controls.watches).toBe(1);
expect(updates).toEqual([{ cwd: aliasRoot, path: "file.txt" }]);
direct.unsubscribe();
alias.unsubscribe();
});
test("publishes deletion without dropping the subscription", async () => {
const root = await workspace();
const controls = new ObservationControls();
const observer = new FileObserver(controls);
const updates: string[] = [];
const subscription = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) =>
updates.push(version.status),
);
await unlink(path.join(root, "file.txt"));
await controls.fileChanged("file.txt");
expect(updates).toEqual(["missing"]);
subscription.unsubscribe();
});
});
@@ -0,0 +1,173 @@
import { watch, type FSWatcher } from "node:fs";
import path from "node:path";
import type { FileVersion } from "@getpaseo/protocol/messages";
import { getExplorerFileVersion, resolveExplorerFilePath } from "./service.js";
interface FileWatch {
close(): void;
}
export interface FileObserverDependencies {
watchDirectory(
directory: string,
onChange: (filename: string | null) => void,
onError: () => void,
): FileWatch;
setTimeout(callback: () => void | Promise<void>, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
setInterval(
callback: () => void | Promise<void>,
delayMs: number,
): ReturnType<typeof setInterval>;
clearInterval(handle: ReturnType<typeof setInterval>): void;
}
interface ObservedFile {
cwd: string;
path: string;
basename: string;
listeners: Map<(version: FileVersion) => void, { cwd: string; path: string }>;
fingerprint: string;
watcher: FileWatch | null;
debounce: ReturnType<typeof setTimeout> | null;
fallback: ReturnType<typeof setInterval> | null;
}
const nodeDependencies: FileObserverDependencies = {
watchDirectory(directory, onChange, onError) {
const watcher: FSWatcher = watch(directory, (_event, filename) => {
onChange(filename === null ? null : filename.toString());
});
watcher.on("error", onError);
return watcher;
},
setTimeout,
clearTimeout,
setInterval,
clearInterval,
};
export class FileObserver {
private readonly dependencies: FileObserverDependencies;
private readonly observed = new Map<string, ObservedFile>();
constructor(dependencies: FileObserverDependencies = nodeDependencies) {
this.dependencies = dependencies;
}
async subscribe(
input: { cwd: string; path: string },
listener: (version: FileVersion) => void,
): Promise<{ initial: FileVersion; unsubscribe: () => void }> {
const target = await resolveExplorerFilePath({ root: input.cwd, relativePath: input.path });
let observed = this.observed.get(target);
if (!observed) {
const initial = await getExplorerFileVersion({ root: input.cwd, relativePath: input.path });
observed = this.observed.get(target);
if (!observed) {
observed = {
cwd: input.cwd,
path: input.path,
basename: path.basename(target),
listeners: new Map(),
fingerprint: fingerprint(initial),
watcher: null,
debounce: null,
fallback: null,
};
this.observed.set(target, observed);
this.startWatching(target, path.dirname(target), observed);
}
}
const initial = await getExplorerFileVersion({
root: input.cwd,
relativePath: input.path,
});
observed.listeners.set(listener, { cwd: initial.cwd, path: initial.path });
observed.fingerprint = fingerprint(initial);
let active = true;
return {
initial,
unsubscribe: () => {
if (!active) return;
active = false;
observed?.listeners.delete(listener);
if (observed && observed.listeners.size === 0) {
this.stopWatching(observed);
this.observed.delete(target);
}
},
};
}
dispose(): void {
for (const observed of this.observed.values()) {
this.stopWatching(observed);
}
this.observed.clear();
}
private startWatching(target: string, directory: string, observed: ObservedFile): void {
try {
observed.watcher = this.dependencies.watchDirectory(
directory,
(filename) => {
if (filename === null || filename === observed.basename) {
this.scheduleRestat(target, observed);
}
},
() => this.useFallback(target, observed),
);
} catch {
this.useFallback(target, observed);
}
}
private useFallback(target: string, observed: ObservedFile): void {
observed.watcher?.close();
observed.watcher = null;
if (observed.fallback) return;
observed.fallback = this.dependencies.setInterval(() => this.restate(target, observed), 5_000);
}
private scheduleRestat(target: string, observed: ObservedFile): void {
if (observed.debounce) {
this.dependencies.clearTimeout(observed.debounce);
}
observed.debounce = this.dependencies.setTimeout(() => {
observed.debounce = null;
return this.restate(target, observed);
}, 50);
}
private async restate(target: string, observed: ObservedFile): Promise<void> {
if (this.observed.get(target) !== observed) return;
const version = await getExplorerFileVersion({
root: observed.cwd,
relativePath: observed.path,
});
const nextFingerprint = fingerprint(version);
if (nextFingerprint === observed.fingerprint) return;
observed.fingerprint = nextFingerprint;
for (const [listener, identity] of observed.listeners) {
listener({ ...version, ...identity });
}
}
private stopWatching(observed: ObservedFile): void {
observed.watcher?.close();
if (observed.debounce) this.dependencies.clearTimeout(observed.debounce);
if (observed.fallback) this.dependencies.clearInterval(observed.fallback);
observed.watcher = null;
observed.debounce = null;
observed.fallback = null;
}
}
function fingerprint(version: FileVersion): string {
if (version.status !== "ready") return version.status;
return `${version.status}:${version.revision ?? `${version.size}:${version.modifiedAt}`}`;
}
export const workspaceFileObserver = new FileObserver();
@@ -1,8 +1,8 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { readExplorerFile } from "./service.js";
import { getExplorerFileVersion, readExplorerFile, writeExplorerFile } from "./service.js";
async function createHomeTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.homedir(), prefix));
@@ -13,6 +13,120 @@ async function createTempDir(prefix: string): Promise<string> {
}
describe("file explorer service", () => {
it("atomically writes an existing text file at the expected revision", async () => {
const root = await createTempDir("paseo-file-write-");
try {
const filePath = path.join(root, "notes.txt");
await writeFile(filePath, "before", "utf8");
const current = await getExplorerFileVersion({ root, relativePath: "notes.txt" });
expect(current.status).toBe("ready");
if (current.status !== "ready") return;
const result = await writeExplorerFile({
root,
relativePath: "notes.txt",
content: "after",
expectedModifiedAt: current.modifiedAt,
expectedRevision: current.revision,
});
expect(result.status).toBe("written");
expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe("after");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32")(
"preserves the original file permissions across atomic replacement",
async () => {
const root = await createTempDir("paseo-file-mode-");
try {
const filePath = path.join(root, "script.sh");
await writeFile(filePath, "before", "utf8");
await chmod(filePath, 0o764);
const current = await getExplorerFileVersion({ root, relativePath: "script.sh" });
expect(current.status).toBe("ready");
if (current.status !== "ready") return;
const result = await writeExplorerFile({
root,
relativePath: "script.sh",
content: "after",
expectedModifiedAt: current.modifiedAt,
expectedRevision: current.revision,
});
expect(result.status).toBe("written");
expect((await stat(filePath)).mode & 0o7777).toBe(0o764);
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
it("preserves a newer disk revision instead of overwriting it", async () => {
const root = await createTempDir("paseo-file-conflict-");
try {
const filePath = path.join(root, "notes.txt");
await writeFile(filePath, "newer on disk", "utf8");
const result = await writeExplorerFile({
root,
relativePath: "notes.txt",
content: "stale local edit",
expectedModifiedAt: "2020-01-01T00:00:00.000Z",
});
expect(result).toMatchObject({ status: "conflict", version: { status: "ready" } });
expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe(
"newer on disk",
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("prefers the high-precision revision token over the display timestamp", async () => {
const root = await createTempDir("paseo-file-revision-");
try {
const filePath = path.join(root, "notes.txt");
await writeFile(filePath, "on disk", "utf8");
const current = await getExplorerFileVersion({ root, relativePath: "notes.txt" });
expect(current.status).toBe("ready");
if (current.status !== "ready") return;
const result = await writeExplorerFile({
root,
relativePath: "notes.txt",
content: "stale local edit",
expectedModifiedAt: current.modifiedAt,
expectedRevision: `${current.revision}-stale`,
});
expect(result.status).toBe("conflict");
expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe("on disk");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("never creates a missing file through the write API", async () => {
const root = await createTempDir("paseo-file-missing-");
try {
const result = await writeExplorerFile({
root,
relativePath: "missing.txt",
content: "new file",
expectedModifiedAt: "2020-01-01T00:00:00.000Z",
});
expect(result).toMatchObject({ status: "conflict", version: { status: "missing" } });
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("reads .ex files as text", async () => {
const root = await createTempDir("paseo-file-explorer-");
@@ -1,6 +1,7 @@
import { constants, promises as fs } from "fs";
import { constants, promises as fs, type BigIntStats } from "fs";
import type { FileHandle } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
export type ExplorerEntryKind = "file" | "directory";
@@ -17,6 +18,29 @@ export interface ReadFileParams {
relativePath: string;
}
export interface WriteFileParams extends ReadFileParams {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}
export type ExplorerFileVersion =
| {
status: "ready";
cwd: string;
path: string;
size: number;
modifiedAt: string;
revision: string;
}
| { status: "missing"; cwd: string; path: string }
| { status: "error"; cwd: string; path: string; error: string };
export type ExplorerFileWriteResult =
| { status: "written"; modifiedAt: string; size: number; revision: string }
| { status: "conflict"; version: ExplorerFileVersion }
| { status: "error"; error: string };
export interface FileExplorerEntry {
name: string;
path: string;
@@ -38,6 +62,7 @@ export interface FileExplorerFile {
mimeType?: string;
size: number;
modifiedAt: string;
revision: string;
}
export interface FileExplorerFileBytes {
@@ -48,6 +73,7 @@ export interface FileExplorerFileBytes {
mimeType: string;
size: number;
modifiedAt: string;
revision: string;
}
const TEXT_MIME_TYPES: Record<string, string> = {
@@ -56,10 +82,25 @@ const TEXT_MIME_TYPES: Record<string, string> = {
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
const FILE_TYPE_SAMPLE_BYTES = 8192;
export const MAX_EDITABLE_FILE_BYTES = 1024 * 1024;
const READ_FILE_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed";
function fileRevision(stats: BigIntStats): string {
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}`;
}
function matchesExpectedRevision(
stats: BigIntStats,
expectedModifiedAt: string,
expectedRevision?: string,
): boolean {
return expectedRevision
? fileRevision(stats) === expectedRevision
: stats.mtime.toISOString() === expectedModifiedAt;
}
const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -151,6 +192,7 @@ export async function readExplorerFile({
mimeType: file.mimeType,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
@@ -162,6 +204,7 @@ export async function readExplorerFile({
mimeType: file.mimeType,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
@@ -173,6 +216,7 @@ export async function readExplorerFile({
mimeType: file.mimeType,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
@@ -184,7 +228,7 @@ export async function readExplorerFileBytes({
const handle = await openFileForRead(filePath.resolvedPath);
try {
const stats = await handle.stat();
const stats = await handle.stat({ bigint: true });
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
@@ -193,8 +237,9 @@ export async function readExplorerFileBytes({
const ext = path.extname(filePath.resolvedPath).toLowerCase();
const basePayload = {
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: stats.size,
size: Number(stats.size),
modifiedAt: stats.mtime.toISOString(),
revision: fileRevision(stats),
};
const buffer = await handle.readFile();
@@ -208,7 +253,7 @@ export async function readExplorerFileBytes({
};
}
if (isLikelyBinary(buffer)) {
if (isLikelyBinary(buffer) || !isValidUtf8(buffer)) {
return {
...basePayload,
kind: "binary",
@@ -230,6 +275,146 @@ export async function readExplorerFileBytes({
}
}
export async function getExplorerFileVersion({
root,
relativePath,
}: ReadFileParams): Promise<ExplorerFileVersion> {
const cwd = expandUserPath(root);
try {
const filePath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(filePath.resolvedPath, { bigint: true });
if (!stats.isFile()) {
return { status: "error", cwd, path: relativePath, error: "Requested path is not a file" };
}
return {
status: "ready",
cwd,
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: Number(stats.size),
modifiedAt: stats.mtime.toISOString(),
revision: fileRevision(stats),
};
} catch (error) {
if (isMissingEntryError(error)) {
return { status: "missing", cwd, path: relativePath };
}
return {
status: "error",
cwd,
path: relativePath,
error: error instanceof Error ? error.message : String(error),
};
}
}
export async function resolveExplorerFilePath({
root,
relativePath,
}: ReadFileParams): Promise<string> {
return (await resolveScopedPath({ root, relativePath })).resolvedPath;
}
export async function writeExplorerFile({
root,
relativePath,
content,
expectedModifiedAt,
expectedRevision,
}: WriteFileParams): Promise<ExplorerFileWriteResult> {
const encoded = Buffer.from(content, "utf8");
if (encoded.byteLength > MAX_EDITABLE_FILE_BYTES) {
return { status: "error", error: "File is too large to edit" };
}
let filePath: ScopedPath;
let currentMode = 0o600;
try {
filePath = await resolveScopedPath({ root, relativePath });
const handle = await openFileForRead(filePath.resolvedPath);
try {
const stats = await handle.stat({ bigint: true });
if (!stats.isFile()) {
return { status: "error", error: "Requested path is not a file" };
}
if (stats.size > BigInt(MAX_EDITABLE_FILE_BYTES)) {
return { status: "error", error: "File is too large to edit" };
}
const current = await handle.readFile();
if (isLikelyBinary(current) || !isValidUtf8(current)) {
return { status: "error", error: "Binary files cannot be edited" };
}
currentMode = Number(stats.mode);
const modifiedAt = stats.mtime.toISOString();
if (!matchesExpectedRevision(stats, expectedModifiedAt, expectedRevision)) {
return {
status: "conflict",
version: {
status: "ready",
cwd: expandUserPath(root),
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: Number(stats.size),
modifiedAt,
revision: fileRevision(stats),
},
};
}
} finally {
await handle.close();
}
} catch (error) {
if (isMissingEntryError(error)) {
return {
status: "conflict",
version: { status: "missing", cwd: expandUserPath(root), path: relativePath },
};
}
return { status: "error", error: error instanceof Error ? error.message : String(error) };
}
const temporaryPath = path.join(
path.dirname(filePath.resolvedPath),
`.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`,
);
let temporaryHandle: FileHandle | null = null;
try {
temporaryHandle = await fs.open(temporaryPath, "wx", currentMode);
if (process.platform !== "win32") {
await temporaryHandle.chmod(currentMode & 0o7777);
}
await temporaryHandle.writeFile(encoded);
await temporaryHandle.sync();
await temporaryHandle.close();
temporaryHandle = null;
const latestStats = await fs.stat(filePath.resolvedPath, { bigint: true });
if (!matchesExpectedRevision(latestStats, expectedModifiedAt, expectedRevision)) {
return {
status: "conflict",
version: {
status: "ready",
cwd: expandUserPath(root),
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: Number(latestStats.size),
modifiedAt: latestStats.mtime.toISOString(),
revision: fileRevision(latestStats),
},
};
}
await fs.rename(temporaryPath, filePath.resolvedPath);
const stats = await fs.stat(filePath.resolvedPath, { bigint: true });
return {
status: "written",
modifiedAt: stats.mtime.toISOString(),
size: Number(stats.size),
revision: fileRevision(stats),
};
} catch (error) {
return { status: "error", error: error instanceof Error ? error.message : String(error) };
} finally {
await temporaryHandle?.close().catch(() => undefined);
await fs.unlink(temporaryPath).catch(() => undefined);
}
}
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
path: string;
absolutePath: string;
@@ -370,3 +555,12 @@ function isLikelyBinary(buffer: Buffer): boolean {
return suspicious / buffer.length > 0.3;
}
function isValidUtf8(buffer: Buffer): boolean {
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
return true;
} catch {
return false;
}
}
@@ -804,6 +804,7 @@ describe("file explorer binary responses", () => {
size: 5,
encoding: "binary",
modifiedAt: expect.any(String),
revision: expect.any(String),
},
payload: new Uint8Array(),
});
+8
View File
@@ -1962,6 +1962,13 @@ export class Session {
switch (msg.type) {
case "file_explorer_request":
return this.workspaceFilesSession.handleFileExplorerRequest(msg);
case "fs.file.subscribe.request":
return this.workspaceFilesSession.handleFileSubscribeRequest(msg);
case "fs.file.unsubscribe.request":
this.workspaceFilesSession.handleFileUnsubscribeRequest(msg);
return undefined;
case "fs.file.write.request":
return this.workspaceFilesSession.handleFileWriteRequest(msg);
case "project_icon_request":
return this.workspaceFilesSession.handleProjectIconRequest(msg);
case "file_download_token_request":
@@ -6321,6 +6328,7 @@ export class Session {
this.checkoutSession.cleanup();
this.workspaceGitObserver.dispose();
this.workspaceFilesSession.dispose();
}
}
@@ -9,6 +9,9 @@ import type {
FileDownloadTokenRequest,
FileExplorerRequest,
FileUploadRequest,
FileSubscribeRequest,
FileUnsubscribeRequest,
FileWriteRequest,
SessionInboundMessage,
SessionOutboundMessage,
} from "../../messages.js";
@@ -19,7 +22,9 @@ import {
listDirectoryEntries,
readExplorerFile,
readExplorerFileBytes,
writeExplorerFile,
} from "../../file-explorer/service.js";
import { workspaceFileObserver, type FileObserver } from "../../file-explorer/observer.js";
import { getProjectIcon } from "../../../utils/project-icon.js";
/**
@@ -39,6 +44,7 @@ export interface WorkspaceFilesSessionOptions {
downloadTokenStore: DownloadTokenStore;
paseoHome: string;
logger: pino.Logger;
fileObserver?: FileObserver;
}
/**
@@ -53,12 +59,81 @@ export class WorkspaceFilesSession {
private readonly downloadTokenStore: DownloadTokenStore;
private readonly logger: pino.Logger;
private readonly fileUploads: FileUploadStore;
private readonly fileObserver: FileObserver;
private readonly fileSubscriptions = new Map<string, () => void>();
constructor(options: WorkspaceFilesSessionOptions) {
this.host = options.host;
this.downloadTokenStore = options.downloadTokenStore;
this.logger = options.logger;
this.fileUploads = new FileUploadStore({ paseoHome: options.paseoHome });
this.fileObserver = options.fileObserver ?? workspaceFileObserver;
}
async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise<void> {
this.fileSubscriptions.get(request.subscriptionId)?.();
try {
const subscription = await this.fileObserver.subscribe(
{ cwd: request.cwd, path: request.path },
(version) => {
this.host.emit({
type: "fs.file.update",
payload: { subscriptionId: request.subscriptionId, version },
});
},
);
this.fileSubscriptions.set(request.subscriptionId, subscription.unsubscribe);
this.host.emit({
type: "fs.file.subscribe.response",
payload: {
subscriptionId: request.subscriptionId,
initial: subscription.initial,
requestId: request.requestId,
},
});
} catch (error) {
this.host.emit({
type: "fs.file.subscribe.response",
payload: {
subscriptionId: request.subscriptionId,
initial: {
status: "error",
cwd: request.cwd,
path: request.path,
error: getErrorMessage(error),
},
requestId: request.requestId,
},
});
}
}
handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): void {
this.fileSubscriptions.get(request.subscriptionId)?.();
this.fileSubscriptions.delete(request.subscriptionId);
this.host.emit({
type: "fs.file.unsubscribe.response",
payload: { subscriptionId: request.subscriptionId, requestId: request.requestId },
});
}
async handleFileWriteRequest(request: FileWriteRequest): Promise<void> {
const result = await writeExplorerFile({
root: request.cwd,
relativePath: request.path,
content: request.content,
expectedModifiedAt: request.expectedModifiedAt,
expectedRevision: request.expectedRevision,
});
this.host.emit({
type: "fs.file.write.response",
payload: { result, requestId: request.requestId },
});
}
dispose(): void {
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
this.fileSubscriptions.clear();
}
async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
@@ -115,6 +190,7 @@ export class WorkspaceFilesSession {
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
}),
);
@@ -1380,6 +1380,8 @@ export class VoiceAssistantWebSocketServer {
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: true,
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: true,
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.