Fix right sidebar activity overflow (#2343)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
8b6f666de5
commit
fbb4db5479
|
|
@ -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<CheckStatus, string> = {
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex h-[36px] w-8 shrink-0 items-center justify-center text-muted-foreground/60 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label="More sidebar tabs"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
{hiddenChecksStatus && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-[8px] right-[4px] size-[7px] rounded-full ring-1 ring-sidebar',
|
||||
STATUS_DOT_COLOR[hiddenChecksStatus] ?? 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" sideOffset={6}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = item.id === activeTab
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
className={cn(active && 'bg-accent text-accent-foreground')}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{item.title}</span>
|
||||
{item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative flex shrink-0 items-center justify-center transition-colors right-sidebar-header-no-drag',
|
||||
isTop ? 'h-[36px] w-9' : 'w-10 h-10',
|
||||
active ? 'text-foreground' : 'text-muted-foreground/60 hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-label={item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
|
||||
>
|
||||
<Icon size={isTop ? 16 : 18} />
|
||||
|
||||
{statusIndicator && statusIndicator !== 'neutral' && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full size-[7px] ring-1 ring-sidebar',
|
||||
isTop ? 'top-[8px] right-[5px]' : 'top-[7px] right-[7px]',
|
||||
STATUS_DOT_COLOR[statusIndicator] ?? 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{active && isTop && (
|
||||
<div className="absolute bottom-0 left-[25%] right-[25%] h-[2px] bg-foreground rounded-t" />
|
||||
)}
|
||||
{active && !isTop && (
|
||||
<div className="absolute right-0 top-[25%] bottom-[25%] w-[2px] bg-foreground rounded-l" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={isTop ? 'bottom' : 'left'} sideOffset={6}>
|
||||
{item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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<T extends { id: RightSidebarTab }>(
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof useAppStore.getState>):
|
|||
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<number | null>(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 = (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
|
||||
|
|
@ -177,13 +175,18 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
</div>
|
||||
)
|
||||
|
||||
const activityBarIcons = visibleItems.map((item) => (
|
||||
const topActivityLayout = useMemo(
|
||||
() => getTopActivityBarLayout(visibleItems, topActivityStripWidth, effectiveTab),
|
||||
[visibleItems, topActivityStripWidth, effectiveTab]
|
||||
)
|
||||
|
||||
const sideActivityBarIcons = visibleItems.map((item) => (
|
||||
<ActivityBarButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={effectiveTab === item.id}
|
||||
onClick={() => setRightSidebarTab(item.id)}
|
||||
layout={activityBarPosition}
|
||||
layout="side"
|
||||
statusIndicator={item.id === 'checks' ? checksStatus : null}
|
||||
/>
|
||||
))
|
||||
|
|
@ -232,11 +235,33 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
<div className="flex items-center border-b border-border h-[36px] min-h-[36px] pl-2 pr-1 right-sidebar-header-inset right-sidebar-header-drag overflow-hidden">
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="right-sidebar-activity-strip flex min-w-0 flex-1 items-center overflow-x-auto overflow-y-hidden scrollbar-sleek right-sidebar-header-no-drag">
|
||||
{/* 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. */}
|
||||
<div className="flex shrink-0">{activityBarIcons}</div>
|
||||
<div
|
||||
ref={topActivityStripRef}
|
||||
className="right-sidebar-activity-strip flex min-w-0 flex-1 items-center overflow-hidden right-sidebar-header-no-drag"
|
||||
>
|
||||
{/* 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. */}
|
||||
<div className="flex min-w-0 shrink">
|
||||
{topActivityLayout.visibleItems.map((item) => (
|
||||
<ActivityBarButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={effectiveTab === item.id}
|
||||
onClick={() => setRightSidebarTab(item.id)}
|
||||
layout="top"
|
||||
statusIndicator={item.id === 'checks' ? checksStatus : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{topActivityLayout.overflowItems.length > 0 && (
|
||||
<TopActivityOverflowMenu
|
||||
items={topActivityLayout.overflowItems}
|
||||
activeTab={effectiveTab}
|
||||
onSelect={setRightSidebarTab}
|
||||
checksStatus={checksStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<div className="flex shrink-0 items-center right-sidebar-header-no-drag">
|
||||
|
|
@ -280,7 +305,7 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border side-activity-bar-windows-inset">
|
||||
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
|
||||
<TooltipProvider delayDuration={400}>{sideActivityBarIcons}</TooltipProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ActivityBarPositionMenu
|
||||
|
|
@ -320,69 +345,28 @@ function computeMaxRightSidebarWidth(): number {
|
|||
return Math.max(MIN_WIDTH, window.innerWidth - MIN_NON_SIDEBAR_AREA)
|
||||
}
|
||||
|
||||
// ─── Status indicator dot color mapping ──────
|
||||
const STATUS_DOT_COLOR: Record<CheckStatus, string> = {
|
||||
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<ResizeObserver | null>(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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex items-center justify-center transition-colors right-sidebar-header-no-drag',
|
||||
isTop ? 'h-[36px] w-9' : 'w-10 h-10',
|
||||
active ? 'text-foreground' : 'text-muted-foreground/60 hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-label={item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
|
||||
>
|
||||
<Icon size={isTop ? 16 : 18} />
|
||||
if (!node || typeof ResizeObserver === 'undefined') {
|
||||
onWidth(node ? node.getBoundingClientRect().width : null)
|
||||
return
|
||||
}
|
||||
|
||||
{/* Status indicator dot */}
|
||||
{statusIndicator && statusIndicator !== 'neutral' && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full size-[7px] ring-1 ring-sidebar',
|
||||
isTop ? 'top-[8px] right-[5px]' : 'top-[7px] right-[7px]',
|
||||
STATUS_DOT_COLOR[statusIndicator] ?? 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Active indicator */}
|
||||
{active && isTop && (
|
||||
<div className="absolute bottom-0 left-[25%] right-[25%] h-[2px] bg-foreground rounded-t" />
|
||||
)}
|
||||
{active && !isTop && (
|
||||
<div className="absolute right-0 top-[25%] bottom-[25%] w-[2px] bg-foreground rounded-l" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={isTop ? 'bottom' : 'left'} sideOffset={6}>
|
||||
{item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
const updateWidth = (): void => {
|
||||
onWidth(node.getBoundingClientRect().width)
|
||||
}
|
||||
updateWidth()
|
||||
const observer = new ResizeObserver(updateWidth)
|
||||
observer.observe(node)
|
||||
observerRef.current = observer
|
||||
},
|
||||
[onWidth]
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue