From fbb4db5479e298335da21d2bddc00a2e66dbe038 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 19 May 2026 16:44:01 -0400 Subject: [PATCH] Fix right sidebar activity overflow (#2343) Co-authored-by: Orca --- .../right-sidebar/activity-bar-buttons.tsx | 142 +++++++++++++++++ .../activity-bar-overflow.test.ts | 40 +++++ .../right-sidebar/activity-bar-overflow.ts | 36 +++++ .../src/components/right-sidebar/index.tsx | 144 ++++++++---------- 4 files changed, 282 insertions(+), 80 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx create mode 100644 src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts create mode 100644 src/renderer/src/components/right-sidebar/activity-bar-overflow.ts diff --git a/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx new file mode 100644 index 000000000..d9d283746 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx @@ -0,0 +1,142 @@ +import React from 'react' +import { MoreHorizontal } from 'lucide-react' +import type { RightSidebarTab } from '@/store/slices/editor' +import type { CheckStatus } from '../../../../shared/types' +import { cn } from '@/lib/utils' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuShortcut, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' + +export type ActivityBarItem = { + id: RightSidebarTab + icon: React.ComponentType<{ size?: number; className?: string }> + title: string + shortcut: string + /** When true, hidden for non-git (folder-mode) repos. */ + gitOnly?: boolean +} + +const STATUS_DOT_COLOR: Record = { + success: 'bg-emerald-500', + failure: 'bg-rose-500', + pending: 'bg-amber-500', + neutral: 'bg-muted-foreground' +} + +export function TopActivityOverflowMenu({ + items, + activeTab, + onSelect, + checksStatus +}: { + items: ActivityBarItem[] + activeTab: RightSidebarTab + onSelect: (tab: RightSidebarTab) => void + checksStatus?: CheckStatus | null +}): React.JSX.Element { + const hiddenChecksStatus = + checksStatus && checksStatus !== 'neutral' && items.some((item) => item.id === 'checks') + ? checksStatus + : null + + return ( + + + + + + {items.map((item) => { + const Icon = item.icon + const active = item.id === activeTab + return ( + onSelect(item.id)} + className={cn(active && 'bg-accent text-accent-foreground')} + aria-current={active ? 'page' : undefined} + > + + {item.title} + {item.shortcut && {item.shortcut}} + + ) + })} + + + ) +} + +export function ActivityBarButton({ + item, + active, + onClick, + layout, + statusIndicator +}: { + item: ActivityBarItem + active: boolean + onClick: () => void + layout: 'top' | 'side' + statusIndicator?: CheckStatus | null +}): React.JSX.Element { + const Icon = item.icon + const isTop = layout === 'top' + + return ( + + + + + + {item.shortcut ? `${item.title} (${item.shortcut})` : item.title} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts b/src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts new file mode 100644 index 000000000..d556da9ed --- /dev/null +++ b/src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import type { RightSidebarTab } from '@/store/slices/editor' +import { getTopActivityBarLayout } from './activity-bar-overflow' + +const items = ( + ['explorer', 'search', 'source-control', 'checks', 'ports'] as RightSidebarTab[] +).map((id) => ({ id })) + +describe('getTopActivityBarLayout', () => { + it('shows every item when the top activity strip has enough room', () => { + const layout = getTopActivityBarLayout(items, 180, 'explorer') + + expect(layout.visibleItems.map((item) => item.id)).toEqual([ + 'explorer', + 'search', + 'source-control', + 'checks', + 'ports' + ]) + expect(layout.overflowItems).toEqual([]) + }) + + it('moves trailing items behind the overflow menu when width is tight', () => { + const layout = getTopActivityBarLayout(items, 160, 'explorer') + + expect(layout.visibleItems.map((item) => item.id)).toEqual([ + 'explorer', + 'search', + 'source-control' + ]) + expect(layout.overflowItems.map((item) => item.id)).toEqual(['checks', 'ports']) + }) + + it('keeps the active tab visible even when it would otherwise overflow', () => { + const layout = getTopActivityBarLayout(items, 160, 'ports') + + expect(layout.visibleItems.map((item) => item.id)).toEqual(['explorer', 'search', 'ports']) + expect(layout.overflowItems.map((item) => item.id)).toEqual(['source-control', 'checks']) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/activity-bar-overflow.ts b/src/renderer/src/components/right-sidebar/activity-bar-overflow.ts new file mode 100644 index 000000000..cdfa0f115 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/activity-bar-overflow.ts @@ -0,0 +1,36 @@ +import type { RightSidebarTab } from '@/store/slices/editor' + +const TOP_ACTIVITY_BUTTON_WIDTH = 36 +const TOP_ACTIVITY_MORE_BUTTON_WIDTH = 32 + +export function getTopActivityBarLayout( + items: readonly T[], + availableWidth: number | null, + activeId: RightSidebarTab +): { visibleItems: T[]; overflowItems: T[] } { + if (!availableWidth || !Number.isFinite(availableWidth)) { + return { visibleItems: [...items], overflowItems: [] } + } + if (items.length * TOP_ACTIVITY_BUTTON_WIDTH <= availableWidth) { + return { visibleItems: [...items], overflowItems: [] } + } + + const visibleCount = Math.max( + 1, + Math.min( + items.length - 1, + Math.floor((availableWidth - TOP_ACTIVITY_MORE_BUTTON_WIDTH) / TOP_ACTIVITY_BUTTON_WIDTH) + ) + ) + const visibleItems = items.slice(0, visibleCount) + const activeItem = items.find((item) => item.id === activeId) + if (activeItem && !visibleItems.some((item) => item.id === activeItem.id)) { + visibleItems[visibleItems.length - 1] = activeItem + } + + const visibleIds = new Set(visibleItems.map((item) => item.id)) + return { + visibleItems, + overflowItems: items.filter((item) => !visibleIds.has(item.id)) + } +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 895dc0899..ae66e61bc 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -4,7 +4,7 @@ import { useAppStore } from '@/store' import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { useSidebarResize } from '@/hooks/useSidebarResize' -import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor' +import type { ActivityBarPosition } from '@/store/slices/editor' import type { CheckStatus } from '../../../../shared/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { findWorktreeById } from '@/store/slices/worktree-helpers' @@ -22,6 +22,12 @@ import SourceControl from './SourceControl' import SearchPanel from './Search' import ChecksPanel from './ChecksPanel' import PortsPanel from './PortsPanel' +import { getTopActivityBarLayout } from './activity-bar-overflow' +import { + ActivityBarButton, + TopActivityOverflowMenu, + type ActivityBarItem +} from './activity-bar-buttons' const MIN_WIDTH = 220 // Why: long file names (e.g. construction drawing sheets, multi-part document @@ -33,7 +39,6 @@ const MIN_NON_SIDEBAR_AREA = 320 const ABSOLUTE_FALLBACK_MAX_WIDTH = 2000 const ACTIVITY_BAR_SIDE_WIDTH = 40 - function branchDisplayName(branch: string): string { return branch.replace(/^refs\/heads\//, '') } @@ -60,16 +65,7 @@ function getActiveChecksStatus(state: ReturnType): return state.prCache[prCacheKey]?.data?.checksStatus ?? null } -type ActivityBarItem = { - id: RightSidebarTab - icon: React.ComponentType<{ size?: number; className?: string }> - title: string - shortcut: string - /** When true, hidden for non-git (folder-mode) repos. */ - gitOnly?: boolean -} - -const isMac = navigator.userAgent.includes('Mac') +const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') const mod = isMac ? '\u2318' : 'Ctrl+' const ACTIVITY_ITEMS: ActivityBarItem[] = [ @@ -120,6 +116,7 @@ function RightSidebarInner(): React.JSX.Element { const checksStatus = useAppStore(getActiveChecksStatus) const activityBarPosition = useAppStore((s) => s.activityBarPosition) const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition) + const [topActivityStripWidth, setTopActivityStripWidth] = useState(null) // Why: source control and checks are meaningless for non-git folders. // Hide those tabs so the activity bar only shows relevant actions. const activeRepo = useRepoById(activeWorktree?.repoId ?? null) @@ -153,6 +150,7 @@ function RightSidebarInner(): React.JSX.Element { renderedExtraWidth: activityBarSideWidth, setWidth: setRightSidebarWidth }) + const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth) const panelContent = (
@@ -177,13 +175,18 @@ function RightSidebarInner(): React.JSX.Element {
) - const activityBarIcons = visibleItems.map((item) => ( + const topActivityLayout = useMemo( + () => getTopActivityBarLayout(visibleItems, topActivityStripWidth, effectiveTab), + [visibleItems, topActivityStripWidth, effectiveTab] + ) + + const sideActivityBarIcons = visibleItems.map((item) => ( setRightSidebarTab(item.id)} - layout={activityBarPosition} + layout="side" statusIndicator={item.id === 'checks' ? checksStatus : null} /> )) @@ -232,11 +235,33 @@ function RightSidebarInner(): React.JSX.Element {
-
- {/* Why: Windows window controls can leave less safe header width - than the activity buttons need; scroll inside the safe area - instead of letting buttons extend under the overlay. */} -
{activityBarIcons}
+
+ {/* Why: the top strip shares a narrow titlebar with the close + button and Windows controls. Overflow goes behind More + instead of creating a horizontally scrollable toolbar. */} +
+ {topActivityLayout.visibleItems.map((item) => ( + setRightSidebarTab(item.id)} + layout="top" + statusIndicator={item.id === 'checks' ? checksStatus : null} + /> + ))} +
+ {topActivityLayout.overflowItems.length > 0 && ( + + )}
@@ -280,7 +305,7 @@ function RightSidebarInner(): React.JSX.Element {
- {activityBarIcons} + {sideActivityBarIcons}
= { - success: 'bg-emerald-500', - failure: 'bg-rose-500', - pending: 'bg-amber-500', - neutral: 'bg-muted-foreground' -} +function useMeasuredWidth(onWidth: (width: number | null) => void) { + const observerRef = React.useRef(null) -// ─── Activity Bar Button (shared for top + side) ────── -function ActivityBarButton({ - item, - active, - onClick, - layout, - statusIndicator -}: { - item: ActivityBarItem - active: boolean - onClick: () => void - layout: 'top' | 'side' - statusIndicator?: CheckStatus | null -}): React.JSX.Element { - const Icon = item.icon - const isTop = layout === 'top' + return React.useCallback( + (node: HTMLDivElement | null) => { + observerRef.current?.disconnect() + observerRef.current = null - return ( - - - - - - {item.shortcut ? `${item.title} (${item.shortcut})` : item.title} - - + const updateWidth = (): void => { + onWidth(node.getBoundingClientRect().width) + } + updateWidth() + const observer = new ResizeObserver(updateWidth) + observer.observe(node) + observerRef.current = observer + }, + [onWidth] ) }