Make the workspace right pane a first-class tab host (#3287)
* feat(app): add persistent workspace side panes Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry. * fix(app): localize pull request empty state * test(app): update explorer pane coverage * fix(app): preserve explorer pane workflows * test(app): make explorer tab selection idempotent * fix(app): persist workspace review pins * fix(app): preserve sidebar scrim drag hook * fix(app): restore companion pane interactions * test(app): follow selected sidebar surface
This commit is contained in:
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
@@ -31,8 +32,7 @@ test("adds a changed file to the focused chat without replacing its composer dra
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
await openChangesPanel(page);
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
|
||||
@@ -755,7 +758,7 @@ async function expectRenderedBefore(first: Locator, second: Locator): Promise<vo
|
||||
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await createAgentTabFromMenu(page);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -29,6 +29,25 @@ interface CleanupTask {
|
||||
const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-tree-rail-tree");
|
||||
}
|
||||
|
||||
function changesContent(page: Page) {
|
||||
return page.getByTestId("changes-tree-rail-content");
|
||||
}
|
||||
|
||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function failNextDiscardRequest(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
@@ -266,7 +285,7 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(menuBounds!.x).toBeCloseTo(fileRowBounds!.x + 80, 0);
|
||||
expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1);
|
||||
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
@@ -282,14 +301,14 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts")))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
@@ -299,24 +318,25 @@ test("changes context menu recursively collapses descendant folders", async ({ p
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||
@@ -327,7 +347,8 @@ test("changes context menus expose folder revert and restore a file after confir
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
const tree = changesTree(page);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||
await expect(folderRevert).toBeVisible();
|
||||
const revertLabelColor = await folderRevert
|
||||
@@ -336,7 +357,7 @@ test("changes context menus expose folder revert and restore a file after confir
|
||||
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await tree.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
@@ -346,12 +367,12 @@ test("changes context menus expose folder revert and restore a file after confir
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await tree.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
@@ -362,7 +383,7 @@ test("changes context menus expose folder revert and restore a file after confir
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(tree.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(BEFORE);
|
||||
@@ -451,49 +472,39 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
test("Changes keeps review navigation and controls inside its workspace tab", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTabToggle = page.getByTestId("changes-open-tab");
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
|
||||
await changesTabToggle.click();
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||
const workingDiffLayoutToggle = visiblePanel.getByRole("button", {
|
||||
name: "Switch to side-by-side diff",
|
||||
});
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await workingDiffLayoutToggle.click();
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
|
||||
await page.getByTestId("working-diff-toggle-wrap-lines").click();
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
|
||||
"Scroll long lines",
|
||||
);
|
||||
await expect(visiblePanel.getByRole("button", { name: "Switch to unified diff" })).toBeVisible();
|
||||
await visiblePanel.getByRole("button", { name: "Diff options" }).click();
|
||||
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/^Refresh/)).toBeVisible();
|
||||
await page.getByText("Wrap long lines", { exact: true }).click();
|
||||
await visiblePanel.getByRole("button", { name: "Diff options" }).click();
|
||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await page.getByRole("button", { name: "Expand all files" }).click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Collapse all files" }).click();
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
@@ -505,24 +516,8 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
|
||||
"zz-deleted.ts",
|
||||
);
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
|
||||
await changesTabToggle.click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
@@ -547,16 +542,18 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
const tree = changesTree(page);
|
||||
const content = changesContent(page);
|
||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
"user-select",
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
||||
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
|
||||
const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox();
|
||||
const folderChevronBounds = await page
|
||||
await expect(tree.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
||||
await expect(tree.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
|
||||
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
|
||||
const folderChevronBounds = await tree
|
||||
.getByTestId("diff-folder-src-toggle")
|
||||
.locator("svg")
|
||||
.boundingBox();
|
||||
@@ -566,11 +563,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
folderToggleBounds!.y + folderToggleBounds!.height / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await page
|
||||
const folderLabelBounds = await tree
|
||||
.getByTestId("diff-folder-src")
|
||||
.getByText("src", { exact: true })
|
||||
.boundingBox();
|
||||
const fileLabelBounds = await page
|
||||
const fileLabelBounds = await tree
|
||||
.getByTestId("diff-file-0")
|
||||
.getByText("use-mounted-tab-set.ts", { exact: true })
|
||||
.boundingBox();
|
||||
@@ -578,28 +575,28 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0);
|
||||
|
||||
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
||||
const folderToggle = tree.getByTestId("diff-folder-src-toggle");
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await expect(tree.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
const fileToggle = page.getByTestId("diff-file-0-toggle");
|
||||
const fileToggle = tree.getByTestId("diff-file-0-toggle");
|
||||
await fileToggle.click({ button: "right" });
|
||||
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Expand all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Collapse all files" }).click();
|
||||
await expect(content.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Expand all files" }).click();
|
||||
await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expectFlatFileList(page);
|
||||
@@ -801,14 +798,13 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await page.getByTestId("diff-file-0").click();
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||
|
||||
@@ -13,7 +14,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesPanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -33,7 +34,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesPanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -57,6 +58,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-tab-commit_diff_/).hover();
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await commitRow.click();
|
||||
|
||||
@@ -38,10 +38,7 @@ function fitsViewportWidth(element: HTMLElement): boolean {
|
||||
}
|
||||
|
||||
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
const contentElement = editor(page);
|
||||
await contentElement.click();
|
||||
await contentElement.press("Control+A");
|
||||
await contentElement.type(content);
|
||||
await editor(page).fill(content);
|
||||
}
|
||||
|
||||
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
@@ -154,9 +151,8 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await page.getByRole("button", { name: "Split pane right" }).first().click();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
@@ -347,14 +343,13 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await replaceEditorText(page, "const localWins = 5;\n");
|
||||
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||
await openWorkspaceFile(page, fileName);
|
||||
}
|
||||
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||
|
||||
await replaceEditorText(page, "const discarded = 7;\n");
|
||||
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||
@@ -441,20 +436,21 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -437,7 +438,7 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
|
||||
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectExplorerNodeRetained,
|
||||
hideExplorerSplit,
|
||||
measureExplorerSplit,
|
||||
openExplorerSplit,
|
||||
resizeExplorerSplit,
|
||||
stampExplorerNode,
|
||||
} from "../support/helpers/split-pane";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Hidden split pane retention", () => {
|
||||
test("releases all layout space without unmounting and restores its dragged width", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await createAgentTabFromMenu(page);
|
||||
await openExplorerSplit(page);
|
||||
|
||||
// Drag to a non-default width first: restoring the default would prove nothing.
|
||||
await resizeExplorerSplit(page, 137);
|
||||
await stampExplorerNode(page);
|
||||
const open = await measureExplorerSplit(page);
|
||||
expect(open.explorerWidth).toBeGreaterThan(250);
|
||||
expect(open.separatorCount).toBe(1);
|
||||
|
||||
await hideExplorerSplit(page);
|
||||
const hidden = await measureExplorerSplit(page);
|
||||
expect(hidden.explorerWidth).toBe(0);
|
||||
// The sibling must absorb the whole row, not just collapse the hidden pane.
|
||||
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
|
||||
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
|
||||
expect(hidden.separatorCount).toBe(0);
|
||||
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
|
||||
await expectExplorerNodeRetained(page);
|
||||
|
||||
await openExplorerSplit(page);
|
||||
await expectExplorerNodeRetained(page);
|
||||
const reopened = await measureExplorerSplit(page);
|
||||
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
|
||||
expect(reopened.separatorCount).toBe(1);
|
||||
|
||||
await testInfo.attach("split-pane-geometry", {
|
||||
body: JSON.stringify({ open, hidden, reopened }, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,22 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectExplorerEntryVisible } from "../support/helpers/file-explorer";
|
||||
import { expectNoTerminalTabs, clickFirstTerminalTab } from "../support/helpers/workspace-tabs";
|
||||
import { expectExplorerEntryVisible, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import {
|
||||
expectNoTerminalTabs,
|
||||
clickFirstTerminalTab,
|
||||
openChangesPanel,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
// Model B: two workspaces can back the SAME directory. What follows from that
|
||||
// split is the contract these specs pin:
|
||||
// - The right sidebar (file browser / git changes) reads the directory, so it
|
||||
// is IDENTICAL across same-directory workspaces.
|
||||
// - Files and Changes read the directory, so their content is IDENTICAL
|
||||
// across same-directory workspaces.
|
||||
// - Tabs (agents, terminals) are owned by the workspace, so they are
|
||||
// INDEPENDENT across same-directory workspaces.
|
||||
|
||||
// On desktop the explorer is pinned open; on narrow layouts it must be toggled.
|
||||
// Open it either way, then select the requested tab.
|
||||
async function openExplorerTab(page: Page, tab: "files" | "changes"): Promise<void> {
|
||||
const openButton = page.getByRole("button", { name: "Open explorer" }).first();
|
||||
if (await openButton.isVisible().catch(() => false)) {
|
||||
await openButton.click();
|
||||
}
|
||||
await page.getByTestId(`explorer-tab-${tab}`).click();
|
||||
}
|
||||
|
||||
async function createSecondWorkspaceOnSameDir(
|
||||
seeded: SeededWorkspace,
|
||||
title: string,
|
||||
@@ -41,7 +35,7 @@ async function createSecondWorkspaceOnSameDir(
|
||||
test.describe("Same-directory workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("the right sidebar is shared: a directory change shows in both same-dir workspaces", async ({
|
||||
test("directory-backed Files and Changes content is shared across same-dir workspaces", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" });
|
||||
@@ -71,19 +65,19 @@ test.describe("Same-directory workspaces", () => {
|
||||
// Workspace A: the new file shows in both the file browser and the git
|
||||
// changes view.
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await openExplorerTab(page, "files");
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openExplorerTab(page, "changes");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Workspace B (same directory): the SAME change is visible. The right
|
||||
// sidebar content does not differ between the two views.
|
||||
// Workspace B (same directory): the SAME directory-backed content is
|
||||
// visible even though the panel tabs belong to a different workspace.
|
||||
await gotoWorkspace(page, secondWorkspaceId);
|
||||
await openExplorerTab(page, "files");
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openExplorerTab(page, "changes");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
|
||||
async function expectBorderHighlight(page: Page, testID: string) {
|
||||
async function expectBorderHighlight(
|
||||
page: Page,
|
||||
testID: string,
|
||||
hoverTarget: Locator,
|
||||
expectedWidth: string,
|
||||
) {
|
||||
const handle = page.getByTestId(testID);
|
||||
await expect(handle).toBeVisible();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await handle.hover();
|
||||
await hoverTarget.hover();
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
await expect(highlight).toHaveCSS("width", "1px");
|
||||
await expect(highlight).toHaveCSS("width", expectedWidth);
|
||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle");
|
||||
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expectBorderHighlight(page, "explorer-sidebar-resize-handle");
|
||||
const splitHandle = page.getByTestId("workspace-split-resize-handle");
|
||||
await expectBorderHighlight(
|
||||
page,
|
||||
"workspace-split-resize-handle",
|
||||
splitHandle.getByRole("separator"),
|
||||
"3px",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
||||
|
||||
@@ -331,7 +332,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("yields app navigation to the Explorer", async ({ page }) => {
|
||||
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
||||
|
||||
try {
|
||||
@@ -339,44 +340,19 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-tab-files").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||
await expect(page.getByTestId("explorer-close")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
|
||||
const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox();
|
||||
const headerGlyphBounds = await page
|
||||
.getByTestId("menu-button")
|
||||
.locator("svg")
|
||||
.first()
|
||||
.boundingBox();
|
||||
const tabGlyphBounds = await page
|
||||
.locator('[data-testid^="workspace-tab-"]')
|
||||
.first()
|
||||
.locator("svg")
|
||||
.first()
|
||||
.boundingBox();
|
||||
expect(centerBounds).not.toBeNull();
|
||||
expect(headerGlyphBounds).not.toBeNull();
|
||||
expect(tabGlyphBounds).not.toBeNull();
|
||||
expect((headerGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0)).toBeCloseTo(
|
||||
(tabGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0),
|
||||
await explorerToggle.click();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0,
|
||||
)
|
||||
.toBeGreaterThanOrEqual(400);
|
||||
|
||||
await page.getByTestId("explorer-close").click();
|
||||
await expect(page.getByTestId("explorer-tab-files")).not.toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { expectComposerVisible, submitMessage } from "../support/helpers/compose
|
||||
import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate";
|
||||
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
@@ -56,7 +59,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await createAgentTabFromMenu(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Keep the optimistic agent pane visible during handoff";
|
||||
@@ -99,7 +102,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await createAgentTabFromMenu(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const promptTitle = "Investigate optimistic tab title handoff";
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectExplorerEntryVisible } from "../support/helpers/file-explorer";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -24,14 +25,8 @@ function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
// On desktop the file explorer is pinned open; on narrower layouts it must be
|
||||
// toggled first. Open it either way, then select the Files tab.
|
||||
async function openFilesTab(page: Page): Promise<void> {
|
||||
const openButton = page.getByRole("button", { name: "Open explorer" }).first();
|
||||
if (await openButton.isVisible().catch(() => false)) {
|
||||
await openButton.click();
|
||||
}
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await openFilesPanel(page);
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ async function getVisibleDraftTabCount(page: Page): Promise<number> {
|
||||
}
|
||||
|
||||
async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
||||
const tab = page
|
||||
.locator('[data-testid^="workspace-tab-draft"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(tab).toBeVisible({ timeout: 30_000 });
|
||||
await tab.hover();
|
||||
const closeButton = page.locator('[data-testid^="workspace-draft-close-"]').filter({
|
||||
visible: true,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
|
||||
|
||||
// The branch switcher lives in the git diff panel's Changes header (right-side
|
||||
// ExplorerSidebar), not in the workspace header. It renders as a button whose
|
||||
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
||||
// accessible name carries the current branch ("Current branch: <name>. Press to
|
||||
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
||||
// what a screen reader user hears, and it proves the panel resolved a real
|
||||
@@ -16,17 +16,9 @@ function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
.first();
|
||||
}
|
||||
|
||||
// Opens the right-side explorer and lands on the Changes tab, where the branch
|
||||
// switcher and diff live. Git checkouts default to the Changes tab when the
|
||||
// explorer opens, so this is enough to reveal the switcher on desktop and mobile.
|
||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first();
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
await openWorkspaceChangesPanel(page);
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
return page.getByTestId("file-explorer-tree-scroll");
|
||||
@@ -9,8 +10,7 @@ function fileExplorerEntry(page: Page, name: string) {
|
||||
}
|
||||
|
||||
export async function openFileExplorer(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).first().click();
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await openFilesPanel(page);
|
||||
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createAgentTabFromMenu } from "./workspace-tabs";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,10 +70,10 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** Assert the inline new-agent plus button is visible in the tab bar. */
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -85,14 +86,9 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void>
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Click the inline plus button to create a draft/chat tab. */
|
||||
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page
|
||||
.getByTestId("workspace-new-agent-tab-inline")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
await createAgentTabFromMenu(page);
|
||||
}
|
||||
|
||||
/** Open the new-tab menu and click "New terminal". */
|
||||
@@ -129,9 +125,9 @@ export async function waitForTabWithTitle(
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the inline new-agent plus button is visible in the tab bar. */
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true });
|
||||
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-pr").click();
|
||||
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
|
||||
await openPullRequestPanel(page);
|
||||
}
|
||||
|
||||
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export interface SplitPaneGeometry {
|
||||
containerWidth: number;
|
||||
explorerWidth: number;
|
||||
visibleWidth: number;
|
||||
separatorCount: number;
|
||||
occupiedWidth: number;
|
||||
}
|
||||
|
||||
function splitSeparator(page: Page): Locator {
|
||||
return page.getByRole("separator", { name: "" });
|
||||
}
|
||||
|
||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close explorer" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
|
||||
const separator = splitSeparator(page);
|
||||
await expect(separator).toHaveCount(1);
|
||||
const box = await separator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) throw new Error("Split separator has no bounding box");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function stampExplorerNode(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByTestId("split-group-child")
|
||||
.last()
|
||||
.evaluate((node) => {
|
||||
Reflect.set(window, "__splitPaneExplorerNode", node);
|
||||
node.setAttribute("data-split-pane-identity", "retained");
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
|
||||
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
|
||||
await expect(stampedNode).toHaveCount(1);
|
||||
expect(
|
||||
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
|
||||
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
|
||||
await expect(explorerChild).toHaveCount(1);
|
||||
|
||||
return explorerChild.evaluate((explorerNode) => {
|
||||
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate !== explorerNode &&
|
||||
(candidate as HTMLElement).dataset.testid === "split-group-child",
|
||||
)
|
||||
.sort(
|
||||
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
|
||||
)[0];
|
||||
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
|
||||
const visible = visibleNode.getBoundingClientRect();
|
||||
const explorer = explorerNode.getBoundingClientRect();
|
||||
const container = explorerNode.parentElement?.getBoundingClientRect();
|
||||
if (!container) throw new Error("Split child has no container");
|
||||
const separatorCount =
|
||||
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
|
||||
return {
|
||||
containerWidth: container.width,
|
||||
explorerWidth: explorer.width,
|
||||
visibleWidth: visible.width,
|
||||
separatorCount,
|
||||
occupiedWidth: explorer.width + visible.width,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||
@@ -17,15 +17,89 @@ function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function explorerPaneTabRow(page: Page) {
|
||||
return visibleTestId(page, "workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
if ((await tab.getAttribute("aria-selected")) !== "true") {
|
||||
// The close action overlays the chip's trailing edge on hover. Click the
|
||||
// leading icon area so Playwright does not target that separate control.
|
||||
await tab.click({ position: { x: 12, y: 13 } });
|
||||
}
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function ensureExplorerPane(page: Page) {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const tabRow = explorerPaneTabRow(page);
|
||||
if ((await tabRow.count()) === 0) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(tabRow).toBeVisible({ timeout: 30_000 });
|
||||
return tabRow;
|
||||
}
|
||||
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const filesTab = tabRow.getByTestId("workspace-tab-files");
|
||||
if ((await filesTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(filesTab);
|
||||
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
const existingTab = visibleTestId(page, "workspace-tab-pull_request").first();
|
||||
if ((await existingTab.count()) > 0) {
|
||||
await selectWorkspaceTab(existingTab);
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
|
||||
if ((await pullRequestTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(pullRequestTab);
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visibleTestId(page, "workspace-new-agent-tab-inline").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the `+` menu in the tab row and pick "New agent". */
|
||||
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||
await expect(item).toBeVisible({ timeout: 10_000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
|
||||
@@ -445,7 +445,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "side");
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
|
||||
@@ -5,9 +5,7 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownLinkText } from "@/components/markdown/link-text";
|
||||
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
@@ -32,21 +30,13 @@ export function AssistantMarkdownLink({
|
||||
monoSurface,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||
const { target, onHoverIn, onPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onAuxPress();
|
||||
});
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
() => ({ onPress, accessibilityRole: "link" }),
|
||||
[onPress],
|
||||
@@ -93,7 +83,7 @@ export function AssistantMarkdownLink({
|
||||
{...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})}
|
||||
href={source.href}
|
||||
title={source.title}
|
||||
onClickCapture={handleAnchorClickCapture}
|
||||
onClickCapture={preventAnchorNavigation}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
@@ -209,8 +199,6 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
||||
display: "inline-flex" as ViewStyle["display"],
|
||||
};
|
||||
|
||||
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
|
||||
|
||||
function FileLinkHoverTooltip({
|
||||
filePath,
|
||||
children,
|
||||
@@ -228,17 +216,9 @@ function FileLinkHoverTooltip({
|
||||
</TooltipTrigger>
|
||||
{filePath ? (
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<View style={styles.tooltipBody}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
<View style={styles.tooltipHintRow}>
|
||||
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
||||
<Text selectable={false} style={styles.tooltipHintText}>
|
||||
click for side pane
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
@@ -255,27 +235,10 @@ function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function isModifiedOpenEvent(event: MouseEvent<HTMLElement>): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
tooltipBody: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipPath: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipHintRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipHintText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -136,7 +136,6 @@ describe("useFileLink", () => {
|
||||
expect(result.current).toBe(first);
|
||||
expect(result.current.onHoverIn).toBe(first.onHoverIn);
|
||||
expect(result.current.onPress).toBe(first.onPress);
|
||||
expect(result.current.onAuxPress).toBe(first.onAuxPress);
|
||||
expect(result.current.open).toBe(first.open);
|
||||
});
|
||||
|
||||
@@ -177,7 +176,7 @@ describe("useFileLink", () => {
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
disposition: "main",
|
||||
disposition: "side",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface UseFileLinkResult {
|
||||
target: InlinePathTarget | null;
|
||||
onHoverIn: () => void;
|
||||
onPress: () => void;
|
||||
onAuxPress: () => void;
|
||||
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
}
|
||||
|
||||
@@ -118,9 +117,6 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
});
|
||||
|
||||
const onPress = useStableEvent(() => {
|
||||
open(stableSource, "main");
|
||||
});
|
||||
const onAuxPress = useStableEvent(() => {
|
||||
open(stableSource, "side");
|
||||
});
|
||||
|
||||
@@ -131,10 +127,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
return query.data ?? null;
|
||||
}, [query.data, resolution]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
|
||||
[target, onHoverIn, onPress, onAuxPress, open],
|
||||
);
|
||||
return useMemo(() => ({ target, onHoverIn, onPress, open }), [target, onHoverIn, onPress, open]);
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
|
||||
|
||||
@@ -13,18 +13,8 @@ import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestPaneError,
|
||||
PullRequestPaneSkeleton,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
|
||||
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
@@ -35,17 +25,14 @@ import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/util
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
||||
import { PullRequestContent } from "@/panels/pull-request";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
@@ -318,31 +305,19 @@ function ExplorerSidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
enabled: canQueryPullRequest && isOpen,
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
isGit,
|
||||
requested: activeTab === "pr",
|
||||
enabled: isOpen,
|
||||
timelineEnabled: activeTab === "pr",
|
||||
});
|
||||
const hasPullRequest = prPane.prNumber !== null;
|
||||
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const handlePrRetry = useCallback(() => {
|
||||
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -436,10 +411,9 @@ function ExplorerSidebarContent({
|
||||
{resolvedTab === "pr" && (
|
||||
<PrTabContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
prPane={prPane}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
onRetry={handlePrRetry}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -447,42 +421,6 @@ function ExplorerSidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared add-to-chat state for the changes/files panes: both expose an "add file
|
||||
* to chat" action that attaches the file to the focused chat's composer.
|
||||
* Available only when a workspace with a focused chat is available.
|
||||
*/
|
||||
function useAddFileToChat({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
|
||||
const workspaceKey = workspaceId
|
||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
||||
: null;
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const focusedChat = useMemo(
|
||||
() => resolveFocusedChatTarget({ serverId, layout }),
|
||||
[serverId, layout],
|
||||
);
|
||||
const addFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!focusedChat || !workspaceKey) {
|
||||
return;
|
||||
}
|
||||
void useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: focusedChat.draftKey,
|
||||
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||
});
|
||||
focusTab(workspaceKey, focusedChat.tabId);
|
||||
},
|
||||
[focusTab, focusedChat, workspaceKey],
|
||||
);
|
||||
return { addFile, canAddToChat: focusedChat !== null };
|
||||
}
|
||||
|
||||
function ChangedFilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -496,6 +434,7 @@ function ChangedFilesPane({
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<GitDiffPane
|
||||
host="explorer"
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
@@ -524,37 +463,7 @@ function FilesPane({
|
||||
);
|
||||
}
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
workspaceAttachmentScopeKey: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function PrTabContent({
|
||||
serverId,
|
||||
cwd,
|
||||
prPane,
|
||||
workspaceAttachmentScopeKey,
|
||||
onRetry,
|
||||
}: PrTabContentProps) {
|
||||
if (prPane.data) {
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
data={prPane.data}
|
||||
activityLoading={prPane.activityLoading}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prPane.error) {
|
||||
return <PullRequestPaneError onRetry={onRetry} />;
|
||||
}
|
||||
return <PullRequestPaneSkeleton />;
|
||||
}
|
||||
const PrTabContent = PullRequestContent;
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
|
||||
@@ -1459,7 +1459,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
fileLinkActions.open({ href: url }, "main");
|
||||
fileLinkActions.open({ href: url }, "side");
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startResizeHandleDrag } from "@/components/resize-handle-drag";
|
||||
|
||||
describe("startResizeHandleDrag", () => {
|
||||
it("previews pointer frames and commits only the final size", () => {
|
||||
const preview = vi.fn();
|
||||
const commit = vi.fn();
|
||||
const drag = startResizeHandleDrag({
|
||||
sizes: [0.5, 0.5],
|
||||
index: 0,
|
||||
preview,
|
||||
commit,
|
||||
});
|
||||
|
||||
drag.move(0.05);
|
||||
drag.move(0.1);
|
||||
|
||||
expect(preview).toHaveBeenCalledTimes(2);
|
||||
expect(preview.mock.calls[0]?.[0][0]).toBeCloseTo(0.55, 10);
|
||||
expect(preview.mock.calls[0]?.[0][1]).toBeCloseTo(0.45, 10);
|
||||
expect(preview.mock.calls[1]?.[0][0]).toBeCloseTo(0.6, 10);
|
||||
expect(preview.mock.calls[1]?.[0][1]).toBeCloseTo(0.4, 10);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
drag.finish();
|
||||
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
expect(commit.mock.calls[0]?.[0][0]).toBeCloseTo(0.6, 10);
|
||||
expect(commit.mock.calls[0]?.[0][1]).toBeCloseTo(0.4, 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
interface StartResizeHandleDragInput {
|
||||
sizes: number[];
|
||||
index: number;
|
||||
preview: (sizes: number[]) => void;
|
||||
commit: (sizes: number[]) => void;
|
||||
}
|
||||
|
||||
export interface ResizeHandleDrag {
|
||||
move: (deltaRatio: number) => void;
|
||||
finish: () => void;
|
||||
}
|
||||
|
||||
export function startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview,
|
||||
commit,
|
||||
}: StartResizeHandleDragInput): ResizeHandleDrag {
|
||||
let pendingSizes: number[] | null = null;
|
||||
|
||||
return {
|
||||
move(deltaRatio) {
|
||||
pendingSizes = computeResizeHandleSizes({ sizes, index, deltaRatio });
|
||||
preview(pendingSizes);
|
||||
},
|
||||
finish() {
|
||||
if (pendingSizes) {
|
||||
commit(pendingSizes);
|
||||
pendingSizes = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
testID?: string;
|
||||
direction: "horizontal" | "vertical";
|
||||
groupId: string;
|
||||
index: number;
|
||||
sizes: number[];
|
||||
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
}
|
||||
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
drag: ResizeHandleDrag;
|
||||
}
|
||||
|
||||
function resetWindowHorizontalScroll() {
|
||||
@@ -25,10 +28,12 @@ function resetWindowHorizontalScroll() {
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
testID,
|
||||
direction,
|
||||
groupId,
|
||||
index,
|
||||
sizes,
|
||||
onPreviewResizeSplit,
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -68,6 +73,12 @@ export function ResizeHandle({
|
||||
containerSize,
|
||||
pointerStart:
|
||||
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
|
||||
drag: startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||
}),
|
||||
});
|
||||
|
||||
if (pointerStatesRef.current.size === 1) {
|
||||
@@ -113,14 +124,7 @@ export function ResizeHandle({
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
onResizeSplit(
|
||||
groupId,
|
||||
computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
}),
|
||||
);
|
||||
pointerState.drag.move(deltaRatio);
|
||||
}
|
||||
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
@@ -128,6 +132,7 @@ export function ResizeHandle({
|
||||
return;
|
||||
}
|
||||
|
||||
pointerStatesRef.current.get(pointerId)?.drag.finish();
|
||||
cleanup();
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ export function ResizeHandle({
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes],
|
||||
[direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
@@ -181,8 +186,14 @@ export function ResizeHandle({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={handleStyle}>
|
||||
{highlighted && <View pointerEvents="none" style={highlightStyle} />}
|
||||
<View style={handleStyle} testID={testID}>
|
||||
{highlighted && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={highlightStyle}
|
||||
testID={testID ? `${testID}-highlight` : undefined}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
role="separator"
|
||||
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react";
|
||||
import { memo, useMemo, useCallback, useState, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
|
||||
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
@@ -28,11 +27,7 @@ import {
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
|
||||
// SCRIM_SOLID_OFFSET rightward, which keeps the kebab itself off the gradient entirely.
|
||||
const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
@@ -45,38 +40,6 @@ const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
|
||||
/**
|
||||
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
|
||||
* without rendering them, so wrapping Stop itself in withUnistyles hides the color from it and
|
||||
* the native gradient silently falls back to black. Theme the whole SVG instead and keep real
|
||||
* Stop elements as direct children of the gradient.
|
||||
*/
|
||||
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||
return (
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
{/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward
|
||||
`transparent` goes through black in some engines and leaves a grey fringe. */}
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||
|
||||
const scrimColorMappings: Record<SidebarSurfaceBackdrop, (theme: Theme) => { color: string }> = {
|
||||
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
|
||||
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
|
||||
surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }),
|
||||
surface2: (theme) => ({ color: theme.colors.surface2 }),
|
||||
};
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
isDragging = false,
|
||||
@@ -365,13 +328,6 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
trailingActionScrim: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: SCRIM_WIDTH,
|
||||
},
|
||||
}));
|
||||
|
||||
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
||||
@@ -476,40 +432,14 @@ export function SidebarWorkspaceTrailingActionOverlay({
|
||||
if (!visible || !children) return null;
|
||||
return (
|
||||
<>
|
||||
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
|
||||
{scrimBackdrop ? (
|
||||
<TrailingActionScrim backdrop={scrimBackdrop} testID="sidebar-workspace-trailing-scrim" />
|
||||
) : null}
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's own background, faded in from the right, sitting between the diff stat and the
|
||||
* kebab. The kebab lands on fully opaque background while the diff dissolves underneath it
|
||||
* rather than blinking out — hiding the diff outright was the old behavior and it cost a
|
||||
* visible flicker on every hover.
|
||||
*
|
||||
* Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose:
|
||||
* the fade has to start before the diff stat does or the diff's left edge cuts off hard.
|
||||
*/
|
||||
function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) {
|
||||
// useId's output contains characters that are not legal inside url(#...) — React 19 wraps
|
||||
// ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all.
|
||||
// Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
|
||||
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
return (
|
||||
<View
|
||||
style={sidebarWorkspaceRowStyles.trailingActionScrim}
|
||||
pointerEvents="none"
|
||||
testID="sidebar-workspace-trailing-scrim"
|
||||
>
|
||||
<ThemedTrailingActionScrimSvg
|
||||
gradientId={gradientId}
|
||||
uniProps={scrimColorMappings[backdrop]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowContent: {
|
||||
position: "relative",
|
||||
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { View, Text } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
@@ -139,6 +144,9 @@ interface SplitPaneDropData {
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
const EMPTY_SPLIT_NODES: SplitNode[] = [];
|
||||
const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
@@ -721,9 +729,68 @@ function DragOverlayTabChipInner({
|
||||
);
|
||||
}
|
||||
|
||||
function SplitGroupChild({ flex, children }: { flex: number; children: ReactNode }) {
|
||||
const childStyle = useMemo(() => [styles.groupChild, { flex }], [flex]);
|
||||
return <View style={childStyle}>{children}</View>;
|
||||
function SplitGroupChild({
|
||||
resizeFlex,
|
||||
index,
|
||||
hidden,
|
||||
children,
|
||||
}: {
|
||||
resizeFlex: SharedValue<number[]>;
|
||||
index: number;
|
||||
hidden: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const resizeStyle = useAnimatedStyle(() => ({
|
||||
flexGrow: resizeFlex.value[index] ?? 0,
|
||||
}));
|
||||
const childStyle = useMemo(
|
||||
() => [
|
||||
styles.groupChild,
|
||||
{
|
||||
flexShrink: hidden ? 0 : 1,
|
||||
flexBasis: 0,
|
||||
...(hidden ? { width: 0, height: 0 } : {}),
|
||||
},
|
||||
],
|
||||
[hidden],
|
||||
);
|
||||
return (
|
||||
<Animated.View
|
||||
style={[childStyle, resizeStyle]}
|
||||
testID={hidden ? "split-group-child-hidden" : "split-group-child"}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex grow per child, renormalized so the visible ones always sum to 1.
|
||||
*
|
||||
* `sizes` are fractions, so a two-pane group is `[0.5, 0.5]`. Hiding one child drops the group's
|
||||
* total grow factor to 0.5, and CSS hands children that sum to less than 1 only that fraction of
|
||||
* the free space — the other half of the row is simply left empty. Renormalizing is what makes a
|
||||
* hidden pane give its space back instead of just going invisible. The stored `sizes` are never
|
||||
* touched, so unhiding restores the width the user dragged to.
|
||||
*/
|
||||
function resolveVisibleGroupFlex(children: SplitNode[], sizes: number[]): number[] {
|
||||
const visibleTotal = children.reduce(
|
||||
(total, child, index) => (isSplitNodeHidden(child) ? total : total + (sizes[index] ?? 1)),
|
||||
0,
|
||||
);
|
||||
if (visibleTotal <= 0) {
|
||||
return children.map(() => 0);
|
||||
}
|
||||
return children.map((child, index) =>
|
||||
isSplitNodeHidden(child) ? 0 : (sizes[index] ?? 1) / visibleTotal,
|
||||
);
|
||||
}
|
||||
|
||||
function isSplitNodeHidden(node: SplitNode): boolean {
|
||||
if (node.kind === "pane") {
|
||||
return node.pane.hidden === true;
|
||||
}
|
||||
return node.group.children.every(isSplitNodeHidden);
|
||||
}
|
||||
|
||||
function SplitNodeView({
|
||||
@@ -773,6 +840,23 @@ function SplitNodeView({
|
||||
const storedGroupSizes = useWorkspaceLayoutStore((state) =>
|
||||
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
|
||||
);
|
||||
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
|
||||
const groupSizes =
|
||||
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
|
||||
const visibleFlex = useMemo(
|
||||
() => resolveVisibleGroupFlex(groupChildren, groupSizes),
|
||||
[groupChildren, groupSizes],
|
||||
);
|
||||
const resizeFlex = useSharedValue(visibleFlex);
|
||||
useEffect(() => {
|
||||
resizeFlex.value = visibleFlex;
|
||||
}, [resizeFlex, visibleFlex]);
|
||||
const previewResizeSplit = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes);
|
||||
},
|
||||
[groupChildren, resizeFlex],
|
||||
);
|
||||
|
||||
const groupStyle = useMemo(
|
||||
() => [
|
||||
@@ -784,56 +868,56 @@ function SplitNodeView({
|
||||
|
||||
if (node.kind === "pane") {
|
||||
return (
|
||||
<WindowChromeRegion corners={windowChromeCorners}>
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
<RetainedPanel active={node.pane.hidden !== true}>
|
||||
<WindowChromeRegion corners={windowChromeCorners}>
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
</RetainedPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const groupSizes = storedGroupSizes ?? node.group.sizes;
|
||||
|
||||
return (
|
||||
<View style={groupStyle}>
|
||||
{node.group.children.map((child, index) => (
|
||||
<Fragment key={getNodeKey(child)}>
|
||||
<SplitGroupChild flex={groupSizes[index] ?? 1}>
|
||||
<SplitGroupChild resizeFlex={resizeFlex} index={index} hidden={isSplitNodeHidden(child)}>
|
||||
<SplitNodeView
|
||||
node={child}
|
||||
workspaceKey={workspaceKey}
|
||||
@@ -876,12 +960,16 @@ function SplitNodeView({
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
{index < node.group.children.length - 1 &&
|
||||
!isSplitNodeHidden(child) &&
|
||||
!isSplitNodeHidden(node.group.children[index + 1]) ? (
|
||||
<ResizeHandle
|
||||
testID="workspace-split-resize-handle"
|
||||
direction={node.group.direction}
|
||||
groupId={node.group.id}
|
||||
index={index}
|
||||
sizes={groupSizes}
|
||||
onPreviewResizeSplit={previewResizeSplit}
|
||||
onResizeSplit={onResizeSplit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
acceptTreeRailContainerMeasurement,
|
||||
resolveVisibleTreeRailWidth,
|
||||
} from "@/components/tree-rail-layout";
|
||||
|
||||
describe("tree rail layout", () => {
|
||||
it("keeps the last usable geometry while a retained panel is hidden", () => {
|
||||
const visibleContainerWidth = 900;
|
||||
const hiddenContainerWidth = acceptTreeRailContainerMeasurement(visibleContainerWidth, 0);
|
||||
|
||||
expect(hiddenContainerWidth).toBe(visibleContainerWidth);
|
||||
expect(resolveVisibleTreeRailWidth(420, hiddenContainerWidth)).toBe(420);
|
||||
});
|
||||
|
||||
it("clamps the rail after a usable narrow-container measurement", () => {
|
||||
const containerWidth = acceptTreeRailContainerMeasurement(900, 500);
|
||||
|
||||
expect(resolveVisibleTreeRailWidth(420, containerWidth)).toBe(260);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
|
||||
|
||||
const MIN_CONTENT_WIDTH = 240;
|
||||
|
||||
export function resolveVisibleTreeRailWidth(
|
||||
requestedWidth: number,
|
||||
containerWidth: number,
|
||||
): number {
|
||||
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
|
||||
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
|
||||
}
|
||||
|
||||
export function acceptTreeRailContainerMeasurement(
|
||||
currentWidth: number,
|
||||
measuredWidth: number,
|
||||
): number {
|
||||
return measuredWidth > 0 ? measuredWidth : currentWidth;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import {
|
||||
acceptTreeRailContainerMeasurement,
|
||||
resolveVisibleTreeRailWidth,
|
||||
} from "@/components/tree-rail-layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
export interface TreeRailProps {
|
||||
/**
|
||||
* Exactly two children, in order: content, then tree.
|
||||
*
|
||||
* Slots are children rather than `content` / `tree` props because `react-perf/jsx-no-jsx-as-prop`
|
||||
* rejects JSX in a prop, including via a local variable. Pass both unconditionally — a `{cond &&
|
||||
* …}` slot collapses out of the array and silently promotes the tree into the content position.
|
||||
* Branch around the whole rail instead, the way `diff-panel.tsx` does.
|
||||
*/
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
|
||||
export function TreeRail({ children, testID }: TreeRailProps) {
|
||||
const [content, tree] = Children.toArray(children);
|
||||
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
|
||||
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const [containerWidth, setContainerWidth] = useState(viewportWidth);
|
||||
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
|
||||
const startWidthRef = useRef(visibleWidth);
|
||||
const resizeWidth = useSharedValue(visibleWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleWidth;
|
||||
}, [resizeWidth, visibleWidth]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onBegin(() => scheduleOnRN(showResizeGrip))
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleWidth + event.translationX;
|
||||
resizeWidth.value = visibleWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
resizeWidth.value = resolveVisibleTreeRailWidth(
|
||||
startWidthRef.current - event.translationX,
|
||||
containerWidth,
|
||||
);
|
||||
})
|
||||
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
|
||||
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
|
||||
);
|
||||
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number } } }) =>
|
||||
setContainerWidth((currentWidth) =>
|
||||
acceptTreeRailContainerMeasurement(currentWidth, event.nativeEvent.layout.width),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID={testID} onLayout={handleLayout}>
|
||||
<View style={styles.content} testID={testID ? `${testID}-content` : undefined}>
|
||||
{content}
|
||||
</View>
|
||||
<Animated.View
|
||||
style={[styles.rail, railWidthStyle]}
|
||||
testID={testID ? `${testID}-tree` : undefined}
|
||||
>
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID={testID ? `${testID}-resize-handle` : "tree-rail-resize-handle"}
|
||||
/>
|
||||
{tree}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
flexDirection: "row",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rail: {
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useId } from "react";
|
||||
import { View } from "react-native";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
|
||||
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||
return (
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
|
||||
some engines and leaves a grey fringe. */}
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||
|
||||
const backdropColorMappings: Record<SurfaceBackdrop, (theme: Theme) => { color: string }> = {
|
||||
surface0: (theme) => ({ color: theme.colors.surface0 }),
|
||||
surface1: (theme) => ({ color: theme.colors.surface1 }),
|
||||
surface2: (theme) => ({ color: theme.colors.surface2 }),
|
||||
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
|
||||
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
|
||||
surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }),
|
||||
};
|
||||
|
||||
/** Fades trailing content into the surface beneath an absolutely overlaid action. */
|
||||
export function TrailingActionScrim({
|
||||
backdrop,
|
||||
testID,
|
||||
}: {
|
||||
backdrop: SurfaceBackdrop;
|
||||
testID?: string;
|
||||
}) {
|
||||
// React-generated ids contain characters that are invalid inside SVG fragment references.
|
||||
const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
return (
|
||||
<View style={styles.scrim} pointerEvents="none" testID={testID}>
|
||||
<ThemedTrailingActionScrimSvg
|
||||
gradientId={gradientId}
|
||||
uniProps={backdropColorMappings[backdrop]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrim: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: SCRIM_WIDTH,
|
||||
},
|
||||
});
|
||||
@@ -46,4 +46,32 @@ describe("focused chat target", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("targets a utility tab's parent chat", () => {
|
||||
const layout = {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "pane-1",
|
||||
tabIds: ["agent-tab", "changes-tab"],
|
||||
focusedTabId: "changes-tab",
|
||||
tabs: [
|
||||
{
|
||||
tabId: "agent-tab",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
{ tabId: "changes-tab", target: { kind: "working_diff" }, createdAt: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "pane-1",
|
||||
parentTabIdByTabId: { "changes-tab": "agent-tab" },
|
||||
} as WorkspaceLayout;
|
||||
|
||||
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
|
||||
tabId: "agent-tab",
|
||||
draftKey: "agent:server-1:agent-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,29 @@ export interface FocusedChatTarget {
|
||||
draftKey: string;
|
||||
}
|
||||
|
||||
function resolveChatTab(
|
||||
serverId: string,
|
||||
tab: ReturnType<typeof collectAllTabs>[number] | undefined,
|
||||
): FocusedChatTarget | null {
|
||||
if (tab?.target.kind === "agent") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({ serverId, agentId: tab.target.agentId }),
|
||||
};
|
||||
}
|
||||
if (tab?.target.kind === "draft") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: tab.tabId,
|
||||
draftId: tab.target.draftId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveFocusedChatTarget(input: {
|
||||
serverId: string;
|
||||
layout: WorkspaceLayout | undefined;
|
||||
@@ -22,27 +45,15 @@ export function resolveFocusedChatTarget(input: {
|
||||
if (!focusedTabId) {
|
||||
return null;
|
||||
}
|
||||
const tab = collectAllTabs(input.layout.root).find(
|
||||
(candidate) => candidate.tabId === focusedTabId,
|
||||
const tabs = collectAllTabs(input.layout.root);
|
||||
const tab = tabs.find((candidate) => candidate.tabId === focusedTabId);
|
||||
const focusedChat = resolveChatTab(input.serverId, tab);
|
||||
if (focusedChat) {
|
||||
return focusedChat;
|
||||
}
|
||||
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
|
||||
return resolveChatTab(
|
||||
input.serverId,
|
||||
tabs.find((candidate) => candidate.tabId === parentTabId),
|
||||
);
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId: input.serverId,
|
||||
agentId: tab.tabId,
|
||||
draftId: tab.target.draftId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 48,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
@@ -66,6 +67,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
message: {
|
||||
flex: 1,
|
||||
flexBasis: 180,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
@@ -80,6 +82,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
actions: {
|
||||
flexShrink: 0,
|
||||
marginLeft: "auto",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { TreeRail } from "@/components/tree-rail";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -1575,6 +1576,7 @@ interface GitDiffPaneProps {
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
host: "explorer" | "panel";
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
@@ -1660,6 +1662,29 @@ interface ChangesTabToggleProps {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function resolveChangesTabOpen(host: "explorer" | "panel", changesTabOpen: boolean): boolean {
|
||||
return host === "explorer" ? changesTabOpen : false;
|
||||
}
|
||||
|
||||
function resolveChangesFilePress(
|
||||
host: "explorer" | "panel",
|
||||
onChangesFilePress: ((path?: string) => void) | undefined,
|
||||
): ((path?: string) => void) | undefined {
|
||||
return host === "explorer" ? onChangesFilePress : undefined;
|
||||
}
|
||||
|
||||
function ChangesTabToggleForHost({
|
||||
host,
|
||||
isMobile,
|
||||
selected,
|
||||
onPress,
|
||||
}: ChangesTabToggleProps & { host: "explorer" | "panel" }) {
|
||||
if (host === "panel") {
|
||||
return null;
|
||||
}
|
||||
return <ChangesTabToggle isMobile={isMobile} selected={selected} onPress={onPress} />;
|
||||
}
|
||||
|
||||
interface DiffModeMenuProps {
|
||||
diffMode: "uncommitted" | "base";
|
||||
committedDescription?: string;
|
||||
@@ -1749,17 +1774,21 @@ function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps
|
||||
interface DiffViewModeToggleProps {
|
||||
viewMode: "flat" | "tree";
|
||||
isMobile: boolean;
|
||||
toggleStyle: PressableStyleFn;
|
||||
toggleStyle?: PressableStyleFn;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function DiffViewModeToggle({
|
||||
export function DiffViewModeToggle({
|
||||
viewMode,
|
||||
isMobile,
|
||||
toggleStyle,
|
||||
onToggle,
|
||||
}: DiffViewModeToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const defaultToggleStyle = useMemo(
|
||||
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
|
||||
[viewMode],
|
||||
);
|
||||
const label =
|
||||
viewMode === "flat"
|
||||
? t("workspace.git.diff.showTreeView")
|
||||
@@ -1771,7 +1800,7 @@ function DiffViewModeToggle({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
testID="changes-toggle-view-mode"
|
||||
style={toggleStyle}
|
||||
style={toggleStyle ?? defaultToggleStyle}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{viewMode === "flat" ? (
|
||||
@@ -2140,6 +2169,8 @@ interface SharedDiffViewProps {
|
||||
};
|
||||
}
|
||||
|
||||
type WorkingTreeDiffMode = Extract<SharedDiffViewProps["mode"], { kind: "working_tree" }>;
|
||||
|
||||
export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences;
|
||||
@@ -2929,6 +2960,81 @@ function useChangesTreeState({
|
||||
};
|
||||
}
|
||||
|
||||
export function ChangedFilesTree({
|
||||
workspaceId,
|
||||
cwd,
|
||||
files,
|
||||
displayPreferences,
|
||||
workingTreeMode,
|
||||
}: {
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
files: ParsedDiffFile[];
|
||||
displayPreferences: SharedDiffViewProps["displayPreferences"];
|
||||
workingTreeMode: WorkingTreeDiffMode;
|
||||
}) {
|
||||
const treeState = useChangesTreeState({
|
||||
workspaceId,
|
||||
cwd,
|
||||
files,
|
||||
viewMode: "tree",
|
||||
changesTabOpen: false,
|
||||
onViewModeChange: () => {},
|
||||
});
|
||||
const mode = useMemo(
|
||||
() => ({
|
||||
...workingTreeMode,
|
||||
viewMode: "tree" as const,
|
||||
expandedPaths: EMPTY_PATH_LIST,
|
||||
collapsedFolders: treeState.collapsedFolders,
|
||||
onExpandedPathsChange: () => {},
|
||||
onCollapsedFoldersChange: treeState.updateCollapsedFolders,
|
||||
}),
|
||||
[treeState.collapsedFolders, treeState.updateCollapsedFolders, workingTreeMode],
|
||||
);
|
||||
return <SharedDiffView files={files} displayPreferences={displayPreferences} mode={mode} />;
|
||||
}
|
||||
|
||||
function GitDiffTreeRail({
|
||||
shown,
|
||||
children,
|
||||
workspaceId,
|
||||
cwd,
|
||||
files,
|
||||
displayPreferences,
|
||||
workingTreeMode,
|
||||
}: {
|
||||
shown: boolean;
|
||||
children: ReactElement;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
files: ParsedDiffFile[];
|
||||
displayPreferences: SharedDiffViewProps["displayPreferences"];
|
||||
workingTreeMode: WorkingTreeDiffMode;
|
||||
}) {
|
||||
if (!shown) return children;
|
||||
return (
|
||||
<TreeRail testID="changes-tree-rail">
|
||||
{children}
|
||||
<ChangedFilesTree
|
||||
workspaceId={workspaceId}
|
||||
cwd={cwd}
|
||||
files={files}
|
||||
displayPreferences={displayPreferences}
|
||||
workingTreeMode={workingTreeMode}
|
||||
/>
|
||||
</TreeRail>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWorkingTreeMode<T>(isMobile: boolean, mobileMode: T, desktopMode: T): T {
|
||||
return isMobile ? mobileMode : desktopMode;
|
||||
}
|
||||
|
||||
function shouldShowTreeRail(viewMode: "flat" | "tree", isMobile: boolean): boolean {
|
||||
return viewMode === "tree" && !isMobile;
|
||||
}
|
||||
|
||||
function useDiffTabNavigation({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -3000,6 +3106,7 @@ export function GitDiffPane({
|
||||
workspaceId,
|
||||
cwd,
|
||||
enabled,
|
||||
host,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
}: GitDiffPaneProps) {
|
||||
@@ -3049,11 +3156,13 @@ export function GitDiffPane({
|
||||
});
|
||||
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
|
||||
const {
|
||||
changesTabOpen,
|
||||
changesTabOpen: workspaceChangesTabOpen,
|
||||
toggleChanges: handleToggleChangesTab,
|
||||
openCommit: handleCommitPress,
|
||||
onChangesFilePress,
|
||||
onChangesFilePress: workspaceOnChangesFilePress,
|
||||
} = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile });
|
||||
const changesTabOpen = resolveChangesTabOpen(host, workspaceChangesTabOpen);
|
||||
const onChangesFilePress = resolveChangesFilePress(host, workspaceOnChangesFilePress);
|
||||
const refreshSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
@@ -3283,7 +3392,16 @@ export function GitDiffPane({
|
||||
},
|
||||
);
|
||||
|
||||
const bodyContent: ReactElement = (
|
||||
const flatWorkingTreeMode = useMemo(
|
||||
() => ({ ...workingTreeMode, viewMode: "flat" as const }),
|
||||
[workingTreeMode],
|
||||
);
|
||||
const primaryWorkingTreeMode = resolveWorkingTreeMode(
|
||||
isMobile,
|
||||
workingTreeMode,
|
||||
flatWorkingTreeMode,
|
||||
);
|
||||
const diffContent: ReactElement = (
|
||||
<DiffBodyContent
|
||||
isStatusLoading={isStatusLoading}
|
||||
statusErrorMessage={statusErrorMessage}
|
||||
@@ -3299,10 +3417,22 @@ export function GitDiffPane({
|
||||
<SharedDiffView
|
||||
files={files}
|
||||
displayPreferences={sharedDisplayPreferences}
|
||||
mode={workingTreeMode}
|
||||
mode={primaryWorkingTreeMode}
|
||||
/>
|
||||
</DiffBodyContent>
|
||||
);
|
||||
const bodyContent = (
|
||||
<GitDiffTreeRail
|
||||
shown={shouldShowTreeRail(viewMode, isMobile)}
|
||||
workspaceId={workspaceId}
|
||||
cwd={cwd}
|
||||
files={files}
|
||||
displayPreferences={sharedDisplayPreferences}
|
||||
workingTreeMode={workingTreeMode}
|
||||
>
|
||||
{diffContent}
|
||||
</GitDiffTreeRail>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -3335,7 +3465,8 @@ export function GitDiffPane({
|
||||
onSelectBase={handleSelectBase}
|
||||
/>
|
||||
<View style={styles.diffStatusButtons}>
|
||||
<ChangesTabToggle
|
||||
<ChangesTabToggleForHost
|
||||
host={host}
|
||||
isMobile={isMobile}
|
||||
selected={changesTabOpen}
|
||||
onPress={handleToggleChangesTab}
|
||||
|
||||
@@ -129,6 +129,15 @@ describe("translation resources", () => {
|
||||
expect(countMatchingEnglishStrings(zhCN)).toBeLessThan(maxFallbackStrings);
|
||||
});
|
||||
|
||||
it("localizes the pull request empty state in every supported language", () => {
|
||||
for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) {
|
||||
expect(resource.panels.pullRequest.emptyTitle).not.toBe(en.panels.pullRequest.emptyTitle);
|
||||
expect(resource.panels.pullRequest.emptyDescription).not.toBe(
|
||||
en.panels.pullRequest.emptyDescription,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves interpolation placeholders in every language", () => {
|
||||
expect(findInterpolationMismatches(ar)).toEqual([]);
|
||||
expect(findInterpolationMismatches(es)).toEqual([]);
|
||||
|
||||
@@ -587,6 +587,7 @@ export const ar: TranslationResources = {
|
||||
renameAgent: "إعادة تسمية الوكيل",
|
||||
},
|
||||
actions: {
|
||||
newTab: "علامة تبويب جديدة",
|
||||
newAgent: "وكيل جديد",
|
||||
newTerminal: "محطة جديدة",
|
||||
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
|
||||
@@ -595,6 +596,9 @@ export const ar: TranslationResources = {
|
||||
exitFocusMode: "إنهاء وضع التركيز",
|
||||
splitRight: "تقسيم الجزء الأيمن",
|
||||
splitDown: "تقسيم الجزء لأسفل",
|
||||
changes: "التغييرات",
|
||||
files: "الملفات",
|
||||
pullRequest: "طلب السحب",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
pinTarget: "تثبيت",
|
||||
@@ -1658,6 +1662,17 @@ export const ar: TranslationResources = {
|
||||
reloadMessage: "ستفقد تغييراتك المحلية.",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "الملفات",
|
||||
subtitle: "ملفات مساحة العمل",
|
||||
tooltip: "تصفح ملفات مساحة العمل",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "طلب السحب",
|
||||
subtitle: "تفاصيل طلب السحب",
|
||||
emptyTitle: "لا يوجد طلب سحب بعد",
|
||||
emptyDescription: "أنشئ طلب سحب لنسخة العمل هذه لعرض تفاصيله هنا.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "التغييرات",
|
||||
changesSubtitle: "فروقات شجرة العمل",
|
||||
|
||||
@@ -585,6 +585,7 @@ export const en = {
|
||||
renameAgent: "Rename agent",
|
||||
},
|
||||
actions: {
|
||||
newTab: "New tab",
|
||||
newAgent: "New agent",
|
||||
newTerminal: "New terminal",
|
||||
preparingTerminal: "Preparing terminal tab",
|
||||
@@ -593,6 +594,9 @@ export const en = {
|
||||
exitFocusMode: "Exit focus mode",
|
||||
splitRight: "Split pane right",
|
||||
splitDown: "Split pane down",
|
||||
changes: "Changes",
|
||||
files: "Files",
|
||||
pullRequest: "Pull request",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
pinTarget: "Pin",
|
||||
@@ -1668,6 +1672,17 @@ export const en = {
|
||||
reloadMessage: "Your local changes will be lost.",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "Files",
|
||||
subtitle: "Workspace files",
|
||||
tooltip: "Browse workspace files",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Pull request",
|
||||
subtitle: "Pull request details",
|
||||
emptyTitle: "No pull request yet",
|
||||
emptyDescription: "Create a pull request for this checkout to see its details here.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Changes",
|
||||
changesSubtitle: "Working tree diff",
|
||||
|
||||
@@ -592,6 +592,7 @@ export const es: TranslationResources = {
|
||||
renameAgent: "Cambiar nombre del agente",
|
||||
},
|
||||
actions: {
|
||||
newTab: "Nueva pestaña",
|
||||
newAgent: "Nuevo agente",
|
||||
newTerminal: "Nueva terminal",
|
||||
preparingTerminal: "Preparando la pestaña del terminal",
|
||||
@@ -600,6 +601,9 @@ export const es: TranslationResources = {
|
||||
exitFocusMode: "Salir del modo de concentración",
|
||||
splitRight: "Panel dividido a la derecha",
|
||||
splitDown: "Dividir panel hacia abajo",
|
||||
changes: "Cambios",
|
||||
files: "Archivos",
|
||||
pullRequest: "Solicitud de extracción",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
pinTarget: "Fijar",
|
||||
@@ -1701,6 +1705,17 @@ export const es: TranslationResources = {
|
||||
reloadMessage: "Se perderán tus cambios locales.",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "Archivos",
|
||||
subtitle: "Archivos del espacio de trabajo",
|
||||
tooltip: "Explorar archivos del espacio de trabajo",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Solicitud de extracción",
|
||||
subtitle: "Detalles de la solicitud de extracción",
|
||||
emptyTitle: "Aún no hay ninguna solicitud de extracción",
|
||||
emptyDescription: "Crea una solicitud para este checkout y consulta aquí sus detalles.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Cambios",
|
||||
changesSubtitle: "Diferencias del árbol de trabajo",
|
||||
|
||||
@@ -592,6 +592,7 @@ export const fr: TranslationResources = {
|
||||
renameAgent: "Renommer l'agent",
|
||||
},
|
||||
actions: {
|
||||
newTab: "Nouvel onglet",
|
||||
newAgent: "Nouvel agent",
|
||||
newTerminal: "Nouveau terminal",
|
||||
preparingTerminal: "Préparation de l'onglet du terminal",
|
||||
@@ -600,6 +601,9 @@ export const fr: TranslationResources = {
|
||||
exitFocusMode: "Quitter le mode concentration",
|
||||
splitRight: "Volet divisé à droite",
|
||||
splitDown: "Diviser le volet vers le bas",
|
||||
changes: "Modifications",
|
||||
files: "Fichiers",
|
||||
pullRequest: "Demande de fusion",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
pinTarget: "Épingler",
|
||||
@@ -1705,6 +1709,18 @@ export const fr: TranslationResources = {
|
||||
reloadMessage: "Vos modifications locales seront perdues.",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "Fichiers",
|
||||
subtitle: "Fichiers de l’espace de travail",
|
||||
tooltip: "Parcourir les fichiers de l’espace de travail",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Demande de fusion",
|
||||
subtitle: "Détails de la demande de fusion",
|
||||
emptyTitle: "Aucune demande de fusion pour le moment",
|
||||
emptyDescription:
|
||||
"Créez une demande de fusion pour cette copie de travail afin d’afficher ses détails ici.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Modifications",
|
||||
changesSubtitle: "Différences de l'arbre de travail",
|
||||
|
||||
@@ -592,6 +592,7 @@ export const ja: TranslationResources = {
|
||||
renameAgent: "エージェントの名前を変更",
|
||||
},
|
||||
actions: {
|
||||
newTab: "新しいタブ",
|
||||
newAgent: "新しいエージェント",
|
||||
newTerminal: "新しいターミナル",
|
||||
preparingTerminal: "ターミナルタブを準備中",
|
||||
@@ -600,6 +601,9 @@ export const ja: TranslationResources = {
|
||||
exitFocusMode: "フォーカスモードを終了",
|
||||
splitRight: "右にペインを分割",
|
||||
splitDown: "下にペインを分割",
|
||||
changes: "変更",
|
||||
files: "ファイル",
|
||||
pullRequest: "プルリクエスト",
|
||||
terminalProfilesMenu: "ターミナルプロファイル",
|
||||
editTerminalProfiles: "プロファイルを編集…",
|
||||
pinTarget: "ピン留め",
|
||||
@@ -1674,6 +1678,18 @@ export const ja: TranslationResources = {
|
||||
reloadMessage: "ローカルの変更は失われます。",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "ファイル",
|
||||
subtitle: "ワークスペースのファイル",
|
||||
tooltip: "ワークスペースのファイルを参照",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "プルリクエスト",
|
||||
subtitle: "プルリクエストの詳細",
|
||||
emptyTitle: "プルリクエストはまだありません",
|
||||
emptyDescription:
|
||||
"このチェックアウトのプルリクエストを作成すると、ここに詳細が表示されます。",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "変更",
|
||||
changesSubtitle: "作業ツリーの差分",
|
||||
|
||||
@@ -589,6 +589,7 @@ export const ko: TranslationResources = {
|
||||
renameAgent: "에이전트 이름 변경",
|
||||
},
|
||||
actions: {
|
||||
newTab: "새 탭",
|
||||
newAgent: "새 에이전트",
|
||||
newTerminal: "새 터미널",
|
||||
preparingTerminal: "터미널 탭 준비 중",
|
||||
@@ -597,6 +598,9 @@ export const ko: TranslationResources = {
|
||||
exitFocusMode: "집중 모드 종료",
|
||||
splitRight: "창을 오른쪽으로 분할",
|
||||
splitDown: "창을 아래로 분할",
|
||||
changes: "변경 사항",
|
||||
files: "파일",
|
||||
pullRequest: "풀 리퀘스트",
|
||||
terminalProfilesMenu: "터미널 프로필",
|
||||
editTerminalProfiles: "프로필 편집…",
|
||||
pinTarget: "고정",
|
||||
@@ -1668,6 +1672,17 @@ export const ko: TranslationResources = {
|
||||
reloadMessage: "로컬 변경사항이 손실됩니다.",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "파일",
|
||||
subtitle: "워크스페이스 파일",
|
||||
tooltip: "워크스페이스 파일 탐색",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "풀 리퀘스트",
|
||||
subtitle: "풀 리퀘스트 세부 정보",
|
||||
emptyTitle: "아직 풀 리퀘스트가 없습니다",
|
||||
emptyDescription: "이 체크아웃에 풀 리퀘스트를 만들면 세부 정보가 여기에 표시됩니다.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "변경 사항",
|
||||
changesSubtitle: "작업 트리 diff",
|
||||
|
||||
@@ -591,6 +591,7 @@ export const ptBR: TranslationResources = {
|
||||
renameAgent: "Renomear agente",
|
||||
},
|
||||
actions: {
|
||||
newTab: "Nova aba",
|
||||
newAgent: "Novo agente",
|
||||
newTerminal: "Novo terminal",
|
||||
preparingTerminal: "Preparando aba de terminal",
|
||||
@@ -599,6 +600,9 @@ export const ptBR: TranslationResources = {
|
||||
exitFocusMode: "Sair do modo de foco",
|
||||
splitRight: "Dividir painel à direita",
|
||||
splitDown: "Dividir painel abaixo",
|
||||
changes: "Alterações",
|
||||
files: "Arquivos",
|
||||
pullRequest: "Pull request",
|
||||
terminalProfilesMenu: "Perfis de terminal",
|
||||
editTerminalProfiles: "Editar perfis…",
|
||||
pinTarget: "Fixar",
|
||||
@@ -1687,6 +1691,17 @@ export const ptBR: TranslationResources = {
|
||||
reloadMessage: "Suas alterações locais serão perdidas.",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "Arquivos",
|
||||
subtitle: "Arquivos do espaço de trabalho",
|
||||
tooltip: "Explorar arquivos do espaço de trabalho",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Pull request",
|
||||
subtitle: "Detalhes do pull request",
|
||||
emptyTitle: "Ainda não há pull request",
|
||||
emptyDescription: "Crie um pull request para este checkout para ver os detalhes aqui.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Alterações",
|
||||
changesSubtitle: "Diff da árvore de trabalho",
|
||||
|
||||
@@ -591,6 +591,7 @@ export const ru: TranslationResources = {
|
||||
renameAgent: "Переименовать агента",
|
||||
},
|
||||
actions: {
|
||||
newTab: "Новая вкладка",
|
||||
newAgent: "Новый агент",
|
||||
newTerminal: "Новый терминал",
|
||||
preparingTerminal: "Подготовка вкладки терминала",
|
||||
@@ -599,6 +600,9 @@ export const ru: TranslationResources = {
|
||||
exitFocusMode: "Выйти из режима фокусировки",
|
||||
splitRight: "Разделить панель справа",
|
||||
splitDown: "Разделить панель вниз",
|
||||
changes: "Изменения",
|
||||
files: "Файлы",
|
||||
pullRequest: "Запрос на слияние",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
pinTarget: "Закрепить",
|
||||
@@ -1692,6 +1696,18 @@ export const ru: TranslationResources = {
|
||||
reloadMessage: "Локальные изменения будут потеряны.",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "Файлы",
|
||||
subtitle: "Файлы рабочего пространства",
|
||||
tooltip: "Просмотр файлов рабочего пространства",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Запрос на слияние",
|
||||
subtitle: "Сведения о запросе на слияние",
|
||||
emptyTitle: "Запроса на слияние пока нет",
|
||||
emptyDescription:
|
||||
"Создайте запрос на слияние для этой рабочей копии, чтобы увидеть здесь его сведения.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Изменения",
|
||||
changesSubtitle: "Различия рабочего дерева",
|
||||
|
||||
@@ -587,6 +587,7 @@ export const zhCN: TranslationResources = {
|
||||
renameAgent: "重命名 Agent",
|
||||
},
|
||||
actions: {
|
||||
newTab: "新建标签页",
|
||||
newAgent: "新建 Agent",
|
||||
newTerminal: "新建 Terminal",
|
||||
preparingTerminal: "正在准备 Terminal 标签",
|
||||
@@ -595,6 +596,9 @@ export const zhCN: TranslationResources = {
|
||||
exitFocusMode: "退出专注模式",
|
||||
splitRight: "向右拆分窗格",
|
||||
splitDown: "向下拆分窗格",
|
||||
changes: "更改",
|
||||
files: "文件",
|
||||
pullRequest: "拉取请求",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
pinTarget: "固定",
|
||||
@@ -1638,6 +1642,17 @@ export const zhCN: TranslationResources = {
|
||||
reloadMessage: "本地更改将丢失。",
|
||||
},
|
||||
},
|
||||
files: {
|
||||
label: "文件",
|
||||
subtitle: "工作区文件",
|
||||
tooltip: "浏览工作区文件",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "拉取请求",
|
||||
subtitle: "拉取请求详情",
|
||||
emptyTitle: "尚无拉取请求",
|
||||
emptyDescription: "为此检出创建拉取请求后,可在此处查看其详情。",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "更改",
|
||||
changesSubtitle: "工作区差异",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
|
||||
@@ -7,25 +7,13 @@ import invariant from "tiny-invariant";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import {
|
||||
DiffFilesToolbar,
|
||||
DiffLayoutToggle,
|
||||
DiffModeMenu,
|
||||
DiffOptionsMenu,
|
||||
resolveDiffLayout,
|
||||
SharedDiffView,
|
||||
} from "@/git/diff-pane";
|
||||
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||
import { DiffLayoutToggle, GitDiffPane, resolveDiffLayout, SharedDiffView } from "@/git/diff-pane";
|
||||
import { useCommitDiffFiles } from "@/git/use-diff-files";
|
||||
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
@@ -56,6 +44,9 @@ function useDiffPanelPreferences() {
|
||||
const toggleHideWhitespace = useCallback(() => {
|
||||
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
|
||||
}, [preferences.hideWhitespace, updatePreferences]);
|
||||
const toggleViewMode = useCallback(() => {
|
||||
void updatePreferences({ viewMode: preferences.viewMode === "flat" ? "tree" : "flat" });
|
||||
}, [preferences.viewMode, updatePreferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
@@ -65,6 +56,7 @@ function useDiffPanelPreferences() {
|
||||
toggleLayout,
|
||||
toggleWrapLines,
|
||||
toggleHideWhitespace,
|
||||
toggleViewMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,186 +76,34 @@ function PanelState({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingDiffBody({
|
||||
cwd,
|
||||
isConnected,
|
||||
workingDiff,
|
||||
hideWhitespace,
|
||||
displayPreferences,
|
||||
mode,
|
||||
}: {
|
||||
cwd: string | null | undefined;
|
||||
isConnected: boolean;
|
||||
workingDiff: ReturnType<typeof useWorkingDiff>;
|
||||
hideWhitespace: boolean;
|
||||
displayPreferences: ReturnType<typeof useDiffPanelPreferences>["displayPreferences"];
|
||||
mode: Extract<ComponentProps<typeof SharedDiffView>["mode"], { kind: "working_tab" }>;
|
||||
}) {
|
||||
function WorkingDiffPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(path: string) => openFileInWorkspace({ location: { path }, disposition: "side" }),
|
||||
[openFileInWorkspace],
|
||||
);
|
||||
|
||||
if (!cwd) {
|
||||
return <PanelState message={t("panels.diff.directoryMissing")} />;
|
||||
}
|
||||
if (!isConnected) {
|
||||
return <PanelState message={t("workspace.terminal.hostDisconnected")} />;
|
||||
}
|
||||
if (workingDiff.isStatusLoading) {
|
||||
return <PanelState message={t("workspace.git.diff.checkingRepository")} />;
|
||||
}
|
||||
if (workingDiff.statusErrorMessage) {
|
||||
return (
|
||||
<PanelState
|
||||
message={workingDiff.statusErrorMessage}
|
||||
tone="error"
|
||||
testID="working-diff-error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (workingDiff.notGit) {
|
||||
return <PanelState message={t("workspace.git.diff.notRepository")} />;
|
||||
}
|
||||
if (workingDiff.diffTooLarge) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (workingDiff.diffPayloadError) {
|
||||
return (
|
||||
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
|
||||
);
|
||||
}
|
||||
if (workingDiff.isDiffLoading && workingDiff.files.length === 0) {
|
||||
return <PanelState message={t("workspace.tabs.loading")} testID="working-diff-loading" />;
|
||||
}
|
||||
if (workingDiff.files.length === 0) {
|
||||
return (
|
||||
<PanelState
|
||||
message={
|
||||
hideWhitespace ? t("workspace.git.diff.emptyHiddenWhitespace") : t("panels.diff.empty")
|
||||
}
|
||||
testID="working-diff-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SharedDiffView files={workingDiff.files} displayPreferences={displayPreferences} mode={mode} />
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingDiffPanel() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { serverId, workspaceId, tabId, target } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const panelPreferences = useDiffPanelPreferences();
|
||||
const [expandedPaths, setExpandedPaths] = useState<string[] | null>(null);
|
||||
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
|
||||
|
||||
const workingDiff = useWorkingDiff({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd: cwd ?? "",
|
||||
ignoreWhitespace: panelPreferences.preferences.hideWhitespace,
|
||||
enabled: Boolean(cwd) && isActive,
|
||||
queryScope: `working-diff-tab:${tabId}`,
|
||||
});
|
||||
usePublishWorkingDiffAttachment({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd: cwd ?? "",
|
||||
attachment: workingDiff.reviewAttachment,
|
||||
enabled: Boolean(cwd) && isActive,
|
||||
});
|
||||
|
||||
const refreshSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }),
|
||||
) === "pending";
|
||||
const refresh = useCallback(() => {
|
||||
if (!cwd || isRefreshing) {
|
||||
return;
|
||||
}
|
||||
void runRefresh({ serverId, cwd }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
||||
|
||||
const expandedPathSet = useMemo(
|
||||
() => (expandedPaths === null ? null : new Set(expandedPaths)),
|
||||
[expandedPaths],
|
||||
);
|
||||
const allFilesExpanded =
|
||||
workingDiff.files.length > 0 &&
|
||||
(expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path)));
|
||||
const toggleExpandAll = useCallback(() => {
|
||||
setExpandedPaths(allFilesExpanded ? [] : null);
|
||||
}, [allFilesExpanded]);
|
||||
const mode = useMemo(
|
||||
() => ({
|
||||
kind: "working_tab" as const,
|
||||
expandedPaths,
|
||||
reviewActions: workingDiff.reviewActions,
|
||||
focusPath: target.focusPath,
|
||||
focusRequestId: target.focusRequestId,
|
||||
onExpandedPathsChange: setExpandedPaths,
|
||||
}),
|
||||
[expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions],
|
||||
);
|
||||
|
||||
const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? "";
|
||||
return (
|
||||
<View style={styles.container} testID="working-diff-panel">
|
||||
<View style={styles.toolbar}>
|
||||
<DiffModeMenu
|
||||
diffMode={workingDiff.diffMode}
|
||||
committedDescription={baseRefLabel || undefined}
|
||||
testIDPrefix="working-diff"
|
||||
onSelectUncommitted={workingDiff.selectUncommitted}
|
||||
onSelectBase={workingDiff.selectBase}
|
||||
/>
|
||||
<View style={styles.toolbarActions} testID="working-diff-toolbar">
|
||||
{panelPreferences.canUseSplitLayout ? (
|
||||
<DiffLayoutToggle
|
||||
layout={panelPreferences.preferences.layout}
|
||||
isMobile={panelPreferences.isCompact}
|
||||
testID="working-diff-toggle-layout"
|
||||
onToggle={panelPreferences.toggleLayout}
|
||||
/>
|
||||
) : null}
|
||||
{workingDiff.files.length > 0 ? (
|
||||
<DiffFilesToolbar
|
||||
allFileDiffsExpanded={allFilesExpanded}
|
||||
isMobile={panelPreferences.isCompact}
|
||||
testID="working-diff-toggle-expand-all"
|
||||
onToggleExpandAll={toggleExpandAll}
|
||||
/>
|
||||
) : null}
|
||||
<DiffOptionsMenu
|
||||
hideWhitespace={panelPreferences.preferences.hideWhitespace}
|
||||
isMobile={panelPreferences.isCompact}
|
||||
isRefreshing={isRefreshing}
|
||||
refreshSupported={refreshSupported}
|
||||
testIDPrefix="working-diff"
|
||||
wrapLines={panelPreferences.preferences.wrapLines}
|
||||
onRefresh={refresh}
|
||||
onToggleHideWhitespace={panelPreferences.toggleHideWhitespace}
|
||||
onToggleWrapLines={panelPreferences.toggleWrapLines}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<WorkingDiffBody
|
||||
cwd={cwd}
|
||||
isConnected={isConnected}
|
||||
workingDiff={workingDiff}
|
||||
hideWhitespace={panelPreferences.preferences.hideWhitespace}
|
||||
displayPreferences={panelPreferences.displayPreferences}
|
||||
mode={mode}
|
||||
/>
|
||||
</View>
|
||||
<GitDiffPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={cwd}
|
||||
enabled={isActive}
|
||||
host="panel"
|
||||
onOpenFile={handleOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FilePane } from "@/file-pane/pane";
|
||||
@@ -7,6 +7,9 @@ import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { createMaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { FileExplorerPane } from "@/components/file-explorer-pane";
|
||||
import { TreeRail } from "@/components/tree-rail";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
@@ -30,8 +33,14 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
|
||||
function FilePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext();
|
||||
const { serverId, workspaceId, target, fileNavigationRevision, retargetCurrentTab } =
|
||||
usePaneContext();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const handleOpenFile = useCallback(
|
||||
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
||||
[retargetCurrentTab],
|
||||
);
|
||||
invariant(target.kind === "file", "FilePanel requires file target");
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
@@ -40,13 +49,31 @@ function FilePanel() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isCompact) {
|
||||
return (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
location={target}
|
||||
navigationRevision={fileNavigationRevision ?? 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
location={target}
|
||||
navigationRevision={fileNavigationRevision ?? 0}
|
||||
/>
|
||||
<TreeRail testID="file-tree-rail">
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
location={target}
|
||||
navigationRevision={fileNavigationRevision ?? 0}
|
||||
/>
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
</TreeRail>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { FolderTree } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { FileExplorerPane } from "@/components/file-explorer-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
} as const;
|
||||
|
||||
function useFilesPanelDescriptor() {
|
||||
const { t } = useTranslation();
|
||||
return {
|
||||
label: t("panels.files.label"),
|
||||
subtitle: t("panels.files.subtitle"),
|
||||
tooltip: t("panels.files.tooltip"),
|
||||
titleState: "ready" as const,
|
||||
icon: ThemedFolderTree,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FilesPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
|
||||
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
invariant(target.kind === "files", "FilesPanel requires files target");
|
||||
const onOpenFile = useCallback(
|
||||
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
||||
[retargetCurrentTab],
|
||||
);
|
||||
if (!workspaceRoot) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const filesPanelRegistration: PanelRegistration<"files"> = {
|
||||
kind: "files",
|
||||
component: FilesPanel,
|
||||
useDescriptor: useFilesPanelDescriptor,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { usePullRequestData } from "@/panels/pull-request";
|
||||
|
||||
export function usePullRequestPanelAvailability(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
requested: boolean;
|
||||
enabled: boolean;
|
||||
timelineEnabled?: boolean;
|
||||
}) {
|
||||
const canQuery = input.isGit && Boolean(input.cwd);
|
||||
const prPane = usePullRequestData({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd,
|
||||
enabled: canQuery && input.enabled,
|
||||
timelineEnabled: canQuery && input.enabled && Boolean(input.timelineEnabled),
|
||||
});
|
||||
return {
|
||||
prPane,
|
||||
showPullRequest: prPane.prNumber !== null || (input.requested && prPane.isLoading),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { GitPullRequest } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { formatPrTabLabel } from "@/git/pull-request-panel";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { PullRequestContent, usePullRequestData } from "@/panels/pull-request";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
} as const;
|
||||
|
||||
function usePullRequestPanelDescriptor(
|
||||
_target: { kind: "pull_request" },
|
||||
context: { serverId: string; workspaceId: string },
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const cwd = useWorkspaceDirectory(context.serverId, context.workspaceId) ?? "";
|
||||
const prPane = usePullRequestData({ serverId: context.serverId, cwd, timelineEnabled: false });
|
||||
const label =
|
||||
prPane.prNumber === null ? t("panels.pullRequest.label") : formatPrTabLabel(prPane.prNumber);
|
||||
return {
|
||||
label,
|
||||
subtitle: t("panels.pullRequest.subtitle"),
|
||||
tooltip: label,
|
||||
titleState: prPane.isLoading ? ("loading" as const) : ("ready" as const),
|
||||
icon: ThemedGitPullRequest,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function PullRequestPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId) ?? "";
|
||||
invariant(target.kind === "pull_request", "PullRequestPanel requires pull_request target");
|
||||
const prPane = usePullRequestData({ serverId, cwd });
|
||||
if (!cwd) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PullRequestContent serverId={serverId} workspaceId={workspaceId} cwd={cwd} prPane={prPane} />
|
||||
);
|
||||
}
|
||||
|
||||
export const pullRequestPanelRegistration: PanelRegistration<"pull_request"> = {
|
||||
kind: "pull_request",
|
||||
component: PullRequestPanel,
|
||||
useDescriptor: usePullRequestPanelDescriptor,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPullRequestIdentity, resolvePullRequestContentState } from "./state";
|
||||
|
||||
describe("pull request panel", () => {
|
||||
it("resolves a manually opened panel without a pull request to the empty state", () => {
|
||||
expect(resolvePullRequestContentState({ data: null, error: null, isLoading: false })).toBe(
|
||||
"empty",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the canonical URL as the stable detected pull request identity", () => {
|
||||
expect(
|
||||
getPullRequestIdentity({
|
||||
forge: "github",
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "PR panel",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
isMerged: false,
|
||||
isDraft: false,
|
||||
mergeable: "MERGEABLE",
|
||||
checks: [],
|
||||
}),
|
||||
).toBe("url:https://github.com/getpaseo/paseo/pull/42");
|
||||
});
|
||||
|
||||
it("does not manufacture an identity while detection has no result", () => {
|
||||
expect(getPullRequestIdentity(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import {
|
||||
PullRequestPane,
|
||||
PullRequestPaneError,
|
||||
PullRequestPaneSkeleton,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
|
||||
import { getPullRequestIdentity, resolvePullRequestContentState } from "./state";
|
||||
|
||||
export function usePullRequestAutoAdd(input: {
|
||||
workspaceKey: string | null;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const status = useCheckoutPrStatusQuery({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd || "",
|
||||
enabled: input.enabled,
|
||||
}).status;
|
||||
const observePullRequest = useWorkspaceLayoutStore((state) => state.observePullRequest);
|
||||
const identity = getPullRequestIdentity(status);
|
||||
|
||||
useEffect(() => {
|
||||
if (input.workspaceKey) {
|
||||
observePullRequest(input.workspaceKey, identity);
|
||||
}
|
||||
}, [identity, input.workspaceKey, observePullRequest]);
|
||||
}
|
||||
|
||||
export function PullRequestContent(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const onRetry = useCallback(() => {
|
||||
void refresh({ serverId: input.serverId, cwd: input.cwd }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [input.cwd, input.serverId, refresh, t, toast]);
|
||||
|
||||
if (input.prPane.data) {
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={input.serverId}
|
||||
cwd={input.cwd}
|
||||
data={input.prPane.data}
|
||||
activityLoading={input.prPane.activityLoading}
|
||||
workspaceAttachmentScopeKey={buildWorkspaceAttachmentScopeKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
cwd: input.cwd,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const contentState = resolvePullRequestContentState(input.prPane);
|
||||
if (contentState === "error") {
|
||||
return <PullRequestPaneError onRetry={onRetry} />;
|
||||
}
|
||||
if (contentState === "loading") {
|
||||
return <PullRequestPaneSkeleton />;
|
||||
}
|
||||
return (
|
||||
<View style={EMPTY_STYLE} testID="pull-request-empty-state">
|
||||
<Text style={EMPTY_TITLE_STYLE}>{t("panels.pullRequest.emptyTitle")}</Text>
|
||||
<Text>{t("panels.pullRequest.emptyDescription")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePullRequestData(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
timelineEnabled?: boolean;
|
||||
}) {
|
||||
return usePrPaneData(input);
|
||||
}
|
||||
|
||||
const EMPTY_STYLE = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 24,
|
||||
} as const;
|
||||
|
||||
const EMPTY_TITLE_STYLE = { fontWeight: "600" } as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
|
||||
|
||||
type PullRequestStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
|
||||
export function getPullRequestIdentity(status: PullRequestStatus): string | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
const url = status.url.trim();
|
||||
if (url) {
|
||||
return `url:${url}`;
|
||||
}
|
||||
if (status.number === undefined) {
|
||||
return null;
|
||||
}
|
||||
return `repo:${status.forge}:${status.repoOwner ?? ""}/${status.repoName ?? ""}#${status.number}`;
|
||||
}
|
||||
|
||||
export function resolvePullRequestContentState(input: {
|
||||
data: unknown;
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
}): "ready" | "error" | "loading" | "empty" {
|
||||
if (input.data) return "ready";
|
||||
if (input.error) return "error";
|
||||
if (input.isLoading) return "loading";
|
||||
return "empty";
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { browserPanelRegistration } from "@/desktop/browser/panel";
|
||||
import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { filesPanelRegistration } from "@/panels/files-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { setupPanelRegistration } from "@/panels/setup-panel";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
|
||||
import { pullRequestPanelRegistration } from "@/panels/pull-request-panel";
|
||||
|
||||
let panelsRegistered = false;
|
||||
|
||||
@@ -21,6 +23,8 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(browserPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
registerPanel(filesPanelRegistration);
|
||||
registerPanel(pullRequestPanelRegistration);
|
||||
registerPanel(commitDiffPanelRegistration);
|
||||
registerPanel(workingDiffPanelRegistration);
|
||||
panelsRegistered = true;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
export function useAddFileToChat(input: { serverId: string; workspaceId?: string | null }) {
|
||||
const workspaceKey = input.workspaceId
|
||||
? buildWorkspaceTabPersistenceKey({ serverId: input.serverId, workspaceId: input.workspaceId })
|
||||
: null;
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const focusedChat = useMemo(
|
||||
() => resolveFocusedChatTarget({ serverId: input.serverId, layout }),
|
||||
[input.serverId, layout],
|
||||
);
|
||||
const addFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!focusedChat || !workspaceKey) {
|
||||
return;
|
||||
}
|
||||
await useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: focusedChat.draftKey,
|
||||
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||
});
|
||||
focusTab(workspaceKey, focusedChat.tabId);
|
||||
},
|
||||
[focusTab, focusedChat, workspaceKey],
|
||||
);
|
||||
return { addFile, canAddToChat: focusedChat !== null };
|
||||
}
|
||||
@@ -20,13 +20,15 @@ import {
|
||||
CopyX,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
Copy,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
Globe,
|
||||
FileDiff,
|
||||
FolderTree,
|
||||
GitPullRequest,
|
||||
Plus,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
@@ -59,7 +61,7 @@ import {
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||
import {
|
||||
@@ -75,6 +77,7 @@ import {
|
||||
type WorkspaceTabMenuLabels,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
@@ -89,10 +92,25 @@ import { runPinnedTabTarget, type TabTargetHandlers } from "@/workspace-pins/run
|
||||
import type { PinnedTabTarget } from "@/workspace-pins/target";
|
||||
import { PinnedTargetsRow } from "@/workspace-pins/pinned-targets-row";
|
||||
import { PinnableMenuItem } from "@/workspace-pins/pinnable-menu-item";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
||||
// Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes
|
||||
// shape without changing them mis-measures and drops the row into the overflow-scroll fallback at
|
||||
// the wrong width. Keep them together.
|
||||
const TAB_CHIP_HEIGHT = 26;
|
||||
const TAB_CHIP_HORIZONTAL_PADDING = 8;
|
||||
const TAB_CHIP_GAP = 4;
|
||||
const TAB_ROW_PADDING_HORIZONTAL = 4;
|
||||
const TAB_ICON_WIDTH = 14;
|
||||
const TAB_DROP_INDICATOR_WIDTH = 4;
|
||||
const TAB_MAX_WIDTH = 160;
|
||||
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedX = withUnistyles(X);
|
||||
@@ -104,21 +122,29 @@ const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedColumns2 = withUnistyles(Columns2);
|
||||
const ThemedRows2 = withUnistyles(Rows2);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
|
||||
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
|
||||
const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />;
|
||||
const CHANGES_ICON = <ThemedFileDiff size={14} uniProps={mutedColorMapping} />;
|
||||
const FILES_ICON = <ThemedFolderTree size={14} uniProps={mutedColorMapping} />;
|
||||
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={14} uniProps={mutedColorMapping} />;
|
||||
|
||||
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
|
||||
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
|
||||
const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" };
|
||||
const CHANGES_TARGET = { kind: "working_diff" } as const;
|
||||
const FILES_TARGET = { kind: "files" } as const;
|
||||
const PULL_REQUEST_TARGET = { kind: "pull_request" } as const;
|
||||
|
||||
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
||||
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||
@@ -169,67 +195,32 @@ function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfil
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceInlineAddTabButtonProps {
|
||||
shortcutKeys: ShortcutKey[][] | null;
|
||||
onCreateAgentTab: () => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
function WorkspaceInlineAddTabButton({
|
||||
shortcutKeys,
|
||||
onCreateAgentTab,
|
||||
onLayout,
|
||||
}: WorkspaceInlineAddTabButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const tooltipText = t("workspace.tabs.actions.newAgent");
|
||||
|
||||
return (
|
||||
<View style={styles.inlineAddButton} onLayout={onLayout}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-agent-tab-inline"
|
||||
onPress={onCreateAgentTab}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={tooltipText}
|
||||
style={inlineAddActionButtonStyle}
|
||||
>
|
||||
<ThemedPlus size={14} uniProps={mutedColorMapping} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
|
||||
{shortcutKeys ? (
|
||||
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceTabRowExtrasProps {
|
||||
interface TabTargetLauncherOptions {
|
||||
normalizedServerId: string;
|
||||
onCreateAgentTab: () => void;
|
||||
onCreateTerminal: () => void;
|
||||
onCreateBrowser: () => void;
|
||||
onOpenChanges: () => void;
|
||||
onOpenFiles: () => void;
|
||||
onOpenPullRequest: () => void;
|
||||
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
|
||||
onEditProfiles: () => void;
|
||||
normalizedServerId: string;
|
||||
showCreateBrowserTab: boolean;
|
||||
terminalDisabled: boolean;
|
||||
}
|
||||
|
||||
function WorkspaceTabRowExtras({
|
||||
/**
|
||||
* The `+` menu and the pinned-targets row launch the same targets but sit in different parts of
|
||||
* the row, so each owns its own subscription rather than re-rendering the whole row from a shared
|
||||
* one. `useDaemonConfig` is a cached query, so the second caller is free.
|
||||
*/
|
||||
function useTabTargetLauncher({
|
||||
normalizedServerId,
|
||||
onCreateAgentTab,
|
||||
onCreateTerminal,
|
||||
onCreateBrowser,
|
||||
onOpenChanges,
|
||||
onOpenFiles,
|
||||
onOpenPullRequest,
|
||||
onCreateTerminalWithProfile,
|
||||
onEditProfiles,
|
||||
normalizedServerId,
|
||||
showCreateBrowserTab,
|
||||
terminalDisabled,
|
||||
}: WorkspaceTabRowExtrasProps) {
|
||||
const { t } = useTranslation();
|
||||
}: TabTargetLauncherOptions) {
|
||||
const { config } = useDaemonConfig(normalizedServerId);
|
||||
const profiles = useMemo(
|
||||
() => resolveTerminalProfiles(config?.terminalProfiles),
|
||||
@@ -241,9 +232,20 @@ function WorkspaceTabRowExtras({
|
||||
createDraft: onCreateAgentTab,
|
||||
createTerminal: onCreateTerminal,
|
||||
createBrowser: onCreateBrowser,
|
||||
openChanges: onOpenChanges,
|
||||
openFiles: onOpenFiles,
|
||||
openPullRequest: onOpenPullRequest,
|
||||
createTerminalWithProfile: onCreateTerminalWithProfile,
|
||||
}),
|
||||
[onCreateAgentTab, onCreateBrowser, onCreateTerminal, onCreateTerminalWithProfile],
|
||||
[
|
||||
onCreateAgentTab,
|
||||
onCreateBrowser,
|
||||
onCreateTerminal,
|
||||
onCreateTerminalWithProfile,
|
||||
onOpenChanges,
|
||||
onOpenFiles,
|
||||
onOpenPullRequest,
|
||||
],
|
||||
);
|
||||
|
||||
const onLaunch = useCallback(
|
||||
@@ -253,32 +255,74 @@ function WorkspaceTabRowExtras({
|
||||
[handlers, profiles],
|
||||
);
|
||||
|
||||
const launchers = usePinnedLaunchers({ serverId: normalizedServerId, onLaunch });
|
||||
return { profiles, onLaunch };
|
||||
}
|
||||
|
||||
interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
|
||||
shortcutKeys: ShortcutKey[][] | null;
|
||||
onEditProfiles: () => void;
|
||||
showCreateBrowserTab: boolean;
|
||||
terminalDisabled: boolean;
|
||||
isGit: boolean;
|
||||
showPullRequest: boolean;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
function WorkspaceNewTabButton({
|
||||
shortcutKeys,
|
||||
onCreateAgentTab,
|
||||
onCreateTerminal,
|
||||
onCreateBrowser,
|
||||
onOpenChanges,
|
||||
onOpenFiles,
|
||||
onOpenPullRequest,
|
||||
onCreateTerminalWithProfile,
|
||||
onEditProfiles,
|
||||
normalizedServerId,
|
||||
showCreateBrowserTab,
|
||||
terminalDisabled,
|
||||
isGit,
|
||||
showPullRequest,
|
||||
onLayout,
|
||||
}: WorkspaceNewTabButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { profiles, onLaunch } = useTabTargetLauncher({
|
||||
normalizedServerId,
|
||||
onCreateAgentTab,
|
||||
onCreateTerminal,
|
||||
onCreateBrowser,
|
||||
onCreateTerminalWithProfile,
|
||||
onOpenChanges,
|
||||
onOpenFiles,
|
||||
onOpenPullRequest,
|
||||
});
|
||||
const tooltipText = t("workspace.tabs.actions.newTab");
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.inlineAddButton} onLayout={onLayout}>
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-new-tab-menu-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.actions.moreActions")}
|
||||
style={newTabActionButtonStyle}
|
||||
accessibilityLabel={tooltipText}
|
||||
style={inlineAddActionButtonStyle}
|
||||
>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
<ThemedPlus size={14} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>{t("workspace.tabs.actions.moreActions")}</Text>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="bottom" align="end" offset={4} minWidth={200}>
|
||||
<DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
|
||||
<PinnableMenuItem
|
||||
testID="workspace-new-tab-menu-agent"
|
||||
target={DRAFT_TARGET}
|
||||
label={t("workspace.tabs.actions.newAgent")}
|
||||
leading={AGENT_ICON}
|
||||
shortcut={shortcutKeys}
|
||||
onSelect={onCreateAgentTab}
|
||||
/>
|
||||
<PinnableMenuItem
|
||||
@@ -298,6 +342,31 @@ function WorkspaceTabRowExtras({
|
||||
onSelect={onCreateBrowser}
|
||||
/>
|
||||
) : null}
|
||||
{isGit ? (
|
||||
<PinnableMenuItem
|
||||
testID="workspace-new-tab-menu-changes"
|
||||
target={CHANGES_TARGET}
|
||||
label={t("workspace.tabs.actions.changes")}
|
||||
leading={CHANGES_ICON}
|
||||
onSelect={onOpenChanges}
|
||||
/>
|
||||
) : null}
|
||||
<PinnableMenuItem
|
||||
testID="workspace-new-tab-menu-files"
|
||||
target={FILES_TARGET}
|
||||
label={t("workspace.tabs.actions.files")}
|
||||
leading={FILES_ICON}
|
||||
onSelect={onOpenFiles}
|
||||
/>
|
||||
{showPullRequest ? (
|
||||
<PinnableMenuItem
|
||||
testID="workspace-new-tab-menu-pull-request"
|
||||
target={PULL_REQUEST_TARGET}
|
||||
label={t("workspace.tabs.actions.pullRequest")}
|
||||
leading={PULL_REQUEST_ICON}
|
||||
onSelect={onOpenPullRequest}
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
|
||||
{profiles.map((profile) => (
|
||||
@@ -314,11 +383,24 @@ function WorkspaceTabRowExtras({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePinnedTargets(
|
||||
options: TabTargetLauncherOptions & { isGit: boolean; showPullRequest: boolean },
|
||||
) {
|
||||
const { onLaunch } = useTabTargetLauncher(options);
|
||||
const launchers = usePinnedLaunchers({
|
||||
serverId: options.normalizedServerId,
|
||||
onLaunch,
|
||||
isGit: options.isGit,
|
||||
hasPullRequest: options.showPullRequest,
|
||||
});
|
||||
|
||||
return <PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />;
|
||||
}
|
||||
|
||||
function TabContextMenuItem({
|
||||
entry,
|
||||
}: {
|
||||
@@ -445,7 +527,15 @@ interface WorkspaceDesktopTabsRowProps {
|
||||
|
||||
function getFallbackTabLabel(
|
||||
tab: WorkspaceTabDescriptor,
|
||||
labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string },
|
||||
labels: {
|
||||
newAgent: string;
|
||||
setup: string;
|
||||
terminal: string;
|
||||
agent: string;
|
||||
changes: string;
|
||||
files: string;
|
||||
pullRequest: string;
|
||||
},
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return labels.newAgent;
|
||||
@@ -462,6 +552,12 @@ function getFallbackTabLabel(
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return labels.changes;
|
||||
}
|
||||
if (tab.target.kind === "files") {
|
||||
return labels.files;
|
||||
}
|
||||
if (tab.target.kind === "pull_request") {
|
||||
return labels.pullRequest;
|
||||
}
|
||||
return labels.agent;
|
||||
}
|
||||
|
||||
@@ -487,16 +583,30 @@ function useMiddleClickClose(onClose: () => void) {
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** The chip fill the running-status ring has to knock out of. Mirrors `styles.tab*` exactly. */
|
||||
function resolveChipBackdrop({
|
||||
isActiveFocused,
|
||||
isFilled,
|
||||
}: {
|
||||
isActiveFocused: boolean;
|
||||
isFilled: boolean;
|
||||
}): SurfaceBackdrop {
|
||||
if (isActiveFocused) return "surface2";
|
||||
return isFilled ? "surface1" : "surface0";
|
||||
}
|
||||
|
||||
function TabHandleContent({
|
||||
presentation,
|
||||
isHighlighted,
|
||||
showLabel,
|
||||
backdrop,
|
||||
tabLabelSkeletonStyle,
|
||||
tabLabelStyle,
|
||||
}: {
|
||||
presentation: WorkspaceTabPresentation;
|
||||
isHighlighted: boolean;
|
||||
showLabel: boolean;
|
||||
backdrop: SurfaceBackdrop;
|
||||
tabLabelSkeletonStyle: React.ComponentProps<typeof View>["style"];
|
||||
tabLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
}) {
|
||||
@@ -508,7 +618,7 @@ function TabHandleContent({
|
||||
return (
|
||||
<View style={styles.tabHandle} dataSet={tabHandleDataSet}>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop="surface0" />
|
||||
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop={backdrop} />
|
||||
</View>
|
||||
{showLabel && presentation.titleState === "loading" ? (
|
||||
<View style={tabLabelSkeletonStyle} />
|
||||
@@ -562,9 +672,18 @@ function TabChip({
|
||||
const middleClickRef = useMiddleClickClose(
|
||||
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
|
||||
);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isHighlighted = isActive || hovered || isCloseHovered;
|
||||
const showTrailingAffordance = showCloseButton || presentation.modified;
|
||||
// An active tab in a pane that does not have focus stays legible but quiet: it keeps the fill of
|
||||
// a hovered chip and the muted label, so only one chip in the window reads as the live one.
|
||||
const isActiveFocused = isActive && isFocused;
|
||||
const isHovered = hovered || isCloseHovered;
|
||||
const isHighlighted = isActiveFocused || isHovered;
|
||||
const chipBackdrop: SurfaceBackdrop = resolveChipBackdrop({
|
||||
isActiveFocused,
|
||||
isFilled: isActive || isHovered,
|
||||
});
|
||||
const showCloseControl = showCloseButton && (isHovered || isNative || isCompact || isClosingTab);
|
||||
const closeButtonDragBlockers = isWeb
|
||||
? ({
|
||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||
@@ -579,6 +698,9 @@ function TabChip({
|
||||
const tabChipStyle = useCallback(
|
||||
() => [
|
||||
styles.tab,
|
||||
isActiveFocused && styles.tabActive,
|
||||
isActive && !isFocused && styles.tabActiveUnfocused,
|
||||
!isActive && isHovered && styles.tabHovered,
|
||||
isWeb && isDragging && ({ cursor: "grabbing" } as object),
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
@@ -586,14 +708,14 @@ function TabChip({
|
||||
maxWidth: resolvedTabWidth,
|
||||
},
|
||||
],
|
||||
[isDragging, resolvedTabWidth],
|
||||
[isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth],
|
||||
);
|
||||
|
||||
const handleTabHoverIn = useCallback(() => {
|
||||
const handleTabPointerEnter = useCallback(() => {
|
||||
setHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleTabHoverOut = useCallback(() => {
|
||||
const handleTabPointerLeave = useCallback(() => {
|
||||
setHovered(false);
|
||||
}, []);
|
||||
|
||||
@@ -621,38 +743,20 @@ function TabChip({
|
||||
[onCloseTab, tab.tabId],
|
||||
);
|
||||
|
||||
const closeButtonStyle = useCallback(
|
||||
({ hovered: isButtonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.tabCloseButton,
|
||||
styles.tabCloseButtonShown,
|
||||
(Boolean(isButtonHovered) || pressed) && styles.tabCloseButtonActive,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
|
||||
const tabFocusIndicatorStyle = useMemo(
|
||||
() => [styles.tabFocusIndicator, !isFocused && styles.tabFocusIndicatorUnfocused],
|
||||
[isFocused],
|
||||
);
|
||||
const tabLabelSkeletonStyle = useMemo(
|
||||
() => [
|
||||
styles.tabLabelSkeleton,
|
||||
showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton,
|
||||
],
|
||||
[showTrailingAffordance],
|
||||
);
|
||||
const tabLabelSkeletonStyle = styles.tabLabelSkeleton;
|
||||
const tabLabelStyle = useMemo(
|
||||
() => [
|
||||
styles.tabLabel,
|
||||
isHighlighted && styles.tabLabelActive,
|
||||
showTrailingAffordance && styles.tabLabelWithCloseButton,
|
||||
],
|
||||
[isHighlighted, showTrailingAffordance],
|
||||
() => [styles.tabLabel, isHighlighted && styles.tabLabelActive],
|
||||
[isHighlighted],
|
||||
);
|
||||
|
||||
return (
|
||||
<View ref={middleClickRef}>
|
||||
<View
|
||||
ref={middleClickRef}
|
||||
style={styles.tabHoverFrame}
|
||||
onPointerEnter={handleTabPointerEnter}
|
||||
onPointerLeave={handleTabPointerLeave}
|
||||
>
|
||||
<ContextMenu key={tab.key}>
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
@@ -663,8 +767,6 @@ function TabChip({
|
||||
triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined}
|
||||
enabledOnMobile={false}
|
||||
style={tabChipStyle}
|
||||
onHoverIn={handleTabHoverIn}
|
||||
onHoverOut={handleTabHoverOut}
|
||||
onPressIn={handleNavigateTab}
|
||||
onPress={handleNavigateTab}
|
||||
accessibilityRole="button"
|
||||
@@ -672,53 +774,22 @@ function TabChip({
|
||||
accessibilityState={tabAccessibilityState}
|
||||
aria-selected={isActive}
|
||||
>
|
||||
{isActive && <View style={tabFocusIndicatorStyle} />}
|
||||
<TabHandleContent
|
||||
presentation={presentation}
|
||||
isHighlighted={isHighlighted}
|
||||
showLabel={showLabel}
|
||||
backdrop={chipBackdrop}
|
||||
tabLabelSkeletonStyle={tabLabelSkeletonStyle}
|
||||
tabLabelStyle={tabLabelStyle}
|
||||
/>
|
||||
|
||||
{showTrailingAffordance ? (
|
||||
<Pressable
|
||||
{...(closeButtonDragBlockers as object | undefined)}
|
||||
testID={closeButtonTestId}
|
||||
disabled={isClosingTab}
|
||||
onPressIn={handleCloseButtonPressIn}
|
||||
onHoverIn={handleCloseButtonHoverIn}
|
||||
onHoverOut={handleCloseButtonHoverOut}
|
||||
onPress={handleCloseButtonPress}
|
||||
style={closeButtonStyle}
|
||||
{presentation.modified ? (
|
||||
<View
|
||||
style={styles.tabModifiedIndicator}
|
||||
accessibilityLabel={t("workspace.tabs.modified")}
|
||||
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
|
||||
>
|
||||
{({ hovered: closeHovered, pressed }) => {
|
||||
const highlighted = closeHovered || pressed;
|
||||
if (isClosingTab) {
|
||||
return (
|
||||
<ThemedLoadingSpinner
|
||||
size={12}
|
||||
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (highlighted || !presentation.modified) {
|
||||
return (
|
||||
<ThemedX
|
||||
size={12}
|
||||
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={styles.tabModifiedDot}
|
||||
accessibilityLabel={t("workspace.tabs.modified")}
|
||||
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
<View style={styles.tabModifiedDot} />
|
||||
</View>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -740,6 +811,46 @@ function TabChip({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{showCloseButton ? (
|
||||
<View
|
||||
pointerEvents={showCloseControl ? "auto" : "none"}
|
||||
style={[
|
||||
styles.tabTrailingOverlay,
|
||||
showCloseControl ? styles.tabTrailingOverlayShown : styles.tabTrailingOverlayHidden,
|
||||
]}
|
||||
>
|
||||
<TrailingActionScrim backdrop={chipBackdrop} />
|
||||
<Pressable
|
||||
{...(closeButtonDragBlockers as object | undefined)}
|
||||
testID={closeButtonTestId}
|
||||
disabled={isClosingTab}
|
||||
onPressIn={handleCloseButtonPressIn}
|
||||
onHoverIn={handleCloseButtonHoverIn}
|
||||
onHoverOut={handleCloseButtonHoverOut}
|
||||
onPress={handleCloseButtonPress}
|
||||
style={styles.tabCloseButton}
|
||||
>
|
||||
{({ hovered: closeHovered, pressed }) => {
|
||||
const highlighted = closeHovered || pressed;
|
||||
if (isClosingTab) {
|
||||
return (
|
||||
<ThemedLoadingSpinner
|
||||
size={12}
|
||||
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ThemedX
|
||||
size={12}
|
||||
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
@@ -798,6 +909,19 @@ export function WorkspaceDesktopTabsRow({
|
||||
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
||||
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
||||
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
||||
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
|
||||
const checkoutStatus = useCheckoutStatusQuery({
|
||||
serverId: normalizedServerId,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
const isGit = checkoutStatus.status?.isGit === true;
|
||||
const showPullRequest = isGit;
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const focusPane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const openTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
|
||||
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
updateMeasuredWidth(setTabsContainerWidth, event);
|
||||
@@ -824,13 +948,13 @@ export function WorkspaceDesktopTabsRow({
|
||||
(inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
|
||||
(focusModeEnabled ? exitFocusModeWidth : 0),
|
||||
),
|
||||
rowPaddingHorizontal: 0,
|
||||
tabGap: 0,
|
||||
maxTabWidth: 200,
|
||||
tabIconWidth: 14,
|
||||
tabHorizontalPadding: 12,
|
||||
rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
|
||||
tabGap: TAB_CHIP_GAP,
|
||||
maxTabWidth: TAB_MAX_WIDTH,
|
||||
tabIconWidth: TAB_ICON_WIDTH,
|
||||
tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING,
|
||||
estimatedCharWidth: 7,
|
||||
closeButtonWidth: 22,
|
||||
closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH,
|
||||
}),
|
||||
[exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth],
|
||||
);
|
||||
@@ -842,6 +966,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
terminal: t("workspace.tabs.fallback.terminal"),
|
||||
agent: t("workspace.tabs.fallback.agent"),
|
||||
changes: t("panels.diff.changesLabel"),
|
||||
files: t("panels.files.label"),
|
||||
pullRequest: t("panels.pullRequest.label"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -917,6 +1043,25 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCreateBrowserTab({ paneId });
|
||||
}, [onCreateBrowserTab, paneId]);
|
||||
|
||||
const openPanelTarget = useCallback(
|
||||
(target: { kind: "working_diff" } | { kind: "files" } | { kind: "pull_request" }) => {
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
}
|
||||
if (paneId) {
|
||||
focusPane(workspaceKey, paneId);
|
||||
}
|
||||
openTabFocused(workspaceKey, target);
|
||||
},
|
||||
[focusPane, openTabFocused, paneId, workspaceKey],
|
||||
);
|
||||
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
|
||||
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
|
||||
const handleOpenPullRequest = useCallback(
|
||||
() => openPanelTarget(PULL_REQUEST_TARGET),
|
||||
[openPanelTarget],
|
||||
);
|
||||
|
||||
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
|
||||
|
||||
const renderTab = useCallback(
|
||||
@@ -1053,22 +1198,36 @@ export function WorkspaceDesktopTabsRow({
|
||||
getItemData={getTabDragData}
|
||||
renderItem={renderTab}
|
||||
/>
|
||||
<WorkspaceInlineAddTabButton
|
||||
shortcutKeys={newTabKeys}
|
||||
onCreateAgentTab={handleCreateAgentTab}
|
||||
onLayout={handleInlineAddButtonLayout}
|
||||
/>
|
||||
</ScrollView>
|
||||
<WorkspaceNewTabButton
|
||||
shortcutKeys={newTabKeys}
|
||||
onCreateAgentTab={handleCreateAgentTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowser}
|
||||
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
|
||||
onOpenChanges={handleOpenChanges}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
onOpenPullRequest={handleOpenPullRequest}
|
||||
onEditProfiles={handleEditProfiles}
|
||||
normalizedServerId={normalizedServerId}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
terminalDisabled={terminalDisabled}
|
||||
isGit={isGit}
|
||||
showPullRequest={showPullRequest}
|
||||
onLayout={handleInlineAddButtonLayout}
|
||||
/>
|
||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||
<WorkspaceTabRowExtras
|
||||
<WorkspacePinnedTargets
|
||||
onCreateAgentTab={handleCreateAgentTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowser}
|
||||
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
|
||||
onEditProfiles={handleEditProfiles}
|
||||
onOpenChanges={handleOpenChanges}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
onOpenPullRequest={handleOpenPullRequest}
|
||||
normalizedServerId={normalizedServerId}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
terminalDisabled={terminalDisabled}
|
||||
isGit={isGit}
|
||||
showPullRequest={showPullRequest}
|
||||
/>
|
||||
{showPaneSplitActions ? (
|
||||
<>
|
||||
@@ -1251,7 +1410,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
tabsContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
|
||||
},
|
||||
tabsActions: {
|
||||
flexDirection: "row",
|
||||
@@ -1272,18 +1432,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
tab: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
height: TAB_CHIP_HEIGHT,
|
||||
paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
tabHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tabActiveUnfocused: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tabHoverFrame: {
|
||||
position: "relative",
|
||||
},
|
||||
tabSlot: {
|
||||
position: "relative",
|
||||
overflow: "visible",
|
||||
marginHorizontal: TAB_CHIP_GAP / 2,
|
||||
},
|
||||
tabHandle: {
|
||||
flexDirection: "row",
|
||||
@@ -1294,34 +1466,29 @@ const styles = StyleSheet.create((theme) => ({
|
||||
userSelect: "none",
|
||||
},
|
||||
tabIcon: {
|
||||
width: TAB_ICON_WIDTH,
|
||||
height: TAB_ICON_WIDTH,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tabFocusIndicator: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
tabFocusIndicatorUnfocused: {
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
},
|
||||
// The chip box stops at the slot's padding box, so the gap between two chips runs from
|
||||
// -TAB_CHIP_GAP to 0. Centre a TAB_DROP_INDICATOR_WIDTH pill in it.
|
||||
tabDropIndicator: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
width: 5,
|
||||
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",
|
||||
},
|
||||
tabDropIndicatorBefore: {
|
||||
left: -3,
|
||||
left: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
|
||||
},
|
||||
tabDropIndicatorAfter: {
|
||||
right: -3,
|
||||
right: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
|
||||
},
|
||||
tabLabel: {
|
||||
flexShrink: 1,
|
||||
@@ -1341,28 +1508,44 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface3,
|
||||
opacity: 0.9,
|
||||
},
|
||||
tabLabelSkeletonWithCloseButton: {
|
||||
width: LOADING_TAB_LABEL_SKELETON_WIDTH,
|
||||
},
|
||||
tabLabelWithCloseButton: {
|
||||
paddingRight: 0,
|
||||
},
|
||||
tabLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
tabTrailingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 48,
|
||||
borderTopRightRadius: theme.borderRadius.md,
|
||||
borderBottomRightRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2,
|
||||
},
|
||||
tabTrailingOverlayShown: {
|
||||
opacity: 1,
|
||||
},
|
||||
tabTrailingOverlayHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
tabCloseButton: {
|
||||
position: "absolute",
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
marginLeft: 0,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
},
|
||||
tabCloseButtonShown: {
|
||||
opacity: 1,
|
||||
},
|
||||
tabCloseButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
tabModifiedIndicator: {
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tabModifiedDot: {
|
||||
width: 8,
|
||||
|
||||
@@ -74,6 +74,8 @@ import { traceInstant } from "@/performance/native-trace";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
collectAllTabs,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
getFocusedBrowserId,
|
||||
type WorkspaceLayout,
|
||||
useWorkspaceLayoutStore,
|
||||
@@ -152,10 +154,7 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
resolveSideFileOpenPlacement,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildWorkspacePaneContentModel,
|
||||
WorkspacePaneContent,
|
||||
@@ -204,6 +203,7 @@ import {
|
||||
} from "@/workspace/file-open";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { usePullRequestAutoAdd } from "@/panels/pull-request";
|
||||
|
||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
|
||||
@@ -344,6 +344,8 @@ function getFallbackTabOptionLabel(
|
||||
browser: string;
|
||||
agent: string;
|
||||
changes: string;
|
||||
files: string;
|
||||
pullRequest: string;
|
||||
},
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
@@ -364,6 +366,12 @@ function getFallbackTabOptionLabel(
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return labels.changes;
|
||||
}
|
||||
if (tab.target.kind === "files") {
|
||||
return labels.files;
|
||||
}
|
||||
if (tab.target.kind === "pull_request") {
|
||||
return labels.pullRequest;
|
||||
}
|
||||
if (tab.target.kind === "commit_diff") {
|
||||
return tab.target.sha.slice(0, 7);
|
||||
}
|
||||
@@ -379,6 +387,8 @@ function getFallbackTabOptionDescription(
|
||||
terminal: string;
|
||||
browser: string;
|
||||
changes: string;
|
||||
files: string;
|
||||
pullRequest: string;
|
||||
},
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
@@ -405,6 +415,12 @@ function getFallbackTabOptionDescription(
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return labels.changes;
|
||||
}
|
||||
if (tab.target.kind === "files") {
|
||||
return labels.files;
|
||||
}
|
||||
if (tab.target.kind === "pull_request") {
|
||||
return labels.pullRequest;
|
||||
}
|
||||
return tab.target.path;
|
||||
}
|
||||
|
||||
@@ -603,6 +619,8 @@ function MobileWorkspaceTabOption({
|
||||
browser: t("workspace.tabs.fallback.browser"),
|
||||
agent: t("workspace.tabs.fallback.agent"),
|
||||
changes: t("panels.diff.changesLabel"),
|
||||
files: t("panels.files.label"),
|
||||
pullRequest: t("panels.pullRequest.label"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -1627,6 +1645,75 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function canObservePullRequest(isRouteFocused: boolean, isGitCheckout: boolean): boolean {
|
||||
return isRouteFocused && isGitCheckout;
|
||||
}
|
||||
|
||||
interface ToggleWorkspaceExplorerPaneInput {
|
||||
isCompact: boolean;
|
||||
persistenceKey: string | null;
|
||||
checkout: ExplorerCheckoutContext | null;
|
||||
toggleCompact: (input: { isCompact: boolean; checkout: ExplorerCheckoutContext }) => void;
|
||||
}
|
||||
|
||||
function toggleWorkspaceExplorerPane(input: ToggleWorkspaceExplorerPaneInput): void {
|
||||
if (input.isCompact || !supportsDesktopPaneSplits()) {
|
||||
if (input.checkout) {
|
||||
input.toggleCompact({ isCompact: input.isCompact, checkout: input.checkout });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!input.persistenceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const layout = store.layoutByWorkspace[input.persistenceKey] ?? createDefaultLayout();
|
||||
const parentTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
|
||||
const explorerPaneId = store.explorerPaneIdByWorkspace[input.persistenceKey] ?? null;
|
||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
||||
if (explorerPane) {
|
||||
if (explorerPane.hidden === true) {
|
||||
store.showPane(input.persistenceKey, explorerPane.id);
|
||||
} else {
|
||||
store.hidePane(input.persistenceKey, explorerPane.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ensuredPane = store.ensureExplorerPane(input.persistenceKey);
|
||||
if (!ensuredPane) {
|
||||
return;
|
||||
}
|
||||
const tabId = parentTabId
|
||||
? store.openChildTabFocused(input.persistenceKey, { kind: "working_diff" }, parentTabId)
|
||||
: store.openTabFocused(input.persistenceKey, { kind: "working_diff" });
|
||||
if (tabId) {
|
||||
store.moveTabToPane(input.persistenceKey, tabId, ensuredPane.paneId);
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkspaceExplorerPaneOpen(
|
||||
state: ReturnType<typeof useWorkspaceLayoutStore.getState>,
|
||||
persistenceKey: string | null,
|
||||
): boolean {
|
||||
if (!persistenceKey) {
|
||||
return false;
|
||||
}
|
||||
const paneId = state.explorerPaneIdByWorkspace[persistenceKey];
|
||||
const layout = state.layoutByWorkspace[persistenceKey];
|
||||
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
|
||||
return Boolean(pane && pane.hidden !== true);
|
||||
}
|
||||
|
||||
function resolveExplorerOpen(
|
||||
isCompact: boolean,
|
||||
compactExplorerOpen: boolean,
|
||||
desktopExplorerOpen: boolean,
|
||||
): boolean {
|
||||
return isCompact ? compactExplorerOpen : desktopExplorerOpen;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalTabActionsInput {
|
||||
persistenceKey: string | null;
|
||||
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
|
||||
@@ -1900,8 +1987,14 @@ function WorkspaceScreenContent({
|
||||
workspace: workspaceDescriptor,
|
||||
checkoutState: workspaceHeaderCheckoutState,
|
||||
});
|
||||
usePullRequestAutoAdd({
|
||||
workspaceKey: persistenceKey,
|
||||
serverId: normalizedServerId,
|
||||
cwd: workspaceDirectory,
|
||||
enabled: canObservePullRequest(isRouteFocused, isGitCheckout),
|
||||
});
|
||||
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
const isCompactExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
|
||||
);
|
||||
const toggleFileExplorerForCheckout = usePanelStore(
|
||||
@@ -1920,15 +2013,14 @@ function WorkspaceScreenContent({
|
||||
};
|
||||
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
if (!activeExplorerCheckout) {
|
||||
return;
|
||||
}
|
||||
toggleFileExplorerForCheckout({
|
||||
isCompact: isMobile,
|
||||
checkout: activeExplorerCheckout,
|
||||
});
|
||||
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
|
||||
const isDesktopExplorerOpen = useWorkspaceLayoutStore((state) =>
|
||||
isWorkspaceExplorerPaneOpen(state, persistenceKey),
|
||||
);
|
||||
const isExplorerOpen = resolveExplorerOpen(
|
||||
isMobile,
|
||||
isCompactExplorerOpen,
|
||||
isDesktopExplorerOpen,
|
||||
);
|
||||
|
||||
const hasDiffStat = useMemo(() => Boolean(workspaceDescriptor?.diffStat), [workspaceDescriptor]);
|
||||
const explorerToggleStyle = useCallback(
|
||||
@@ -1996,6 +2088,14 @@ function WorkspaceScreenContent({
|
||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
toggleWorkspaceExplorerPane({
|
||||
isCompact: isMobile,
|
||||
persistenceKey,
|
||||
checkout: activeExplorerCheckout,
|
||||
toggleCompact: toggleFileExplorerForCheckout,
|
||||
});
|
||||
}, [activeExplorerCheckout, isMobile, persistenceKey, toggleFileExplorerForCheckout]);
|
||||
const paneFocusSuppressedRef = useRef(false);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
@@ -2373,58 +2473,30 @@ function WorkspaceScreenContent({
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFileFromChatInSidePane = useCallback(
|
||||
(input: {
|
||||
location: WorkspaceFileLocation;
|
||||
sourcePaneId?: string;
|
||||
parentTabId?: string | null;
|
||||
}) => {
|
||||
const handleOpenAssistantFileInExplorerPane = useCallback(
|
||||
(input: { location: WorkspaceFileLocation; parentTabId?: string | null }) => {
|
||||
const location = normalizeWorkspaceFileLocation(input.location);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
||||
if (!persistenceKey || isMobile || !supportsDesktopPaneSplits()) {
|
||||
handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
|
||||
return;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
|
||||
const placement = resolveSideFileOpenPlacement({
|
||||
layout: workspaceLayout,
|
||||
sourcePaneId: input.sourcePaneId,
|
||||
tabs: uiTabs,
|
||||
target,
|
||||
});
|
||||
if (placement.kind === "focus-side-pane") {
|
||||
focusWorkspacePane(persistenceKey, placement.paneId);
|
||||
} else if (placement.kind === "split-side-pane") {
|
||||
splitWorkspacePaneEmpty(persistenceKey, {
|
||||
targetPaneId: placement.paneId,
|
||||
position: "right",
|
||||
const tabId = useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabInExplorerPaneFocused(persistenceKey, {
|
||||
target,
|
||||
parentTabId: input.parentTabId,
|
||||
});
|
||||
}
|
||||
|
||||
const tabId = input.parentTabId
|
||||
? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId)
|
||||
: openWorkspaceTabFocused(persistenceKey, target);
|
||||
if (tabId) {
|
||||
requestFileNavigation(tabId);
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[
|
||||
handleOpenFileFromChat,
|
||||
isMobile,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
openWorkspaceChildTabFocused,
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
requestFileNavigation,
|
||||
splitWorkspacePaneEmpty,
|
||||
uiTabs,
|
||||
workspaceLayout,
|
||||
],
|
||||
[handleOpenFileFromChat, isMobile, navigateToTabId, persistenceKey, requestFileNavigation],
|
||||
);
|
||||
|
||||
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
|
||||
@@ -2442,9 +2514,8 @@ function WorkspaceScreenContent({
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
}
|
||||
if (request.disposition === "side") {
|
||||
handleOpenFileFromChatInSidePane({
|
||||
handleOpenAssistantFileInExplorerPane({
|
||||
location: request.location,
|
||||
sourcePaneId: paneId ?? undefined,
|
||||
parentTabId,
|
||||
});
|
||||
return;
|
||||
@@ -2523,6 +2594,8 @@ function WorkspaceScreenContent({
|
||||
browser: t("workspace.tabs.fallback.browser"),
|
||||
agent: t("workspace.tabs.fallback.agent"),
|
||||
changes: t("panels.diff.changesLabel"),
|
||||
files: t("panels.files.label"),
|
||||
pullRequest: t("panels.pullRequest.label"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -6,11 +6,11 @@ const metrics = {
|
||||
actionsReservedWidth: 120,
|
||||
rowPaddingHorizontal: 8,
|
||||
tabGap: 4,
|
||||
maxTabWidth: 200,
|
||||
maxTabWidth: 160,
|
||||
tabIconWidth: 14,
|
||||
tabHorizontalPadding: 12,
|
||||
tabHorizontalPadding: 8,
|
||||
estimatedCharWidth: 7,
|
||||
closeButtonWidth: 22,
|
||||
closeButtonWidth: 0,
|
||||
};
|
||||
|
||||
describe("computeWorkspaceTabLayout", () => {
|
||||
@@ -25,7 +25,7 @@ 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([200, 200, 200]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([160, 160, 160]);
|
||||
});
|
||||
|
||||
it("shrinks equal-width tabs proportionally to fit the pane", () => {
|
||||
@@ -41,7 +41,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the split width for evenly sized tabs when space is available", () => {
|
||||
it("keeps evenly sized tabs at the cap when the split width exceeds it", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 743,
|
||||
tabLabelLengths: [8, 8, 8, 8],
|
||||
@@ -55,32 +55,32 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([175, 175, 175, 175]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([160, 160, 160, 160]);
|
||||
});
|
||||
|
||||
it("collapses to icon-only before allowing horizontal scroll fallback", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 388,
|
||||
viewportWidth: 268,
|
||||
tabLabelLengths: [14, 14, 14, 14],
|
||||
metrics,
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]);
|
||||
expect(result.items.every((item) => !item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows horizontal scroll only when icon-only tabs still cannot fit", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 300,
|
||||
viewportWidth: 267,
|
||||
tabLabelLengths: [14, 14, 14, 14],
|
||||
metrics,
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(true);
|
||||
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]);
|
||||
expect(result.items.every((item) => !item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -151,6 +151,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
|
||||
}
|
||||
if (tab.target.kind === "files" || tab.target.kind === "pull_request") {
|
||||
return `workspace-${tab.target.kind}-close`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,16 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
|
||||
import {
|
||||
buildOpenFileExplorerPatch,
|
||||
buildToggleFileExplorerPatch,
|
||||
clampExplorerFilesSplitRatio,
|
||||
clampTreeRailWidth,
|
||||
clampExplorerWidth,
|
||||
clampSidebarWidth,
|
||||
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
DEFAULT_TREE_RAIL_WIDTH,
|
||||
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_FILES_SPLIT_RATIO,
|
||||
MAX_TREE_RAIL_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_EXPLORER_FILES_SPLIT_RATIO,
|
||||
MIN_TREE_RAIL_WIDTH,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
migratePanelState,
|
||||
@@ -51,15 +51,16 @@ export type {
|
||||
SortOption,
|
||||
} from "./state";
|
||||
export {
|
||||
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
DEFAULT_TREE_RAIL_WIDTH,
|
||||
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_FILES_SPLIT_RATIO,
|
||||
MAX_TREE_RAIL_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_EXPLORER_FILES_SPLIT_RATIO,
|
||||
MIN_TREE_RAIL_WIDTH,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
clampTreeRailWidth,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
selectPanelVisibility,
|
||||
@@ -88,7 +89,7 @@ export interface PanelState {
|
||||
explorerWidth: number;
|
||||
explorerSortOption: SortOption;
|
||||
explorerShowHiddenFiles: boolean;
|
||||
explorerFilesSplitRatio: number;
|
||||
treeRailWidth: number;
|
||||
|
||||
// Actions
|
||||
toggleFocusMode: () => void;
|
||||
@@ -117,7 +118,7 @@ export interface PanelState {
|
||||
setExplorerWidth: (width: number) => void;
|
||||
setExplorerSortOption: (option: SortOption) => void;
|
||||
toggleExplorerShowHiddenFiles: () => void;
|
||||
setExplorerFilesSplitRatio: (ratio: number) => void;
|
||||
setTreeRailWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = isWeb;
|
||||
@@ -153,7 +154,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
explorerSortOption: "name",
|
||||
explorerShowHiddenFiles: true,
|
||||
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
|
||||
|
||||
toggleFocusMode: () =>
|
||||
set((state) => ({
|
||||
@@ -303,16 +304,11 @@ export const usePanelStore = create<PanelState>()(
|
||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||
toggleExplorerShowHiddenFiles: () =>
|
||||
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
|
||||
setExplorerFilesSplitRatio: (ratio) =>
|
||||
set({
|
||||
explorerFilesSplitRatio: Number.isFinite(ratio)
|
||||
? clampExplorerFilesSplitRatio(ratio)
|
||||
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
}),
|
||||
setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }),
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 12,
|
||||
version: 13,
|
||||
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
|
||||
migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
|
||||
partialize: (state) => ({
|
||||
@@ -326,7 +322,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
|
||||
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
||||
treeRailWidth: state.treeRailWidth,
|
||||
}),
|
||||
},
|
||||
),
|
||||
@@ -356,7 +352,7 @@ export function usePanelState(isMobile: boolean) {
|
||||
const explorerTabByCheckout = usePanelStore((state) => state.explorerTabByCheckout);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const explorerSortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const explorerFilesSplitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
|
||||
const treeRailWidth = usePanelStore((state) => state.treeRailWidth);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
@@ -364,7 +360,7 @@ export function usePanelState(isMobile: boolean) {
|
||||
);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const setExplorerSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const setExplorerFilesSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
|
||||
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
|
||||
|
||||
return {
|
||||
isAgentListOpen,
|
||||
@@ -377,12 +373,12 @@ export function usePanelState(isMobile: boolean) {
|
||||
explorerTabByCheckout,
|
||||
explorerWidth,
|
||||
explorerSortOption,
|
||||
explorerFilesSplitRatio,
|
||||
treeRailWidth,
|
||||
setExplorerTab,
|
||||
setExplorerTabForCheckout,
|
||||
activateExplorerTabForCheckout,
|
||||
setExplorerWidth,
|
||||
setExplorerSortOption,
|
||||
setExplorerFilesSplitRatio,
|
||||
setTreeRailWidth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
buildOpenFileExplorerPatch,
|
||||
buildToggleFileExplorerPatch,
|
||||
DEFAULT_TREE_RAIL_WIDTH,
|
||||
migratePanelState,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
@@ -119,6 +120,13 @@ describe("panel-store migration", () => {
|
||||
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
|
||||
});
|
||||
|
||||
it("replaces the dead explorer split ratio with the shared tree rail width", () => {
|
||||
const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12, { isWeb: false });
|
||||
|
||||
expect(state.explorerFilesSplitRatio).toBeUndefined();
|
||||
expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH);
|
||||
});
|
||||
|
||||
it("drops persisted compact panel state so cold starts return to content", () => {
|
||||
const state = migratePanelState(
|
||||
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
|
||||
|
||||
@@ -31,9 +31,9 @@ export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
|
||||
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
|
||||
|
||||
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
|
||||
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
|
||||
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
|
||||
export const DEFAULT_TREE_RAIL_WIDTH = 320;
|
||||
export const MIN_TREE_RAIL_WIDTH = 200;
|
||||
export const MAX_TREE_RAIL_WIDTH = 600;
|
||||
|
||||
export interface PanelVisibilityState {
|
||||
isAgentListOpen: boolean;
|
||||
@@ -70,8 +70,8 @@ export function clampExplorerWidth(width: number): number {
|
||||
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
|
||||
}
|
||||
|
||||
export function clampExplorerFilesSplitRatio(ratio: number): number {
|
||||
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
|
||||
export function clampTreeRailWidth(width: number): number {
|
||||
return clampNumber(width, MIN_TREE_RAIL_WIDTH, MAX_TREE_RAIL_WIDTH);
|
||||
}
|
||||
|
||||
export function selectPanelVisibility(
|
||||
@@ -190,6 +190,7 @@ export const PanelPersistedStateSchema = z.strictObject({
|
||||
explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
|
||||
explorerShowHiddenFiles: z.boolean().optional(),
|
||||
explorerFilesSplitRatio: z.number().optional(),
|
||||
treeRailWidth: z.number().optional(),
|
||||
});
|
||||
|
||||
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
|
||||
@@ -198,11 +199,6 @@ function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): vo
|
||||
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
|
||||
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
||||
}
|
||||
if (typeof state.explorerFilesSplitRatio !== "number") {
|
||||
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
|
||||
} else {
|
||||
state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio);
|
||||
}
|
||||
}
|
||||
|
||||
function migratePanelV3Explorer(state: MigratablePanelState, isWeb: boolean): void {
|
||||
@@ -253,6 +249,15 @@ function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
|
||||
}
|
||||
}
|
||||
|
||||
function migrateTreeRailWidth(state: MigratablePanelState, version: number): void {
|
||||
if (version < 13 || typeof state.treeRailWidth !== "number") {
|
||||
delete state.explorerFilesSplitRatio;
|
||||
state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH;
|
||||
return;
|
||||
}
|
||||
state.treeRailWidth = clampTreeRailWidth(state.treeRailWidth);
|
||||
}
|
||||
|
||||
export function migratePanelState(
|
||||
persistedState: unknown,
|
||||
version: number,
|
||||
@@ -302,6 +307,7 @@ export function migratePanelState(
|
||||
if (typeof state.explorerShowHiddenFiles !== "boolean") {
|
||||
state.explorerShowHiddenFiles = true;
|
||||
}
|
||||
migrateTreeRailWidth(state, version);
|
||||
if (version < 12) {
|
||||
// Compact panel position is transient UI state. Cold starts always begin
|
||||
// at content, regardless of what an older version persisted.
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SplitPane {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
focusedTabId: string | null;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface SplitGroup {
|
||||
@@ -240,6 +241,7 @@ function createPaneNode(input: {
|
||||
id: string;
|
||||
tabs?: WorkspaceTab[];
|
||||
focusedTabId?: string | null;
|
||||
hidden?: boolean;
|
||||
}): SplitNodeInternal {
|
||||
const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []);
|
||||
const tabIds = normalizedTabs.map((tab) => tab.tabId);
|
||||
@@ -254,6 +256,7 @@ function createPaneNode(input: {
|
||||
tabs: normalizedTabs,
|
||||
tabIds,
|
||||
focusedTabId,
|
||||
...(input.hidden === true ? { hidden: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -511,7 +514,7 @@ function insertChildIntoGroup(
|
||||
|
||||
function listPaneIds(node: SplitNodeInternal): string[] {
|
||||
if (node.kind === "pane") {
|
||||
return [node.pane.id];
|
||||
return node.pane.hidden === true ? [] : [node.pane.id];
|
||||
}
|
||||
const next: string[] = [];
|
||||
for (const child of node.group.children) {
|
||||
@@ -562,6 +565,7 @@ function normalizePaneAfterTabChange(pane: SplitPaneInternal): SplitPaneInternal
|
||||
tabs,
|
||||
tabIds,
|
||||
focusedTabId,
|
||||
...(pane.hidden === true ? { hidden: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -584,6 +588,7 @@ function normalizePaneNode(rawPane: SplitPaneInternal | undefined): SplitNodeInt
|
||||
id: paneId,
|
||||
tabs: mergedTabs,
|
||||
focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null,
|
||||
hidden: rawPane?.hidden === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -756,6 +761,7 @@ function focusTabInPane(root: SplitNodeInternal, paneId: string, tabId: string):
|
||||
pane: normalizePaneAfterTabChange({
|
||||
...node.pane,
|
||||
focusedTabId: tabId,
|
||||
hidden: undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -972,7 +978,7 @@ export function collectAllTabs(root: SplitNode): WorkspaceTab[] {
|
||||
export function collectAllPanes(root: SplitNode): SplitPane[] {
|
||||
const internalRoot = asInternalNode(root);
|
||||
if (internalRoot.kind === "pane") {
|
||||
return [internalRoot.pane];
|
||||
return internalRoot.pane.hidden === true ? [] : [internalRoot.pane];
|
||||
}
|
||||
return internalRoot.group.children.flatMap((child) => collectAllPanes(child));
|
||||
}
|
||||
@@ -995,6 +1001,7 @@ function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal
|
||||
id: node.pane.id,
|
||||
tabs: nextTabs,
|
||||
focusedTabId: node.pane.focusedTabId,
|
||||
hidden: node.pane.hidden,
|
||||
});
|
||||
}
|
||||
return createGroupNode({
|
||||
@@ -1028,7 +1035,7 @@ export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined):
|
||||
return null;
|
||||
}
|
||||
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
|
||||
if (!focusedPane?.focusedTabId) {
|
||||
if (!focusedPane?.focusedTabId || focusedPane.hidden === true) {
|
||||
return null;
|
||||
}
|
||||
const focusedTab = collectAllTabs(layout.root).find(
|
||||
@@ -1221,7 +1228,11 @@ export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pane.focusedTabId === input.tabId && layout.focusedPaneId === pane.id) {
|
||||
if (
|
||||
pane.focusedTabId === input.tabId &&
|
||||
layout.focusedPaneId === pane.id &&
|
||||
pane.hidden !== true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1448,7 +1459,8 @@ export function splitPaneEmptyInLayout(
|
||||
export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const sourcePane = findPaneContainingTab(layout.root, input.tabId);
|
||||
if (!sourcePane || !findPaneById(layout.root, input.toPaneId)) {
|
||||
const targetPane = findPaneById(layout.root, input.toPaneId);
|
||||
if (!sourcePane || !targetPane || targetPane.hidden === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1472,19 +1484,59 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
|
||||
}
|
||||
|
||||
export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null {
|
||||
if (!findPaneById(input.layout.root, input.paneId)) {
|
||||
const pane = findPaneById(input.layout.root, input.paneId);
|
||||
if (!pane) {
|
||||
return null;
|
||||
}
|
||||
if (input.layout.focusedPaneId === input.paneId) {
|
||||
if (input.layout.focusedPaneId === input.paneId && pane.hidden !== true) {
|
||||
return null;
|
||||
}
|
||||
return withNormalizedParentTabMap({
|
||||
root: input.layout.root,
|
||||
root:
|
||||
pane.hidden === true
|
||||
? updatePaneInTree(asInternalNode(input.layout.root), {
|
||||
paneId: input.paneId,
|
||||
updater: (currentPane) => ({ ...currentPane, hidden: undefined }),
|
||||
})
|
||||
: input.layout.root,
|
||||
focusedPaneId: input.paneId,
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function setPaneHiddenInLayout(input: {
|
||||
layout: WorkspaceLayout;
|
||||
paneId: string;
|
||||
hidden: boolean;
|
||||
}): WorkspaceLayout | null {
|
||||
const pane = findPaneById(input.layout.root, input.paneId);
|
||||
const isHidden = pane?.hidden === true;
|
||||
if (!pane || isHidden === input.hidden) {
|
||||
return null;
|
||||
}
|
||||
if (input.hidden && collectAllPanes(input.layout.root).length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = updatePaneInTree(asInternalNode(input.layout.root), {
|
||||
paneId: input.paneId,
|
||||
updater: (currentPane) => ({
|
||||
...currentPane,
|
||||
hidden: input.hidden ? true : undefined,
|
||||
}),
|
||||
});
|
||||
const focusedPaneId =
|
||||
input.hidden && input.layout.focusedPaneId === input.paneId
|
||||
? (collectAllPanes(root)[0]?.id ?? null)
|
||||
: input.layout.focusedPaneId;
|
||||
|
||||
return withNormalizedParentTabMap({
|
||||
root,
|
||||
focusedPaneId,
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
return withNormalizedParentTabMap({
|
||||
|
||||
@@ -84,6 +84,7 @@ function createPane(input: {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
focusedTabId?: string | null;
|
||||
hidden?: boolean;
|
||||
targetsByTabId?: Record<string, WorkspaceTab["target"]>;
|
||||
}): SplitNode {
|
||||
const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId]));
|
||||
@@ -93,6 +94,7 @@ function createPane(input: {
|
||||
id: input.id,
|
||||
tabIds: input.tabIds,
|
||||
focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null,
|
||||
...(input.hidden === true ? { hidden: true } : {}),
|
||||
tabs,
|
||||
} as SplitPane,
|
||||
};
|
||||
@@ -200,6 +202,14 @@ describe("workspace-layout-store helpers", () => {
|
||||
|
||||
expect(getFocusedBrowserId({ root, focusedPaneId: "main" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps tabs in hidden panes while excluding those panes from focus helpers", () => {
|
||||
const root = createPane({ id: "hidden", tabIds: ["tab-a"], hidden: true });
|
||||
|
||||
expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual(["tab-a"]);
|
||||
expect(collectAllPanes(root)).toEqual([]);
|
||||
expect(getFocusedBrowserId({ root, focusedPaneId: "hidden" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-layout-store tree transforms", () => {
|
||||
@@ -314,6 +324,8 @@ describe("workspace-layout-store actions", () => {
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
explorerPaneIdByWorkspace: {},
|
||||
acknowledgedPullRequestByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,6 +347,142 @@ describe("workspace-layout-store actions", () => {
|
||||
await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it("persists first-class pane targets, visibility, and focus", async () => {
|
||||
await AsyncStorage.removeItem("workspace-layout-state");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
||||
await source.persist.rehydrate();
|
||||
|
||||
source.getState().openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const focusedAgentTabId = source
|
||||
.getState()
|
||||
.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-2" });
|
||||
const explorer = source.getState().ensureExplorerPane(workspaceKey);
|
||||
expect(explorer).toBeTruthy();
|
||||
source.getState().openTabFocused(workspaceKey, { kind: "files" });
|
||||
source.getState().observePullRequest(workspaceKey, "pr:1");
|
||||
source.getState().hidePane(workspaceKey, explorer!.paneId);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await AsyncStorage.getItem("workspace-layout-state")).not.toBeNull();
|
||||
});
|
||||
|
||||
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
||||
await restored.persist.rehydrate();
|
||||
const state = restored.getState();
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(focusedAgentTabId);
|
||||
expect(findPaneById(layout.root, explorer!.paneId)?.hidden).toBe(true);
|
||||
expect(collectAllTabs(layout.root).map((tab) => tab.target.kind)).toEqual([
|
||||
"agent",
|
||||
"agent",
|
||||
"files",
|
||||
"pull_request",
|
||||
]);
|
||||
expect(state.explorerPaneIdByWorkspace[workspaceKey]).toBe(explorer!.paneId);
|
||||
expect(state.acknowledgedPullRequestByWorkspace[workspaceKey]).toBe("pr:1");
|
||||
});
|
||||
|
||||
it("auto-adds each detected pull request once and respects a deliberate close", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.observePullRequest(workspaceKey, null);
|
||||
expect(store.getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
|
||||
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1");
|
||||
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.target.kind)).toEqual([
|
||||
"pull_request",
|
||||
]);
|
||||
|
||||
store.closeTab(workspaceKey, "pull_request");
|
||||
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1");
|
||||
expect(store.getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
|
||||
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/2");
|
||||
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.target.kind)).toEqual([
|
||||
"pull_request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("places an auto-added pull request in the registered explorer pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
useWorkspaceLayoutIds("explorer");
|
||||
const explorerPaneId = store.splitPaneEmpty(workspaceKey, {
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
store.setExplorerPaneId(workspaceKey, explorerPaneId);
|
||||
|
||||
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1");
|
||||
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId);
|
||||
});
|
||||
|
||||
it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
useWorkspaceLayoutIds("explorer");
|
||||
|
||||
const tabId = store.openTabInExplorerPaneFocused(workspaceKey, {
|
||||
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
||||
parentTabId: "agent_agent-a",
|
||||
});
|
||||
const createdState = workspaceLayoutStore.getState();
|
||||
const explorerPaneId = createdState.explorerPaneIdByWorkspace[workspaceKey];
|
||||
|
||||
expect(explorerPaneId).toBe("pane_explorer");
|
||||
expect(
|
||||
findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id,
|
||||
).toBe(explorerPaneId);
|
||||
expect(collectAllTabs(createdState.layoutByWorkspace[workspaceKey].root)).toContainEqual({
|
||||
tabId,
|
||||
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
|
||||
store.hidePane(workspaceKey, explorerPaneId!);
|
||||
store.openTabInExplorerPaneFocused(workspaceKey, {
|
||||
target: { kind: "file", path: "/repo/worktree/b.ts" },
|
||||
});
|
||||
const revealedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(findPaneById(revealedLayout.root, explorerPaneId)?.hidden).toBeUndefined();
|
||||
expect(findPaneContainingTab(revealedLayout.root, "file_/repo/worktree/b.ts")?.id).toBe(
|
||||
explorerPaneId,
|
||||
);
|
||||
});
|
||||
|
||||
it("moves a canonical duplicate file tab into the revealed explorer pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
useWorkspaceLayoutIds("explorer");
|
||||
const tabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
});
|
||||
const explorerPane = store.ensureExplorerPane(workspaceKey);
|
||||
store.hidePane(workspaceKey, explorerPane!.paneId);
|
||||
|
||||
const duplicateTabId = store.openTabInExplorerPaneFocused(workspaceKey, {
|
||||
target: { kind: "file", path: "/repo/worktree/a.ts", lineStart: 12 },
|
||||
parentTabId: "agent_agent-a",
|
||||
});
|
||||
const state = workspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(duplicateTabId).toBe(tabId);
|
||||
expect(collectAllTabs(layout.root).filter((tab) => tab.tabId === tabId)).toHaveLength(1);
|
||||
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPane?.paneId);
|
||||
expect(findPaneById(layout.root, explorerPane?.paneId)?.hidden).toBeUndefined();
|
||||
expect(layout.focusedPaneId).toBe(explorerPane?.paneId);
|
||||
expect(findPaneById(layout.root, explorerPane?.paneId)?.focusedTabId).toBe(tabId);
|
||||
});
|
||||
|
||||
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
|
||||
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
@@ -641,6 +789,36 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(findPaneById(layout.root, newPaneId)?.focusedTabId).toBe(draftTabId);
|
||||
});
|
||||
|
||||
it("hides and shows a pane without changing its tabs or split sizes", () => {
|
||||
useWorkspaceLayoutIds("88888888-8888-8888-8888-888888888888");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "draft", draftId: "main-tab" });
|
||||
const paneId = store.splitPaneEmpty(workspaceKey, {
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
})!;
|
||||
store.openTabFocused(workspaceKey, { kind: "working_diff" });
|
||||
const sizes = expectGroup(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root)
|
||||
.group.sizes;
|
||||
|
||||
store.hidePane(workspaceKey, paneId);
|
||||
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneById(layout.root, paneId)).toMatchObject({
|
||||
hidden: true,
|
||||
tabIds: ["working_diff"],
|
||||
});
|
||||
expect(expectGroup(layout.root).group.sizes).toEqual(sizes);
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.showPane(workspaceKey, paneId);
|
||||
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: ["working_diff"] });
|
||||
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
||||
expect(expectGroup(layout.root).group.sizes).toEqual(sizes);
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
@@ -671,6 +849,44 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("focusTab reveals a hidden pane and focuses its requested tab without changing sizes", () => {
|
||||
useWorkspaceLayoutIds("89898989-8989-8989-8989-898989898989");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const targetTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "terminal",
|
||||
terminalId: "term-hidden",
|
||||
})!;
|
||||
const paneId = store.splitPane(workspaceKey, {
|
||||
tabId: targetTabId,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
})!;
|
||||
store.openTabFocused(workspaceKey, { kind: "working_diff" });
|
||||
store.focusPane(workspaceKey, "main");
|
||||
const group = expectGroup(
|
||||
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
|
||||
).group;
|
||||
store.resizeSplit(workspaceKey, group.id, [0.7, 0.3]);
|
||||
store.hidePane(workspaceKey, paneId);
|
||||
const splitSizes = workspaceLayoutStore.getState().splitSizesByWorkspace[workspaceKey];
|
||||
|
||||
store.focusTab(workspaceKey, targetTabId);
|
||||
|
||||
const state = workspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe(paneId);
|
||||
expect(findPaneById(layout.root, paneId)).toMatchObject({
|
||||
focusedTabId: targetTabId,
|
||||
tabIds: [targetTabId, "working_diff"],
|
||||
});
|
||||
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
||||
expect(expectGroup(layout.root).group.sizes).toEqual(group.sizes);
|
||||
expect(state.splitSizesByWorkspace[workspaceKey]).toBe(splitSizes);
|
||||
});
|
||||
|
||||
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
||||
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
@@ -946,6 +1162,26 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
});
|
||||
|
||||
it("focusPane reveals an explicitly targeted hidden pane", () => {
|
||||
useWorkspaceLayoutIds("57575757-5757-5757-5757-575757575757");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const paneId = store.splitPaneEmpty(workspaceKey, {
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
})!;
|
||||
store.focusPane(workspaceKey, "main");
|
||||
store.hidePane(workspaceKey, paneId);
|
||||
|
||||
store.focusPane(workspaceKey, paneId);
|
||||
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe(paneId);
|
||||
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
||||
});
|
||||
|
||||
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
|
||||
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
@@ -1170,6 +1406,8 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(persisted).toEqual({
|
||||
layoutByWorkspace: { [workspaceKey]: layout },
|
||||
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
|
||||
explorerPaneIdByWorkspace: {},
|
||||
acknowledgedPullRequestByWorkspace: {},
|
||||
});
|
||||
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
|
||||
{
|
||||
@@ -1302,6 +1540,8 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
explorerPaneIdByWorkspace: {},
|
||||
acknowledgedPullRequestByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1338,6 +1578,8 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
explorerPaneIdByWorkspace: {},
|
||||
acknowledgedPullRequestByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
reorderFocusedPaneTabsInLayout,
|
||||
reorderPaneTabsInLayout,
|
||||
retargetTabInLayout,
|
||||
setPaneHiddenInLayout,
|
||||
splitPaneEmptyInLayout,
|
||||
splitPaneInLayout,
|
||||
stripEphemeralTabsFromLayout,
|
||||
@@ -75,6 +76,8 @@ interface WorkspaceLayoutStore {
|
||||
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
explorerPaneIdByWorkspace: Record<string, string | null>;
|
||||
acknowledgedPullRequestByWorkspace: Record<string, string>;
|
||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openChildTabFocused: (
|
||||
workspaceKey: string,
|
||||
@@ -82,6 +85,12 @@ interface WorkspaceLayoutStore {
|
||||
parentTabId: string,
|
||||
) => string | null;
|
||||
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null;
|
||||
openTabInExplorerPaneFocused: (
|
||||
workspaceKey: string,
|
||||
input: { target: WorkspaceTabTarget; parentTabId?: string | null },
|
||||
) => string | null;
|
||||
observePullRequest: (workspaceKey: string, identity: string | null) => void;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
|
||||
@@ -107,6 +116,9 @@ interface WorkspaceLayoutStore {
|
||||
) => string | null;
|
||||
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
||||
focusPane: (workspaceKey: string, paneId: string) => void;
|
||||
hidePane: (workspaceKey: string, paneId: string) => void;
|
||||
showPane: (workspaceKey: string, paneId: string) => void;
|
||||
setExplorerPaneId: (workspaceKey: string, paneId: string | null) => void;
|
||||
unfocusPane: (workspaceKey: string) => string | null;
|
||||
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
||||
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||
@@ -118,6 +130,11 @@ interface WorkspaceLayoutStore {
|
||||
purgeWorkspace: (workspaceKey: string) => void;
|
||||
}
|
||||
|
||||
interface EnsureExplorerPaneResult {
|
||||
paneId: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
interface WorkspaceFocusRestorationState {
|
||||
restorePaneId: string | null;
|
||||
tokens: string[];
|
||||
@@ -147,6 +164,8 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }),
|
||||
z.strictObject({ kind: z.literal("browser"), browserId: z.string() }),
|
||||
z.strictObject({ kind: z.literal("files") }),
|
||||
z.strictObject({ kind: z.literal("pull_request") }),
|
||||
z.strictObject({
|
||||
kind: z.literal("file"),
|
||||
path: z.string(),
|
||||
@@ -179,6 +198,7 @@ const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
|
||||
tabIds: z.array(z.string()),
|
||||
focusedTabId: z.string().nullable(),
|
||||
tabs: z.array(WorkspaceTabStorageSchema).optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
@@ -200,6 +220,8 @@ const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject(
|
||||
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
|
||||
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
|
||||
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
|
||||
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
|
||||
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -308,6 +330,8 @@ export function createWorkspaceLayoutStore(
|
||||
pendingAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
explorerPaneIdByWorkspace: {},
|
||||
acknowledgedPullRequestByWorkspace: {},
|
||||
openTabFocused: (workspaceKey, target) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
@@ -408,6 +432,104 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
ensureExplorerPane: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = get();
|
||||
const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const explorerPaneId = store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null;
|
||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
||||
if (explorerPane) {
|
||||
store.focusPane(normalizedWorkspaceKey, explorerPane.id);
|
||||
return { paneId: explorerPane.id, created: false };
|
||||
}
|
||||
|
||||
const targetPaneId =
|
||||
findPaneById(layout.root, layout.focusedPaneId)?.id ??
|
||||
collectAllPanes(layout.root)[0]?.id;
|
||||
if (!targetPaneId) {
|
||||
return null;
|
||||
}
|
||||
const paneId = store.splitPaneEmpty(normalizedWorkspaceKey, {
|
||||
targetPaneId,
|
||||
position: "right",
|
||||
});
|
||||
if (!paneId) {
|
||||
return null;
|
||||
}
|
||||
get().setExplorerPaneId(normalizedWorkspaceKey, paneId);
|
||||
return { paneId, created: true };
|
||||
},
|
||||
openTabInExplorerPaneFocused: (workspaceKey, input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = get();
|
||||
const explorerPane = store.ensureExplorerPane(normalizedWorkspaceKey);
|
||||
if (!explorerPane) {
|
||||
return null;
|
||||
}
|
||||
const parentTabId = trimNonEmpty(input.parentTabId);
|
||||
const tabId = parentTabId
|
||||
? get().openChildTabFocused(normalizedWorkspaceKey, normalizedTarget, parentTabId)
|
||||
: get().openTabFocused(normalizedWorkspaceKey, normalizedTarget);
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const currentPane = findPaneContainingTab(layout.root, tabId);
|
||||
if (currentPane?.id !== explorerPane.paneId) {
|
||||
get().moveTabToPane(normalizedWorkspaceKey, tabId, explorerPane.paneId);
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
observePullRequest: (workspaceKey, identity) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedIdentity = trimNonEmpty(identity);
|
||||
if (!normalizedWorkspaceKey || !normalizedIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (
|
||||
state.acknowledgedPullRequestByWorkspace[normalizedWorkspaceKey] ===
|
||||
normalizedIdentity
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const explorerPaneId = state.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null;
|
||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
||||
const placementLayout = explorerPane
|
||||
? (focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout)
|
||||
: layout;
|
||||
const result = openTabInLayoutFocused({
|
||||
layout: placementLayout,
|
||||
target: { kind: "pull_request" },
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
acknowledgedPullRequestByWorkspace: {
|
||||
...state.acknowledgedPullRequestByWorkspace,
|
||||
[normalizedWorkspaceKey]: normalizedIdentity,
|
||||
},
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
closeTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
@@ -722,6 +844,67 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
hidePane: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = setPaneHiddenInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
paneId: normalizedPaneId,
|
||||
hidden: true,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
showPane: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = setPaneHiddenInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
paneId: normalizedPaneId,
|
||||
hidden: false,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
setExplorerPaneId: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return;
|
||||
}
|
||||
set((state) => ({
|
||||
explorerPaneIdByWorkspace: {
|
||||
...state.explorerPaneIdByWorkspace,
|
||||
[normalizedWorkspaceKey]: trimNonEmpty(paneId),
|
||||
},
|
||||
}));
|
||||
},
|
||||
unfocusPane: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
@@ -787,7 +970,8 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
}
|
||||
|
||||
const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null;
|
||||
const restorePane = findPaneById(layout.root, restoration.restorePaneId);
|
||||
const restorePaneId = restorePane?.hidden === true ? null : (restorePane?.id ?? null);
|
||||
if (!restorePaneId) {
|
||||
return {
|
||||
focusRestorationByWorkspace: remainingRestorations,
|
||||
@@ -991,7 +1175,9 @@ export function createWorkspaceLayoutStore(
|
||||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace;
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
||||
normalizedWorkspaceKey in state.explorerPaneIdByWorkspace ||
|
||||
normalizedWorkspaceKey in state.acknowledgedPullRequestByWorkspace;
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
@@ -1007,6 +1193,12 @@ export function createWorkspaceLayoutStore(
|
||||
state.hiddenAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
||||
state.focusRestorationByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _explorerPane, ...explorerPaneIdByWorkspace } =
|
||||
state.explorerPaneIdByWorkspace;
|
||||
const {
|
||||
[normalizedWorkspaceKey]: _acknowledgedPullRequest,
|
||||
...acknowledgedPullRequestByWorkspace
|
||||
} = state.acknowledgedPullRequestByWorkspace;
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
@@ -1014,6 +1206,8 @@ export function createWorkspaceLayoutStore(
|
||||
pendingAgentIdsByWorkspace,
|
||||
hiddenAgentIdsByWorkspace,
|
||||
focusRestorationByWorkspace,
|
||||
explorerPaneIdByWorkspace,
|
||||
acknowledgedPullRequestByWorkspace,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -1034,6 +1228,8 @@ export function createWorkspaceLayoutStore(
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
||||
explorerPaneIdByWorkspace: state.explorerPaneIdByWorkspace,
|
||||
acknowledgedPullRequestByWorkspace: state.acknowledgedPullRequestByWorkspace,
|
||||
};
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
@@ -1045,6 +1241,9 @@ export function createWorkspaceLayoutStore(
|
||||
...currentState,
|
||||
layoutByWorkspace: result.data.layoutByWorkspace,
|
||||
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
|
||||
explorerPaneIdByWorkspace: result.data.explorerPaneIdByWorkspace ?? {},
|
||||
acknowledgedPullRequestByWorkspace:
|
||||
result.data.acknowledgedPullRequestByWorkspace ?? {},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import type { SplitNode } from "@/stores/workspace-layout-store";
|
||||
import { findAdjacentPane } from "./split-navigation";
|
||||
|
||||
function createPaneNode(id: string): SplitNode {
|
||||
function createPaneNode(id: string, hidden = false): SplitNode {
|
||||
return {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id,
|
||||
tabIds: [],
|
||||
focusedTabId: null,
|
||||
...(hidden ? { hidden: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -80,4 +81,15 @@ describe("findAdjacentPane", () => {
|
||||
expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left");
|
||||
expect(findAdjacentPane(root, "bottom-right", "up")).toBe("top");
|
||||
});
|
||||
|
||||
it("skips hidden panes", () => {
|
||||
const root = createGroupNode({
|
||||
direction: "horizontal",
|
||||
sizes: [0.3, 0.3, 0.4],
|
||||
children: [createPaneNode("left"), createPaneNode("hidden", true), createPaneNode("right")],
|
||||
});
|
||||
|
||||
expect(findAdjacentPane(root, "left", "right")).toBe("right");
|
||||
expect(findAdjacentPane(root, "hidden", "right")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,9 @@ function collectPaneBounds(
|
||||
bounds: { left: number; top: number; right: number; bottom: number },
|
||||
): PaneBounds[] {
|
||||
if (node.kind === "pane") {
|
||||
if (node.pane.hidden === true) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
paneId: node.pane.id,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import {
|
||||
FileDiff,
|
||||
FolderTree,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
getTerminalProfileIcon,
|
||||
@@ -27,6 +34,8 @@ export interface ResolvedPin {
|
||||
interface UsePinnedLaunchersInput {
|
||||
serverId: string;
|
||||
onLaunch: (target: PinnedTabTarget) => void;
|
||||
isGit?: boolean;
|
||||
hasPullRequest?: boolean;
|
||||
}
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -34,6 +43,9 @@ const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMut
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
|
||||
function ProviderPinIcon({
|
||||
iconKey,
|
||||
@@ -57,7 +69,12 @@ export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): React
|
||||
return <ThemedProviderPinIcon iconKey={iconKey} size={14} uniProps={mutedColorMapping} />;
|
||||
}
|
||||
|
||||
export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInput): ResolvedPin[] {
|
||||
export function usePinnedLaunchers({
|
||||
serverId,
|
||||
onLaunch,
|
||||
isGit,
|
||||
hasPullRequest,
|
||||
}: UsePinnedLaunchersInput): ResolvedPin[] {
|
||||
const { t } = useTranslation();
|
||||
const pinned = usePinnedTargetsStore((state) => state.pinned);
|
||||
const { config } = useDaemonConfig(serverId);
|
||||
@@ -69,7 +86,9 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp
|
||||
return useMemo(() => {
|
||||
const resolved: ResolvedPin[] = [];
|
||||
for (const target of pinned) {
|
||||
if (!isPinnedTargetAvailable(target, { isElectron: getIsElectron() })) {
|
||||
if (
|
||||
!isPinnedTargetAvailable(target, { isElectron: getIsElectron(), isGit, hasPullRequest })
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (target.kind === "draft") {
|
||||
@@ -99,6 +118,33 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (target.kind === "working_diff") {
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
label: t("workspace.tabs.actions.changes"),
|
||||
icon: <ThemedFileDiff size={14} uniProps={mutedColorMapping} />,
|
||||
onPress: () => onLaunch(target),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (target.kind === "files") {
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
label: t("workspace.tabs.actions.files"),
|
||||
icon: <ThemedFolderTree size={14} uniProps={mutedColorMapping} />,
|
||||
onPress: () => onLaunch(target),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (target.kind === "pull_request") {
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
label: t("workspace.tabs.actions.pullRequest"),
|
||||
icon: <ThemedGitPullRequest size={14} uniProps={mutedColorMapping} />,
|
||||
onPress: () => onLaunch(target),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const profile = profiles.find((entry) => entry.id === target.profileId);
|
||||
if (!profile) {
|
||||
continue;
|
||||
@@ -111,5 +157,5 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
}, [onLaunch, pinned, profiles, t]);
|
||||
}, [hasPullRequest, isGit, onLaunch, pinned, profiles, t]);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pin, PinOff } from "lucide-react-native";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target";
|
||||
import { usePinnedTargetsStore } from "@/workspace-pins/store";
|
||||
|
||||
@@ -22,6 +24,8 @@ interface PinnableMenuItemProps {
|
||||
disabled?: boolean;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
/** Chord for the keyboard action that runs this target directly, shown beside the pin toggle. */
|
||||
shortcut?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
export function PinnableMenuItem({
|
||||
@@ -31,6 +35,7 @@ export function PinnableMenuItem({
|
||||
disabled,
|
||||
onSelect,
|
||||
testID,
|
||||
shortcut,
|
||||
}: PinnableMenuItemProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -58,28 +63,31 @@ export function PinnableMenuItem({
|
||||
|
||||
const trailing = useMemo(
|
||||
() => (
|
||||
<View style={slotStyle} pointerEvents={showToggle ? "auto" : "none"}>
|
||||
<Pressable
|
||||
onPress={handleTogglePin}
|
||||
hitSlop={8}
|
||||
style={styles.pinToggleButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isPinned
|
||||
? t("workspace.tabs.actions.unpinTarget")
|
||||
: t("workspace.tabs.actions.pinTarget")
|
||||
}
|
||||
testID={`workspace-pin-toggle-${pinnedTargetKey(target)}`}
|
||||
>
|
||||
{isPinned ? (
|
||||
<ThemedPinOff size={14} uniProps={mutedColorMapping} />
|
||||
) : (
|
||||
<ThemedPin size={14} uniProps={mutedColorMapping} />
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.trailingRow}>
|
||||
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
||||
<View style={slotStyle} pointerEvents={showToggle ? "auto" : "none"}>
|
||||
<Pressable
|
||||
onPress={handleTogglePin}
|
||||
hitSlop={8}
|
||||
style={styles.pinToggleButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isPinned
|
||||
? t("workspace.tabs.actions.unpinTarget")
|
||||
: t("workspace.tabs.actions.pinTarget")
|
||||
}
|
||||
testID={`workspace-pin-toggle-${pinnedTargetKey(target)}`}
|
||||
>
|
||||
{isPinned ? (
|
||||
<ThemedPinOff size={14} uniProps={mutedColorMapping} />
|
||||
) : (
|
||||
<ThemedPin size={14} uniProps={mutedColorMapping} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
[handleTogglePin, isPinned, showToggle, slotStyle, t, target],
|
||||
[handleTogglePin, isPinned, shortcut, showToggle, slotStyle, t, target],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -97,7 +105,12 @@ export function PinnableMenuItem({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trailingRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
pinToggleSlot: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
|
||||
@@ -8,7 +8,7 @@ const PROFILES: readonly TerminalProfile[] = [
|
||||
];
|
||||
|
||||
interface RecordedLaunch {
|
||||
action: "draft" | "terminal" | "browser" | "profile";
|
||||
action: "draft" | "terminal" | "browser" | "changes" | "files" | "pull_request" | "profile";
|
||||
profile?: TerminalProfileInput;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ function recordingHandlers() {
|
||||
createDraft: () => launches.push({ action: "draft" }),
|
||||
createTerminal: () => launches.push({ action: "terminal" }),
|
||||
createBrowser: () => launches.push({ action: "browser" }),
|
||||
openChanges: () => launches.push({ action: "changes" }),
|
||||
openFiles: () => launches.push({ action: "files" }),
|
||||
openPullRequest: () => launches.push({ action: "pull_request" }),
|
||||
createTerminalWithProfile: (profile) => launches.push({ action: "profile", profile }),
|
||||
};
|
||||
return { launches, handlers };
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface TabTargetHandlers {
|
||||
createDraft: () => void;
|
||||
createTerminal: () => void;
|
||||
createBrowser: () => void;
|
||||
openChanges: () => void;
|
||||
openFiles: () => void;
|
||||
openPullRequest: () => void;
|
||||
createTerminalWithProfile: (profile: TerminalProfileInput) => void;
|
||||
}
|
||||
|
||||
@@ -27,6 +30,18 @@ export function runPinnedTabTarget(
|
||||
handlers.createBrowser();
|
||||
return;
|
||||
}
|
||||
if (target.kind === "working_diff") {
|
||||
handlers.openChanges();
|
||||
return;
|
||||
}
|
||||
if (target.kind === "files") {
|
||||
handlers.openFiles();
|
||||
return;
|
||||
}
|
||||
if (target.kind === "pull_request") {
|
||||
handlers.openPullRequest();
|
||||
return;
|
||||
}
|
||||
const profile = profiles.find((entry) => entry.id === target.profileId);
|
||||
if (!profile) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { usePinnedTargetsStore } from "./store";
|
||||
|
||||
describe("pinned targets persistence", () => {
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.removeItem("pinned-tab-targets");
|
||||
usePinnedTargetsStore.setState({ pinned: [] });
|
||||
});
|
||||
|
||||
it("restores workspace review targets", async () => {
|
||||
await AsyncStorage.setItem(
|
||||
"pinned-tab-targets",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
pinned: [{ kind: "working_diff" }, { kind: "files" }, { kind: "pull_request" }],
|
||||
},
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
await usePinnedTargetsStore.persist.rehydrate();
|
||||
|
||||
expect(usePinnedTargetsStore.getState().pinned).toEqual([
|
||||
{ kind: "working_diff" },
|
||||
{ kind: "files" },
|
||||
{ kind: "pull_request" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,9 @@ const PinnedTabTargetSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({ kind: z.literal("draft") }),
|
||||
z.strictObject({ kind: z.literal("terminal") }),
|
||||
z.strictObject({ kind: z.literal("browser") }),
|
||||
z.strictObject({ kind: z.literal("working_diff") }),
|
||||
z.strictObject({ kind: z.literal("files") }),
|
||||
z.strictObject({ kind: z.literal("pull_request") }),
|
||||
z.strictObject({ kind: z.literal("profile"), profileId: z.string() }),
|
||||
]);
|
||||
const PinnedTargetsPersistedStateSchema = z.strictObject({
|
||||
|
||||
@@ -2,13 +2,25 @@ export type PinnedTabTarget =
|
||||
| { kind: "draft" }
|
||||
| { kind: "terminal" }
|
||||
| { kind: "browser" }
|
||||
| { kind: "working_diff" }
|
||||
| { kind: "files" }
|
||||
| { kind: "pull_request" }
|
||||
| { kind: "profile"; profileId: string };
|
||||
|
||||
export function isPinnedTargetAvailable(
|
||||
target: PinnedTabTarget,
|
||||
environment: { isElectron: boolean },
|
||||
environment: { isElectron: boolean; isGit?: boolean; hasPullRequest?: boolean },
|
||||
): boolean {
|
||||
return target.kind !== "browser" || environment.isElectron;
|
||||
if (target.kind === "browser") {
|
||||
return environment.isElectron;
|
||||
}
|
||||
if (target.kind === "working_diff") {
|
||||
return environment.isGit !== false;
|
||||
}
|
||||
if (target.kind === "pull_request") {
|
||||
return environment.hasPullRequest !== false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pinnedTargetKey(target: PinnedTabTarget): string {
|
||||
|
||||
@@ -80,6 +80,19 @@ describe("working diff tab identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace utility panel identity", () => {
|
||||
it.each(["files", "pull_request"] as const)(
|
||||
"normalizes and deterministically keys %s",
|
||||
(kind) => {
|
||||
const target = { kind };
|
||||
|
||||
expect(normalizeWorkspaceTabTarget(target)).toEqual(target);
|
||||
expect(buildDeterministicWorkspaceTabId(target)).toBe(kind);
|
||||
expect(workspaceTabTargetsEqual(target, target)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("commit diff tab identity", () => {
|
||||
it("keys a commit diff tab by its sha", () => {
|
||||
expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe(
|
||||
|
||||
@@ -49,6 +49,9 @@ function normalizeSimpleWorkspaceTabTarget(value: WorkspaceTabTarget): Workspace
|
||||
const browserId = trimNonEmpty(value.browserId);
|
||||
return browserId ? { kind: "browser", browserId } : null;
|
||||
}
|
||||
case "files":
|
||||
case "pull_request":
|
||||
return { kind: value.kind };
|
||||
case "setup": {
|
||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||
return workspaceId ? { kind: "setup", workspaceId } : null;
|
||||
@@ -121,6 +124,12 @@ function secondaryWorkspaceTabTargetsEqual(
|
||||
if (left.kind === "working_diff" && right.kind === "working_diff") {
|
||||
return left.focusPath === right.focusPath && left.focusRequestId === right.focusRequestId;
|
||||
}
|
||||
if (left.kind === "files" && right.kind === "files") {
|
||||
return true;
|
||||
}
|
||||
if (left.kind === "pull_request" && right.kind === "pull_request") {
|
||||
return true;
|
||||
}
|
||||
if (left.kind === "setup" && right.kind === "setup") {
|
||||
return left.workspaceId === right.workspaceId;
|
||||
}
|
||||
@@ -188,6 +197,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "working_diff") {
|
||||
return "working_diff";
|
||||
}
|
||||
if (target.kind === "files" || target.kind === "pull_request") {
|
||||
return target.kind;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export type WorkspaceTabTarget =
|
||||
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| { kind: "files" }
|
||||
| { kind: "pull_request" }
|
||||
| WorkspaceFileTabTarget
|
||||
| WorkspaceWorkingDiffTabTarget
|
||||
| { kind: "setup"; workspaceId: string }
|
||||
|
||||
Reference in New Issue
Block a user