mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
refactor: quick panel remove multi-select mode
This commit is contained in:
parent
559fcecf77
commit
d7002cda11
@ -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>
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user