Files
clash-verge-rev/src/components/proxy/proxy-groups.tsx
T
wonfen 5e6a696087 revert(tray): revert per-group latency testing (#8173)
This reverts commit cb751052a4.

Remove the tray latency test and its dashboard URL persistence and cache
changes because the two entry points have diverging behavior.

Preserve the subsequent IPC binding migration and remove generated fields,
input projections, and locale entries that depended on the reverted feature.
2026-10-21 05:37:18 +08:00

656 lines
18 KiB
TypeScript

import { DragDropProvider } from '@dnd-kit/react'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { useLockFn } from 'ahooks'
import { throttle } from 'lodash-es'
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
BaseEmpty,
BaseLoading,
StickyVirtualList,
type StickyVirtualListHandle,
} from '@/components/base'
import { useProfiles } from '@/hooks/use-profiles'
import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge'
import { useProxiesData, useSystemData } from '@/providers/app-data-context'
import delayManager from '@/services/delay'
import {
isInteractableMember,
resolveMember,
type ProxyGroupView,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { ProxyEmptyState } from './proxy-empty-state'
import {
resolveEmptyListReason,
resolveProxyListState,
} from './proxy-empty-state-model'
import {
DEFAULT_HOVER_DELAY,
ProxyGroupNavigator,
} from './proxy-group-navigator'
import { ProxyRender } from './proxy-render'
import type { HeadState } from './use-head-state'
import {
PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout,
} from './use-proxy-group-header-layout'
import {
hasRenderableItems,
type IRenderItem,
useRenderList,
} from './use-render-list'
const ProxyGroupsChain = lazy(() =>
import('./proxy-groups-chain').then((m) => ({
default: m.ProxyGroupsChain,
})),
)
function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
const ref = useRef(fn)
ref.current = fn
return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T
}
interface Props {
mode: string
isChainMode?: boolean
chainConfigData?: string | null
onExitChainMode: () => void
}
function useEmptyRenderList() {
const { isProxyViewError } = useProxiesData()
const { runningMode } = useSystemData()
return (
<ProxyEmptyState
reason={resolveEmptyListReason({ runningMode, isProxyViewError })}
/>
)
}
function useProxyRenderState(
mode: string,
isChainMode: boolean,
activeSelectedGroup: string | null,
) {
const { verge } = useVerge()
const { proxyView } = useProxiesData()
const {
renderList,
onProxies,
onHeadState: setListHeadState,
} = useRenderList(mode, isChainMode, activeSelectedGroup)
const scrollPositionKey = useMemo(
() =>
isChainMode
? `${mode}:chain:${activeSelectedGroup ?? 'all'}`
: `${mode}:normal`,
[activeSelectedGroup, isChainMode, mode],
)
const onHeadState = useCallback(
(groupName: string, patch: Partial<HeadState>) => {
if (patch.open) onProxies()
setListHeadState(groupName, patch)
},
[onProxies, setListHeadState],
)
const timeout = verge?.default_latency_timeout || 10000
const handleCheckAll = useStableCallback(
useLockFn(async (groupName: string) => {
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
const group =
proxyView?.groups.find(({ name }) => name === groupName) ??
(proxyView?.global?.name === groupName ? proxyView.global : undefined)
const occurrences =
proxyView && group
? group.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(proxyView, member),
}))
: []
const interactable = occurrences
.map(({ member }) => member)
.filter(isInteractableMember)
debugLog(`[ProxyGroups] 找到代理数量: ${interactable.length}`)
const url = delayManager.getUrl(groupName)
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`)
try {
await delayManager.checkListDelay(interactable, groupName, timeout)
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`)
} catch (error) {
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally {
onProxies()
}
}),
)
const saveScrollPosition = useCallback(
(scrollTop: number) => {
const scrollPositions = localStorage.getItem('proxy-scroll-positions')
? JSON.parse(localStorage.getItem('proxy-scroll-positions') ?? '{}')
: {}
scrollPositions[scrollPositionKey] = scrollTop
try {
localStorage.setItem(
'proxy-scroll-positions',
JSON.stringify(scrollPositions),
)
} catch (e) {
console.error('Error saving scroll position:', e)
}
},
[scrollPositionKey],
)
const getScrollPosition = useCallback(() => {
try {
const savedPositions = localStorage.getItem('proxy-scroll-positions')
if (savedPositions) {
const positions = JSON.parse(savedPositions)
const savedPosition = positions[scrollPositionKey]
return savedPosition ?? 0
}
} catch (e) {
console.error('Error restoring scroll position:', e)
}
}, [scrollPositionKey])
return {
verge,
renderList,
onProxies,
onHeadState,
handleCheckAll,
saveScrollPosition,
getScrollPosition,
}
}
function ChainProxyGroups(props: {
mode: string
chainConfigData?: string | null
onExitChainMode: () => void
}) {
const { mode, chainConfigData, onExitChainMode } = props
const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
const availableGroups = useMemo(() => {
const groups = proxyView?.groups
if (!groups) return []
return groups.filter(
(group) => group.type === 'Selector' || group.type === 'URLTest',
)
}, [proxyView?.groups])
const defaultRuleGroup = useMemo(() => {
if (mode === 'rule' && availableGroups.length > 0) {
return availableGroups[0].name
}
return null
}, [availableGroups, mode])
const activeSelectedGroup = selectedGroup ?? defaultRuleGroup
const {
renderList,
onHeadState,
handleCheckAll,
getScrollPosition,
saveScrollPosition,
} = useProxyRenderState(mode, true, activeSelectedGroup)
const emptyList = useEmptyRenderList()
const parentRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef(0)
const showScrollTopRef = useRef(false)
const activeStickyIndexRef = useRef<number | null>(null)
const [showScrollTop, setShowScrollTop] = useState(false)
const stickyGroupIndexes = useMemo(
() =>
renderList.flatMap((item, index) =>
item.type === 0 && !item.group.hidden ? [index] : [],
),
[renderList],
)
const rangeExtractor = useCallback(
(range: Parameters<typeof defaultRangeExtractor>[0]) => {
const activeStickyIndex = [...stickyGroupIndexes]
.reverse()
.find((index) => index <= range.startIndex)
activeStickyIndexRef.current = activeStickyIndex ?? null
const indexes = defaultRangeExtractor(range)
return activeStickyIndex == null || indexes.includes(activeStickyIndex)
? indexes
: [activeStickyIndex, ...indexes]
},
[stickyGroupIndexes],
)
const virtualizer = useVirtualizer({
count: renderList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 15,
getItemKey: (index) => renderList[index]?.key ?? index,
rangeExtractor,
})
const virtualItems = virtualizer.getVirtualItems()
const activeStickyIndex = activeStickyIndexRef.current
useLayoutEffect(() => {
if (renderList.length === 0) return
const node = parentRef.current
if (!node) return
const savedPosition = getScrollPosition()
if (savedPosition !== undefined) {
node.scrollTop = savedPosition
scrollTopRef.current = savedPosition
const nextShowScrollTop = savedPosition > 100
showScrollTopRef.current = nextShowScrollTop
queueMicrotask(() => setShowScrollTop(nextShowScrollTop))
}
}, [renderList.length, getScrollPosition])
const saveScrollPositionThrottled = useMemo(
() => throttle(saveScrollPosition, 500),
[saveScrollPosition],
)
const handleScroll = useCallback(
(event: Event) => {
const target = event.target as HTMLElement | null
const nextScrollTop = target?.scrollTop ?? 0
const nextShowScrollTop = nextScrollTop > 100
scrollTopRef.current = nextScrollTop
if (showScrollTopRef.current !== nextShowScrollTop) {
showScrollTopRef.current = nextShowScrollTop
setShowScrollTop(nextShowScrollTop)
}
saveScrollPositionThrottled(nextScrollTop)
},
[saveScrollPositionThrottled],
)
useEffect(() => {
const node = parentRef.current
if (!node) return
const listener = handleScroll as EventListener
const options: AddEventListenerOptions = { passive: true }
node.addEventListener('scroll', listener, options)
return () => {
saveScrollPosition(scrollTopRef.current)
node.removeEventListener('scroll', listener, options)
}
}, [handleScroll, saveScrollPosition])
const scrollToTop = useCallback(() => {
parentRef.current?.scrollTo?.({
top: 0,
behavior: 'smooth',
})
scrollTopRef.current = 0
}, [])
const handleLocation = useStableCallback((group: ProxyGroupView) => {
if (!group) return
const { name, now } = group
const index = renderList.findIndex(
(item) =>
item.group?.name === name &&
((item.type === 2 && item.member?.member.ref.name === now) ||
(item.type === 4 &&
item.memberCol?.some(({ member }) => member.ref.name === now))),
)
if (index >= 0) {
virtualizer.scrollToIndex(index, {
align: 'center',
behavior: 'smooth',
})
}
})
if (!hasRenderableItems(renderList)) return emptyList
return (
<Suspense fallback={<BaseLoading />}>
<ProxyGroupsChain
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
availableGroups={availableGroups}
activeSelectedGroup={activeSelectedGroup}
showScrollTop={showScrollTop}
parentRef={parentRef}
totalSize={virtualizer.getTotalSize()}
virtualItems={virtualItems}
renderList={renderList}
activeStickyIndex={activeStickyIndex}
measureElement={virtualizer.measureElement}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onLocation={handleLocation}
onGroupSelect={setSelectedGroup}
onScrollToTop={scrollToTop}
/>
</Suspense>
)
}
function NormalProxyGroups(props: { mode: string }) {
const { mode } = props
const stickyListRef = useRef<StickyVirtualListHandle>(null)
const {
verge,
renderList,
onProxies,
onHeadState,
handleCheckAll,
getScrollPosition,
saveScrollPosition,
} = useProxyRenderState(mode, false, null)
const emptyList = useEmptyRenderList()
const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout()
const renderFirstRef = useRef(true)
// Do not persist intermediate positions produced while restoring virtual scroll.
const isRestoringRef = useRef(false)
useLayoutEffect(() => {
if (renderList.length === 0) return
if (!renderFirstRef.current) return
const node = stickyListRef.current?.getScrollElement()
if (!node) return
const savedPosition = getScrollPosition()
if (!savedPosition) {
renderFirstRef.current = false
return
}
// Retry across frames until virtual-list measurements can reach the saved offset.
isRestoringRef.current = true
let rafId = 0
let attempts = 0
const maxAttempts = 30
const step = () => {
const el = stickyListRef.current?.getScrollElement()
if (!el) {
isRestoringRef.current = false
return
}
el.scrollTop = savedPosition
attempts += 1
const reached = Math.abs(el.scrollTop - savedPosition) <= 1
if (reached || attempts >= maxAttempts) {
renderFirstRef.current = false
isRestoringRef.current = false
return
}
rafId = requestAnimationFrame(step)
}
rafId = requestAnimationFrame(step)
return () => {
cancelAnimationFrame(rafId)
isRestoringRef.current = false
}
}, [renderList.length, getScrollPosition])
const saveScrollPositionThrottled = useMemo(
() => throttle(saveScrollPosition, 500),
[saveScrollPosition],
)
const handleScroll = useCallback(
(event: Event) => {
if (isRestoringRef.current) return
const target = event.target as HTMLElement | null
const nextScrollTop = target?.scrollTop ?? 0
saveScrollPositionThrottled(nextScrollTop)
},
[saveScrollPositionThrottled],
)
useEffect(() => {
const node = stickyListRef.current?.getScrollElement()
if (!node) return
const listener = handleScroll as EventListener
const options: AddEventListenerOptions = { passive: true }
node.addEventListener('scroll', listener, options)
return () => {
node.removeEventListener('scroll', listener, options)
}
}, [handleScroll])
const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => {
onProxies()
},
onError: (error) => {
console.error('代理切换失败', error)
onProxies()
},
})
const handleChangeProxy = useCallback(
(group: ProxyGroupView, member: ResolvedProxyMember) => {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return
handleProxyGroupChange(group, { name: member.ref.name })
},
[handleProxyGroupChange],
)
const handleLocation = useStableCallback((group: ProxyGroupView) => {
if (!group) return
const { name, now } = group
const index = renderList.findIndex(
(e) =>
e.group?.name === name &&
((e.type === 2 && e.member?.member.ref.name === now) ||
(e.type === 4 &&
e.memberCol?.some(({ member }) => member.ref.name === now))),
)
if (index >= 0) {
stickyListRef.current?.scrollToIndex(index, {
align: 'center',
behavior: 'smooth',
})
}
})
const handleGroupLocationByName = useCallback(
(groupName: string) => {
const index = renderList.findIndex(
(item) => item.type === 0 && item.group?.name === groupName,
)
if (index >= 0) {
stickyListRef.current?.scrollToIndex(index, {
align: 'start',
behavior: 'smooth',
})
}
},
[renderList],
)
const proxyGroupNames = useMemo(() => {
const names = renderList
.filter((item) => item.type === 0 && item.group?.name)
.map((item) => item.group!.name)
return Array.from(new Set(names))
}, [renderList])
const handleGroupToggle = useCallback(
async (group: ProxyGroupView) => {
const index = renderList.findIndex(
(item) => item.type === 0 && item.group.name === group.name,
)
if (index < 0) return
if (!stickyListRef.current?.isItemScrolledPastStart(index, 1)) return
stickyListRef.current.scrollToIndex(index, {
align: 'start',
behavior: 'auto',
})
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
},
[renderList],
)
const renderGroupItem = useCallback(
(item: IRenderItem, _index: number, stickyed: boolean) => (
<ProxyRender
item={item}
stickyed={stickyed}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={async (groupName, patch) => {
if (stickyed && patch.filterText !== undefined) {
handleGroupLocationByName(groupName)
await stickyListRef.current?.waitForScrollEnd()
}
onHeadState(groupName, patch)
}}
onChangeProxy={handleChangeProxy}
onGroupToggle={handleGroupToggle}
/>
),
[
handleChangeProxy,
handleCheckAll,
onHeadState,
handleLocation,
handleGroupToggle,
handleGroupLocationByName,
],
)
const renderProxyItem = useCallback(
(item: IRenderItem) => (
<ProxyRender
key={item.key}
item={item}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
),
[handleChangeProxy, handleCheckAll, onHeadState, handleLocation],
)
if (!hasRenderableItems(renderList)) return emptyList
return (
<div style={{ position: 'relative', height: '100%' }}>
<DragDropProvider
sensors={PROXY_GROUP_HEADER_SENSORS}
onDragEnd={onHeaderDragEnd}
>
<StickyVirtualList
ref={stickyListRef}
items={renderList}
isGroupItem={(item) => item.type === 0}
getItemKey={(item) => item.key}
estimateGroupItemHeight={76}
estimateItemHeight={64}
renderGroupItem={renderGroupItem}
renderItem={renderProxyItem}
/>
</DragDropProvider>
{mode === 'rule' && (
<ProxyGroupNavigator
proxyGroupNames={proxyGroupNames}
onGroupLocation={handleGroupLocationByName}
enableHoverJump={verge?.enable_hover_jump_navigator ?? true}
hoverDelay={verge?.hover_jump_navigator_delay ?? DEFAULT_HOVER_DELAY}
/>
)}
</div>
)
}
export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData, onExitChainMode } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { isProxyViewPending } = useProxiesData()
const { isRunningModePending } = useSystemData()
const listState = resolveProxyListState({
mode,
profiles,
isProfilesPending: !profiles && isProfilesLoading,
isProxyViewPending,
isRunningModePending,
})
switch (listState.kind) {
case 'direct':
return <BaseEmpty textKey="proxies.page.messages.directMode" />
case 'loading':
return <BaseLoading />
case 'empty':
return <ProxyEmptyState reason={listState.reason} />
case 'render':
return isChainMode ? (
<ChainProxyGroups
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
/>
) : (
<NormalProxyGroups mode={mode} />
)
}
}