diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 3043437c7..f7387190b 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -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"))) .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 page.getByTestId("diff-folder-src-duplicate").click(); await expect @@ -317,7 +317,7 @@ test("changes context menu recursively collapses descendant folders", async ({ p await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); - await page.getByTestId("changes-toggle-view-mode").click(); + await page.getByTestId("changes-toggle-tree").click(); const tree = changesTree(page); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); @@ -346,7 +346,7 @@ test("changes context menus expose folder revert and restore a file after confir await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); - await page.getByTestId("changes-toggle-view-mode").click(); + await page.getByTestId("changes-toggle-tree").click(); const tree = changesTree(page); await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); 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 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((resolve) => { page.once("dialog", async (dialog) => { const message = dialog.message(); @@ -365,14 +365,14 @@ test("changes context menus expose folder revert and restore a file after confir 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"); - await expect(tree.getByTestId("diff-file-0")).toBeVisible(); + await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible(); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .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((resolve) => { page.once("dialog", async (dialog) => { const message = dialog.message(); @@ -380,10 +380,10 @@ test("changes context menus expose folder revert and restore a file after confir 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"); - 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 .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .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).toContainText("zz-deleted.ts"); 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 visiblePanel.getByTestId("diff-file-0-toggle").click(); - await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0); - await visiblePanel.getByTestId("diff-file-0-toggle").click(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); const workingDiffLayoutToggle = visiblePanel.getByRole("button", { 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 expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible(); await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Expand all files" }).click(); - 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.getByRole("button", { name: /all files/i })).toHaveCount(0); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); @@ -520,7 +518,9 @@ test("Changes keeps review navigation and controls inside its workspace tab", as 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(); await useUnwrappedDiffLines(page); 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 scrollToLowerUnwrappedDiffRows(page); - await page.getByTestId("changes-toggle-view-mode").click(); + await page.getByTestId("changes-toggle-tree").click(); const tree = changesTree(page); const content = changesContent(page); 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", "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-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 folderChevronBounds = await tree .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 }) .boundingBox(); const fileLabelBounds = await tree - .getByTestId("diff-file-0") + .getByTestId("diff-tree-file-0") .getByText("use-mounted-tab-set.ts", { exact: true }) .boundingBox(); 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"); await folderToggle.click(); 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 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 expect(fileToggle).toHaveAttribute("aria-selected", "true"); 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.getByRole("button", { name: "Collapse all files" }).click(); - await expect(content.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); - await page.getByRole("button", { name: "Expand all files" }).click(); + await fileToggle.click(); 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 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); }); @@ -649,7 +653,7 @@ async function useUnwrappedDiffLines(page: Page): Promise { preferencesKey, JSON.stringify({ layout: "unified", - viewMode: "flat", + desktopTreeVisible: false, wrapLines: false, hideWhitespace: false, }), diff --git a/packages/app/e2e/browser/workspace-pins.spec.ts b/packages/app/e2e/browser/workspace-pins.spec.ts deleted file mode 100644 index 8c8e6ec51..000000000 --- a/packages/app/e2e/browser/workspace-pins.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/e2e/support/helpers/pins.ts b/packages/app/e2e/support/helpers/pins.ts deleted file mode 100644 index e68c2c24c..000000000 --- a/packages/app/e2e/support/helpers/pins.ts +++ /dev/null @@ -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 { - 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 { - 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"); -} diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 13a17b999..39eb7dadb 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -94,7 +94,7 @@ export function BranchSwitcher({ accessibilityRole="button" accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })} > - + {currentBranchName} @@ -132,8 +132,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], minWidth: 0, paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - marginLeft: -theme.spacing[2], + paddingHorizontal: theme.spacing[1], borderRadius: theme.borderRadius.md, flexShrink: 1, }, @@ -141,9 +140,9 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface1, }, branchLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - fontWeight: theme.fontWeight.medium, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, flexShrink: 1, }, })); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index a30c9aa87..1bb0d6eb0 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -18,7 +18,7 @@ import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/sto import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; 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 { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window"; @@ -433,7 +433,7 @@ function ChangedFilesPane({ >) { const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); return ( - onSplitPaneEmpty({ targetPaneId: paneId, position: "right" }), - [onSplitPaneEmpty, paneId], - ); - const handleSplitDown = useCallback( - () => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }), - [onSplitPaneEmpty, paneId], - ); - return ( @@ -1151,8 +1142,6 @@ function SplitPaneView({ onCreateBrowserTab={onCreateBrowserTab} showCreateBrowserTab={showCreateBrowserTab} onReorderTabs={handleReorderTabs} - onSplitRight={handleSplitRight} - onSplitDown={handleSplitDown} externalDndContext activeDragTabId={activeDragTabId} tabDropPreviewIndex={ diff --git a/packages/app/src/components/terminal-profile-icon.tsx b/packages/app/src/components/terminal-profile-icon.tsx new file mode 100644 index 000000000..61cf992b2 --- /dev/null +++ b/packages/app/src/components/terminal-profile-icon.tsx @@ -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 ; +} + +const ThemedProviderProfileIcon = withUnistyles(ProviderProfileIcon); + +export function TerminalProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement { + if (!iconKey) { + return ; + } + return ; +} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 9fa315595..b2eaebd9f 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -35,9 +35,6 @@ import { ChevronDown, Columns2, FolderTree, - List, - ListChevronsDownUp, - ListChevronsUpDown, Maximize2, Pilcrow, RotateCw, @@ -70,7 +67,6 @@ import { useAppSettings } from "@/hooks/use-settings"; import { DiffScroll } from "@/components/diff-scroll"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; -import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll"; import { buildSplitDiffRows, buildUnifiedDiffLines, @@ -94,6 +90,7 @@ import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { GitActionsSplitButton } from "@/git/actions-split-button"; +import type { GitActions } from "@/git/policy"; import { BranchSwitcher } from "@/components/branch-switcher"; import { useGitActions } from "@/git/use-actions"; import { GIT_ACTION_ICONS } from "@/git/action-icons"; @@ -226,14 +223,15 @@ const HighlightedText = memo(function HighlightedText({ interface DiffFileSectionProps { file: ParsedDiffFile; workspaceFileDragScope?: { serverId: string; workspaceId: string }; - isExpanded: boolean; + bodyVisible: boolean; + showsBodyState?: boolean; isSelected?: boolean; /** Tree indentation level (0 on the flat/mobile path). */ depth?: number; /** Show the muted directory suffix (flat list); false inside the folder tree. */ showDir?: boolean; interactive?: boolean; - onToggle?: (path: string) => void; + onActivate?: (path: string) => void; onSelect?: (path: string) => void; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; @@ -1114,12 +1112,13 @@ function DiffFileActionsContextMenuContent({ const DiffFileHeader = memo(function DiffFileHeader({ file, workspaceFileDragScope, - isExpanded, + bodyVisible, + showsBodyState = true, isSelected = false, depth = 0, showDir = true, interactive = true, - onToggle, + onActivate, onSelect, onOpenFile, onAddToChat, @@ -1150,7 +1149,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ } }, [file.path, interactive, onSelect]); - const toggleExpanded = useCallback(() => { + const handleActivate = useCallback(() => { if (!interactive) { return; } @@ -1160,8 +1159,8 @@ const DiffFileHeader = memo(function DiffFileHeader({ } pressHandledRef.current = true; handleSelect(); - onToggle?.(file.path); - }, [file.path, handleSelect, interactive, onToggle]); + onActivate?.(file.path); + }, [file.path, handleSelect, interactive, onActivate]); const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -1199,20 +1198,20 @@ const DiffFileHeader = memo(function DiffFileHeader({ const dy = event.nativeEvent.pageY - pressInRef.current.pageY; const distance = Math.hypot(dx, dy); if (durationMs <= 500 && distance <= 12) { - toggleExpanded(); + handleActivate(); } } }, - [interactive, toggleExpanded], + [handleActivate, interactive], ); const containerStyle = useMemo( - () => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded], - [isExpanded], + () => [styles.fileSectionHeaderContainer, bodyVisible && styles.fileSectionHeaderExpanded], + [bodyVisible], ); const accessibilityState = useMemo( - () => ({ expanded: isExpanded, selected: isSelected }), - [isExpanded, isSelected], + () => ({ ...(showsBodyState ? { expanded: bodyVisible } : {}), selected: isSelected }), + [bodyVisible, isSelected, showsBodyState], ); const headerPressableStyle = useCallback( @@ -1286,7 +1285,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ onPressOut={handlePressOut} onLongPress={handleLongPress} onContextMenu={handleSelect} - onPress={toggleExpanded} + onPress={handleActivate} accessibilityState={accessibilityState} aria-selected={isSelected} > @@ -1571,12 +1570,14 @@ export function DiffFileBody({ ); } -interface GitDiffPaneProps { +interface ChangesSurfaceProps { serverId: string; workspaceId?: string | null; cwd: string; enabled?: boolean; host: "explorer" | "panel"; + focusPath?: string; + focusRequestId?: number; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; } @@ -1592,10 +1593,7 @@ const ThemedAlignJustify = withUnistyles(AlignJustify); const ThemedColumns2 = withUnistyles(Columns2); const ThemedPilcrow = withUnistyles(Pilcrow); const ThemedWrapText = withUnistyles(WrapText); -const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp); -const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown); const ThemedFolderTree = withUnistyles(FolderTree); -const ThemedList = withUnistyles(List); const ThemedMaximize2 = withUnistyles(Maximize2); const ThemedChevronDown = withUnistyles(ChevronDown); const DIFF_OPTIONS_WHITESPACE_ICON = ( @@ -1621,7 +1619,7 @@ export function DiffLayoutToggle({ onToggle, }: DiffLayoutToggleProps) { const defaultToggleStyle = useMemo( - () => buildToggleButtonStyle(false, styles.expandAllButton), + () => buildToggleButtonStyle(false, styles.toolbarIconButton), [], ); const { t } = useTranslation(); @@ -1742,7 +1740,7 @@ export function DiffModeMenu({ function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps) { const { t } = useTranslation(); const buttonStyle = useMemo( - () => buildToggleButtonStyle(selected, styles.expandAllButton), + () => buildToggleButtonStyle(selected, styles.toolbarIconButton), [selected], ); const label = t( @@ -1771,46 +1769,30 @@ function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps ); } -interface DiffViewModeToggleProps { - viewMode: "flat" | "tree"; - isMobile: boolean; +interface DesktopTreeToggleProps { + visible: boolean; toggleStyle?: PressableStyleFn; onToggle: () => void; } -export function DiffViewModeToggle({ - viewMode, - isMobile, - toggleStyle, - onToggle, -}: DiffViewModeToggleProps) { +function DesktopTreeToggle({ visible, toggleStyle, onToggle }: DesktopTreeToggleProps) { const { t } = useTranslation(); const defaultToggleStyle = useMemo( - () => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton), - [viewMode], + () => buildToggleButtonStyle(visible, styles.toolbarIconButton), + [visible], ); - const label = - viewMode === "flat" - ? t("workspace.git.diff.showTreeView") - : t("workspace.git.diff.showFlatView"); + const label = t(visible ? "workspace.git.diff.hideTreeView" : "workspace.git.diff.showTreeView"); return ( - {viewMode === "flat" ? ( - - ) : ( - - )} + @@ -1820,54 +1802,131 @@ export function DiffViewModeToggle({ ); } -interface DiffFilesToolbarProps { - allFileDiffsExpanded: boolean; +interface ChangesToolbarProps { + branchName: string | null; + canUseSplitLayout: boolean; + changesTabOpen: boolean; + committedDescription?: string; + cwd: string; + desktopTreeVisible: boolean; + diffMode: "uncommitted" | "base"; + forge: Forge; + gitActions: GitActions; + hasFiles: boolean; + hideWhitespace: boolean; + host: "explorer" | "panel"; isMobile: boolean; - testID?: string; - expandAllToggleStyle?: PressableStyleFn; - onToggleExpandAll: () => void; + isRefreshing: boolean; + layout: "unified" | "split"; + layoutToggleStyle: PressableStyleFn; + overflowToggleStyle: PressableStyleFn; + refreshSupported: boolean; + serverId: string; + treeToggleStyle: PressableStyleFn; + workspaceId?: string | null; + wrapLines: boolean; + onRefresh: () => void; + onSelectBase: () => void; + onSelectUncommitted: () => void; + onToggleChangesTab: () => void; + onToggleDesktopTree: () => void; + onToggleHideWhitespace: () => void; + onToggleLayout: () => void; + onToggleWrapLines: () => void; } -export function DiffFilesToolbar({ - allFileDiffsExpanded, +function ChangesToolbar({ + branchName, + canUseSplitLayout, + changesTabOpen, + committedDescription, + cwd, + desktopTreeVisible, + diffMode, + forge, + gitActions, + hasFiles, + hideWhitespace, + host, isMobile, - testID, - expandAllToggleStyle, - onToggleExpandAll, -}: DiffFilesToolbarProps) { - const defaultToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); - const { t } = useTranslation(); - const expandAllLabel = allFileDiffsExpanded - ? t("workspace.git.diff.collapseAll") - : t("workspace.git.diff.expandAll"); + isRefreshing, + layout, + layoutToggleStyle, + overflowToggleStyle, + refreshSupported, + serverId, + treeToggleStyle, + workspaceId, + wrapLines, + onRefresh, + onSelectBase, + onSelectUncommitted, + onToggleChangesTab, + onToggleDesktopTree, + onToggleHideWhitespace, + onToggleLayout, + onToggleWrapLines, +}: ChangesToolbarProps) { return ( - - - - - {allFileDiffsExpanded ? ( - - ) : ( - - )} - - - - {expandAllLabel} - - + + + + + + + + + {canUseSplitLayout && !changesTabOpen ? ( + + ) : null} + {!isMobile && hasFiles ? ( + + ) : null} + + + + {isMobile ? ( + + + + ) : null} ); } @@ -2127,7 +2186,7 @@ function DiffBodyContent({ return children; } -interface SharedDiffViewProps { +interface DiffDocumentProps { files: ParsedDiffFile[]; displayPreferences: { layout: "unified" | "split"; @@ -2137,12 +2196,11 @@ interface SharedDiffViewProps { }; mode: | { - kind: "working_tree"; - viewMode: "flat" | "tree"; - expandedPaths: string[]; - collapsedFolders: string[]; + kind: "working"; reviewActions?: InlineReviewActions; onFilePress?: (path: string) => void; + focusPath?: string; + focusRequestId?: number; workspaceFileDragScope?: { serverId: string; workspaceId: string }; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; @@ -2153,25 +2211,15 @@ interface SharedDiffViewProps { onDownload?: (path: string) => void; onDuplicate?: (path: string) => void; onRevert?: (path: string, oldPath?: string) => void; - onExpandedPathsChange: (paths: string[]) => void; - onCollapsedFoldersChange: (paths: string[]) => void; - } - | { - kind: "working_tab"; - expandedPaths: string[] | null; - reviewActions: InlineReviewActions; - focusPath?: string; - focusRequestId?: number; - onExpandedPathsChange: (paths: string[]) => void; } | { kind: "commit"; }; } -type WorkingTreeDiffMode = Extract; +type WorkingDiffMode = Extract; -export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { +export function DiffDocument({ files, displayPreferences, mode }: DiffDocumentProps) { const isCompact = useIsCompactFormFactor(); const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; const diffBodyLineHeight = Math.round(codeFontSize * 1.5); @@ -2184,57 +2232,33 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ...(trimmedMonoFontFamily ? { fontFamily: trimmedMonoFontFamily } : null), }; }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); - const viewMode = mode.kind === "working_tree" ? mode.viewMode : "flat"; - const expandedPathsArray = useMemo(() => { - if (mode.kind === "working_tree") { - return mode.expandedPaths; - } - if (mode.kind === "working_tab" && mode.expandedPaths !== null) { - return mode.expandedPaths; - } - return files.map((file) => file.path); - }, [files, mode]); + const expandedPathsArray = useMemo(() => files.map((file) => file.path), [files]); const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); - const collapsedFoldersArray = - mode.kind === "working_tree" ? mode.collapsedFolders : EMPTY_PATH_LIST; - const collapsedFolders = useMemo(() => new Set(collapsedFoldersArray), [collapsedFoldersArray]); const stickyHeaders = mode.kind !== "commit"; const interactive = mode.kind !== "commit"; const reviewActions = mode.kind === "commit" ? undefined : mode.reviewActions; - const onFilePress = mode.kind === "working_tree" ? mode.onFilePress : undefined; - const focusPath = mode.kind === "working_tab" ? mode.focusPath : undefined; - const focusRequestId = mode.kind === "working_tab" ? mode.focusRequestId : undefined; - const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined; - const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined; - const workspaceFileDragScope = - mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined; - const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined; - const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined; - const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined; - const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined; - const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined; - const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined; - const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined; + const onFilePress = mode.kind === "working" ? mode.onFilePress : undefined; + const focusPath = mode.kind === "working" ? mode.focusPath : undefined; + const focusRequestId = mode.kind === "working" ? mode.focusRequestId : undefined; + const onOpenFile = mode.kind === "working" ? mode.onOpenFile : undefined; + const onAddToChat = mode.kind === "working" ? mode.onAddToChat : undefined; + const workspaceFileDragScope = mode.kind === "working" ? mode.workspaceFileDragScope : undefined; + const onCopyPath = mode.kind === "working" ? mode.onCopyPath : undefined; + const onCopyRelativePath = mode.kind === "working" ? mode.onCopyRelativePath : undefined; + const onReveal = mode.kind === "working" ? mode.onReveal : undefined; + const revealTargetName = mode.kind === "working" ? mode.revealTargetName : undefined; + const onDownload = mode.kind === "working" ? mode.onDownload : undefined; + const onDuplicate = mode.kind === "working" ? mode.onDuplicate : undefined; + const onRevert = mode.kind === "working" ? mode.onRevert : undefined; // Keep selection independent from expansion so future keyboard actions (such as R to rename) // can target the current VCS file or folder without changing its open state. const [selectedPath, setSelectedPath] = useState(null); const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); - const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); - const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); - const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]); useEffect(() => { - if ( - selectedPath && - !allFolderPathSet.has(selectedPath) && - !files.some((file) => file.path === selectedPath) - ) { + if (selectedPath && !files.some((file) => file.path === selectedPath)) { setSelectedPath(null); } - }, [allFolderPathSet, files, selectedPath]); - const effectiveCollapsedFolders = useMemo( - () => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))), - [allFolderPathSet, collapsedFolders], - ); + }, [files, selectedPath]); const diffListRef = useRef>(null); const diffViewport = useMemo(() => createDiffViewport(), []); useEffect(() => () => diffViewport.dispose(), [diffViewport]); @@ -2246,7 +2270,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); const bodyHeightByKeyRef = useRef>({}); - const folderRowHeightRef = useRef(0); const defaultHeaderHeightRef = useRef(44); const [heightVersion, setHeightVersion] = useState(0); const heightVersionFrameRef = useRef(null); @@ -2273,16 +2296,15 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const { flatItems, stickyHeaderIndices } = useMemo(() => { const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({ files, - viewMode, - tree: compressedTree, - collapsedFolders: effectiveCollapsedFolders, + viewMode: "flat", + collapsedFolders: new Set(), expandedPaths, }); return { flatItems: items, stickyHeaderIndices: stickyHeaders ? stickyIndices : [], }; - }, [compressedTree, effectiveCollapsedFolders, expandedPaths, files, stickyHeaders, viewMode]); + }, [expandedPaths, files, stickyHeaders]); const getBodyHeightKey = useCallback( (file: ParsedDiffFile): string => { @@ -2325,7 +2347,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const getFlatItemHeight = useCallback( (item: DiffFlatItem): number => { if (item.type === "folder") { - return folderRowHeightRef.current || defaultHeaderHeightRef.current; + return defaultHeaderHeightRef.current; } if (item.type === "header") { return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; @@ -2336,21 +2358,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [estimateBodyHeight, getBodyHeightKey], ); - const handleFolderRowHeightChange = useCallback( - (height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = folderRowHeightRef.current; - if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { - return; - } - folderRowHeightRef.current = height; - scheduleHeightVersionUpdate(); - }, - [scheduleHeightVersionUpdate], - ); - const handleHeaderHeightChange = useCallback( (path: string, height: number) => { if (!Number.isFinite(height) || height <= 0) { @@ -2465,145 +2472,22 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi }; }, [computeHeaderOffset, flatItems, focusPath, focusRequestId]); - const handleToggleExpanded = useCallback( - (path: string) => { - if (mode.kind === "commit") { - return; - } - const isCurrentlyExpanded = expandedPaths.has(path); - const nextExpanded = !isCurrentlyExpanded; - const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; - const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current; - const shouldAnchor = - isCurrentlyExpanded && - targetOffset !== null && - shouldAnchorHeaderBeforeCollapse({ - headerOffset: targetOffset, - headerHeight, - viewportOffset: diffListScrollOffsetRef.current, - viewportHeight: diffListViewportHeightRef.current, - }); - - if (shouldAnchor && targetOffset !== null) { - diffListRef.current?.scrollToOffset({ - offset: targetOffset, - animated: false, - }); - } - - mode.onExpandedPathsChange( - nextExpanded - ? [...expandedPaths, path] - : Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path), - ); - }, - [computeHeaderOffset, expandedPaths, mode], - ); - - const handleToggleFolder = useCallback( - (dirPath: string) => { - if (mode.kind !== "working_tree") { - return; - } - const isCurrentlyCollapsed = effectiveCollapsedFolders.has(dirPath); - if (!isCurrentlyCollapsed) { - const targetOffset = computeItemOffset( - (item) => item.type === "folder" && item.dirPath === dirPath, - ); - const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current; - if ( - targetOffset !== null && - shouldAnchorHeaderBeforeCollapse({ - headerOffset: targetOffset, - headerHeight: folderHeight, - viewportOffset: diffListScrollOffsetRef.current, - viewportHeight: diffListViewportHeightRef.current, - }) - ) { - diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false }); - } - } - - mode.onCollapsedFoldersChange( - isCurrentlyCollapsed - ? Array.from(effectiveCollapsedFolders).filter((path) => path !== dirPath) - : [...effectiveCollapsedFolders, dirPath], - ); - }, - [computeItemOffset, effectiveCollapsedFolders, mode], - ); - - const handleCollapseFolder = useCallback( - (dirPath: string) => { - if (mode.kind !== "working_tree") { - return; - } - const targetOffset = computeItemOffset( - (item) => item.type === "folder" && item.dirPath === dirPath, - ); - const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current; - if ( - targetOffset !== null && - shouldAnchorHeaderBeforeCollapse({ - headerOffset: targetOffset, - headerHeight: folderHeight, - viewportOffset: diffListScrollOffsetRef.current, - viewportHeight: diffListViewportHeightRef.current, - }) - ) { - diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false }); - } - - const pathPrefix = `${dirPath}/`; - mode.onCollapsedFoldersChange([ - ...new Set([ - ...effectiveCollapsedFolders, - ...allFolderPaths.filter( - (folderPath) => folderPath === dirPath || folderPath.startsWith(pathPrefix), - ), - ]), - ]); - }, - [allFolderPaths, computeItemOffset, effectiveCollapsedFolders, mode], - ); - const renderFlatItem = useCallback( ({ item, index }: { item: DiffFlatItem; index: number }) => { if (item.type === "folder") { - return ( - - ); + return null; } if (item.type === "header") { return ( ({ expandedPathsArray, - collapsedFoldersArray, layout, typographyKey, heightVersion, - viewMode, wrapLines, reviewActions, workspaceFileDragScope, }), [ expandedPathsArray, - collapsedFoldersArray, heightVersion, layout, reviewActions, typographyKey, - viewMode, workspaceFileDragScope, wrapLines, ], @@ -2826,13 +2701,6 @@ function buildDiffModeTriggerStyle(): PressableStyleFn { ]; } -function buildExpandAllButtonStyle(): PressableStyleFn { - return ({ hovered, pressed }) => [ - styles.expandAllButton, - (Boolean(hovered) || pressed) && styles.toggleButtonSelected, - ]; -} - function buildOverflowButtonStyle(): PressableStyleFn { return ({ hovered, pressed }) => [ styles.overflowButton, @@ -2850,20 +2718,12 @@ function buildToggleButtonStyle( ]; } -function useChangesTreeState({ +function useCollapsedDiffFolders({ workspaceId, cwd, - files, - viewMode, - changesTabOpen, - onViewModeChange, }: { workspaceId?: string | null; cwd: string; - files: ParsedDiffFile[]; - viewMode: "flat" | "tree"; - changesTabOpen: boolean; - onViewModeChange: (viewMode: "flat" | "tree") => void; }) { const workspaceStateKey = useMemo( () => @@ -2873,73 +2733,11 @@ function useChangesTreeState({ }), [cwd, workspaceId], ); - const expandedPaths = usePanelStore((state) => - workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined, - ); const collapsedFolders = usePanelStore((state) => workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined, ); - const setExpandedPaths = usePanelStore((state) => state.setDiffExpandedPathsForWorkspace); const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace); - const stableExpandedPaths = expandedPaths ?? EMPTY_PATH_LIST; const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST; - const folderPaths = useMemo( - () => collectDirPaths(compressSingleChildChains(buildDiffTree(files))), - [files], - ); - const folderPathSet = useMemo(() => new Set(folderPaths), [folderPaths]); - const allExpanded = useMemo(() => { - if (files.length === 0 || changesTabOpen) { - return false; - } - const everyFileExpanded = files.every((file) => stableExpandedPaths.includes(file.path)); - const everyFolderExpanded = - viewMode !== "tree" || - stableCollapsedFolders.every((folderPath) => !folderPathSet.has(folderPath)); - return everyFileExpanded && everyFolderExpanded; - }, [changesTabOpen, files, folderPathSet, stableCollapsedFolders, stableExpandedPaths, viewMode]); - const toggleViewMode = useCallback(() => { - const nextViewMode = viewMode === "flat" ? "tree" : "flat"; - if (nextViewMode === "tree" && workspaceStateKey) { - setCollapsedFolders(workspaceStateKey, []); - } - onViewModeChange(nextViewMode); - }, [onViewModeChange, setCollapsedFolders, viewMode, workspaceStateKey]); - const toggleExpandAll = useCallback(() => { - if (!workspaceStateKey) { - return; - } - if (allExpanded) { - setExpandedPaths(workspaceStateKey, []); - if (viewMode === "tree") { - setCollapsedFolders(workspaceStateKey, folderPaths); - } - return; - } - setExpandedPaths( - workspaceStateKey, - files.map((file) => file.path), - ); - if (viewMode === "tree") { - setCollapsedFolders(workspaceStateKey, []); - } - }, [ - allExpanded, - files, - folderPaths, - setCollapsedFolders, - setExpandedPaths, - viewMode, - workspaceStateKey, - ]); - const updateExpandedPaths = useCallback( - (paths: string[]) => { - if (workspaceStateKey) { - setExpandedPaths(workspaceStateKey, paths); - } - }, - [setExpandedPaths, workspaceStateKey], - ); const updateCollapsedFolders = useCallback( (paths: string[]) => { if (workspaceStateKey) { @@ -2950,67 +2748,167 @@ function useChangesTreeState({ ); return { - expandedPaths: changesTabOpen ? EMPTY_PATH_LIST : stableExpandedPaths, collapsedFolders: stableCollapsedFolders, - allExpanded, - toggleViewMode, - toggleExpandAll, - updateExpandedPaths, updateCollapsedFolders, }; } -export function ChangedFilesTree({ +function ChangedFilesTree({ workspaceId, cwd, files, - displayPreferences, - workingTreeMode, + mode, + onSelectFile, }: { workspaceId?: string | null; cwd: string; files: ParsedDiffFile[]; - displayPreferences: SharedDiffViewProps["displayPreferences"]; - workingTreeMode: WorkingTreeDiffMode; + mode: WorkingDiffMode; + onSelectFile: (path: string) => void; }) { - const treeState = useChangesTreeState({ - workspaceId, - cwd, - files, - viewMode: "tree", - changesTabOpen: false, - onViewModeChange: () => {}, - }); - const mode = useMemo( - () => ({ - ...workingTreeMode, - viewMode: "tree" as const, - expandedPaths: EMPTY_PATH_LIST, - collapsedFolders: treeState.collapsedFolders, - onExpandedPathsChange: () => {}, - onCollapsedFoldersChange: treeState.updateCollapsedFolders, - }), - [treeState.collapsedFolders, treeState.updateCollapsedFolders, workingTreeMode], + const [selectedPath, setSelectedPath] = useState(null); + const { collapsedFolders: collapsedFolderPaths, updateCollapsedFolders } = + useCollapsedDiffFolders({ workspaceId, cwd }); + const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); + const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); + const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]); + const items = useMemo( + () => + buildDiffFlatItems({ + files, + viewMode: "tree", + tree: compressedTree, + collapsedFolders, + expandedPaths: new Set(), + }).items, + [collapsedFolders, compressedTree, files], + ); + const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); + const handleSelectFile = useCallback( + (path: string) => { + setSelectedPath(path); + onSelectFile(path); + }, + [onSelectFile], + ); + const handleToggleFolder = useCallback( + (dirPath: string) => { + const next = collapsedFolders.has(dirPath) + ? Array.from(collapsedFolders).filter((path) => path !== dirPath) + : [...collapsedFolders, dirPath]; + updateCollapsedFolders(next); + }, + [collapsedFolders, updateCollapsedFolders], + ); + const handleCollapseFolder = useCallback( + (dirPath: string) => { + const prefix = `${dirPath}/`; + updateCollapsedFolders([ + ...new Set([ + ...collapsedFolders, + ...allFolderPaths.filter( + (folderPath) => folderPath === dirPath || folderPath.startsWith(prefix), + ), + ]), + ]); + }, + [allFolderPaths, collapsedFolders, updateCollapsedFolders], + ); + const renderItem = useCallback( + ({ item }: { item: DiffFlatItem }) => { + if (item.type === "folder") { + return ( + + ); + } + if (item.type === "body") { + return null; + } + return ( + + ); + }, + [ + handleCollapseFolder, + handleSelectFile, + handleSelectPath, + handleToggleFolder, + mode, + selectedPath, + ], + ); + const keyExtractor = useCallback( + (item: DiffFlatItem) => + item.type === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`, + [], + ); + + return ( + ); - return ; } -function GitDiffTreeRail({ +function ChangesTreeRail({ shown, children, workspaceId, cwd, files, - displayPreferences, - workingTreeMode, + mode, + onSelectFile, }: { shown: boolean; children: ReactElement; workspaceId?: string | null; cwd: string; files: ParsedDiffFile[]; - displayPreferences: SharedDiffViewProps["displayPreferences"]; - workingTreeMode: WorkingTreeDiffMode; + mode: WorkingDiffMode; + onSelectFile: (path: string) => void; }) { if (!shown) return children; return ( @@ -3020,21 +2918,13 @@ function GitDiffTreeRail({ workspaceId={workspaceId} cwd={cwd} files={files} - displayPreferences={displayPreferences} - workingTreeMode={workingTreeMode} + mode={mode} + onSelectFile={onSelectFile} /> ); } -function resolveWorkingTreeMode(isMobile: boolean, mobileMode: T, desktopMode: T): T { - return isMobile ? mobileMode : desktopMode; -} - -function shouldShowTreeRail(viewMode: "flat" | "tree", isMobile: boolean): boolean { - return viewMode === "tree" && !isMobile; -} - function useDiffTabNavigation({ serverId, workspaceId, @@ -3101,15 +2991,17 @@ function useDiffTabNavigation({ }; } -export function GitDiffPane({ +export function ChangesSurface({ serverId, workspaceId, cwd, enabled, host, + focusPath, + focusRequestId, onOpenFile, onAddToChat, -}: GitDiffPaneProps) { +}: ChangesSurfaceProps) { const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); @@ -3117,7 +3009,7 @@ export function GitDiffPane({ const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = useChangesPreferences(); const wrapLines = changesPreferences.wrapLines; - const viewMode = changesPreferences.viewMode; + const desktopTreeVisible = changesPreferences.desktopTreeVisible; const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout); const handleToggleWrapLines = useCallback(() => { @@ -3136,17 +3028,15 @@ export function GitDiffPane({ const codeFontSize = appSettings.codeFontSize; const layoutToggleStyle = useMemo( - () => buildToggleButtonStyle(false, styles.expandAllButton), + () => buildToggleButtonStyle(false, styles.toolbarIconButton), [], ); - const viewModeToggleStyle = useMemo( - () => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton), - [viewMode], + const treeToggleStyle = useMemo( + () => buildToggleButtonStyle(desktopTreeVisible, styles.toolbarIconButton), + [desktopTreeVisible], ); - const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); - const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const toast = useToast(); @@ -3244,20 +3134,9 @@ export function GitDiffPane({ }), [forgeSetupAction, forge, status?.remoteUrl, t], ); - const handleViewModeChange = useCallback( - (nextViewMode: "flat" | "tree") => { - void updateChangesPreferences({ viewMode: nextViewMode }); - }, - [updateChangesPreferences], - ); - const changesTree = useChangesTreeState({ - workspaceId, - cwd, - files, - viewMode, - changesTabOpen, - onViewModeChange: handleViewModeChange, - }); + const handleToggleDesktopTree = useCallback(() => { + void updateChangesPreferences({ desktopTreeVisible: !desktopTreeVisible }); + }, [desktopTreeVisible, updateChangesPreferences]); const sharedDisplayPreferences = useMemo( () => ({ layout: effectiveLayout, @@ -3321,14 +3200,32 @@ export function GitDiffPane({ [client, cwd, t, toast], ); const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode }); - const workingTreeMode = useMemo( + const [localFocusRequest, setLocalFocusRequest] = useState<{ + path: string; + revision: number; + } | null>(null); + const externalFocusRequest = useMemo( + () => (focusPath ? { path: focusPath, revision: focusRequestId ?? 0 } : null), + [focusPath, focusRequestId], + ); + const documentFocusRequest = + localFocusRequest && + (!externalFocusRequest || localFocusRequest.revision >= externalFocusRequest.revision) + ? localFocusRequest + : externalFocusRequest; + const handleSelectTreeFile = useCallback((path: string) => { + setLocalFocusRequest((current) => ({ + path, + revision: Math.max(Date.now(), (current?.revision ?? 0) + 1), + })); + }, []); + const workingMode = useMemo( () => ({ - kind: "working_tree" as const, - viewMode, - expandedPaths: changesTree.expandedPaths, - collapsedFolders: changesTree.collapsedFolders, + kind: "working" as const, reviewActions, onFilePress: onChangesFilePress, + focusPath: documentFocusRequest?.path, + focusRequestId: documentFocusRequest?.revision, workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined, onOpenFile, onAddToChat, @@ -3339,15 +3236,12 @@ export function GitDiffPane({ onDownload: handleDownloadPath, onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined, onRevert: onRevertPath, - onExpandedPathsChange: changesTree.updateExpandedPaths, - onCollapsedFoldersChange: changesTree.updateCollapsedFolders, }), [ - viewMode, - changesTree.expandedPaths, - changesTree.collapsedFolders, reviewActions, onChangesFilePress, + documentFocusRequest?.path, + documentFocusRequest?.revision, serverId, workspaceId, onOpenFile, @@ -3360,8 +3254,6 @@ export function GitDiffPane({ fileManagerTarget, fsEntryDuplicateEnabled, onRevertPath, - changesTree.updateExpandedPaths, - changesTree.updateCollapsedFolders, ], ); @@ -3392,15 +3284,6 @@ export function GitDiffPane({ }, ); - const flatWorkingTreeMode = useMemo( - () => ({ ...workingTreeMode, viewMode: "flat" as const }), - [workingTreeMode], - ); - const primaryWorkingTreeMode = resolveWorkingTreeMode( - isMobile, - workingTreeMode, - flatWorkingTreeMode, - ); const diffContent: ReactElement = ( - ); const bodyContent = ( - 0} workspaceId={workspaceId} cwd={cwd} files={files} - displayPreferences={sharedDisplayPreferences} - workingTreeMode={workingTreeMode} + mode={workingMode} + onSelectFile={handleSelectTreeFile} > {diffContent} - + ); return ( @@ -3441,75 +3324,39 @@ export function GitDiffPane({ }} style={styles.container} > - {isGit && (currentBranchName || isMobile) ? ( - - - {isMobile ? : null} - - ) : null} - {isGit ? ( - - - - - - {canUseSplitLayout && !changesTabOpen ? ( - - ) : null} - {files.length > 0 ? ( - - ) : null} - {files.length > 0 && !changesTabOpen ? ( - - ) : null} - - - - + ) : null} {forgeSetupMessage ? ( @@ -3532,35 +3379,48 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - header: { + changesToolbar: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + flexShrink: 0, + }, + changesToolbarMainRow: { + minHeight: WORKSPACE_SECONDARY_HEADER_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[3], }, - diffStatusContainer: { - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - diffStatusInner: { - flex: 1, + changesToolbarIdentity: { + minWidth: 0, + flexShrink: 1, flexDirection: "row", alignItems: "center", - justifyContent: "space-between", - paddingRight: theme.spacing[3], + gap: theme.spacing[1], + }, + changesToolbarControls: { + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing[1], + flexShrink: 0, + }, + mobileGitActionsRow: { + minHeight: WORKSPACE_SECONDARY_HEADER_HEIGHT, + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + paddingHorizontal: theme.spacing[3], + borderTopWidth: 1, + borderTopColor: theme.colors.border, }, diffModeTrigger: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], - // Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding) - marginLeft: theme.spacing[3] - theme.spacing[1], paddingHorizontal: theme.spacing[1], height: { xs: 28, @@ -3587,16 +3447,10 @@ const styles = StyleSheet.create((theme) => ({ diffStatusIconHidden: { opacity: 0, }, - diffStatusButtons: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexWrap: "wrap", - }, toggleButtonSelected: { backgroundColor: theme.colors.surface2, }, - expandAllButton: { + toolbarIconButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index f5241b19a..efc9c9e34 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -1,19 +1,18 @@ import { GitActionsSplitButton } from "@/git/actions-split-button"; -import { useGitActions } from "@/git/use-actions"; import { GIT_ACTION_ICONS } from "@/git/action-icons"; +import { useGitActions } from "@/git/use-actions"; interface WorkspaceActionsProps { serverId: string; cwd: string; - hideLabels?: boolean; } -export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { +export function WorkspaceActions({ serverId, cwd }: WorkspaceActionsProps) { const { gitActions } = useGitActions({ serverId, cwd, icons: GIT_ACTION_ICONS, }); - return ; + return ; } diff --git a/packages/app/src/hooks/use-changes-preferences/storage.test.ts b/packages/app/src/hooks/use-changes-preferences/storage.test.ts index e4e08aa94..4f37f4a30 100644 --- a/packages/app/src/hooks/use-changes-preferences/storage.test.ts +++ b/packages/app/src/hooks/use-changes-preferences/storage.test.ts @@ -28,7 +28,7 @@ describe("loadChangesPreferencesFromStorage", () => { expect(result).toEqual({ layout: "unified", - viewMode: "flat", + desktopTreeVisible: false, wrapLines: true, hideWhitespace: false, commitsCollapsed: true, @@ -51,7 +51,7 @@ describe("loadChangesPreferencesFromStorage", () => { expect(result).toEqual({ layout: "split", - viewMode: "tree", + desktopTreeVisible: true, hideWhitespace: true, wrapLines: false, commitsCollapsed: true, @@ -95,14 +95,14 @@ describe("saveChangesPreferences", () => { await saveChangesPreferences({ queryClient, - updates: { layout: "split", viewMode: "tree", hideWhitespace: true }, + updates: { layout: "split", desktopTreeVisible: true, hideWhitespace: true }, storage, }); const expected = { ...DEFAULT_CHANGES_PREFERENCES, layout: "split", - viewMode: "tree", + desktopTreeVisible: true, hideWhitespace: true, }; expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected); diff --git a/packages/app/src/hooks/use-changes-preferences/storage.ts b/packages/app/src/hooks/use-changes-preferences/storage.ts index 05a0038dc..4d49bed65 100644 --- a/packages/app/src/hooks/use-changes-preferences/storage.ts +++ b/packages/app/src/hooks/use-changes-preferences/storage.ts @@ -8,6 +8,8 @@ export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; const changesPreferencesSchema = z.strictObject({ 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(), wrapLines: z.boolean().optional(), hideWhitespace: z.boolean().optional(), @@ -16,7 +18,7 @@ const changesPreferencesSchema = z.strictObject({ export interface ChangesPreferences { layout: "unified" | "split"; - viewMode: "flat" | "tree"; + desktopTreeVisible: boolean; wrapLines: boolean; hideWhitespace: boolean; commitsCollapsed: boolean; @@ -24,7 +26,7 @@ export interface ChangesPreferences { export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { layout: "unified", - viewMode: "flat", + desktopTreeVisible: false, wrapLines: false, hideWhitespace: false, commitsCollapsed: true, @@ -54,7 +56,12 @@ export async function loadChangesPreferencesFromStorage( changesPreferencesSchema, ); 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); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1993cd2fa..df08f0930 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -600,8 +600,6 @@ export const ar: TranslationResources = { pullRequest: "طلب السحب", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", - pinTarget: "تثبيت", - unpinTarget: "إلغاء التثبيت", }, explorer: { open: "افتح المستكشف", @@ -855,14 +853,12 @@ export const ar: TranslationResources = { switchToUnified: "التبديل إلى الفرق الموحد", switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب", showTreeView: "إظهار شجرة المجلدات", - showFlatView: "إظهار قائمة الملفات المسطحة", + hideTreeView: "إخفاء شجرة المجلدات", options: "خيارات الفرق", hideWhitespace: "إخفاء المسافة البيضاء", showWhitespace: "إظهار المسافة البيضاء", scrollLongLines: "قم بتمرير الخطوط الطويلة", wrapLongLines: "لف الخطوط الطويلة", - collapseAll: "طي كافة الملفات", - expandAll: "قم بتوسيع كافة الملفات", collapseAllFolders: "طي كافة المجلدات", expandAllFolders: "توسيع كافة المجلدات", refreshing: "منعش", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9b14e32d3..e72e0a00c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -598,8 +598,6 @@ export const en = { pullRequest: "Pull request", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", - pinTarget: "Pin", - unpinTarget: "Unpin", }, explorer: { open: "Open explorer", @@ -864,14 +862,12 @@ export const en = { switchToUnified: "Switch to unified diff", switchToSplit: "Switch to side-by-side diff", showTreeView: "Show folder tree", - showFlatView: "Show flat file list", + hideTreeView: "Hide folder tree", options: "Diff options", hideWhitespace: "Hide whitespace", showWhitespace: "Show whitespace", scrollLongLines: "Scroll long lines", wrapLongLines: "Wrap long lines", - collapseAll: "Collapse all files", - expandAll: "Expand all files", collapseAllFolders: "Collapse all folders", expandAllFolders: "Expand all folders", refreshing: "Refreshing", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 74757e827..fe6ca3899 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -605,8 +605,6 @@ export const es: TranslationResources = { pullRequest: "Solicitud de extracción", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", - pinTarget: "Fijar", - unpinTarget: "Desfijar", }, explorer: { open: "Explorador abierto", @@ -886,14 +884,12 @@ export const es: TranslationResources = { switchToUnified: "Cambiar a diferencia unificada", switchToSplit: "Cambiar a diferencia lado a lado", showTreeView: "Mostrar árbol de carpetas", - showFlatView: "Mostrar lista plana de archivos", + hideTreeView: "Ocultar árbol de carpetas", options: "Opciones de diferencia", hideWhitespace: "Ocultar espacios en blanco", showWhitespace: "Mostrar espacios en blanco", scrollLongLines: "Desplazarse por largas filas", wrapLongLines: "Envolver largas filas", - collapseAll: "Contraer todos los archivos", - expandAll: "Expandir todos los archivos", collapseAllFolders: "Contraer todas las carpetas", expandAllFolders: "Expandir todas las carpetas", refreshing: "Refrescante", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 170633d31..154f71d4a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -605,8 +605,6 @@ export const fr: TranslationResources = { pullRequest: "Demande de fusion", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", - pinTarget: "Épingler", - unpinTarget: "Détacher", }, explorer: { open: "Ouvrir l'explorateur", @@ -885,14 +883,12 @@ export const fr: TranslationResources = { switchToUnified: "Passer au diff unifié", switchToSplit: "Passer au diff côte à côte", showTreeView: "Afficher l'arborescence des dossiers", - showFlatView: "Afficher la liste de fichiers à plat", + hideTreeView: "Masquer l'arborescence des dossiers", options: "Options du diff", hideWhitespace: "Masquer les espaces", showWhitespace: "Afficher les espaces", scrollLongLines: "Faire défiler 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", expandAllFolders: "Développer tous les dossiers", refreshing: "Rafraîchissant", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index c6e72af87..91573a1b1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -605,8 +605,6 @@ export const ja: TranslationResources = { pullRequest: "プルリクエスト", terminalProfilesMenu: "ターミナルプロファイル", editTerminalProfiles: "プロファイルを編集…", - pinTarget: "ピン留め", - unpinTarget: "ピン留めを解除", }, explorer: { open: "エクスプローラーを開く", @@ -866,14 +864,12 @@ export const ja: TranslationResources = { switchToUnified: "ユニファイド差分に切り替え", switchToSplit: "左右比較差分に切り替え", showTreeView: "フォルダツリーを表示", - showFlatView: "フラットなファイル一覧を表示", + hideTreeView: "フォルダツリーを非表示", options: "差分オプション", hideWhitespace: "空白を非表示", showWhitespace: "空白を表示", scrollLongLines: "長い行をスクロール", wrapLongLines: "長い行を折り返す", - collapseAll: "すべて折りたたむ", - expandAll: "すべて展開", collapseAllFolders: "すべてのフォルダを折りたたむ", expandAllFolders: "すべてのフォルダを展開", refreshing: "更新中", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index dcf1cc88a..6b9d19c73 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -602,8 +602,6 @@ export const ko: TranslationResources = { pullRequest: "풀 리퀘스트", terminalProfilesMenu: "터미널 프로필", editTerminalProfiles: "프로필 편집…", - pinTarget: "고정", - unpinTarget: "고정 해제", }, explorer: { open: "탐색기 열기", @@ -862,14 +860,12 @@ export const ko: TranslationResources = { switchToUnified: "통합 diff로 전환", switchToSplit: "나란히 보기 diff로 전환", showTreeView: "폴더 트리 표시", - showFlatView: "플랫 파일 목록 표시", + hideTreeView: "폴더 트리 숨기기", options: "Diff 옵션", hideWhitespace: "공백 숨기기", showWhitespace: "공백 표시", scrollLongLines: "긴 줄 스크롤", wrapLongLines: "긴 줄 줄바꿈", - collapseAll: "모든 파일 접기", - expandAll: "모든 파일 펼치기", collapseAllFolders: "모든 폴더 축소", expandAllFolders: "모든 폴더 확장", refreshing: "새로고침 중", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index df5243ef8..356051c1c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -604,8 +604,6 @@ export const ptBR: TranslationResources = { pullRequest: "Pull request", terminalProfilesMenu: "Perfis de terminal", editTerminalProfiles: "Editar perfis…", - pinTarget: "Fixar", - unpinTarget: "Desafixar", }, explorer: { open: "Abrir explorador", @@ -877,14 +875,12 @@ export const ptBR: TranslationResources = { switchToUnified: "Alternar para diff unificado", switchToSplit: "Alternar para diff lado a lado", showTreeView: "Mostrar árvore de pastas", - showFlatView: "Mostrar lista plana de arquivos", + hideTreeView: "Ocultar árvore de pastas", options: "Opções do diff", hideWhitespace: "Ocultar espaços em branco", showWhitespace: "Mostrar espaços em branco", scrollLongLines: "Rolar linhas longas", wrapLongLines: "Quebrar linhas longas", - collapseAll: "Recolher todos os arquivos", - expandAll: "Expandir todos os arquivos", collapseAllFolders: "Recolher todas as pastas", expandAllFolders: "Expandir todas as pastas", refreshing: "Atualizando", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 437692783..b333fd1b2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -604,8 +604,6 @@ export const ru: TranslationResources = { pullRequest: "Запрос на слияние", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", - pinTarget: "Закрепить", - unpinTarget: "Открепить", }, explorer: { open: "Открыть проводник", @@ -877,14 +875,12 @@ export const ru: TranslationResources = { switchToUnified: "Переключить на объединенный diff", switchToSplit: "Переключить на diff рядом", showTreeView: "Показать дерево папок", - showFlatView: "Показать плоский список файлов", + hideTreeView: "Скрыть дерево папок", options: "Параметры diff", hideWhitespace: "Скрыть пробелы", showWhitespace: "Показать пробелы", scrollLongLines: "Прокручивать длинные строки", wrapLongLines: "Перенос длинных строк", - collapseAll: "Свернуть все файлы", - expandAll: "Развернуть все файлы", collapseAllFolders: "Свернуть все папки", expandAllFolders: "Развернуть все папки", refreshing: "Освежающий", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 5acaf0838..02dd80d9c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -600,8 +600,6 @@ export const zhCN: TranslationResources = { pullRequest: "拉取请求", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", - pinTarget: "固定", - unpinTarget: "取消固定", }, explorer: { open: "打开 explorer", @@ -847,14 +845,12 @@ export const zhCN: TranslationResources = { switchToUnified: "切换到统一差异视图", switchToSplit: "切换到并排差异视图", showTreeView: "显示文件夹树", - showFlatView: "显示平铺文件列表", + hideTreeView: "隐藏文件夹树", options: "Diff 选项", hideWhitespace: "隐藏空白差异", showWhitespace: "显示空白差异", scrollLongLines: "滚动长行", wrapLongLines: "自动换行长行", - collapseAll: "折叠所有文件", - expandAll: "展开所有文件", collapseAllFolders: "折叠所有文件夹", expandAllFolders: "展开所有文件夹", refreshing: "正在刷新", diff --git a/packages/app/src/new-workspace-launch/launch-control.tsx b/packages/app/src/new-workspace-launch/launch-control.tsx index 12c546edd..ea7d5cc4e 100644 --- a/packages/app/src/new-workspace-launch/launch-control.tsx +++ b/packages/app/src/new-workspace-launch/launch-control.tsx @@ -12,7 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } 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 { ICON_SIZE } from "@/styles/theme"; import { @@ -54,7 +54,7 @@ function LaunchProfileMenuItem({ }): ReactElement { const handleSelect = useCallback(() => onSelect(profile.id), [onSelect, profile.id]); const leading = useMemo( - () => , + () => , [profile], ); // What the profile runs before any prompt is typed, so the row says what it @@ -102,7 +102,7 @@ function TriggerIcon({ return ; } if (profile) { - return ; + return ; } return ; } diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index 745d0843e..176c0fed9 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -7,7 +7,7 @@ import invariant from "tiny-invariant"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import { 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 { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; @@ -44,10 +44,6 @@ function useDiffPanelPreferences() { const toggleHideWhitespace = useCallback(() => { void updatePreferences({ hideWhitespace: !preferences.hideWhitespace }); }, [preferences.hideWhitespace, updatePreferences]); - const toggleViewMode = useCallback(() => { - void updatePreferences({ viewMode: preferences.viewMode === "flat" ? "tree" : "flat" }); - }, [preferences.viewMode, updatePreferences]); - return { preferences, isCompact, @@ -56,7 +52,6 @@ function useDiffPanelPreferences() { toggleLayout, toggleWrapLines, toggleHideWhitespace, - toggleViewMode, }; } @@ -95,12 +90,14 @@ function WorkingDiffPanel() { return ( - @@ -142,7 +139,7 @@ function CommitDiffPanel() { body = ; } else { body = ( - computeWorkspaceTabLayout({ viewportWidth: resolvedViewportWidth, - tabLabelLengths: input.tabLabelLengths, + tabLabelWidths: input.tabLabelWidths, metrics: input.metrics, }), - [input.metrics, input.tabLabelLengths, resolvedViewportWidth], + [input.metrics, input.tabLabelWidths, resolvedViewportWidth], ); return { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 23bd94123..e9e792b35 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -20,11 +20,9 @@ import { CopyX, ArrowLeftToLine, ArrowRightToLine, - Columns2, Copy, Pencil, RotateCw, - Rows2, Globe, FileDiff, FolderTree, @@ -87,11 +85,7 @@ import { } from "@getpaseo/protocol/terminal-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; -import { ProfileIcon, usePinnedLaunchers } from "@/workspace-pins/launch"; -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 { TerminalProfileIcon } from "@/components/terminal-profile-icon"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; @@ -108,9 +102,13 @@ const TAB_CHIP_HORIZONTAL_PADDING = 8; const TAB_CHIP_GAP = 4; const TAB_ROW_PADDING_HORIZONTAL = 4; const TAB_ICON_WIDTH = 14; +const TAB_CONTENT_GAP = 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_LABEL_FALLBACK_CHARACTER_WIDTH = 7; +const TAB_LABEL_LAYOUT_ALLOWANCE = 4; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedX = withUnistyles(X); @@ -123,14 +121,13 @@ const ThemedPencil = withUnistyles(Pencil); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); -const ThemedColumns2 = withUnistyles(Columns2); -const ThemedRows2 = withUnistyles(Rows2); const ThemedPlus = withUnistyles(Plus); const ThemedFileDiff = withUnistyles(FileDiff); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); const AGENT_ICON = ; const TERMINAL_ICON = ; @@ -139,17 +136,10 @@ const CHANGES_ICON = ; const FILES_ICON = ; const PULL_REQUEST_ICON = ; -const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" }; -const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" }; -const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" }; const CHANGES_TARGET = { kind: "working_diff" } as const; const FILES_TARGET = { kind: "files" } as const; const PULL_REQUEST_TARGET = { kind: "pull_request" } as const; -function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { - return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; -} - function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; } @@ -159,39 +149,48 @@ function updateMeasuredWidth(setWidth: Dispatch>, event: 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 ( - - - + + {label} + ); } -interface PinnableProfileMenuItemProps { +interface TerminalProfileMenuItemProps { profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; disabled?: boolean; - onLaunch: (target: PinnedTabTarget) => void; + onLaunch: (profile: TerminalProfileInput) => void; } -function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfileMenuItemProps) { - const target = useMemo( - () => ({ kind: "profile", profileId: profile.id }), - [profile.id], - ); +function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) { const leading = useMemo( - () => , + () => , [profile], ); - const handleSelect = useCallback(() => onLaunch(target), [onLaunch, target]); + const handleSelect = useCallback(() => onLaunch(profile), [onLaunch, profile]); return ( - + + {profile.name} + ); } @@ -206,58 +205,6 @@ interface TabTargetLauncherOptions { 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( - () => ({ - 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 { shortcutKeys: ShortcutKey[][] | null; onEditProfiles: () => void; @@ -286,16 +233,15 @@ function WorkspaceNewTabButton({ onLayout, }: WorkspaceNewTabButtonProps) { const { t } = useTranslation(); - const { profiles, onLaunch } = useTabTargetLauncher({ - normalizedServerId, - onCreateAgentTab, - onCreateTerminal, - onCreateBrowser, - onCreateTerminalWithProfile, - onOpenChanges, - onOpenFiles, - onOpenPullRequest, - }); + const { config } = useDaemonConfig(normalizedServerId); + const profiles = useMemo( + () => resolveTerminalProfiles(config?.terminalProfiles), + [config?.terminalProfiles], + ); + const shortcut = useMemo( + () => (shortcutKeys ? : undefined), + [shortcutKeys], + ); const tooltipText = t("workspace.tabs.actions.newTab"); return ( @@ -309,7 +255,7 @@ function WorkspaceNewTabButton({ accessibilityLabel={tooltipText} style={inlineAddActionButtonStyle} > - + @@ -317,64 +263,64 @@ function WorkspaceNewTabButton({ - - + {t("workspace.tabs.actions.newAgent")} + + + > + {t("workspace.tabs.actions.newTerminal")} + {showCreateBrowserTab ? ( - + > + {t("workspace.tabs.actions.newBrowser")} + ) : null} {isGit ? ( - + > + {t("workspace.tabs.actions.changes")} + ) : null} - + > + {t("workspace.tabs.actions.files")} + {showPullRequest ? ( - + > + {t("workspace.tabs.actions.pullRequest")} + ) : null} {t("workspace.tabs.actions.terminalProfilesMenu")} {profiles.map((profile) => ( - ))} @@ -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 ; -} - function TabContextMenuItem({ entry, }: { @@ -456,38 +388,8 @@ export interface WorkspaceDesktopTabRowItem { isClosingTab: boolean; } -interface SplitActionButtonProps { - onPress: () => void; - label: string; - shortcutKeys: ShortcutKey[][] | null; - icon: "split-right" | "split-down"; -} - -function SplitActionButton({ onPress, label, shortcutKeys, icon }: SplitActionButtonProps) { - return ( - - - {icon === "split-right" ? ( - - ) : ( - - )} - - - - {label} - {shortcutKeys ? ( - - ) : null} - - - - ); +interface ResolvedWorkspaceDesktopTabRowItem extends WorkspaceDesktopTabRowItem { + presentation: WorkspaceTabPresentation; } interface WorkspaceDesktopTabsRowProps { @@ -515,16 +417,61 @@ interface WorkspaceDesktopTabsRowProps { disableCreateTerminal?: boolean; isWaitingOnTerminalReadiness?: boolean; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; - onSplitRight: () => void; - onSplitDown: () => void; externalDndContext?: boolean; activeDragTabId?: string | null; tabDropPreviewIndex?: number | null; - showPaneSplitActions?: boolean; focusModeEnabled: boolean; onExitFocusMode: () => void; } +interface ResolvedWorkspaceDesktopTabsRowProps extends Omit { + 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 ( + + {(presentation) => { + const nextResolvedTabs = [...resolvedTabs, { ...item, presentation }]; + return ( + + {children} + + ); + }} + + ); +} + function getFallbackTabLabel( tab: WorkspaceTabDescriptor, labels: { @@ -813,7 +760,7 @@ function TabChip({ {showCloseButton ? ( + {(tabs) => } + + ); +} + +function ResolvedWorkspaceDesktopTabsRow({ paneId, isFocused = false, tabs, @@ -890,25 +851,22 @@ export function WorkspaceDesktopTabsRow({ disableCreateTerminal = false, isWaitingOnTerminalReadiness = false, onReorderTabs, - onSplitRight, - onSplitDown, externalDndContext = false, activeDragTabId = null, tabDropPreviewIndex = null, - showPaneSplitActions = true, focusModeEnabled, onExitFocusMode, -}: WorkspaceDesktopTabsRowProps) { +}: ResolvedWorkspaceDesktopTabsRowProps) { const { t } = useTranslation(); const router = useRouter(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const focusModeKeys = useShortcutKeys("toggle-focus"); - const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); - const splitDownKeys = useShortcutKeys("workspace-pane-split-down"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); - const [tabsActionsWidth, setTabsActionsWidth] = useState(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); + const [measuredTabLabels, setMeasuredTabLabels] = useState< + Record + >({}); const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; const checkoutStatus = useCheckoutStatusQuery({ serverId: normalizedServerId, @@ -927,10 +885,6 @@ export function WorkspaceDesktopTabsRow({ updateMeasuredWidth(setTabsContainerWidth, event); }, []); - const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => { - updateMeasuredWidth(setTabsActionsWidth, event); - }, []); - const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setInlineAddButtonWidth, event); }, []); @@ -944,19 +898,19 @@ export function WorkspaceDesktopTabsRow({ rowHorizontalInset: 0, actionsReservedWidth: Math.max( 0, - tabsActionsWidth + - (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) + + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) + (focusModeEnabled ? exitFocusModeWidth : 0), ), rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, tabGap: TAB_CHIP_GAP, + minTabWidth: TAB_MIN_WIDTH, maxTabWidth: TAB_MAX_WIDTH, tabIconWidth: TAB_ICON_WIDTH, + tabContentGap: TAB_CONTENT_GAP, tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, - estimatedCharWidth: 7, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, }), - [exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth], + [exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth], ); const fallbackTabLabels = useMemo( @@ -989,23 +943,49 @@ export function WorkspaceDesktopTabsRow({ }), [t], ); - const tabLabelLengths = useMemo( + const tabLabels = useMemo( () => tabs.map((tab) => { - const label = getFallbackTabLabel(tab.tab, fallbackTabLabels); - return label.length; + const label = + tab.presentation.titleState === "loading" + ? getFallbackTabLabel(tab.tab, fallbackTabLabels) + : tab.presentation.label; + return { key: tab.tab.key, label }; }), [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({ - tabLabelLengths, + tabLabelWidths, viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); const handleDragEnd = useCallback( - (nextTabs: WorkspaceDesktopTabRowItem[]) => { + (nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => { onReorderTabs(nextTabs.map((tab) => tab.tab)); }, [onReorderTabs], @@ -1013,7 +993,7 @@ export function WorkspaceDesktopTabsRow({ const getTabDragData = useMemo(() => { if (!paneId) return undefined; - return (tab: WorkspaceDesktopTabRowItem) => ({ + return (tab: ResolvedWorkspaceDesktopTabRowItem) => ({ kind: "workspace-tab" as const, paneId, tabId: tab.tab.tabId, @@ -1070,7 +1050,7 @@ export function WorkspaceDesktopTabsRow({ index, dragHandleProps, isActive, - }: DraggableRenderItemInfo) => { + }: DraggableRenderItemInfo) => { const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; @@ -1089,8 +1069,6 @@ export function WorkspaceDesktopTabsRow({ isDragging={isActive} index={index} tabCount={tabs.length} - normalizedServerId={normalizedServerId} - normalizedWorkspaceId={normalizedWorkspaceId} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onCopyTerminalId={onCopyTerminalId} @@ -1118,8 +1096,6 @@ export function WorkspaceDesktopTabsRow({ isFocused, layout.closeButtonPolicy, layout.items, - normalizedServerId, - normalizedWorkspaceId, onCloseOtherTabs, onCloseTab, onCloseTabsToLeft, @@ -1154,6 +1130,21 @@ export function WorkspaceDesktopTabsRow({ testID="workspace-tabs-row" onLayout={handleTabsContainerLayout} > + + {tabLabels.map(({ key, label }) => ( + + ))} + {focusModeEnabled ? ( @@ -1198,26 +1189,29 @@ export function WorkspaceDesktopTabsRow({ getItemData={getTabDragData} renderItem={renderTab} /> + {!layout.requiresHorizontalScrollFallback ? ( + + ) : null} - - - - {showPaneSplitActions ? ( - <> - - - - ) : null} - + ) : null} ); @@ -1257,8 +1239,6 @@ function ResolvedDesktopTabChip({ isDragging, index, tabCount, - normalizedServerId, - normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, onCopyTerminalId, @@ -1279,13 +1259,11 @@ function ResolvedDesktopTabChip({ showDropIndicatorBefore, showDropIndicatorAfter, }: { - item: WorkspaceDesktopTabRowItem; + item: ResolvedWorkspaceDesktopTabRowItem; isFocused: boolean; isDragging: boolean; index: number; tabCount: number; - normalizedServerId: string; - normalizedWorkspaceId: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onCopyTerminalId: (terminalId: string) => Promise | void; @@ -1307,6 +1285,7 @@ function ResolvedDesktopTabChip({ showDropIndicatorAfter: boolean; }) { const { t } = useTranslation(); + const presentation = item.presentation; const resolvedTab = useMemo( () => buildWorkspaceDesktopTabActions({ @@ -1343,48 +1322,38 @@ function ResolvedDesktopTabChip({ ], ); - return ( - - {(presentation) => { - const tooltipLabel = - presentation.titleState === "loading" - ? t("workspace.tabs.loadingAgentTitle") - : presentation.tooltip; + const tooltipLabel = + presentation.titleState === "loading" + ? t("workspace.tabs.loadingAgentTitle") + : presentation.tooltip; - return ( - - {showDropIndicatorBefore ? ( - - ) : null} - - {showDropIndicatorAfter ? ( - - ) : null} - - ); - }} - + return ( + + {showDropIndicatorBefore ? ( + + ) : null} + + {showDropIndicatorAfter ? ( + + ) : null} + ); } @@ -1413,11 +1382,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, }, - tabsActions: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: theme.spacing[2], - }, exitFocusModeSlot: { alignSelf: "stretch", flexDirection: "row", @@ -1498,6 +1462,17 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, userSelect: "none", }, + tabLabelMeasurements: { + position: "absolute", + top: 0, + left: 0, + opacity: 0, + alignItems: "flex-start", + pointerEvents: "none", + }, + tabLabelMeasurement: { + flexShrink: 0, + }, tabLabelSkeleton: { width: 96, maxWidth: "100%", @@ -1522,6 +1497,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", zIndex: 2, + overflow: "hidden", }, tabTrailingOverlayShown: { opacity: 1, @@ -1553,13 +1529,6 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.foregroundMuted, }, - newTabActionButton: { - width: 22, - height: 22, - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", - }, inlineAddActionButton: { width: 28, height: 28, @@ -1567,9 +1536,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - newTabActionButtonDisabled: { - opacity: 0.5, - }, newTabActionButtonHovered: { backgroundColor: theme.colors.surface2, }, @@ -1596,8 +1562,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, - terminalProfileIconWrapper: { - width: 14, - height: 14, - }, })); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index deecd7874..17e50ef0d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -178,7 +178,6 @@ import { findAdjacentPane } from "@/utils/split-navigation"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { buildHostRootRoute, buildSettingsHostRoute, @@ -530,8 +529,6 @@ function WorkspaceDocumentTitleEffect({ return null; } -function noop() {} - function switcherTriggerStyle({ pressed }: { pressed?: boolean }) { return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed]; } @@ -2112,8 +2109,6 @@ function WorkspaceScreenContent({ ); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const { closingTabIds, closeTab } = useCloseTabs(); - const { onLayout: onHeaderLayout, isBelow: showCompactButtonLabels } = - useContainerWidthBelow(700); const closeWorkspaceTabWithCleanup = useCallback( function closeWorkspaceTabWithCleanup(input: { tabId: string; @@ -3616,11 +3611,7 @@ function WorkspaceScreenContent({ ) : null} {!isMobile && workspaceDirectory ? ( <> - + {isGitCheckout ? ( @@ -3727,7 +3718,6 @@ function WorkspaceScreenContent({ handleScriptTerminalStarted, handleViewScriptTerminal, handleOpenUrlInBrowserTab, - showCompactButtonLabels, isGitCheckout, handleToggleExplorer, isExplorerOpen, @@ -3845,7 +3835,6 @@ function WorkspaceScreenContent({ {showScreenHeader && ( @@ -3938,9 +3927,6 @@ function WorkspaceScreenContent({ disableCreateTerminal={createTerminalMutation.isPending} isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null} onReorderTabs={handleReorderTabsInFocusedPane} - onSplitRight={noop} - onSplitDown={noop} - showPaneSplitActions={false} focusModeEnabled={desktopFocusModeEnabled} onExitFocusMode={toggleFocusMode} /> diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index c31b4c705..b55d38bcc 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -6,18 +6,19 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, - maxTabWidth: 160, + minTabWidth: 96, + maxTabWidth: 240, tabIconWidth: 14, + tabContentGap: 4, tabHorizontalPadding: 8, - estimatedCharWidth: 7, closeButtonWidth: 0, }; 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({ viewportWidth: 1200, - tabLabelLengths: [8, 10, 7], + tabLabelWidths: [56, 70, 49], metrics, }); @@ -25,26 +26,37 @@ describe("computeWorkspaceTabLayout", () => { expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.items).toHaveLength(3); expect(result.items.every((item) => item.showLabel)).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([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({ - viewportWidth: 520, - tabLabelLengths: [24, 12, 8], + viewportWidth: 1200, + 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, }); expect(result.closeButtonPolicy).toBe("all"); 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); }); - 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({ - viewportWidth: 743, - tabLabelLengths: [8, 8, 8, 8], + viewportWidth: 1004, + tabLabelWidths: [280, 280, 280, 280], metrics: { ...metrics, actionsReservedWidth: 44, @@ -55,39 +67,39 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); 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({ - viewportWidth: 268, - tabLabelLengths: [14, 14, 14, 14], + viewportWidth: 532, + tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]); - expect(result.items.every((item) => !item.showLabel)).toBe(true); + expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + 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({ - viewportWidth: 267, - tabLabelLengths: [14, 14, 14, 14], + viewportWidth: 531, + tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]); - expect(result.items.every((item) => !item.showLabel)).toBe(true); + expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.every((item) => item.showLabel)).toBe(true); }); it("returns empty layout details when there are no tabs", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 1200, - tabLabelLengths: [], + tabLabelWidths: [], metrics, }); @@ -95,4 +107,14 @@ describe("computeWorkspaceTabLayout", () => { expect(result.requiresHorizontalScrollFallback).toBe(false); 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]); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 0ddcf3320..f4f389d15 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -1,25 +1,27 @@ 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 { viewportWidth: number; - tabLabelLengths: number[]; - metrics: { - rowHorizontalInset: number; - actionsReservedWidth: number; - rowPaddingHorizontal: number; - tabGap: number; - maxTabWidth: number; - tabIconWidth: number; - tabHorizontalPadding: number; - estimatedCharWidth: number; - closeButtonWidth: number; - }; + tabLabelWidths: number[]; + metrics: WorkspaceTabLayoutMetrics; } export interface WorkspaceTabLayoutItem { width: number; showLabel: boolean; - labelCharCap: number; } export interface WorkspaceTabLayoutResult { @@ -41,7 +43,7 @@ function clamp(value: number, min: number, max: number): number { export function computeWorkspaceTabLayout( input: WorkspaceTabLayoutInput, ): WorkspaceTabLayoutResult { - const tabCount = input.tabLabelLengths.length; + const tabCount = input.tabLabelWidths.length; if (tabCount === 0) { return { items: [], @@ -57,31 +59,39 @@ export function computeWorkspaceTabLayout( const rowOverhead = input.metrics.rowPaddingHorizontal * 2 + Math.max(tabCount - 1, 0) * input.metrics.tabGap; const availableTabsWidth = Math.max(0, availableWidth - rowOverhead); - const iconOnlyTabWidth = + const tabChromeWidth = input.metrics.tabIconWidth + + input.metrics.tabContentGap + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth; - const iconOnlyTotalTabsWidth = iconOnlyTabWidth * tabCount; - const requiresHorizontalScrollFallback = availableTabsWidth < iconOnlyTotalTabsWidth; - const resolvedWidth = requiresHorizontalScrollFallback - ? iconOnlyTabWidth - : clamp(availableTabsWidth / tabCount, iconOnlyTabWidth, input.metrics.maxTabWidth); - const resolvedWidths = Array.from({ length: tabCount }, () => resolvedWidth); + const naturalWidths = input.tabLabelWidths.map((labelWidth) => + clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth), + ); + const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0); + const minimumTotalWidth = input.metrics.minTabWidth * tabCount; + 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) => - Math.round(clamp(width, iconOnlyTabWidth, input.metrics.maxTabWidth)), + Math.round(clamp(width, input.metrics.minTabWidth, input.metrics.maxTabWidth)), ); return { - items: roundedWidths.map((width) => { - const rawCharCap = Math.floor((width - iconOnlyTabWidth) / input.metrics.estimatedCharWidth); - const labelCharCap = Math.max(0, rawCharCap); - return { - width, - showLabel: labelCharCap > 0, - labelCharCap, - }; - }), + items: roundedWidths.map((width) => ({ + width, + showLabel: width > tabChromeWidth, + })), closeButtonPolicy: "all", requiresHorizontalScrollFallback, }; diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index bc05114c4..081c295b4 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -79,7 +79,6 @@ export interface PanelState { explorerTab: ExplorerTab; explorerTabByCheckout: Record; expandedPathsByWorkspace: Record; - diffExpandedPathsByWorkspace: Record; // Changes-view folder tree. Inverted semantics vs the fields above: // this stores COLLAPSED directory paths (empty = all folders expanded), keyed // by full uncompressed dir path, so folders default to expanded and new @@ -111,7 +110,6 @@ export interface PanelState { setExplorerTab: (tab: ExplorerTab) => void; setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; - setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void; setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void; activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; setSidebarWidth: (width: number) => void; @@ -148,7 +146,6 @@ export const usePanelStore = create()( explorerTab: "changes", explorerTabByCheckout: {}, expandedPathsByWorkspace: {}, - diffExpandedPathsByWorkspace: {}, diffCollapsedFoldersByWorkspace: {}, sidebarWidth: DEFAULT_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, @@ -276,13 +273,6 @@ export const usePanelStore = create()( }, }; }), - setDiffExpandedPathsForWorkspace: (workspaceKey, paths) => - set((state) => ({ - diffExpandedPathsByWorkspace: { - ...state.diffExpandedPathsByWorkspace, - [workspaceKey]: paths, - }, - })), setDiffCollapsedFoldersForWorkspace: (workspaceKey, dirPaths) => set((state) => ({ diffCollapsedFoldersByWorkspace: { @@ -308,7 +298,7 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 13, + version: 14, storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }), partialize: (state) => ({ @@ -316,7 +306,6 @@ export const usePanelStore = create()( explorerTab: state.explorerTab, explorerTabByCheckout: state.explorerTabByCheckout, expandedPathsByWorkspace: state.expandedPathsByWorkspace, - diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace, diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace, sidebarWidth: state.sidebarWidth, explorerWidth: state.explorerWidth, diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index adf53a97d..1db82e361 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -120,6 +120,14 @@ describe("panel-store migration", () => { 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", () => { const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12, { isWeb: false }); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index bf55f1fd1..d133d73ee 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -183,6 +183,7 @@ export const PanelPersistedStateSchema = z.strictObject({ explorerTab: ExplorerTabSchema.optional(), explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).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(), diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(), sidebarWidth: z.number().optional(), @@ -290,13 +291,7 @@ export function migratePanelState( ) { state.expandedPathsByWorkspace = {}; } - if ( - version < 10 || - typeof state.diffExpandedPathsByWorkspace !== "object" || - !state.diffExpandedPathsByWorkspace - ) { - state.diffExpandedPathsByWorkspace = {}; - } + delete state.diffExpandedPathsByWorkspace; if ( version < 12 || typeof state.diffCollapsedFoldersByWorkspace !== "object" || diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index d49b47f53..f0d2968b0 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -423,6 +423,25 @@ describe("workspace-layout-store actions", () => { 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", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index fdadc5ac1..eb82465af 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -521,10 +521,17 @@ export function createWorkspaceLayoutStore( } 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 explorerPane = findPaneById(layout.root, explorerPaneId); - const placementLayout = explorerPane - ? (focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout) + const placementPane = workingDiffPane ?? explorerPane; + const placementLayout = placementPane + ? (focusPaneInLayout({ layout, paneId: placementPane.id }) ?? layout) : layout; const result = openTabInLayoutFocused({ layout: placementLayout, diff --git a/packages/app/src/workspace-pins/launch.tsx b/packages/app/src/workspace-pins/launch.tsx deleted file mode 100644 index 0c6be8258..000000000 --- a/packages/app/src/workspace-pins/launch.tsx +++ /dev/null @@ -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 ; -} - -const ThemedProviderPinIcon = withUnistyles(ProviderPinIcon); - -export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement { - if (!iconKey) { - return ; - } - return ; -} - -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: , - onPress: () => onLaunch(target), - }); - continue; - } - if (target.kind === "terminal") { - resolved.push({ - key: pinnedTargetKey(target), - label: t("workspace.tabs.actions.newTerminal"), - icon: , - onPress: () => onLaunch(target), - }); - continue; - } - if (target.kind === "browser") { - resolved.push({ - key: pinnedTargetKey(target), - label: t("workspace.tabs.actions.newBrowser"), - icon: , - onPress: () => onLaunch(target), - }); - continue; - } - if (target.kind === "working_diff") { - resolved.push({ - key: pinnedTargetKey(target), - label: t("workspace.tabs.actions.changes"), - icon: , - onPress: () => onLaunch(target), - }); - continue; - } - if (target.kind === "files") { - resolved.push({ - key: pinnedTargetKey(target), - label: t("workspace.tabs.actions.files"), - icon: , - onPress: () => onLaunch(target), - }); - continue; - } - if (target.kind === "pull_request") { - resolved.push({ - key: pinnedTargetKey(target), - label: t("workspace.tabs.actions.pullRequest"), - icon: , - 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: , - onPress: () => onLaunch(target), - }); - } - return resolved; - }, [hasPullRequest, isGit, onLaunch, pinned, profiles, t]); -} diff --git a/packages/app/src/workspace-pins/pinnable-menu-item.tsx b/packages/app/src/workspace-pins/pinnable-menu-item.tsx deleted file mode 100644 index a0caa2fb0..000000000 --- a/packages/app/src/workspace-pins/pinnable-menu-item.tsx +++ /dev/null @@ -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( - () => ( - - {shortcut ? : null} - - - {isPinned ? ( - - ) : ( - - )} - - - - ), - [handleTogglePin, isPinned, shortcut, showToggle, slotStyle, t, target], - ); - - return ( - - - {label} - - - ); -} - -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", - }, -})); diff --git a/packages/app/src/workspace-pins/pinned-targets-row.tsx b/packages/app/src/workspace-pins/pinned-targets-row.tsx deleted file mode 100644 index 78e7d1f66..000000000 --- a/packages/app/src/workspace-pins/pinned-targets-row.tsx +++ /dev/null @@ -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 ( - - {launchers.map((launcher) => ( - - - {launcher.icon} - - - {launcher.label} - - - ))} - - ); -} - -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, - }, -})); diff --git a/packages/app/src/workspace-pins/run.test.ts b/packages/app/src/workspace-pins/run.test.ts deleted file mode 100644 index 6432a4793..000000000 --- a/packages/app/src/workspace-pins/run.test.ts +++ /dev/null @@ -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([]); - }); -}); diff --git a/packages/app/src/workspace-pins/run.ts b/packages/app/src/workspace-pins/run.ts deleted file mode 100644 index cf755f1ca..000000000 --- a/packages/app/src/workspace-pins/run.ts +++ /dev/null @@ -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, "")); -} diff --git a/packages/app/src/workspace-pins/store.test.ts b/packages/app/src/workspace-pins/store.test.ts deleted file mode 100644 index 4d21f180c..000000000 --- a/packages/app/src/workspace-pins/store.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - 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" }, - ]); - }); -}); diff --git a/packages/app/src/workspace-pins/store.ts b/packages/app/src/workspace-pins/store.ts deleted file mode 100644 index 51257ea02..000000000 --- a/packages/app/src/workspace-pins/store.ts +++ /dev/null @@ -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()( - persist>( - (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 }; - }, - }, - ), -); diff --git a/packages/app/src/workspace-pins/target.test.ts b/packages/app/src/workspace-pins/target.test.ts deleted file mode 100644 index 6e9c39ded..000000000 --- a/packages/app/src/workspace-pins/target.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/workspace-pins/target.ts b/packages/app/src/workspace-pins/target.ts deleted file mode 100644 index 3631c1aea..000000000 --- a/packages/app/src/workspace-pins/target.ts +++ /dev/null @@ -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; -}