Files
clash-verge-rev/src/components/proxy/proxy-groups.tsx
T
wonfen 4e5ac8683b feat(proxy): confirm before enabling chain proxy
Show the speed warning with explicit enable/keep-disabled actions, and add a close button to the empty chain panel.
2026-10-19 20:01:43 +08:00

664 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 { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
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()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxyGroups] Failed to sync tray latency:', error)
})
}
}),
)
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} />
)
}
}