From 8991d34f78fa0c12d59d91c5e7506d36c741710b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Apr 2026 16:49:09 +0800 Subject: [PATCH] fix(app): allow collapsing the parent folder of a selected file (#500) The file explorer had a useEffect that re-enforced expansion of every ancestor of `selectedEntryPath` whenever `expandedPaths` changed. As a result, the moment a user collapsed the parent folder of a file they had just opened, the effect re-added that folder to the expanded set and the folder appeared stuck open. Selection and expansion are orthogonal: clicking a file should not dictate that its parent folder stays open forever. There is also no current caller that sets `selectedEntryPath` from outside the tree, so the reveal-on-external-selection use case the effect was designed for is unused. Delete the effect and its two now-dead helpers. If a future "Reveal in Explorer" action is introduced, it can expand ancestors at the call site. Adds an E2E regression test that reproduces the original scenario: expand a folder, open an image, collapse the parent folder, assert children are hidden. Also asserts an unrelated sibling folder still expands after the image is open, guarding against any future regression that makes the bug global. --- .../app/e2e/file-explorer-collapse.spec.ts | 61 +++++++++++++++++++ packages/app/e2e/helpers/file-explorer.ts | 41 +++++++++++++ .../app/src/components/file-explorer-pane.tsx | 58 ------------------ 3 files changed, 102 insertions(+), 58 deletions(-) create mode 100644 packages/app/e2e/file-explorer-collapse.spec.ts create mode 100644 packages/app/e2e/helpers/file-explorer.ts diff --git a/packages/app/e2e/file-explorer-collapse.spec.ts b/packages/app/e2e/file-explorer-collapse.spec.ts new file mode 100644 index 000000000..97fcd9e30 --- /dev/null +++ b/packages/app/e2e/file-explorer-collapse.spec.ts @@ -0,0 +1,61 @@ +import { test } from "./fixtures"; +import { + collapseFolder, + expandFolder, + expectExplorerEntryHidden, + expectExplorerEntryVisible, + expectFileTabOpen, + openFileExplorer, + openFileFromExplorer, +} from "./helpers/file-explorer"; +import { gotoWorkspace } from "./helpers/launcher"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectWorkspaceSetupClient, + type WorkspaceSetupDaemonClient, +} from "./helpers/workspace-setup"; + +let tempRepo: { path: string; cleanup: () => Promise }; +let workspaceId: string; +let seedClient: WorkspaceSetupDaemonClient; + +test.beforeAll(async () => { + tempRepo = await createTempGitRepo("file-explorer-collapse-", { + files: [ + { path: "assets/logo.png", content: "image bytes for explorer e2e\n" }, + { path: "docs/guide.md", content: "# Guide\n" }, + ], + }); + seedClient = await connectWorkspaceSetupClient(); + const result = await seedClient.openProject(tempRepo.path); + if (!result.workspace) { + throw new Error(result.error ?? "Failed to seed workspace"); + } + workspaceId = String(result.workspace.id); +}); + +test.afterAll(async () => { + await seedClient?.close(); + await tempRepo?.cleanup(); +}); + +test.describe("File explorer collapse", () => { + test("collapses an opened image file parent folder and still expands other folders", async ({ + page, + }) => { + await gotoWorkspace(page, workspaceId); + await openFileExplorer(page); + + await expandFolder(page, "assets"); + await expectExplorerEntryVisible(page, "logo.png"); + + await openFileFromExplorer(page, "logo.png"); + await expectFileTabOpen(page, "assets/logo.png"); + + await collapseFolder(page, "assets"); + await expectExplorerEntryHidden(page, "logo.png"); + + await expandFolder(page, "docs"); + await expectExplorerEntryVisible(page, "guide.md"); + }); +}); diff --git a/packages/app/e2e/helpers/file-explorer.ts b/packages/app/e2e/helpers/file-explorer.ts new file mode 100644 index 000000000..0d63f34ba --- /dev/null +++ b/packages/app/e2e/helpers/file-explorer.ts @@ -0,0 +1,41 @@ +import { expect, type Page } from "@playwright/test"; + +function fileExplorerTree(page: Page) { + return page.getByTestId("file-explorer-tree-scroll"); +} + +function fileExplorerEntry(page: Page, name: string) { + return fileExplorerTree(page).getByText(name, { exact: true }).first(); +} + +export async function openFileExplorer(page: Page): Promise { + await page.getByRole("button", { name: "Open explorer" }).first().click(); + await page.getByTestId("explorer-tab-files").click(); + await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 }); +} + +export async function expandFolder(page: Page, folderName: string): Promise { + await fileExplorerEntry(page, folderName).click(); +} + +export async function collapseFolder(page: Page, folderName: string): Promise { + await fileExplorerEntry(page, folderName).click(); +} + +export async function openFileFromExplorer(page: Page, fileName: string): Promise { + await fileExplorerEntry(page, fileName).click(); +} + +export async function expectExplorerEntryVisible(page: Page, name: string): Promise { + await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 }); +} + +export async function expectExplorerEntryHidden(page: Page, name: string): Promise { + await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 }); +} + +export async function expectFileTabOpen(page: Page, filePath: string): Promise { + await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({ + timeout: 30_000, + }); +} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 9575eb891..7b09137ca 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -195,35 +195,6 @@ export function FileExplorerPane({ }); }, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]); - // Expand ancestor directories when a file is selected (e.g., from an inline path click) - useEffect(() => { - if (!selectedEntryPath || !workspaceStateKey) { - return; - } - const parentDir = getParentDirectory(selectedEntryPath); - const ancestors = getAncestorDirectories(parentDir); - const newPaths = ancestors.filter((path) => !expandedPaths.has(path)); - if (newPaths.length === 0) { - return; - } - setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]); - newPaths.forEach((path) => { - if (!directories.has(path)) { - void requestDirectoryListing(path, { - recordHistory: false, - setCurrentPath: false, - }); - } - }); - }, [ - directories, - workspaceStateKey, - expandedPaths, - requestDirectoryListing, - selectedEntryPath, - setExpandedPathsForWorkspace, - ]); - const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => { if (!workspaceStateKey) { @@ -682,35 +653,6 @@ function buildTreeRows({ return rows; } -function getParentDirectory(path: string): string { - const normalized = path.replace(/\/+$/, ""); - if (!normalized || normalized === ".") { - return "."; - } - const lastSlash = normalized.lastIndexOf("/"); - if (lastSlash === -1) { - return "."; - } - const dir = normalized.slice(0, lastSlash); - return dir.length > 0 ? dir : "."; -} - -function getAncestorDirectories(directory: string): string[] { - const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, ""); - if (!trimmed || trimmed === ".") { - return ["."]; - } - - const parts = trimmed.split("/").filter(Boolean); - const ancestors: string[] = ["."]; - let acc = ""; - for (const part of parts) { - acc = acc ? `${acc}/${part}` : part; - ancestors.push(acc); - } - return ancestors; -} - function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string { if (!state) { return ".";