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:
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user