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,87 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import {
bracketMatching,
defaultHighlightStyle,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { searchKeymap } from "@codemirror/search";
import {
EditorView,
drawSelection,
highlightActiveLine,
keymap,
lineNumbers,
} from "@codemirror/view";
import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight";
export interface EditorVisualTheme {
colorScheme: "light" | "dark";
background: string;
foreground: string;
cursor: string;
foregroundMuted: string;
border: string;
selection: string;
monoFont: string;
codeFontSize: number;
syntax: Record<HighlightStyle, string>;
}
export function editorBaseExtensions(onSave: () => void) {
return [
lineNumbers(),
history(),
drawSelection(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([
{ key: "Mod-s", preventDefault: true, run: () => (onSave(), true) },
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
];
}
export function editorTheme(theme: EditorVisualTheme) {
return [
EditorView.theme(
{
"&": {
height: "100%",
backgroundColor: theme.background,
color: theme.foreground,
fontFamily: theme.monoFont,
fontSize: `${theme.codeFontSize}px`,
},
".cm-scroller": {
overflow: "auto",
fontFamily: theme.monoFont,
lineHeight: "1.45",
},
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
".cm-gutters": {
backgroundColor: theme.background,
color: theme.foregroundMuted,
borderRight: `1px solid ${theme.border}`,
},
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent", color: theme.foreground },
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
backgroundColor: theme.selection,
},
".cm-selectionBackground, ::selection": {
backgroundColor: theme.selection,
},
"&.cm-focused": { outline: "none" },
},
{ dark: theme.colorScheme === "dark" },
),
syntaxHighlighting(createCodeMirrorHighlightStyle(theme.syntax)),
];
}
@@ -0,0 +1,252 @@
import { describe, expect, test } from "vitest";
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
import {
FileEditorModel,
type FileEditorClock,
type FileEditorFile,
type FileEditorSession,
} from "./model";
class TestClock implements FileEditorClock {
private callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout(): void {
this.callback = null;
}
fire(): void {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
class FileSession implements FileEditorSession {
file: FileEditorFile;
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
nextWrite: FileWriteResult | Error | null = null;
private pendingWrite: Promise<FileWriteResult> | null = null;
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
constructor(file: FileEditorFile) {
this.file = file;
}
async read(): Promise<FileEditorFile> {
return this.file;
}
async write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
this.writes.push(input);
if (this.pendingWrite) return this.pendingWrite;
if (this.nextWrite instanceof Error) throw this.nextWrite;
if (this.nextWrite) return this.nextWrite;
return {
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: input.content.length,
};
}
holdNextWrite(): void {
this.pendingWrite = new Promise((resolve) => {
this.resolvePendingWrite = resolve;
});
}
finishHeldWrite(result: FileWriteResult): void {
this.resolvePendingWrite?.(result);
this.pendingWrite = null;
this.resolvePendingWrite = null;
}
}
function ready(
modifiedAt = "2026-07-18T00:00:00.000Z",
size = 3,
): Extract<FileVersion, { status: "ready" }> {
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
}
function makeModel() {
const file = { content: "one", version: ready() as Extract<FileVersion, { status: "ready" }> };
const session = new FileSession(file);
const clock = new TestClock();
return { model: new FileEditorModel({ file, session, clock }), session, clock };
}
describe("FileEditorModel", () => {
test("tracks whether the current buffer differs from persisted content", async () => {
const { model } = makeModel();
expect(model.getSnapshot().modified).toBe(false);
model.edit("two");
expect(model.getSnapshot().modified).toBe(true);
model.edit("one");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
model.edit("saved");
await model.save();
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
const { model } = makeModel();
model.receiveFileVersion({ ...ready(), revision: "precise-revision" });
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
});
test("keeps a newer edit modified when an older save finishes", async () => {
const { model, session } = makeModel();
session.holdNextWrite();
model.edit("saving");
const save = model.save();
model.edit("newer edit");
session.finishHeldWrite({
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: 6,
});
await save;
expect(model.getSnapshot()).toMatchObject({
status: "dirty",
content: "newer edit",
modified: true,
});
});
test("autosaves the latest edit after inactivity", async () => {
const { model, session, clock } = makeModel();
model.edit("two");
model.edit("three");
clock.fire();
await Promise.resolve();
expect(session.writes).toEqual([
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("reloads a clean editor when the disk version changes", async () => {
const { model, session } = makeModel();
session.file = {
content: "external",
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
};
model.receiveFileVersion(session.file.version);
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
});
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
const { model, session } = makeModel();
const reads: Array<(file: FileEditorFile) => void> = [];
session.read = () => new Promise((resolve) => reads.push(resolve));
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
model.receiveFileVersion(firstVersion);
model.receiveFileVersion(latestVersion);
reads[0]?.({ content: "first", version: firstVersion });
await Promise.resolve();
reads[1]?.({ content: "latest", version: latestVersion });
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
});
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
const { model, session } = makeModel();
model.edit("local");
model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4));
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
await model.overwrite();
expect(session.writes).toEqual([
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("reload discards a conflicted local buffer for the disk contents", async () => {
const { model, session } = makeModel();
model.edit("local");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk", version: diskVersion };
model.receiveFileVersion(diskVersion);
await model.reload();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("reports failed saves without losing the local buffer", async () => {
const { model, session } = makeModel();
session.nextWrite = new Error("disk full");
model.edit("important local work");
await model.save();
expect(model.getSnapshot()).toMatchObject({
status: "error",
content: "important local work",
error: "disk full",
});
});
test("a deletion conflicts with local changes and stops autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" });
clock.fire();
expect(model.getSnapshot().status).toBe("conflict");
expect(session.writes).toEqual([]);
});
test("dispose cancels pending autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
model.dispose();
clock.fire();
expect(session.writes).toEqual([]);
});
test("suspends a pending autosave while close confirmation is active", async () => {
const { model, session, clock } = makeModel();
model.edit("local");
const resume = model.suspendAutosave();
clock.fire();
expect(session.writes).toEqual([]);
resume();
clock.fire();
await Promise.resolve();
expect(session.writes).toHaveLength(1);
});
});
+302
View File
@@ -0,0 +1,302 @@
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
modified: boolean;
version: FileVersion;
observedVersion: FileVersion;
error: string | null;
}
export interface FileEditorFile {
content: string;
version: Extract<FileVersion, { status: "ready" }>;
}
export interface FileEditorSession {
read(): Promise<FileEditorFile>;
write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult>;
}
export interface FileEditorClock {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
}
const systemClock: FileEditorClock = {
setTimeout(callback, delay) {
return globalThis.setTimeout(callback, delay);
},
clearTimeout(handle) {
globalThis.clearTimeout(handle);
},
};
export class FileEditorModel {
private readonly session: FileEditorSession;
private readonly clock: FileEditorClock;
private readonly listeners = new Set<() => void>();
private snapshot: FileEditorSnapshot;
private autosave: ReturnType<typeof setTimeout> | null = null;
private saveSequence = 0;
private disposed = false;
private observedWhileSaving: FileVersion | null = null;
private persistedContent: string;
constructor(input: {
file: FileEditorFile;
session: FileEditorSession;
clock?: FileEditorClock;
}) {
this.session = input.session;
this.clock = input.clock ?? systemClock;
this.persistedContent = input.file.content;
this.snapshot = {
status: "clean",
content: input.file.content,
modified: false,
version: input.file.version,
observedVersion: input.file.version,
error: null,
};
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileEditorSnapshot => this.snapshot;
edit(content: string): void {
if (this.disposed || content === this.snapshot.content) return;
const modified = content !== this.persistedContent;
let status: FileEditorStatus = modified ? "dirty" : "clean";
if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") {
status = "conflict";
}
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
async save(): Promise<void> {
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
return;
}
if (this.snapshot.observedVersion.status !== "ready") {
this.enterConflict(this.snapshot.observedVersion);
return;
}
await this.performWrite(this.snapshot.observedVersion);
}
receiveFileVersion(version: FileVersion): void {
if (this.disposed) return;
if (sameVersion(version, this.snapshot.observedVersion)) {
if (
version.status === "ready" &&
this.snapshot.observedVersion.status === "ready" &&
version.revision &&
!this.snapshot.observedVersion.revision
) {
this.setSnapshot({
...this.snapshot,
version:
this.snapshot.version.status === "ready"
? { ...this.snapshot.version, revision: version.revision }
: this.snapshot.version,
observedVersion: version,
});
}
return;
}
this.setSnapshot({ ...this.snapshot, observedVersion: version });
if (this.snapshot.status === "saving") {
this.observedWhileSaving = version;
return;
}
if (this.snapshot.status === "clean" || this.snapshot.status === "loading") {
void this.reloadFromDisk(version);
return;
}
this.enterConflict(version);
}
async overwrite(): Promise<void> {
if (this.disposed || this.snapshot.status !== "conflict") return;
if (this.snapshot.observedVersion.status !== "ready") return;
await this.performWrite(this.snapshot.observedVersion);
}
async reload(): Promise<void> {
if (this.disposed) return;
await this.reloadFromDisk(this.snapshot.observedVersion);
}
dispose(): void {
this.disposed = true;
this.saveSequence += 1;
this.clearAutosave();
this.listeners.clear();
}
suspendAutosave(): () => void {
const wasScheduled = this.autosave !== null;
this.clearAutosave();
let resumed = false;
return () => {
if (resumed || this.disposed) return;
resumed = true;
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
};
}
private async performWrite(
expectedVersion: Extract<FileVersion, { status: "ready" }>,
): Promise<void> {
this.clearAutosave();
const sequence = ++this.saveSequence;
const content = this.snapshot.content;
this.observedWhileSaving = null;
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
let result: FileWriteResult;
try {
result = await this.session.write({
content,
expectedModifiedAt: expectedVersion.modifiedAt,
expectedRevision: expectedVersion.revision,
});
} catch (error) {
if (this.disposed || sequence !== this.saveSequence) return;
this.setSnapshot({
...this.snapshot,
status: "error",
error: error instanceof Error ? error.message : String(error),
});
return;
}
if (this.disposed || sequence !== this.saveSequence) return;
if (result.status === "error") {
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
return;
}
if (result.status === "conflict") {
this.enterConflict(result.version);
return;
}
const writtenVersion: FileVersion = {
status: "ready",
cwd: this.snapshot.version.cwd,
path: this.snapshot.version.path,
size: result.size,
modifiedAt: result.modifiedAt,
revision: result.revision,
};
const pending = this.observedWhileSaving;
this.observedWhileSaving = null;
this.persistedContent = content;
if (pending && !sameVersion(pending, writtenVersion)) {
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
version: writtenVersion,
observedVersion: pending,
error: null,
});
return;
}
const modified = this.snapshot.content !== this.persistedContent;
this.setSnapshot({
...this.snapshot,
status: modified ? "dirty" : "clean",
modified,
version: writtenVersion,
observedVersion: writtenVersion,
error: null,
});
if (modified) this.scheduleAutosave();
}
private async reloadFromDisk(version: FileVersion): Promise<void> {
this.clearAutosave();
if (version.status !== "ready") {
this.enterConflict(version);
return;
}
const sequence = ++this.saveSequence;
this.setSnapshot({ ...this.snapshot, status: "loading", error: null });
try {
const file = await this.session.read();
if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") {
return;
}
this.persistedContent = file.content;
this.setSnapshot({
status: "clean",
content: file.content,
modified: false,
version: file.version,
observedVersion: file.version,
error: null,
});
} catch (error) {
if (this.disposed || sequence !== this.saveSequence) return;
this.setSnapshot({
...this.snapshot,
status: "error",
error: error instanceof Error ? error.message : String(error),
});
}
}
private enterConflict(version: FileVersion): void {
this.clearAutosave();
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
observedVersion: version,
error: version.status === "error" ? version.error : null,
});
}
private scheduleAutosave(): void {
this.clearAutosave();
this.autosave = this.clock.setTimeout(() => {
this.autosave = null;
void this.save();
}, 800);
}
private clearAutosave(): void {
if (!this.autosave) return;
this.clock.clearTimeout(this.autosave);
this.autosave = null;
}
private setSnapshot(snapshot: FileEditorSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
function sameVersion(left: FileVersion, right: FileVersion): boolean {
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
return false;
if (left.status === "ready" && right.status === "ready") {
if (left.revision && right.revision) return left.revision === right.revision;
return left.modifiedAt === right.modifiedAt && left.size === right.size;
}
if (left.status === "error" && right.status === "error") return left.error === right.error;
return true;
}
@@ -0,0 +1,33 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightStyle } from "@getpaseo/highlight";
import type { FileEditorModel } from "./model";
export function FileEditorView(_props: {
model: FileEditorModel;
filename: string;
vimEnabled: boolean;
theme: {
background: string;
foreground: string;
foregroundMuted: string;
border: string;
selection: string;
monoFont: string;
codeFontSize: number;
syntax: Record<HighlightStyle, string>;
};
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
}) {
return (
<View style={styles.container}>
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, alignItems: "center", justifyContent: "center" },
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
}));
@@ -0,0 +1,124 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
interface FileEditorViewProps {
model: FileEditorModel;
filename: string;
vimEnabled: boolean;
theme: EditorVisualTheme;
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
}
const languageCompartment = new Compartment();
const themeCompartment = new Compartment();
const vimCompartment = new Compartment();
export function FileEditorView({
model,
filename,
vimEnabled,
theme,
onCursorChange,
onVimModeChange,
}: FileEditorViewProps) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
const onCursorChangeRef = useRef(onCursorChange);
onCursorChangeRef.current = onCursorChange;
useEffect(() => {
if (!hostRef.current) return;
const values = initial.current;
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: values.content,
extensions: [
vimCompartment.of(values.vimEnabled ? vim() : []),
...editorBaseExtensions(() => void values.model.save()),
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
themeCompartment.of(editorTheme(values.theme)),
EditorView.updateListener.of((update) => {
if (
update.docChanged &&
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
) {
values.model.edit(update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
}
}),
],
}),
});
viewRef.current = view;
onCursorChangeRef.current({ line: 1, column: 1 });
return () => {
view.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view || view.state.doc.toString() === snapshot.content) return;
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
selection: { anchor: head },
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
});
}, [snapshot.content]);
useEffect(() => {
viewRef.current?.dispatch({
effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
});
}, [filename]);
useEffect(() => {
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
}, [theme]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
if (!vimEnabled) {
onVimModeChange(null);
return;
}
const cm = getCM(view);
if (!cm) return;
function handleModeChange(event: { mode?: string }) {
onVimModeChange((event.mode ?? "normal").toUpperCase());
}
cm.on("vim-mode-change", handleModeChange);
onVimModeChange("NORMAL");
return () => cm.off("vim-mode-change", handleModeChange);
}, [onVimModeChange, vimEnabled]);
return (
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
);
}
const remoteUpdate = Annotation.define<boolean>();
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;