Files
clash-verge-rev/src/components/proxy/use-render-list.ts
T
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* refactor(ipc): generate bindings with Specta

* fix(profile): project IPC inputs to consumed fields

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

* fix(proxy): confirm runtime chain updates before UI changes

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
2026-10-12 06:16:05 +00:00

378 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef } from 'react'
import { useRuntimeConfig } from '@/hooks/use-clash'
import { useGroupsDelays } from '@/hooks/use-group-delays'
import { useVerge } from '@/hooks/use-verge'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import delayManager, { type DelaySnapshot } from '@/services/delay'
import {
isInteractableMember,
resolveMember,
selectGlobalChainNodes,
selectRuleChainMembers,
type ProxyGroupView,
type ProxyViewV1,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { filterSort } from './use-filter-sort'
import {
DEFAULT_STATE,
useHeadStateNew,
type HeadState,
} from './use-head-state'
import { useWindowWidth } from './use-window-width'
export interface ResolvedMemberOccurrence {
memberIndex: number
member: ResolvedProxyMember
}
type ProxyGroup = ProxyGroupView
export interface IRenderItem {
type: 0 | 1 | 2 | 3 | 4
key: string
group: ProxyGroup
member?: ResolvedMemberOccurrence
memberCol?: ResolvedMemberOccurrence[]
col?: number
headState?: HeadState
icon?: string
testUrl?: string
}
/**
* Whether the list about to be drawn contains anything a user would call content.
*
* Derived from the list itself rather than predicted beside it. The prediction that used to
* live in the empty-state model asked a different question in chain mode — whether any
* Selector or URLTest group existed — which is unrelated to what the chain list is actually
* built from, so both a false "empty" and a false "not empty" were reachable.
*
* A group header alone counts only when the group is visible; every other row is content by
* definition, including the members of a group that is hidden but expanded.
*/
export const hasRenderableItems = (
renderList: readonly IRenderItem[],
): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden)
type GroupCache = {
now: string | undefined
members: ProxyGroupView['members']
headState: HeadState
col: number
latencyTimeout: number | undefined
/// This group's own delays. Compared by identity so that a test settling in one group
/// does not throw away every other group's sorted order.
delays: DelaySnapshot | undefined
items: IRenderItem[]
}
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
const resolveOccurrences = (view: ProxyViewV1, group: ProxyGroupView) =>
group.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(view, member),
}))
const memberKey = (
group: ProxyGroupView,
occurrence: ResolvedMemberOccurrence,
) => {
const { memberIndex, member } = occurrence
const identity =
member.kind === 'node' ? member.node.recordId : member.ref.name
return `${group.name}:${memberIndex}:${identity}`
}
const calculateColumns = (width: number, configCol: number): number => {
if (configCol > 0 && configCol < 6) return configCol
if (width > 1920) return 5
if (width > 1450) return 4
if (width > 1024) return 3
if (width >= 600) return 2
return 1
}
const groupOccurrences = <T>(list: T[], size: number): T[][] =>
list.reduce<T[][]>((acc, item) => {
const lastGroup = acc[acc.length - 1]
if (!lastGroup || lastGroup.length >= size) acc.push([item])
else lastGroup.push(item)
return acc
}, [])
const CHAIN_DELAY_GROUP = 'chain-mode'
const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
name: CHAIN_DELAY_GROUP,
type: 'Selector',
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
extra: {},
members,
})
export const useRenderList = (
mode: string,
isChainMode?: boolean,
selectedGroup?: string | null,
) => {
const { proxyView } = useProxiesData()
const { refreshProxy } = useAppRefreshers()
const { verge } = useVerge()
const { width } = useWindowWidth()
const [headStates, setHeadState] = useHeadStateNew()
const latencyTimeout = verge?.default_latency_timeout
const { data: runtimeConfig } = useRuntimeConfig(!!isChainMode)
const runtimeProxies = (
runtimeConfig as RuntimeConfigWithProxySequence | null
)?.proxies
const col = useMemo(
() => calculateColumns(width, verge?.proxy_layout_column || 6),
[width, verge?.proxy_layout_column],
)
const chainOccurrences = useMemo(() => {
if (!proxyView || !isChainMode) return []
if (mode === 'rule' && selectedGroup) {
return selectRuleChainMembers(proxyView, selectedGroup)
}
if (!runtimeConfig) return []
return selectGlobalChainNodes(proxyView, runtimeProxies).map(
(node, memberIndex) => ({
memberIndex,
member: {
kind: 'node' as const,
ref: {
kind: 'node' as const,
name: node.name,
recordId: node.recordId,
},
node,
},
}),
)
}, [
isChainMode,
mode,
proxyView,
runtimeConfig,
runtimeProxies,
selectedGroup,
])
const chainOccurrencesRef = useRef(chainOccurrences)
chainOccurrencesRef.current = chainOccurrences
const chainDelayGroup =
mode === 'rule' && selectedGroup ? selectedGroup : CHAIN_DELAY_GROUP
const chainDelayKey = chainOccurrences
.map(({ member }) => {
if (member.kind !== 'node') return `${member.kind}:${member.ref.name}`
const source = member.node.source
return source.kind === 'provider'
? `provider:${source.providerName}:${source.proxyName}`
: `core:${source.proxyName}`
})
.join('\u0000')
useEffect(() => {
if (!isChainMode || !chainDelayKey) return
const interactable = chainOccurrencesRef.current
.map(({ member }) => member)
.filter(isInteractableMember)
if (interactable.length === 0) return
const handle = setTimeout(() => {
const timeout = verge?.default_latency_timeout || 10000
debugLog(`[ChainMode] 开始计算 ${interactable.length} 个节点的延迟`)
void delayManager.checkListDelay(interactable, chainDelayGroup, timeout)
}, 100)
return () => {
clearTimeout(handle)
}
}, [
chainDelayGroup,
chainDelayKey,
isChainMode,
verge?.default_latency_timeout,
])
// Every group this list draws, so a test settling in any of them re-sorts that group.
const renderedGroupNames = useMemo(() => {
if (!proxyView) return []
if (isChainMode)
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
return mode === 'rule' || mode === 'script'
? proxyView.groups.map(({ name }) => name)
: proxyView.global
? [proxyView.global.name]
: []
}, [isChainMode, mode, proxyView, selectedGroup])
const groupDelays = useGroupsDelays(renderedGroupNames)
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
const prevListRef = useRef<IRenderItem[]>([])
const renderList = useMemo<IRenderItem[]>(() => {
if (!proxyView) return []
if (isChainMode) {
const selected =
mode === 'rule'
? proxyView.groups.find(({ name }) => name === selectedGroup)
: undefined
const group = selected ?? virtualGroup([])
const occurrences = filterSort(
chainOccurrences,
selected?.name ?? CHAIN_DELAY_GROUP,
'',
0,
latencyTimeout!,
)
if (col > 1) {
return groupOccurrences(occurrences, col).map((memberCol) => ({
type: 4,
key: `chain-col:${memberKey(group, memberCol[0])}`,
group,
headState: DEFAULT_STATE,
col,
memberCol,
}))
}
return occurrences.map((member) => ({
type: 2,
key: `chain:${memberKey(group, member)}`,
group,
member,
headState: DEFAULT_STATE,
}))
}
const useRule = mode === 'rule' || mode === 'script'
const renderGroups = useRule
? proxyView.groups
: proxyView.global === null
? []
: [proxyView.global]
const cache = groupCacheRef.current
let anyChanged = false
const retList = renderGroups.flatMap((group) => {
const headState = headStates[group.name] || DEFAULT_STATE
const cached = cache.get(group.name)
if (
cached &&
cached.now === group.now &&
cached.members === group.members &&
cached.headState === headState &&
cached.col === col &&
cached.latencyTimeout === latencyTimeout &&
cached.delays === groupDelays.get(group.name)
) {
return cached.items
}
anyChanged = true
const ret: IRenderItem[] = [
{
type: 0,
key: group.name,
group,
headState,
icon: group.icon!,
testUrl: group.testUrl!,
},
]
if (headState.open || !useRule) {
const occurrences = filterSort(
resolveOccurrences(proxyView, group),
group.name,
headState.filterText,
headState.sortType,
latencyTimeout!,
{
matchCase: headState.filterMatchCase,
matchWholeWord: headState.filterMatchWholeWord,
useRegularExpression: headState.filterUseRegularExpression,
},
)
if (!useRule) {
ret.push({ type: 1, key: `head-${group.name}`, group, headState })
}
if (occurrences.length === 0) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState })
} else if (col > 1) {
ret.push(
...groupOccurrences(occurrences, col).map((memberCol) => ({
type: 4 as const,
key: `col:${memberKey(group, memberCol[0])}`,
group,
headState,
col,
memberCol,
})),
)
} else {
ret.push(
...occurrences.map((member) => ({
type: 2 as const,
key: memberKey(group, member),
group,
member,
headState,
})),
)
}
}
cache.set(group.name, {
now: group.now!,
members: group.members,
headState,
col,
latencyTimeout: latencyTimeout!,
delays: groupDelays.get(group.name),
items: ret,
})
return ret
})
const filtered = !useRule
? retList.slice(1)
: retList.filter((item) => !item.group.hidden)
if (!anyChanged && prevListRef.current.length === filtered.length) {
return prevListRef.current
}
prevListRef.current = filtered
return filtered
}, [
chainOccurrences,
col,
groupDelays,
headStates,
isChainMode,
latencyTimeout,
mode,
proxyView,
selectedGroup,
])
return {
renderList,
onProxies: refreshProxy,
onHeadState: setHeadState,
currentColumns: col,
}
}