perf: model select popup (#8766)

- use DynamicVirtualList in SelectModelPopup
- use DynamicVirtualList in QuickPanelView
- remove react-window
- simplify SelectModelPopup states, improve maintainability
This commit is contained in:
one 2025-08-02 23:17:14 +08:00 committed by GitHub
parent 82923a7c64
commit 9e405f0604
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 304 additions and 561 deletions

View File

@ -149,7 +149,6 @@
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-window": "^1",
"@types/tinycolor2": "^1",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.23.14",
@ -236,7 +235,6 @@
"react-router": "6",
"react-router-dom": "6",
"react-spinners": "^0.14.1",
"react-window": "^1.8.11",
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"reflect-metadata": "0.2.2",

View File

@ -1,40 +0,0 @@
import { useMemo, useReducer } from 'react'
import { initialScrollState, scrollReducer } from './reducer'
import { FlatListItem, ScrollTrigger } from './types'
/**
* hook
*/
export function useScrollState() {
const [state, dispatch] = useReducer(scrollReducer, initialScrollState)
const actions = useMemo(
() => ({
setFocusedItemKey: (key: string) => dispatch({ type: 'SET_FOCUSED_ITEM_KEY', payload: key }),
setScrollTrigger: (trigger: ScrollTrigger) => dispatch({ type: 'SET_SCROLL_TRIGGER', payload: trigger }),
setLastScrollOffset: (offset: number) => dispatch({ type: 'SET_LAST_SCROLL_OFFSET', payload: offset }),
setStickyGroup: (group: FlatListItem | null) => dispatch({ type: 'SET_STICKY_GROUP', payload: group }),
setIsMouseOver: (isMouseOver: boolean) => dispatch({ type: 'SET_IS_MOUSE_OVER', payload: isMouseOver }),
focusNextItem: (modelItems: FlatListItem[], step: number) =>
dispatch({ type: 'FOCUS_NEXT_ITEM', payload: { modelItems, step } }),
focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) =>
dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }),
searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }),
focusOnListChange: (modelItems: FlatListItem[]) =>
dispatch({ type: 'FOCUS_ON_LIST_CHANGE', payload: { modelItems } })
}),
[]
)
return {
// 状态
focusedItemKey: state.focusedItemKey,
scrollTrigger: state.scrollTrigger,
lastScrollOffset: state.lastScrollOffset,
stickyGroup: state.stickyGroup,
isMouseOver: state.isMouseOver,
// 操作
...actions
}
}

View File

