feat(app): polish workspace tabs and changes

Size tabs from rendered labels and switch to horizontal overflow once minimum click targets are reached, keeping the add-tab action adjacent until it must remain fixed.

Make Changes an always-expanded document with an optional desktop navigation tree, keep Git actions in their platform-owned headers, remove pinned and inline split shortcuts, and place review tabs beside Changes.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:37 +02:00
parent 6d7606160e
commit 75adf53893
41 changed files with 929 additions and 1901 deletions
+34 -30
View File
@@ -304,7 +304,7 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"))) .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
.toBe(AFTER); .toBe(AFTER);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-tree").click();
await changesTree(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 page.getByTestId("diff-folder-src-duplicate").click();
await expect await expect
@@ -317,7 +317,7 @@ test("changes context menu recursively collapses descendant folders", async ({ p
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page); const tree = changesTree(page);
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
@@ -346,7 +346,7 @@ test("changes context menus expose folder revert and restore a file after confir
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page); const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert"); const folderRevert = page.getByTestId("diff-folder-src-revert");
@@ -357,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 expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await tree.getByTestId("diff-file-0-toggle").click({ button: "right" }); await tree.getByTestId("diff-tree-file-0-toggle").click({ button: "right" });
const cancelledConfirmation = new Promise<string>((resolve) => { const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
const message = dialog.message(); const message = dialog.message();
@@ -365,14 +365,14 @@ test("changes context menus expose folder revert and restore a file after confir
resolve(message); resolve(message);
}); });
}); });
await page.getByTestId("diff-file-0-revert").click(); await page.getByTestId("diff-tree-file-0-revert").click();
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts"); expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
await expect(tree.getByTestId("diff-file-0")).toBeVisible(); await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
await expect await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER); .toBe(AFTER);
await tree.getByTestId("diff-file-0-toggle").click({ button: "right" }); await tree.getByTestId("diff-tree-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<string>((resolve) => { const confirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
const message = dialog.message(); const message = dialog.message();
@@ -380,10 +380,10 @@ test("changes context menus expose folder revert and restore a file after confir
resolve(message); resolve(message);
}); });
}); });
await page.getByTestId("diff-file-0-revert").click(); await page.getByTestId("diff-tree-file-0-revert").click();
expect(await confirmation).toContain("src/use-mounted-tab-set.ts"); expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
await expect(tree.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 }); await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0, { timeout: 30_000 });
await expect await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(BEFORE); .toBe(BEFORE);
@@ -482,10 +482,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts"); await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("changes-open-tab")).toHaveCount(0); await expect(visiblePanel.getByTestId("changes-open-tab")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await visiblePanel.getByTestId("diff-file-0-toggle").click(); 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(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByRole("button", { const workingDiffLayoutToggle = visiblePanel.getByRole("button", {
name: "Switch to side-by-side diff", name: "Switch to side-by-side diff",
@@ -501,10 +502,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await visiblePanel.getByRole("button", { name: "Diff options" }).click(); await visiblePanel.getByRole("button", { name: "Diff options" }).click();
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible(); await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Expand all files" }).click(); await expect(page.getByRole("button", { name: /all files/i })).toHaveCount(0);
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
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 expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -520,7 +518,9 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible(); await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
}); });
test("changes diff switches between flat and tree file lists", async ({ page }) => { test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff(); const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
@@ -541,7 +541,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page); await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page); const tree = changesTree(page);
const content = changesContent(page); const content = changesContent(page);
await expect(tree.getByTestId("diff-folder-src")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
@@ -549,9 +549,9 @@ test("changes diff switches between flat and tree file lists", async ({ page })
"user-select", "user-select",
"none", "none",
); );
await expect(tree.getByTestId("diff-file-0")).toBeVisible(); await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1); await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
await expect(tree.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); await expect(tree.getByTestId("diff-tree-file-0-toggle").locator("svg")).toHaveCount(1);
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox(); const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
const folderChevronBounds = await tree const folderChevronBounds = await tree
.getByTestId("diff-folder-src-toggle") .getByTestId("diff-folder-src-toggle")
@@ -568,7 +568,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
.getByText("src", { exact: true }) .getByText("src", { exact: true })
.boundingBox(); .boundingBox();
const fileLabelBounds = await tree const fileLabelBounds = await tree
.getByTestId("diff-file-0") .getByTestId("diff-tree-file-0")
.getByText("use-mounted-tab-set.ts", { exact: true }) .getByText("use-mounted-tab-set.ts", { exact: true })
.boundingBox(); .boundingBox();
expect(folderLabelBounds).not.toBeNull(); expect(folderLabelBounds).not.toBeNull();
@@ -578,27 +578,31 @@ test("changes diff switches between flat and tree file lists", async ({ page })
const folderToggle = tree.getByTestId("diff-folder-src-toggle"); const folderToggle = tree.getByTestId("diff-folder-src-toggle");
await folderToggle.click(); await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true"); await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(tree.getByTestId("diff-file-0")).toHaveCount(0); await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await folderToggle.click(); await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true"); await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(tree.getByTestId("diff-file-0")).toBeVisible(); await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
const fileToggle = tree.getByTestId("diff-file-0-toggle"); const fileToggle = tree.getByTestId("diff-tree-file-0-toggle");
await fileToggle.click({ button: "right" }); await fileToggle.click({ button: "right" });
await expect(fileToggle).toHaveAttribute("aria-selected", "true"); await expect(fileToggle).toHaveAttribute("aria-selected", "true");
await expect(folderToggle).toHaveAttribute("aria-selected", "false"); await expect(folderToggle).toHaveAttribute("aria-selected", "false");
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible(); await expect(page.getByTestId("diff-tree-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Collapse all files" }).click(); await fileToggle.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 expect(content.getByTestId("diff-file-0-body")).toBeVisible();
const contentBounds = await content.boundingBox();
const focusedFileBounds = await content.getByTestId("diff-file-0").boundingBox();
expect(contentBounds).not.toBeNull();
expect(focusedFileBounds).not.toBeNull();
expect(focusedFileBounds!.y).toBeGreaterThanOrEqual(contentBounds!.y);
expect(focusedFileBounds!.y).toBeLessThan(contentBounds!.y + 80);
await tree.getByTestId("diff-folder-src-toggle").click(); await tree.getByTestId("diff-folder-src-toggle").click();
await expect(tree.getByTestId("diff-file-0")).toHaveCount(0); await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-tree").click();
await expectFlatFileList(page); await expectFlatFileList(page);
}); });
@@ -649,7 +653,7 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
preferencesKey, preferencesKey,
JSON.stringify({ JSON.stringify({
layout: "unified", layout: "unified",
viewMode: "flat", desktopTreeVisible: false,
wrapLines: false, wrapLines: false,
hideWhitespace: false, hideWhitespace: false,
}), }),
@@ -1,47 +0,0 @@
import { test, expect } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { togglePinFromMenu, tabRowPin } from "../support/helpers/pins";
import { expectTerminalTabOpen } from "../support/helpers/workspace-tabs";
import type { PinnedTabTarget } from "../../src/workspace-pins/target";
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "workspace-pins-e2e-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test.describe("Pinned tab targets", () => {
test("pinning a target from the dropdown adds its quick-launch button to the tab row, unpinning removes it", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await expect(tabRowPin(page, DRAFT_TARGET)).toHaveCount(0);
await togglePinFromMenu(page, DRAFT_TARGET);
await expect(tabRowPin(page, DRAFT_TARGET)).toBeVisible({ timeout: 10_000 });
await togglePinFromMenu(page, DRAFT_TARGET);
await expect(tabRowPin(page, DRAFT_TARGET)).toHaveCount(0, { timeout: 10_000 });
});
test("clicking the pinned quick-launch button in the tab row opens a terminal tab", async ({
page,
}) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspace.workspaceId);
await expect(tabRowPin(page, TERMINAL_TARGET)).toBeVisible({ timeout: 10_000 });
await tabRowPin(page, TERMINAL_TARGET).click();
await expectTerminalTabOpen(page);
});
});
-45
View File
@@ -1,45 +0,0 @@
import { expect, type Page } from "@playwright/test";
import type { PinnedTabTarget } from "../../../src/workspace-pins/target";
import { pinnedTargetKey } from "../../../src/workspace-pins/target";
// The new-tab dropdown menu items carry no stable web ARIA role for the inline
// pin toggle (it lives inside a reveal-on-hover slot), so the pins flow is
// addressed through the test ids the feature assigns per target key — the same
// escape-hatch convention the sidebar kebab and tab context menus use.
function pinToggle(page: Page, target: PinnedTabTarget) {
return page.getByTestId(`workspace-pin-toggle-${pinnedTargetKey(target)}`);
}
function menuItemFor(page: Page, target: PinnedTabTarget) {
if (target.kind === "draft") {
return page.getByTestId("workspace-new-tab-menu-agent");
}
return page.getByTestId(`workspace-new-tab-menu-${target.kind}`);
}
export function tabRowPin(page: Page, target: PinnedTabTarget) {
return page.getByTestId(`workspace-pinned-target-${pinnedTargetKey(target)}`);
}
export async function openNewTabMenu(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
}
// The pin toggle is hidden behind reveal-on-hover (opacity + pointerEvents) on
// desktop web, so the menu item must be hovered before the toggle is clickable.
export async function togglePinFromMenu(page: Page, target: PinnedTabTarget): Promise<void> {
await openNewTabMenu(page);
const item = menuItemFor(page, target);
await expect(item).toBeVisible({ timeout: 10_000 });
await item.hover();
const toggle = pinToggle(page, target);
await expect(toggle).toBeVisible({ timeout: 10_000 });
await toggle.click();
await page.keyboard.press("Escape");
}
@@ -94,7 +94,7 @@ export function BranchSwitcher({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })} accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
> >
<ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} /> <ThemedGitBranch size={12} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} numberOfLines={1}> <Text style={styles.branchLabel} numberOfLines={1}>
{currentBranchName} {currentBranchName}
</Text> </Text>
@@ -132,8 +132,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1], gap: theme.spacing[1],
minWidth: 0, minWidth: 0,
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[1],
marginLeft: -theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
flexShrink: 1, flexShrink: 1,
}, },
@@ -141,9 +140,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
branchLabel: { branchLabel: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.normal,
flexShrink: 1, flexShrink: 1,
}, },
})); }));
@@ -18,7 +18,7 @@ import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/sto
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane"; import { ChangesSurface } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
@@ -433,7 +433,7 @@ function ChangedFilesPane({
>) { >) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return ( return (
<GitDiffPane <ChangesSurface
host="explorer" host="explorer"
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
@@ -1007,7 +1007,7 @@ function SplitPaneView({
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane: _onSplitPane, onSplitPane: _onSplitPane,
onSplitPaneEmpty, onSplitPaneEmpty: _onSplitPaneEmpty,
onReorderTabsInPane, onReorderTabsInPane,
renderPaneEmptyState, renderPaneEmptyState,
activeDragTabId, activeDragTabId,
@@ -1114,15 +1114,6 @@ function SplitPaneView({
}, },
[onReorderTabsInPane, paneId], [onReorderTabsInPane, paneId],
); );
const handleSplitRight = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "right" }),
[onSplitPaneEmpty, paneId],
);
const handleSplitDown = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }),
[onSplitPaneEmpty, paneId],
);
return ( return (
<RenderProfile id={`SplitPaneView:${pane.id}`}> <RenderProfile id={`SplitPaneView:${pane.id}`}>
<View ref={paneRef} collapsable={false} style={styles.pane}> <View ref={paneRef} collapsable={false} style={styles.pane}>
@@ -1151,8 +1142,6 @@ function SplitPaneView({
onCreateBrowserTab={onCreateBrowserTab} onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab} showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs} onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext externalDndContext
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
tabDropPreviewIndex={ tabDropPreviewIndex={
@@ -0,0 +1,31 @@
import type { ReactElement } from "react";
import { SquareTerminal } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import type { Theme } from "@/styles/theme";
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
function ProviderProfileIcon({
iconKey,
size,
color = "",
}: {
iconKey: string;
size: number;
color?: string;
}) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedProviderProfileIcon = withUnistyles(ProviderProfileIcon);
export function TerminalProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement {
if (!iconKey) {
return <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
}
return <ThemedProviderProfileIcon iconKey={iconKey} size={14} uniProps={mutedColorMapping} />;
}
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,19 +1,18 @@
import { GitActionsSplitButton } from "@/git/actions-split-button"; import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActions } from "@/git/use-actions";
interface WorkspaceActionsProps { interface WorkspaceActionsProps {
serverId: string; serverId: string;
cwd: string; cwd: string;
hideLabels?: boolean;
} }
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { export function WorkspaceActions({ serverId, cwd }: WorkspaceActionsProps) {
const { gitActions } = useGitActions({ const { gitActions } = useGitActions({
serverId, serverId,
cwd, cwd,
icons: GIT_ACTION_ICONS, icons: GIT_ACTION_ICONS,
}); });
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />; return <GitActionsSplitButton gitActions={gitActions} />;
} }
@@ -28,7 +28,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({ expect(result).toEqual({
layout: "unified", layout: "unified",
viewMode: "flat", desktopTreeVisible: false,
wrapLines: true, wrapLines: true,
hideWhitespace: false, hideWhitespace: false,
commitsCollapsed: true, commitsCollapsed: true,
@@ -51,7 +51,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({ expect(result).toEqual({
layout: "split", layout: "split",
viewMode: "tree", desktopTreeVisible: true,
hideWhitespace: true, hideWhitespace: true,
wrapLines: false, wrapLines: false,
commitsCollapsed: true, commitsCollapsed: true,
@@ -95,14 +95,14 @@ describe("saveChangesPreferences", () => {
await saveChangesPreferences({ await saveChangesPreferences({
queryClient, queryClient,
updates: { layout: "split", viewMode: "tree", hideWhitespace: true }, updates: { layout: "split", desktopTreeVisible: true, hideWhitespace: true },
storage, storage,
}); });
const expected = { const expected = {
...DEFAULT_CHANGES_PREFERENCES, ...DEFAULT_CHANGES_PREFERENCES,
layout: "split", layout: "split",
viewMode: "tree", desktopTreeVisible: true,
hideWhitespace: true, hideWhitespace: true,
}; };
expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected); expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected);
@@ -8,6 +8,8 @@ export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.strictObject({ const changesPreferencesSchema = z.strictObject({
layout: z.enum(["unified", "split"]).optional(), layout: z.enum(["unified", "split"]).optional(),
desktopTreeVisible: z.boolean().optional(),
// Read the former overloaded preference once so existing installations keep their desktop tree.
viewMode: z.enum(["flat", "tree"]).optional(), viewMode: z.enum(["flat", "tree"]).optional(),
wrapLines: z.boolean().optional(), wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(), hideWhitespace: z.boolean().optional(),
@@ -16,7 +18,7 @@ const changesPreferencesSchema = z.strictObject({
export interface ChangesPreferences { export interface ChangesPreferences {
layout: "unified" | "split"; layout: "unified" | "split";
viewMode: "flat" | "tree"; desktopTreeVisible: boolean;
wrapLines: boolean; wrapLines: boolean;
hideWhitespace: boolean; hideWhitespace: boolean;
commitsCollapsed: boolean; commitsCollapsed: boolean;
@@ -24,7 +26,7 @@ export interface ChangesPreferences {
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified", layout: "unified",
viewMode: "flat", desktopTreeVisible: false,
wrapLines: false, wrapLines: false,
hideWhitespace: false, hideWhitespace: false,
commitsCollapsed: true, commitsCollapsed: true,
@@ -54,7 +56,12 @@ export async function loadChangesPreferencesFromStorage(
changesPreferencesSchema, changesPreferencesSchema,
); );
if (stored) { if (stored) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...stored }; const { viewMode, ...currentPreferences } = stored;
return {
...DEFAULT_CHANGES_PREFERENCES,
...currentPreferences,
desktopTreeVisible: stored.desktopTreeVisible ?? viewMode === "tree",
};
} }
const legacyWrapLines = await loadLegacyWrapLinesPreference(storage); const legacyWrapLines = await loadLegacyWrapLinesPreference(storage);
+1 -5
View File
@@ -600,8 +600,6 @@ export const ar: TranslationResources = {
pullRequest: "طلب السحب", pullRequest: "طلب السحب",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "تثبيت",
unpinTarget: "إلغاء التثبيت",
}, },
explorer: { explorer: {
open: "افتح المستكشف", open: "افتح المستكشف",
@@ -855,14 +853,12 @@ export const ar: TranslationResources = {
switchToUnified: "التبديل إلى الفرق الموحد", switchToUnified: "التبديل إلى الفرق الموحد",
switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب", switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب",
showTreeView: "إظهار شجرة المجلدات", showTreeView: "إظهار شجرة المجلدات",
showFlatView: "إظهار قائمة الملفات المسطحة", hideTreeView: "إخفاء شجرة المجلدات",
options: "خيارات الفرق", options: "خيارات الفرق",
hideWhitespace: "إخفاء المسافة البيضاء", hideWhitespace: "إخفاء المسافة البيضاء",
showWhitespace: "إظهار المسافة البيضاء", showWhitespace: "إظهار المسافة البيضاء",
scrollLongLines: "قم بتمرير الخطوط الطويلة", scrollLongLines: "قم بتمرير الخطوط الطويلة",
wrapLongLines: "لف الخطوط الطويلة", wrapLongLines: "لف الخطوط الطويلة",
collapseAll: "طي كافة الملفات",
expandAll: "قم بتوسيع كافة الملفات",
collapseAllFolders: "طي كافة المجلدات", collapseAllFolders: "طي كافة المجلدات",
expandAllFolders: "توسيع كافة المجلدات", expandAllFolders: "توسيع كافة المجلدات",
refreshing: "منعش", refreshing: "منعش",
+1 -5
View File
@@ -598,8 +598,6 @@ export const en = {
pullRequest: "Pull request", pullRequest: "Pull request",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Pin",
unpinTarget: "Unpin",
}, },
explorer: { explorer: {
open: "Open explorer", open: "Open explorer",
@@ -864,14 +862,12 @@ export const en = {
switchToUnified: "Switch to unified diff", switchToUnified: "Switch to unified diff",
switchToSplit: "Switch to side-by-side diff", switchToSplit: "Switch to side-by-side diff",
showTreeView: "Show folder tree", showTreeView: "Show folder tree",
showFlatView: "Show flat file list", hideTreeView: "Hide folder tree",
options: "Diff options", options: "Diff options",
hideWhitespace: "Hide whitespace", hideWhitespace: "Hide whitespace",
showWhitespace: "Show whitespace", showWhitespace: "Show whitespace",
scrollLongLines: "Scroll long lines", scrollLongLines: "Scroll long lines",
wrapLongLines: "Wrap long lines", wrapLongLines: "Wrap long lines",
collapseAll: "Collapse all files",
expandAll: "Expand all files",
collapseAllFolders: "Collapse all folders", collapseAllFolders: "Collapse all folders",
expandAllFolders: "Expand all folders", expandAllFolders: "Expand all folders",
refreshing: "Refreshing", refreshing: "Refreshing",
+1 -5
View File
@@ -605,8 +605,6 @@ export const es: TranslationResources = {
pullRequest: "Solicitud de extracción", pullRequest: "Solicitud de extracción",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Fijar",
unpinTarget: "Desfijar",
}, },
explorer: { explorer: {
open: "Explorador abierto", open: "Explorador abierto",
@@ -886,14 +884,12 @@ export const es: TranslationResources = {
switchToUnified: "Cambiar a diferencia unificada", switchToUnified: "Cambiar a diferencia unificada",
switchToSplit: "Cambiar a diferencia lado a lado", switchToSplit: "Cambiar a diferencia lado a lado",
showTreeView: "Mostrar árbol de carpetas", showTreeView: "Mostrar árbol de carpetas",
showFlatView: "Mostrar lista plana de archivos", hideTreeView: "Ocultar árbol de carpetas",
options: "Opciones de diferencia", options: "Opciones de diferencia",
hideWhitespace: "Ocultar espacios en blanco", hideWhitespace: "Ocultar espacios en blanco",
showWhitespace: "Mostrar espacios en blanco", showWhitespace: "Mostrar espacios en blanco",
scrollLongLines: "Desplazarse por largas filas", scrollLongLines: "Desplazarse por largas filas",
wrapLongLines: "Envolver largas filas", wrapLongLines: "Envolver largas filas",
collapseAll: "Contraer todos los archivos",
expandAll: "Expandir todos los archivos",
collapseAllFolders: "Contraer todas las carpetas", collapseAllFolders: "Contraer todas las carpetas",
expandAllFolders: "Expandir todas las carpetas", expandAllFolders: "Expandir todas las carpetas",
refreshing: "Refrescante", refreshing: "Refrescante",
+1 -5
View File
@@ -605,8 +605,6 @@ export const fr: TranslationResources = {
pullRequest: "Demande de fusion", pullRequest: "Demande de fusion",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Épingler",
unpinTarget: "Détacher",
}, },
explorer: { explorer: {
open: "Ouvrir l'explorateur", open: "Ouvrir l'explorateur",
@@ -885,14 +883,12 @@ export const fr: TranslationResources = {
switchToUnified: "Passer au diff unifié", switchToUnified: "Passer au diff unifié",
switchToSplit: "Passer au diff côte à côte", switchToSplit: "Passer au diff côte à côte",
showTreeView: "Afficher l'arborescence des dossiers", showTreeView: "Afficher l'arborescence des dossiers",
showFlatView: "Afficher la liste de fichiers à plat", hideTreeView: "Masquer l'arborescence des dossiers",
options: "Options du diff", options: "Options du diff",
hideWhitespace: "Masquer les espaces", hideWhitespace: "Masquer les espaces",
showWhitespace: "Afficher les espaces", showWhitespace: "Afficher les espaces",
scrollLongLines: "Faire défiler les longues lignes", scrollLongLines: "Faire défiler les longues lignes",
wrapLongLines: "Enroulez les longues lignes", wrapLongLines: "Enroulez les longues lignes",
collapseAll: "Réduire tous les fichiers",
expandAll: "Développer tous les fichiers",
collapseAllFolders: "Réduire tous les dossiers", collapseAllFolders: "Réduire tous les dossiers",
expandAllFolders: "Développer tous les dossiers", expandAllFolders: "Développer tous les dossiers",
refreshing: "Rafraîchissant", refreshing: "Rafraîchissant",
+1 -5
View File
@@ -605,8 +605,6 @@ export const ja: TranslationResources = {
pullRequest: "プルリクエスト", pullRequest: "プルリクエスト",
terminalProfilesMenu: "ターミナルプロファイル", terminalProfilesMenu: "ターミナルプロファイル",
editTerminalProfiles: "プロファイルを編集…", editTerminalProfiles: "プロファイルを編集…",
pinTarget: "ピン留め",
unpinTarget: "ピン留めを解除",
}, },
explorer: { explorer: {
open: "エクスプローラーを開く", open: "エクスプローラーを開く",
@@ -866,14 +864,12 @@ export const ja: TranslationResources = {
switchToUnified: "ユニファイド差分に切り替え", switchToUnified: "ユニファイド差分に切り替え",
switchToSplit: "左右比較差分に切り替え", switchToSplit: "左右比較差分に切り替え",
showTreeView: "フォルダツリーを表示", showTreeView: "フォルダツリーを表示",
showFlatView: "フラットなファイル一覧を表示", hideTreeView: "フォルダツリーを非表示",
options: "差分オプション", options: "差分オプション",
hideWhitespace: "空白を非表示", hideWhitespace: "空白を非表示",
showWhitespace: "空白を表示", showWhitespace: "空白を表示",
scrollLongLines: "長い行をスクロール", scrollLongLines: "長い行をスクロール",
wrapLongLines: "長い行を折り返す", wrapLongLines: "長い行を折り返す",
collapseAll: "すべて折りたたむ",
expandAll: "すべて展開",
collapseAllFolders: "すべてのフォルダを折りたたむ", collapseAllFolders: "すべてのフォルダを折りたたむ",
expandAllFolders: "すべてのフォルダを展開", expandAllFolders: "すべてのフォルダを展開",
refreshing: "更新中", refreshing: "更新中",
+1 -5
View File
@@ -602,8 +602,6 @@ export const ko: TranslationResources = {
pullRequest: "풀 리퀘스트", pullRequest: "풀 리퀘스트",
terminalProfilesMenu: "터미널 프로필", terminalProfilesMenu: "터미널 프로필",
editTerminalProfiles: "프로필 편집…", editTerminalProfiles: "프로필 편집…",
pinTarget: "고정",
unpinTarget: "고정 해제",
}, },
explorer: { explorer: {
open: "탐색기 열기", open: "탐색기 열기",
@@ -862,14 +860,12 @@ export const ko: TranslationResources = {
switchToUnified: "통합 diff로 전환", switchToUnified: "통합 diff로 전환",
switchToSplit: "나란히 보기 diff로 전환", switchToSplit: "나란히 보기 diff로 전환",
showTreeView: "폴더 트리 표시", showTreeView: "폴더 트리 표시",
showFlatView: "플랫 파일 목록 표시", hideTreeView: "폴더 트리 숨기기",
options: "Diff 옵션", options: "Diff 옵션",
hideWhitespace: "공백 숨기기", hideWhitespace: "공백 숨기기",
showWhitespace: "공백 표시", showWhitespace: "공백 표시",
scrollLongLines: "긴 줄 스크롤", scrollLongLines: "긴 줄 스크롤",
wrapLongLines: "긴 줄 줄바꿈", wrapLongLines: "긴 줄 줄바꿈",
collapseAll: "모든 파일 접기",
expandAll: "모든 파일 펼치기",
collapseAllFolders: "모든 폴더 축소", collapseAllFolders: "모든 폴더 축소",
expandAllFolders: "모든 폴더 확장", expandAllFolders: "모든 폴더 확장",
refreshing: "새로고침 중", refreshing: "새로고침 중",
+1 -5
View File
@@ -604,8 +604,6 @@ export const ptBR: TranslationResources = {
pullRequest: "Pull request", pullRequest: "Pull request",
terminalProfilesMenu: "Perfis de terminal", terminalProfilesMenu: "Perfis de terminal",
editTerminalProfiles: "Editar perfis…", editTerminalProfiles: "Editar perfis…",
pinTarget: "Fixar",
unpinTarget: "Desafixar",
}, },
explorer: { explorer: {
open: "Abrir explorador", open: "Abrir explorador",
@@ -877,14 +875,12 @@ export const ptBR: TranslationResources = {
switchToUnified: "Alternar para diff unificado", switchToUnified: "Alternar para diff unificado",
switchToSplit: "Alternar para diff lado a lado", switchToSplit: "Alternar para diff lado a lado",
showTreeView: "Mostrar árvore de pastas", showTreeView: "Mostrar árvore de pastas",
showFlatView: "Mostrar lista plana de arquivos", hideTreeView: "Ocultar árvore de pastas",
options: "Opções do diff", options: "Opções do diff",
hideWhitespace: "Ocultar espaços em branco", hideWhitespace: "Ocultar espaços em branco",
showWhitespace: "Mostrar espaços em branco", showWhitespace: "Mostrar espaços em branco",
scrollLongLines: "Rolar linhas longas", scrollLongLines: "Rolar linhas longas",
wrapLongLines: "Quebrar linhas longas", wrapLongLines: "Quebrar linhas longas",
collapseAll: "Recolher todos os arquivos",
expandAll: "Expandir todos os arquivos",
collapseAllFolders: "Recolher todas as pastas", collapseAllFolders: "Recolher todas as pastas",
expandAllFolders: "Expandir todas as pastas", expandAllFolders: "Expandir todas as pastas",
refreshing: "Atualizando", refreshing: "Atualizando",
+1 -5
View File
@@ -604,8 +604,6 @@ export const ru: TranslationResources = {
pullRequest: "Запрос на слияние", pullRequest: "Запрос на слияние",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Закрепить",
unpinTarget: "Открепить",
}, },
explorer: { explorer: {
open: "Открыть проводник", open: "Открыть проводник",
@@ -877,14 +875,12 @@ export const ru: TranslationResources = {
switchToUnified: "Переключить на объединенный diff", switchToUnified: "Переключить на объединенный diff",
switchToSplit: "Переключить на diff рядом", switchToSplit: "Переключить на diff рядом",
showTreeView: "Показать дерево папок", showTreeView: "Показать дерево папок",
showFlatView: "Показать плоский список файлов", hideTreeView: "Скрыть дерево папок",
options: "Параметры diff", options: "Параметры diff",
hideWhitespace: "Скрыть пробелы", hideWhitespace: "Скрыть пробелы",
showWhitespace: "Показать пробелы", showWhitespace: "Показать пробелы",
scrollLongLines: "Прокручивать длинные строки", scrollLongLines: "Прокручивать длинные строки",
wrapLongLines: "Перенос длинных строк", wrapLongLines: "Перенос длинных строк",
collapseAll: "Свернуть все файлы",
expandAll: "Развернуть все файлы",
collapseAllFolders: "Свернуть все папки", collapseAllFolders: "Свернуть все папки",
expandAllFolders: "Развернуть все папки", expandAllFolders: "Развернуть все папки",
refreshing: "Освежающий", refreshing: "Освежающий",
+1 -5
View File
@@ -600,8 +600,6 @@ export const zhCN: TranslationResources = {
pullRequest: "拉取请求", pullRequest: "拉取请求",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "固定",
unpinTarget: "取消固定",
}, },
explorer: { explorer: {
open: "打开 explorer", open: "打开 explorer",
@@ -847,14 +845,12 @@ export const zhCN: TranslationResources = {
switchToUnified: "切换到统一差异视图", switchToUnified: "切换到统一差异视图",
switchToSplit: "切换到并排差异视图", switchToSplit: "切换到并排差异视图",
showTreeView: "显示文件夹树", showTreeView: "显示文件夹树",
showFlatView: "显示平铺文件列表", hideTreeView: "隐藏文件夹树",
options: "Diff 选项", options: "Diff 选项",
hideWhitespace: "隐藏空白差异", hideWhitespace: "隐藏空白差异",
showWhitespace: "显示空白差异", showWhitespace: "显示空白差异",
scrollLongLines: "滚动长行", scrollLongLines: "滚动长行",
wrapLongLines: "自动换行长行", wrapLongLines: "自动换行长行",
collapseAll: "折叠所有文件",
expandAll: "展开所有文件",
collapseAllFolders: "折叠所有文件夹", collapseAllFolders: "折叠所有文件夹",
expandAllFolders: "展开所有文件夹", expandAllFolders: "展开所有文件夹",
refreshing: "正在刷新", refreshing: "正在刷新",
@@ -12,7 +12,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ProfileIcon } from "@/workspace-pins/launch"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE } from "@/styles/theme"; import { ICON_SIZE } from "@/styles/theme";
import { import {
@@ -54,7 +54,7 @@ function LaunchProfileMenuItem({
}): ReactElement { }): ReactElement {
const handleSelect = useCallback(() => onSelect(profile.id), [onSelect, profile.id]); const handleSelect = useCallback(() => onSelect(profile.id), [onSelect, profile.id]);
const leading = useMemo( const leading = useMemo(
() => <ProfileIcon iconKey={getTerminalProfileIcon(profile)} />, () => <TerminalProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
[profile], [profile],
); );
// What the profile runs before any prompt is typed, so the row says what it // What the profile runs before any prompt is typed, so the row says what it
@@ -102,7 +102,7 @@ function TriggerIcon({
return <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />; return <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
} }
if (profile) { if (profile) {
return <ProfileIcon iconKey={getTerminalProfileIcon(profile)} />; return <TerminalProfileIcon iconKey={getTerminalProfileIcon(profile)} />;
} }
return <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />; return <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
} }
+5 -8
View File
@@ -7,7 +7,7 @@ import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { DiffLayoutToggle, GitDiffPane, resolveDiffLayout, SharedDiffView } from "@/git/diff-pane"; import { ChangesSurface, DiffDocument, DiffLayoutToggle, resolveDiffLayout } from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files"; import { useCommitDiffFiles } from "@/git/use-diff-files";
import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
@@ -44,10 +44,6 @@ function useDiffPanelPreferences() {
const toggleHideWhitespace = useCallback(() => { const toggleHideWhitespace = useCallback(() => {
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace }); void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
}, [preferences.hideWhitespace, updatePreferences]); }, [preferences.hideWhitespace, updatePreferences]);
const toggleViewMode = useCallback(() => {
void updatePreferences({ viewMode: preferences.viewMode === "flat" ? "tree" : "flat" });
}, [preferences.viewMode, updatePreferences]);
return { return {
preferences, preferences,
isCompact, isCompact,
@@ -56,7 +52,6 @@ function useDiffPanelPreferences() {
toggleLayout, toggleLayout,
toggleWrapLines, toggleWrapLines,
toggleHideWhitespace, toggleHideWhitespace,
toggleViewMode,
}; };
} }
@@ -95,12 +90,14 @@ function WorkingDiffPanel() {
return ( return (
<View style={styles.container} testID="working-diff-panel"> <View style={styles.container} testID="working-diff-panel">
<GitDiffPane <ChangesSurface
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
cwd={cwd} cwd={cwd}
enabled={isActive} enabled={isActive}
host="panel" host="panel"
focusPath={target.focusPath}
focusRequestId={target.focusRequestId}
onOpenFile={handleOpenFile} onOpenFile={handleOpenFile}
onAddToChat={canAddToChat ? addFile : undefined} onAddToChat={canAddToChat ? addFile : undefined}
/> />
@@ -142,7 +139,7 @@ function CommitDiffPanel() {
body = <PanelState message={t("panels.diff.empty")} testID="commit-diff-empty" />; body = <PanelState message={t("panels.diff.empty")} testID="commit-diff-empty" />;
} else { } else {
body = ( body = (
<SharedDiffView <DiffDocument
files={files} files={files}
displayPreferences={panelPreferences.displayPreferences} displayPreferences={panelPreferences.displayPreferences}
mode={mode} mode={mode}
@@ -2,23 +2,14 @@ import { useMemo } from "react";
import { useWindowDimensions } from "react-native"; import { useWindowDimensions } from "react-native";
import { import {
computeWorkspaceTabLayout, computeWorkspaceTabLayout,
type WorkspaceTabLayoutMetrics,
type WorkspaceTabLayoutResult, type WorkspaceTabLayoutResult,
} from "@/screens/workspace/workspace-tab-layout"; } from "@/screens/workspace/workspace-tab-layout";
interface UseWorkspaceTabLayoutInput { interface UseWorkspaceTabLayoutInput {
tabLabelLengths: number[]; tabLabelWidths: number[];
viewportWidthOverride?: number | null; viewportWidthOverride?: number | null;
metrics: { metrics: WorkspaceTabLayoutMetrics;
rowHorizontalInset: number;
actionsReservedWidth: number;
rowPaddingHorizontal: number;
tabGap: number;
maxTabWidth: number;
tabIconWidth: number;
tabHorizontalPadding: number;
estimatedCharWidth: number;
closeButtonWidth: number;
};
} }
interface UseWorkspaceTabLayoutResult { interface UseWorkspaceTabLayoutResult {
@@ -38,10 +29,10 @@ export function useWorkspaceTabLayout(
() => () =>
computeWorkspaceTabLayout({ computeWorkspaceTabLayout({
viewportWidth: resolvedViewportWidth, viewportWidth: resolvedViewportWidth,
tabLabelLengths: input.tabLabelLengths, tabLabelWidths: input.tabLabelWidths,
metrics: input.metrics, metrics: input.metrics,
}), }),
[input.metrics, input.tabLabelLengths, resolvedViewportWidth], [input.metrics, input.tabLabelWidths, resolvedViewportWidth],
); );
return { return {
@@ -20,11 +20,9 @@ import {
CopyX, CopyX,
ArrowLeftToLine, ArrowLeftToLine,
ArrowRightToLine, ArrowRightToLine,
Columns2,
Copy, Copy,
Pencil, Pencil,
RotateCw, RotateCw,
Rows2,
Globe, Globe,
FileDiff, FileDiff,
FolderTree, FolderTree,
@@ -87,11 +85,7 @@ import {
} from "@getpaseo/protocol/terminal-profiles"; } from "@getpaseo/protocol/terminal-profiles";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
import { ProfileIcon, usePinnedLaunchers } from "@/workspace-pins/launch"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
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 { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
@@ -108,9 +102,13 @@ const TAB_CHIP_HORIZONTAL_PADDING = 8;
const TAB_CHIP_GAP = 4; const TAB_CHIP_GAP = 4;
const TAB_ROW_PADDING_HORIZONTAL = 4; const TAB_ROW_PADDING_HORIZONTAL = 4;
const TAB_ICON_WIDTH = 14; const TAB_ICON_WIDTH = 14;
const TAB_CONTENT_GAP = 4;
const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_DROP_INDICATOR_WIDTH = 4;
const TAB_MAX_WIDTH = 160; const TAB_MIN_WIDTH = 96;
const TAB_MAX_WIDTH = 240;
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
const TAB_LABEL_FALLBACK_CHARACTER_WIDTH = 7;
const TAB_LABEL_LAYOUT_ALLOWANCE = 4;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedX = withUnistyles(X); const ThemedX = withUnistyles(X);
@@ -123,14 +121,13 @@ const ThemedPencil = withUnistyles(Pencil);
const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2);
const ThemedPlus = withUnistyles(Plus); const ThemedPlus = withUnistyles(Plus);
const ThemedFileDiff = withUnistyles(FileDiff); const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedFolderTree = withUnistyles(FolderTree); const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />; const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />; const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
@@ -139,17 +136,10 @@ const CHANGES_ICON = <ThemedFileDiff size={14} uniProps={mutedColorMapping} />;
const FILES_ICON = <ThemedFolderTree size={14} uniProps={mutedColorMapping} />; const FILES_ICON = <ThemedFolderTree size={14} uniProps={mutedColorMapping} />;
const PULL_REQUEST_ICON = <ThemedGitPullRequest 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 CHANGES_TARGET = { kind: "working_diff" } as const;
const FILES_TARGET = { kind: "files" } as const; const FILES_TARGET = { kind: "files" } as const;
const PULL_REQUEST_TARGET = { kind: "pull_request" } as const; const PULL_REQUEST_TARGET = { kind: "pull_request" } as const;
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
}
function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
} }
@@ -159,39 +149,48 @@ function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, event:
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
} }
function ProfileLeadingIcon({ iconKey }: { iconKey: string | undefined }) { function TabLabelMeasurement({
tabKey,
label,
onMeasure,
}: {
tabKey: string;
label: string;
onMeasure: (tabKey: string, label: string, event: LayoutChangeEvent) => void;
}) {
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onMeasure(tabKey, label, event),
[label, onMeasure, tabKey],
);
return ( return (
<View style={styles.terminalProfileIconWrapper}> <Text
<ProfileIcon iconKey={iconKey} /> style={[styles.tabLabel, styles.tabLabelMeasurement]}
</View> numberOfLines={1}
onLayout={handleLayout}
>
{label}
</Text>
); );
} }
interface PinnableProfileMenuItemProps { interface TerminalProfileMenuItemProps {
profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
disabled?: boolean; disabled?: boolean;
onLaunch: (target: PinnedTabTarget) => void; onLaunch: (profile: TerminalProfileInput) => void;
} }
function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfileMenuItemProps) { function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) {
const target = useMemo<PinnedTabTarget>(
() => ({ kind: "profile", profileId: profile.id }),
[profile.id],
);
const leading = useMemo( const leading = useMemo(
() => <ProfileLeadingIcon iconKey={getTerminalProfileIcon(profile)} />, () => <TerminalProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
[profile], [profile],
); );
const handleSelect = useCallback(() => onLaunch(target), [onLaunch, target]); const handleSelect = useCallback(() => onLaunch(profile), [onLaunch, profile]);
return ( return (
<PinnableMenuItem <DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
target={target} {profile.name}
label={profile.name} </DropdownMenuItem>
leading={leading}
disabled={disabled}
onSelect={handleSelect}
/>
); );
} }
@@ -206,58 +205,6 @@ interface TabTargetLauncherOptions {
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
} }
/**
* 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,
}: TabTargetLauncherOptions) {
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const handlers = useMemo<TabTargetHandlers>(
() => ({
createDraft: onCreateAgentTab,
createTerminal: onCreateTerminal,
createBrowser: onCreateBrowser,
openChanges: onOpenChanges,
openFiles: onOpenFiles,
openPullRequest: onOpenPullRequest,
createTerminalWithProfile: onCreateTerminalWithProfile,
}),
[
onCreateAgentTab,
onCreateBrowser,
onCreateTerminal,
onCreateTerminalWithProfile,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
],
);
const onLaunch = useCallback(
(target: PinnedTabTarget) => {
runPinnedTabTarget(target, profiles, handlers);
},
[handlers, profiles],
);
return { profiles, onLaunch };
}
interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
shortcutKeys: ShortcutKey[][] | null; shortcutKeys: ShortcutKey[][] | null;
onEditProfiles: () => void; onEditProfiles: () => void;
@@ -286,16 +233,15 @@ function WorkspaceNewTabButton({
onLayout, onLayout,
}: WorkspaceNewTabButtonProps) { }: WorkspaceNewTabButtonProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, onLaunch } = useTabTargetLauncher({ const { config } = useDaemonConfig(normalizedServerId);
normalizedServerId, const profiles = useMemo(
onCreateAgentTab, () => resolveTerminalProfiles(config?.terminalProfiles),
onCreateTerminal, [config?.terminalProfiles],
onCreateBrowser, );
onCreateTerminalWithProfile, const shortcut = useMemo(
onOpenChanges, () => (shortcutKeys ? <Shortcut chord={shortcutKeys} /> : undefined),
onOpenFiles, [shortcutKeys],
onOpenPullRequest, );
});
const tooltipText = t("workspace.tabs.actions.newTab"); const tooltipText = t("workspace.tabs.actions.newTab");
return ( return (
@@ -309,7 +255,7 @@ function WorkspaceNewTabButton({
accessibilityLabel={tooltipText} accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle} style={inlineAddActionButtonStyle}
> >
<ThemedPlus size={14} uniProps={mutedColorMapping} /> <ThemedPlus size={14} uniProps={extraMutedColorMapping} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
@@ -317,64 +263,64 @@ function WorkspaceNewTabButton({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}> <DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
<PinnableMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-agent" testID="workspace-new-tab-menu-agent"
target={DRAFT_TARGET}
label={t("workspace.tabs.actions.newAgent")}
leading={AGENT_ICON} leading={AGENT_ICON}
shortcut={shortcutKeys} trailing={shortcut}
onSelect={onCreateAgentTab} onSelect={onCreateAgentTab}
/> >
<PinnableMenuItem {t("workspace.tabs.actions.newAgent")}
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-new-tab-menu-terminal" testID="workspace-new-tab-menu-terminal"
target={TERMINAL_TARGET}
label={t("workspace.tabs.actions.newTerminal")}
leading={TERMINAL_ICON} leading={TERMINAL_ICON}
disabled={terminalDisabled} disabled={terminalDisabled}
onSelect={terminalDisabled ? undefined : onCreateTerminal} onSelect={terminalDisabled ? undefined : onCreateTerminal}
/> >
{t("workspace.tabs.actions.newTerminal")}
</DropdownMenuItem>
{showCreateBrowserTab ? ( {showCreateBrowserTab ? (
<PinnableMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-browser" testID="workspace-new-tab-menu-browser"
target={BROWSER_TARGET}
label={t("workspace.tabs.actions.newBrowser")}
leading={BROWSER_ICON} leading={BROWSER_ICON}
onSelect={onCreateBrowser} onSelect={onCreateBrowser}
/> >
{t("workspace.tabs.actions.newBrowser")}
</DropdownMenuItem>
) : null} ) : null}
{isGit ? ( {isGit ? (
<PinnableMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-changes" testID="workspace-new-tab-menu-changes"
target={CHANGES_TARGET}
label={t("workspace.tabs.actions.changes")}
leading={CHANGES_ICON} leading={CHANGES_ICON}
onSelect={onOpenChanges} onSelect={onOpenChanges}
/> >
{t("workspace.tabs.actions.changes")}
</DropdownMenuItem>
) : null} ) : null}
<PinnableMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-files" testID="workspace-new-tab-menu-files"
target={FILES_TARGET}
label={t("workspace.tabs.actions.files")}
leading={FILES_ICON} leading={FILES_ICON}
onSelect={onOpenFiles} onSelect={onOpenFiles}
/> >
{t("workspace.tabs.actions.files")}
</DropdownMenuItem>
{showPullRequest ? ( {showPullRequest ? (
<PinnableMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-pull-request" testID="workspace-new-tab-menu-pull-request"
target={PULL_REQUEST_TARGET}
label={t("workspace.tabs.actions.pullRequest")}
leading={PULL_REQUEST_ICON} leading={PULL_REQUEST_ICON}
onSelect={onOpenPullRequest} onSelect={onOpenPullRequest}
/> >
{t("workspace.tabs.actions.pullRequest")}
</DropdownMenuItem>
) : null} ) : null}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel> <DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
{profiles.map((profile) => ( {profiles.map((profile) => (
<PinnableProfileMenuItem <TerminalProfileMenuItem
key={profile.id} key={profile.id}
profile={profile} profile={profile}
disabled={terminalDisabled} disabled={terminalDisabled}
onLaunch={onLaunch} onLaunch={onCreateTerminalWithProfile}
/> />
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -387,20 +333,6 @@ function WorkspaceNewTabButton({
); );
} }
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({ function TabContextMenuItem({
entry, entry,
}: { }: {
@@ -456,38 +388,8 @@ export interface WorkspaceDesktopTabRowItem {
isClosingTab: boolean; isClosingTab: boolean;
} }
interface SplitActionButtonProps { interface ResolvedWorkspaceDesktopTabRowItem extends WorkspaceDesktopTabRowItem {
onPress: () => void; presentation: WorkspaceTabPresentation;
label: string;
shortcutKeys: ShortcutKey[][] | null;
icon: "split-right" | "split-down";
}
function SplitActionButton({ onPress, label, shortcutKeys, icon }: SplitActionButtonProps) {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
style={newTabActionButtonStyle}
>
{icon === "split-right" ? (
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
) : (
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{label}</Text>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
);
} }
interface WorkspaceDesktopTabsRowProps { interface WorkspaceDesktopTabsRowProps {
@@ -515,16 +417,61 @@ interface WorkspaceDesktopTabsRowProps {
disableCreateTerminal?: boolean; disableCreateTerminal?: boolean;
isWaitingOnTerminalReadiness?: boolean; isWaitingOnTerminalReadiness?: boolean;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
onSplitRight: () => void;
onSplitDown: () => void;
externalDndContext?: boolean; externalDndContext?: boolean;
activeDragTabId?: string | null; activeDragTabId?: string | null;
tabDropPreviewIndex?: number | null; tabDropPreviewIndex?: number | null;
showPaneSplitActions?: boolean;
focusModeEnabled: boolean; focusModeEnabled: boolean;
onExitFocusMode: () => void; onExitFocusMode: () => void;
} }
interface ResolvedWorkspaceDesktopTabsRowProps extends Omit<WorkspaceDesktopTabsRowProps, "tabs"> {
tabs: ResolvedWorkspaceDesktopTabRowItem[];
}
interface WorkspaceDesktopTabRowsResolverProps {
tabs: WorkspaceDesktopTabRowItem[];
serverId: string;
workspaceId: string;
index: number;
resolvedTabs: ResolvedWorkspaceDesktopTabRowItem[];
children: (tabs: ResolvedWorkspaceDesktopTabRowItem[]) => React.ReactNode;
}
const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = [];
function WorkspaceDesktopTabRowsResolver({
tabs,
serverId,
workspaceId,
index,
resolvedTabs,
children,
}: WorkspaceDesktopTabRowsResolverProps): React.ReactElement {
const item = tabs[index];
if (!item) {
return <>{children(resolvedTabs)}</>;
}
return (
<WorkspaceTabPresentationResolver tab={item.tab} serverId={serverId} workspaceId={workspaceId}>
{(presentation) => {
const nextResolvedTabs = [...resolvedTabs, { ...item, presentation }];
return (
<WorkspaceDesktopTabRowsResolver
tabs={tabs}
serverId={serverId}
workspaceId={workspaceId}
index={index + 1}
resolvedTabs={nextResolvedTabs}
>
{children}
</WorkspaceDesktopTabRowsResolver>
);
}}
</WorkspaceTabPresentationResolver>
);
}
function getFallbackTabLabel( function getFallbackTabLabel(
tab: WorkspaceTabDescriptor, tab: WorkspaceTabDescriptor,
labels: { labels: {
@@ -813,7 +760,7 @@ function TabChip({
{showCloseButton ? ( {showCloseButton ? (
<View <View
pointerEvents={showCloseControl ? "auto" : "none"} pointerEvents={showCloseControl ? "box-none" : "none"}
style={[ style={[
styles.tabTrailingOverlay, styles.tabTrailingOverlay,
showCloseControl ? styles.tabTrailingOverlayShown : styles.tabTrailingOverlayHidden, showCloseControl ? styles.tabTrailingOverlayShown : styles.tabTrailingOverlayHidden,
@@ -865,7 +812,21 @@ function TabChip({
); );
} }
export function WorkspaceDesktopTabsRow({ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
return (
<WorkspaceDesktopTabRowsResolver
tabs={props.tabs}
serverId={props.normalizedServerId}
workspaceId={props.normalizedWorkspaceId}
index={0}
resolvedTabs={EMPTY_RESOLVED_TAB_ROWS}
>
{(tabs) => <ResolvedWorkspaceDesktopTabsRow {...props} tabs={tabs} />}
</WorkspaceDesktopTabRowsResolver>
);
}
function ResolvedWorkspaceDesktopTabsRow({
paneId, paneId,
isFocused = false, isFocused = false,
tabs, tabs,
@@ -890,25 +851,22 @@ export function WorkspaceDesktopTabsRow({
disableCreateTerminal = false, disableCreateTerminal = false,
isWaitingOnTerminalReadiness = false, isWaitingOnTerminalReadiness = false,
onReorderTabs, onReorderTabs,
onSplitRight,
onSplitDown,
externalDndContext = false, externalDndContext = false,
activeDragTabId = null, activeDragTabId = null,
tabDropPreviewIndex = null, tabDropPreviewIndex = null,
showPaneSplitActions = true,
focusModeEnabled, focusModeEnabled,
onExitFocusMode, onExitFocusMode,
}: WorkspaceDesktopTabsRowProps) { }: ResolvedWorkspaceDesktopTabsRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTabKeys = useShortcutKeys("workspace-tab-new");
const focusModeKeys = useShortcutKeys("toggle-focus"); const focusModeKeys = useShortcutKeys("toggle-focus");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0); const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const [measuredTabLabels, setMeasuredTabLabels] = useState<
Record<string, { label: string; width: number }>
>({});
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
const checkoutStatus = useCheckoutStatusQuery({ const checkoutStatus = useCheckoutStatusQuery({
serverId: normalizedServerId, serverId: normalizedServerId,
@@ -927,10 +885,6 @@ export function WorkspaceDesktopTabsRow({
updateMeasuredWidth(setTabsContainerWidth, event); updateMeasuredWidth(setTabsContainerWidth, event);
}, []); }, []);
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsActionsWidth, event);
}, []);
const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => { const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setInlineAddButtonWidth, event); updateMeasuredWidth(setInlineAddButtonWidth, event);
}, []); }, []);
@@ -944,19 +898,19 @@ export function WorkspaceDesktopTabsRow({
rowHorizontalInset: 0, rowHorizontalInset: 0,
actionsReservedWidth: Math.max( actionsReservedWidth: Math.max(
0, 0,
tabsActionsWidth + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
(inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
(focusModeEnabled ? exitFocusModeWidth : 0), (focusModeEnabled ? exitFocusModeWidth : 0),
), ),
rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
tabGap: TAB_CHIP_GAP, tabGap: TAB_CHIP_GAP,
minTabWidth: TAB_MIN_WIDTH,
maxTabWidth: TAB_MAX_WIDTH, maxTabWidth: TAB_MAX_WIDTH,
tabIconWidth: TAB_ICON_WIDTH, tabIconWidth: TAB_ICON_WIDTH,
tabContentGap: TAB_CONTENT_GAP,
tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING,
estimatedCharWidth: 7,
closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH,
}), }),
[exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth], [exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth],
); );
const fallbackTabLabels = useMemo( const fallbackTabLabels = useMemo(
@@ -989,23 +943,49 @@ export function WorkspaceDesktopTabsRow({
}), }),
[t], [t],
); );
const tabLabelLengths = useMemo( const tabLabels = useMemo(
() => () =>
tabs.map((tab) => { tabs.map((tab) => {
const label = getFallbackTabLabel(tab.tab, fallbackTabLabels); const label =
return label.length; tab.presentation.titleState === "loading"
? getFallbackTabLabel(tab.tab, fallbackTabLabels)
: tab.presentation.label;
return { key: tab.tab.key, label };
}), }),
[fallbackTabLabels, tabs], [fallbackTabLabels, tabs],
); );
const tabLabelWidths = useMemo(
() =>
tabLabels.map(({ key, label }) => {
const measurement = measuredTabLabels[key];
return measurement?.label === label
? measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE
: label.length * TAB_LABEL_FALLBACK_CHARACTER_WIDTH + TAB_LABEL_LAYOUT_ALLOWANCE;
}),
[measuredTabLabels, tabLabels],
);
const handleTabLabelLayout = useCallback(
(key: string, label: string, event: LayoutChangeEvent) => {
const width = Math.ceil(event.nativeEvent.layout.width);
setMeasuredTabLabels((current) => {
const existing = current[key];
if (existing?.label === label && existing.width === width) {
return current;
}
return { ...current, [key]: { label, width } };
});
},
[],
);
const { layout } = useWorkspaceTabLayout({ const { layout } = useWorkspaceTabLayout({
tabLabelLengths, tabLabelWidths,
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics, metrics: layoutMetrics,
}); });
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(nextTabs: WorkspaceDesktopTabRowItem[]) => { (nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => {
onReorderTabs(nextTabs.map((tab) => tab.tab)); onReorderTabs(nextTabs.map((tab) => tab.tab));
}, },
[onReorderTabs], [onReorderTabs],
@@ -1013,7 +993,7 @@ export function WorkspaceDesktopTabsRow({
const getTabDragData = useMemo(() => { const getTabDragData = useMemo(() => {
if (!paneId) return undefined; if (!paneId) return undefined;
return (tab: WorkspaceDesktopTabRowItem) => ({ return (tab: ResolvedWorkspaceDesktopTabRowItem) => ({
kind: "workspace-tab" as const, kind: "workspace-tab" as const,
paneId, paneId,
tabId: tab.tab.tabId, tabId: tab.tab.tabId,
@@ -1070,7 +1050,7 @@ export function WorkspaceDesktopTabsRow({
index, index,
dragHandleProps, dragHandleProps,
isActive, isActive,
}: DraggableRenderItemInfo<WorkspaceDesktopTabRowItem>) => { }: DraggableRenderItemInfo<ResolvedWorkspaceDesktopTabRowItem>) => {
const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const shouldShowCloseButton = layout.closeButtonPolicy === "all";
const layoutItem = layout.items[index] ?? null; const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150; const resolvedTabWidth = layoutItem?.width ?? 150;
@@ -1089,8 +1069,6 @@ export function WorkspaceDesktopTabsRow({
isDragging={isActive} isDragging={isActive}
index={index} index={index}
tabCount={tabs.length} tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId} onCopyTerminalId={onCopyTerminalId}
@@ -1118,8 +1096,6 @@ export function WorkspaceDesktopTabsRow({
isFocused, isFocused,
layout.closeButtonPolicy, layout.closeButtonPolicy,
layout.items, layout.items,
normalizedServerId,
normalizedWorkspaceId,
onCloseOtherTabs, onCloseOtherTabs,
onCloseTab, onCloseTab,
onCloseTabsToLeft, onCloseTabsToLeft,
@@ -1154,6 +1130,21 @@ export function WorkspaceDesktopTabsRow({
testID="workspace-tabs-row" testID="workspace-tabs-row"
onLayout={handleTabsContainerLayout} onLayout={handleTabsContainerLayout}
> >
<View
style={styles.tabLabelMeasurements}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{tabLabels.map(({ key, label }) => (
<TabLabelMeasurement
key={`${key}:${label}`}
tabKey={key}
label={label}
onMeasure={handleTabLabelLayout}
/>
))}
</View>
{focusModeEnabled ? ( {focusModeEnabled ? (
<View style={styles.exitFocusModeSlot} onLayout={handleExitFocusModeLayout}> <View style={styles.exitFocusModeSlot} onLayout={handleExitFocusModeLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -1198,26 +1189,29 @@ export function WorkspaceDesktopTabsRow({
getItemData={getTabDragData} getItemData={getTabDragData}
renderItem={renderTab} renderItem={renderTab}
/> />
{!layout.requiresHorizontalScrollFallback ? (
<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}
/>
) : null}
</ScrollView> </ScrollView>
<WorkspaceNewTabButton {layout.requiresHorizontalScrollFallback ? (
shortcutKeys={newTabKeys} <WorkspaceNewTabButton
onCreateAgentTab={handleCreateAgentTab} shortcutKeys={newTabKeys}
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}>
<WorkspacePinnedTargets
onCreateAgentTab={handleCreateAgentTab} onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal} onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser} onCreateBrowser={handleCreateBrowser}
@@ -1225,27 +1219,15 @@ export function WorkspaceDesktopTabsRow({
onOpenChanges={handleOpenChanges} onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles} onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest} onOpenPullRequest={handleOpenPullRequest}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit} isGit={isGit}
showPullRequest={showPullRequest} showPullRequest={showPullRequest}
onLayout={handleInlineAddButtonLayout}
/> />
{showPaneSplitActions ? ( ) : null}
<>
<SplitActionButton
icon="split-right"
onPress={onSplitRight}
label={t("workspace.tabs.actions.splitRight")}
shortcutKeys={splitRightKeys}
/>
<SplitActionButton
icon="split-down"
onPress={onSplitDown}
label={t("workspace.tabs.actions.splitDown")}
shortcutKeys={splitDownKeys}
/>
</>
) : null}
</View>
</View> </View>
); );
@@ -1257,8 +1239,6 @@ function ResolvedDesktopTabChip({
isDragging, isDragging,
index, index,
tabCount, tabCount,
normalizedServerId,
normalizedWorkspaceId,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyTerminalId, onCopyTerminalId,
@@ -1279,13 +1259,11 @@ function ResolvedDesktopTabChip({
showDropIndicatorBefore, showDropIndicatorBefore,
showDropIndicatorAfter, showDropIndicatorAfter,
}: { }: {
item: WorkspaceDesktopTabRowItem; item: ResolvedWorkspaceDesktopTabRowItem;
isFocused: boolean; isFocused: boolean;
isDragging: boolean; isDragging: boolean;
index: number; index: number;
tabCount: number; tabCount: number;
normalizedServerId: string;
normalizedWorkspaceId: string;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void; onCopyTerminalId: (terminalId: string) => Promise<void> | void;
@@ -1307,6 +1285,7 @@ function ResolvedDesktopTabChip({
showDropIndicatorAfter: boolean; showDropIndicatorAfter: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const presentation = item.presentation;
const resolvedTab = useMemo( const resolvedTab = useMemo(
() => () =>
buildWorkspaceDesktopTabActions({ buildWorkspaceDesktopTabActions({
@@ -1343,48 +1322,38 @@ function ResolvedDesktopTabChip({
], ],
); );
return ( const tooltipLabel =
<WorkspaceTabPresentationResolver presentation.titleState === "loading"
tab={item.tab} ? t("workspace.tabs.loadingAgentTitle")
serverId={normalizedServerId} : presentation.tooltip;
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const tooltipLabel =
presentation.titleState === "loading"
? t("workspace.tabs.loadingAgentTitle")
: presentation.tooltip;
return ( return (
<View style={styles.tabSlot}> <View style={styles.tabSlot}>
{showDropIndicatorBefore ? ( {showDropIndicatorBefore ? (
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} /> <View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
) : null} ) : null}
<TabChip <TabChip
tab={item.tab} tab={item.tab}
isActive={item.isActive} isActive={item.isActive}
isDragging={isDragging} isDragging={isDragging}
isFocused={isFocused} isFocused={isFocused}
resolvedTabWidth={resolvedTabWidth} resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel} showLabel={showLabel}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
isCloseHovered={item.isCloseHovered} isCloseHovered={item.isCloseHovered}
isClosingTab={item.isClosingTab} isClosingTab={item.isClosingTab}
presentation={presentation} presentation={presentation}
tooltipLabel={tooltipLabel} tooltipLabel={tooltipLabel}
resolvedTab={resolvedTab} resolvedTab={resolvedTab}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab} onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab} onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
/> />
{showDropIndicatorAfter ? ( {showDropIndicatorAfter ? (
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} /> <View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} />
) : null} ) : null}
</View> </View>
);
}}
</WorkspaceTabPresentationResolver>
); );
} }
@@ -1413,11 +1382,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
}, },
tabsActions: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[2],
},
exitFocusModeSlot: { exitFocusModeSlot: {
alignSelf: "stretch", alignSelf: "stretch",
flexDirection: "row", flexDirection: "row",
@@ -1498,6 +1462,17 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
userSelect: "none", userSelect: "none",
}, },
tabLabelMeasurements: {
position: "absolute",
top: 0,
left: 0,
opacity: 0,
alignItems: "flex-start",
pointerEvents: "none",
},
tabLabelMeasurement: {
flexShrink: 0,
},
tabLabelSkeleton: { tabLabelSkeleton: {
width: 96, width: 96,
maxWidth: "100%", maxWidth: "100%",
@@ -1522,6 +1497,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
zIndex: 2, zIndex: 2,
overflow: "hidden",
}, },
tabTrailingOverlayShown: { tabTrailingOverlayShown: {
opacity: 1, opacity: 1,
@@ -1553,13 +1529,6 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundMuted, backgroundColor: theme.colors.foregroundMuted,
}, },
newTabActionButton: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
inlineAddActionButton: { inlineAddActionButton: {
width: 28, width: 28,
height: 28, height: 28,
@@ -1567,9 +1536,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
newTabActionButtonDisabled: {
opacity: 0.5,
},
newTabActionButtonHovered: { newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
@@ -1596,8 +1562,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
terminalProfileIconWrapper: {
width: 14,
height: 14,
},
})); }));
@@ -178,7 +178,6 @@ import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { import {
buildHostRootRoute, buildHostRootRoute,
buildSettingsHostRoute, buildSettingsHostRoute,
@@ -530,8 +529,6 @@ function WorkspaceDocumentTitleEffect({
return null; return null;
} }
function noop() {}
function switcherTriggerStyle({ pressed }: { pressed?: boolean }) { function switcherTriggerStyle({ pressed }: { pressed?: boolean }) {
return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed]; return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed];
} }
@@ -2112,8 +2109,6 @@ function WorkspaceScreenContent({
); );
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const { closingTabIds, closeTab } = useCloseTabs(); const { closingTabIds, closeTab } = useCloseTabs();
const { onLayout: onHeaderLayout, isBelow: showCompactButtonLabels } =
useContainerWidthBelow(700);
const closeWorkspaceTabWithCleanup = useCallback( const closeWorkspaceTabWithCleanup = useCallback(
function closeWorkspaceTabWithCleanup(input: { function closeWorkspaceTabWithCleanup(input: {
tabId: string; tabId: string;
@@ -3616,11 +3611,7 @@ function WorkspaceScreenContent({
) : null} ) : null}
{!isMobile && workspaceDirectory ? ( {!isMobile && workspaceDirectory ? (
<> <>
<WorkspaceActions <WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
{isGitCheckout ? ( {isGitCheckout ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -3727,7 +3718,6 @@ function WorkspaceScreenContent({
handleScriptTerminalStarted, handleScriptTerminalStarted,
handleViewScriptTerminal, handleViewScriptTerminal,
handleOpenUrlInBrowserTab, handleOpenUrlInBrowserTab,
showCompactButtonLabels,
isGitCheckout, isGitCheckout,
handleToggleExplorer, handleToggleExplorer,
isExplorerOpen, isExplorerOpen,
@@ -3845,7 +3835,6 @@ function WorkspaceScreenContent({
<View style={styles.centerColumn}> <View style={styles.centerColumn}>
{showScreenHeader && ( {showScreenHeader && (
<ScreenHeader <ScreenHeader
onRowLayout={onHeaderLayout}
left={ left={
<> <>
<SidebarMenuToggle /> <SidebarMenuToggle />
@@ -3938,9 +3927,6 @@ function WorkspaceScreenContent({
disableCreateTerminal={createTerminalMutation.isPending} disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null} isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane} onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
focusModeEnabled={desktopFocusModeEnabled} focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode} onExitFocusMode={toggleFocusMode}
/> />
@@ -6,18 +6,19 @@ const metrics = {
actionsReservedWidth: 120, actionsReservedWidth: 120,
rowPaddingHorizontal: 8, rowPaddingHorizontal: 8,
tabGap: 4, tabGap: 4,
maxTabWidth: 160, minTabWidth: 96,
maxTabWidth: 240,
tabIconWidth: 14, tabIconWidth: 14,
tabContentGap: 4,
tabHorizontalPadding: 8, tabHorizontalPadding: 8,
estimatedCharWidth: 7,
closeButtonWidth: 0, closeButtonWidth: 0,
}; };
describe("computeWorkspaceTabLayout", () => { describe("computeWorkspaceTabLayout", () => {
it("caps equal-width tabs at the ideal width when there is extra horizontal space", () => { it("keeps each tab at its natural content width when space is available", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 1200, viewportWidth: 1200,
tabLabelLengths: [8, 10, 7], tabLabelWidths: [56, 70, 49],
metrics, metrics,
}); });
@@ -25,26 +26,37 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items).toHaveLength(3); expect(result.items).toHaveLength(3);
expect(result.items.every((item) => item.showLabel)).toBe(true); expect(result.items.every((item) => item.showLabel)).toBe(true);
expect(result.items.map((item) => item.width)).toEqual([160, 160, 160]); expect(result.items.map((item) => item.width)).toEqual([96, 104, 96]);
}); });
it("shrinks equal-width tabs proportionally to fit the pane", () => { it("sizes a single tab between the minimum and maximum from its content", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 520, viewportWidth: 1200,
tabLabelLengths: [24, 12, 8], tabLabelWidths: [105],
metrics,
});
expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items.map((item) => item.width)).toEqual([139]);
});
it("shrinks natural widths proportionally without crossing the clickable minimum", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 460,
tabLabelWidths: [168, 84, 56],
metrics, metrics,
}); });
expect(result.closeButtonPolicy).toBe("all"); expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items.map((item) => item.width)).toEqual([125, 125, 125]); expect(result.items.map((item) => item.width)).toEqual([119, 101, 96]);
expect(result.items.every((item) => item.showLabel)).toBe(true); expect(result.items.every((item) => item.showLabel)).toBe(true);
}); });
it("keeps evenly sized tabs at the cap when the split width exceeds it", () => { it("caps long tabs at the maximum width", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 743, viewportWidth: 1004,
tabLabelLengths: [8, 8, 8, 8], tabLabelWidths: [280, 280, 280, 280],
metrics: { metrics: {
...metrics, ...metrics,
actionsReservedWidth: 44, actionsReservedWidth: 44,
@@ -55,39 +67,39 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.closeButtonPolicy).toBe("all"); expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items.map((item) => item.width)).toEqual([160, 160, 160, 160]); expect(result.items.map((item) => item.width)).toEqual([240, 240, 240, 240]);
}); });
it("collapses to icon-only before allowing horizontal scroll fallback", () => { it("keeps every tab at the clickable minimum at the exact fit boundary", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 268, viewportWidth: 532,
tabLabelLengths: [14, 14, 14, 14], tabLabelWidths: [98, 98, 98, 98],
metrics, metrics,
}); });
expect(result.closeButtonPolicy).toBe("all"); expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]); expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]);
expect(result.items.every((item) => !item.showLabel)).toBe(true); expect(result.items.every((item) => item.showLabel)).toBe(true);
}); });
it("allows horizontal scroll only when icon-only tabs still cannot fit", () => { it("uses horizontal scroll rather than shrinking below the clickable minimum", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 267, viewportWidth: 531,
tabLabelLengths: [14, 14, 14, 14], tabLabelWidths: [98, 98, 98, 98],
metrics, metrics,
}); });
expect(result.closeButtonPolicy).toBe("all"); expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(true); expect(result.requiresHorizontalScrollFallback).toBe(true);
expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]); expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]);
expect(result.items.every((item) => !item.showLabel)).toBe(true); expect(result.items.every((item) => item.showLabel)).toBe(true);
}); });
it("returns empty layout details when there are no tabs", () => { it("returns empty layout details when there are no tabs", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 1200, viewportWidth: 1200,
tabLabelLengths: [], tabLabelWidths: [],
metrics, metrics,
}); });
@@ -95,4 +107,14 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items).toEqual([]); expect(result.items).toEqual([]);
}); });
it("uses rendered label width rather than character count", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 1200,
tabLabelWidths: [68, 104],
metrics,
});
expect(result.items.map((item) => item.width)).toEqual([102, 138]);
});
}); });
@@ -1,25 +1,27 @@
export type WorkspaceTabCloseButtonPolicy = "all"; export type WorkspaceTabCloseButtonPolicy = "all";
export interface WorkspaceTabLayoutMetrics {
rowHorizontalInset: number;
actionsReservedWidth: number;
rowPaddingHorizontal: number;
tabGap: number;
minTabWidth: number;
maxTabWidth: number;
tabIconWidth: number;
tabContentGap: number;
tabHorizontalPadding: number;
closeButtonWidth: number;
}
export interface WorkspaceTabLayoutInput { export interface WorkspaceTabLayoutInput {
viewportWidth: number; viewportWidth: number;
tabLabelLengths: number[]; tabLabelWidths: number[];
metrics: { metrics: WorkspaceTabLayoutMetrics;
rowHorizontalInset: number;
actionsReservedWidth: number;
rowPaddingHorizontal: number;
tabGap: number;
maxTabWidth: number;
tabIconWidth: number;
tabHorizontalPadding: number;
estimatedCharWidth: number;
closeButtonWidth: number;
};
} }
export interface WorkspaceTabLayoutItem { export interface WorkspaceTabLayoutItem {
width: number; width: number;
showLabel: boolean; showLabel: boolean;
labelCharCap: number;
} }
export interface WorkspaceTabLayoutResult { export interface WorkspaceTabLayoutResult {
@@ -41,7 +43,7 @@ function clamp(value: number, min: number, max: number): number {
export function computeWorkspaceTabLayout( export function computeWorkspaceTabLayout(
input: WorkspaceTabLayoutInput, input: WorkspaceTabLayoutInput,
): WorkspaceTabLayoutResult { ): WorkspaceTabLayoutResult {
const tabCount = input.tabLabelLengths.length; const tabCount = input.tabLabelWidths.length;
if (tabCount === 0) { if (tabCount === 0) {
return { return {
items: [], items: [],
@@ -57,31 +59,39 @@ export function computeWorkspaceTabLayout(
const rowOverhead = const rowOverhead =
input.metrics.rowPaddingHorizontal * 2 + Math.max(tabCount - 1, 0) * input.metrics.tabGap; input.metrics.rowPaddingHorizontal * 2 + Math.max(tabCount - 1, 0) * input.metrics.tabGap;
const availableTabsWidth = Math.max(0, availableWidth - rowOverhead); const availableTabsWidth = Math.max(0, availableWidth - rowOverhead);
const iconOnlyTabWidth = const tabChromeWidth =
input.metrics.tabIconWidth + input.metrics.tabIconWidth +
input.metrics.tabContentGap +
input.metrics.tabHorizontalPadding * 2 + input.metrics.tabHorizontalPadding * 2 +
input.metrics.closeButtonWidth; input.metrics.closeButtonWidth;
const iconOnlyTotalTabsWidth = iconOnlyTabWidth * tabCount; const naturalWidths = input.tabLabelWidths.map((labelWidth) =>
const requiresHorizontalScrollFallback = availableTabsWidth < iconOnlyTotalTabsWidth; clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth),
const resolvedWidth = requiresHorizontalScrollFallback );
? iconOnlyTabWidth const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0);
: clamp(availableTabsWidth / tabCount, iconOnlyTabWidth, input.metrics.maxTabWidth); const minimumTotalWidth = input.metrics.minTabWidth * tabCount;
const resolvedWidths = Array.from({ length: tabCount }, () => resolvedWidth); const requiresHorizontalScrollFallback = availableTabsWidth < minimumTotalWidth;
let resolvedWidths = naturalWidths;
if (requiresHorizontalScrollFallback) {
resolvedWidths = Array.from({ length: tabCount }, () => input.metrics.minTabWidth);
} else if (naturalTotalWidth > availableTabsWidth) {
const widthToRemove = naturalTotalWidth - availableTabsWidth;
const shrinkCapacity = naturalTotalWidth - minimumTotalWidth;
const shrinkRatio = widthToRemove / shrinkCapacity;
resolvedWidths = naturalWidths.map(
(width) => width - (width - input.metrics.minTabWidth) * shrinkRatio,
);
}
const roundedWidths = resolvedWidths.map((width) => const roundedWidths = resolvedWidths.map((width) =>
Math.round(clamp(width, iconOnlyTabWidth, input.metrics.maxTabWidth)), Math.round(clamp(width, input.metrics.minTabWidth, input.metrics.maxTabWidth)),
); );
return { return {
items: roundedWidths.map((width) => { items: roundedWidths.map((width) => ({
const rawCharCap = Math.floor((width - iconOnlyTabWidth) / input.metrics.estimatedCharWidth); width,
const labelCharCap = Math.max(0, rawCharCap); showLabel: width > tabChromeWidth,
return { })),
width,
showLabel: labelCharCap > 0,
labelCharCap,
};
}),
closeButtonPolicy: "all", closeButtonPolicy: "all",
requiresHorizontalScrollFallback, requiresHorizontalScrollFallback,
}; };
+1 -12
View File
@@ -79,7 +79,6 @@ export interface PanelState {
explorerTab: ExplorerTab; explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>; explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>; expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
// Changes-view folder tree. Inverted semantics vs the fields above: // Changes-view folder tree. Inverted semantics vs the fields above:
// this stores COLLAPSED directory paths (empty = all folders expanded), keyed // this stores COLLAPSED directory paths (empty = all folders expanded), keyed
// by full uncompressed dir path, so folders default to expanded and new // by full uncompressed dir path, so folders default to expanded and new
@@ -111,7 +110,6 @@ export interface PanelState {
setExplorerTab: (tab: ExplorerTab) => void; setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void; setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setSidebarWidth: (width: number) => void; setSidebarWidth: (width: number) => void;
@@ -148,7 +146,6 @@ export const usePanelStore = create<PanelState>()(
explorerTab: "changes", explorerTab: "changes",
explorerTabByCheckout: {}, explorerTabByCheckout: {},
expandedPathsByWorkspace: {}, expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {}, diffCollapsedFoldersByWorkspace: {},
sidebarWidth: DEFAULT_SIDEBAR_WIDTH, sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
@@ -276,13 +273,6 @@ export const usePanelStore = create<PanelState>()(
}, },
}; };
}), }),
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
diffExpandedPathsByWorkspace: {
...state.diffExpandedPathsByWorkspace,
[workspaceKey]: paths,
},
})),
setDiffCollapsedFoldersForWorkspace: (workspaceKey, dirPaths) => setDiffCollapsedFoldersForWorkspace: (workspaceKey, dirPaths) =>
set((state) => ({ set((state) => ({
diffCollapsedFoldersByWorkspace: { diffCollapsedFoldersByWorkspace: {
@@ -308,7 +298,7 @@ export const usePanelStore = create<PanelState>()(
}), }),
{ {
name: "panel-state", name: "panel-state",
version: 13, version: 14,
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }), migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
partialize: (state) => ({ partialize: (state) => ({
@@ -316,7 +306,6 @@ export const usePanelStore = create<PanelState>()(
explorerTab: state.explorerTab, explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout, explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace, expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace, diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace,
sidebarWidth: state.sidebarWidth, sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth, explorerWidth: state.explorerWidth,
@@ -120,6 +120,14 @@ describe("panel-store migration", () => {
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] }); expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
}); });
it("drops the retired per-file diff expansion state", () => {
const state = migratePanelState({ diffExpandedPathsByWorkspace: { ws: ["src/app.ts"] } }, 13, {
isWeb: false,
});
expect(state.diffExpandedPathsByWorkspace).toBeUndefined();
});
it("replaces the dead explorer split ratio with the shared tree rail width", () => { it("replaces the dead explorer split ratio with the shared tree rail width", () => {
const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12, { isWeb: false }); const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12, { isWeb: false });
+2 -7
View File
@@ -183,6 +183,7 @@ export const PanelPersistedStateSchema = z.strictObject({
explorerTab: ExplorerTabSchema.optional(), explorerTab: ExplorerTabSchema.optional(),
explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).optional(), explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).optional(),
expandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), expandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
// Accepted only so migration can discard the former per-file diff expansion state.
diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(), diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
sidebarWidth: z.number().optional(), sidebarWidth: z.number().optional(),
@@ -290,13 +291,7 @@ export function migratePanelState(
) { ) {
state.expandedPathsByWorkspace = {}; state.expandedPathsByWorkspace = {};
} }
if ( delete state.diffExpandedPathsByWorkspace;
version < 10 ||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
!state.diffExpandedPathsByWorkspace
) {
state.diffExpandedPathsByWorkspace = {};
}
if ( if (
version < 12 || version < 12 ||
typeof state.diffCollapsedFoldersByWorkspace !== "object" || typeof state.diffCollapsedFoldersByWorkspace !== "object" ||
@@ -423,6 +423,25 @@ describe("workspace-layout-store actions", () => {
expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId); expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId);
}); });
it("places an auto-added pull request in the pane containing Changes", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "working_diff" });
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, "working_diff")?.id).toBe("main");
expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe("main");
});
it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => { it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = workspaceLayoutStore.getState();
@@ -521,10 +521,17 @@ export function createWorkspaceLayoutStore(
} }
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const workingDiffTab = collectAllTabs(layout.root).find(
(tab) => tab.target.kind === "working_diff",
);
const workingDiffPane = workingDiffTab
? findPaneContainingTab(layout.root, workingDiffTab.tabId)
: null;
const explorerPaneId = state.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null; const explorerPaneId = state.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null;
const explorerPane = findPaneById(layout.root, explorerPaneId); const explorerPane = findPaneById(layout.root, explorerPaneId);
const placementLayout = explorerPane const placementPane = workingDiffPane ?? explorerPane;
? (focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout) const placementLayout = placementPane
? (focusPaneInLayout({ layout, paneId: placementPane.id }) ?? layout)
: layout; : layout;
const result = openTabInLayoutFocused({ const result = openTabInLayoutFocused({
layout: placementLayout, layout: placementLayout,
-161
View File
@@ -1,161 +0,0 @@
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
FileDiff,
FolderTree,
GitPullRequest,
Globe,
SquarePen,
SquareTerminal,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { getProviderIcon } from "@/components/provider-icons";
import { getIsElectron } from "@/constants/platform";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import type { Theme } from "@/styles/theme";
import {
isPinnedTargetAvailable,
pinnedTargetKey,
type PinnedTabTarget,
} from "@/workspace-pins/target";
import { usePinnedTargetsStore } from "@/workspace-pins/store";
export interface ResolvedPin {
key: string;
label: string;
icon: ReactElement;
onPress: () => void;
}
interface UsePinnedLaunchersInput {
serverId: string;
onLaunch: (target: PinnedTabTarget) => void;
isGit?: boolean;
hasPullRequest?: boolean;
}
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
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,
size,
color = "",
}: {
iconKey: string;
size: number;
color?: string;
}) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedProviderPinIcon = withUnistyles(ProviderPinIcon);
export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement {
if (!iconKey) {
return <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
}
return <ThemedProviderPinIcon iconKey={iconKey} size={14} uniProps={mutedColorMapping} />;
}
export function usePinnedLaunchers({
serverId,
onLaunch,
isGit,
hasPullRequest,
}: UsePinnedLaunchersInput): ResolvedPin[] {
const { t } = useTranslation();
const pinned = usePinnedTargetsStore((state) => state.pinned);
const { config } = useDaemonConfig(serverId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
return useMemo(() => {
const resolved: ResolvedPin[] = [];
for (const target of pinned) {
if (
!isPinnedTargetAvailable(target, { isElectron: getIsElectron(), isGit, hasPullRequest })
) {
continue;
}
if (target.kind === "draft") {
resolved.push({
key: pinnedTargetKey(target),
label: t("workspace.tabs.actions.newAgent"),
icon: <ThemedSquarePen size={14} uniProps={mutedColorMapping} />,
onPress: () => onLaunch(target),
});
continue;
}
if (target.kind === "terminal") {
resolved.push({
key: pinnedTargetKey(target),
label: t("workspace.tabs.actions.newTerminal"),
icon: <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />,
onPress: () => onLaunch(target),
});
continue;
}
if (target.kind === "browser") {
resolved.push({
key: pinnedTargetKey(target),
label: t("workspace.tabs.actions.newBrowser"),
icon: <ThemedGlobe size={14} uniProps={mutedColorMapping} />,
onPress: () => onLaunch(target),
});
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;
}
resolved.push({
key: pinnedTargetKey(target),
label: profile.name,
icon: <ProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
onPress: () => onLaunch(target),
});
}
return resolved;
}, [hasPullRequest, isGit, onLaunch, pinned, profiles, t]);
}
@@ -1,132 +0,0 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, View, type GestureResponderEvent } from "react-native";
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";
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedPin = withUnistyles(Pin);
const ThemedPinOff = withUnistyles(PinOff);
interface PinnableMenuItemProps {
target: PinnedTabTarget;
label: string;
leading: ReactElement;
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({
target,
label,
leading,
disabled,
onSelect,
testID,
shortcut,
}: PinnableMenuItemProps): ReactElement {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isPinned = usePinnedTargetsStore((state) => state.isPinned(target));
const toggle = usePinnedTargetsStore((state) => state.toggle);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const handleTogglePin = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
toggle(target);
},
[target, toggle],
);
const showToggle = isHovered || isNative || isCompact;
const slotStyle = useMemo(
() => [styles.pinToggleSlot, showToggle ? styles.pinToggleShown : styles.pinToggleHidden],
[showToggle],
);
const trailing = useMemo(
() => (
<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, shortcut, showToggle, slotStyle, t, target],
);
return (
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<DropdownMenuItem
testID={testID}
leading={leading}
trailing={trailing}
disabled={disabled}
onSelect={onSelect}
>
{label}
</DropdownMenuItem>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
trailingRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
pinToggleSlot: {
width: 22,
height: 22,
alignItems: "center",
justifyContent: "center",
},
pinToggleHidden: {
opacity: 0,
},
pinToggleShown: {
opacity: 1,
},
pinToggleButton: {
width: 22,
height: 22,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -1,58 +0,0 @@
import type { ReactElement } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { ResolvedPin } from "@/workspace-pins/launch";
function pinButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.pinButton, (Boolean(hovered) || pressed) && styles.pinButtonHovered];
}
interface PinnedTargetsRowProps {
launchers: ResolvedPin[];
testIdPrefix: string;
}
export function PinnedTargetsRow({ launchers, testIdPrefix }: PinnedTargetsRowProps): ReactElement {
return (
<View style={styles.row}>
{launchers.map((launcher) => (
<Tooltip key={launcher.key} delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID={`${testIdPrefix}-${launcher.key}`}
onPress={launcher.onPress}
accessibilityRole="button"
accessibilityLabel={launcher.label}
style={pinButtonStyle}
>
{launcher.icon}
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.tooltipText}>{launcher.label}</Text>
</TooltipContent>
</Tooltip>
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
},
pinButton: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
pinButtonHovered: {
backgroundColor: theme.colors.surface2,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));
@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
import { runPinnedTabTarget, type TabTargetHandlers } from "./run";
const PROFILES: readonly TerminalProfile[] = [
{ id: "claude", name: "Claude Code", command: "claude" },
];
interface RecordedLaunch {
action: "draft" | "terminal" | "browser" | "changes" | "files" | "pull_request" | "profile";
profile?: TerminalProfileInput;
}
function recordingHandlers() {
const launches: RecordedLaunch[] = [];
const handlers: TabTargetHandlers = {
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 };
}
describe("runPinnedTabTarget", () => {
it("creates a draft agent for the draft target", () => {
const { launches, handlers } = recordingHandlers();
runPinnedTabTarget({ kind: "draft" }, PROFILES, handlers);
expect(launches).toEqual([{ action: "draft" }]);
});
it("creates a terminal for the terminal target", () => {
const { launches, handlers } = recordingHandlers();
runPinnedTabTarget({ kind: "terminal" }, PROFILES, handlers);
expect(launches).toEqual([{ action: "terminal" }]);
});
it("creates a browser for the browser target", () => {
const { launches, handlers } = recordingHandlers();
runPinnedTabTarget({ kind: "browser" }, PROFILES, handlers);
expect(launches).toEqual([{ action: "browser" }]);
});
it("launches the resolved profile command for a known profile target", () => {
const { launches, handlers } = recordingHandlers();
runPinnedTabTarget({ kind: "profile", profileId: "claude" }, PROFILES, handlers);
expect(launches).toEqual([
{ action: "profile", profile: { name: "Claude Code", command: "claude", args: [] } },
]);
});
it("launches a prompt-taking profile bare, never with the sentinel as a literal argument", () => {
const { launches, handlers } = recordingHandlers();
runPinnedTabTarget(
{ kind: "profile", profileId: "codex" },
[{ id: "codex", name: "Codex", command: "codex", args: ["--yolo", "{{{prompt}}}"] }],
handlers,
);
// A pin has nowhere to type, so the prompt-only arg drops instead of
// reaching the CLI as the literal text `{{{prompt}}}`.
expect(launches).toEqual([
{ action: "profile", profile: { name: "Codex", command: "codex", args: ["--yolo"] } },
]);
});
it("does nothing when the profile id is absent from the host", () => {
const { launches, handlers } = recordingHandlers();
runPinnedTabTarget({ kind: "profile", profileId: "missing" }, PROFILES, handlers);
expect(launches).toEqual([]);
});
});
-52
View File
@@ -1,52 +0,0 @@
import { resolveTerminalProfileLaunch } from "@getpaseo/protocol/terminal-profiles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
import type { PinnedTabTarget } from "@/workspace-pins/target";
export interface TabTargetHandlers {
createDraft: () => void;
createTerminal: () => void;
createBrowser: () => void;
openChanges: () => void;
openFiles: () => void;
openPullRequest: () => void;
createTerminalWithProfile: (profile: TerminalProfileInput) => void;
}
export function runPinnedTabTarget(
target: PinnedTabTarget,
profiles: readonly TerminalProfile[],
handlers: TabTargetHandlers,
): void {
if (target.kind === "draft") {
handlers.createDraft();
return;
}
if (target.kind === "terminal") {
handlers.createTerminal();
return;
}
if (target.kind === "browser") {
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;
}
// Nothing to type from a pin, so the prompt is empty and any sentinel arg
// drops out.
handlers.createTerminalWithProfile(resolveTerminalProfileLaunch(profile, ""));
}
@@ -1,46 +0,0 @@
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" },
]);
});
});
-67
View File
@@ -1,67 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { z } from "zod";
import { isTargetPinned, togglePinnedTarget, type PinnedTabTarget } from "@/workspace-pins/target";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
interface PinnedTargetsState {
pinned: PinnedTabTarget[];
toggle: (target: PinnedTabTarget) => void;
isPinned: (target: PinnedTabTarget) => boolean;
}
const DEFAULT_PINNED_TARGETS: PinnedTabTarget[] = [{ kind: "terminal" }, { kind: "browser" }];
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({
pinned: z.array(PinnedTabTargetSchema),
});
function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] {
const next = [...DEFAULT_PINNED_TARGETS];
for (const target of pinned) {
if (!isTargetPinned(next, target)) {
next.push(target);
}
}
return next;
}
export const usePinnedTargetsStore = create<PinnedTargetsState>()(
persist<PinnedTargetsState, [], [], z.infer<typeof PinnedTargetsPersistedStateSchema>>(
(set, get) => ({
pinned: [],
toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })),
isPinned: (target) => isTargetPinned(get().pinned, target),
}),
{
name: "pinned-tab-targets",
version: 1,
merge: (persistedState, currentState) => {
const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState);
return {
...currentState,
pinned: result.success ? result.data.pinned : applyDefaultPinnedTargets([]),
};
},
storage: createValidatedPersistStorage(AsyncStorage, PinnedTargetsPersistedStateSchema),
partialize: (state) => ({ pinned: state.pinned }),
migrate: (persistedState, version) => {
const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState);
const pinned = result.success ? result.data.pinned : [];
if (version === 0) {
return { pinned: applyDefaultPinnedTargets(pinned) };
}
return { pinned };
},
},
),
);
@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isPinnedTargetAvailable,
isTargetPinned,
pinnedTargetKey,
togglePinnedTarget,
} from "./target";
describe("isPinnedTargetAvailable", () => {
it("only offers browser targets in Electron", () => {
const browser = { kind: "browser" } as const;
expect(isPinnedTargetAvailable(browser, { isElectron: true })).toBe(true);
expect(isPinnedTargetAvailable(browser, { isElectron: false })).toBe(false);
});
it("offers cross-platform targets outside Electron", () => {
const environment = { isElectron: false };
expect(isPinnedTargetAvailable({ kind: "draft" }, environment)).toBe(true);
expect(isPinnedTargetAvailable({ kind: "terminal" }, environment)).toBe(true);
expect(isPinnedTargetAvailable({ kind: "profile", profileId: "claude" }, environment)).toBe(
true,
);
});
});
describe("pinnedTargetKey", () => {
it("uses the bare kind as the key for non-profile targets", () => {
expect(pinnedTargetKey({ kind: "draft" })).toBe("draft");
expect(pinnedTargetKey({ kind: "terminal" })).toBe("terminal");
expect(pinnedTargetKey({ kind: "browser" })).toBe("browser");
});
it("namespaces a profile target by its profile id", () => {
expect(pinnedTargetKey({ kind: "profile", profileId: "claude" })).toBe("profile:claude");
});
it("gives two profiles with different ids different keys", () => {
const claude = pinnedTargetKey({ kind: "profile", profileId: "claude" });
const codex = pinnedTargetKey({ kind: "profile", profileId: "codex" });
expect(claude).not.toBe(codex);
});
});
describe("togglePinnedTarget / isTargetPinned", () => {
it("pins a target that is not yet pinned", () => {
const pinned = togglePinnedTarget([], { kind: "terminal" });
expect(isTargetPinned(pinned, { kind: "terminal" })).toBe(true);
});
it("unpins a target that is already pinned", () => {
const pinned = togglePinnedTarget([{ kind: "browser" }], { kind: "browser" });
expect(isTargetPinned(pinned, { kind: "browser" })).toBe(false);
});
it("treats profiles with different ids as independent pins", () => {
const pinned = togglePinnedTarget([], { kind: "profile", profileId: "claude" });
expect(isTargetPinned(pinned, { kind: "profile", profileId: "claude" })).toBe(true);
expect(isTargetPinned(pinned, { kind: "profile", profileId: "codex" })).toBe(false);
});
it("unpins only the matching profile id", () => {
let pinned = togglePinnedTarget([], { kind: "profile", profileId: "claude" });
pinned = togglePinnedTarget(pinned, { kind: "profile", profileId: "codex" });
pinned = togglePinnedTarget(pinned, { kind: "profile", profileId: "claude" });
expect(isTargetPinned(pinned, { kind: "profile", profileId: "claude" })).toBe(false);
expect(isTargetPinned(pinned, { kind: "profile", profileId: "codex" })).toBe(true);
});
});
-51
View File
@@ -1,51 +0,0 @@
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; isGit?: boolean; hasPullRequest?: boolean },
): boolean {
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 {
if (target.kind === "profile") {
return `profile:${target.profileId}`;
}
return target.kind;
}
export function isTargetPinned(
pinned: readonly PinnedTabTarget[],
target: PinnedTabTarget,
): boolean {
const key = pinnedTargetKey(target);
return pinned.some((entry) => pinnedTargetKey(entry) === key);
}
export function togglePinnedTarget(
pinned: readonly PinnedTabTarget[],
target: PinnedTabTarget,
): PinnedTabTarget[] {
const key = pinnedTargetKey(target);
const next = pinned.filter((entry) => pinnedTargetKey(entry) !== key);
if (next.length === pinned.length) {
next.push(target);
}
return next;
}