perf: draggable virtual list (#7904)

* perf(TopicsTab): use DraggableVirtualList for the topic list

- Add a DraggableVirtualList implemented using react-virtual
- Rename DragableList to DraggableList
- Add tests

* refactor: improve props, fix drag area
This commit is contained in:
one 2025-07-08 17:05:40 +08:00 committed by GitHub
parent 33da5d31cf
commit da5badc189
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 597 additions and 135 deletions

View File

@ -0,0 +1,2 @@
export { default as DraggableList } from './list'
export { default as DraggableVirtualList } from './virtual-list'

View File

@ -23,7 +23,7 @@ interface Props<T> {
droppableProps?: Partial<DroppableProps>
}
const DragableList: FC<Props<any>> = ({
const DraggableList: FC<Props<any>> = ({
children,
list,
style,
@ -78,4 +78,4 @@ const DragableList: FC<Props<any>> = ({
)
}
export default DragableList
export default DraggableList

View File

@ -0,0 +1,212 @@
import {
DragDropContext,
Draggable,
Droppable,
DroppableProps,
DropResult,
OnDragEndResponder,
OnDragStartResponder,
ResponderProvided
} from '@hello-pangea/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { droppableReorder } from '@renderer/utils'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type Key, memo, useCallback, useRef } from 'react'
/**
* Props DraggableVirtualList
*
* @template T
* @property {string} [className] class
* @property {React.CSSProperties} [style]
* @property {React.CSSProperties} [itemStyle]
* @property {React.CSSProperties} [itemContainerStyle]
* @property {Partial<DroppableProps>} [droppableProps] Droppable
* @property {(list: T[]) => void} onUpdate
* @property {OnDragStartResponder} [onDragStart]
* @property {OnDragEndResponder} [onDragEnd]
* @property {T[]} list
* @property {(index: number) => Key} [itemKey] key使 index
* @property {number} [overscan=5]
* @property {(item: T, index: number) => React.ReactNode} children
*/
interface DraggableVirtualListProps<T> {
ref?: React.Ref<HTMLDivElement>
className?: string
style?: React.CSSProperties
itemStyle?: React.CSSProperties
itemContainerStyle?: React.CSSProperties
droppableProps?: Partial<DroppableProps>
onUpdate: (list: T[]) => void
onDragStart?: OnDragStartResponder
onDragEnd?: OnDragEndResponder
list: T[]
itemKey?: (index: number) => Key
overscan?: number
children: (item: T, index: number) => React.ReactNode
}
/**
*
* -
* @template T
* @param {DraggableVirtualListProps<T>} props
* @returns {React.ReactElement}
*/
function DraggableVirtualList<T>({
ref,
className,
style,
itemStyle,
itemContainerStyle,
droppableProps,
onDragStart,
onUpdate,
onDragEnd,
list,
itemKey,
overscan = 5,
children
}: DraggableVirtualListProps<T>): React.ReactElement {
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided)
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
}
}
// 虚拟列表滚动容器的 ref
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: list.length,
getScrollElement: useCallback(() => parentRef.current, []),
getItemKey: itemKey,
estimateSize: useCallback(() => 50, []),
overscan
})
return (
<div ref={ref} className={`${className} draggable-virtual-list`} style={{ height: '100%', ...style }}>
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
<Droppable
droppableId="droppable"
mode="virtual"
renderClone={(provided, _snapshot, rubric) => {
const item = list[rubric.source.index]
return (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={{
...itemStyle,
...provided.draggableProps.style
}}>
{item && children(item, rubric.source.index)}
</div>
)
}}
{...droppableProps}>
{(provided) => {
// 让 dnd 和虚拟列表共享同一个滚动容器
const setRefs = (el: HTMLDivElement | null) => {
provided.innerRef(el)
parentRef.current = el
}
return (
<Scrollbar
ref={setRefs}
{...provided.droppableProps}
className="virtual-scroller"
style={{
height: '100%',
width: '100%',
overflowY: 'auto',
position: 'relative'
}}>
<div
className="virtual-list"
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<VirtualRow
key={virtualItem.key}
virtualItem={virtualItem}
list={list}
itemStyle={itemStyle}
itemContainerStyle={itemContainerStyle}
virtualizer={virtualizer}
children={children}
/>
))}
</div>
</Scrollbar>
)
}}
</Droppable>
</DragDropContext>
</div>
)
}
/**
*
*/
const VirtualRow = memo(({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer }: any) => {
const item = list[virtualItem.index]
const draggableId = String(virtualItem.key)
return (
<Draggable
key={`draggable_${draggableId}_${virtualItem.index}`}
draggableId={draggableId}
index={virtualItem.index}>
{(provided) => {
const setDragRefs = (el: HTMLElement | null) => {
provided.innerRef(el)
virtualizer.measureElement(el)
}
const dndStyle = provided.draggableProps.style
const virtualizerTransform = `translateY(${virtualItem.start}px)`
// dnd 的 transform 负责拖拽时的位移和让位动画,
// virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置,
// 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。
const combinedTransform = dndStyle?.transform
? `${dndStyle.transform} ${virtualizerTransform}`
: virtualizerTransform
return (
<div
{...provided.draggableProps}
ref={setDragRefs}
className="draggable-item"
data-index={virtualItem.index}
style={{
...itemContainerStyle,
...dndStyle,
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: combinedTransform
}}>
<div {...provided.dragHandleProps} className="draggable-content" style={{ ...itemStyle }}>
{item && children(item, virtualItem.index)}
</div>
</div>
)
}}
</Draggable>
)
})
export default DraggableVirtualList