@ -1,17 +1,16 @@
import { PushpinOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { TopView } from '@renderer/components/TopView'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { Model, Provider } from '@renderer/types'
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd'
import { Avatar, Divider, Empty, Modal } from 'antd'
import { first, sortBy } from 'lodash'
import { Search } from 'lucide-react'
import {
import React, {
startTransition,
useCallback,
useDeferredValue,
@ -21,15 +20,13 @@ import {
useRef,
useState
} from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { FixedSizeList } from 'react-window'
import styled from 'styled-components'
import { useScrollState } from './hook'
import SelectModelSearchBar from './searchbar'
import { FlatListItem } from './types'
const PAGE_SIZE = 10
const PAGE_SIZE = 11
const ITEM_HEIGHT = 36
interface PopupParams {
@ -47,8 +44,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const { providers } = useProviders()
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
const [open, setOpen] = useState(true)
const inputRef = useRef<InputRef>(null)
const listRef = useRef<FixedSizeList>(null)
const listRef = useRef<DynamicVirtualListRef>(null)
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
@ -56,49 +52,19 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const currentModelId = model ? getModelUniqId(model) : ''
// 管理滚动和焦点状态
const {
focusedItemKey,
scrollTrigger,
lastScrollOffset,
stickyGroup,
isMouseOver,
setFocusedItemKey: _setFocusedItemKey,
setScrollTrigger,
setLastScrollOffset: _setLastScrollOffset,
setStickyGroup: _setStickyGroup,
setIsMouseOver,
focusNextItem,
focusPage,
searchChanged,
focusOnListChange
} = useScrollState()
const [focusedItemKey, _setFocusedItemKey] = useState('')
const [isMouseOver, setIsMouseOver] = useState(false)
const preventScrollToIndex = useRef(false)
const firstGroupRef = useRef<FlatListItem | null>(null)
const setFocusedItemKey = useCallback(
(key: string) => {
startTransition(() => _setFocusedItemKey(key))
},
[_setFocusedItemKey]
)
const setLastScrollOffset = useCallback(
(offset: number) => {
startTransition(() => _setLastScrollOffset(offset))
},
[_setLastScrollOffset]
)
const setStickyGroup = useCallback(
(group: FlatListItem | null) => {
startTransition(() => _setStickyGroup(group))
},
[_setStickyGroup]
)
const setFocusedItemKey = useCallback((key: string) => {
startTransition(() => {
_setFocusedItemKey(key)
})
}, [])
// 根据输入的文本筛选模型
const getFilteredModels = useCallback(
(provider) => {
(provider: Provider) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
if (searchText.trim()) {
@ -112,7 +78,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 创建模型列表项
const createModelItem = useCallback(
(model: Model, provider: any, isPinned: boolean): FlatListItem => {
(model: Model, provider: Provider, isPinned: boolean): FlatListItem => {
const modelId = getModelUniqId(model)
const groupName = getFancyProviderName(provider)
@ -143,16 +109,18 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
[currentModelId]
)
// 构建扁平化列表数据
const listItems = useMemo(() => {
// 构建扁平化列表数据,并派生出可选择的模型项
const { listItems, modelItems } = useMemo(() => {
const items: FlatListItem[] = []
const pinnedModelIds = new Set(pinnedModels)
const finalModelFilter = modelFilter || (() => true)
// 添加置顶模型分组(仅在无搜索文本时)
if (searchText.length === 0 && pinnedModels.length > 0) {
if (searchText.length === 0 && pinnedModelIds.size > 0) {
const pinnedItems = providers.flatMap((p) =>
p.models
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.filter(modelFilter ? modelFilter : () => true)
.filter((m) => pinnedModelIds.has(getModelUniqId(m)))
.filter(finalModelFilter)
.map((m) => createModelItem(m, p, true))
)
@ -172,8 +140,8 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 添加常规模型分组
providers.forEach((p) => {
const filteredModels = getFilteredModels(p)
.filter((m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m)))
.filter(modelFilter ? modelFilter : () => true)
.filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m)))
.filter(finalModelFilter)
if (filteredModels.length === 0) return
@ -185,92 +153,52 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
isSelected: false
})
items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModels.includes(getModelUniqId(m)))))
items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModelIds.has(getModelUniqId(m)))))
})
// 移除第一个分组标题,使用 sticky group banner 替代,模拟 sticky 效果
if (items.length > 0 && items[0].type === 'group') {
firstGroupRef.current = items[0]
items.shift()
} else {
firstGroupRef.current = null
}
return items
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[]
return { listItems: items, modelItems }
}, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels])
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = useMemo(() => {
return listItems.filter((item) => item.type === 'model')
}, [listItems])
const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
}, [listItems.length])
// 当搜索文本变化时更新滚动触发器
useEffect(() => {
searchChanged(searchText)
}, [searchText, searchChanged])
// 基于滚动位置更新sticky分组标题
const updateStickyGroup = useCallback(
(scrollOffset?: number) => {
if (listItems.length === 0) {
stickyGroup && setStickyGroup(null)
return
}
let newStickyGroup: FlatListItem | null = null
// 基于滚动位置计算当前可见的第一个项的索引
const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT)
// 从该索引向前查找最近的分组标题
for (let i = estimatedIndex - 1; i >= 0; i--) {
if (i < listItems.length && listItems[i]?.type === 'group') {
newStickyGroup = listItems[i]
break
}
}
// 找不到则使用第一个分组标题
if (!newStickyGroup) newStickyGroup = firstGroupRef.current
if (stickyGroup?.key !== newStickyGroup?.key) {
setStickyGroup(newStickyGroup)
}
},
[listItems, lastScrollOffset, setStickyGroup, stickyGroup]
)
// 处理列表滚动事件更新lastScrollOffset并更新sticky分组
const handleScroll = useCallback(
({ scrollOffset }) => {
setLastScrollOffset(scrollOffset)
},
[setLastScrollOffset]
)
// 列表项更新时,更新焦点
useEffect(() => {
if (!loading) focusOnListChange(modelItems)
}, [modelItems, focusOnListChange, loading])
// 列表项更新时更新sticky分组
useEffect(() => {
if (!loading) updateStickyGroup()
}, [modelItems, updateStickyGroup, loading])
// 滚动到聚焦项
// 处理程序化滚动(加载、搜索开始、搜索清空)
useLayoutEffect(() => {
if (scrollTrigger === 'none' || !focusedItemKey) return
if (loading) return
const index = listItems.findIndex((item) => item.key === focusedItemKey)
if (index < 0) return
if (preventScrollToIndex.current) {
preventScrollToIndex.current = false
return
}
// 根据触发源决定滚动对齐方式
const alignment = scrollTrigger === 'keyboard' ? 'auto' : 'center'
listRef.current?.scrollToItem(index, alignment)
let targetItemKey: string | undefined
// 滚动后重置触发器
setScrollTrigger('none')
}, [focusedItemKey, scrollTrigger, listItems, setScrollTrigger])
// 启动搜索时,滚动到第一个 item
if (searchText) {
targetItemKey = modelItems[0]?.key
}
// 初始加载或清空搜索时,滚动到 selected item
else {
targetItemKey = modelItems.find((item) => item.isSelected)?.key
}
if (targetItemKey) {
setFocusedItemKey(targetItemKey)
const index = listItems.findIndex((item) => item.key === targetItemKey)
if (index >= 0) {
// FIXME: 手动计算偏移量,给 scroller 增加了 scrollPaddingStart 之后,
// scrollToIndex 不能准确滚动到 item 中心,但是又需要 padding 来改善体验。
const targetScrollTop = index * ITEM_HEIGHT - listHeight / 2
listRef.current?.scrollToOffset(targetScrollTop, {
align: 'start',
behavior: 'auto'
})
}
}
}, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight])
const handleItemClick = useCallback(
(item: FlatListItem) => {
@ -285,7 +213,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 处理键盘导航
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!open || modelItems.length === 0 || e.isComposing) return
const modelCount = modelItems.length
if (!open || modelCount === 0 || e.isComposing) return
// 键盘操作时禁用鼠标 hover
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) {
@ -294,25 +224,31 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
setIsMouseOver(false)
}
// 当前聚焦的模型 index
const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey)
const normalizedIndex = currentIndex < 0 ? 0 : currentIndex
let nextIndex = -1
switch (e.key) {
case 'ArrowUp':
focusNextItem(modelItems, -1)
case 'ArrowUp': {
nextIndex = (currentIndex < 0 ? 0 : currentIndex - 1 + modelCount) % modelCount
break
case 'ArrowDown':
focusNextItem(modelItems, 1)
}
case 'ArrowDown': {
nextIndex = (currentIndex < 0 ? 0 : currentIndex + 1) % modelCount
break
case 'PageUp':
focusPage(modelItems, normalizedIndex, -PAGE_SIZE)
}
case 'PageUp': {
nextIndex = Math.max(0, (currentIndex < 0 ? 0 : currentIndex) - PAGE_SIZE)
break
case 'PageDown':
focusPage(modelItems, normalizedIndex, PAGE_SIZE)
}
case 'PageDown': {
nextIndex = Math.min(modelCount - 1, (currentIndex < 0 ? 0 : currentIndex) + PAGE_SIZE)
break
}
case 'Enter':
if (focusedItemKey) {
const selectedItem = modelItems.find((item) => item.key === focusedItemKey)
if (currentIndex >= 0) {
const selectedItem = modelItems[currentIndex]
if (selectedItem) {
handleItemClick(selectedItem)
}
@ -324,8 +260,20 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
resolve(undefined)
break
}
// 没有键盘导航,直接返回
if (nextIndex < 0) return
const nextKey = modelItems[nextIndex]?.key || ''
if (nextKey) {
setFocusedItemKey(nextKey)
const index = listItems.findIndex((item) => item.key === nextKey)
if (index >= 0) {
listRef.current?.scrollToIndex(index, { align: 'auto' })
}
}
},
[focusedItemKey, modelItems, handleItemClick, open, resolve, setIsMouseOver, focusNextItem, focusPage]
[modelItems, open, focusedItemKey, resolve, handleItemClick, setFocusedItemKey, listItems]
)
useEffect(() => {
@ -338,40 +286,57 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
}, [])
const onAfterClose = useCallback(async () => {
setScrollTrigger('initial')
resolve(undefined)
SelectModelPopup.hide()
}, [resolve, setScrollTrigger])
// 初始化焦点和滚动位置
useEffect(() => {
if (!open) return
const timer = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timer)
}, [open])
}, [resolve])
const togglePin = useCallback(
async (modelId: string) => {
await togglePinnedModel(modelId)
preventScrollToIndex.current = true
},
[togglePinnedModel]
)
const RowData = useMemo(
(): VirtualizedRowData => ({
listItems,
focusedItemKey,
setFocusedItemKey,
stickyGroup,
handleItemClick,
togglePin
}),
[stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin, setFocusedItemKey]
)
const getItemKey = useCallback((index: number) => listItems[index].key, [listItems])
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
const isSticky = useCallback((index: number) => listItems[index].type === 'group', [listItems])
const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
}, [listItems.length])
const rowRenderer = useCallback(
(item: FlatListItem) => {
const isFocused = item.key === focusedItemKey
if (item.type === 'group') {
return <GroupItem>{item.name}</GroupItem>
}
return (
<ModelItem
className={classNames({
focused: isFocused,
selected: item.isSelected
})}
onClick={() => handleItemClick(item)}
onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}>
<ModelItemLeft>
{item.icon}
{item.name}
{item.tags}
</ModelItemLeft>
<PinIconWrapper
onClick={(e) => {
e.stopPropagation()
if (item.model) {
togglePin(getModelUniqId(item.model))
}
}}
data-pinned={item.isPinned}
$isPinned={item.isPinned}>
<PushpinOutlined />
</PinIconWrapper>
</ModelItem>
)
},
[focusedItemKey, handleItemClick, setFocusedItemKey, togglePin]
)
return (
<Modal
@ -396,50 +361,23 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
closeIcon={null}
footer={null}>
{/* 搜索框 */}
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
<Input
prefix={
<SearchIcon>
<Search size={15} />
</SearchIcon>
}
ref={inputRef}
placeholder={t('models.search')}
value={_searchText} // 使用 _searchText需要实时更新
onChange={(e) => setSearchText(e.target.value)}
allowClear
autoFocus
spellCheck={false}
style={{ paddingLeft: 0 }}
variant="borderless"
size="middle"
onKeyDown={(e) => {
// 防止上下键移动光标
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
e.preventDefault()
}
}}
/>
</HStack>
<SelectModelSearchBar onSearch={setSearchText} />
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
{listItems.length > 0 ? (
<ListContainer onMouseMove={() => !isMouseOver && startTransition(() => setIsMouseOver(true))}>
{/* Sticky Group Banner它会替换第一个分组名称 */}
<StickyGroupBanner>{stickyGroup?.name}</StickyGroupBanner>
<FixedSizeList
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
<DynamicVirtualList
ref={listRef}
height={listHeight}
width="100%"
itemCount={listItems.length}
itemSize={ITEM_HEIGHT}
itemData={RowData}
itemKey={(index, data) => data.listItems[index].key}
overscanCount={4}
onScroll={handleScroll}
style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}>
{VirtualizedRow}
</FixedSizeList>
list={listItems}
size={listHeight}
getItemKey={getItemKey}
estimateSize={estimateSize}
isSticky={isSticky}
scrollPaddingStart={ITEM_HEIGHT} // 留出 sticky header 高度
overscan={5}
scrollerStyle={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}>
{rowRenderer}
</DynamicVirtualList>
</ListContainer>
) : (
<EmptyState>
@ -450,73 +388,12 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
)
}
interface VirtualizedRowData {
listItems: FlatListItem[]
focusedItemKey: string
setFocusedItemKey: (key: string) => void
stickyGroup: FlatListItem | null
handleItemClick: (item: FlatListItem) => void
togglePin: (modelId: string) => void
}
/**
*
*/
const VirtualizedRow = React.memo(
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
const { listItems, focusedItemKey, setFocusedItemKey, handleItemClick, togglePin, stickyGroup } = data
const item = listItems[index]
if (!item) {
return <div style={style} />
}
const isFocused = item.key === focusedItemKey
return (
<div style={style}>
{item.type === 'group' ? (
<GroupItem $isSticky={item.key === stickyGroup?.key}>{item.name}</GroupItem>
) : (
<ModelItem
className={classNames({
focused: isFocused,
selected: item.isSelected
})}
onClick={() => handleItemClick(item)}
onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}>
<ModelItemLeft>
{item.icon}
{item.name}
{item.tags}
</ModelItemLeft>
<PinIconWrapper
onClick={(e) => {
e.stopPropagation()
if (item.model) {
togglePin(getModelUniqId(item.model))
}
}}
data-pinned={item.isPinned}
$isPinned={item.isPinned}>
<PushpinOutlined />
</PinIconWrapper>
</ModelItem>
)}
</div>
)
}
)
VirtualizedRow.displayName = 'VirtualizedRow'
const ListContainer = styled.div`
position: relative;
overflow: hidden;
`
const GroupItem = styled.div<{ $isSticky?: boolean }>`
const GroupItem = styled.div`
display: flex;
align-items: center;
position: relative;
@ -526,12 +403,6 @@ const GroupItem = styled.div<{ $isSticky?: boolean }>`
padding: 5px 10px 5px 18px;
color: var(--color-text-3);
z-index: 1;
visibility: ${(props) => (props.$isSticky ? 'hidden' : 'visible')};
`
const StickyGroupBanner = styled(GroupItem)`
position: sticky;
background: var(--modal-background);
`
@ -613,18 +484,6 @@ const EmptyState = styled.div`
height: 200px;
`
const SearchIcon = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: var(--color-background-soft);
margin-right: 2px;
`
const PinIconWrapper = styled.div.attrs({ className: 'pin-icon' })<{ $isPinned?: boolean }>`
margin-left: auto;
padding: 0 10px;

