refactor: quick panel remove multi-select mode

This commit is contained in:
kangfenmao 2025-07-09 20:39:41 +08:00
parent 559fcecf77
commit d7002cda11
6 changed files with 126 additions and 43 deletions

View File

@ -27,6 +27,11 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const clearTimer = useRef<NodeJS.Timeout | null>(null) const clearTimer = useRef<NodeJS.Timeout | null>(null)
// 添加更新item选中状态的方法
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
const open = useCallback((options: QuickPanelOpenOptions) => { const open = useCallback((options: QuickPanelOpenOptions) => {
if (clearTimer.current) { if (clearTimer.current) {
clearTimeout(clearTimer.current) clearTimeout(clearTimer.current)
@ -77,6 +82,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
() => ({ () => ({
open, open,
close, close,
updateItemSelection,
isVisible, isVisible,
symbol, symbol,
@ -90,7 +96,21 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
beforeAction, beforeAction,
afterAction afterAction
}), }),
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction] [
open,
close,
updateItemSelection,
isVisible,
symbol,
list,
title,
defaultIndex,
pageSize,
multiple,
onClose,
beforeAction,
afterAction
]
) )
return <QuickPanelContext value={value}>{children}</QuickPanelContext> return <QuickPanelContext value={value}>{children}</QuickPanelContext>

View File

@ -52,6 +52,7 @@ export type QuickPanelListItem = {
export interface QuickPanelContextType { export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction) => void readonly close: (action?: QuickPanelCloseAction) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
readonly isVisible: boolean readonly isVisible: boolean
readonly symbol: string readonly symbol: string
readonly list: QuickPanelListItem[] readonly list: QuickPanelListItem[]

View File

@ -50,7 +50,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [isMouseOver, setIsMouseOver] = useState(false) const [isMouseOver, setIsMouseOver] = useState(false)
const scrollTriggerRef = useRef<QuickPanelScrollTrigger>('initial') const scrollTriggerRef = useRef<QuickPanelScrollTrigger>('initial')
const [_index, setIndex] = useState(ctx.defaultIndex) const [_index, setIndex] = useState(-1)
const index = useDeferredValue(_index) const index = useDeferredValue(_index)
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([]) const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
@ -62,6 +62,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText) const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('') const searchTextRef = useRef('')
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 处理搜索,过滤列表 // 处理搜索,过滤列表
const list = useMemo(() => { const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return [] if (!ctx.isVisible && !ctx.symbol) return []
@ -104,7 +108,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
} }
}) })
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1) // 只有在搜索文本变化或面板符号变化时才重置index
const isSearchChanged = prevSearchTextRef.current !== searchText
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
if (isSearchChanged || isSymbolChanged) {
setIndex(-1) // 不默认高亮任何项,让用户主动选择
} else {
// 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => {
if (prevIndex >= newList.length) {
return newList.length > 0 ? newList.length - 1 : -1
}
return prevIndex
})
}
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
return newList return newList
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText]) }, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
@ -168,12 +189,33 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => { (item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
if (item.disabled) return if (item.disabled) return
// 在多选模式下,先更新选中状态
if (ctx.multiple && !item.isMenu) {
const newSelectedState = !item.isSelected
ctx.updateItemSelection(item, newSelectedState)
// 创建更新后的item对象用于回调
const updatedItem = { ...item, isSelected: newSelectedState }
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol,
action,
item: updatedItem,
searchText: searchText,
multiple: ctx.multiple
}
ctx.beforeAction?.(quickPanelCallBackOptions)
item?.action?.(quickPanelCallBackOptions)
ctx.afterAction?.(quickPanelCallBackOptions)
return
}
const quickPanelCallBackOptions: QuickPanelCallBackOptions = { const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol, symbol: ctx.symbol,
action, action,
item, item,
searchText: searchText, searchText: searchText,
multiple: isAssistiveKeyPressed multiple: ctx.multiple
} }
ctx.beforeAction?.(quickPanelCallBackOptions) ctx.beforeAction?.(quickPanelCallBackOptions)
@ -200,11 +242,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return return
} }
if (ctx.multiple && isAssistiveKeyPressed) return // 多选模式下不关闭面板
if (ctx.multiple) return
handleClose(action) handleClose(action)
}, },
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index] [ctx, searchText, handleClose, clearSearchText, index]
) )
useEffect(() => { useEffect(() => {
@ -294,12 +337,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard' scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) { if (isAssistiveKeyPressed) {
setIndex((prev) => { setIndex((prev) => {
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
const newIndex = prev - ctx.pageSize const newIndex = prev - ctx.pageSize
if (prev === 0) return list.length - 1 if (prev === 0) return list.length - 1
return newIndex < 0 ? 0 : newIndex return newIndex < 0 ? 0 : newIndex
}) })
} else { } else {
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1)) setIndex((prev) => {
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
return prev > 0 ? prev - 1 : list.length - 1
})
} }
break break
@ -307,18 +354,23 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard' scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) { if (isAssistiveKeyPressed) {
setIndex((prev) => { setIndex((prev) => {
if (prev === -1) return list.length > 0 ? 0 : -1
const newIndex = prev + ctx.pageSize const newIndex = prev + ctx.pageSize
if (prev + 1 === list.length) return 0 if (prev + 1 === list.length) return 0
return newIndex >= list.length ? list.length - 1 : newIndex return newIndex >= list.length ? list.length - 1 : newIndex
}) })
} else { } else {
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0)) setIndex((prev) => {
if (prev === -1) return list.length > 0 ? 0 : -1
return prev < list.length - 1 ? prev + 1 : 0
})
} }
break break
case 'PageUp': case 'PageUp':
scrollTriggerRef.current = 'keyboard' scrollTriggerRef.current = 'keyboard'
setIndex((prev) => { setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.max(0, list.length - ctx.pageSize) : -1
const newIndex = prev - ctx.pageSize const newIndex = prev - ctx.pageSize
return newIndex < 0 ? 0 : newIndex return newIndex < 0 ? 0 : newIndex
}) })
@ -327,6 +379,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
case 'PageDown': case 'PageDown':
scrollTriggerRef.current = 'keyboard' scrollTriggerRef.current = 'keyboard'
setIndex((prev) => { setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.min(ctx.pageSize - 1, list.length - 1) : -1
const newIndex = prev + ctx.pageSize const newIndex = prev + ctx.pageSize
return newIndex >= list.length ? list.length - 1 : newIndex return newIndex >= list.length ? list.length - 1 : newIndex
}) })
@ -421,10 +474,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(): VirtualizedRowData => ({ (): VirtualizedRowData => ({
list, list,
focusedIndex: index, focusedIndex: index,
handleItemAction, handleItemAction
setIndex
}), }),
[list, index, handleItemAction, setIndex] [list, index, handleItemAction]
) )
return ( return (
@ -487,15 +539,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
<Flex align="center" gap={4}> <Flex align="center" gap={4}>
{t('settings.quickPanel.confirm')} {t('settings.quickPanel.confirm')}
</Flex> </Flex>
{ctx.multiple && (
<Flex align="center" gap={4}>
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
{ASSISTIVE_KEY}
</span>
+ {t('settings.quickPanel.multiple')}
</Flex>
)}
</QuickPanelFooterTips> </QuickPanelFooterTips>
</QuickPanelFooter> </QuickPanelFooter>
</QuickPanelBody> </QuickPanelBody>
@ -507,7 +550,6 @@ interface VirtualizedRowData {
list: QuickPanelListItem[] list: QuickPanelListItem[]
focusedIndex: number focusedIndex: number
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
setIndex: (index: number) => void
} }
/** /**
@ -515,7 +557,7 @@ interface VirtualizedRowData {
*/ */
const VirtualizedRow = React.memo( const VirtualizedRow = React.memo(
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
const { list, focusedIndex, handleItemAction, setIndex } = data const { list, focusedIndex, handleItemAction } = data
const item = list[index] const item = list[index]
if (!item) return null if (!item) return null
@ -531,8 +573,7 @@ const VirtualizedRow = React.memo(
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleItemAction(item, 'click') handleItemAction(item, 'click')
}} }}>
onMouseEnter={() => setIndex(index)}>
<QuickPanelItemLeft> <QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon> <QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel> <QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
@ -651,11 +692,19 @@ const QuickPanelItem = styled.div`
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: background-color 0.1s ease; transition: background-color 0.1s ease;
&:hover:not(.disabled) {
background-color: var(--focused-color);
}
&.selected { &.selected {
background-color: var(--selected-color); background-color: var(--selected-color);
&.focused { &.focused {
background-color: var(--selected-color-dark); background-color: var(--selected-color-dark);
} }
&:hover:not(.disabled) {
background-color: var(--selected-color-dark);
}
} }
&.focused { &.focused {
background-color: var(--focused-color); background-color: var(--focused-color);

View File

@ -122,7 +122,7 @@ describe('QuickPanelView', () => {
} }
} }
it('should focus on the first item after panel open', () => { it('should not focus on any item after panel open by default', () => {
const list = createList(100) const list = createList(100)
render( render(
@ -134,11 +134,16 @@ describe('QuickPanelView', () => {
) )
) )
// 检查第一个 item 是否有 focused // 检查是否没有任何 focused item
const panel = screen.getByTestId('quick-panel')
const focused = panel.querySelectorAll('.focused')
expect(focused.length).toBe(0)
// 检查第一个 item 存在但没有 focused 类
const item1 = screen.getByText('Item 1') const item1 = screen.getByText('Item 1')
const focused = item1.closest('.focused')
expect(focused).not.toBeNull()
expect(item1).toBeInTheDocument() expect(item1).toBeInTheDocument()
const focusedItem1 = item1.closest('.focused')
expect(focusedItem1).toBeNull()
}) })
it('should focus on the right item using ArrowUp, ArrowDown', async () => { it('should focus on the right item using ArrowUp, ArrowDown', async () => {
@ -154,10 +159,11 @@ describe('QuickPanelView', () => {
) )
const keySequence = [ const keySequence = [
{ key: 'ArrowUp', expected: 'Item 100' }, { key: 'ArrowDown', expected: 'Item 1' }, // 从未选中状态按 ArrowDown 会选中第一个
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会循环到最后一个
{ key: 'ArrowUp', expected: 'Item 99' }, { key: 'ArrowUp', expected: 'Item 99' },
{ key: 'ArrowDown', expected: 'Item 100' }, { key: 'ArrowDown', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' } { key: 'ArrowDown', expected: 'Item 1' } // 从最后一个按 ArrowDown 会循环到第一个
] ]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@ -176,11 +182,11 @@ describe('QuickPanelView', () => {
) )
const keySequence = [ const keySequence = [
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部 { key: 'PageDown', expected: `Item ${PAGE_SIZE}` }, // 从未选中状态按 PageDown 会选中第 pageSize 个项目
{ key: 'ArrowUp', expected: 'Item 100' }, { key: 'PageUp', expected: 'Item 1' }, // PageUp 会选中第一个
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部 { key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会到最后一个
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` }, { key: 'PageDown', expected: 'Item 100' }, // 从最后一个按 PageDown 仍然是最后一个
{ key: 'PageDown', expected: 'Item 100' } { key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` } // PageUp 会向上翻页从索引99到92对应Item 93
] ]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@ -199,10 +205,11 @@ describe('QuickPanelView', () => {
) )
const keySequence = [ const keySequence = [
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, { key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }, // 从未选中状态按 Ctrl+ArrowDown 会选中第一个
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, { key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, // Ctrl+ArrowDown 会跳转 pageSize 个位置
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, { key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, // Ctrl+ArrowUp 会跳转回去
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } { key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, // 从第一个位置再按 Ctrl+ArrowUp 会循环到最后
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } // 从最后位置按 Ctrl+ArrowDown 会循环到第一个
] ]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)

View File

@ -864,7 +864,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
onInput={onInput} onInput={onInput}
disabled={searching} disabled={searching}
onPaste={(e) => onPaste(e.nativeEvent)} onPaste={(e) => onPaste(e.nativeEvent)}
onClick={() => searching && dispatch(setSearching(false))} onClick={() => {
searching && dispatch(setSearching(false))
quickPanel.close()
}}
/> />
<DragHandle onMouseDown={handleDragStart}> <DragHandle onMouseDown={handleDragStart}>
<HolderOutlined /> <HolderOutlined />

View File

@ -183,12 +183,15 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
label: t('common.close'), label: t('common.close'),
description: t('settings.mcp.disable.description'), description: t('settings.mcp.disable.description'),
icon: <CircleX />, icon: <CircleX />,
isSelected: !(assistant.mcpServers && assistant.mcpServers.length > 0), isSelected: false,
action: () => updateMcpEnabled(false) action: () => {
updateMcpEnabled(false)
quickPanel.close()
}
}) })
return newList return newList
}, [activedMcpServers, t, assistant.mcpServers, assistantMcpServers, navigate, updateMcpEnabled]) }, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
const openQuickPanel = useCallback(() => { const openQuickPanel = useCallback(() => {
quickPanel.open({ quickPanel.open({