feat: sticky groups (#7195)

* feat(proxy): implement sticky virtual list and group tools

- Add new StickyVirtualList component for better performance with sticky headers
- Create ProxyGroupTools component for filtering, sorting and testing proxy groups
- Enhance proxy groups with improved scrolling and group toggle functionality
- Integrate search box with click event handling
- Remove redundant ProxyHead component usage

* style: remove unused import and update TypeScript types

* refactor: convert function component to forwardRef component to fix biome lint error

* feat(proxy): extract proxy chain UI to separate component

* refactor: remove custom throttle implementation and simplify dependencies

* feat(proxy): improve proxy groups UI and scrolling behavior

- Add initialOffset prop to StickyVirtualList for better scroll position restoration
- Refactor ProxyGroups component to separate chain and normal modes
- Implement scroll position persistence for both chain and normal modes
- Add sticky shadow effect for expanded proxy groups
- Remove unnecessary ScrollTopButton component
- Optimize scroll event handling with throttling
- Improve code organization with custom hooks and component separation

* docs(proxy-groups): Add TODO comment for initial render position issue

* feat(proxy): improve scroll position handling and UI display

* refactor(proxy): simplify scroll handling logic and remove unused code

* fix(sticky-virtual-list): remove duplicate styles and add padding

* refactor(proxy): rename scroll offset variables and functions for clarity

* refactor: remove unnecessary refs and simplify onClick handlers

* fix(proxy-groups): handle initial scroll position state

* feat(zed): add biome formatter configuration

* refactor(proxy): remove unused headState import and type annotations

* style(proxy): improve type imports and conditional rendering

* .

* feat(proxy): add ProxyHead component to render in global mode

* chore: ignore .zed directory

* docs: update changelog
This commit is contained in:
oomeow
2026-07-02 13:22:40 +00:00
committed by GitHub
parent c2ddb5c111
commit dd3ef698f5
13 changed files with 1593 additions and 769 deletions
@@ -1,5 +1,4 @@
import { Box, CircularProgress } from '@mui/material'
import React from 'react'
interface BaseLoadingOverlayProps {
isLoading: boolean
+5 -1
View File
@@ -2,7 +2,8 @@ import { ClearRounded } from '@mui/icons-material'
import { Box, SvgIcon, TextField, styled, IconButton } from '@mui/material'
import Tooltip from '@mui/material/Tooltip'
import {
ChangeEvent,
type ChangeEvent,
type MouseEvent,
useCallback,
useEffect,
useMemo,
@@ -35,6 +36,7 @@ type SearchProps = {
useRegularExpression?: boolean
searchState?: Partial<SearchOptionState>
onSearch: (match: (content: string) => boolean, state: SearchState) => void
onClick?: (e: MouseEvent<HTMLDivElement>) => void
}
const StyledTextField = styled(TextField)(({ theme }) => ({
@@ -80,6 +82,7 @@ export const BaseSearchBox = ({
matchWholeWord: defaultMatchWholeWord = false,
useRegularExpression: defaultUseRegularExpression = false,
onSearch,
onClick,
}: SearchProps) => {
const { t } = useTranslation()
const onSearchRef = useRef(onSearch)
@@ -199,6 +202,7 @@ export const BaseSearchBox = ({
placeholder={placeholder ?? t('shared.placeholders.filter')}
sx={{ input: { py: 0.65, px: 1.25 } }}
value={text}
onClick={onClick}
onChange={handleChangeText}
error={!!effectiveErrorMessage}
slotProps={{
+4
View File
@@ -16,3 +16,7 @@ export { MonacoEditor } from './monaco-editor'
export { Switch } from './base-switch'
export { TooltipIcon } from './base-tooltip-icon'
export { VirtualList, type VirtualListHandle } from './virtual-list'
export {
StickyVirtualList,
type StickyVirtualListHandle,
} from './sticky-virtual-list'
+301
View File
@@ -0,0 +1,301 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import {
forwardRef,
type ReactNode,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
type ScrollToIndexOptions = {
align?: 'auto' | 'center' | 'end' | 'start'
behavior?: ScrollBehavior
}
const findGroupSectionIndex = (groupIndexes: number[], itemIndex: number) => {
let low = 0
let high = groupIndexes.length - 1
let matchedIndex = -1
while (low <= high) {
const middle = Math.floor((low + high) / 2)
if (groupIndexes[middle] <= itemIndex) {
matchedIndex = middle
low = middle + 1
} else {
high = middle - 1
}
}
return matchedIndex
}
export interface StickyVirtualListHandle {
getScrollElement: () => HTMLDivElement | null
isItemScrolledPastStart: (index: number, tolerance?: number) => boolean
scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void
isScrolling: () => boolean
waitForScrollEnd: () => Promise<void>
}
export interface StickyVirtualListProps<TItem> {
initialOffset?: number
items: TItem[]
isGroupItem: (item: TItem, index: number) => boolean
getItemKey: (item: TItem, index: number) => React.Key
// 组项预估高度
estimateGroupItemHeight: number
// 非组项预估高度
estimateItemHeight: number
renderGroupItem: (item: TItem, index: number, stickyed: boolean) => ReactNode
renderItem: (item: TItem, index: number) => ReactNode
className?: string
style?: React.CSSProperties
overscan?: number
}
export const StickyVirtualList = forwardRef(function StickyVirtualListInner<
TItem,
>(
props: StickyVirtualListProps<TItem>,
ref: React.ForwardedRef<StickyVirtualListHandle>,
) {
const {
initialOffset = 0,
items,
isGroupItem,
getItemKey,
estimateGroupItemHeight,
estimateItemHeight,
renderGroupItem,
renderItem,
className,
style,
overscan = 8,
} = props
const scrollParentRef = useRef<HTMLDivElement>(null)
const getEstimatedItemHeight = useCallback(
(index: number) =>
isGroupItem(items[index], index)
? estimateGroupItemHeight
: estimateItemHeight,
[estimateGroupItemHeight, estimateItemHeight, isGroupItem, items],
)
const groupIndexes = useMemo(
() =>
items.reduce<number[]>((indexes, item, index) => {
if (isGroupItem(item, index)) indexes.push(index)
return indexes
}, []),
[isGroupItem, items],
)
const groupSections = useMemo(
() =>
groupIndexes.map((groupIndex, index) => ({
groupIndex,
nextGroupIndex: groupIndexes[index + 1] ?? items.length,
})),
[groupIndexes, items.length],
)
const estimatedOffsets = useMemo(() => {
const offsets = new Array<number>(items.length + 1)
offsets[0] = 0
for (let i = 0; i < items.length; i++) {
offsets[i + 1] = offsets[i] + getEstimatedItemHeight(i)
}
return offsets
}, [getEstimatedItemHeight, items.length])
const rowVirtualizer = useVirtualizer({
initialOffset,
count: items.length,
estimateSize: getEstimatedItemHeight,
getItemKey: (index) => getItemKey(items[index], index),
getScrollElement: () => scrollParentRef.current,
overscan,
})
const virtualItems = rowVirtualizer.getVirtualItems()
const getVirtualOffset = useCallback(
(index: number) => {
return (
rowVirtualizer.measurementsCache[index]?.start ??
estimatedOffsets[index]
)
},
[estimatedOffsets, rowVirtualizer],
)
const visibleGroupSections = useMemo(() => {
if (!virtualItems.length || !groupSections.length) return []
const firstVirtualIndex = virtualItems[0].index
const lastVirtualIndex = virtualItems[virtualItems.length - 1].index
const matchedFirstSectionIndex = findGroupSectionIndex(
groupIndexes,
firstVirtualIndex,
)
const lastSectionIndex = findGroupSectionIndex(
groupIndexes,
lastVirtualIndex,
)
if (lastSectionIndex < 0) return []
const firstSectionIndex =
matchedFirstSectionIndex >= 0 ? matchedFirstSectionIndex : 0
return groupSections.slice(
firstSectionIndex,
Math.min(lastSectionIndex + 2, groupSections.length),
)
}, [groupIndexes, groupSections, virtualItems])
const isGroupSticky = useCallback(
(groupIndex: number, tolerance = 0) => {
const scroller = scrollParentRef.current
if (!scroller) return false
return scroller.scrollTop > getVirtualOffset(groupIndex) + tolerance
},
[getVirtualOffset],
)
useImperativeHandle(
ref,
() => ({
getScrollElement: () => scrollParentRef.current,
isItemScrolledPastStart: (index, tolerance = 0) => {
return isGroupSticky(index, tolerance)
},
scrollToIndex: (index, options) => {
rowVirtualizer.scrollToIndex(index, options)
},
isScrolling: () => rowVirtualizer.isScrolling,
waitForScrollEnd: () => {
return new Promise((resolve) => {
let maxCheckCount = 5
const interval = setInterval(() => {
if (!rowVirtualizer.isScrolling || maxCheckCount < 0) {
clearInterval(interval)
resolve()
return
}
maxCheckCount -= 1
}, 100)
})
},
}),
[isGroupSticky, rowVirtualizer],
)
return (
<div
ref={scrollParentRef}
className={className}
style={{
overflowY: 'auto',
contain: 'strict',
width: '100%',
height: '100%',
overflowAnchor: 'none',
...style,
}}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
width: '100%',
paddingBottom: 10,
}}
>
<div
style={{
inset: 0,
pointerEvents: 'none',
position: 'absolute',
zIndex: 10,
}}
>
{visibleGroupSections.map(({ groupIndex, nextGroupIndex }) => {
const group = items[groupIndex]
const start = getVirtualOffset(groupIndex)
const end =
nextGroupIndex < items.length
? getVirtualOffset(nextGroupIndex)
: rowVirtualizer.getTotalSize()
const stickyed = isGroupSticky(groupIndex, 1)
return (
<div
key={getItemKey(group, groupIndex)}
style={{
position: 'absolute',
top: start,
left: 0,
width: '100%',
height: Math.max(end - start, estimateGroupItemHeight),
}}
>
<div
data-index={groupIndex}
style={{
pointerEvents: 'auto',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
{renderGroupItem(group, groupIndex, stickyed)}
</div>
</div>
)
})}
</div>
{virtualItems.map((virtualRow) => {
const item = items[virtualRow.index]
const isGroup = isGroupItem(item, virtualRow.index)
return (
<div
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
style={{
left: 0,
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
width: '100%',
zIndex: 1,
...(isGroup && {
opacity: 0,
zIndex: -10,
}),
}}
>
{isGroup
? renderGroupItem(item, virtualRow.index, false) // 渲染组,以便动态计算组高度
: renderItem(item, virtualRow.index)}
</div>
)
})}
</div>
</div>
)
}) as <TItem>(
props: StickyVirtualListProps<TItem> & {
ref?: React.Ref<StickyVirtualListHandle>
},
) => React.ReactElement