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>
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',

View File

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

View File

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

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
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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