feat: add file manager context actions (#3027)

* feat(app): add file manager context actions

* fix: reshape file context actions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-08-10 18:18:23 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent a98d154b6b
commit ca2b633286
36 changed files with 4159 additions and 227 deletions
+276 -2
View File
@@ -1,8 +1,10 @@
import { unlink, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
@@ -15,6 +17,9 @@ interface DirtyWorkspace {
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
includeNestedFolders?: boolean;
includeRenamedFile?: boolean;
includeUntrackedFile?: boolean;
}
interface CleanupTask {
@@ -23,6 +28,39 @@ interface CleanupTask {
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
async function failNextDiscardRequest(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message) as {
message?: { type?: string; cwd?: string; requestId?: string };
};
if (envelope.message?.type === "checkout.discard_changes.request") {
browserSocket.send(
JSON.stringify({
type: "session",
message: {
type: "checkout.discard_changes.response",
payload: {
cwd: envelope.message.cwd,
success: false,
error: { code: "UNKNOWN", message: "Injected revert failure" },
requestId: envelope.message.requestId,
},
},
}),
);
return;
}
}
serverSocket.send(message);
});
serverSocket.onMessage((message) => browserSocket.send(message));
});
}
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
@@ -209,9 +247,15 @@ test("changes file actions open below the right-click without a reserved kebab",
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
@@ -230,6 +274,183 @@ test("changes file actions open below the right-click without a reserved kebab",
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("changes context menus duplicate files and folders", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-duplicate").click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
.toBe(AFTER);
await page.getByTestId("changes-toggle-view-mode").click();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-duplicate").click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
});
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-collapse-folder").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
});
test("changes context menus expose folder revert and restore a file after confirmation", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert");
await expect(folderRevert).toBeVisible();
const revertLabelColor = await folderRevert
.getByText("Discard changes", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.dismiss();
resolve(message);
});
});
await page.getByTestId("diff-file-0-revert").click();
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.accept();
resolve(message);
});
});
await page.getByTestId("diff-file-0-revert").click();
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(BEFORE);
});
test("discarding a staged rename restores its source path", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const renamedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-renamed.ts" });
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
await renamedToggle.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId(`${rowTestId}-revert`).click();
await confirmation;
await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8"))
.toBe("export const renamed = true;\n");
});
test("discarding an untracked file removes it from the working tree", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const untrackedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-untracked.txt" });
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
await untrackedToggle.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId(`${rowTestId}-revert`).click();
await confirmation;
await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(
readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"),
).rejects.toThrow();
});
test("shows a revert error returned by the daemon", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId("diff-file-0-revert").click();
await confirmation;
await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
@@ -327,8 +548,37 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
"user-select",
"none",
);
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2);
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
const folderLabelBounds = await page
.getByTestId("diff-folder-src")
.getByText("src", { exact: true })
.boundingBox();
const fileLabelBounds = await page
.getByTestId("diff-file-0")
.getByText("use-mounted-tab-set.ts", { exact: true })
.boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
const folderToggle = page.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(page.getByTestId("diff-file-0")).toBeVisible();
const fileToggle = page.getByTestId("diff-file-0-toggle");
await fileToggle.click({ button: "right" });
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape");
@@ -488,6 +738,15 @@ async function createWorkspaceWithMountedTabDiff(
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
if (options.includeRenamedFile) {
files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" });
}
if (options.includeNestedFolders) {
files.push(
{ path: "src/zz-folder/root.ts", content: "export const root = 1;\n" },
{ path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" },
);
}
const repo = await createTempGitRepo("changes-pane-", { files });
const client = await connectSeedClient();
cleanupTasks.push({
@@ -498,9 +757,24 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeUntrackedFile) {
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
}
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
if (options.includeRenamedFile) {
execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], {
cwd: repo.path,
});
}
if (options.includeNestedFolders) {
await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n");
await writeFile(
path.join(repo.path, "src/zz-folder/nested/changed.ts"),
"export const nested = 2;\n",
);
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
@@ -0,0 +1,417 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
async function failNextDeleteRequest(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message) as {
message?: { type?: string; cwd?: string; path?: string; requestId?: string };
};
if (envelope.message?.type === "fs.entry.delete.request") {
browserSocket.send(
JSON.stringify({
type: "session",
message: {
type: "fs.entry.delete.response",
payload: {
cwd: envelope.message.cwd,
path: envelope.message.path,
success: false,
error: "Injected delete failure",
requestId: envelope.message.requestId,
},
},
}),
);
return;
}
}
serverSocket.send(message);
});
serverSocket.onMessage((message) => browserSocket.send(message));
});
}
async function hideContextActionCapabilities(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => serverSocket.send(message));
serverSocket.onMessage((message) => {
if (typeof message !== "string") {
browserSocket.send(message);
return;
}
const envelope = JSON.parse(message) as {
message?: {
type?: string;
payload?: { status?: string; features?: Record<string, unknown> };
};
};
if (
envelope.message?.type === "status" &&
envelope.message.payload?.status === "server_info"
) {
envelope.message.payload.features = {
...envelope.message.payload.features,
fsEntryOps: false,
fsEntryDuplicate: false,
checkoutDiscardChanges: false,
};
}
browserSocket.send(JSON.stringify(envelope));
});
});
}
async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise<void> }> {
let resolveRequest: (() => void) | undefined;
const requestSeen = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message) as { message?: { type?: string } };
const type = envelope.message?.type;
if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") {
resolveRequest?.();
void browserSocket.close({
code: 1008,
reason: "Context action connection failed",
});
return;
}
}
serverSocket.send(message);
});
serverSocket.onMessage((message) => browserSocket.send(message));
});
return { wait: () => requestSeen };
}
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" });
});
test.afterEach(async () => {
await workspace?.cleanup();
});
test("creates, renames, copies, and deletes entries through the file explorer", async ({
context,
page,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const tree = page.getByTestId("file-explorer-tree-scroll");
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
const nameInput = page.getByTestId("file-explorer-name-input");
await page.getByTestId("files-new-folder").click();
await expect(nameInput).toBeVisible();
await nameInput.press("Tab");
await expect(nameInput).toBeHidden();
await page.getByTestId("files-new-file").click();
await nameInput.fill("README.md");
await nameInput.press("Enter");
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
const emptyArea = page.getByTestId("files-empty-area");
await expect(emptyArea).toBeVisible();
const emptyAreaBounds = await emptyArea.boundingBox();
expect(emptyAreaBounds).not.toBeNull();
await emptyArea.click({
button: "right",
position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 },
});
await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible();
await page.getByTestId("files-empty-area-new-folder").click();
await nameInput.fill("folder");
await nameInput.press("Enter");
await expect(entry("folder")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("New file", { exact: true }).click();
await nameInput.fill("child.txt");
await nameInput.press("Enter");
await expect(entry("child.txt")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("New folder", { exact: true }).click();
await nameInput.fill("nested");
await nameInput.press("Enter");
await entry("nested").click({ button: "right" });
await page.getByText("New folder", { exact: true }).click();
await nameInput.fill("deep");
await nameInput.press("Enter");
await expect(entry("deep")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("Collapse folder", { exact: true }).click();
await expect(entry("child.txt")).toBeHidden();
await expect(entry("nested")).toBeHidden();
await expect(entry("deep")).toBeHidden();
await entry("folder").click();
await expect(entry("child.txt")).toBeVisible();
await expect(entry("nested")).toBeVisible();
await expect(entry("deep")).toBeHidden();
await entry("nested").click();
await expect(entry("deep")).toBeVisible();
await page.getByTestId("files-new-file").click();
await nameInput.fill("created.txt");
await nameInput.press("Enter");
await expect(entry("created.txt")).toBeVisible();
await expect(entry("created.txt")).toHaveCSS("user-select", "none");
await entry("created.txt").dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
const folderRow = entry("folder").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
const fileRow = entry("created.txt").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
await expect(folderRow.locator("svg")).toHaveCount(2);
await expect(fileRow.locator("svg")).toHaveCount(1);
const gitRow = entry(".git").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
expect(collapsedChevronBounds).not.toBeNull();
expect(expandedChevronBounds).not.toBeNull();
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
const folderLabelBounds = await entry("folder").boundingBox();
const fileLabelBounds = await entry("created.txt").boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0);
await folderRow.click();
await expect(folderRow).toHaveAttribute("aria-selected", "true");
await expect(entry("child.txt")).toBeHidden();
await folderRow.click();
await expect(folderRow).toHaveAttribute("aria-selected", "true");
await expect(entry("child.txt")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await nameInput.fill("renamed-folder");
await nameInput.press("Tab");
await expect(entry("renamed-folder")).toBeVisible();
await expect(entry("folder")).toBeHidden();
await expect(entry("child.txt")).toBeVisible();
const renamedFolderRow = entry("renamed-folder").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true");
await entry("created.txt").click({ button: "right" });
await expect(fileRow).toHaveAttribute("aria-selected", "true");
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false");
const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
await expect(menu).toBeVisible();
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
await expect(menu).toBeVisible();
const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/);
await backdrop.click({ button: "right" });
await expect(menu).toBeHidden();
await entry("created.txt").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await expect(nameInput).toHaveValue("created.txt");
await nameInput.press("Escape");
await expect(entry("created.txt")).toBeVisible();
await entry("created.txt").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await nameInput.fill("README.md");
await nameInput.press("Enter");
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
await expect(entry("created.txt")).toBeVisible();
await entry("created.txt").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await nameInput.fill("renamed.txt");
await nameInput.press("Tab");
await expect(entry("renamed.txt")).toBeVisible();
await expect(entry("created.txt")).toBeHidden();
await entry("renamed.txt").click({ button: "right" });
await page.getByText("Copy relative path", { exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt");
await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
const deleteLabelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.dismiss();
resolve(message);
});
});
await page.getByText("Delete", { exact: true }).click();
expect(await cancelledConfirmation).toContain("renamed.txt");
await expect(entry("renamed.txt")).toBeVisible();
await entry("renamed.txt").click({ button: "right" });
const confirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.accept();
resolve(message);
});
});
await page.getByText("Delete", { exact: true }).click();
expect(await confirmation).toContain("renamed.txt");
await expect(entry("renamed.txt")).toBeHidden();
await entry("renamed-folder").click({ button: "right" });
const folderConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.accept();
resolve(message);
});
});
await page.getByText("Delete", { exact: true }).click();
expect(await folderConfirmation).toContain("renamed-folder");
await expect(entry("renamed-folder")).toBeHidden();
await expect(entry("child.txt")).toBeHidden();
});
test("duplicates files and folders with collision-free names", async ({ page }) => {
const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt");
const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder");
await writeFile(sourceFilePath, "duplicate contents", "utf8");
await mkdir(sourceFolderPath);
await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8");
try {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const tree = page.getByTestId("file-explorer-tree-scroll");
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
await entry("duplicate-source.txt").click({ button: "right" });
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
await expect(entry("duplicate-source copy.txt")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8"))
.toBe("duplicate contents");
await entry("duplicate-source.txt").click({ button: "right" });
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
await expect(entry("duplicate-source copy 2.txt")).toBeVisible();
await entry("duplicate-folder").click({ button: "right" });
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
await expect(entry("duplicate-folder copy")).toBeVisible();
await expect
.poll(() =>
readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"),
)
.toBe("nested contents");
} finally {
await Promise.all([
rm(sourceFilePath, { force: true }),
rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }),
rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }),
rm(sourceFolderPath, { recursive: true, force: true }),
rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }),
]);
}
});
test("shows an actionable error when the connection drops during creation", async ({ page }) => {
const disconnect = await disconnectNextContextAction(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await page.getByTestId("files-new-file").click();
const nameInput = page.getByTestId("file-explorer-name-input");
await nameInput.fill("disconnected.txt");
await nameInput.press("Enter");
await disconnect.wait();
await expect(page.getByText(/Context action connection failed/i)).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }),
).toHaveCount(0);
});
test("keeps an entry visible when deletion fails", async ({ page }) => {
await failNextDeleteRequest(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const readme = page
.getByTestId("file-explorer-tree-scroll")
.getByText("README.md", { exact: true });
await readme.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByText("Delete", { exact: true }).click();
await confirmation;
await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible();
await expect(readme).toBeVisible();
});
test("hides unsupported file operations and revert actions", async ({ page }) => {
await hideContextActionCapabilities(page);
await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n");
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expect(page.getByTestId("files-new-file")).toHaveCount(0);
await expect(page.getByTestId("files-new-folder")).toHaveCount(0);
await page
.getByTestId("file-explorer-tree-scroll")
.getByText("README.md", { exact: true })
.click({ button: "right" });
const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0);
await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0);
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("explorer-tab-changes").click();
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});