mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 14:31:35 +08:00
refactor: add a custom dynamic virtual list component (#8711)
- add a custom dynamic virtual list component - add tests - support autohide - used in ManageModelsList, ModelListGroup, KnowledgePage, FileList - improve DraggableVirtualList - use name DraggableVirtualList directly, make it flex by default - use DraggableVirtualList in ProviderList
This commit is contained in:
parent
9217101032
commit
2711cf5c27
@ -35,6 +35,7 @@ interface DraggableVirtualListProps<T> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
scrollerStyle?: React.CSSProperties
|
||||
itemStyle?: React.CSSProperties
|
||||
itemContainerStyle?: React.CSSProperties
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
@ -43,6 +44,7 @@ interface DraggableVirtualListProps<T> {
|
||||
onDragEnd?: OnDragEndResponder
|
||||
list: T[]
|
||||
itemKey?: (index: number) => Key
|
||||
estimateSize?: (index: number) => number
|
||||
overscan?: number
|
||||
header?: React.ReactNode
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
@ -59,6 +61,7 @@ function DraggableVirtualList<T>({
|
||||
ref,
|
||||
className,
|
||||
style,
|
||||
scrollerStyle,
|
||||
itemStyle,
|
||||
itemContainerStyle,
|
||||
droppableProps,
|
||||
@ -67,6 +70,7 @@ function DraggableVirtualList<T>({
|
||||
onDragEnd,
|
||||
list,
|
||||
itemKey,
|
||||
estimateSize: _estimateSize,
|
||||
overscan = 5,
|
||||
header,
|
||||
children
|
||||
@ -88,12 +92,15 @@ function DraggableVirtualList<T>({
|
||||
count: list?.length ?? 0,
|
||||
getScrollElement: useCallback(() => parentRef.current, []),
|
||||
getItemKey: itemKey,
|
||||
estimateSize: useCallback(() => 50, []),
|
||||
estimateSize: useCallback((index) => _estimateSize?.(index) ?? 50, [_estimateSize]),
|
||||
overscan
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`${className} draggable-virtual-list`} style={{ height: '100%', ...style }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${className} draggable-virtual-list`}
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
{header}
|
||||
<Droppable
|
||||
@ -128,6 +135,7 @@ function DraggableVirtualList<T>({
|
||||
{...provided.droppableProps}
|
||||
className="virtual-scroller"
|
||||
style={{
|
||||
...scrollerStyle,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflowY: 'auto',
|
||||
|
||||
@ -3,14 +3,14 @@ import CustomTag from '@renderer/components/CustomTag'
|
||||
import ExpandableText from '@renderer/components/ExpandableText'
|
||||
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
|
||||
import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Button, Flex, Tooltip } from 'antd'
|
||||
import { Avatar } from 'antd'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -39,8 +39,6 @@ interface ManageModelsListProps {
|
||||
|
||||
const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provider, onAddModel, onRemoveModel }) => {
|
||||
const { t } = useTranslation()
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const activeStickyIndexRef = useRef(0)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState(new Set<string>())
|
||||
|
||||
const handleGroupToggle = useCallback((groupName: string) => {
|
||||
@ -74,33 +72,6 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
|
||||
return rows
|
||||
}, [modelGroups, collapsedGroups])
|
||||
|
||||
// 找到所有组 header 的索引
|
||||
const stickyIndexes = useMemo(() => {
|
||||
return flatRows.map((row, index) => (row.type === 'group' ? index : -1)).filter((index) => index !== -1)
|
||||
}, [flatRows])
|
||||
|
||||
const isSticky = useCallback((index: number) => stickyIndexes.includes(index), [stickyIndexes])
|
||||
|
||||
const isActiveSticky = useCallback((index: number) => activeStickyIndexRef.current === index, [])
|
||||
|
||||
// 自定义 range extractor 用于 sticky header
|
||||
const rangeExtractor = useCallback(
|
||||
(range: any) => {
|
||||
activeStickyIndexRef.current = [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0
|
||||
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
|
||||
return [...next].sort((a, b) => a - b)
|
||||
},
|
||||
[stickyIndexes]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatRows.length,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
estimateSize: () => 42,
|
||||
rangeExtractor,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
const renderGroupTools = useCallback(
|
||||
(models: Model[]) => {
|
||||
const isAllInProvider = models.every((model) => isModelInProvider(provider, model.id))
|
||||
@ -153,79 +124,47 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
|
||||
[provider, onRemoveModel, onAddModel, t]
|
||||
)
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
|
||||
return (
|
||||
<ListContainer ref={scrollerRef}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const row = flatRows[virtualItem.index]
|
||||
const isRowSticky = isSticky(virtualItem.index)
|
||||
const isRowActiveSticky = isActiveSticky(virtualItem.index)
|
||||
const isCollapsed = row.type === 'group' && collapsedGroups.has(row.groupName)
|
||||
|
||||
if (!row) return null
|
||||
|
||||
<DynamicVirtualList
|
||||
list={flatRows}
|
||||
estimateSize={useCallback(() => 60, [])}
|
||||
isSticky={useCallback((index: number) => flatRows[index].type === 'group', [flatRows])}
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
paddingRight: '10px'
|
||||
}}
|
||||
itemContainerStyle={{
|
||||
paddingBottom: '8px'
|
||||
}}>
|
||||
{(row) => {
|
||||
if (row.type === 'group') {
|
||||
const isCollapsed = collapsedGroups.has(row.groupName)
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.index}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
...(isRowSticky
|
||||
? {
|
||||
background: 'var(--color-background)',
|
||||
zIndex: 1
|
||||
}
|
||||
: {}),
|
||||
...(isRowActiveSticky
|
||||
? {
|
||||
position: 'sticky'
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
transform: `translateY(${virtualItem.start}px)`
|
||||
}),
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%'
|
||||
}}>
|
||||
{row.type === 'group' ? (
|
||||
<GroupHeader onClick={() => handleGroupToggle(row.groupName)}>
|
||||
<Flex align="center" gap={10} style={{ flex: 1 }}>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color="var(--color-text-3)"
|
||||
strokeWidth={1.5}
|
||||
style={{ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' }}
|
||||
/>
|
||||
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>{row.groupName}</span>
|
||||
<CustomTag color="#02B96B" size={10}>
|
||||
{row.models.length}
|
||||
</CustomTag>
|
||||
</Flex>
|
||||
{renderGroupTools(row.models)}
|
||||
</GroupHeader>
|
||||
) : (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<ModelListItem
|
||||
model={row.model}
|
||||
provider={provider}
|
||||
onAddModel={onAddModel}
|
||||
onRemoveModel={onRemoveModel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<GroupHeader
|
||||
style={{ background: 'var(--color-background)' }}
|
||||
onClick={() => handleGroupToggle(row.groupName)}>
|
||||
<Flex align="center" gap={10} style={{ flex: 1 }}>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color="var(--color-text-3)"
|
||||
strokeWidth={1.5}
|
||||
style={{ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' }}
|
||||
/>
|
||||
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>{row.groupName}</span>
|
||||
<CustomTag color="#02B96B" size={10}>
|
||||
{row.models.length}
|
||||
</CustomTag>
|
||||
</Flex>
|
||||
{renderGroupTools(row.models)}
|
||||
</GroupHeader>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ListContainer>
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelListItem model={row.model} provider={provider} onAddModel={onAddModel} onRemoveModel={onRemoveModel} />
|
||||
)
|
||||
}}
|
||||
</DynamicVirtualList>
|
||||
)
|
||||
}
|
||||
|
||||
@ -262,18 +201,12 @@ const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onA
|
||||
)
|
||||
})
|
||||
|
||||
const ListContainer = styled.div`
|
||||
height: calc(100vh - 300px);
|
||||
overflow: auto;
|
||||
padding-right: 10px;
|
||||
`
|
||||
|
||||
const GroupHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
min-height: 48px;
|
||||
min-height: 50px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { MinusOutlined } from '@ant-design/icons'
|
||||
import CustomCollapse from '@renderer/components/CustomCollapse'
|
||||
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
|
||||
import { Model } from '@renderer/types'
|
||||
import { ModelWithStatus } from '@renderer/types/healthCheck'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Button, Flex, Tooltip } from 'antd'
|
||||
import React, { memo, useEffect, useRef, useState } from 'react'
|
||||
import React, { memo, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -32,29 +32,15 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
|
||||
onRemoveGroup
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const [isExpanded, setIsExpanded] = useState(defaultOpen)
|
||||
const listRef = useRef<DynamicVirtualListRef>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: models.length,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
estimateSize: () => 52,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
|
||||
// 监听折叠面板状态变化,确保虚拟列表在展开时正确渲染
|
||||
useEffect(() => {
|
||||
if (isExpanded && scrollerRef.current) {
|
||||
requestAnimationFrame(() => virtualizer.measure())
|
||||
}
|
||||
}, [isExpanded, virtualizer])
|
||||
|
||||
const handleCollapseChange = (activeKeys: string[] | string) => {
|
||||
const handleCollapseChange = useCallback((activeKeys: string[] | string) => {
|
||||
const isNowExpanded = Array.isArray(activeKeys) ? activeKeys.length > 0 : !!activeKeys
|
||||
setIsExpanded(isNowExpanded)
|
||||
}
|
||||
if (isNowExpanded) {
|
||||
// 延迟到 DOM 可见后测量
|
||||
requestAnimationFrame(() => listRef.current?.measure())
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<CustomCollapseWrapper>
|
||||
@ -80,45 +66,28 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
}>
|
||||
<ScrollContainer ref={scrollerRef}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
|
||||
}}>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const model = models[virtualItem.index]
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
/* 在这里调整 item 间距 */
|
||||
padding: '4px 0'
|
||||
}}>
|
||||
<ModelListItem
|
||||
model={model}
|
||||
modelStatus={modelStatuses.find((status) => status.model.id === model.id)}
|
||||
onEdit={onEditModel}
|
||||
onRemove={onRemoveModel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={models}
|
||||
estimateSize={useCallback(() => 52, [])} // 44px item + 8px padding
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
maxHeight: '390px',
|
||||
padding: '4px 16px'
|
||||
}}
|
||||
itemContainerStyle={{
|
||||
padding: '4px 0'
|
||||
}}>
|
||||
{(model) => (
|
||||
<ModelListItem
|
||||
model={model}
|
||||
modelStatus={modelStatuses.find((status) => status.model.id === model.id)}
|
||||
onEdit={onEditModel}
|
||||
onRemove={onRemoveModel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</DynamicVirtualList>
|
||||
</CustomCollapse>
|
||||
</CustomCollapseWrapper>
|
||||
)
|
||||
@ -141,10 +110,4 @@ const CustomCollapseWrapper = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const ScrollContainer = styled.div`
|
||||
overflow-y: auto;
|
||||
max-height: 390px;
|
||||
padding: 4px 16px;
|
||||
`
|
||||
|
||||
export default memo(ModelListGroup)
|
||||
|
||||
@ -0,0 +1,372 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import React, { useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DynamicVirtualList, type DynamicVirtualListRef } from '..'
|
||||
|
||||
// Mock management
|
||||
const mocks = vi.hoisted(() => ({
|
||||
virtualizer: {
|
||||
getVirtualItems: vi.fn(() => [
|
||||
{ index: 0, key: 'item-0', start: 0, size: 50 },
|
||||
{ index: 1, key: 'item-1', start: 50, size: 50 },
|
||||
{ index: 2, key: 'item-2', start: 100, size: 50 }
|
||||
]),
|
||||
getTotalSize: vi.fn(() => 150),
|
||||
getVirtualIndexes: vi.fn(() => [0, 1, 2]),
|
||||
measure: vi.fn(),
|
||||
scrollToOffset: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
resizeItem: vi.fn(),
|
||||
measureElement: vi.fn(),
|
||||
scrollElement: null as HTMLDivElement | null
|
||||
},
|
||||
useVirtualizer: vi.fn()
|
||||
}))
|
||||
|
||||
// Set up the mock to return our mock virtualizer
|
||||
mocks.useVirtualizer.mockImplementation(() => mocks.virtualizer)
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: mocks.useVirtualizer,
|
||||
defaultRangeExtractor: vi.fn((range) =>
|
||||
Array.from({ length: range.endIndex - range.startIndex + 1 }, (_, i) => range.startIndex + i)
|
||||
)
|
||||
}))
|
||||
|
||||
// Test data factory
|
||||
interface TestItem {
|
||||
id: string
|
||||
content: string
|
||||
}
|
||||
|
||||
function createTestItems(count = 5): TestItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `${i + 1}`,
|
||||
content: `Item ${i + 1}`
|
||||
}))
|
||||
}
|
||||
|
||||
describe('DynamicVirtualList', () => {
|
||||
const defaultItems = createTestItems()
|
||||
const defaultProps = {
|
||||
list: defaultItems,
|
||||
estimateSize: () => 50,
|
||||
children: (item: TestItem, index: number) => <div data-testid={`item-${index}`}>{item.content}</div>
|
||||
}
|
||||
|
||||
// Test component for ref testing
|
||||
const TestComponentWithRef: React.FC<{
|
||||
onRefReady?: (ref: DynamicVirtualListRef | null) => void
|
||||
listProps?: any
|
||||
}> = ({ onRefReady, listProps = {} }) => {
|
||||
const ref = useRef<DynamicVirtualListRef>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
onRefReady?.(ref.current)
|
||||
}, [onRefReady])
|
||||
|
||||
return <DynamicVirtualList ref={ref} {...defaultProps} {...listProps} />
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('snapshot test', () => {
|
||||
const { container } = render(<DynamicVirtualList {...defaultProps} />)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should apply custom scroller styles', () => {
|
||||
const customStyle = { backgroundColor: 'red', height: '400px' }
|
||||
render(<DynamicVirtualList {...defaultProps} scrollerStyle={customStyle} />)
|
||||
|
||||
const scrollContainer = document.querySelector('.dynamic-virtual-list')
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
expect(scrollContainer).toHaveStyle('background-color: rgb(255, 0, 0)')
|
||||
expect(scrollContainer).toHaveStyle('height: 400px')
|
||||
})
|
||||
|
||||
it('should apply custom item container styles', () => {
|
||||
const itemStyle = { padding: '10px', margin: '5px' }
|
||||
render(<DynamicVirtualList {...defaultProps} itemContainerStyle={itemStyle} />)
|
||||
|
||||
const items = document.querySelectorAll('[data-index]')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
|
||||
// Check first item styles
|
||||
const firstItem = items[0] as HTMLElement
|
||||
expect(firstItem).toHaveStyle('padding: 10px')
|
||||
expect(firstItem).toHaveStyle('margin: 5px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('props integration', () => {
|
||||
it('should render correctly with different item counts', () => {
|
||||
const { rerender } = render(<DynamicVirtualList {...defaultProps} list={createTestItems(3)} />)
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||
|
||||
// Should handle dynamic item count changes
|
||||
rerender(<DynamicVirtualList {...defaultProps} list={createTestItems(10)} />)
|
||||
expect(document.querySelector('.dynamic-virtual-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with custom estimateSize function', () => {
|
||||
const customEstimateSize = vi.fn(() => 80)
|
||||
|
||||
// Should render without errors when using custom estimateSize
|
||||
expect(() => {
|
||||
render(<DynamicVirtualList {...defaultProps} estimateSize={customEstimateSize} />)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sticky feature', () => {
|
||||
it('should apply sticky positioning to specified items', () => {
|
||||
const isSticky = vi.fn((index: number) => index === 0) // First item is sticky
|
||||
|
||||
render(<DynamicVirtualList {...defaultProps} isSticky={isSticky} />)
|
||||
|
||||
// Should call isSticky function during rendering
|
||||
expect(isSticky).toHaveBeenCalled()
|
||||
|
||||
// Should apply sticky styles to sticky items
|
||||
const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement
|
||||
expect(stickyItem).toBeInTheDocument()
|
||||
expect(stickyItem).toHaveStyle('position: sticky')
|
||||
expect(stickyItem).toHaveStyle('z-index: 1')
|
||||
})
|
||||
|
||||
it('should apply absolute positioning to non-sticky items', () => {
|
||||
const isSticky = vi.fn((index: number) => index === 0)
|
||||
|
||||
render(<DynamicVirtualList {...defaultProps} isSticky={isSticky} />)
|
||||
|
||||
// Non-sticky items should have absolute positioning
|
||||
const regularItem = document.querySelector('[data-index="1"]') as HTMLElement
|
||||
expect(regularItem).toBeInTheDocument()
|
||||
expect(regularItem).toHaveStyle('position: absolute')
|
||||
})
|
||||
|
||||
it('should apply absolute positioning to all items when no sticky function provided', () => {
|
||||
render(<DynamicVirtualList {...defaultProps} />)
|
||||
|
||||
// All items should have absolute positioning
|
||||
const items = document.querySelectorAll('[data-index]')
|
||||
items.forEach((item) => {
|
||||
const htmlItem = item as HTMLElement
|
||||
expect(htmlItem).toHaveStyle('position: absolute')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom range extractor', () => {
|
||||
it('should work with custom rangeExtractor', () => {
|
||||
const customRangeExtractor = vi.fn(() => [0, 1, 2])
|
||||
|
||||
// Should render without errors when using custom rangeExtractor
|
||||
expect(() => {
|
||||
render(<DynamicVirtualList {...defaultProps} rangeExtractor={customRangeExtractor} />)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle both rangeExtractor and sticky props gracefully', () => {
|
||||
const customRangeExtractor = vi.fn(() => [0, 1, 2])
|
||||
const isSticky = vi.fn((index: number) => index === 0)
|
||||
|
||||
// Should render without conflicts when both props are provided
|
||||
expect(() => {
|
||||
render(<DynamicVirtualList {...defaultProps} rangeExtractor={customRangeExtractor} isSticky={isSticky} />)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ref api', () => {
|
||||
let refInstance: DynamicVirtualListRef | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
render(
|
||||
<TestComponentWithRef
|
||||
onRefReady={(ref) => {
|
||||
refInstance = ref
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
// Wait for ref to be ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
it('should expose all required ref methods', () => {
|
||||
expect(refInstance).toBeTruthy()
|
||||
expect(refInstance).not.toBeNull()
|
||||
|
||||
// Type assertion to help TypeScript understand the type
|
||||
const ref = refInstance as unknown as DynamicVirtualListRef
|
||||
expect(typeof ref.measure).toBe('function')
|
||||
expect(typeof ref.scrollElement).toBe('function')
|
||||
expect(typeof ref.scrollToOffset).toBe('function')
|
||||
expect(typeof ref.scrollToIndex).toBe('function')
|
||||
expect(typeof ref.resizeItem).toBe('function')
|
||||
expect(typeof ref.getTotalSize).toBe('function')
|
||||
expect(typeof ref.getVirtualItems).toBe('function')
|
||||
expect(typeof ref.getVirtualIndexes).toBe('function')
|
||||
})
|
||||
|
||||
it('should allow calling all ref methods without throwing', () => {
|
||||
const ref = refInstance as unknown as DynamicVirtualListRef
|
||||
|
||||
// Test that all methods can be called without errors
|
||||
expect(() => ref.measure()).not.toThrow()
|
||||
expect(() => ref.scrollToOffset(100, { align: 'start' })).not.toThrow()
|
||||
expect(() => ref.scrollToIndex(2, { align: 'center' })).not.toThrow()
|
||||
expect(() => ref.resizeItem(1, 80)).not.toThrow()
|
||||
|
||||
// Test that data methods return expected types
|
||||
expect(typeof ref.getTotalSize()).toBe('number')
|
||||
expect(Array.isArray(ref.getVirtualItems())).toBe(true)
|
||||
expect(Array.isArray(ref.getVirtualIndexes())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orientation support', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks for orientation tests
|
||||
mocks.virtualizer.getVirtualItems.mockReturnValue([
|
||||
{ index: 0, key: 'item-0', start: 0, size: 100 },
|
||||
{ index: 1, key: 'item-1', start: 100, size: 100 }
|
||||
])
|
||||
mocks.virtualizer.getTotalSize.mockReturnValue(200)
|
||||
})
|
||||
|
||||
it('should apply horizontal layout styles correctly', () => {
|
||||
render(<DynamicVirtualList {...defaultProps} horizontal={true} />)
|
||||
|
||||
// Verify container styles for horizontal layout
|
||||
const container = document.querySelector('div[style*="position: relative"]') as HTMLElement
|
||||
expect(container).toHaveStyle('width: 200px') // totalSize
|
||||
expect(container).toHaveStyle('height: 100%')
|
||||
|
||||
// Verify item transform for horizontal layout
|
||||
const items = document.querySelectorAll('[data-index]')
|
||||
const firstItem = items[0] as HTMLElement
|
||||
expect(firstItem.style.transform).toContain('translateX(0px)')
|
||||
expect(firstItem).toHaveStyle('height: 100%')
|
||||
})
|
||||
|
||||
it('should apply vertical layout styles correctly', () => {
|
||||
// Reset to default vertical mock values
|
||||
mocks.virtualizer.getTotalSize.mockReturnValue(150)
|
||||
|
||||
render(<DynamicVirtualList {...defaultProps} horizontal={false} />)
|
||||
|
||||
// Verify container styles for vertical layout
|
||||
const container = document.querySelector('div[style*="position: relative"]') as HTMLElement
|
||||
expect(container).toHaveStyle('width: 100%')
|
||||
expect(container).toHaveStyle('height: 150px') // totalSize from mock
|
||||
|
||||
// Verify item transform for vertical layout
|
||||
const items = document.querySelectorAll('[data-index]')
|
||||
const firstItem = items[0] as HTMLElement
|
||||
expect(firstItem.style.transform).toContain('translateY(0px)')
|
||||
expect(firstItem).toHaveStyle('width: 100%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle edge cases gracefully', () => {
|
||||
// Empty items list
|
||||
mocks.virtualizer.getVirtualItems.mockReturnValueOnce([])
|
||||
expect(() => {
|
||||
render(<DynamicVirtualList {...defaultProps} list={[]} />)
|
||||
}).not.toThrow()
|
||||
|
||||
// Null ref
|
||||
expect(() => {
|
||||
render(<DynamicVirtualList {...defaultProps} ref={null} />)
|
||||
}).not.toThrow()
|
||||
|
||||
// Zero estimate size
|
||||
expect(() => {
|
||||
render(<DynamicVirtualList {...defaultProps} estimateSize={() => 0} />)
|
||||
}).not.toThrow()
|
||||
|
||||
// Items without expected properties
|
||||
const itemsWithoutContent = [{ id: '1' }, { id: '2' }] as any[]
|
||||
expect(() => {
|
||||
render(
|
||||
<DynamicVirtualList
|
||||
{...defaultProps}
|
||||
list={itemsWithoutContent}
|
||||
children={(_item, index) => <div data-testid={`item-${index}`}>No content</div>}
|
||||
/>
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto hide scrollbar', () => {
|
||||
it('should always show scrollbar when autoHideScrollbar is false', () => {
|
||||
render(<DynamicVirtualList {...defaultProps} autoHideScrollbar={false} />)
|
||||
|
||||
const scrollContainer = document.querySelector('.dynamic-virtual-list') as HTMLElement
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
|
||||
// When autoHideScrollbar is false, scrollbar should always be visible
|
||||
expect(scrollContainer).not.toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should hide scrollbar initially and show during scrolling when autoHideScrollbar is true', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<DynamicVirtualList {...defaultProps} autoHideScrollbar={true} />)
|
||||
|
||||
const scrollContainer = document.querySelector('.dynamic-virtual-list') as HTMLElement
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
|
||||
// Initially hidden
|
||||
expect(scrollContainer).toHaveAttribute('aria-hidden', 'true')
|
||||
|
||||
// We can't easily simulate real scroll events in JSDOM, so we'll test the internal logic directly
|
||||
// by calling the onChange handler which should update the state
|
||||
const onChangeCallback = mocks.useVirtualizer.mock.calls[0][0].onChange
|
||||
|
||||
// Simulate scroll start
|
||||
act(() => {
|
||||
onChangeCallback({ isScrolling: true }, true)
|
||||
})
|
||||
|
||||
// After scrolling starts, scrollbar should be visible
|
||||
expect(scrollContainer).toHaveAttribute('aria-hidden', 'false')
|
||||
|
||||
// Simulate scroll end
|
||||
act(() => {
|
||||
onChangeCallback({ isScrolling: false }, true)
|
||||
})
|
||||
|
||||
// Advance timers to trigger the hide timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
|
||||
// After timeout, scrollbar should be hidden again
|
||||
expect(scrollContainer).toHaveAttribute('aria-hidden', 'true')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,58 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
||||
.c0::-webkit-scrollbar-thumb {
|
||||
transition: background 0.3s ease-in-out;
|
||||
will-change: background;
|
||||
background: var(--color-scrollbar-thumb);
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
aria-hidden="false"
|
||||
aria-label="Dynamic Virtual List"
|
||||
class="c0 dynamic-virtual-list"
|
||||
role="region"
|
||||
style="overflow: auto; height: 100%;"
|
||||
>
|
||||
<div
|
||||
style="position: relative; width: 100%; height: 150px;"
|
||||
>
|
||||
<div
|
||||
data-index="0"
|
||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(0px); width: 100%;"
|
||||
>
|
||||
<div
|
||||
data-testid="item-0"
|
||||
>
|
||||
Item 1
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-index="1"
|
||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(50px); width: 100%;"
|
||||
>
|
||||
<div
|
||||
data-testid="item-1"
|
||||
>
|
||||
Item 2
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-index="2"
|
||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(100px); width: 100%;"
|
||||
>
|
||||
<div
|
||||
data-testid="item-2"
|
||||
>
|
||||
Item 3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
257
src/renderer/src/components/VirtualList/dynamic.tsx
Normal file
257
src/renderer/src/components/VirtualList/dynamic.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import type { Range, ScrollToOptions, VirtualItem, VirtualizerOptions } from '@tanstack/react-virtual'
|
||||
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
|
||||
import React, { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const SCROLLBAR_AUTO_HIDE_DELAY = 2000
|
||||
|
||||
type InheritedVirtualizerOptions = Partial<
|
||||
Omit<
|
||||
VirtualizerOptions<HTMLDivElement, Element>,
|
||||
| 'count' // determined by items.length
|
||||
| 'getScrollElement' // determined by internal scrollerRef
|
||||
| 'estimateSize' // promoted to a required prop
|
||||
| 'rangeExtractor' // isSticky provides a simpler abstraction
|
||||
>
|
||||
>
|
||||
|
||||
export interface DynamicVirtualListRef {
|
||||
/** Resets any prev item measurements. */
|
||||
measure: () => void
|
||||
/** Returns the scroll element for the virtualizer. */
|
||||
scrollElement: () => HTMLDivElement | null
|
||||
/** Scrolls the virtualizer to the pixel offset provided. */
|
||||
scrollToOffset: (offset: number, options?: ScrollToOptions) => void
|
||||
/** Scrolls the virtualizer to the items of the index provided. */
|
||||
scrollToIndex: (index: number, options?: ScrollToOptions) => void
|
||||
/** Resizes an item. */
|
||||
resizeItem: (index: number, size: number) => void
|
||||
/** Returns the total size in pixels for the virtualized items. */
|
||||
getTotalSize: () => number
|
||||
/** Returns the virtual items for the current state of the virtualizer. */
|
||||
getVirtualItems: () => VirtualItem[]
|
||||
/** Returns the virtual row indexes for the current state of the virtualizer. */
|
||||
getVirtualIndexes: () => number[]
|
||||
}
|
||||
|
||||
export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions {
|
||||
ref?: React.Ref<DynamicVirtualListRef>
|
||||
|
||||
/**
|
||||
* List data
|
||||
*/
|
||||
list: T[]
|
||||
|
||||
/**
|
||||
* List item renderer function
|
||||
*/
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
|
||||
/**
|
||||
* List size (height or width, default is 100%)
|
||||
*/
|
||||
size?: string | number
|
||||
|
||||
/**
|
||||
* List item size estimator function (initial estimation)
|
||||
*/
|
||||
estimateSize: (index: number) => number
|
||||
|
||||
/**
|
||||
* Sticky item predicate, cannot be used with rangeExtractor
|
||||
*/
|
||||
isSticky?: (index: number) => boolean
|
||||
|
||||
/**
|
||||
* Range extractor function, cannot be used with isSticky
|
||||
*/
|
||||
rangeExtractor?: (range: Range) => number[]
|
||||
|
||||
/**
|
||||
* List item container style
|
||||
*/
|
||||
itemContainerStyle?: React.CSSProperties
|
||||
|
||||
/**
|
||||
* Scroll container style
|
||||
*/
|
||||
scrollerStyle?: React.CSSProperties
|
||||
|
||||
/**
|
||||
* Hide the scrollbar automatically when scrolling is stopped
|
||||
*/
|
||||
autoHideScrollbar?: boolean
|
||||
}
|
||||
|
||||
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
const {
|
||||
ref,
|
||||
list,
|
||||
children,
|
||||
size,
|
||||
estimateSize,
|
||||
isSticky,
|
||||
rangeExtractor: customRangeExtractor,
|
||||
itemContainerStyle,
|
||||
scrollerStyle,
|
||||
autoHideScrollbar = false,
|
||||
...restOptions
|
||||
} = props
|
||||
|
||||
const [showScrollbar, setShowScrollbar] = useState(!autoHideScrollbar)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const internalScrollerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = internalScrollerRef
|
||||
|
||||
const activeStickyIndexRef = useRef(0)
|
||||
|
||||
const stickyIndexes = useMemo(() => {
|
||||
if (!isSticky) return []
|
||||
return list.map((_, index) => (isSticky(index) ? index : -1)).filter((index) => index !== -1)
|
||||
}, [list, isSticky])
|
||||
|
||||
const internalStickyRangeExtractor = useCallback(
|
||||
(range: Range) => {
|
||||
// The active sticky index is the last one that is before or at the start of the visible range
|
||||
const newActiveStickyIndex =
|
||||
[...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? stickyIndexes[0] ?? 0
|
||||
|
||||
if (newActiveStickyIndex !== activeStickyIndexRef.current) {
|
||||
activeStickyIndexRef.current = newActiveStickyIndex
|
||||
}
|
||||
|
||||
// Merge the active sticky index and the default range extractor
|
||||
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
|
||||
|
||||
// Sort the set to maintain proper order
|
||||
return [...next].sort((a, b) => a - b)
|
||||
},
|
||||
[stickyIndexes]
|
||||
)
|
||||
|
||||
const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined)
|
||||
|
||||
const handleScrollbarHide = useCallback(
|
||||
(isScrolling: boolean) => {
|
||||
if (!autoHideScrollbar) return
|
||||
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
if (isScrolling) {
|
||||
setShowScrollbar(true)
|
||||
} else {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setShowScrollbar(false)
|
||||
}, SCROLLBAR_AUTO_HIDE_DELAY)
|
||||
}
|
||||
},
|
||||
[autoHideScrollbar]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
...restOptions,
|
||||
count: list.length,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
estimateSize,
|
||||
rangeExtractor,
|
||||
onChange: (instance, sync) => {
|
||||
restOptions.onChange?.(instance, sync)
|
||||
handleScrollbarHide(instance.isScrolling)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [autoHideScrollbar])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
measure: () => virtualizer.measure(),
|
||||
scrollElement: () => virtualizer.scrollElement,
|
||||
scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options),
|
||||
scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options),
|
||||
resizeItem: (index, size) => virtualizer.resizeItem(index, size),
|
||||
getTotalSize: () => virtualizer.getTotalSize(),
|
||||
getVirtualItems: () => virtualizer.getVirtualItems(),
|
||||
getVirtualIndexes: () => virtualizer.getVirtualIndexes()
|
||||
}),
|
||||
[virtualizer]
|
||||
)
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
const { horizontal } = restOptions
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="dynamic-virtual-list"
|
||||
role="region"
|
||||
aria-label="Dynamic Virtual List"
|
||||
aria-hidden={!showScrollbar}
|
||||
$autoHide={autoHideScrollbar}
|
||||
$show={showScrollbar}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
|
||||
...scrollerStyle
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: horizontal ? `${totalSize}px` : '100%',
|
||||
height: !horizontal ? `${totalSize}px` : '100%'
|
||||
}}>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const isItemSticky = stickyIndexes.includes(virtualItem.index)
|
||||
const isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...itemContainerStyle,
|
||||
position: isItemActiveSticky ? 'sticky' : 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: isItemSticky ? 1 : undefined,
|
||||
...(horizontal
|
||||
? {
|
||||
transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`,
|
||||
height: '100%'
|
||||
}
|
||||
: {
|
||||
transform: isItemActiveSticky ? undefined : `translateY(${virtualItem.start}px)`,
|
||||
width: '100%'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement} style={style}>
|
||||
{children(list[virtualItem.index], virtualItem.index)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ScrollContainer = styled.div<{ $autoHide: boolean; $show: boolean }>`
|
||||
&::-webkit-scrollbar-thumb {
|
||||
transition: background 0.3s ease-in-out;
|
||||
will-change: background;
|
||||
background: ${(props) => (props.$autoHide && !props.$show ? 'transparent' : 'var(--color-scrollbar-thumb)')};
|
||||
|
||||
&:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MemoizedDynamicVirtualList = memo(DynamicVirtualList) as <T>(
|
||||
props: DynamicVirtualListProps<T>
|
||||
) => React.ReactElement
|
||||
|
||||
export default MemoizedDynamicVirtualList
|
||||
1
src/renderer/src/components/VirtualList/index.ts
Normal file
1
src/renderer/src/components/VirtualList/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as DynamicVirtualList, type DynamicVirtualListProps, type DynamicVirtualListRef } from './dynamic'
|
||||
@ -4,7 +4,7 @@ exports[`DraggableVirtualList > snapshot > should match snapshot with custom sty
|
||||
<div>
|
||||
<div
|
||||
class="custom-class draggable-virtual-list"
|
||||
style="height: 100%; border: 1px solid red;"
|
||||
style="height: 100%; display: flex; flex-direction: column; border: 1px solid red;"
|
||||
>
|
||||
<div
|
||||
data-testid="drag-drop-context"
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { handleDelete } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Col, Image, Row, Spin } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import React, { memo } from 'react'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import FileItem from './FileItem'
|
||||
@ -27,6 +27,8 @@ interface FileItemProps {
|
||||
}
|
||||
|
||||
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
const estimateSize = useCallback(() => 75, [])
|
||||
|
||||
if (id === FileTypes.IMAGE && files?.length && files?.length > 0) {
|
||||
return (
|
||||
<div style={{ padding: 16, overflowY: 'auto' }}>
|
||||
@ -78,38 +80,29 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualList
|
||||
data={list}
|
||||
height={window.innerHeight - 100}
|
||||
itemHeight={75}
|
||||
itemKey="key"
|
||||
style={{ padding: '0 16px 16px 16px' }}
|
||||
styles={{
|
||||
verticalScrollBar: {
|
||||
width: 6
|
||||
},
|
||||
verticalScrollBarThumb: {
|
||||
background: 'var(--color-scrollbar-thumb)'
|
||||
}
|
||||
<DynamicVirtualList
|
||||
list={list}
|
||||
estimateSize={estimateSize}
|
||||
overscan={2}
|
||||
scrollerStyle={{
|
||||
padding: '0 16px 16px 16px'
|
||||
}}
|
||||
itemContainerStyle={{
|
||||
height: '75px',
|
||||
paddingTop: '12px'
|
||||
}}>
|
||||
{(item) => (
|
||||
<div
|
||||
style={{
|
||||
height: '75px',
|
||||
paddingTop: '12px'
|
||||
}}>
|
||||
<FileItem
|
||||
key={item.key}
|
||||
fileInfo={{
|
||||
name: item.file,
|
||||
ext: item.ext,
|
||||
extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`,
|
||||
actions: item.actions
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FileItem
|
||||
key={item.key}
|
||||
fileInfo={{
|
||||
name: item.file,
|
||||
ext: item.ext,
|
||||
extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`,
|
||||
actions: item.actions
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VirtualList>
|
||||
</DynamicVirtualList>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
QuestionCircleOutlined,
|
||||
UploadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { DraggableVirtualList as DraggableList } from '@renderer/components/DraggableList'
|
||||
import { DraggableVirtualList } 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'
|
||||
@ -438,11 +438,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
const singlealone = topicPosition === 'right' && position === 'right'
|
||||
|
||||
return (
|
||||
<DraggableList
|
||||
<DraggableVirtualList
|
||||
className="topics-tab"
|
||||
list={sortedTopics}
|
||||
onUpdate={updateTopics}
|
||||
style={{ height: '100%', padding: '13px 0 10px 10px', display: 'flex', flexDirection: 'column' }}
|
||||
style={{ height: '100%', padding: '13px 0 10px 10px' }}
|
||||
itemContainerStyle={{ paddingBottom: '8px' }}
|
||||
header={
|
||||
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
|
||||
@ -521,7 +521,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
</Dropdown>
|
||||
)
|
||||
}}
|
||||
</DraggableList>
|
||||
</DraggableVirtualList>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -104,36 +104,34 @@ const KnowledgePage: FC = () => {
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<KnowledgeSideNav>
|
||||
<ScrollContainer>
|
||||
<DraggableList
|
||||
list={bases}
|
||||
onUpdate={updateKnowledgeBases}
|
||||
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={() => setIsDragging(false)}>
|
||||
{(base: KnowledgeBase) => (
|
||||
<Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}>
|
||||
<div>
|
||||
<ListItem
|
||||
active={selectedBase?.id === base.id}
|
||||
icon={<Book size={16} />}
|
||||
title={base.name}
|
||||
onClick={() => setSelectedBase(base)}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</DraggableList>
|
||||
{!isDragging && (
|
||||
<AddKnowledgeItem onClick={handleAddKnowledge}>
|
||||
<AddKnowledgeName>
|
||||
<Plus size={18} />
|
||||
{t('button.add')}
|
||||
</AddKnowledgeName>
|
||||
</AddKnowledgeItem>
|
||||
<DraggableList
|
||||
list={bases}
|
||||
onUpdate={updateKnowledgeBases}
|
||||
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={() => setIsDragging(false)}>
|
||||
{(base: KnowledgeBase) => (
|
||||
<Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}>
|
||||
<div>
|
||||
<ListItem
|
||||
active={selectedBase?.id === base.id}
|
||||
icon={<Book size={16} />}
|
||||
title={base.name}
|
||||
onClick={() => setSelectedBase(base)}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
<div style={{ minHeight: '10px' }}></div>
|
||||
</ScrollContainer>
|
||||
</DraggableList>
|
||||
{!isDragging && (
|
||||
<AddKnowledgeItem onClick={handleAddKnowledge}>
|
||||
<AddKnowledgeName>
|
||||
<Plus size={18} />
|
||||
{t('button.add')}
|
||||
</AddKnowledgeName>
|
||||
</AddKnowledgeItem>
|
||||
)}
|
||||
<div style={{ minHeight: '10px' }}></div>
|
||||
</KnowledgeSideNav>
|
||||
{bases.length === 0 ? (
|
||||
<MainContent>
|
||||
@ -169,13 +167,14 @@ const MainContent = styled(Scrollbar)`
|
||||
padding-bottom: 50px;
|
||||
`
|
||||
|
||||
export const KnowledgeSideNav = styled.div`
|
||||
min-width: var(--settings-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
padding: 12px 10px;
|
||||
const KnowledgeSideNav = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: calc(var(--settings-width) + 100px);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
padding: 12px 10px;
|
||||
|
||||
.ant-menu {
|
||||
border-inline-end: none !important;
|
||||
background: transparent;
|
||||
@ -197,12 +196,6 @@ export const KnowledgeSideNav = styled.div`
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ScrollContainer = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
> div {
|
||||
margin-bottom: 8px;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
@ -9,7 +9,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -46,6 +46,9 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
const reversedItems = useMemo(() => [...directoryItems].reverse(), [directoryItems])
|
||||
const estimateSize = useCallback(() => 75, [])
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
@ -76,46 +79,51 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{directoryItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{directoryItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.folder',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
progress={progressMap.get(item.id)}
|
||||
type="directory"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<DynamicVirtualList
|
||||
list={reversedItems}
|
||||
estimateSize={estimateSize}
|
||||
overscan={2}
|
||||
scrollerStyle={{ paddingRight: 2 }}
|
||||
itemContainerStyle={{ paddingBottom: 10 }}
|
||||
autoHideScrollbar>
|
||||
{(item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.folder',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
progress={progressMap.get(item.id)}
|
||||
type="directory"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DynamicVirtualList>
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
const ItemFlexColumn = styled.div`
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
@ -12,13 +12,14 @@ import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@sh
|
||||
import { Button, Tooltip, Upload } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const logger = loggerService.withContext('KnowledgeFiles')
|
||||
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
|
||||
import {
|
||||
ClickableSpan,
|
||||
FlexAlignCenter,
|
||||
@ -64,6 +65,8 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
const estimateSize = useCallback(() => 75, [])
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
@ -160,15 +163,12 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
{fileItems.length === 0 ? (
|
||||
<KnowledgeEmptyView />
|
||||
) : (
|
||||
<VirtualList
|
||||
data={fileItems.reverse()}
|
||||
height={windowHeight - 270}
|
||||
itemHeight={75}
|
||||
itemKey="id"
|
||||
styles={{
|
||||
verticalScrollBar: { width: 6 },
|
||||
verticalScrollBarThumb: { background: 'var(--color-scrollbar-thumb)' }
|
||||
}}>
|
||||
<DynamicVirtualList
|
||||
list={fileItems.reverse()}
|
||||
estimateSize={estimateSize}
|
||||
overscan={2}
|
||||
scrollerStyle={{ height: windowHeight - 270 }}
|
||||
autoHideScrollbar>
|
||||
{(item) => {
|
||||
const file = item.content as FileType
|
||||
return (
|
||||
@ -218,7 +218,7 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</VirtualList>
|
||||
</DynamicVirtualList>
|
||||
)}
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
@ -8,7 +8,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -34,6 +34,9 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
const reversedItems = useMemo(() => [...noteItems].reverse(), [noteItems])
|
||||
const estimateSize = useCallback(() => 75, [])
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
@ -72,34 +75,44 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{noteItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{noteItems.reverse().map((note) => (
|
||||
<FileItem
|
||||
key={note.id}
|
||||
fileInfo={{
|
||||
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
|
||||
ext: '.txt',
|
||||
extra: getDisplayTime(note),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} type="note" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<DynamicVirtualList
|
||||
list={reversedItems}
|
||||
estimateSize={estimateSize}
|
||||
overscan={2}
|
||||
scrollerStyle={{ paddingRight: 2 }}
|
||||
itemContainerStyle={{ paddingBottom: 10 }}
|
||||
autoHideScrollbar>
|
||||
{(note) => (
|
||||
<FileItem
|
||||
key={note.id}
|
||||
fileInfo={{
|
||||
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
|
||||
ext: '.txt',
|
||||
extra: getDisplayTime(note),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={note.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="note"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DynamicVirtualList>
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
const ItemFlexColumn = styled.div`
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
@ -2,7 +2,7 @@ import { DeleteOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
@ -10,7 +10,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button, message, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -46,6 +46,9 @@ const KnowledgeSitemaps: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
const reversedItems = useMemo(() => [...sitemapItems].reverse(), [sitemapItems])
|
||||
const estimateSize = useCallback(() => 75, [])
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
@ -95,49 +98,54 @@ const KnowledgeSitemaps: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{sitemapItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{sitemapItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.content as string}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.sitemap',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="sitemap"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<DynamicVirtualList
|
||||
list={reversedItems}
|
||||
estimateSize={estimateSize}
|
||||
overscan={2}
|
||||
scrollerStyle={{ paddingRight: 2 }}
|
||||
itemContainerStyle={{ paddingBottom: 10 }}
|
||||
autoHideScrollbar>
|
||||
{(item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.content as string}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.sitemap',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="sitemap"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DynamicVirtualList>
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
const ItemFlexColumn = styled.div`
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
@ -9,7 +9,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button, Dropdown, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -43,6 +43,9 @@ const KnowledgeUrls: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
const reversedItems = useMemo(() => [...urlItems].reverse(), [urlItems])
|
||||
const estimateSize = useCallback(() => 75, [])
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
@ -123,66 +126,71 @@ const KnowledgeUrls: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{urlItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{urlItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: t('knowledge.edit_remark'),
|
||||
onClick: () => handleEditRemark(item)
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <CopyOutlined />,
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(item.content as string)
|
||||
window.message.success(t('message.copied'))
|
||||
<DynamicVirtualList
|
||||
list={reversedItems}
|
||||
estimateSize={estimateSize}
|
||||
overscan={2}
|
||||
scrollerStyle={{ paddingRight: 2 }}
|
||||
itemContainerStyle={{ paddingBottom: 10 }}
|
||||
autoHideScrollbar>
|
||||
{(item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: t('knowledge.edit_remark'),
|
||||
onClick: () => handleEditRemark(item)
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <CopyOutlined />,
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(item.content as string)
|
||||
window.message.success(t('message.copied'))
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
trigger={['contextMenu']}>
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.remark || (item.content as string)}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</Dropdown>
|
||||
),
|
||||
ext: '.url',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
]
|
||||
}}
|
||||
trigger={['contextMenu']}>
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.remark || (item.content as string)}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</Dropdown>
|
||||
),
|
||||
ext: '.url',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DynamicVirtualList>
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
const ItemFlexColumn = styled.div`
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
|
||||
import { loggerService } from '@logger'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { DraggableVirtualList } from '@renderer/components/DraggableList'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
@ -9,7 +8,6 @@ import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { INITIAL_PROVIDERS } from '@renderer/store/llm'
|
||||
import { Provider, ProviderType } from '@renderer/types'
|
||||
import {
|
||||
droppableReorder,
|
||||
generateColorFromChar,
|
||||
getFancyProviderName,
|
||||
getFirstCharacter,
|
||||
@ -30,6 +28,8 @@ import ProviderSetting from './ProviderSetting'
|
||||
|
||||
const logger = loggerService.withContext('ProvidersList')
|
||||
|
||||
const BUTTON_WRAPPER_HEIGHT = 50
|
||||
|
||||
const ProvidersList: FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const providers = useAllProviders()
|
||||
@ -272,14 +272,9 @@ const ProvidersList: FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams])
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
const handleUpdateProviders = (reorderProviders: Provider[]) => {
|
||||
setDragging(false)
|
||||
if (result.destination) {
|
||||
const sourceIndex = result.source.index
|
||||
const destIndex = result.destination.index
|
||||
const reorderProviders = droppableReorder<Provider>(providers, sourceIndex, destIndex)
|
||||
updateProviders(reorderProviders)
|
||||
}
|
||||
updateProviders(reorderProviders)
|
||||
}
|
||||
|
||||
const onAddProvider = async () => {
|
||||
@ -462,50 +457,37 @@ const ProvidersList: FC = () => {
|
||||
disabled={dragging}
|
||||
/>
|
||||
</AddButtonWrapper>
|
||||
<Scrollbar>
|
||||
<ProviderList>
|
||||
<DragDropContext onDragStart={() => setDragging(true)} onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{filteredProviders.map((provider, index) => (
|
||||
<Draggable
|
||||
key={`draggable_${provider.id}_${index}`}
|
||||
draggableId={provider.id}
|
||||
index={index}
|
||||
isDragDisabled={searchText.length > 0}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{ ...provided.draggableProps.style, marginBottom: 5 }}>
|
||||
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
|
||||
<ProviderListItem
|
||||
key={JSON.stringify(provider)}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedProvider(provider)}>
|
||||
{getProviderAvatar(provider)}
|
||||
<ProviderItemName className="text-nowrap">
|
||||
{getFancyProviderName(provider)}
|
||||
</ProviderItemName>
|
||||
{provider.enabled && (
|
||||
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
|
||||
ON
|
||||
</Tag>
|
||||
)}
|
||||
</ProviderListItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</div>
|
||||
<DraggableVirtualList
|
||||
list={filteredProviders}
|
||||
onUpdate={handleUpdateProviders}
|
||||
onDragStart={() => setDragging(true)}
|
||||
estimateSize={useCallback(() => 40, [])}
|
||||
overscan={3}
|
||||
style={{
|
||||
height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
|
||||
}}
|
||||
scrollerStyle={{
|
||||
padding: 8,
|
||||
paddingRight: 5
|
||||
}}
|
||||
itemContainerStyle={{ paddingBottom: 5 }}>
|
||||
{(provider) => (
|
||||
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
|
||||
<ProviderListItem
|
||||
key={JSON.stringify(provider)}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedProvider(provider)}>
|
||||
{getProviderAvatar(provider)}
|
||||
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
|
||||
{provider.enabled && (
|
||||
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
|
||||
ON
|
||||
</Tag>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</ProviderList>
|
||||
</Scrollbar>
|
||||
</ProviderListItem>
|
||||
</Dropdown>
|
||||
)}
|
||||
</DraggableVirtualList>
|
||||
<AddButtonWrapper>
|
||||
<Button
|
||||
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
|
||||
@ -536,14 +518,6 @@ const ProviderListContainer = styled.div`
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ProviderList = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
padding-right: 5px;
|
||||
`
|
||||
|
||||
const ProviderListItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -575,7 +549,7 @@ const ProviderItemName = styled.div`
|
||||
`
|
||||
|
||||
const AddButtonWrapper = styled.div`
|
||||
height: 50px;
|
||||
height: ${BUTTON_WRAPPER_HEIGHT}px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user