mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
Unify Explorer tabs and refine launch controls (#5942)
* feat(app): unify Explorer tabs and refine interface styling Give Explorer the workspace tab controls while keeping launcher choices separate from drag-and-drop support. Upgrade Lucide and set a shared icon stroke width for consistent styling. Reduce the desktop composer bottom inset to align the action row with its corners. * fix(app): show active Explorer tabs in foreground The shared row coupled foreground styling to keyboard shortcut ownership. Separate those concerns so Explorer can highlight its active tab while Cmd+T remains with the workspace pane. * fix(app): refine Explorer close controls and launch options * test(app): respect singleton Files in Explorer plugin workflow * test(server): trim unused setup from no-track checkout regression * test(app): count visible main tab rows after pane moves
This commit is contained in:
@@ -15,8 +15,10 @@ fixed-target labels and icons from that registration, filter by host, and never
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
|
||||
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||
Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons,
|
||||
so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu
|
||||
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
|
||||
can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
@@ -41,11 +43,13 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T
|
||||
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||
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 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.
|
||||
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
|
||||
hide their close buttons through the panel manifest; close them from the tab context menu.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
|
||||
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. 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. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
|
||||
Generated
+18
-12
@@ -26270,17 +26270,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react-native": {
|
||||
"version": "0.546.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
|
||||
"integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -36489,7 +36478,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
@@ -37344,6 +37333,23 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/lucide-react-native": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz",
|
||||
"integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/miniflare": {
|
||||
"version": "4.20260625.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
|
||||
|
||||
@@ -17,7 +17,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const explorer = await openExplorerWithKeyboard(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -25,13 +25,16 @@ function draftTabChip(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||
/** Scope the shared tab row to the Explorer dock. */
|
||||
function explorerTabRow(page: Page): Locator {
|
||||
return visible(page, "explorer-sidebar-tab-rail");
|
||||
return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
|
||||
}
|
||||
|
||||
function mainTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row");
|
||||
return page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true });
|
||||
}
|
||||
|
||||
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||
@@ -173,7 +176,7 @@ test.describe("explorer pane tab placement", () => {
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
@@ -233,14 +236,14 @@ async function closeOnlyDraft(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
|
||||
await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Split pane right/ }).click();
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, draftTabChip(page), {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
|
||||
await expect(mainTabRow(page)).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function expectNewLauncher(page: Page): Promise<void> {
|
||||
|
||||
@@ -3,20 +3,20 @@ import {
|
||||
withExplorerPanelPlugins,
|
||||
expectWorkspacePanelsInExplorerLauncher,
|
||||
openWorkspacePanelFromExplorerMenu,
|
||||
expectIndependentExplorerPanelToggles,
|
||||
expectIndependentExplorerPanelTabs,
|
||||
expectExplorerPanelWithFocusedAgent,
|
||||
} from "../support/helpers/explorer-plugin-menu";
|
||||
|
||||
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
|
||||
test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withExplorerPanelPlugins(page, async (workspace) => {
|
||||
await test.step("the New Tab launcher offers compatible workspace panels", () =>
|
||||
await test.step("the + menu offers compatible workspace panels", () =>
|
||||
expectWorkspacePanelsInExplorerLauncher(page));
|
||||
await test.step("the rail menu offers the same workspace panels", () =>
|
||||
await test.step("the + menu opens a workspace panel in Explorer", () =>
|
||||
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
|
||||
await test.step("different panels and plugins keep independent selection and closing", () =>
|
||||
expectIndependentExplorerPanelToggles(page, workspace));
|
||||
expectIndependentExplorerPanelTabs(page, workspace));
|
||||
await test.step("focusing an agent does not add agent panels to Explorer", () =>
|
||||
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
pressNewTabShortcut,
|
||||
expectTabTitleFits,
|
||||
} from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
ensureExplorerSidebar,
|
||||
@@ -11,6 +15,25 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectExplorerActiveTabForeground(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
await openFilesPanel(page);
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
await main.hover();
|
||||
const activeMainLabel = main
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.locator('[aria-selected="true"]')
|
||||
.getByText("New tab", { exact: true });
|
||||
const foreground = await activeMainLabel.evaluate((element) => getComputedStyle(element).color);
|
||||
const activeFiles = explorerSidebar(page).getByRole("button", {
|
||||
name: "Browse workspace files",
|
||||
exact: true,
|
||||
});
|
||||
await expect(activeFiles).toHaveAttribute("aria-selected", "true");
|
||||
await expect(activeFiles.getByText("Files", { exact: true })).toHaveCSS("color", foreground);
|
||||
}
|
||||
|
||||
test.describe("Explorer sidebar", () => {
|
||||
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
|
||||
page,
|
||||
@@ -26,14 +49,14 @@ test.describe("Explorer sidebar", () => {
|
||||
.count();
|
||||
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1);
|
||||
|
||||
await openFilesPanel(page);
|
||||
await expectExplorerActiveTabForeground(page);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
|
||||
await explorer.getByTestId("workspace-tab-changes_tree").click();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
@@ -46,3 +69,181 @@ test.describe("Explorer sidebar", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function launchExplorerPanel(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
name: string,
|
||||
) {
|
||||
const explorer = explorerSidebar(page);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: new RegExp(`^${name}`) }).click();
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: new RegExp(`^${name}`) })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function closeExplorerFilesFromContextMenu(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
const explorer = explorerSidebar(page);
|
||||
const files = explorer.getByRole("button", { name: "Browse workspace files", exact: true });
|
||||
await files.hover();
|
||||
await expect(explorer.getByTestId("workspace-files-close")).toHaveCount(0);
|
||||
await explorer.getByTestId("workspace-tab-changes_tree").hover();
|
||||
await expect(explorer.getByTestId("workspace-working-diff-close-changes_tree")).toHaveCount(0);
|
||||
await files.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(files).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectWorkspaceCloseOnHover(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
const tab = main.locator('[data-testid^="workspace-tab-"][aria-selected="true"]');
|
||||
const close = main.getByRole("button", { name: "Close", exact: true });
|
||||
await explorerSidebar(page).hover();
|
||||
await expect(close.locator("..")).toHaveCSS("opacity", "0");
|
||||
await tab.hover();
|
||||
await expect(close.locator("..")).toHaveCSS("opacity", "1");
|
||||
}
|
||||
|
||||
async function closeOtherExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
const files = explorerSidebar(page).getByRole("button", {
|
||||
name: "Browse workspace files",
|
||||
exact: true,
|
||||
});
|
||||
await files.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
const confirmation = page.waitForEvent("dialog").then((dialog) => {
|
||||
expect(dialog.message()).toContain("close 1 tab");
|
||||
return dialog.accept();
|
||||
});
|
||||
await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click();
|
||||
await confirmation;
|
||||
}
|
||||
|
||||
test("Explorer keeps Files and Changes close actions in the context menu", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-shared-tabs-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
const mainTabsBefore = await main.getByTestId("workspace-tabs-row").getByRole("button").count();
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expectTabTitleFits(page, "Files", { min: 64, max: 90 });
|
||||
|
||||
await test.step("Explorer's + menu excludes Agent and terminal profiles", async () => {
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
|
||||
const entries = await menu.getByRole("menuitem").allTextContents();
|
||||
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
|
||||
entries.findIndex((entry) => entry.startsWith("Diff")),
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await main.getByTestId("workspace-new-tab-button").click();
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await test.step("Files and Changes omit X, and + restores Files after context-menu close", async () => {
|
||||
await expectWorkspaceCloseOnHover(page);
|
||||
await closeExplorerFilesFromContextMenu(page);
|
||||
await launchExplorerPanel(page, "Files");
|
||||
await expect(
|
||||
explorer.getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("standard bulk-close menus affect only Explorer tabs", async () => {
|
||||
await closeOtherExplorerTabs(page);
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(main.getByTestId("workspace-tabs-row").getByRole("button")).toHaveCount(
|
||||
mainTabsBefore,
|
||||
);
|
||||
await launchExplorerPanel(page, "Changes");
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Cmd+T still belongs to the focused workspace pane", async () => {
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(
|
||||
main.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await testInfo.attach("shared-explorer-tabs", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function dragAgentIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
agentId: string,
|
||||
) {
|
||||
const source = page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`);
|
||||
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
|
||||
const sourceBox = await source.boundingBox();
|
||||
const destinationBox = await destination.boundingBox();
|
||||
if (!sourceBox || !destinationBox)
|
||||
throw new Error("Tab drag requires visible source and destination");
|
||||
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 12, start.y + 4);
|
||||
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("agents can still be dragged into Explorer when absent from its + menu", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Dragged agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await dragAgentIntoExplorer(page, agent.id);
|
||||
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await testInfo.attach("agent-in-explorer", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,10 +64,8 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||
).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
@@ -102,7 +100,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -116,7 +114,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -279,7 +279,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(2);
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
|
||||
@@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
|
||||
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
|
||||
@@ -572,18 +572,21 @@ test.describe("Half-screen desktop layout", () => {
|
||||
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await explorerToggle.click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
|
||||
@@ -35,18 +35,14 @@ export default function contribute(client) {
|
||||
|
||||
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 explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toHaveCSS("opacity", "1");
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function toggleExplorerView(page: Page, name: string) {
|
||||
async function launchExplorerView(page: Page, name: string | RegExp) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name, exact: true }).click();
|
||||
await expect(menu).not.toBeVisible();
|
||||
@@ -84,13 +80,12 @@ export async function withExplorerPanelPlugins(
|
||||
|
||||
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(
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review main only", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function openWorkspacePanelFromExplorerMenu(
|
||||
@@ -103,67 +98,51 @@ export async function openWorkspacePanelFromExplorerMenu(
|
||||
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 expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole("menuitem", { name: "Other review summary", exact: true }),
|
||||
).toBeVisible();
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectIndependentExplorerPanelToggles(
|
||||
export async function expectIndependentExplorerPanelTabs(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
) {
|
||||
await toggleExplorerView(page, "Other review");
|
||||
await launchExplorerView(page, "Other review");
|
||||
await expect(
|
||||
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await toggleExplorerView(page, "Review summary");
|
||||
await launchExplorerView(page, "Review summary");
|
||||
const tabs = page.getByTestId("workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
|
||||
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
|
||||
await tabs
|
||||
.getByRole("button", { name: "Review", exact: true })
|
||||
.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toHaveCount(0);
|
||||
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
|
||||
const files = tabs.getByRole("button", { name: "Browse workspace files", exact: true });
|
||||
await expect(files).toHaveCount(1);
|
||||
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 expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await files.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(files).toHaveCount(0);
|
||||
await launchExplorerView(page, /^Files/);
|
||||
await expect(files).toHaveCount(1);
|
||||
const restoredMenu = await openExplorerMenu(page);
|
||||
await expect(restoredMenu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
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(
|
||||
|
||||
@@ -89,7 +89,7 @@ async function openExplorerView(
|
||||
): Promise<void> {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
const tab = explorer.getByTestId(view.tabTestId);
|
||||
await tab.click();
|
||||
await selectWorkspaceTab(tab);
|
||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||
timeout: view.timeout ?? 30_000,
|
||||
});
|
||||
@@ -97,7 +97,7 @@ async function openExplorerView(
|
||||
|
||||
export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-changes_tree",
|
||||
tabTestId: "workspace-tab-changes_tree",
|
||||
contentTestId: "changes-tree-panel",
|
||||
});
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export async function openChangesPanel(page: Page, timeout = 30_000): Promise<vo
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-files",
|
||||
tabTestId: "workspace-tab-files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { LucideProvider } from "lucide-react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
@@ -113,7 +114,7 @@ import {
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -972,11 +973,13 @@ function RootAppTree() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</LucideProvider>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Github,
|
||||
HardDrive,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
applyAvailableAddProjectHosts,
|
||||
backAddProjectPage,
|
||||
@@ -162,7 +162,7 @@ function FlowBackButton({ onPress }: { onPress: () => void }) {
|
||||
}
|
||||
|
||||
function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] {
|
||||
if (method === "github") return Github;
|
||||
if (method === "github") return GitHubIcon;
|
||||
if (method === "browse") return FolderOpen;
|
||||
if (method === "new-directory") return FolderPlus;
|
||||
return Search;
|
||||
@@ -656,7 +656,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}`
|
||||
: repository.nameWithOwner,
|
||||
subtitle: repository.description,
|
||||
icon: Github,
|
||||
icon: GitHubIcon,
|
||||
testID: `add-project-flow-repository-${repository.id}`,
|
||||
select: () =>
|
||||
setState((current) => openGithubLocationPage(current, page.hostId, repository)),
|
||||
|
||||
@@ -397,18 +397,6 @@ export function SplitContainer({
|
||||
},
|
||||
[workspaceKey],
|
||||
);
|
||||
const handleCreateExplorerTab = useCallback(
|
||||
() => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }),
|
||||
[explorerSidebarPaneId, onCreateNewTab],
|
||||
);
|
||||
const handleMoveExplorerTabToMain = useCallback(
|
||||
(tabId: string) => {
|
||||
if (layout.focusedPaneId) {
|
||||
onMoveTabToPane(tabId, layout.focusedPaneId);
|
||||
}
|
||||
},
|
||||
[layout.focusedPaneId, onMoveTabToPane],
|
||||
);
|
||||
const splitRoot = useMemo(
|
||||
() =>
|
||||
resolveSplitContainerRoot({
|
||||
@@ -725,8 +713,19 @@ export function SplitContainer({
|
||||
closingTabIds={closingTabIds}
|
||||
onSelectTab={onSelectTabInPane}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={handleCreateExplorerTab}
|
||||
onMoveTabToMain={handleMoveExplorerTabToMain}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
|
||||
@@ -1921,10 +1921,15 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
paddingVertical: {
|
||||
paddingTop: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
// The button row bleeds 6px horizontally, so match its corner inset at the bottom.
|
||||
paddingBottom: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[3],
|
||||
},
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
|
||||
@@ -137,6 +137,7 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement {
|
||||
serverId,
|
||||
purpose: host === "explorer" ? "supporting" : "primary",
|
||||
host,
|
||||
surface: "panel",
|
||||
});
|
||||
const itemsById = useMemo(
|
||||
() => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])),
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { panelResourceKey, panelSupportsHost } from "@/panels/panel-manifest";
|
||||
import {
|
||||
getPanelManifest,
|
||||
panelCanLaunchInPane,
|
||||
panelResourceKey,
|
||||
panelSupportsHost,
|
||||
} from "@/panels/panel-manifest";
|
||||
|
||||
describe("panel manifest", () => {
|
||||
it("answers host support without React panel registration", () => {
|
||||
@@ -13,6 +18,23 @@ describe("panel manifest", () => {
|
||||
expect(panelSupportsHost("setup", "explorer")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets navigation tabs hide their close button without losing their close action", () => {
|
||||
expect(getPanelManifest("files").showCloseButton).toBe(false);
|
||||
expect(getPanelManifest("changes_tree").showCloseButton).toBe(false);
|
||||
expect(getPanelManifest("agent").showCloseButton).toBe(true);
|
||||
expect(getPanelManifest("terminal").showCloseButton).toBe(true);
|
||||
expect(getPanelManifest("file").showCloseButton).toBe(true);
|
||||
expect(getPanelManifest("working_diff").showCloseButton).toBe(true);
|
||||
});
|
||||
|
||||
it("hides a singleton launch only when it already exists in the destination pane", () => {
|
||||
expect(panelCanLaunchInPane("files", ["files"])).toBe(false);
|
||||
expect(panelCanLaunchInPane("changes_tree", ["changes_tree"])).toBe(false);
|
||||
expect(panelCanLaunchInPane("files", ["changes_tree"])).toBe(true);
|
||||
expect(panelCanLaunchInPane("files", [])).toBe(true);
|
||||
expect(panelCanLaunchInPane("terminal", ["terminal"])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps durable resource identity separate from transient target input", () => {
|
||||
expect(
|
||||
panelResourceKey({ kind: "working_diff", focusPath: "src/a.ts", focusRequestId: 1 }),
|
||||
|
||||
@@ -5,6 +5,8 @@ export type PaneHost = "main" | "explorer";
|
||||
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
supportedHosts: readonly PaneHost[];
|
||||
showCloseButton: boolean;
|
||||
singleton: boolean;
|
||||
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
|
||||
}
|
||||
|
||||
@@ -16,62 +18,86 @@ const manifests = {
|
||||
new_tab: {
|
||||
kind: "new_tab",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: () => "new_tab",
|
||||
},
|
||||
draft: {
|
||||
kind: "draft",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.draftId,
|
||||
},
|
||||
agent: {
|
||||
kind: "agent",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.agentId,
|
||||
},
|
||||
provider_subagent: {
|
||||
kind: "provider_subagent",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
||||
},
|
||||
terminal: {
|
||||
kind: "terminal",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.terminalId,
|
||||
},
|
||||
browser: {
|
||||
kind: "browser",
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.browserId,
|
||||
},
|
||||
changes_tree: {
|
||||
kind: "changes_tree",
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
resourceKey: () => "changes_tree",
|
||||
},
|
||||
files: {
|
||||
kind: "files",
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
resourceKey: () => "files",
|
||||
},
|
||||
pull_request: {
|
||||
kind: "pull_request",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: () => "pull_request",
|
||||
},
|
||||
file: {
|
||||
kind: "file",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.path,
|
||||
},
|
||||
working_diff: {
|
||||
kind: "working_diff",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: () => "working_diff",
|
||||
},
|
||||
plugin: {
|
||||
kind: "plugin",
|
||||
// Plugin targets are narrowed by the target-aware plugin panel capability resolver.
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) =>
|
||||
target.context === "agent"
|
||||
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
|
||||
@@ -80,11 +106,15 @@ const manifests = {
|
||||
setup: {
|
||||
kind: "setup",
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.workspaceId,
|
||||
},
|
||||
commit_diff: {
|
||||
kind: "commit_diff",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.sha,
|
||||
},
|
||||
} satisfies PanelManifestByKind;
|
||||
@@ -97,6 +127,13 @@ export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHo
|
||||
return getPanelManifest(kind).supportedHosts.includes(host);
|
||||
}
|
||||
|
||||
export function panelCanLaunchInPane(
|
||||
kind: WorkspaceTabTarget["kind"],
|
||||
panePanelKinds: readonly WorkspaceTabTarget["kind"][],
|
||||
): boolean {
|
||||
return !getPanelManifest(kind).singleton || !panePanelKinds.includes(kind);
|
||||
}
|
||||
|
||||
export function panelResourceKey(target: WorkspaceTabTarget): string {
|
||||
const manifest = getPanelManifest(target.kind);
|
||||
return `${target.kind}:${manifest.resourceKey(target as never)}`;
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import { useCallback, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { ArrowLeftToLine, Plus, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import { EXPLORER_TAB_RAIL_INSET } from "@/components/explorer-sidebar-layout";
|
||||
import type {
|
||||
DraggableListDragHandleProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "@/components/draggable-list.types";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { titlebarDragSurfaceStyle } from "@/components/desktop/titlebar-drag-region";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabPresentationResolver,
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
useWorkspaceTabLaunchCatalog,
|
||||
type WorkspaceTabLaunchItem,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
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";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import {
|
||||
HorizontalScrollBoundaryShades,
|
||||
useHorizontalScrollBoundary,
|
||||
} from "@/components/ui/horizontal-scroll-boundary";
|
||||
|
||||
const TAB_GAP = 4;
|
||||
const TAB_DROP_INDICATOR_WIDTH = 4;
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
interface ExplorerSidebarTabRailProps {
|
||||
paneId: string;
|
||||
tabs: WorkspaceDesktopTabRowItem[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
activeDragTabId: string | null;
|
||||
tabDropPreviewIndex: number | null;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCreateNewTab: () => void;
|
||||
onMoveTabToMain: (tabId: string) => void;
|
||||
onReorderTabs: (tabs: WorkspaceTabDescriptor[]) => void;
|
||||
trailingAccessory?: ReactNode;
|
||||
}
|
||||
|
||||
function tabKey(item: WorkspaceDesktopTabRowItem): string {
|
||||
return `${item.tab.key}:${item.tab.kind}`;
|
||||
}
|
||||
|
||||
function resolveExplorerSidebarTabBackdrop(): SurfaceBackdrop {
|
||||
return "surfaceSidebar";
|
||||
}
|
||||
|
||||
function ExplorerSidebarTab({
|
||||
item,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onMoveTabToMain,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
item: WorkspaceDesktopTabRowItem;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onMoveTabToMain: (tabId: string) => void;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handlePress = useCallback(
|
||||
() => onNavigateTab(item.tab.tabId),
|
||||
[item.tab.tabId, onNavigateTab],
|
||||
);
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
const handleClose = useCallback(() => {
|
||||
void onCloseTab(item.tab.tabId);
|
||||
}, [item.tab.tabId, onCloseTab]);
|
||||
const handleMoveToMain = useCallback(
|
||||
() => onMoveTabToMain(item.tab.tabId),
|
||||
[item.tab.tabId, onMoveTabToMain],
|
||||
);
|
||||
const canMoveToMain = panelTargetSupportsHost(normalizedServerId, item.tab.target, "main");
|
||||
const moveToMainLeading = useMemo(
|
||||
() => <ThemedArrowLeftToLine size={14} uniProps={mutedColorMapping} />,
|
||||
[],
|
||||
);
|
||||
const closeLeading = useMemo(() => <ThemedX size={14} uniProps={mutedColorMapping} />, []);
|
||||
const accessibilityState = useMemo(() => ({ selected: item.isActive }), [item.isActive]);
|
||||
const renderPresentation = useCallback(
|
||||
(presentation: WorkspaceTabPresentation) => (
|
||||
<ContextMenu>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<ContextMenuTrigger
|
||||
{...(dragHandleProps?.attributes as object | undefined)}
|
||||
{...(dragHandleProps?.listeners as object | undefined)}
|
||||
triggerRef={dragHandleProps?.setActivatorNodeRef as never}
|
||||
testID={`explorer-sidebar-tab-${item.tab.tabId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={presentation.tooltip}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={[
|
||||
styles.tab,
|
||||
hovered ? styles.tabHovered : null,
|
||||
item.isActive ? styles.tabActive : null,
|
||||
isDragging ? styles.tabDragging : null,
|
||||
]}
|
||||
>
|
||||
<WorkspaceTabIcon
|
||||
presentation={presentation}
|
||||
active={item.isActive}
|
||||
size={iconButtonChromeGlyphSize("small")}
|
||||
strokeWidth={1.5}
|
||||
backdrop={resolveExplorerSidebarTabBackdrop()}
|
||||
/>
|
||||
<Text
|
||||
selectable={false}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
style={[styles.tabLabel, item.isActive ? styles.tabLabelActive : null]}
|
||||
>
|
||||
{presentation.label}
|
||||
</Text>
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{presentation.tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ContextMenuContent align="start" minWidth={180}>
|
||||
{canMoveToMain ? (
|
||||
<ContextMenuItem leading={moveToMainLeading} onSelect={handleMoveToMain}>
|
||||
{t("workspace.tabs.menu.moveToMain")}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{canMoveToMain ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem leading={closeLeading} onSelect={handleClose}>
|
||||
{t("workspace.tabs.menu.close")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
[
|
||||
accessibilityState,
|
||||
dragHandleProps,
|
||||
handleHoverIn,
|
||||
handleHoverOut,
|
||||
handleClose,
|
||||
handleMoveToMain,
|
||||
handlePress,
|
||||
hovered,
|
||||
isDragging,
|
||||
item,
|
||||
canMoveToMain,
|
||||
closeLeading,
|
||||
moveToMainLeading,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={item.tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{renderPresentation}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ComponentType<PanelIconProps>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={14} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedCatalogIcon = withUnistyles(CatalogIcon);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
function ExplorerSidebarConfigurationItem({
|
||||
item,
|
||||
paneId,
|
||||
tab,
|
||||
onCloseTab,
|
||||
}: {
|
||||
item: WorkspaceTabLaunchItem;
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor | null;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
return item.Icon ? <ThemedCatalogIcon Icon={item.Icon} uniProps={mutedColorMapping} /> : null;
|
||||
}, [item.Icon]);
|
||||
const handleSelect = useCallback(() => {
|
||||
if (tab) {
|
||||
void onCloseTab(tab.tabId);
|
||||
return;
|
||||
}
|
||||
item.launch({ kind: "open", paneId });
|
||||
}, [item, onCloseTab, paneId, tab]);
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
leading={leading}
|
||||
selected={Boolean(tab)}
|
||||
showSelectedCheck
|
||||
disabled={!tab && item.disabled}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{item.label}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean {
|
||||
return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target);
|
||||
}
|
||||
|
||||
export function ExplorerSidebarTabRail({
|
||||
paneId,
|
||||
tabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
activeDragTabId,
|
||||
tabDropPreviewIndex,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCreateNewTab,
|
||||
onMoveTabToMain,
|
||||
onReorderTabs,
|
||||
trailingAccessory,
|
||||
}: ExplorerSidebarTabRailProps) {
|
||||
const scrollBoundary = useHorizontalScrollBoundary();
|
||||
const { t } = useTranslation();
|
||||
const groups = useWorkspaceTabLaunchCatalog({
|
||||
serverId: normalizedServerId,
|
||||
purpose: "supporting",
|
||||
host: "explorer",
|
||||
});
|
||||
const singletonConfigurationItems = useMemo(
|
||||
() => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null),
|
||||
[groups],
|
||||
);
|
||||
const newTabLeading = useMemo(() => <ThemedPlus size={14} uniProps={mutedColorMapping} />, []);
|
||||
const handleDragEnd = useCallback(
|
||||
(nextTabs: WorkspaceDesktopTabRowItem[]) => onReorderTabs(nextTabs.map((item) => item.tab)),
|
||||
[onReorderTabs],
|
||||
);
|
||||
const getTabDragData = useCallback(
|
||||
(item: WorkspaceDesktopTabRowItem) => ({
|
||||
kind: "workspace-tab" as const,
|
||||
paneId,
|
||||
tabId: item.tab.tabId,
|
||||
}),
|
||||
[paneId],
|
||||
);
|
||||
const renderTab = useCallback(
|
||||
({
|
||||
item,
|
||||
index,
|
||||
dragHandleProps,
|
||||
isActive,
|
||||
}: DraggableRenderItemInfo<WorkspaceDesktopTabRowItem>) => {
|
||||
const showBefore = activeDragTabId !== null && tabDropPreviewIndex === index;
|
||||
const showAfter =
|
||||
activeDragTabId !== null &&
|
||||
tabDropPreviewIndex === tabs.length &&
|
||||
index === tabs.length - 1;
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
{showBefore ? <View style={[styles.dropIndicator, styles.dropIndicatorBefore]} /> : null}
|
||||
<ExplorerSidebarTab
|
||||
item={item}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onMoveTabToMain={onMoveTabToMain}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
{showAfter ? <View style={[styles.dropIndicator, styles.dropIndicatorAfter]} /> : null}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeDragTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onMoveTabToMain,
|
||||
tabDropPreviewIndex,
|
||||
tabs.length,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
contextOnly
|
||||
style={[styles.track, titlebarDragSurfaceStyle as never]}
|
||||
testID="explorer-sidebar-tab-rail"
|
||||
>
|
||||
<View style={styles.scrollContainer}>
|
||||
<Animated.ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
onLayout={scrollBoundary.onLayout}
|
||||
onContentSizeChange={scrollBoundary.onContentSizeChange}
|
||||
onScroll={scrollBoundary.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<SortableInlineList
|
||||
data={tabs}
|
||||
keyExtractor={tabKey}
|
||||
renderItem={renderTab}
|
||||
onDragEnd={handleDragEnd}
|
||||
useDragHandle
|
||||
externalDndContext
|
||||
activeId={activeDragTabId}
|
||||
getItemData={getTabDragData}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
<HorizontalScrollBoundaryShades
|
||||
visible
|
||||
backdrop="sidebar"
|
||||
testIDPrefix="explorer-sidebar-tabs-scroll-shade"
|
||||
leftStyle={scrollBoundary.leftShadeStyle}
|
||||
rightStyle={scrollBoundary.rightShadeStyle}
|
||||
/>
|
||||
</View>
|
||||
{trailingAccessory ? (
|
||||
<View style={styles.trailingAccessory}>{trailingAccessory}</View>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent align="start" minWidth={200} testID="explorer-sidebar-tab-configuration">
|
||||
<ContextMenuItem leading={newTabLeading} onSelect={onCreateNewTab}>
|
||||
{t("workspace.tabs.actions.newTab")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
{singletonConfigurationItems.map((item) => (
|
||||
<ExplorerSidebarConfigurationItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
paneId={paneId}
|
||||
tab={tabs.find(({ tab }) => catalogItemMatchesTab(item, tab))?.tab ?? null}
|
||||
onCloseTab={onCloseTab}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
track: {
|
||||
minWidth: 0,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
scrollContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: EXPLORER_TAB_RAIL_INSET,
|
||||
},
|
||||
trailingAccessory: {
|
||||
marginRight: 4,
|
||||
},
|
||||
tabSlot: {
|
||||
position: "relative",
|
||||
marginHorizontal: TAB_GAP / 2,
|
||||
},
|
||||
tab: {
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
maxWidth: 180,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
tabHovered: {
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
tabLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
userSelect: "none",
|
||||
},
|
||||
tabLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
tabDragging: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
dropIndicator: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[0.5],
|
||||
bottom: theme.spacing[0.5],
|
||||
width: TAB_DROP_INDICATOR_WIDTH,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
dropIndicatorBefore: {
|
||||
left: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
|
||||
},
|
||||
dropIndicatorAfter: {
|
||||
right: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.popoverForeground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -4,17 +4,35 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import type { TabDropPreview } from "@/components/split-container-tab-drop-preview";
|
||||
import { ExplorerSidebarTabRail } from "@/screens/workspace/explorer-sidebar-tab-rail";
|
||||
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
type WorkspaceDesktopTabsRowProps,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SplitPane } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
|
||||
interface ExplorerSidebarDockProps {
|
||||
interface ExplorerSidebarDockProps extends Pick<
|
||||
WorkspaceDesktopTabsRowProps,
|
||||
| "setHoveredCloseTabKey"
|
||||
| "onCopyResumeCommand"
|
||||
| "onCopyAgentId"
|
||||
| "onCopyTerminalId"
|
||||
| "onCopyFilePath"
|
||||
| "onReloadAgent"
|
||||
| "onRenameTab"
|
||||
| "onCreateNewTab"
|
||||
| "onExitFocusMode"
|
||||
> {
|
||||
hoveredCloseTabKey: string | null;
|
||||
onCloseTabsToLeft: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
pane: SplitPane;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
@@ -25,8 +43,6 @@ interface ExplorerSidebarDockProps {
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
onSelectTab: (paneId: string, tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCreateNewTab: () => void;
|
||||
onMoveTabToMain: (tabId: string) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
@@ -48,7 +64,18 @@ export function ExplorerSidebarDock({
|
||||
onSelectTab,
|
||||
onCloseTab,
|
||||
onCreateNewTab,
|
||||
onMoveTabToMain,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCopyTerminalId,
|
||||
onCopyFilePath,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onExitFocusMode,
|
||||
onReorderTabsInPane,
|
||||
buildPaneContentModel,
|
||||
headerAction,
|
||||
@@ -61,10 +88,10 @@ export function ExplorerSidebarDock({
|
||||
tabs.map((tab) => ({
|
||||
tab,
|
||||
isActive: tab.tabId === activeTabId,
|
||||
isCloseHovered: false,
|
||||
isCloseHovered: hoveredCloseTabKey === tab.key,
|
||||
isClosingTab: closingTabIds.has(tab.tabId),
|
||||
})),
|
||||
[activeTabId, closingTabIds, tabs],
|
||||
[activeTabId, closingTabIds, hoveredCloseTabKey, tabs],
|
||||
);
|
||||
const handleSelectTab = useCallback(
|
||||
(tabId: string) => onSelectTab(pane.id, tabId),
|
||||
@@ -80,28 +107,59 @@ export function ExplorerSidebarDock({
|
||||
[onReorderTabsInPane, pane.id],
|
||||
);
|
||||
|
||||
const handleCloseTabsToLeft = useCallback(
|
||||
(tabId: string) => onCloseTabsToLeft(tabId, tabs),
|
||||
[onCloseTabsToLeft, tabs],
|
||||
);
|
||||
const handleCloseTabsToRight = useCallback(
|
||||
(tabId: string) => onCloseTabsToRight(tabId, tabs),
|
||||
[onCloseTabsToRight, tabs],
|
||||
);
|
||||
const handleCloseOtherTabs = useCallback(
|
||||
(tabId: string) => onCloseOtherTabs(tabId, tabs),
|
||||
[onCloseOtherTabs, tabs],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanel active>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={styles.dock} testID="workspace-explorer-sidebar">
|
||||
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
|
||||
<TitlebarDragRegion />
|
||||
<ExplorerSidebarTabRail
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onMoveTabToMain={onMoveTabToMain}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
trailingAccessory={headerAction}
|
||||
/>
|
||||
<View style={styles.tabRow}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
host="explorer"
|
||||
launchPurpose="supporting"
|
||||
isFocused={isWorkspaceFocused}
|
||||
ownsKeyboardShortcuts={false}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
focusModeEnabled={false}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
externalDndContext
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</View>
|
||||
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
|
||||
<View pointerEvents="none" style={styles.tabRailDivider} />
|
||||
</WindowChromeSafeArea>
|
||||
<View style={styles.content}>
|
||||
@@ -131,9 +189,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
tabRail: {
|
||||
position: "relative",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
tabRow: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerAction: {
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
tabRailDivider: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
|
||||
@@ -63,6 +63,8 @@ import {
|
||||
type WorkspaceTabMenuLabels,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
@@ -107,7 +109,7 @@ const TAB_ICON_WIDTH = 14;
|
||||
const TAB_CONTENT_GAP = 4;
|
||||
const TAB_DROP_INDICATOR_WIDTH = 4;
|
||||
const TAB_MODIFIED_DOT_SIZE = 8;
|
||||
const TAB_MIN_WIDTH = 96;
|
||||
const TAB_MIN_WIDTH = 64;
|
||||
const TAB_MAX_WIDTH = 160;
|
||||
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
|
||||
const TAB_LABEL_LAYOUT_ALLOWANCE = 4;
|
||||
@@ -209,7 +211,12 @@ function TabLabelMeasurement({
|
||||
);
|
||||
}
|
||||
|
||||
type PanePanelKinds = readonly WorkspaceTabDescriptor["kind"][];
|
||||
|
||||
interface WorkspaceNewTabButtonProps {
|
||||
panePanelKinds: PanePanelKinds;
|
||||
host: PaneHost;
|
||||
launchPurpose: WorkspaceTabLaunchPurpose;
|
||||
serverId: string;
|
||||
paneId?: string;
|
||||
shortcutKeys: ShortcutKey[][] | null;
|
||||
@@ -217,6 +224,9 @@ interface WorkspaceNewTabButtonProps {
|
||||
}
|
||||
|
||||
function WorkspaceNewTabButton({
|
||||
panePanelKinds,
|
||||
host,
|
||||
launchPurpose,
|
||||
serverId,
|
||||
paneId,
|
||||
shortcutKeys,
|
||||
@@ -237,8 +247,9 @@ function WorkspaceNewTabButton({
|
||||
</ToolbarButton>
|
||||
<WorkspaceNewTabMenuContent
|
||||
serverId={serverId}
|
||||
purpose="primary"
|
||||
host="main"
|
||||
purpose={launchPurpose}
|
||||
host={host}
|
||||
panePanelKinds={panePanelKinds}
|
||||
paneId={paneId}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
@@ -248,6 +259,9 @@ function WorkspaceNewTabButton({
|
||||
}
|
||||
|
||||
function WorkspacePaneToolbarActions({
|
||||
panePanelKinds,
|
||||
host,
|
||||
launchPurpose,
|
||||
showNewTabButton,
|
||||
showSplitActions,
|
||||
showMaximizeAction,
|
||||
@@ -259,6 +273,9 @@ function WorkspacePaneToolbarActions({
|
||||
onSplitDown,
|
||||
onTogglePaneMaximized,
|
||||
}: {
|
||||
panePanelKinds: PanePanelKinds;
|
||||
host: PaneHost;
|
||||
launchPurpose: WorkspaceTabLaunchPurpose;
|
||||
showNewTabButton: boolean;
|
||||
showSplitActions: boolean;
|
||||
showMaximizeAction: boolean;
|
||||
@@ -297,6 +314,9 @@ function WorkspacePaneToolbarActions({
|
||||
<ToolbarControls style={styles.paneSplitActions}>
|
||||
{showNewTabButton ? (
|
||||
<WorkspaceNewTabButton
|
||||
panePanelKinds={panePanelKinds}
|
||||
host={host}
|
||||
launchPurpose={launchPurpose}
|
||||
placement="toolbar"
|
||||
serverId={serverId}
|
||||
paneId={paneId}
|
||||
@@ -492,9 +512,12 @@ function sameWidths(left: number[], right: number[]): boolean {
|
||||
return left.length === right.length && left.every((width, index) => width === right[index]);
|
||||
}
|
||||
|
||||
interface WorkspaceDesktopTabsRowProps {
|
||||
export interface WorkspaceDesktopTabsRowProps {
|
||||
host?: PaneHost;
|
||||
launchPurpose?: WorkspaceTabLaunchPurpose;
|
||||
paneId?: string;
|
||||
isFocused?: boolean;
|
||||
ownsKeyboardShortcuts?: boolean;
|
||||
tabs: WorkspaceDesktopTabRowItem[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -988,8 +1011,11 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
|
||||
}
|
||||
|
||||
function ResolvedWorkspaceDesktopTabsRow({
|
||||
host = "main",
|
||||
launchPurpose = "primary",
|
||||
paneId,
|
||||
isFocused = false,
|
||||
ownsKeyboardShortcuts = isFocused,
|
||||
tabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -1059,6 +1085,8 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
[exitFocusModeWidth, focusModeEnabled, showPaneMaximizeAction, showPaneSplitActions],
|
||||
);
|
||||
|
||||
const panePanelKinds = useMemo(() => tabs.map(({ tab }) => tab.kind), [tabs]);
|
||||
|
||||
const fallbackTabLabels = useMemo(
|
||||
() => ({
|
||||
newTab: t("workspace.tabs.actions.newTab"),
|
||||
@@ -1203,14 +1231,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
|
||||
const handleNewTabKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isFocused) return false;
|
||||
if (!ownsKeyboardShortcuts) return false;
|
||||
if (action.id === "workspace.tab.menu.open") {
|
||||
createNewTab();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[createNewTab, isFocused],
|
||||
[createNewTab, ownsKeyboardShortcuts],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -1221,7 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
paneId,
|
||||
}),
|
||||
actions: ["workspace.tab.menu.open"],
|
||||
enabled: isFocused,
|
||||
enabled: ownsKeyboardShortcuts,
|
||||
priority: 200,
|
||||
handle: handleNewTabKeyboardAction,
|
||||
});
|
||||
@@ -1233,7 +1261,8 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
dragHandleProps,
|
||||
isActive,
|
||||
}: DraggableRenderItemInfo<ResolvedWorkspaceDesktopTabRowItem>) => {
|
||||
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
|
||||
const shouldShowCloseButton =
|
||||
layout.closeButtonPolicy === "all" && item.presentation.showCloseButton;
|
||||
const layoutItem = layout.items[index] ?? null;
|
||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||
const showLabel = layoutItem?.showLabel ?? true;
|
||||
@@ -1360,6 +1389,9 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
/>
|
||||
{!layout.requiresHorizontalScrollFallback ? (
|
||||
<WorkspaceNewTabButton
|
||||
panePanelKinds={panePanelKinds}
|
||||
host={host}
|
||||
launchPurpose={launchPurpose}
|
||||
placement="inline"
|
||||
serverId={normalizedServerId}
|
||||
paneId={paneId}
|
||||
@@ -1376,6 +1408,9 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
/>
|
||||
</View>
|
||||
<WorkspacePaneToolbarActions
|
||||
panePanelKinds={panePanelKinds}
|
||||
host={host}
|
||||
launchPurpose={launchPurpose}
|
||||
showNewTabButton={layout.requiresHorizontalScrollFallback}
|
||||
showSplitActions={showPaneSplitActions}
|
||||
showMaximizeAction={showPaneMaximizeAction}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type WorkspaceTabLaunchItem,
|
||||
type WorkspaceTabLaunchPurpose,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
|
||||
@@ -84,14 +85,22 @@ export function WorkspaceNewTabMenuContent({
|
||||
serverId,
|
||||
purpose,
|
||||
host,
|
||||
panePanelKinds,
|
||||
paneId,
|
||||
}: {
|
||||
serverId: string;
|
||||
purpose: WorkspaceTabLaunchPurpose;
|
||||
host: PaneHost;
|
||||
panePanelKinds: readonly WorkspaceTabTarget["kind"][];
|
||||
paneId?: string;
|
||||
}) {
|
||||
const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose, host });
|
||||
const groups = useWorkspaceTabLaunchCatalog({
|
||||
serverId,
|
||||
purpose,
|
||||
host,
|
||||
surface: "menu",
|
||||
panePanelKinds,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -9,7 +9,7 @@ const metrics = {
|
||||
actionsReservedWidth: 120,
|
||||
rowPaddingHorizontal: 8,
|
||||
tabGap: 4,
|
||||
minTabWidth: 96,
|
||||
minTabWidth: 64,
|
||||
maxTabWidth: 160,
|
||||
tabIconWidth: 14,
|
||||
tabContentGap: 4,
|
||||
@@ -29,7 +29,20 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
expect(result.items.map((item) => item.width)).toEqual([96, 104, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]);
|
||||
});
|
||||
|
||||
it("lets short labels use their content width below the old 96px floor", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 300,
|
||||
tabLabelWidths: [29, 52],
|
||||
metrics: { ...metrics, actionsReservedWidth: 36 },
|
||||
});
|
||||
expect(result.items).toEqual([
|
||||
{ width: 64, showLabel: true },
|
||||
{ width: 86, showLabel: true },
|
||||
]);
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
});
|
||||
|
||||
it("sizes a single tab between the minimum and maximum from its content", () => {
|
||||
@@ -52,7 +65,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([132, 102, 82]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -75,27 +88,27 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
it("keeps every tab at the clickable minimum at the exact fit boundary", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 532,
|
||||
viewportWidth: 404,
|
||||
tabLabelWidths: [98, 98, 98, 98],
|
||||
metrics,
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses horizontal scroll rather than shrinking below the clickable minimum", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 531,
|
||||
viewportWidth: 403,
|
||||
tabLabelWidths: [98, 98, 98, 98],
|
||||
metrics,
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(true);
|
||||
expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface WorkspaceTabPresentation {
|
||||
subtitle: string;
|
||||
tooltip: string;
|
||||
modified: boolean;
|
||||
showCloseButton: boolean;
|
||||
titleState: "ready" | "loading";
|
||||
icon: React.ComponentType<PanelIconProps>;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
@@ -90,6 +91,7 @@ function WorkspaceTabPresentationResolverInner({
|
||||
subtitle: descriptor.subtitle,
|
||||
tooltip: descriptor.tooltip,
|
||||
modified: attributes.modified,
|
||||
showCloseButton: registration.showCloseButton,
|
||||
titleState: descriptor.titleState,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
@@ -104,6 +106,7 @@ function WorkspaceTabPresentationResolverInner({
|
||||
tab.key,
|
||||
tab.kind,
|
||||
attributes.modified,
|
||||
registration.showCloseButton,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -597,6 +597,8 @@ export const ICON_SIZE = {
|
||||
lg: 20,
|
||||
} as const;
|
||||
|
||||
export const ICON_STROKE_WIDTH = 1.5;
|
||||
|
||||
export const FONT_WEIGHT = {
|
||||
normal: "normal" as const,
|
||||
medium: "500" as const,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getPanelManifest } from "@/panels/panel-manifest";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPre
|
||||
...data,
|
||||
tooltip: data.label,
|
||||
modified: false,
|
||||
showCloseButton: getPanelManifest(data.kind).showCloseButton,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import { panelSupportsHost, type PaneHost } from "@/panels/panel-manifest";
|
||||
import { panelCanLaunchInPane, panelSupportsHost, type PaneHost } from "@/panels/panel-manifest";
|
||||
import {
|
||||
getPanelRegistration,
|
||||
type PanelIconProps,
|
||||
@@ -53,8 +53,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -65,6 +63,8 @@ export interface WorkspaceTabLaunchGroup {
|
||||
accessory?: { id: string; label: string; run: () => void };
|
||||
}
|
||||
|
||||
const EMPTY_PANE_PANEL_KINDS: readonly WorkspaceTabTarget["kind"][] = [];
|
||||
|
||||
const NewTabLauncherContext = createContext<NewTabLauncher | null>(null);
|
||||
|
||||
export function NewTabLauncherProvider({
|
||||
@@ -97,8 +97,10 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
serverId: string;
|
||||
purpose: WorkspaceTabLaunchPurpose;
|
||||
host: PaneHost;
|
||||
surface: "menu" | "panel";
|
||||
panePanelKinds?: readonly WorkspaceTabTarget["kind"][];
|
||||
}): readonly WorkspaceTabLaunchGroup[] {
|
||||
const { serverId, purpose, host } = input;
|
||||
const { serverId, purpose, host, surface, panePanelKinds = EMPTY_PANE_PANEL_KINDS } = input;
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const launcher = useContext(NewTabLauncherContext);
|
||||
@@ -118,6 +120,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
}, [router, serverId]);
|
||||
|
||||
return useMemo(() => {
|
||||
const isExplorerMenu = host === "explorer" && surface === "menu";
|
||||
const changesPresentation = getLaunchPresentation("changes_tree");
|
||||
const diffPresentation = getLaunchPresentation("working_diff");
|
||||
const filesPresentation = getLaunchPresentation("files");
|
||||
@@ -130,7 +133,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-tab-target-agent",
|
||||
disabled: false,
|
||||
panelKind: "draft",
|
||||
toggleTarget: null,
|
||||
hidden: isExplorerMenu,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.agent),
|
||||
},
|
||||
terminal: {
|
||||
@@ -140,7 +143,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-terminal-new",
|
||||
disabled: launcher.terminalDisabled,
|
||||
panelKind: "terminal",
|
||||
toggleTarget: null,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.terminal),
|
||||
},
|
||||
changes: {
|
||||
@@ -149,7 +151,6 @@ 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),
|
||||
},
|
||||
@@ -160,7 +161,6 @@ 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),
|
||||
},
|
||||
@@ -171,7 +171,6 @@ 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: {
|
||||
@@ -181,7 +180,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
shortcutActionId: "workspace-tab-target-browser",
|
||||
disabled: false,
|
||||
panelKind: "browser",
|
||||
toggleTarget: null,
|
||||
hidden: !launcher.showBrowser,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.browser),
|
||||
},
|
||||
@@ -191,7 +189,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
Icon: pullRequestPresentation.icon,
|
||||
disabled: false,
|
||||
panelKind: "pull_request",
|
||||
toggleTarget: null,
|
||||
hidden: !launcher.showPullRequest,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest),
|
||||
},
|
||||
@@ -218,7 +215,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
Icon: resolvePluginIcon(panel.icon),
|
||||
disabled: false,
|
||||
panelKind: "plugin",
|
||||
toggleTarget: selection.target,
|
||||
launch: launchSelection(selection),
|
||||
});
|
||||
}
|
||||
@@ -229,7 +225,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
if (pluginItems.length > 0) {
|
||||
groups.push({ id: "plugin-panels", label: null, items: pluginItems });
|
||||
}
|
||||
if (profiles.length > 0) {
|
||||
if (!isExplorerMenu && profiles.length > 0) {
|
||||
groups.push({
|
||||
id: "terminal-profiles",
|
||||
label: t("workspace.tabs.actions.terminalProfilesMenu"),
|
||||
@@ -239,7 +235,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
terminalIconKey: getTerminalProfileIcon(profile),
|
||||
disabled: launcher.terminalDisabled,
|
||||
panelKind: "terminal",
|
||||
toggleTarget: null,
|
||||
launch: launchSelection({ kind: "terminal", profile }),
|
||||
})),
|
||||
accessory: {
|
||||
@@ -249,7 +244,13 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
},
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
if (surface !== "menu") return groups;
|
||||
return groups.flatMap((group) => {
|
||||
const items = group.items.filter((item) =>
|
||||
panelCanLaunchInPane(item.panelKind, panePanelKinds),
|
||||
);
|
||||
return items.length > 0 ? [{ ...group, items }] : [];
|
||||
});
|
||||
}, [
|
||||
config?.terminalProfiles,
|
||||
editTerminalProfiles,
|
||||
@@ -258,6 +259,8 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
plugins,
|
||||
purpose,
|
||||
host,
|
||||
surface,
|
||||
panePanelKinds,
|
||||
serverId,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -17,9 +17,9 @@ describe("getBuiltInLaunchOrder", () => {
|
||||
it("leads with companion tools in a supporting pane", () => {
|
||||
expect(getBuiltInLaunchOrder("supporting")).toEqual([
|
||||
"changes",
|
||||
"terminal",
|
||||
"diff",
|
||||
"files",
|
||||
"terminal",
|
||||
"agent",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
|
||||
@@ -10,9 +10,9 @@ export const PRIMARY_LAUNCH_ORDER = [
|
||||
|
||||
export const SUPPORTING_LAUNCH_ORDER = [
|
||||
"changes",
|
||||
"terminal",
|
||||
"diff",
|
||||
"files",
|
||||
"terminal",
|
||||
"agent",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
|
||||
@@ -991,9 +991,9 @@ const x = 1;
|
||||
});
|
||||
|
||||
it("does not report local-only no-track worktree commits as ahead of origin", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
commitFile(repoDir, "second.txt", "second\n", "second commit");
|
||||
execFileSync("git", ["push"], { cwd: repoDir });
|
||||
// This case needs a remote base ref, not a second clone or extra base history.
|
||||
execFileSync("git", ["remote", "add", "origin", repoDir], { cwd: repoDir });
|
||||
execFileSync("git", ["fetch", "origin", "main:refs/remotes/origin/main"], { cwd: repoDir });
|
||||
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
branchName: "fresh-feature",
|
||||
@@ -1007,6 +1007,7 @@ const x = 1;
|
||||
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
|
||||
expect(status).toMatchObject({
|
||||
isGit: true,
|
||||
hasRemote: true,
|
||||
isPaseoOwnedWorktree: true,
|
||||
baseRef: "main",
|
||||
aheadBehind: { ahead: 1, behind: 0 },
|
||||
|
||||
@@ -64,7 +64,7 @@ export function TitleBar({ state }: { state: MockupStateId }) {
|
||||
{shipping ? (
|
||||
<>
|
||||
<GithubGlyph size={13} />
|
||||
Merge PR (squash)
|
||||
Merge PR
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user