mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
fix(app): show workspace plugin panels in Explorer menu (#4446)
* fix(app): show workspace plugin panels in Explorer menu Let the host-filtered launch catalog identify toggleable views and their targets. Explorer uses the same identity comparison for every view while plugin panels remain workspace-scoped. * test(app): express Explorer panel regression as user steps
This commit is contained in:
@@ -39,9 +39,10 @@ that pane from the workspace split tree and docks it separately. Persisted ident
|
||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||
|
||||
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||
an Explorer tab does not change workspace focus.
|
||||
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
|
||||
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
|
||||
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
|
||||
focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withExplorerPanelPlugins,
|
||||
expectWorkspacePanelsInExplorerLauncher,
|
||||
openWorkspacePanelFromExplorerMenu,
|
||||
expectIndependentExplorerPanelToggles,
|
||||
expectExplorerPanelWithFocusedAgent,
|
||||
} from "../support/helpers/explorer-plugin-menu";
|
||||
|
||||
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withExplorerPanelPlugins(page, async (workspace) => {
|
||||
await test.step("the New Tab launcher offers compatible workspace panels", () =>
|
||||
expectWorkspacePanelsInExplorerLauncher(page));
|
||||
await test.step("the rail menu offers the same workspace panels", () =>
|
||||
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
|
||||
await test.step("different panels and plugins keep independent selection and closing", () =>
|
||||
expectIndependentExplorerPanelToggles(page, workspace));
|
||||
await test.step("focusing an agent does not add agent panels to Explorer", () =>
|
||||
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { ensureExplorerSidebar } from "./workspace-tabs";
|
||||
|
||||
type ExplorerPanelWorkspace = Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
|
||||
async function writePanelPlugin(id: string, title: string) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-explorer-menu-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`
|
||||
import React from "react";
|
||||
import { Text } from "react-native";
|
||||
export default function contribute(client) {
|
||||
client.addWorkspacePanel({ id: "review", title: ${JSON.stringify(title)}, icon: "Blocks", context: "workspace", locations: ["workspace", "explorer"], Component: ({ workspaceId }) => <Text>{${JSON.stringify(title)} + " workspace " + workspaceId}</Text> });
|
||||
client.addWorkspacePanel({ id: "summary", title: ${JSON.stringify(`${title} summary`)}, icon: "Blocks", context: "workspace", locations: ["explorer"], Component: () => <Text>Summary content</Text> });
|
||||
client.addWorkspacePanel({ id: "main", title: ${JSON.stringify(`${title} main only`)}, icon: "Blocks", context: "workspace", Component: () => null });
|
||||
client.addWorkspacePanel({ id: "agent", title: ${JSON.stringify(`${title} agent`)}, icon: "Blocks", context: "agent", locations: ["explorer"], Component: () => null });
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function openExplorerMenu(page: Page) {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
// The rail's empty background has no accessible role; tab menus are separate.
|
||||
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
|
||||
button: "right",
|
||||
position: { x: 20, y: 2 },
|
||||
});
|
||||
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toHaveCSS("opacity", "1");
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function toggleExplorerView(page: Page, name: string) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name, exact: true }).click();
|
||||
await expect(menu).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function withExplorerPanelPlugins(
|
||||
page: Page,
|
||||
run: (workspace: ExplorerPanelWorkspace) => Promise<void>,
|
||||
) {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-plugin-menu-" });
|
||||
const first = await writePanelPlugin("explorer-review", "Review");
|
||||
const second = await writePanelPlugin("explorer-other", "Other review");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(first);
|
||||
await client.installDirectoryPlugin(second);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await run(workspace);
|
||||
} finally {
|
||||
await client.removePlugin("explorer-review");
|
||||
await client.removePlugin("explorer-other");
|
||||
await client.patchDaemonConfig({
|
||||
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await rm(first, { recursive: true, force: true });
|
||||
await rm(second, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
|
||||
const launcher = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openWorkspacePanelFromExplorerMenu(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await testInfo.attach("explorer-panel-menu", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"New tab",
|
||||
"Changes",
|
||||
"Files",
|
||||
"Other review",
|
||||
"Other review summary",
|
||||
"Review",
|
||||
"Review summary",
|
||||
]);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectIndependentExplorerPanelToggles(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
) {
|
||||
await toggleExplorerView(page, "Other review");
|
||||
await expect(
|
||||
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await toggleExplorerView(page, "Review summary");
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
const updated = await openExplorerMenu(page);
|
||||
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Other review", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("explorer-sidebar-tab-rail")
|
||||
.getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectExplorerPanelWithFocusedAgent(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Focused agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review agent", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await testInfo.attach("workspace-panel-with-focused-agent", {
|
||||
body: await page.screenshot({
|
||||
path: testInfo.outputPath("workspace-panel-with-focused-agent.png"),
|
||||
}),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
useWorkspaceTabLaunchCatalog,
|
||||
type WorkspaceTabLaunchItem,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
import { panelSupportsHost } from "@/panels/panel-manifest";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -248,7 +248,7 @@ function ExplorerSidebarConfigurationItem({
|
||||
}
|
||||
|
||||
function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean {
|
||||
return item.panelKind === tab.target.kind;
|
||||
return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target);
|
||||
}
|
||||
|
||||
export function ExplorerSidebarTabRail({
|
||||
@@ -273,10 +273,7 @@ export function ExplorerSidebarTabRail({
|
||||
host: "explorer",
|
||||
});
|
||||
const singletonConfigurationItems = useMemo(
|
||||
() =>
|
||||
(groups.find((group) => group.id === "tabs")?.items ?? []).filter(
|
||||
(item) => !panelSupportsHost(item.panelKind, "main"),
|
||||
),
|
||||
() => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null),
|
||||
[groups],
|
||||
);
|
||||
const newTabLeading = useMemo(() => <ThemedPlus size={14} uniProps={mutedColorMapping} />, []);
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface WorkspaceTabLaunchItem {
|
||||
shortcutActionId?: string;
|
||||
disabled: boolean;
|
||||
panelKind: WorkspaceTabTarget["kind"];
|
||||
/** The fixed view this item can toggle in a configuration menu, or null for launch-only items. */
|
||||
toggleTarget: WorkspaceTabTarget | null;
|
||||
launch: (destination: WorkspaceTabLaunchDestination) => void;
|
||||
}
|
||||
|
||||
@@ -75,7 +77,7 @@ export function NewTabLauncherProvider({
|
||||
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
|
||||
}
|
||||
|
||||
const BUILT_IN_SELECTIONS: Record<BuiltInLaunchItemId, NewTabSelection> = {
|
||||
const BUILT_IN_SELECTIONS = {
|
||||
agent: { kind: "agent" },
|
||||
terminal: { kind: "terminal" },
|
||||
changes: { kind: "target", target: { kind: "changes_tree" } },
|
||||
@@ -83,7 +85,7 @@ const BUILT_IN_SELECTIONS: Record<BuiltInLaunchItemId, NewTabSelection> = {
|
||||
files: { kind: "target", target: { kind: "files" } },
|
||||
browser: { kind: "browser" },
|
||||
pullRequest: { kind: "target", target: { kind: "pull_request" } },
|
||||
};
|
||||
} satisfies Record<BuiltInLaunchItemId, NewTabSelection>;
|
||||
|
||||
function getLaunchPresentation(kind: WorkspaceTabTarget["kind"]): PanelPresentation {
|
||||
const registration = getPanelRegistration(kind);
|
||||
@@ -128,6 +130,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-tab-target-agent",
|
||||
disabled: false,
|
||||
panelKind: "draft",
|
||||
toggleTarget: null,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.agent),
|
||||
},
|
||||
terminal: {
|
||||
@@ -137,6 +140,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-terminal-new",
|
||||
disabled: launcher.terminalDisabled,
|
||||
panelKind: "terminal",
|
||||
toggleTarget: null,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.terminal),
|
||||
},
|
||||
changes: {
|
||||
@@ -145,6 +149,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
Icon: changesPresentation.icon,
|
||||
disabled: false,
|
||||
panelKind: "changes_tree",
|
||||
toggleTarget: BUILT_IN_SELECTIONS.changes.target,
|
||||
hidden: !launcher.showChanges,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.changes),
|
||||
},
|
||||
@@ -155,6 +160,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-tab-target-changes",
|
||||
disabled: false,
|
||||
panelKind: "working_diff",
|
||||
toggleTarget: null,
|
||||
hidden: !launcher.showChanges,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.diff),
|
||||
},
|
||||
@@ -165,6 +171,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-tab-target-files",
|
||||
disabled: false,
|
||||
panelKind: "files",
|
||||
toggleTarget: BUILT_IN_SELECTIONS.files.target,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.files),
|
||||
},
|
||||
browser: {
|
||||
@@ -174,6 +181,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-tab-target-browser",
|
||||
disabled: false,
|
||||
panelKind: "browser",
|
||||
toggleTarget: null,
|
||||
hidden: !launcher.showBrowser,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.browser),
|
||||
},
|
||||
@@ -183,6 +191,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
Icon: pullRequestPresentation.icon,
|
||||
disabled: false,
|
||||
panelKind: "pull_request",
|
||||
toggleTarget: null,
|
||||
hidden: !launcher.showPullRequest,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest),
|
||||
},
|
||||
@@ -209,6 +218,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
Icon: resolvePluginIcon(panel.icon),
|
||||
disabled: false,
|
||||
panelKind: "plugin",
|
||||
toggleTarget: selection.target,
|
||||
launch: launchSelection(selection),
|
||||
});
|
||||
}
|
||||
@@ -229,6 +239,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
terminalIconKey: getTerminalProfileIcon(profile),
|
||||
disabled: launcher.terminalDisabled,
|
||||
panelKind: "terminal",
|
||||
toggleTarget: null,
|
||||
launch: launchSelection({ kind: "terminal", profile }),
|
||||
})),
|
||||
accessory: {
|
||||
|
||||
Reference in New Issue
Block a user