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 },
});