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)
// 添加更新item选中状态的方法
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
const open = useCallback((options: QuickPanelOpenOptions) => {
if (clearTimer.current) {
clearTimeout(clearTimer.current)
@ -77,6 +82,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
() => ({
open,
close,
updateItemSelection,
isVisible,
symbol,
@ -90,7 +96,21 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
beforeAction,
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>

View File

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

View File

@ -50,7 +50,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [isMouseOver, setIsMouseOver] = useState(false)
const scrollTriggerRef = useRef<QuickPanelScrollTrigger>('initial')
const [_index, setIndex] = useState(ctx.defaultIndex)
const [_index, setIndex] = useState(-1)
const index = useDeferredValue(_index)
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
@ -62,6 +62,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 处理搜索,过滤列表
const list = useMemo(() => {
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
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
@ -168,12 +189,33 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
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 = {
symbol: ctx.symbol,
action,
item,
searchText: searchText,
multiple: isAssistiveKeyPressed
multiple: ctx.multiple
}
ctx.beforeAction?.(quickPanelCallBackOptions)
@ -200,11 +242,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return
}
if (ctx.multiple && isAssistiveKeyPressed) return
// 多选模式下不关闭面板
if (ctx.multiple) return
handleClose(action)
},
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
[ctx, searchText, handleClose, clearSearchText, index]
)
useEffect(() => {
@ -294,12 +337,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) {
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
const newIndex = prev - ctx.pageSize
if (prev === 0) return list.length - 1
return newIndex < 0 ? 0 : newIndex
})
} 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
@ -307,18 +354,23 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) {
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? 0 : -1
const newIndex = prev + ctx.pageSize
if (prev + 1 === list.length) return 0
return newIndex >= list.length ? list.length - 1 : newIndex
})
} 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
case 'PageUp':
scrollTriggerRef.current = 'keyboard'
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.max(0, list.length - ctx.pageSize) : -1
const newIndex = prev - ctx.pageSize
return newIndex < 0 ? 0 : newIndex
})
@ -327,6 +379,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
case 'PageDown':
scrollTriggerRef.current = 'keyboard'
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.min(ctx.pageSize - 1, list.length - 1) : -1
const newIndex = prev + ctx.pageSize
return newIndex >= list.length ? list.length - 1 : newIndex
})
@ -421,10 +474,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(): VirtualizedRowData => ({
list,
focusedIndex: index,
handleItemAction,
setIndex
handleItemAction
}),
[list, index, handleItemAction, setIndex]
[list, index, handleItemAction]
)
return (
@ -487,15 +539,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
<Flex align="center" gap={4}>
{t('settings.quickPanel.confirm')}
</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>
</QuickPanelFooter>
</QuickPanelBody>
@ -507,7 +550,6 @@ interface VirtualizedRowData {
list: QuickPanelListItem[]
focusedIndex: number
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
setIndex: (index: number) => void
}
/**
@ -515,7 +557,7 @@ interface VirtualizedRowData {
*/
const VirtualizedRow = React.memo(
({ 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]
if (!item) return null
@ -531,8 +573,7 @@ const VirtualizedRow = React.memo(
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}
onMouseEnter={() => setIndex(index)}>
}}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
@ -651,11 +692,19 @@ const QuickPanelItem = styled.div`
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
&:hover:not(.disabled) {
background-color: var(--focused-color);
}
&.selected {
background-color: var(--selected-color);
&.focused {
background-color: var(--selected-color-dark);
}
&:hover:not(.disabled) {
background-color: var(--selected-color-dark);
}
}
&.focused {
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)
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 focused = item1.closest('.focused')
expect(focused).not.toBeNull()
expect(item1).toBeInTheDocument()
const focusedItem1 = item1.closest('.focused')
expect(focusedItem1).toBeNull()
})
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
@ -154,10 +159,11 @@ describe('QuickPanelView', () => {
)
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: 'ArrowDown', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }
{ key: 'ArrowDown', expected: 'Item 1' } // 从最后一个按 ArrowDown 会循环到第一个
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@ -176,11 +182,11 @@ describe('QuickPanelView', () => {
)
const keySequence = [
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
{ key: 'PageDown', expected: 'Item 100' }
{ key: 'PageDown', expected: `Item ${PAGE_SIZE}` }, // 从未选中状态按 PageDown 会选中第 pageSize 个项目
{ key: 'PageUp', expected: 'Item 1' }, // PageUp 会选中第一个
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会到最后一个
{ key: 'PageDown', expected: 'Item 100' }, // 从最后一个按 PageDown 仍然是最后一个
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` } // PageUp 会向上翻页从索引99到92对应Item 93
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@ -199,10 +205,11 @@ describe('QuickPanelView', () => {
)
const keySequence = [
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }, // 从未选中状态按 Ctrl+ArrowDown 会选中第一个
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, // Ctrl+ArrowDown 会跳转 pageSize 个位置
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, // Ctrl+ArrowUp 会跳转回去
{ 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)

View File

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

View File

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