View File

@ -1,102 +0,0 @@
import { ScrollAction, ScrollState } from './types'
/**
*
*/
export const initialScrollState: ScrollState = {
focusedItemKey: '',
scrollTrigger: 'initial',
lastScrollOffset: 0,
stickyGroup: null,
isMouseOver: false
}
/**
* reducer
* @param state
* @param action
* @returns
*/
export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollState => {
switch (action.type) {
case 'SET_FOCUSED_ITEM_KEY':
return { ...state, focusedItemKey: action.payload }
case 'SET_SCROLL_TRIGGER':
return { ...state, scrollTrigger: action.payload }
case 'SET_LAST_SCROLL_OFFSET':
return { ...state, lastScrollOffset: action.payload }
case 'SET_STICKY_GROUP':
return { ...state, stickyGroup: action.payload }
case 'SET_IS_MOUSE_OVER':
return { ...state, isMouseOver: action.payload }
case 'FOCUS_NEXT_ITEM': {
const { modelItems, step } = action.payload
if (modelItems.length === 0) {
return {
...state,
focusedItemKey: '',
scrollTrigger: 'keyboard'
}
}
const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey)
const nextIndex = (currentIndex < 0 ? 0 : currentIndex + step + modelItems.length) % modelItems.length
return {
...state,
focusedItemKey: modelItems[nextIndex].key,
scrollTrigger: 'keyboard'
}
}
case 'FOCUS_PAGE': {
const { modelItems, currentIndex, step } = action.payload
const nextIndex = Math.max(0, Math.min(currentIndex + step, modelItems.length - 1))
return {
...state,
focusedItemKey: modelItems.length > 0 ? modelItems[nextIndex].key : '',
scrollTrigger: 'keyboard'
}
}
case 'SEARCH_CHANGED':
return {
...state,
scrollTrigger: action.payload.searchText ? 'search' : 'initial'
}
case 'FOCUS_ON_LIST_CHANGE': {
const { modelItems } = action.payload
// 在列表变化时尝试聚焦一个模型:
// - 如果是 initial 状态,先尝试聚焦当前选中的模型
// - 如果是 search 状态,尝试聚焦第一个模型
let newFocusedKey = ''
if (state.scrollTrigger === 'initial' || state.scrollTrigger === 'search') {
const selectedItem = modelItems.find((item) => item.isSelected)
if (selectedItem && state.scrollTrigger === 'initial') {
newFocusedKey = selectedItem.key
} else if (modelItems.length > 0) {
newFocusedKey = modelItems[0].key
}
} else {
newFocusedKey = state.focusedItemKey
}
return {
...state,
focusedItemKey: newFocusedKey
}
}
default:
return state
}
}

