Files
clash-verge-rev/src/components/proxy/proxy-groups.tsx
T
Tunglies 05a6e9a64f fix(proxy): unify node switching and connection cleanup (#8592)
Route frontend and tray node selections through the same backend flow for
selection, persistence, state refresh, and connection cleanup. Read the
group's previous selection before switching and preserve the original
rule of closing only connections whose chains contain that selection.

Run optional cleanup in the background and keep its failures separate
from successful selection. Remove redundant frontend effects and unused
IPC commands, and refresh the proxy view for every successful switch.

Refs: 32095daf90
Refs: bea0dde074
2026-11-09 14:28:44 +00:00

679 lines
19 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 type { ProxyChainItem } from './proxy-chain-model'
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
chainConfigItems?: ProxyChainItem[] | 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,
setHeadState: setListHeadState,
handleCheckAll,
saveScrollPosition,
getScrollPosition,
}
}
function ChainProxyGroups(props: {
mode: string
chainConfigItems?: ProxyChainItem[] | null
onExitChainMode: () => void
}) {
const { mode, chainConfigItems, 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}
chainConfigItems={chainConfigItems}
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,
setHeadState,
handleCheckAll,
getScrollPosition,
saveScrollPosition,
} = useProxyRenderState(mode, false, null)
const emptyList = useEmptyRenderList()
const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout()
const { proxyView } = useProxiesData()
const globalGroupName = proxyView?.global?.name
// The revealed group sits below GLOBAL's rows, so a click must bring it into view.
const pendingRevealRef = useRef<string | null>(null)
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 { select } = useProxySelection({
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
if (group.name === globalGroupName) {
pendingRevealRef.current =
member.kind === 'group' ? member.ref.name : null
// The backend selection event refreshes the view.
if (member.kind === 'group') {
setHeadState(member.ref.name, { open: true })
}
}
select(group.name, member.ref.name, group.fixed!)
},
[globalGroupName, select, setHeadState],
)
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])
useEffect(() => {
if (mode !== 'global') {
pendingRevealRef.current = null
return
}
const groupName = pendingRevealRef.current
if (!groupName || !proxyGroupNames.includes(groupName)) return
pendingRevealRef.current = null
handleGroupLocationByName(groupName)
}, [handleGroupLocationByName, mode, proxyGroupNames])
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, chainConfigItems, 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}
chainConfigItems={chainConfigItems}
onExitChainMode={onExitChainMode}
/>
) : (
<NormalProxyGroups mode={mode} />
)
}
}