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