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,249 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const BLUE_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
const contentElement = editor(page);
|
||||
await contentElement.click();
|
||||
await contentElement.press("Control+A");
|
||||
await contentElement.type(content);
|
||||
}
|
||||
|
||||
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
if (!(await tree.isVisible())) await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("shows the full file path and keeps editor controls stable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
||||
const relativePath = "src/deep/visuals.md";
|
||||
const sourcePath = path.join(workspace.repoPath, relativePath);
|
||||
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
||||
await openFileFromExplorer(page, "visuals.md");
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
||||
await fileTab.hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = page.getByTestId("file-markdown-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
||||
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
||||
|
||||
await content.click();
|
||||
const cursor = editorHost.locator(".cm-cursor-primary");
|
||||
await expect(cursor).toBeVisible();
|
||||
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
||||
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||
});
|
||||
|
||||
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await Promise.all(
|
||||
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
||||
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
||||
),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
||||
|
||||
await replaceEditorText(page, "const immediate = 3;\n");
|
||||
await editor(page).press("Control+s");
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
||||
|
||||
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const external = 4;");
|
||||
await expect(page.getByLabel("3 lines")).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const localWins = 5;\n");
|
||||
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||
await openWorkspaceFile(page, fileName);
|
||||
}
|
||||
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||
|
||||
await replaceEditorText(page, "const discarded = 7;\n");
|
||||
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(editor(page)).toContainText("const diskWins = 8;");
|
||||
|
||||
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
||||
await gate.drop();
|
||||
gate.restore();
|
||||
await expect
|
||||
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
||||
.toBeGreaterThan(subscriptionCount);
|
||||
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
||||
});
|
||||
|
||||
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "draft.ts");
|
||||
|
||||
await replaceEditorText(page, "const local = 2;\n");
|
||||
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
|
||||
let closePrompt = "";
|
||||
page.once("dialog", async (dialog) => {
|
||||
closePrompt = dialog.message();
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page
|
||||
.getByTestId("workspace-tab-file_draft.ts")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await page.getByTestId("file-mode-preview").click();
|
||||
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
||||
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
||||
|
||||
await page.goto("/settings/editor");
|
||||
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
||||
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "vim.ts");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
||||
await editor(page).click();
|
||||
await editor(page).press("i");
|
||||
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
||||
await editor(page).press("Escape");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
Reference in New Issue
Block a user