View File

@ -0,0 +1,77 @@
import { HStack } from '@renderer/components/Layout'
import { Input, InputRef } from 'antd'
import { Search } from 'lucide-react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface SelectModelSearchBarProps {
onSearch: (text: string) => void
}
const SelectModelSearchBar: React.FC<SelectModelSearchBarProps> = ({ onSearch }) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
const handleTextChange = useCallback(
(text: string) => {
setSearchText(text)
onSearch(text)
},
[onSearch]
)
const handleClear = useCallback(() => {
setSearchText('')
onSearch('')
}, [onSearch])
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timer)
}, [])
return (
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
<Input
prefix={
<SearchIcon>
<Search size={15} />
</SearchIcon>
}
ref={inputRef}
placeholder={t('models.search')}
value={searchText}
onChange={(e) => handleTextChange(e.target.value)}
onClear={handleClear}
allowClear
autoFocus
spellCheck={false}
style={{ paddingLeft: 0 }}
variant="borderless"
size="middle"
onKeyDown={(e) => {
// 防止上下键移动光标
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
e.preventDefault()
}
}}
/>
</HStack>
)
}
const SearchIcon = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: var(--color-background-soft);
margin-right: 2px;
`
export default memo(SelectModelSearchBar)

View File

@ -18,24 +18,3 @@ export interface FlatListItem {
isPinned?: boolean
isSelected?: boolean
}
// 滚动和焦点相关的状态类型
export interface ScrollState {
focusedItemKey: string
scrollTrigger: ScrollTrigger
lastScrollOffset: number
stickyGroup: FlatListItem | null
isMouseOver: boolean
}
// 滚动和焦点相关的 action 类型
export type ScrollAction =
| { type: 'SET_FOCUSED_ITEM_KEY'; payload: string }
| { type: 'SET_SCROLL_TRIGGER'; payload: ScrollTrigger }
| { type: 'SET_LAST_SCROLL_OFFSET'; payload: number }
| { type: 'SET_STICKY_GROUP'; payload: FlatListItem | null }
| { type: 'SET_IS_MOUSE_OVER'; payload: boolean }
| { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } }
| { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } }
| { type: 'SEARCH_CHANGED'; payload: { searchText: string } }
| { type: 'FOCUS_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } }

View File

@ -1,4 +1,5 @@
import { RightOutlined } from '@ant-design/icons'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { isMac } from '@renderer/config/constant'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { classNames } from '@renderer/utils'
@ -6,7 +7,6 @@ import { Flex } from 'antd'
import { t } from 'i18next'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { FixedSizeList } from 'react-window'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
@ -55,7 +55,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
const bodyRef = useRef<HTMLDivElement>(null)
const listRef = useRef<FixedSizeList>(null)
const listRef = useRef<DynamicVirtualListRef>(null)
const footerRef = useRef<HTMLDivElement>(null)
const [_searchText, setSearchText] = useState('')
@ -306,8 +306,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
useLayoutEffect(() => {
if (!listRef.current || index < 0 || scrollTriggerRef.current === 'none') return
const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'smart'
listRef.current?.scrollToItem(index, alignment)
const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center'
listRef.current?.scrollToIndex(index, { align: alignment })
scrollTriggerRef.current = 'none'
}, [index])
@ -470,13 +470,45 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
}, [ctx.pageSize, list.length])
const RowData = useMemo(
(): VirtualizedRowData => ({
list,
focusedIndex: index,
handleItemAction
}),
[list, index, handleItemAction]
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
const rowRenderer = useCallback(
(item: QuickPanelListItem, itemIndex: number) => {
if (!item) return null
return (
<QuickPanelItem
className={classNames({
focused: itemIndex === index,
selected: item.isSelected,
disabled: item.disabled
})}
data-id={itemIndex}
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
</QuickPanelItemLeft>
<QuickPanelItemRight>
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
<QuickPanelItemSuffixIcon>
{item.suffix ? (
item.suffix
) : item.isSelected ? (
<Check />
) : (
item.isMenu && !item.disabled && <RightOutlined />
)}
</QuickPanelItemSuffixIcon>
</QuickPanelItemRight>
</QuickPanelItem>
)
},
[index, handleItemAction]
)
return (
@ -494,19 +526,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true
})
}>
<FixedSizeList
<DynamicVirtualList
ref={listRef}
itemCount={list.length}
itemSize={ITEM_HEIGHT}
itemData={RowData}
height={listHeight}
width="100%"
overscanCount={4}
style={{
list={list}
size={listHeight}
estimateSize={estimateSize}
overscan={5}
scrollerStyle={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{VirtualizedRow}
</FixedSizeList>
{rowRenderer}
</DynamicVirtualList>
<QuickPanelFooter ref={footerRef}>
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
<QuickPanelFooterTips $footerWidth={footerWidth}>
@ -546,57 +576,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
)
}
interface VirtualizedRowData {
list: QuickPanelListItem[]
focusedIndex: number
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
}
/**
*
*/
const VirtualizedRow = React.memo(
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
const { list, focusedIndex, handleItemAction } = data
const item = list[index]
if (!item) return null
return (
<div style={style}>
<QuickPanelItem
className={classNames({
focused: index === focusedIndex,
selected: item.isSelected,
disabled: item.disabled
})}
data-id={index}
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
</QuickPanelItemLeft>
<QuickPanelItemRight>
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
<QuickPanelItemSuffixIcon>
{item.suffix ? (
item.suffix
) : item.isSelected ? (
<Check />
) : (
item.isMenu && !item.disabled && <RightOutlined />
)}
</QuickPanelItemSuffixIcon>
</QuickPanelItemRight>
</QuickPanelItem>
</div>
)
}
)
const QuickPanelContainer = styled.div<{
$pageSize: number
$selectedColor: string

View File

@ -1,12 +1,35 @@
import { configureStore } from '@reduxjs/toolkit'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import React, { useEffect } from 'react'
import { Provider } from 'react-redux'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel'
// Mock the DynamicVirtualList component
vi.mock('@renderer/components/VirtualList', async (importOriginal) => {
const mod = await importOriginal<typeof import('@renderer/components/VirtualList')>()
return {
...mod,
DynamicVirtualList: ({ ref, list, children, scrollerStyle }: any & { ref?: React.RefObject<any | null> }) => {
// Expose a mock function for scrollToIndex
React.useImperativeHandle(ref, () => ({
scrollToIndex: vi.fn()
}))
// Render all items, not virtualized
return (
<div style={scrollerStyle}>
{list.map((item: any, index: number) => (
<div key={item.id || index}>{children(item, index)}</div>
))}
</div>
)
}
}
})
// Mock Redux store
const mockStore = configureStore({
reducer: {
@ -16,6 +39,7 @@ const mockStore = configureStore({
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
return Array.from({ length }, (_, i) => ({
id: `${prefix}-${i + 1}`,
label: `${prefix} ${i + 1}`,
description: `${prefix} Description ${i + 1}`,
icon: `${prefix} Icon ${i + 1}`,

View File

@ -1461,7 +1461,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2":
"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2":
version: 7.27.4
resolution: "@babel/runtime@npm:7.27.4"
checksum: 10c0/ca99e964179c31615e1352e058cc9024df7111c829631c90eec84caba6703cc32acc81503771847c306b3c70b815609fe82dde8682936debe295b0b283b2dc6e
@ -6625,15 +6625,6 @@ __metadata:
languageName: node
linkType: hard
"@types/react-window@npm:^1":
version: 1.8.8
resolution: "@types/react-window@npm:1.8.8"
dependencies:
"@types/react": "npm:*"
checksum: 10c0/2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f
languageName: node
linkType: hard
"@types/react@npm:*, @types/react@npm:^19.0.12":
version: 19.1.2
resolution: "@types/react@npm:19.1.2"
@ -7698,7 +7689,6 @@ __metadata:
"@types/react": "npm:^19.0.12"
"@types/react-dom": "npm:^19.0.4"
"@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/react-window": "npm:^1"
"@types/tinycolor2": "npm:^1"
"@types/word-extractor": "npm:^1"
"@uiw/codemirror-extensions-langs": "npm:^4.23.14"
@ -7790,7 +7780,6 @@ __metadata:
react-router: "npm:6"
react-router-dom: "npm:6"
react-spinners: "npm:^0.14.1"
react-window: "npm:^1.8.11"
redux: "npm:^5.0.1"
redux-persist: "npm:^6.0.0"
reflect-metadata: "npm:0.2.2"
@ -15223,13 +15212,6 @@ __metadata:
languageName: node
linkType: hard
"memoize-one@npm:>=3.1.1 <6":
version: 5.2.1
resolution: "memoize-one@npm:5.2.1"
checksum: 10c0/fd22dbe9a978a2b4f30d6a491fc02fb90792432ad0dab840dc96c1734d2bd7c9cdeb6a26130ec60507eb43230559523615873168bcbe8fafab221c30b11d54c1
languageName: node
linkType: hard
"memoize-one@npm:^6.0.0":
version: 6.0.0
resolution: "memoize-one@npm:6.0.0"
@ -18410,19 +18392,6 @@ __metadata:
languageName: node
linkType: hard
"react-window@npm:^1.8.11":
version: 1.8.11
resolution: "react-window@npm:1.8.11"
dependencies:
"@babel/runtime": "npm:^7.0.0"
memoize-one: "npm:>=3.1.1 <6"
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/5ae8da1bc5c47d8f0a428b28a600256e2db511975573e52cb65a9b27ed1a0e5b9f7b3bee5a54fb0da93956d782c24010be434be451072f46ba5a89159d2b3944
languageName: node
linkType: hard
"react@npm:^19.0.0":
version: 19.1.0
resolution: "react@npm:19.1.0"