feat(app): polish workspace tabs and changes
Size tabs from rendered labels and switch to horizontal overflow once minimum click targets are reached, keeping the add-tab action adjacent until it must remain fixed. Make Changes an always-expanded document with an optional desktop navigation tree, keep Git actions in their platform-owned headers, remove pinned and inline split shortcuts, and place review tabs beside Changes.
This commit is contained in:
@@ -2,23 +2,14 @@ import { useMemo } from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import {
|
||||
computeWorkspaceTabLayout,
|
||||
type WorkspaceTabLayoutMetrics,
|
||||
type WorkspaceTabLayoutResult,
|
||||
} from "@/screens/workspace/workspace-tab-layout";
|
||||
|
||||
interface UseWorkspaceTabLayoutInput {
|
||||
tabLabelLengths: number[];
|
||||
tabLabelWidths: number[];
|
||||
viewportWidthOverride?: number | null;
|
||||
metrics: {
|
||||
rowHorizontalInset: number;
|
||||
actionsReservedWidth: number;
|
||||
rowPaddingHorizontal: number;
|
||||
tabGap: number;
|
||||
maxTabWidth: number;
|
||||
tabIconWidth: number;
|
||||
tabHorizontalPadding: number;
|
||||
estimatedCharWidth: number;
|
||||
closeButtonWidth: number;
|
||||
};
|
||||
metrics: WorkspaceTabLayoutMetrics;
|
||||
}
|
||||
|
||||
interface UseWorkspaceTabLayoutResult {
|
||||
@@ -38,10 +29,10 @@ export function useWorkspaceTabLayout(
|
||||
() =>
|
||||
computeWorkspaceTabLayout({
|
||||
viewportWidth: resolvedViewportWidth,
|
||||
tabLabelLengths: input.tabLabelLengths,
|
||||
tabLabelWidths: input.tabLabelWidths,
|
||||
metrics: input.metrics,
|
||||
}),
|
||||
[input.metrics, input.tabLabelLengths, resolvedViewportWidth],
|
||||
[input.metrics, input.tabLabelWidths, resolvedViewportWidth],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
|
||||
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
|
||||
@@ -139,17 +136,10 @@ const CHANGES_ICON = <ThemedFileDiff size={14} uniProps={mutedColorMapping} />;
|
||||
const FILES_ICON = <ThemedFolderTree size={14} uniProps={mutedColorMapping} />;
|
||||
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={14} uniProps={mutedColorMapping} />;
|
||||
|
||||
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
|
||||
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
|
||||
const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" };
|
||||
const CHANGES_TARGET = { kind: "working_diff" } as const;
|
||||
const FILES_TARGET = { kind: "files" } as const;
|
||||
const PULL_REQUEST_TARGET = { kind: "pull_request" } as const;
|
||||
|
||||
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
||||
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||
}
|
||||
|
||||
function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
||||
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||
}
|
||||
@@ -159,39 +149,48 @@ function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, event:
|
||||
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.terminalProfileIconWrapper}>
|
||||
<ProfileIcon iconKey={iconKey} />
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.tabLabel, styles.tabLabelMeasurement]}
|
||||
numberOfLines={1}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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<PinnedTabTarget>(
|
||||
() => ({ kind: "profile", profileId: profile.id }),
|
||||
[profile.id],
|
||||
);
|
||||
function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) {
|
||||
const leading = useMemo(
|
||||
() => <ProfileLeadingIcon iconKey={getTerminalProfileIcon(profile)} />,
|
||||
() => <TerminalProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
|
||||
[profile],
|
||||
);
|
||||
const handleSelect = useCallback(() => onLaunch(target), [onLaunch, target]);
|
||||
const handleSelect = useCallback(() => onLaunch(profile), [onLaunch, profile]);
|
||||
|
||||
return (
|
||||
<PinnableMenuItem
|
||||
target={target}
|
||||
label={profile.name}
|
||||
leading={leading}
|
||||
disabled={disabled}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
|
||||
{profile.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TabTargetHandlers>(
|
||||
() => ({
|
||||
createDraft: onCreateAgentTab,
|
||||
createTerminal: onCreateTerminal,
|
||||
createBrowser: onCreateBrowser,
|
||||
openChanges: onOpenChanges,
|
||||
openFiles: onOpenFiles,
|
||||
openPullRequest: onOpenPullRequest,
|
||||
createTerminalWithProfile: onCreateTerminalWithProfile,
|
||||
}),
|
||||
[
|
||||
onCreateAgentTab,
|
||||
onCreateBrowser,
|
||||
onCreateTerminal,
|
||||
onCreateTerminalWithProfile,
|
||||
onOpenChanges,
|
||||
onOpenFiles,
|
||||
onOpenPullRequest,
|
||||
],
|
||||
);
|
||||
|
||||
const onLaunch = useCallback(
|
||||
(target: PinnedTabTarget) => {
|
||||
runPinnedTabTarget(target, profiles, handlers);
|
||||
},
|
||||
[handlers, profiles],
|
||||
);
|
||||
|
||||
return { profiles, onLaunch };
|
||||
}
|
||||
|
||||
interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
|
||||
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 ? <Shortcut chord={shortcutKeys} /> : undefined),
|
||||
[shortcutKeys],
|
||||
);
|
||||
const tooltipText = t("workspace.tabs.actions.newTab");
|
||||
|
||||
return (
|
||||
@@ -309,7 +255,7 @@ function WorkspaceNewTabButton({
|
||||
accessibilityLabel={tooltipText}
|
||||
style={inlineAddActionButtonStyle}
|
||||
>
|
||||
<ThemedPlus size={14} uniProps={mutedColorMapping} />
|
||||
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
@@ -317,64 +263,64 @@ function WorkspaceNewTabButton({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
|
||||
<PinnableMenuItem
|
||||
<DropdownMenuItem
|
||||
testID="workspace-new-tab-menu-agent"
|
||||
target={DRAFT_TARGET}
|
||||
label={t("workspace.tabs.actions.newAgent")}
|
||||
leading={AGENT_ICON}
|
||||
shortcut={shortcutKeys}
|
||||
trailing={shortcut}
|
||||
onSelect={onCreateAgentTab}
|
||||
/>
|
||||
<PinnableMenuItem
|
||||
>
|
||||
{t("workspace.tabs.actions.newAgent")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="workspace-new-tab-menu-terminal"
|
||||
target={TERMINAL_TARGET}
|
||||
label={t("workspace.tabs.actions.newTerminal")}
|
||||
leading={TERMINAL_ICON}
|
||||
disabled={terminalDisabled}
|
||||
onSelect={terminalDisabled ? undefined : onCreateTerminal}
|
||||
/>
|
||||
>
|
||||
{t("workspace.tabs.actions.newTerminal")}
|
||||
</DropdownMenuItem>
|
||||
{showCreateBrowserTab ? (
|
||||
<PinnableMenuItem
|
||||
<DropdownMenuItem
|
||||
testID="workspace-new-tab-menu-browser"
|
||||
target={BROWSER_TARGET}
|
||||
label={t("workspace.tabs.actions.newBrowser")}
|
||||
leading={BROWSER_ICON}
|
||||
onSelect={onCreateBrowser}
|
||||
/>
|
||||
>
|
||||
{t("workspace.tabs.actions.newBrowser")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isGit ? (
|
||||
<PinnableMenuItem
|
||||
<DropdownMenuItem
|
||||
testID="workspace-new-tab-menu-changes"
|
||||
target={CHANGES_TARGET}
|
||||
label={t("workspace.tabs.actions.changes")}
|
||||
leading={CHANGES_ICON}
|
||||
onSelect={onOpenChanges}
|
||||
/>
|
||||
>
|
||||
{t("workspace.tabs.actions.changes")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<PinnableMenuItem
|
||||
<DropdownMenuItem
|
||||
testID="workspace-new-tab-menu-files"
|
||||
target={FILES_TARGET}
|
||||
label={t("workspace.tabs.actions.files")}
|
||||
leading={FILES_ICON}
|
||||
onSelect={onOpenFiles}
|
||||
/>
|
||||
>
|
||||
{t("workspace.tabs.actions.files")}
|
||||
</DropdownMenuItem>
|
||||
{showPullRequest ? (
|
||||
<PinnableMenuItem
|
||||
<DropdownMenuItem
|
||||
testID="workspace-new-tab-menu-pull-request"
|
||||
target={PULL_REQUEST_TARGET}
|
||||
label={t("workspace.tabs.actions.pullRequest")}
|
||||
leading={PULL_REQUEST_ICON}
|
||||
onSelect={onOpenPullRequest}
|
||||
/>
|
||||
>
|
||||
{t("workspace.tabs.actions.pullRequest")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
|
||||
{profiles.map((profile) => (
|
||||
<PinnableProfileMenuItem
|
||||
<TerminalProfileMenuItem
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
disabled={terminalDisabled}
|
||||
onLaunch={onLaunch}
|
||||
onLaunch={onCreateTerminalWithProfile}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -387,20 +333,6 @@ function WorkspaceNewTabButton({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePinnedTargets(
|
||||
options: TabTargetLauncherOptions & { isGit: boolean; showPullRequest: boolean },
|
||||
) {
|
||||
const { onLaunch } = useTabTargetLauncher(options);
|
||||
const launchers = usePinnedLaunchers({
|
||||
serverId: options.normalizedServerId,
|
||||
onLaunch,
|
||||
isGit: options.isGit,
|
||||
hasPullRequest: options.showPullRequest,
|
||||
});
|
||||
|
||||
return <PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />;
|
||||
}
|
||||
|
||||
function TabContextMenuItem({
|
||||
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 (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
{icon === "split-right" ? (
|
||||
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
|
||||
) : (
|
||||
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>{label}</Text>
|
||||
{shortcutKeys ? (
|
||||
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
interface 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<WorkspaceDesktopTabsRowProps, "tabs"> {
|
||||
tabs: ResolvedWorkspaceDesktopTabRowItem[];
|
||||
}
|
||||
|
||||
interface WorkspaceDesktopTabRowsResolverProps {
|
||||
tabs: WorkspaceDesktopTabRowItem[];
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
index: number;
|
||||
resolvedTabs: ResolvedWorkspaceDesktopTabRowItem[];
|
||||
children: (tabs: ResolvedWorkspaceDesktopTabRowItem[]) => React.ReactNode;
|
||||
}
|
||||
|
||||
const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = [];
|
||||
|
||||
function WorkspaceDesktopTabRowsResolver({
|
||||
tabs,
|
||||
serverId,
|
||||
workspaceId,
|
||||
index,
|
||||
resolvedTabs,
|
||||
children,
|
||||
}: WorkspaceDesktopTabRowsResolverProps): React.ReactElement {
|
||||
const item = tabs[index];
|
||||
if (!item) {
|
||||
return <>{children(resolvedTabs)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver tab={item.tab} serverId={serverId} workspaceId={workspaceId}>
|
||||
{(presentation) => {
|
||||
const nextResolvedTabs = [...resolvedTabs, { ...item, presentation }];
|
||||
return (
|
||||
<WorkspaceDesktopTabRowsResolver
|
||||
tabs={tabs}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
index={index + 1}
|
||||
resolvedTabs={nextResolvedTabs}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceDesktopTabRowsResolver>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
function getFallbackTabLabel(
|
||||
tab: WorkspaceTabDescriptor,
|
||||
labels: {
|
||||
@@ -813,7 +760,7 @@ function TabChip({
|
||||
|
||||
{showCloseButton ? (
|
||||
<View
|
||||
pointerEvents={showCloseControl ? "auto" : "none"}
|
||||
pointerEvents={showCloseControl ? "box-none" : "none"}
|
||||
style={[
|
||||
styles.tabTrailingOverlay,
|
||||
showCloseControl ? styles.tabTrailingOverlayShown : styles.tabTrailingOverlayHidden,
|
||||
@@ -865,7 +812,21 @@ function TabChip({
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceDesktopTabsRow({
|
||||
export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
|
||||
return (
|
||||
<WorkspaceDesktopTabRowsResolver
|
||||
tabs={props.tabs}
|
||||
serverId={props.normalizedServerId}
|
||||
workspaceId={props.normalizedWorkspaceId}
|
||||
index={0}
|
||||
resolvedTabs={EMPTY_RESOLVED_TAB_ROWS}
|
||||
>
|
||||
{(tabs) => <ResolvedWorkspaceDesktopTabsRow {...props} tabs={tabs} />}
|
||||
</WorkspaceDesktopTabRowsResolver>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedWorkspaceDesktopTabsRow({
|
||||
paneId,
|
||||
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<number>(0);
|
||||
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
||||
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
||||
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
||||
const [measuredTabLabels, setMeasuredTabLabels] = useState<
|
||||
Record<string, { label: string; width: number }>
|
||||
>({});
|
||||
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
|
||||
const 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<WorkspaceDesktopTabRowItem>) => {
|
||||
}: DraggableRenderItemInfo<ResolvedWorkspaceDesktopTabRowItem>) => {
|
||||
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}
|
||||
>
|
||||
<View
|
||||
style={styles.tabLabelMeasurements}
|
||||
pointerEvents="none"
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
{tabLabels.map(({ key, label }) => (
|
||||
<TabLabelMeasurement
|
||||
key={`${key}:${label}`}
|
||||
tabKey={key}
|
||||
label={label}
|
||||
onMeasure={handleTabLabelLayout}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{focusModeEnabled ? (
|
||||
<View style={styles.exitFocusModeSlot} onLayout={handleExitFocusModeLayout}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -1198,26 +1189,29 @@ export function WorkspaceDesktopTabsRow({
|
||||
getItemData={getTabDragData}
|
||||
renderItem={renderTab}
|
||||
/>
|
||||
{!layout.requiresHorizontalScrollFallback ? (
|
||||
<WorkspaceNewTabButton
|
||||
shortcutKeys={newTabKeys}
|
||||
onCreateAgentTab={handleCreateAgentTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowser}
|
||||
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
|
||||
onOpenChanges={handleOpenChanges}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
onOpenPullRequest={handleOpenPullRequest}
|
||||
onEditProfiles={handleEditProfiles}
|
||||
normalizedServerId={normalizedServerId}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
terminalDisabled={terminalDisabled}
|
||||
isGit={isGit}
|
||||
showPullRequest={showPullRequest}
|
||||
onLayout={handleInlineAddButtonLayout}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
<WorkspaceNewTabButton
|
||||
shortcutKeys={newTabKeys}
|
||||
onCreateAgentTab={handleCreateAgentTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowser}
|
||||
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
|
||||
onOpenChanges={handleOpenChanges}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
onOpenPullRequest={handleOpenPullRequest}
|
||||
onEditProfiles={handleEditProfiles}
|
||||
normalizedServerId={normalizedServerId}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
terminalDisabled={terminalDisabled}
|
||||
isGit={isGit}
|
||||
showPullRequest={showPullRequest}
|
||||
onLayout={handleInlineAddButtonLayout}
|
||||
/>
|
||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||
<WorkspacePinnedTargets
|
||||
{layout.requiresHorizontalScrollFallback ? (
|
||||
<WorkspaceNewTabButton
|
||||
shortcutKeys={newTabKeys}
|
||||
onCreateAgentTab={handleCreateAgentTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowser}
|
||||
@@ -1225,27 +1219,15 @@ export function WorkspaceDesktopTabsRow({
|
||||
onOpenChanges={handleOpenChanges}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
onOpenPullRequest={handleOpenPullRequest}
|
||||
onEditProfiles={handleEditProfiles}
|
||||
normalizedServerId={normalizedServerId}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
terminalDisabled={terminalDisabled}
|
||||
isGit={isGit}
|
||||
showPullRequest={showPullRequest}
|
||||
onLayout={handleInlineAddButtonLayout}
|
||||
/>
|
||||
{showPaneSplitActions ? (
|
||||
<>
|
||||
<SplitActionButton
|
||||
icon="split-right"
|
||||
onPress={onSplitRight}
|
||||
label={t("workspace.tabs.actions.splitRight")}
|
||||
shortcutKeys={splitRightKeys}
|
||||
/>
|
||||
<SplitActionButton
|
||||
icon="split-down"
|
||||
onPress={onSplitDown}
|
||||
label={t("workspace.tabs.actions.splitDown")}
|
||||
shortcutKeys={splitDownKeys}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCopyTerminalId: (terminalId: string) => Promise<void> | 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 (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={item.tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const tooltipLabel =
|
||||
presentation.titleState === "loading"
|
||||
? t("workspace.tabs.loadingAgentTitle")
|
||||
: presentation.tooltip;
|
||||
const tooltipLabel =
|
||||
presentation.titleState === "loading"
|
||||
? t("workspace.tabs.loadingAgentTitle")
|
||||
: presentation.tooltip;
|
||||
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
{showDropIndicatorBefore ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||
) : null}
|
||||
<TabChip
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
isFocused={isFocused}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={showCloseButton}
|
||||
isCloseHovered={item.isCloseHovered}
|
||||
isClosingTab={item.isClosingTab}
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
{showDropIndicatorAfter ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
{showDropIndicatorBefore ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||
) : null}
|
||||
<TabChip
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
isFocused={isFocused}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={showCloseButton}
|
||||
isCloseHovered={item.isCloseHovered}
|
||||
isClosingTab={item.isClosingTab}
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
{showDropIndicatorAfter ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<WorkspaceActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
<WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
|
||||
{isGitCheckout ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -3727,7 +3718,6 @@ function WorkspaceScreenContent({
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
handleOpenUrlInBrowserTab,
|
||||
showCompactButtonLabels,
|
||||
isGitCheckout,
|
||||
handleToggleExplorer,
|
||||
isExplorerOpen,
|
||||
@@ -3845,7 +3835,6 @@ function WorkspaceScreenContent({
|
||||
<View style={styles.centerColumn}>
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
@@ -3938,9 +3927,6 @@ function WorkspaceScreenContent({
|
||||
disableCreateTerminal={createTerminalMutation.isPending}
|
||||
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
onSplitRight={noop}
|
||||
onSplitDown={noop}
|
||||
showPaneSplitActions={false}
|
||||
focusModeEnabled={desktopFocusModeEnabled}
|
||||
onExitFocusMode={toggleFocusMode}
|
||||
/>
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user