Skip to content

Commit 4bda2df

Browse files
authored
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
1 parent 9292f58 commit 4bda2df

69 files changed

Lines changed: 3677 additions & 240 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/design.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
6464

6565
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
6666

67+
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
68+
6769
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
6870

6971
---

package-lock.json

Lines changed: 66 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@
132132
"ws": "^8.20.0"
133133
},
134134
"overrides": {
135+
"@codemirror/language": "6.12.4",
136+
"@codemirror/view": "6.43.6",
135137
"lightningcss": "1.30.1",
136138
"react": "19.1.0",
137139
"react-dom": "19.1.0",
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { mkdir, readFile, writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import { expect, test, type Page } from "./fixtures";
4+
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
5+
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
6+
7+
const RED_PIXEL = Buffer.from(
8+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
9+
"base64",
10+
);
11+
const BLUE_PIXEL = Buffer.from(
12+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
13+
"base64",
14+
);
15+
16+
function editor(page: Page) {
17+
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
18+
}
19+
20+
async function replaceEditorText(page: Page, content: string): Promise<void> {
21+
const contentElement = editor(page);
22+
await contentElement.click();
23+
await contentElement.press("Control+A");
24+
await contentElement.type(content);
25+
}
26+
27+
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
28+
const tree = page.getByTestId("file-explorer-tree-scroll");
29+
if (!(await tree.isVisible())) await openFileExplorer(page);
30+
await openFileFromExplorer(page, filename);
31+
await expectFileTabOpen(page, filename);
32+
}
33+
34+
test.describe("CodeMirror workspace file editing", () => {
35+
test("shows the full file path and keeps editor controls stable", async ({
36+
page,
37+
withWorkspace,
38+
}) => {
39+
await page.emulateMedia({ colorScheme: "dark" });
40+
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
41+
const relativePath = "src/deep/visuals.md";
42+
const sourcePath = path.join(workspace.repoPath, relativePath);
43+
await mkdir(path.dirname(sourcePath), { recursive: true });
44+
await writeFile(
45+
sourcePath,
46+
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
47+
"\n",
48+
),
49+
"utf8",
50+
);
51+
await workspace.navigateTo();
52+
await openFileExplorer(page);
53+
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
54+
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
55+
await openFileFromExplorer(page, "visuals.md");
56+
await expectFileTabOpen(page, relativePath);
57+
58+
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
59+
await fileTab.hover();
60+
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
61+
relativePath,
62+
);
63+
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
64+
const modeControl = page.getByTestId("file-markdown-mode");
65+
await expect(modeControl).toBeVisible();
66+
await page.getByTestId("file-mode-source").click();
67+
68+
const editorHost = page.getByTestId("file-source-editor");
69+
const content = editor(page);
70+
await expect(editorHost).toHaveAttribute("data-pmono", "");
71+
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
72+
73+
await content.click();
74+
const cursor = editorHost.locator(".cm-cursor-primary");
75+
await expect(cursor).toBeVisible();
76+
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
77+
78+
const initialModeBox = await modeControl.boundingBox();
79+
expect(initialModeBox).not.toBeNull();
80+
const initialModeX = initialModeBox!.x;
81+
await content.press("Control+End");
82+
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
83+
const movedModeBox = await modeControl.boundingBox();
84+
expect(movedModeBox).not.toBeNull();
85+
expect(movedModeBox!.x).toBe(initialModeX);
86+
87+
await content.press("Control+a");
88+
const selection = editorHost.locator(".cm-selectionBackground").first();
89+
await expect(selection).toBeVisible();
90+
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
91+
});
92+
93+
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
94+
page,
95+
withWorkspace,
96+
}) => {
97+
test.setTimeout(120_000);
98+
const gate = await installDaemonWebSocketGate(page);
99+
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
100+
const sourcePath = path.join(workspace.repoPath, "source.ts");
101+
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
102+
await Promise.all(
103+
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
104+
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
105+
),
106+
);
107+
await workspace.navigateTo();
108+
await openWorkspaceFile(page, "source.ts");
109+
110+
await expect(page.getByTestId("file-source-editor")).toBeVisible();
111+
await expect(page.getByLabel(/File size/)).toBeVisible();
112+
await expect(page.getByLabel(/lines/)).toBeVisible();
113+
114+
await replaceEditorText(page, "const autosaved = 2;\n");
115+
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
116+
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
117+
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
118+
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
119+
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
120+
121+
await replaceEditorText(page, "const immediate = 3;\n");
122+
await editor(page).press("Control+s");
123+
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
124+
125+
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
126+
await expect(editor(page)).toContainText("const external = 4;");
127+
await expect(page.getByLabel("3 lines")).toBeVisible();
128+
129+
await replaceEditorText(page, "const localWins = 5;\n");
130+
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
131+
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
132+
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
133+
await openWorkspaceFile(page, fileName);
134+
}
135+
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
136+
await expect(editor(page)).toContainText("const localWins = 5;");
137+
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
138+
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
139+
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
140+
141+
await replaceEditorText(page, "const discarded = 7;\n");
142+
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
143+
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
144+
page.once("dialog", (dialog) => dialog.accept());
145+
await page.getByRole("button", { name: "Reload", exact: true }).click();
146+
await expect(editor(page)).toContainText("const diskWins = 8;");
147+
148+
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
149+
await gate.drop();
150+
gate.restore();
151+
await expect
152+
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
153+
.toBeGreaterThan(subscriptionCount);
154+
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
155+
await expect(editor(page)).toContainText("const afterReconnect = 9;");
156+
});
157+
158+
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
159+
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
160+
const sourcePath = path.join(workspace.repoPath, "draft.ts");
161+
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
162+
await workspace.navigateTo();
163+
await openWorkspaceFile(page, "draft.ts");
164+
165+
await replaceEditorText(page, "const local = 2;\n");
166+
await writeFile(sourcePath, "const external = 3;\n", "utf8");
167+
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
168+
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
169+
170+
let closePrompt = "";
171+
page.once("dialog", async (dialog) => {
172+
closePrompt = dialog.message();
173+
await dialog.dismiss();
174+
});
175+
await page
176+
.getByTestId("workspace-tab-file_draft.ts")
177+
.filter({ visible: true })
178+
.first()
179+
.click({ button: "right" });
180+
await page
181+
.getByTestId("workspace-tab-context-file_draft.ts-close")
182+
.filter({ visible: true })
183+
.click();
184+
expect(closePrompt).toContain("Closing it will discard the draft.");
185+
186+
await expect(page.getByTestId("file-source-editor")).toBeVisible();
187+
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
188+
});
189+
190+
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
191+
page,
192+
withWorkspace,
193+
}) => {
194+
test.setTimeout(90_000);
195+
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
196+
const markdownPath = path.join(workspace.repoPath, "notes.md");
197+
const imagePath = path.join(workspace.repoPath, "pixel.png");
198+
await writeFile(markdownPath, "# First heading\n", "utf8");
199+
await writeFile(imagePath, RED_PIXEL);
200+
await workspace.navigateTo();
201+
await openWorkspaceFile(page, "notes.md");
202+
203+
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
204+
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
205+
await writeFile(markdownPath, "# Updated heading\n", "utf8");
206+
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
207+
208+
await page.getByTestId("file-mode-source").click();
209+
await expect(page.getByTestId("file-source-editor")).toBeVisible();
210+
await replaceEditorText(page, "# Saved from source\n");
211+
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
212+
await page.getByTestId("file-mode-preview").click();
213+
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
214+
215+
await openWorkspaceFile(page, "pixel.png");
216+
const image = page.getByTestId("workspace-file-pane").locator("img");
217+
await expect(image).toBeVisible();
218+
const initialSource = await image.getAttribute("src");
219+
await writeFile(imagePath, BLUE_PIXEL);
220+
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
221+
});
222+
223+
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
224+
page,
225+
withWorkspace,
226+
}) => {
227+
test.setTimeout(90_000);
228+
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
229+
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
230+
231+
await page.goto("/settings/editor");
232+
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
233+
await expect(toggle).toBeVisible();
234+
await toggle.click();
235+
await expect(toggle).toBeChecked();
236+
await page.reload();
237+
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
238+
239+
await workspace.navigateTo();
240+
await openWorkspaceFile(page, "vim.ts");
241+
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
242+
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
243+
await editor(page).click();
244+
await editor(page).press("i");
245+
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
246+
await editor(page).press("Escape");
247+
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
248+
});
249+
});

0 commit comments

Comments
 (0)