Compare commits

..
Author SHA1 Message Date
Tunglies c576122a4c perf(proxies): reduce delay-driven list and card churn 2026-11-11 05:59:37 +08:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f86db4b344 chore(deps): update cargo dependencies (#8619)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 17:29:51 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
389dddf512 chore(deps): update github/gh-aw-actions action to v1.2.2 (#8617)
* chore(deps): update github/gh-aw-actions action to v1.2.2

* ci(workflows): recompile workflow lockfiles with gh-aw v1.2.2

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

* ci(workflows): set permissions to suppress tips

Agentic workflows of this repository are using available credits by some
collaborators' personal API Key. Configuring permissions of these
workflows to suppress warnings by GitHub Agentic Workflow extension
during lockfile compilation.

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-11-10 14:01:34 +00:00
18 changed files with 1135 additions and 367 deletions
+3 -3
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v1.0.1": {
"github/gh-aw-actions/setup@v1.2.2": {
"repo": "github/gh-aw-actions/setup",
"version": "v1.0.1",
"sha": "221ba4f4319de6b8e809d1a15528b3ae6a818aa5"
"version": "v1.2.2",
"sha": "53d0749e19424a918acd82b4d140264ce48f2f14"
}
},
"containers": {
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@406c7719e31d7fe354224d4bb8fe3d4aeb47da1c # v1.2.1
uses: github/gh-aw-actions/setup-cli@53d0749e19424a918acd82b4d140264ce48f2f14 # v1.2.2
with:
version: v1.0.1
- name: Install Rust Stable
File diff suppressed because one or more lines are too long
+1
View File
@@ -33,6 +33,7 @@ max-daily-ai-credits: 5000
permissions:
issues: read
pull-requests: read
copilot-requests: none
pre-agent-steps:
- name: Make Copilot quota exhaustion non-blocking
+288 -107
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -30,6 +30,7 @@ permissions:
contents: read
issues: read
pull-requests: read
copilot-requests: none
steps:
- name: Load review rules from the base revision
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ permissions:
contents: read
issues: read
pull-requests: read
copilot-requests: none
max-ai-credits: 60
max-turns: 12
max-daily-ai-credits: 500
Generated
+10 -11
View File
@@ -159,9 +159,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.4"
version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92"
checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5"
dependencies = [
"rustversion",
]
@@ -7520,15 +7520,14 @@ dependencies = [
[[package]]
name = "tauri-plugin-http"
version = "2.9.0"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c278945f30912c8c32728265c66d16ca1b139509c4874d00579f5c8c5276605a"
checksum = "2c57e3451553f95a1db3bfed6a358a77ce5080b6399a242c6eadac113f97abd2"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"percent-encoding",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
@@ -7570,9 +7569,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-notification"
version = "2.8.1"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a226bfb8189ec4ab2c1936e66330ad06c44d9f6c54a674338208491dee177f"
checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf"
dependencies = [
"log",
"notify-rust",
@@ -7590,9 +7589,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-opener"
version = "2.7.3"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54592c6718b42ab198bf11bda6a428a3553ca2f2f8e506df56f6a063ec328331"
checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
dependencies = [
"dunce",
"glob",
@@ -10127,9 +10126,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.4.2"
version = "9.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3845f734baabdd02de353a20580e30ceccd03897def44a4eb8e9654992e294"
checksum = "0700e6c427aff64acdbf02e85fbfd946c2ee12319d1af013092f3a2178914ff8"
dependencies = [
"aes",
"bzip2",
+1
View File
@@ -48,6 +48,7 @@
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
- 优化代理页面和首页节点卡片,减少延迟更新时的卡顿
**🍎 macOS**
+5 -5
View File
@@ -72,12 +72,12 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.6.0"
tauri-plugin-opener = "2.7.3"
tauri-plugin-opener = "2.7.2"
tauri-plugin-dialog = "2.8.4"
tauri-plugin-fs = "2.7.1"
tauri-plugin-deep-link = "2.6.3"
tauri-plugin-window-state = "2.5.0"
zip = "9.4.2"
zip = "9.5.0"
flate2 = "1.1.11"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
@@ -87,9 +87,9 @@ getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.8.1"
tauri-plugin-notification = "2.8.0"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.9.0"
tauri-plugin-http = "2.8.3"
tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.3", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
@@ -97,7 +97,7 @@ async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
"client",
] }
arc-swap = "1.9.4"
arc-swap = "1.9.5"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.11"
+16 -8
View File
@@ -605,12 +605,14 @@ export const CurrentProxyCard = () => {
[getProfileStorageKey],
)
const { select } = useProxySelection({
onError: (error) => {
const onSelectionError = useCallback(
(error: unknown) => {
console.error('代理切换失败', error)
refreshProxy()
},
})
[refreshProxy],
)
const { select } = useProxySelection({ onError: onSelectionError })
const mode = clashConfig?.mode?.toLowerCase() || 'rule'
const isGlobalMode = mode === 'global'
@@ -673,7 +675,6 @@ export const CurrentProxyCard = () => {
if (proxyView && lastGroupName !== selectedGroupName) {
setLastGroupName(selectedGroupName)
}
const delays = useGroupDelays(selectedGroupName || null)
const selectedGroup = useMemo<ProxyGroupView | null>(() => {
if (!proxyView || isDirectMode) return null
@@ -767,10 +768,17 @@ export const CurrentProxyCard = () => {
const currentProxy = currentMember ? memberDetails(currentMember) : undefined
const selectedProxyName = currentMember?.ref.name ?? ''
const delays = useGroupDelays(
selectedGroupName,
currentMember,
openSelect === 'proxy',
)
const currentDelay =
currentMember && selectedGroupName
? delayManager.getDelayFix(currentMember, selectedGroupName)
: -1
typeof delays === 'number'
? delays
: currentMember
? delays.of(currentMember)
: -1
const signalInfo =
currentProxy && selectedGroupName
@@ -892,7 +900,7 @@ export const CurrentProxyCard = () => {
const proxyOptions = useMemo(
() =>
isDirectMode || openSelect !== 'proxy'
isDirectMode || openSelect !== 'proxy' || typeof delays === 'number'
? []
: sortProxyOptions(
unsortedProxyOptions,
+5 -3
View File
@@ -469,12 +469,14 @@ function NormalProxyGroups(props: { mode: string }) {
}
}, [handleScroll])
const { select } = useProxySelection({
onError: (error) => {
const onSelectionError = useCallback(
(error: unknown) => {
console.error('代理切换失败', error)
onProxies()
},
})
[onProxies],
)
const { select } = useProxySelection({ onError: onSelectionError })
const handleChangeProxy = useCallback(
(group: ProxyGroupView, member: ResolvedProxyMember) => {
+3
View File
@@ -29,6 +29,9 @@ export function filterSort(
const regex1 = /delay([=<>])(\d+|timeout|error)/i
const regex2 = /type=(.*)/i
export const isDelayFilter = (filterText: string) =>
regex1.test(filterText.trim())
function filterProxies(
proxies: ResolvedMemberOccurrence[],
groupName: string,
@@ -0,0 +1,119 @@
import { expect, it, vi } from 'vitest'
import type { DelaySnapshot } from '@/services/delay'
import { resolveMember, type ProxyViewV1 } from '@/types/proxy-view'
import { filterSort } from './use-filter-sort'
import { useRenderList as readRenderList } from './use-render-list'
const hooks = vi.hoisted(() => {
const slots: { value: unknown; deps?: unknown[] }[] = []
let cursor = 0
return {
reset: () => {
cursor = 0
},
memo: (factory: () => unknown, deps: unknown[]) => {
const index = cursor++
const old = slots[index]
if (!old || deps.some((dep, i) => !Object.is(dep, old.deps?.[i]))) {
slots[index] = { value: factory(), deps }
}
return slots[index].value
},
ref: (current: unknown) => {
const index = cursor++
slots[index] ??= { value: { current } }
return slots[index].value
},
}
})
const state = vi.hoisted(() => ({
view: null as ProxyViewV1 | null,
delays: new Map<string, DelaySnapshot>(),
}))
vi.mock('react', () => ({
useMemo: hooks.memo,
useRef: hooks.ref,
useEffect: () => {},
}))
vi.mock('@/hooks/use-profiles', () => ({}))
vi.mock('@/hooks/use-clash', () => ({
useRuntimeConfig: () => ({ data: null }),
}))
vi.mock('@/hooks/use-verge', () => ({
useVerge: () => ({ verge: { proxy_layout_column: 1 } }),
}))
vi.mock('@/providers/app-data-context', () => ({
useProxiesData: () => ({ proxyView: state.view }),
useAppRefreshers: () => ({ refreshProxy: () => {} }),
}))
vi.mock('@/hooks/use-group-delays', () => ({
useGroupsDelays: (names: string[]) =>
new Map(names.map((name) => [name, state.delays.get(name)])),
}))
vi.mock('./use-window-width', () => ({
useWindowWidth: () => ({ width: 800 }),
}))
vi.mock('./use-head-state', async (original) => {
const actual = await original<typeof import('./use-head-state')>()
const heads = {
plain: { ...actual.DEFAULT_STATE, open: true },
sorted: { ...actual.DEFAULT_STATE, open: true, sortType: 1 },
}
return { ...actual, useHeadStateNew: () => [heads, () => {}] }
})
vi.mock('./use-filter-sort', async (original) => {
const actual = await original<typeof import('./use-filter-sort')>()
return { ...actual, filterSort: vi.fn(actual.filterSort) }
})
vi.mock('@/types/proxy-view', async (original) => {
const actual = await original<typeof import('@/types/proxy-view')>()
return { ...actual, resolveMember: vi.fn(actual.resolveMember) }
})
it('skips delay-independent projections and reuses members for delay sorting', () => {
const group = (name: string) => ({
name,
type: 'Selector',
history: [],
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
members: Array.from({ length: 500 }, (_, i) => ({
kind: 'unresolved' as const,
name: `${i}`,
reason: 'missing' as const,
})),
})
state.view = {
schemaVersion: 1,
orderSource: 'runtime',
providerState: 'ready',
global: null,
direct: null,
groups: [group('plain'), group('sorted')],
records: {},
standalone: [],
providers: [],
}
const render = () => {
hooks.reset()
return readRenderList('rule')
}
state.delays.set('plain', { of: () => 42 })
state.delays.set('sorted', { of: () => 42 })
render()
vi.mocked(filterSort).mockClear()
vi.mocked(resolveMember).mockClear()
state.delays.set('plain', { of: () => 43 })
render()
expect(filterSort).toHaveBeenCalledTimes(0)
state.delays.set('sorted', { of: () => 43 })
render()
expect(filterSort).toHaveBeenCalledTimes(1)
expect(resolveMember).toHaveBeenCalledTimes(0)
})
+58 -17
View File
@@ -17,7 +17,7 @@ import {
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { filterSort } from './use-filter-sort'
import { filterSort, isDelayFilter } from './use-filter-sort'
import {
DEFAULT_STATE,
useHeadStateNew,
@@ -60,14 +60,14 @@ export const hasRenderableItems = (
): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden)
type GroupCache = {
now: string | undefined
members: ProxyGroupView['members']
group: ProxyGroupView
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
occurrences: ResolvedMemberOccurrence[] | undefined
items: IRenderItem[]
}
@@ -222,19 +222,51 @@ export const useRenderList = (
return current?.kind === 'group' ? [global, current.group] : [global]
}, [mode, proxyView])
// Every group this list draws, so a test settling in any of them re-sorts that group.
const repeatedNameGroups = useMemo(() => {
const repeated = new Set<string>()
if (isChainMode) return repeated
for (const group of renderGroups) {
const names = new Set<string>()
if (
group.members.some(({ name }) => {
if (names.has(name)) return true
names.add(name)
return false
})
) {
repeated.add(group.name)
}
}
return repeated
}, [isChainMode, renderGroups])
// Rows own delay updates unless repeated names share the current item listener.
const renderedGroupNames = useMemo(() => {
if (!proxyView) return []
if (isChainMode)
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
return renderGroups.map(({ name }) => name)
}, [isChainMode, proxyView, renderGroups, selectedGroup])
if (!proxyView || isChainMode) return []
return renderGroups
.filter((group) => {
const state = headStates[group.name] || DEFAULT_STATE
return (
(state.open || group === proxyView.global) &&
(state.sortType === 1 ||
isDelayFilter(state.filterText) ||
repeatedNameGroups.has(group.name))
)
})
.map(({ name }) => name)
}, [headStates, isChainMode, proxyView, renderGroups, repeatedNameGroups])
const groupDelays = useGroupsDelays(renderedGroupNames)
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
const groupCacheRef = useRef<Map<string, GroupCache> | null>(null)
const cachedViewRef = useRef(proxyView)
const prevListRef = useRef<IRenderItem[]>([])
const renderList = useMemo<IRenderItem[]>(() => {
const cache = (groupCacheRef.current ??= new Map())
if (cachedViewRef.current !== proxyView) {
cache.clear()
cachedViewRef.current = proxyView
}
if (!proxyView) return []
if (isChainMode) {
@@ -269,16 +301,13 @@ export const useRenderList = (
}))
}
const cache = groupCacheRef.current
const retList = renderGroups.flatMap((group) => {
const isFlat = group === proxyView.global
const headState = headStates[group.name] || DEFAULT_STATE
const cached = cache.get(group.name)
if (
cached &&
cached.now === group.now &&
cached.members === group.members &&
cached.group === group &&
cached.headState === headState &&
cached.col === col &&
cached.latencyTimeout === latencyTimeout &&
@@ -300,9 +329,18 @@ export const useRenderList = (
},
]
// Duplicate names share one item listener until the delay-store fix lands.
const refreshRepeated =
repeatedNameGroups.has(group.name) &&
cached?.delays !== groupDelays.get(group.name)
let resolved =
cached?.group === group && !refreshRepeated
? cached.occurrences
: undefined
if (headState.open || isFlat) {
resolved ??= resolveOccurrences(proxyView, group)
const occurrences = filterSort(
resolveOccurrences(proxyView, group),
resolved,
group.name,
headState.filterText,
headState.sortType,
@@ -337,15 +375,17 @@ export const useRenderList = (
})),
)
}
} else {
resolved = undefined
}
cache.set(group.name, {
now: group.now!,
members: group.members,
group,
headState,
col,
latencyTimeout: latencyTimeout!,
delays: groupDelays.get(group.name),
occurrences: resolved,
items: ret,
})
return ret
@@ -371,6 +411,7 @@ export const useRenderList = (
mode,
proxyView,
renderGroups,
repeatedNameGroups,
selectedGroup,
])
+45
View File
@@ -0,0 +1,45 @@
import { useSyncExternalStore } from 'react'
import { expect, it, vi } from 'vitest'
import delayManager from '@/services/delay'
import type { ResolvedProxyMember } from '@/types/proxy-view'
import { useGroupDelays } from './use-group-delays'
vi.mock('react', () => ({
useCallback: (callback: unknown) => callback,
useSyncExternalStore: vi.fn((_subscribe, read) => read()),
}))
it('keeps the closed-menu snapshot unchanged when another member settles', () => {
const member: ResolvedProxyMember = {
kind: 'node',
ref: { kind: 'node', name: 'current', recordId: 'current' },
node: {
recordId: 'current',
name: 'current',
type: 'Shadowsocks',
source: { kind: 'core', proxyName: 'current' },
history: [],
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
},
}
vi.spyOn(delayManager, 'getDelayFix').mockReturnValue(42)
const snapshot = vi.spyOn(delayManager, 'groupDelays')
snapshot.mockReturnValue({ of: () => 42 })
useGroupDelays('group', member, false)
const [, read] = vi.mocked(useSyncExternalStore).mock.calls.at(-1)!
let previous = read()
let changes = 0
for (let i = 0; i < 120; i++) {
snapshot.mockReturnValue({ of: () => 42 })
const next = read()
if (!Object.is(next, previous)) changes++
previous = next
}
expect(changes).toBe(0)
})
+14 -6
View File
@@ -1,19 +1,27 @@
import { useCallback, useSyncExternalStore } from 'react'
import delayManager, { type DelaySnapshot } from '@/services/delay'
import type { ResolvedProxyMember } from '@/types/proxy-view'
const NO_DELAYS: DelaySnapshot = { of: () => -1 }
/** Exposes the external delay store to React, updating sort order only after a test settles. */
export const useGroupDelays = (group: string | null): DelaySnapshot => {
/** A closed menu observes only its current member, not every settled group delay. */
export const useGroupDelays = (
group: string | null,
member: ResolvedProxyMember | undefined,
menuOpen: boolean,
): DelaySnapshot | number => {
const subscribe = useCallback(
(onSettle: () => void) =>
group ? delayManager.addGroupListener(group, onSettle) : () => {},
[group],
)
const read = useCallback(
() => (group ? delayManager.groupDelays(group) : NO_DELAYS),
[group],
() =>
group && menuOpen
? delayManager.groupDelays(group)
: group && member
? delayManager.getDelayFix(member, group)
: -1,
[group, member, menuOpen],
)
return useSyncExternalStore(subscribe, read, read)