View File

@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
ref?: React.RefObject<HTMLDivElement | null>
ref?: React.Ref<HTMLDivElement | null>
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}

View File

@ -3,7 +3,7 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DragableList from '../DragableList'
import { DraggableList } from '../DraggableList'
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
@ -49,7 +49,7 @@ declare global {
}
}
describe('DragableList', () => {
describe('DraggableList', () => {
describe('rendering', () => {
it('should render all list items', () => {
const list = [
@ -58,9 +58,9 @@ describe('DragableList', () => {
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
const items = screen.getAllByTestId('item')
expect(items.length).toBe(3)
@ -74,9 +74,9 @@ describe('DragableList', () => {
const style = { background: 'red' }
const listStyle = { color: 'blue' }
render(
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
<DraggableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 检查 style 是否传递到外层容器
const virtualList = screen.getByTestId('virtual-list')
@ -85,9 +85,9 @@ describe('DragableList', () => {
it('should render nothing when list is empty', () => {
render(
<DragableList list={[]} onUpdate={() => {}}>
<DraggableList list={[]} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 虚拟列表存在但无内容
const items = screen.queryAllByTestId('item')
@ -106,9 +106,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
@ -128,9 +128,9 @@ describe('DragableList', () => {
const onDragEnd = vi.fn()
render(
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
<DraggableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 先手动调用 onDragStart
@ -150,9 +150,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 模拟拖拽到自身
@ -168,9 +168,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 拖拽自身
@ -188,9 +188,9 @@ describe('DragableList', () => {
// 不传 onDragStart/onDragEnd
expect(() => {
render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
}).not.toThrow()
@ -201,9 +201,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item}</div>}
</DragableList>
</DraggableList>
)
// 拖拽第0项到第2项
@ -222,9 +222,9 @@ describe('DragableList', () => {
]
render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// placeholder 应该在初始渲染时就存在
@ -240,9 +240,9 @@ describe('DragableList', () => {
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 拖拽第2项到第0项
@ -272,9 +272,9 @@ describe('DragableList', () => {
{ id: 'c', name: 'C' }
]
const { container } = render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
expect(container).toMatchSnapshot()
})

View File

@ -0,0 +1,164 @@
/// <reference types="@vitest/browser/context" />
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DraggableVirtualList from '../DraggableList/virtual-list'
// Mock 依赖项
vi.mock('@hello-pangea/dnd', () => ({
__esModule: true,
DragDropContext: ({ children, onDragEnd, onDragStart }) => {
// 挂载到 window 以便测试用例直接调用
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
onDragEnd?.(result, provided)
}
window.triggerOnDragStart = (result = { source: { index: 0 } }, provided = {}) => {
onDragStart?.(result, provided)
}
return <div data-testid="drag-drop-context">{children}</div>
},
Droppable: ({ children, renderClone }) => (
<div data-testid="droppable">
{/* 模拟 renderClone 的调用 */}
{renderClone &&
renderClone({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {}, { source: { index: 0 } })}
{children({ droppableProps: {}, innerRef: vi.fn() })}
</div>
),
Draggable: ({ children, draggableId, index }) => (
<div data-testid={`draggable-${draggableId}-${index}`}>
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {})}
</div>
)
}))
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }) => ({
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 50,
size: 50
})),
getTotalSize: () => count * 50,
measureElement: vi.fn()
})
}))
vi.mock('react-virtualized-auto-sizer', () => ({
__esModule: true,
default: ({ children }) => <div data-testid="auto-sizer">{children({ height: 500, width: 300 })}</div>
}))
vi.mock('@renderer/components/Scrollbar', () => ({
__esModule: true,
default: ({ ref, children, ...props }) => (
<div ref={ref} {...props} data-testid="scrollbar">
{children}
</div>
)
}))
declare global {
interface Window {
triggerOnDragEnd: (result?: any, provided?: any) => void
triggerOnDragStart: (result?: any, provided?: any) => void
}
}
describe('DraggableVirtualList', () => {
const sampleList = [
{ id: 'a', name: 'Item A' },
{ id: 'b', name: 'Item B' },
{ id: 'c', name: 'Item C' }
]
describe('rendering', () => {
it('should render all list items provided', () => {
render(
<DraggableVirtualList list={sampleList} onUpdate={() => {}}>
{(item) => <div data-testid="test-item">{item.name}</div>}
</DraggableVirtualList>
)
const items = screen.getAllByTestId('test-item')
// 我们的 mock 中renderClone 会渲染一个额外的 item
expect(items.length).toBe(sampleList.length + 1)
expect(items[0]).toHaveTextContent('Item A')
expect(items[1]).toHaveTextContent('Item A')
expect(items[2]).toHaveTextContent('Item B')
expect(items[3]).toHaveTextContent('Item C')
})
it('should render nothing when the list is empty', () => {
render(
<DraggableVirtualList list={[]} onUpdate={() => {}}>
{/* @ts-ignore test*/}
{(item) => <div data-testid="test-item">{item.name}</div>}
</DraggableVirtualList>
)
const items = screen.queryAllByTestId('test-item')
expect(items.length).toBe(0)
})
})
describe('drag and drop', () => {
it('should call onUpdate with the new order after a drag operation', () => {
const onUpdate = vi.fn()
render(
<DraggableVirtualList list={sampleList} onUpdate={onUpdate}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } })
const expectedOrder = [sampleList[1], sampleList[2], sampleList[0]] // B, C, A
expect(onUpdate).toHaveBeenCalledWith(expectedOrder)
})
it('should call onDragStart and onDragEnd callbacks', () => {
const onDragStart = vi.fn()
const onDragEnd = vi.fn()
render(
<DraggableVirtualList list={sampleList} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
window.triggerOnDragStart()
expect(onDragStart).toHaveBeenCalledTimes(1)
window.triggerOnDragEnd()
expect(onDragEnd).toHaveBeenCalledTimes(1)
})
it('should not call onUpdate if destination is not defined', () => {
const onUpdate = vi.fn()
render(
<DraggableVirtualList list={sampleList} onUpdate={onUpdate}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: null })
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('snapshot', () => {
it('should match snapshot with custom styles', () => {
const { container } = render(
<DraggableVirtualList
list={sampleList}
onUpdate={() => {}}
className="custom-class"
style={{ border: '1px solid red' }}
itemStyle={{ background: 'blue' }}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
expect(container).toMatchSnapshot()
})
})
})

View File

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DragableList > snapshot > should match snapshot 1`] = `
exports[`DraggableList > snapshot > should match snapshot 1`] = `
<div>
<div
data-testid="drag-drop-context"

View File

@ -0,0 +1,91 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DraggableVirtualList > snapshot > should match snapshot with custom styles 1`] = `
<div>
<div
class="custom-class draggable-virtual-list"
style="height: 100%; border: 1px solid red;"
>
<div
data-testid="drag-drop-context"
>
<div
data-testid="droppable"
>
<div
style="background: blue;"
>
<div>
Item A
</div>
</div>
<div
class="virtual-scroller"
data-testid="scrollbar"
style="height: 100%; width: 100%; overflow-y: auto; position: relative;"
>
<div
class="virtual-list"
style="height: 150px; width: 100%; position: relative;"
>
<div
data-testid="draggable-0-0"
>
<div
class="draggable-item"
data-index="0"
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(0px);"
>
<div
class="draggable-content"
style="background: blue;"
>
<div>
Item A
</div>
</div>
</div>
</div>
<div
data-testid="draggable-1-1"
>
<div
class="draggable-item"
data-index="1"
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(50px);"
>
<div
class="draggable-content"
style="background: blue;"
>
<div>
Item B
</div>
</div>
</div>
</div>
<div
data-testid="draggable-2-2"
>
<div
class="draggable-item"
data-index="2"
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(100px);"
>
<div
class="draggable-content"
style="background: blue;"
>
<div>
Item C
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -33,7 +33,7 @@ import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import DragableList from '../DragableList'
import { DraggableList } from '../DraggableList'
import MinAppIcon from '../Icons/MinAppIcon'
import UserPopup from '../Popups/UserPopup'
@ -288,7 +288,7 @@ const PinnedApps: FC = () => {
const { openMinappKeepAlive } = useMinappPopup()
return (
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
<DraggableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
{(app) => {
const menuItems: MenuProps['items'] = [
{
@ -316,7 +316,7 @@ const PinnedApps: FC = () => {
</Tooltip>
)
}}
</DragableList>
</DraggableList>
)
}

View File

@ -1,5 +1,5 @@
import { MenuOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import { Box, HStack } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
@ -43,7 +43,7 @@ const PopupContainer: React.FC = () => {
centered>
<Container>
{agents.length > 0 && (
<DragableList list={agents} onUpdate={updateAgents}>
<DraggableList list={agents} onUpdate={updateAgents}>
{(item) => (
<AgentItem>
<Box mr={8}>
@ -54,7 +54,7 @@ const PopupContainer: React.FC = () => {
</HStack>
</AgentItem>
)}
</DragableList>
</DraggableList>
)}
{agents.length === 0 && <Empty description="" />}
</Container>

View File

@ -1,5 +1,5 @@
import { DownOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants } from '@renderer/hooks/useAssistant'
@ -92,7 +92,7 @@ const Assistants: FC<AssistantsTabProps> = ({
)}
{!collapsedTags[group.tag] && (
<div>
<DragableList
<DraggableList
list={group.assistants}
onUpdate={(newList) => handleGroupReorder(group.tag, newList)}
onDragStart={() => setDragging(true)}
@ -111,7 +111,7 @@ const Assistants: FC<AssistantsTabProps> = ({
handleSortByChange={handleSortByChange}
/>
)}
</DragableList>
</DraggableList>
</div>
)}
</TagsContainer>
@ -129,7 +129,7 @@ const Assistants: FC<AssistantsTabProps> = ({
return (
<Container className="assistants-tab" ref={containerRef}>
<DragableList
<DraggableList
list={assistants}
onUpdate={updateAssistants}
onDragStart={() => setDragging(true)}
@ -148,7 +148,7 @@ const Assistants: FC<AssistantsTabProps> = ({
handleSortByChange={handleSortByChange}
/>
)}
</DragableList>
</DraggableList>
{!dragging && (
<AssistantAddItem onClick={onCreateAssistant}>
<AssistantName>

View File

@ -9,11 +9,10 @@ import {
QuestionCircleOutlined,
UploadOutlined
} from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableVirtualList as DraggableList } from '@renderer/components/DraggableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { isMac } from '@renderer/config/constant'
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useRuntime'
@ -447,92 +446,86 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
}, [assistant.topics, pinTopicsToTop])
return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<Container className="topics-tab">
<DragableList list={sortedTopics} onUpdate={updateTopics}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '')
const topicPrompt = topic.prompt
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
<DraggableList
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ padding: '10px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '')
const topicPrompt = topic.prompt
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
const getTopicNameClassName = () => {
if (isRenaming(topic.id)) return 'shimmer'
if (isNewlyRenamed(topic.id)) return 'typing'
return ''
}
const getTopicNameClassName = () => {
if (isRenaming(topic.id)) return 'shimmer'
if (isNewlyRenamed(topic.id)) return 'typing'
return ''
}
return (
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
className={isActive ? 'active' : ''}
onClick={() => onSwitchTopic(topic)}
style={{ borderRadius }}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
<TopicNameContainer>
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
</TopicName>
{!topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
title={
<div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
className={isActive ? 'active' : ''}
onClick={() => onSwitchTopic(topic)}
style={{ borderRadius }}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
<TopicNameContainer>
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
</TopicName>
{!topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
title={
<div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{deletingTopicId === topic.id ? (
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
) : (
<CloseOutlined />
)}
</MenuButton>
</Tooltip>
)}
{topic.pinned && (
<MenuButton className="pin">
<PushpinOutlined />
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{deletingTopicId === topic.id ? (
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
) : (
<CloseOutlined />
)}
</MenuButton>
)}
</TopicNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
</TopicPromptText>
</Tooltip>
)}
{showTopicTime && (
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>
{topic.pinned && (
<MenuButton className="pin">
<PushpinOutlined />
</MenuButton>
)}
</TopicListItem>
)
}}
</DragableList>
<div style={{ minHeight: '10px' }}></div>
</Container>
</Dropdown>
</TopicNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
</TopicPromptText>
)}
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
</TopicListItem>
</Dropdown>
)
}}
</DraggableList>
)
}
const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 10px;
`
const TopicListItem = styled.div`
padding: 7px 12px;
border-radius: var(--list-item-border-radius);

View File

@ -1,6 +1,6 @@
import { DeleteOutlined, EditOutlined, SettingOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import ListItem from '@renderer/components/ListItem'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import Scrollbar from '@renderer/components/Scrollbar'
@ -98,7 +98,7 @@ const KnowledgePage: FC = () => {
<ContentContainer id="content-container">
<KnowledgeSideNav>
<ScrollContainer>
<DragableList
<DraggableList
list={bases}
onUpdate={updateKnowledgeBases}
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
@ -116,7 +116,7 @@ const KnowledgePage: FC = () => {
</div>
</Dropdown>
)}
</DragableList>
</DraggableList>
{!isDragging && (
<AddKnowledgeItem onClick={handleAddKnowledge}>
<AddKnowledgeName>

View File

@ -1,5 +1,5 @@
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import Scrollbar from '@renderer/components/Scrollbar'
import { usePaintings } from '@renderer/hooks/usePaintings'
import FileManager from '@renderer/services/FileManager'
@ -38,7 +38,7 @@ const PaintingsList: FC<PaintingsListProps> = ({
<PlusOutlined />
</NewPaintingButton>
)}
<DragableList
<DraggableList
list={paintings}
onUpdate={(value) => updatePaintings(namespace, value)}
onDragStart={() => setDragging(true)}
@ -61,7 +61,7 @@ const PaintingsList: FC<PaintingsListProps> = ({
</DeleteButton>
</CanvasWrapper>
)}
</DragableList>
</DraggableList>
</Container>
)
}

View File

@ -1,5 +1,5 @@
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import FileItem from '@renderer/pages/files/FileItem'
import { Assistant, QuickPhrase } from '@renderer/types'
import { Button, Flex, Input, Modal, Popconfirm, Space } from 'antd'
@ -87,7 +87,7 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
<SettingDivider />
<SettingRow>
<StyledPromptList>
<DragableList
<DraggableList
list={reversedPrompts}
onUpdate={(newPrompts) => handleUpdateOrder([...newPrompts].reverse())}
style={{ paddingBottom: dragging ? '34px' : 0 }}
@ -120,7 +120,7 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
}}
/>
)}
</DragableList>
</DraggableList>
</StyledPromptList>
</SettingRow>

View File

@ -1,6 +1,6 @@
import { EditOutlined } from '@ant-design/icons'
import { nanoid } from '@reduxjs/toolkit'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer } from '@renderer/types'
@ -117,7 +117,7 @@ const McpServersList: FC = () => {
</Button>
</ButtonGroup>
</ListHeader>
<DragableList style={{ width: '100%' }} list={mcpServers} onUpdate={updateMcpServers}>
<DraggableList style={{ width: '100%' }} list={mcpServers} onUpdate={updateMcpServers}>
{(server: MCPServer) => (
<ServerCard key={server.id} onClick={() => navigate(`/settings/mcp/settings`, { state: { server } })}>
<ServerHeader>
@ -171,7 +171,7 @@ const McpServersList: FC = () => {
</ServerFooter>
</ServerCard>
)}
</DragableList>
</DraggableList>
{mcpServers.length === 0 && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}

View File

@ -1,5 +1,5 @@
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import { useTheme } from '@renderer/context/ThemeProvider'
import FileItem from '@renderer/pages/files/FileItem'
import QuickPhraseService from '@renderer/services/QuickPhraseService'
@ -79,7 +79,7 @@ const QuickPhraseSettings: FC = () => {
<SettingDivider />
<SettingRow>
<QuickPhraseList>
<DragableList
<DraggableList
list={reversedPhrases}
onUpdate={(newPhrases) => handleUpdateOrder([...newPhrases].reverse())}
style={{ paddingBottom: dragging ? '34px' : 0 }}
@ -109,7 +109,7 @@ const QuickPhraseSettings: FC = () => {
}}
/>
)}
</DragableList>
</DraggableList>
</QuickPhraseList>
</SettingRow>
</SettingGroup>