From ee8e2ab691d7817dc45aeb6e1d7ba4106845076b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 21:32:57 +0700 Subject: [PATCH] refactor: improve tab layout sizing and always show close buttons --- .../app/src/components/draggable-list.web.tsx | 28 ++++++------ .../src/components/sidebar-workspace-list.tsx | 12 +---- .../components/sortable-inline-list.web.tsx | 1 - .../workspace/use-workspace-tab-layout.ts | 9 +++- .../workspace/workspace-desktop-tabs-row.tsx | 45 +++++++++++++++---- .../workspace/workspace-tab-layout.test.ts | 23 +++++----- .../screens/workspace/workspace-tab-layout.ts | 32 ++++++++++--- 7 files changed, 99 insertions(+), 51 deletions(-) diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 7f1a0d4d8..e3ab82f29 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -139,15 +139,11 @@ export function DraggableList({ nestable: _nestable = false, }: DraggableListProps) { const [activeId, setActiveId] = useState(null); - const [items, setItems] = useState(data); + const [dragItems, setDragItems] = useState(null); + const items = dragItems ?? data; const scrollViewRef = useRef(null); const scrollbarMetrics = useWebDesktopScrollbarMetrics(); - // Sync items with data prop - if (data !== items && !activeId) { - setItems(data); - } - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -159,16 +155,21 @@ export function DraggableList({ }) ); - const handleDragStart = useCallback((event: DragStartEvent) => { - setActiveId(String(event.active.id)); - onDragBegin?.(); - }, [onDragBegin]); + const handleDragStart = useCallback( + (event: DragStartEvent) => { + setDragItems(data); + setActiveId(String(event.active.id)); + onDragBegin?.(); + }, + [data, onDragBegin] + ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; setActiveId(null); + setDragItems(null); if (over && active.id !== over.id) { const oldIndex = items.findIndex( @@ -178,9 +179,10 @@ export function DraggableList({ (item, i) => keyExtractor(item, i) === over.id ); - const newItems = arrayMove(items, oldIndex, newIndex); - setItems(newItems); - onDragEnd(newItems); + if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { + const newItems = arrayMove(items, oldIndex, newIndex); + onDragEnd(newItems); + } } }, [items, keyExtractor, onDragEnd] diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 6054b8939..2437debbe 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -20,7 +20,7 @@ import { type ReactElement, type MutableRefObject, } from 'react' -import { router, usePathname, useSegments } from 'expo-router' +import { router, usePathname } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' import { ChevronDown, ChevronRight } from 'lucide-react-native' @@ -736,7 +736,6 @@ function ProjectBlock({ iconDataUri, serverId, activeWorkspaceSelection, - shouldReplaceWorkspaceNavigation, showShortcutBadges, shortcutIndexByWorkspaceKey, parentGestureRef, @@ -756,7 +755,6 @@ function ProjectBlock({ iconDataUri: string | null serverId: string | null activeWorkspaceSelection: { serverId: string; workspaceId: string } | null - shouldReplaceWorkspaceNavigation: boolean showShortcutBadges: boolean shortcutIndexByWorkspaceKey: Map parentGestureRef?: MutableRefObject @@ -778,7 +776,6 @@ function ProjectBlock({ dragHandleProps: workspaceDragHandleProps, }: DraggableRenderItemInfo) => { const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId) - const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push const isSelected = Boolean(serverId) && activeWorkspaceSelection?.serverId === serverId && @@ -795,7 +792,7 @@ function ProjectBlock({ return } onWorkspacePress?.() - navigate(workspaceRoute as any) + router.push(workspaceRoute as any) }} drag={workspaceDrag} isDragging={isActive} @@ -807,7 +804,6 @@ function ProjectBlock({ activeWorkspaceSelection, onWorkspacePress, serverId, - shouldReplaceWorkspaceNavigation, shortcutIndexByWorkspaceKey, showShortcutBadges, ] @@ -861,9 +857,7 @@ export function SidebarWorkspaceList({ parentGestureRef, }: SidebarWorkspaceListProps) { const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const segments = useSegments() const pathname = usePathname() - const shouldReplaceWorkspaceNavigation = segments[0] === 'h' const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) const [outerScrollEnabled, setOuterScrollEnabled] = useState(true) const isTauri = getIsTauri() @@ -1081,7 +1075,6 @@ export function SidebarWorkspaceList({ iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} serverId={serverId} activeWorkspaceSelection={activeWorkspaceSelection} - shouldReplaceWorkspaceNavigation={shouldReplaceWorkspaceNavigation} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutModel.shortcutIndexByWorkspaceKey} parentGestureRef={parentGestureRef} @@ -1106,7 +1099,6 @@ export function SidebarWorkspaceList({ projectIconByProjectKey, serverId, shortcutModel.shortcutIndexByWorkspaceKey, - shouldReplaceWorkspaceNavigation, showShortcutBadges, toggleProjectCollapsed, lockOuterScrollForDrag, diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index d8d6b0bf5..4996ee427 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -165,7 +165,6 @@ export function SortableInlineList({ if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { const newItems = arrayMove(items, oldIndex, newIndex); - setDragItems(newItems); onDragEnd?.(newItems); } } diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index e09fdc3d8..b66d4ba57 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -7,6 +7,7 @@ import { type UseWorkspaceTabLayoutInput = { tabLabels: string[]; + viewportWidthOverride?: number | null; metrics: { rowHorizontalInset: number; actionsReservedWidth: number; @@ -29,15 +30,19 @@ type UseWorkspaceTabLayoutResult = { export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWorkspaceTabLayoutResult { const { width: viewportWidth } = useWindowDimensions(); + const resolvedViewportWidth = + typeof input.viewportWidthOverride === "number" && input.viewportWidthOverride > 0 + ? input.viewportWidthOverride + : viewportWidth; const layout = useMemo( () => computeWorkspaceTabLayout({ - viewportWidth, + viewportWidth: resolvedViewportWidth, tabLabelLengths: input.tabLabels.map((label) => label.length), metrics: input.metrics, }), - [input.metrics, input.tabLabels, viewportWidth] + [input.metrics, input.tabLabels, 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 04980a48d..799d9c510 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,5 +1,5 @@ -import { useMemo, type Dispatch, type SetStateAction } from "react"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native"; import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, X } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { SortableInlineList } from "@/components/sortable-inline-list"; @@ -73,11 +73,23 @@ export function WorkspaceDesktopTabsRow({ onReorderTabs, }: WorkspaceDesktopTabsRowProps) { const { theme } = useUnistyles(); + const [tabsContainerWidth, setTabsContainerWidth] = useState(0); + const [tabsActionsWidth, setTabsActionsWidth] = useState(0); + + const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { + const nextWidth = Math.round(event.nativeEvent.layout.width); + setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + }, []); + + const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => { + const nextWidth = Math.round(event.nativeEvent.layout.width); + setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + }, []); const layoutMetrics = useMemo( () => ({ rowHorizontalInset: 0, - actionsReservedWidth: 120, + actionsReservedWidth: Math.max(0, tabsActionsWidth), rowPaddingHorizontal: theme.spacing[2], tabGap: theme.spacing[1], minTabWidth: 60, @@ -86,19 +98,24 @@ export function WorkspaceDesktopTabsRow({ tabHorizontalPadding: theme.spacing[3], estimatedCharWidth: 7, closeButtonWidth: 22, - compactLabelCharCap: 10, - compactDenseLabelCharCap: 8, + compactLabelCharCap: 9, + compactDenseLabelCharCap: 7, }), - [theme.spacing] + [tabsActionsWidth, theme.spacing] ); const { layout } = useWorkspaceTabLayout({ tabLabels: tabs.map((tab) => tab.label), + viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); return ( - + [ styles.tab, layout.mode === "icon" && styles.tabIconOnly, + layout.mode === "icon" && { + minWidth: layout.tabMaxWidth, + width: layout.tabMaxWidth, + maxWidth: layout.tabMaxWidth, + }, + layout.mode === "compact" && styles.tabCompact, + layout.mode === "compact" && { maxWidth: layout.tabMaxWidth }, isActive && styles.tabActive, (hovered || pressed || isCloseHovered) && styles.tabHovered, ]} @@ -320,7 +344,7 @@ export function WorkspaceDesktopTabsRow({ }} /> - + ({ maxWidth: 40, justifyContent: "center", }, + tabCompact: { + paddingHorizontal: theme.spacing[2], + }, tabHandle: { flexDirection: "row", alignItems: "center", 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 249f8b7c1..5743e49c7 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -12,8 +12,8 @@ const metrics = { tabHorizontalPadding: 12, estimatedCharWidth: 7, closeButtonWidth: 22, - compactLabelCharCap: 10, - compactDenseLabelCharCap: 8, + compactLabelCharCap: 9, + compactDenseLabelCharCap: 7, }; describe("computeWorkspaceTabLayout", () => { @@ -26,7 +26,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.mode).toBe("full"); expect(result.showLabels).toBe(true); - expect(result.showCloseButtons).toBe(true); + expect(result.closeButtonPolicy).toBe("all"); }); it("uses compact mode before icon-only", () => { @@ -38,7 +38,8 @@ describe("computeWorkspaceTabLayout", () => { expect(result.mode).toBe("compact"); expect(result.showLabels).toBe(true); - expect(result.showCloseButtons).toBe(false); + expect(result.closeButtonPolicy).toBe("all"); + expect(result.tabMaxWidth).toBeGreaterThan(108); }); it("falls back to icon mode when compact labels still cannot fit", () => { @@ -50,7 +51,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.mode).toBe("icon"); expect(result.showLabels).toBe(false); - expect(result.showCloseButtons).toBe(false); + expect(result.closeButtonPolicy).toBe("all"); }); it("keeps icon mode without scroll when icons can fit", () => { @@ -72,18 +73,20 @@ describe("computeWorkspaceTabLayout", () => { expect(result.mode).toBe("compact"); expect(result.showLabels).toBe(true); - expect(result.showCloseButtons).toBe(false); + expect(result.closeButtonPolicy).toBe("all"); + expect(result.tabMaxWidth).toBeGreaterThan(94); }); - it("uses dense compact labels for larger tab counts before icon-only", () => { + it("falls back to icon mode sooner at tighter widths when close buttons stay visible", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 1100, tabLabelLengths: [18, 21, 14, 15, 19, 12, 17, 16, 20], metrics, }); - expect(result.mode).toBe("compact"); - expect(result.showLabels).toBe(true); - expect(result.showCloseButtons).toBe(false); + expect(result.mode).toBe("icon"); + expect(result.showLabels).toBe(false); + expect(result.closeButtonPolicy).toBe("all"); + expect(result.tabMaxWidth).toBe(58); }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index f7c385159..64616d127 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -1,4 +1,5 @@ export type WorkspaceTabLayoutMode = "full" | "compact" | "icon"; +export type WorkspaceTabCloseButtonPolicy = "all"; export type WorkspaceTabLayoutInput = { viewportWidth: number; @@ -22,7 +23,8 @@ export type WorkspaceTabLayoutInput = { export type WorkspaceTabLayoutResult = { mode: WorkspaceTabLayoutMode; showLabels: boolean; - showCloseButtons: boolean; + closeButtonPolicy: WorkspaceTabCloseButtonPolicy; + tabMaxWidth: number; }; function clamp(value: number, min: number, max: number): number { @@ -62,7 +64,8 @@ export function computeWorkspaceTabLayout( return { mode: "full", showLabels: true, - showCloseButtons: true, + closeButtonPolicy: "all", + tabMaxWidth: input.metrics.maxTabWidth, }; } @@ -90,13 +93,20 @@ export function computeWorkspaceTabLayout( rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, }); if (fullTotal <= availableWidth) { - return { mode: "full", showLabels: true, showCloseButtons: true }; + return { + mode: "full", + showLabels: true, + closeButtonPolicy: "all", + tabMaxWidth: input.metrics.maxTabWidth, + }; } const compactTabWidths = input.tabLabelLengths.map((rawLength) => { const labelLength = Math.max(rawLength, 1); const estimatedWidth = - baseTabWidth + estimateLabelWidth(Math.min(labelLength, effectiveCompactLabelCharCap)); + baseTabWidth + + estimateLabelWidth(Math.min(labelLength, effectiveCompactLabelCharCap)) + + input.metrics.closeButtonWidth; return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth); }); const compactTotal = computeTotalRowWidth({ @@ -105,8 +115,18 @@ export function computeWorkspaceTabLayout( rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, }); if (compactTotal <= availableWidth) { - return { mode: "compact", showLabels: true, showCloseButtons: false }; + return { + mode: "compact", + showLabels: true, + closeButtonPolicy: "all", + tabMaxWidth: Math.max(...compactTabWidths), + }; } - return { mode: "icon", showLabels: false, showCloseButtons: false }; + return { + mode: "icon", + showLabels: false, + closeButtonPolicy: "all", + tabMaxWidth: 58, + }; }