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:
one 2025-08-01 11:00:48 +08:00 committed by GitHub
parent 9217101032
commit 2711cf5c27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1074 additions and 485 deletions

View File

@ -35,6 +35,7 @@ interface DraggableVirtualListProps<T> {
ref?: React.Ref<HTMLDivElement> ref?: React.Ref<HTMLDivElement>
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
scrollerStyle?: React.CSSProperties
itemStyle?: React.CSSProperties itemStyle?: React.CSSProperties
itemContainerStyle?: React.CSSProperties itemContainerStyle?: React.CSSProperties
droppableProps?: Partial<DroppableProps> droppableProps?: Partial<DroppableProps>
@ -43,6 +44,7 @@ interface DraggableVirtualListProps<T> {
onDragEnd?: OnDragEndResponder onDragEnd?: OnDragEndResponder
list: T[] list: T[]
itemKey?: (index: number) => Key itemKey?: (index: number) => Key
estimateSize?: (index: number) => number
overscan?: number overscan?: number
header?: React.ReactNode header?: React.ReactNode
children: (item: T, index: number) => React.ReactNode children: (item: T, index: number) => React.ReactNode
@ -59,6 +61,7 @@ function DraggableVirtualList<T>({
ref, ref,
className, className,
style, style,
scrollerStyle,
itemStyle, itemStyle,
itemContainerStyle, itemContainerStyle,
droppableProps, droppableProps,
@ -67,6 +70,7 @@ function DraggableVirtualList<T>({
onDragEnd, onDragEnd,
list, list,
itemKey, itemKey,
estimateSize: _estimateSize,
overscan = 5, overscan = 5,
header, header,
children children
@ -88,12 +92,15 @@ function DraggableVirtualList<T>({
count: list?.length ?? 0, count: list?.length ?? 0,
getScrollElement: useCallback(() => parentRef.current, []), getScrollElement: useCallback(() => parentRef.current, []),
getItemKey: itemKey, getItemKey: itemKey,
estimateSize: useCallback(() => 50, []), estimateSize: useCallback((index) => _estimateSize?.(index) ?? 50, [_estimateSize]),
overscan overscan
}) })
return ( 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}> <DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
{header} {header}
<Droppable <Droppable
@ -128,6 +135,7 @@ function DraggableVirtualList<T>({
{...provided.droppableProps} {...provided.droppableProps}
className="virtual-scroller" className="virtual-scroller"
style={{ style={{
...scrollerStyle,
height: '100%', height: '100%',
width: '100%', width: '100%',
overflowY: 'auto', overflowY: 'auto',

View File

@ -3,14 +3,14 @@ import CustomTag from '@renderer/components/CustomTag'
import ExpandableText from '@renderer/components/ExpandableText' import ExpandableText from '@renderer/components/ExpandableText'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags' import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup' import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { getModelLogo } from '@renderer/config/models' import { getModelLogo } from '@renderer/config/models'
import FileItem from '@renderer/pages/files/FileItem' import FileItem from '@renderer/pages/files/FileItem'
import { Model, Provider } from '@renderer/types' import { Model, Provider } from '@renderer/types'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { Button, Flex, Tooltip } from 'antd' import { Button, Flex, Tooltip } from 'antd'
import { Avatar } from 'antd' import { Avatar } from 'antd'
import { ChevronRight } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -39,8 +39,6 @@ interface ManageModelsListProps {
const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provider, onAddModel, onRemoveModel }) => { const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provider, onAddModel, onRemoveModel }) => {
const { t } = useTranslation() const { t } = useTranslation()
const scrollerRef = useRef<HTMLDivElement>(null)
const activeStickyIndexRef = useRef(0)
const [collapsedGroups, setCollapsedGroups] = useState(new Set<string>()) const [collapsedGroups, setCollapsedGroups] = useState(new Set<string>())
const handleGroupToggle = useCallback((groupName: string) => { const handleGroupToggle = useCallback((groupName: string) => {
@ -74,33 +72,6 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
return rows return rows
}, [modelGroups, collapsedGroups]) }, [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( const renderGroupTools = useCallback(
(models: Model[]) => { (models: Model[]) => {
const isAllInProvider = models.every((model) => isModelInProvider(provider, model.id)) const isAllInProvider = models.every((model) => isModelInProvider(provider, model.id))
@ -153,79 +124,47 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
[provider, onRemoveModel, onAddModel, t] [provider, onRemoveModel, onAddModel, t]
) )
const virtualItems = virtualizer.getVirtualItems()
return ( return (
<ListContainer ref={scrollerRef}> <DynamicVirtualList
<div list={flatRows}
style={{ estimateSize={useCallback(() => 60, [])}
height: `${virtualizer.getTotalSize()}px`, isSticky={useCallback((index: number) => flatRows[index].type === 'group', [flatRows])}
width: '100%', overscan={5}
position: 'relative' scrollerStyle={{
}}> paddingRight: '10px'
{virtualItems.map((virtualItem) => { }}
const row = flatRows[virtualItem.index] itemContainerStyle={{
const isRowSticky = isSticky(virtualItem.index) paddingBottom: '8px'
const isRowActiveSticky = isActiveSticky(virtualItem.index) }}>
const isCollapsed = row.type === 'group' && collapsedGroups.has(row.groupName) {(row) => {
if (row.type === 'group') {
if (!row) return null const isCollapsed = collapsedGroups.has(row.groupName)
return ( return (
<div <GroupHeader
key={virtualItem.index} style={{ background: 'var(--color-background)' }}
data-index={virtualItem.index} onClick={() => handleGroupToggle(row.groupName)}>
ref={virtualizer.measureElement} <Flex align="center" gap={10} style={{ flex: 1 }}>
style={{ <ChevronRight
...(isRowSticky size={16}
? { color="var(--color-text-3)"
background: 'var(--color-background)', strokeWidth={1.5}
zIndex: 1 style={{ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' }}
} />
: {}), <span style={{ fontWeight: 'bold', fontSize: '14px' }}>{row.groupName}</span>
...(isRowActiveSticky <CustomTag color="#02B96B" size={10}>
? { {row.models.length}
position: 'sticky' </CustomTag>
} </Flex>
: { {renderGroupTools(row.models)}
position: 'absolute', </GroupHeader>
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>
) )
})} }
</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` const GroupHeader = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 8px; padding: 0 8px;
min-height: 48px; min-height: 50px;
color: var(--color-text); color: var(--color-text);
cursor: pointer; cursor: pointer;
` `

View File

@ -1,10 +1,10 @@
import { MinusOutlined } from '@ant-design/icons' import { MinusOutlined } from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse' import CustomCollapse from '@renderer/components/CustomCollapse'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { ModelWithStatus } from '@renderer/types/healthCheck' import { ModelWithStatus } from '@renderer/types/healthCheck'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Button, Flex, Tooltip } from 'antd' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -32,29 +32,15 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
onRemoveGroup onRemoveGroup
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const scrollerRef = useRef<HTMLDivElement>(null) const listRef = useRef<DynamicVirtualListRef>(null)
const [isExpanded, setIsExpanded] = useState(defaultOpen)
const virtualizer = useVirtualizer({ const handleCollapseChange = useCallback((activeKeys: string[] | string) => {
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 isNowExpanded = Array.isArray(activeKeys) ? activeKeys.length > 0 : !!activeKeys const isNowExpanded = Array.isArray(activeKeys) ? activeKeys.length > 0 : !!activeKeys
setIsExpanded(isNowExpanded) if (isNowExpanded) {
} // 延迟到 DOM 可见后测量
requestAnimationFrame(() => listRef.current?.measure())
}
}, [])
return ( return (
<CustomCollapseWrapper> <CustomCollapseWrapper>
@ -80,45 +66,28 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
/> />
</Tooltip> </Tooltip>
}> }>
<ScrollContainer ref={scrollerRef}> <DynamicVirtualList
<div ref={listRef}
style={{ list={models}
height: `${virtualizer.getTotalSize()}px`, estimateSize={useCallback(() => 52, [])} // 44px item + 8px padding
width: '100%', overscan={5}
position: 'relative' scrollerStyle={{
}}> maxHeight: '390px',
<div padding: '4px 16px'
style={{ }}
position: 'absolute', itemContainerStyle={{
top: 0, padding: '4px 0'
left: 0, }}>
width: '100%', {(model) => (
transform: `translateY(${virtualItems[0]?.start ?? 0}px)` <ModelListItem
}}> model={model}
{virtualItems.map((virtualItem) => { modelStatus={modelStatuses.find((status) => status.model.id === model.id)}
const model = models[virtualItem.index] onEdit={onEditModel}
return ( onRemove={onRemoveModel}
<div disabled={disabled}
key={virtualItem.key} />
data-index={virtualItem.index} )}
ref={virtualizer.measureElement} </DynamicVirtualList>
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>
</CustomCollapse> </CustomCollapse>
</CustomCollapseWrapper> </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) export default memo(ModelListGroup)

View File

@ -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()
})
})
})

View File

@ -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>
`;

View 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

View File

@ -0,0 +1 @@
export { default as DynamicVirtualList, type DynamicVirtualListProps, type DynamicVirtualListRef } from './dynamic'

View File

@ -4,7 +4,7 @@ exports[`DraggableVirtualList > snapshot > should match snapshot with custom sty
<div> <div>
<div <div
class="custom-class draggable-virtual-list" 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 <div
data-testid="drag-drop-context" data-testid="drag-drop-context"

View File

@ -1,12 +1,12 @@
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons' import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { handleDelete } from '@renderer/services/FileAction' import { handleDelete } from '@renderer/services/FileAction'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { FileMetadata, FileTypes } from '@renderer/types' import { FileMetadata, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils' import { formatFileSize } from '@renderer/utils'
import { Col, Image, Row, Spin } from 'antd' import { Col, Image, Row, Spin } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import VirtualList from 'rc-virtual-list' import React, { memo, useCallback } from 'react'
import React, { memo } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import FileItem from './FileItem' import FileItem from './FileItem'
@ -27,6 +27,8 @@ interface FileItemProps {
} }
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => { const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
const estimateSize = useCallback(() => 75, [])
if (id === FileTypes.IMAGE && files?.length && files?.length > 0) { if (id === FileTypes.IMAGE && files?.length && files?.length > 0) {
return ( return (
<div style={{ padding: 16, overflowY: 'auto' }}> <div style={{ padding: 16, overflowY: 'auto' }}>
@ -78,38 +80,29 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
} }
return ( return (
<VirtualList <DynamicVirtualList
data={list} list={list}
height={window.innerHeight - 100} estimateSize={estimateSize}
itemHeight={75} overscan={2}
itemKey="key" scrollerStyle={{
style={{ padding: '0 16px 16px 16px' }} padding: '0 16px 16px 16px'
styles={{ }}
verticalScrollBar: { itemContainerStyle={{
width: 6 height: '75px',
}, paddingTop: '12px'
verticalScrollBarThumb: {
background: 'var(--color-scrollbar-thumb)'
}
}}> }}>
{(item) => ( {(item) => (
<div <FileItem
style={{ key={item.key}
height: '75px', fileInfo={{
paddingTop: '12px' name: item.file,
}}> ext: item.ext,
<FileItem extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`,
key={item.key} actions: item.actions
fileInfo={{ }}
name: item.file, />
ext: item.ext,
extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`,
actions: item.actions
}}
/>
</div>
)} )}
</VirtualList> </DynamicVirtualList>
) )
} }

View File

@ -10,7 +10,7 @@ import {
QuestionCircleOutlined, QuestionCircleOutlined,
UploadOutlined UploadOutlined
} from '@ant-design/icons' } 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 CopyIcon from '@renderer/components/Icons/CopyIcon'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup' 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' const singlealone = topicPosition === 'right' && position === 'right'
return ( return (
<DraggableList <DraggableVirtualList
className="topics-tab" className="topics-tab"
list={sortedTopics} list={sortedTopics}
onUpdate={updateTopics} 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' }} itemContainerStyle={{ paddingBottom: '8px' }}
header={ header={
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> <AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
@ -521,7 +521,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
</Dropdown> </Dropdown>
) )
}} }}
</DraggableList> </DraggableVirtualList>
) )
} }

View File

@ -104,36 +104,34 @@ const KnowledgePage: FC = () => {
</Navbar> </Navbar>
<ContentContainer id="content-container"> <ContentContainer id="content-container">
<KnowledgeSideNav> <KnowledgeSideNav>
<ScrollContainer> <DraggableList
<DraggableList list={bases}
list={bases} onUpdate={updateKnowledgeBases}
onUpdate={updateKnowledgeBases} style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }} onDragStart={() => setIsDragging(true)}
onDragStart={() => setIsDragging(true)} onDragEnd={() => setIsDragging(false)}>
onDragEnd={() => setIsDragging(false)}> {(base: KnowledgeBase) => (
{(base: KnowledgeBase) => ( <Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}>
<Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}> <div>
<div> <ListItem
<ListItem active={selectedBase?.id === base.id}
active={selectedBase?.id === base.id} icon={<Book size={16} />}
icon={<Book size={16} />} title={base.name}
title={base.name} onClick={() => setSelectedBase(base)}
onClick={() => setSelectedBase(base)} />
/> </div>
</div> </Dropdown>
</Dropdown>
)}
</DraggableList>
{!isDragging && (
<AddKnowledgeItem onClick={handleAddKnowledge}>
<AddKnowledgeName>
<Plus size={18} />
{t('button.add')}
</AddKnowledgeName>
</AddKnowledgeItem>
)} )}
<div style={{ minHeight: '10px' }}></div> </DraggableList>
</ScrollContainer> {!isDragging && (
<AddKnowledgeItem onClick={handleAddKnowledge}>
<AddKnowledgeName>
<Plus size={18} />
{t('button.add')}
</AddKnowledgeName>
</AddKnowledgeItem>
)}
<div style={{ minHeight: '10px' }}></div>
</KnowledgeSideNav> </KnowledgeSideNav>
{bases.length === 0 ? ( {bases.length === 0 ? (
<MainContent> <MainContent>
@ -169,13 +167,14 @@ const MainContent = styled(Scrollbar)`
padding-bottom: 50px; padding-bottom: 50px;
` `
export const KnowledgeSideNav = styled.div` const KnowledgeSideNav = styled(Scrollbar)`
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 12px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: calc(var(--settings-width) + 100px);
border-right: 0.5px solid var(--color-border);
padding: 12px 10px;
.ant-menu { .ant-menu {
border-inline-end: none !important; border-inline-end: none !important;
background: transparent; background: transparent;
@ -197,12 +196,6 @@ export const KnowledgeSideNav = styled.div`
color: var(--color-primary); color: var(--color-primary);
} }
} }
`
const ScrollContainer = styled(Scrollbar)`
display: flex;
flex-direction: column;
flex: 1;
> div { > div {
margin-bottom: 8px; margin-bottom: 8px;

View File

@ -1,7 +1,7 @@
import { DeleteOutlined } from '@ant-design/icons' import { DeleteOutlined } from '@ant-design/icons'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import Ellipsis from '@renderer/components/Ellipsis' 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 { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem' import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService' import { getProviderName } from '@renderer/services/ProviderService'
@ -9,7 +9,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button, Tooltip } from 'antd' import { Button, Tooltip } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { FC } from 'react' import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -46,6 +46,9 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
const providerName = getProviderName(base?.model.provider || '') const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...directoryItems].reverse(), [directoryItems])
const estimateSize = useCallback(() => 75, [])
if (!base) { if (!base) {
return null return null
} }
@ -76,46 +79,51 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
</ItemHeader> </ItemHeader>
<ItemFlexColumn> <ItemFlexColumn>
{directoryItems.length === 0 && <KnowledgeEmptyView />} {directoryItems.length === 0 && <KnowledgeEmptyView />}
{directoryItems.reverse().map((item) => ( <DynamicVirtualList
<FileItem list={reversedItems}
key={item.id} estimateSize={estimateSize}
fileInfo={{ overscan={2}
name: ( scrollerStyle={{ paddingRight: 2 }}
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}> itemContainerStyle={{ paddingBottom: 10 }}
<Ellipsis> autoHideScrollbar>
<Tooltip title={item.content as string}>{item.content as string}</Tooltip> {(item) => (
</Ellipsis> <FileItem
</ClickableSpan> key={item.id}
), fileInfo={{
ext: '.folder', name: (
extra: getDisplayTime(item), <ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
actions: ( <Ellipsis>
<FlexAlignCenter> <Tooltip title={item.content as string}>{item.content as string}</Tooltip>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />} </Ellipsis>
<StatusIconWrapper> </ClickableSpan>
<StatusIcon ),
sourceId={item.id} ext: '.folder',
base={base} extra: getDisplayTime(item),
getProcessingStatus={getProcessingStatus} actions: (
progress={progressMap.get(item.id)} <FlexAlignCenter>
type="directory" {item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
/> <StatusIconWrapper>
</StatusIconWrapper> <StatusIcon
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} /> sourceId={item.id}
</FlexAlignCenter> base={base}
) getProcessingStatus={getProcessingStatus}
}} progress={progressMap.get(item.id)}
/> type="directory"
))} />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
)}
</DynamicVirtualList>
</ItemFlexColumn> </ItemFlexColumn>
</ItemContainer> </ItemContainer>
) )
} }
const ItemFlexColumn = styled(Scrollbar)` const ItemFlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 16px; padding: 20px 16px;
height: calc(100vh - 135px); height: calc(100vh - 135px);
` `

View File

@ -12,13 +12,14 @@ import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@sh
import { Button, Tooltip, Upload } from 'antd' import { Button, Tooltip, Upload } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import VirtualList from 'rc-virtual-list' import { FC, useCallback, useEffect, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
const logger = loggerService.withContext('KnowledgeFiles') const logger = loggerService.withContext('KnowledgeFiles')
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { import {
ClickableSpan, ClickableSpan,
FlexAlignCenter, FlexAlignCenter,
@ -64,6 +65,8 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
const providerName = getProviderName(base?.model.provider || '') const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName const disabled = !base?.version || !providerName
const estimateSize = useCallback(() => 75, [])
if (!base) { if (!base) {
return null return null
} }
@ -160,15 +163,12 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
{fileItems.length === 0 ? ( {fileItems.length === 0 ? (
<KnowledgeEmptyView /> <KnowledgeEmptyView />
) : ( ) : (
<VirtualList <DynamicVirtualList
data={fileItems.reverse()} list={fileItems.reverse()}
height={windowHeight - 270} estimateSize={estimateSize}
itemHeight={75} overscan={2}
itemKey="id" scrollerStyle={{ height: windowHeight - 270 }}
styles={{ autoHideScrollbar>
verticalScrollBar: { width: 6 },
verticalScrollBarThumb: { background: 'var(--color-scrollbar-thumb)' }
}}>
{(item) => { {(item) => {
const file = item.content as FileType const file = item.content as FileType
return ( return (
@ -218,7 +218,7 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
</div> </div>
) )
}} }}
</VirtualList> </DynamicVirtualList>
)} )}
</ItemFlexColumn> </ItemFlexColumn>
</ItemContainer> </ItemContainer>

View File

@ -1,6 +1,6 @@
import { DeleteOutlined, EditOutlined } from '@ant-design/icons' import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup' 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 { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem' import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService' import { getProviderName } from '@renderer/services/ProviderService'
@ -8,7 +8,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button } from 'antd' import { Button } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { FC } from 'react' import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -34,6 +34,9 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const providerName = getProviderName(base?.model.provider || '') const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...noteItems].reverse(), [noteItems])
const estimateSize = useCallback(() => 75, [])
if (!base) { if (!base) {
return null return null
} }
@ -72,34 +75,44 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</ItemHeader> </ItemHeader>
<ItemFlexColumn> <ItemFlexColumn>
{noteItems.length === 0 && <KnowledgeEmptyView />} {noteItems.length === 0 && <KnowledgeEmptyView />}
{noteItems.reverse().map((note) => ( <DynamicVirtualList
<FileItem list={reversedItems}
key={note.id} estimateSize={estimateSize}
fileInfo={{ overscan={2}
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>, scrollerStyle={{ paddingRight: 2 }}
ext: '.txt', itemContainerStyle={{ paddingBottom: 10 }}
extra: getDisplayTime(note), autoHideScrollbar>
actions: ( {(note) => (
<FlexAlignCenter> <FileItem
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} /> key={note.id}
<StatusIconWrapper> fileInfo={{
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} type="note" /> name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
</StatusIconWrapper> ext: '.txt',
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} /> extra: getDisplayTime(note),
</FlexAlignCenter> 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> </ItemFlexColumn>
</ItemContainer> </ItemContainer>
) )
} }
const ItemFlexColumn = styled(Scrollbar)` const ItemFlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 16px; padding: 20px 16px;
height: calc(100vh - 135px); height: calc(100vh - 135px);
` `

View File

@ -2,7 +2,7 @@ import { DeleteOutlined } from '@ant-design/icons'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import Ellipsis from '@renderer/components/Ellipsis' import Ellipsis from '@renderer/components/Ellipsis'
import PromptPopup from '@renderer/components/Popups/PromptPopup' 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 { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem' import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService' import { getProviderName } from '@renderer/services/ProviderService'
@ -10,7 +10,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button, message, Tooltip } from 'antd' import { Button, message, Tooltip } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { FC } from 'react' import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -46,6 +46,9 @@ const KnowledgeSitemaps: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const providerName = getProviderName(base?.model.provider || '') const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...sitemapItems].reverse(), [sitemapItems])
const estimateSize = useCallback(() => 75, [])
if (!base) { if (!base) {
return null return null
} }
@ -95,49 +98,54 @@ const KnowledgeSitemaps: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</ItemHeader> </ItemHeader>
<ItemFlexColumn> <ItemFlexColumn>
{sitemapItems.length === 0 && <KnowledgeEmptyView />} {sitemapItems.length === 0 && <KnowledgeEmptyView />}
{sitemapItems.reverse().map((item) => ( <DynamicVirtualList
<FileItem list={reversedItems}
key={item.id} estimateSize={estimateSize}
fileInfo={{ overscan={2}
name: ( scrollerStyle={{ paddingRight: 2 }}
<ClickableSpan> itemContainerStyle={{ paddingBottom: 10 }}
<Tooltip title={item.content as string}> autoHideScrollbar>
<Ellipsis> {(item) => (
<a href={item.content as string} target="_blank" rel="noopener noreferrer"> <FileItem
{item.content as string} key={item.id}
</a> fileInfo={{
</Ellipsis> name: (
</Tooltip> <ClickableSpan>
</ClickableSpan> <Tooltip title={item.content as string}>
), <Ellipsis>
ext: '.sitemap', <a href={item.content as string} target="_blank" rel="noopener noreferrer">
extra: getDisplayTime(item), {item.content as string}
actions: ( </a>
<FlexAlignCenter> </Ellipsis>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />} </Tooltip>
<StatusIconWrapper> </ClickableSpan>
<StatusIcon ),
sourceId={item.id} ext: '.sitemap',
base={base} extra: getDisplayTime(item),
getProcessingStatus={getProcessingStatus} actions: (
type="sitemap" <FlexAlignCenter>
/> {item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
</StatusIconWrapper> <StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} /> <StatusIcon
</FlexAlignCenter> sourceId={item.id}
) base={base}
}} getProcessingStatus={getProcessingStatus}
/> type="sitemap"
))} />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
)}
</DynamicVirtualList>
</ItemFlexColumn> </ItemFlexColumn>
</ItemContainer> </ItemContainer>
) )
} }
const ItemFlexColumn = styled(Scrollbar)` const ItemFlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 16px; padding: 20px 16px;
height: calc(100vh - 135px); height: calc(100vh - 135px);
` `

View File

@ -1,7 +1,7 @@
import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons' import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import Ellipsis from '@renderer/components/Ellipsis' import Ellipsis from '@renderer/components/Ellipsis'
import PromptPopup from '@renderer/components/Popups/PromptPopup' 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 { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem' import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService' import { getProviderName } from '@renderer/services/ProviderService'
@ -9,7 +9,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button, Dropdown, Tooltip } from 'antd' import { Button, Dropdown, Tooltip } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { FC } from 'react' import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -43,6 +43,9 @@ const KnowledgeUrls: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const providerName = getProviderName(base?.model.provider || '') const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...urlItems].reverse(), [urlItems])
const estimateSize = useCallback(() => 75, [])
if (!base) { if (!base) {
return null return null
} }
@ -123,66 +126,71 @@ const KnowledgeUrls: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</ItemHeader> </ItemHeader>
<ItemFlexColumn> <ItemFlexColumn>
{urlItems.length === 0 && <KnowledgeEmptyView />} {urlItems.length === 0 && <KnowledgeEmptyView />}
{urlItems.reverse().map((item) => ( <DynamicVirtualList
<FileItem list={reversedItems}
key={item.id} estimateSize={estimateSize}
fileInfo={{ overscan={2}
name: ( scrollerStyle={{ paddingRight: 2 }}
<Dropdown itemContainerStyle={{ paddingBottom: 10 }}
menu={{ autoHideScrollbar>
items: [ {(item) => (
{ <FileItem
key: 'edit', key={item.id}
icon: <EditOutlined />, fileInfo={{
label: t('knowledge.edit_remark'), name: (
onClick: () => handleEditRemark(item) <Dropdown
}, menu={{
{ items: [
key: 'copy', {
icon: <CopyOutlined />, key: 'edit',
label: t('common.copy'), icon: <EditOutlined />,
onClick: () => { label: t('knowledge.edit_remark'),
navigator.clipboard.writeText(item.content as string) onClick: () => handleEditRemark(item)
window.message.success(t('message.copied')) },
{
key: 'copy',
icon: <CopyOutlined />,
label: t('common.copy'),
onClick: () => {
navigator.clipboard.writeText(item.content as string)
window.message.success(t('message.copied'))
}
} }
} ]
] }}
}} trigger={['contextMenu']}>
trigger={['contextMenu']}> <ClickableSpan>
<ClickableSpan> <Tooltip title={item.content as string}>
<Tooltip title={item.content as string}> <Ellipsis>
<Ellipsis> <a href={item.content as string} target="_blank" rel="noopener noreferrer">
<a href={item.content as string} target="_blank" rel="noopener noreferrer"> {item.remark || (item.content as string)}
{item.remark || (item.content as string)} </a>
</a> </Ellipsis>
</Ellipsis> </Tooltip>
</Tooltip> </ClickableSpan>
</ClickableSpan> </Dropdown>
</Dropdown> ),
), ext: '.url',
ext: '.url', extra: getDisplayTime(item),
extra: getDisplayTime(item), actions: (
actions: ( <FlexAlignCenter>
<FlexAlignCenter> {item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />} <StatusIconWrapper>
<StatusIconWrapper> <StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" /> </StatusIconWrapper>
</StatusIconWrapper> <Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} /> </FlexAlignCenter>
</FlexAlignCenter> )
) }}
}} />
/> )}
))} </DynamicVirtualList>
</ItemFlexColumn> </ItemFlexColumn>
</ItemContainer> </ItemContainer>
) )
} }
const ItemFlexColumn = styled(Scrollbar)` const ItemFlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 16px; padding: 20px 16px;
height: calc(100vh - 135px); height: calc(100vh - 135px);
` `

View File

@ -1,7 +1,6 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import Scrollbar from '@renderer/components/Scrollbar' import { DraggableVirtualList } from '@renderer/components/DraggableList'
import { getProviderLogo } from '@renderer/config/providers' import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label' import { getProviderLabel } from '@renderer/i18n/label'
@ -9,7 +8,6 @@ import ImageStorage from '@renderer/services/ImageStorage'
import { INITIAL_PROVIDERS } from '@renderer/store/llm' import { INITIAL_PROVIDERS } from '@renderer/store/llm'
import { Provider, ProviderType } from '@renderer/types' import { Provider, ProviderType } from '@renderer/types'
import { import {
droppableReorder,
generateColorFromChar, generateColorFromChar,
getFancyProviderName, getFancyProviderName,
getFirstCharacter, getFirstCharacter,
@ -30,6 +28,8 @@ import ProviderSetting from './ProviderSetting'
const logger = loggerService.withContext('ProvidersList') const logger = loggerService.withContext('ProvidersList')
const BUTTON_WRAPPER_HEIGHT = 50
const ProvidersList: FC = () => { const ProvidersList: FC = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const providers = useAllProviders() const providers = useAllProviders()
@ -272,14 +272,9 @@ const ProvidersList: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]) }, [searchParams])
const onDragEnd = (result: DropResult) => { const handleUpdateProviders = (reorderProviders: Provider[]) => {
setDragging(false) setDragging(false)
if (result.destination) { updateProviders(reorderProviders)
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderProviders = droppableReorder<Provider>(providers, sourceIndex, destIndex)
updateProviders(reorderProviders)
}
} }
const onAddProvider = async () => { const onAddProvider = async () => {
@ -462,50 +457,37 @@ const ProvidersList: FC = () => {
disabled={dragging} disabled={dragging}
/> />
</AddButtonWrapper> </AddButtonWrapper>
<Scrollbar> <DraggableVirtualList
<ProviderList> list={filteredProviders}
<DragDropContext onDragStart={() => setDragging(true)} onDragEnd={onDragEnd}> onUpdate={handleUpdateProviders}
<Droppable droppableId="droppable"> onDragStart={() => setDragging(true)}
{(provided) => ( estimateSize={useCallback(() => 40, [])}
<div {...provided.droppableProps} ref={provided.innerRef}> overscan={3}
{filteredProviders.map((provider, index) => ( style={{
<Draggable height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
key={`draggable_${provider.id}_${index}`} }}
draggableId={provider.id} scrollerStyle={{
index={index} padding: 8,
isDragDisabled={searchText.length > 0}> paddingRight: 5
{(provided) => ( }}
<div itemContainerStyle={{ paddingBottom: 5 }}>
ref={provided.innerRef} {(provider) => (
{...provided.draggableProps} <Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
{...provided.dragHandleProps} <ProviderListItem
style={{ ...provided.draggableProps.style, marginBottom: 5 }}> key={JSON.stringify(provider)}
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}> className={provider.id === selectedProvider?.id ? 'active' : ''}
<ProviderListItem onClick={() => setSelectedProvider(provider)}>
key={JSON.stringify(provider)} {getProviderAvatar(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''} <ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
onClick={() => setSelectedProvider(provider)}> {provider.enabled && (
{getProviderAvatar(provider)} <Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
<ProviderItemName className="text-nowrap"> ON
{getFancyProviderName(provider)} </Tag>
</ProviderItemName>
{provider.enabled && (
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
ON
</Tag>
)}
</ProviderListItem>
</Dropdown>
</div>
)}
</Draggable>
))}
</div>
)} )}
</Droppable> </ProviderListItem>
</DragDropContext> </Dropdown>
</ProviderList> )}
</Scrollbar> </DraggableVirtualList>
<AddButtonWrapper> <AddButtonWrapper>
<Button <Button
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }} 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); 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` const ProviderListItem = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -575,7 +549,7 @@ const ProviderItemName = styled.div`
` `
const AddButtonWrapper = styled.div` const AddButtonWrapper = styled.div`
height: 50px; height: ${BUTTON_WRAPPER_HEIGHT}px;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
align-items: center; align-items: center;