mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 13:19:33 +08:00
refactor: add a custom dynamic virtual list component (#8711)
- add a custom dynamic virtual list component - add tests - support autohide - used in ManageModelsList, ModelListGroup, KnowledgePage, FileList - improve DraggableVirtualList - use name DraggableVirtualList directly, make it flex by default - use DraggableVirtualList in ProviderList
This commit is contained in:
parent
9217101032
commit
2711cf5c27
@ -35,6 +35,7 @@ interface DraggableVirtualListProps<T> {
|
|||||||
ref?: React.Ref<HTMLDivElement>
|
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',
|
||||||
|
|||||||
@ -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;
|
||||||
`
|
`
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -0,0 +1,372 @@
|
|||||||
|
import { act, render, screen } from '@testing-library/react'
|
||||||
|
import React, { useRef } from 'react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { DynamicVirtualList, type DynamicVirtualListRef } from '..'
|
||||||
|
|
||||||
|
// Mock management
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
virtualizer: {
|
||||||
|
getVirtualItems: vi.fn(() => [
|
||||||
|
{ index: 0, key: 'item-0', start: 0, size: 50 },
|
||||||
|
{ index: 1, key: 'item-1', start: 50, size: 50 },
|
||||||
|
{ index: 2, key: 'item-2', start: 100, size: 50 }
|
||||||
|
]),
|
||||||
|
getTotalSize: vi.fn(() => 150),
|
||||||
|
getVirtualIndexes: vi.fn(() => [0, 1, 2]),
|
||||||
|
measure: vi.fn(),
|
||||||
|
scrollToOffset: vi.fn(),
|
||||||
|
scrollToIndex: vi.fn(),
|
||||||
|
resizeItem: vi.fn(),
|
||||||
|
measureElement: vi.fn(),
|
||||||
|
scrollElement: null as HTMLDivElement | null
|
||||||
|
},
|
||||||
|
useVirtualizer: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Set up the mock to return our mock virtualizer
|
||||||
|
mocks.useVirtualizer.mockImplementation(() => mocks.virtualizer)
|
||||||
|
|
||||||
|
vi.mock('@tanstack/react-virtual', () => ({
|
||||||
|
useVirtualizer: mocks.useVirtualizer,
|
||||||
|
defaultRangeExtractor: vi.fn((range) =>
|
||||||
|
Array.from({ length: range.endIndex - range.startIndex + 1 }, (_, i) => range.startIndex + i)
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Test data factory
|
||||||
|
interface TestItem {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestItems(count = 5): TestItem[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: `${i + 1}`,
|
||||||
|
content: `Item ${i + 1}`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DynamicVirtualList', () => {
|
||||||
|
const defaultItems = createTestItems()
|
||||||
|
const defaultProps = {
|
||||||
|
list: defaultItems,
|
||||||
|
estimateSize: () => 50,
|
||||||
|
children: (item: TestItem, index: number) => <div data-testid={`item-${index}`}>{item.content}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test component for ref testing
|
||||||
|
const TestComponentWithRef: React.FC<{
|
||||||
|
onRefReady?: (ref: DynamicVirtualListRef | null) => void
|
||||||
|
listProps?: any
|
||||||
|
}> = ({ onRefReady, listProps = {} }) => {
|
||||||
|
const ref = useRef<DynamicVirtualListRef>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onRefReady?.(ref.current)
|
||||||
|
}, [onRefReady])
|
||||||
|
|
||||||
|
return <DynamicVirtualList ref={ref} {...defaultProps} {...listProps} />
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('basic rendering', () => {
|
||||||
|
it('snapshot test', () => {
|
||||||
|
const { container } = render(<DynamicVirtualList {...defaultProps} />)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply custom scroller styles', () => {
|
||||||
|
const customStyle = { backgroundColor: 'red', height: '400px' }
|
||||||
|
render(<DynamicVirtualList {...defaultProps} scrollerStyle={customStyle} />)
|
||||||
|
|
||||||
|
const scrollContainer = document.querySelector('.dynamic-virtual-list')
|
||||||
|
expect(scrollContainer).toBeInTheDocument()
|
||||||
|
expect(scrollContainer).toHaveStyle('background-color: rgb(255, 0, 0)')
|
||||||
|
expect(scrollContainer).toHaveStyle('height: 400px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply custom item container styles', () => {
|
||||||
|
const itemStyle = { padding: '10px', margin: '5px' }
|
||||||
|
render(<DynamicVirtualList {...defaultProps} itemContainerStyle={itemStyle} />)
|
||||||
|
|
||||||
|
const items = document.querySelectorAll('[data-index]')
|
||||||
|
expect(items.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Check first item styles
|
||||||
|
const firstItem = items[0] as HTMLElement
|
||||||
|
expect(firstItem).toHaveStyle('padding: 10px')
|
||||||
|
expect(firstItem).toHaveStyle('margin: 5px')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('props integration', () => {
|
||||||
|
it('should render correctly with different item counts', () => {
|
||||||
|
const { rerender } = render(<DynamicVirtualList {...defaultProps} list={createTestItems(3)} />)
|
||||||
|
|
||||||
|
// Should render without errors
|
||||||
|
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Should handle dynamic item count changes
|
||||||
|
rerender(<DynamicVirtualList {...defaultProps} list={createTestItems(10)} />)
|
||||||
|
expect(document.querySelector('.dynamic-virtual-list')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should work with custom estimateSize function', () => {
|
||||||
|
const customEstimateSize = vi.fn(() => 80)
|
||||||
|
|
||||||
|
// Should render without errors when using custom estimateSize
|
||||||
|
expect(() => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} estimateSize={customEstimateSize} />)
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sticky feature', () => {
|
||||||
|
it('should apply sticky positioning to specified items', () => {
|
||||||
|
const isSticky = vi.fn((index: number) => index === 0) // First item is sticky
|
||||||
|
|
||||||
|
render(<DynamicVirtualList {...defaultProps} isSticky={isSticky} />)
|
||||||
|
|
||||||
|
// Should call isSticky function during rendering
|
||||||
|
expect(isSticky).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Should apply sticky styles to sticky items
|
||||||
|
const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement
|
||||||
|
expect(stickyItem).toBeInTheDocument()
|
||||||
|
expect(stickyItem).toHaveStyle('position: sticky')
|
||||||
|
expect(stickyItem).toHaveStyle('z-index: 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply absolute positioning to non-sticky items', () => {
|
||||||
|
const isSticky = vi.fn((index: number) => index === 0)
|
||||||
|
|
||||||
|
render(<DynamicVirtualList {...defaultProps} isSticky={isSticky} />)
|
||||||
|
|
||||||
|
// Non-sticky items should have absolute positioning
|
||||||
|
const regularItem = document.querySelector('[data-index="1"]') as HTMLElement
|
||||||
|
expect(regularItem).toBeInTheDocument()
|
||||||
|
expect(regularItem).toHaveStyle('position: absolute')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply absolute positioning to all items when no sticky function provided', () => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} />)
|
||||||
|
|
||||||
|
// All items should have absolute positioning
|
||||||
|
const items = document.querySelectorAll('[data-index]')
|
||||||
|
items.forEach((item) => {
|
||||||
|
const htmlItem = item as HTMLElement
|
||||||
|
expect(htmlItem).toHaveStyle('position: absolute')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('custom range extractor', () => {
|
||||||
|
it('should work with custom rangeExtractor', () => {
|
||||||
|
const customRangeExtractor = vi.fn(() => [0, 1, 2])
|
||||||
|
|
||||||
|
// Should render without errors when using custom rangeExtractor
|
||||||
|
expect(() => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} rangeExtractor={customRangeExtractor} />)
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle both rangeExtractor and sticky props gracefully', () => {
|
||||||
|
const customRangeExtractor = vi.fn(() => [0, 1, 2])
|
||||||
|
const isSticky = vi.fn((index: number) => index === 0)
|
||||||
|
|
||||||
|
// Should render without conflicts when both props are provided
|
||||||
|
expect(() => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} rangeExtractor={customRangeExtractor} isSticky={isSticky} />)
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ref api', () => {
|
||||||
|
let refInstance: DynamicVirtualListRef | null = null
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
render(
|
||||||
|
<TestComponentWithRef
|
||||||
|
onRefReady={(ref) => {
|
||||||
|
refInstance = ref
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for ref to be ready
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expose all required ref methods', () => {
|
||||||
|
expect(refInstance).toBeTruthy()
|
||||||
|
expect(refInstance).not.toBeNull()
|
||||||
|
|
||||||
|
// Type assertion to help TypeScript understand the type
|
||||||
|
const ref = refInstance as unknown as DynamicVirtualListRef
|
||||||
|
expect(typeof ref.measure).toBe('function')
|
||||||
|
expect(typeof ref.scrollElement).toBe('function')
|
||||||
|
expect(typeof ref.scrollToOffset).toBe('function')
|
||||||
|
expect(typeof ref.scrollToIndex).toBe('function')
|
||||||
|
expect(typeof ref.resizeItem).toBe('function')
|
||||||
|
expect(typeof ref.getTotalSize).toBe('function')
|
||||||
|
expect(typeof ref.getVirtualItems).toBe('function')
|
||||||
|
expect(typeof ref.getVirtualIndexes).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow calling all ref methods without throwing', () => {
|
||||||
|
const ref = refInstance as unknown as DynamicVirtualListRef
|
||||||
|
|
||||||
|
// Test that all methods can be called without errors
|
||||||
|
expect(() => ref.measure()).not.toThrow()
|
||||||
|
expect(() => ref.scrollToOffset(100, { align: 'start' })).not.toThrow()
|
||||||
|
expect(() => ref.scrollToIndex(2, { align: 'center' })).not.toThrow()
|
||||||
|
expect(() => ref.resizeItem(1, 80)).not.toThrow()
|
||||||
|
|
||||||
|
// Test that data methods return expected types
|
||||||
|
expect(typeof ref.getTotalSize()).toBe('number')
|
||||||
|
expect(Array.isArray(ref.getVirtualItems())).toBe(true)
|
||||||
|
expect(Array.isArray(ref.getVirtualIndexes())).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('orientation support', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mocks for orientation tests
|
||||||
|
mocks.virtualizer.getVirtualItems.mockReturnValue([
|
||||||
|
{ index: 0, key: 'item-0', start: 0, size: 100 },
|
||||||
|
{ index: 1, key: 'item-1', start: 100, size: 100 }
|
||||||
|
])
|
||||||
|
mocks.virtualizer.getTotalSize.mockReturnValue(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply horizontal layout styles correctly', () => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} horizontal={true} />)
|
||||||
|
|
||||||
|
// Verify container styles for horizontal layout
|
||||||
|
const container = document.querySelector('div[style*="position: relative"]') as HTMLElement
|
||||||
|
expect(container).toHaveStyle('width: 200px') // totalSize
|
||||||
|
expect(container).toHaveStyle('height: 100%')
|
||||||
|
|
||||||
|
// Verify item transform for horizontal layout
|
||||||
|
const items = document.querySelectorAll('[data-index]')
|
||||||
|
const firstItem = items[0] as HTMLElement
|
||||||
|
expect(firstItem.style.transform).toContain('translateX(0px)')
|
||||||
|
expect(firstItem).toHaveStyle('height: 100%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply vertical layout styles correctly', () => {
|
||||||
|
// Reset to default vertical mock values
|
||||||
|
mocks.virtualizer.getTotalSize.mockReturnValue(150)
|
||||||
|
|
||||||
|
render(<DynamicVirtualList {...defaultProps} horizontal={false} />)
|
||||||
|
|
||||||
|
// Verify container styles for vertical layout
|
||||||
|
const container = document.querySelector('div[style*="position: relative"]') as HTMLElement
|
||||||
|
expect(container).toHaveStyle('width: 100%')
|
||||||
|
expect(container).toHaveStyle('height: 150px') // totalSize from mock
|
||||||
|
|
||||||
|
// Verify item transform for vertical layout
|
||||||
|
const items = document.querySelectorAll('[data-index]')
|
||||||
|
const firstItem = items[0] as HTMLElement
|
||||||
|
expect(firstItem.style.transform).toContain('translateY(0px)')
|
||||||
|
expect(firstItem).toHaveStyle('width: 100%')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle edge cases gracefully', () => {
|
||||||
|
// Empty items list
|
||||||
|
mocks.virtualizer.getVirtualItems.mockReturnValueOnce([])
|
||||||
|
expect(() => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} list={[]} />)
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Null ref
|
||||||
|
expect(() => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} ref={null} />)
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Zero estimate size
|
||||||
|
expect(() => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} estimateSize={() => 0} />)
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Items without expected properties
|
||||||
|
const itemsWithoutContent = [{ id: '1' }, { id: '2' }] as any[]
|
||||||
|
expect(() => {
|
||||||
|
render(
|
||||||
|
<DynamicVirtualList
|
||||||
|
{...defaultProps}
|
||||||
|
list={itemsWithoutContent}
|
||||||
|
children={(_item, index) => <div data-testid={`item-${index}`}>No content</div>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auto hide scrollbar', () => {
|
||||||
|
it('should always show scrollbar when autoHideScrollbar is false', () => {
|
||||||
|
render(<DynamicVirtualList {...defaultProps} autoHideScrollbar={false} />)
|
||||||
|
|
||||||
|
const scrollContainer = document.querySelector('.dynamic-virtual-list') as HTMLElement
|
||||||
|
expect(scrollContainer).toBeInTheDocument()
|
||||||
|
|
||||||
|
// When autoHideScrollbar is false, scrollbar should always be visible
|
||||||
|
expect(scrollContainer).not.toHaveAttribute('aria-hidden', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide scrollbar initially and show during scrolling when autoHideScrollbar is true', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
render(<DynamicVirtualList {...defaultProps} autoHideScrollbar={true} />)
|
||||||
|
|
||||||
|
const scrollContainer = document.querySelector('.dynamic-virtual-list') as HTMLElement
|
||||||
|
expect(scrollContainer).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Initially hidden
|
||||||
|
expect(scrollContainer).toHaveAttribute('aria-hidden', 'true')
|
||||||
|
|
||||||
|
// We can't easily simulate real scroll events in JSDOM, so we'll test the internal logic directly
|
||||||
|
// by calling the onChange handler which should update the state
|
||||||
|
const onChangeCallback = mocks.useVirtualizer.mock.calls[0][0].onChange
|
||||||
|
|
||||||
|
// Simulate scroll start
|
||||||
|
act(() => {
|
||||||
|
onChangeCallback({ isScrolling: true }, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// After scrolling starts, scrollbar should be visible
|
||||||
|
expect(scrollContainer).toHaveAttribute('aria-hidden', 'false')
|
||||||
|
|
||||||
|
// Simulate scroll end
|
||||||
|
act(() => {
|
||||||
|
onChangeCallback({ isScrolling: false }, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Advance timers to trigger the hide timeout
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// After timeout, scrollbar should be hidden again
|
||||||
|
expect(scrollContainer).toHaveAttribute('aria-hidden', 'true')
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
||||||
|
.c0::-webkit-scrollbar-thumb {
|
||||||
|
transition: background 0.3s ease-in-out;
|
||||||
|
will-change: background;
|
||||||
|
background: var(--color-scrollbar-thumb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c0::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
aria-hidden="false"
|
||||||
|
aria-label="Dynamic Virtual List"
|
||||||
|
class="c0 dynamic-virtual-list"
|
||||||
|
role="region"
|
||||||
|
style="overflow: auto; height: 100%;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="position: relative; width: 100%; height: 150px;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-index="0"
|
||||||
|
style="position: absolute; top: 0px; left: 0px; transform: translateY(0px); width: 100%;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="item-0"
|
||||||
|
>
|
||||||
|
Item 1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-index="1"
|
||||||
|
style="position: absolute; top: 0px; left: 0px; transform: translateY(50px); width: 100%;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="item-1"
|
||||||
|
>
|
||||||
|
Item 2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-index="2"
|
||||||
|
style="position: absolute; top: 0px; left: 0px; transform: translateY(100px); width: 100%;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="item-2"
|
||||||
|
>
|
||||||
|
Item 3
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
257
src/renderer/src/components/VirtualList/dynamic.tsx
Normal file
257
src/renderer/src/components/VirtualList/dynamic.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import type { Range, ScrollToOptions, VirtualItem, VirtualizerOptions } from '@tanstack/react-virtual'
|
||||||
|
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import React, { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
const SCROLLBAR_AUTO_HIDE_DELAY = 2000
|
||||||
|
|
||||||
|
type InheritedVirtualizerOptions = Partial<
|
||||||
|
Omit<
|
||||||
|
VirtualizerOptions<HTMLDivElement, Element>,
|
||||||
|
| 'count' // determined by items.length
|
||||||
|
| 'getScrollElement' // determined by internal scrollerRef
|
||||||
|
| 'estimateSize' // promoted to a required prop
|
||||||
|
| 'rangeExtractor' // isSticky provides a simpler abstraction
|
||||||
|
>
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface DynamicVirtualListRef {
|
||||||
|
/** Resets any prev item measurements. */
|
||||||
|
measure: () => void
|
||||||
|
/** Returns the scroll element for the virtualizer. */
|
||||||
|
scrollElement: () => HTMLDivElement | null
|
||||||
|
/** Scrolls the virtualizer to the pixel offset provided. */
|
||||||
|
scrollToOffset: (offset: number, options?: ScrollToOptions) => void
|
||||||
|
/** Scrolls the virtualizer to the items of the index provided. */
|
||||||
|
scrollToIndex: (index: number, options?: ScrollToOptions) => void
|
||||||
|
/** Resizes an item. */
|
||||||
|
resizeItem: (index: number, size: number) => void
|
||||||
|
/** Returns the total size in pixels for the virtualized items. */
|
||||||
|
getTotalSize: () => number
|
||||||
|
/** Returns the virtual items for the current state of the virtualizer. */
|
||||||
|
getVirtualItems: () => VirtualItem[]
|
||||||
|
/** Returns the virtual row indexes for the current state of the virtualizer. */
|
||||||
|
getVirtualIndexes: () => number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions {
|
||||||
|
ref?: React.Ref<DynamicVirtualListRef>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List data
|
||||||
|
*/
|
||||||
|
list: T[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List item renderer function
|
||||||
|
*/
|
||||||
|
children: (item: T, index: number) => React.ReactNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List size (height or width, default is 100%)
|
||||||
|
*/
|
||||||
|
size?: string | number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List item size estimator function (initial estimation)
|
||||||
|
*/
|
||||||
|
estimateSize: (index: number) => number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky item predicate, cannot be used with rangeExtractor
|
||||||
|
*/
|
||||||
|
isSticky?: (index: number) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range extractor function, cannot be used with isSticky
|
||||||
|
*/
|
||||||
|
rangeExtractor?: (range: Range) => number[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List item container style
|
||||||
|
*/
|
||||||
|
itemContainerStyle?: React.CSSProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll container style
|
||||||
|
*/
|
||||||
|
scrollerStyle?: React.CSSProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the scrollbar automatically when scrolling is stopped
|
||||||
|
*/
|
||||||
|
autoHideScrollbar?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||||
|
const {
|
||||||
|
ref,
|
||||||
|
list,
|
||||||
|
children,
|
||||||
|
size,
|
||||||
|
estimateSize,
|
||||||
|
isSticky,
|
||||||
|
rangeExtractor: customRangeExtractor,
|
||||||
|
itemContainerStyle,
|
||||||
|
scrollerStyle,
|
||||||
|
autoHideScrollbar = false,
|
||||||
|
...restOptions
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [showScrollbar, setShowScrollbar] = useState(!autoHideScrollbar)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const internalScrollerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollerRef = internalScrollerRef
|
||||||
|
|
||||||
|
const activeStickyIndexRef = useRef(0)
|
||||||
|
|
||||||
|
const stickyIndexes = useMemo(() => {
|
||||||
|
if (!isSticky) return []
|
||||||
|
return list.map((_, index) => (isSticky(index) ? index : -1)).filter((index) => index !== -1)
|
||||||
|
}, [list, isSticky])
|
||||||
|
|
||||||
|
const internalStickyRangeExtractor = useCallback(
|
||||||
|
(range: Range) => {
|
||||||
|
// The active sticky index is the last one that is before or at the start of the visible range
|
||||||
|
const newActiveStickyIndex =
|
||||||
|
[...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? stickyIndexes[0] ?? 0
|
||||||
|
|
||||||
|
if (newActiveStickyIndex !== activeStickyIndexRef.current) {
|
||||||
|
activeStickyIndexRef.current = newActiveStickyIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the active sticky index and the default range extractor
|
||||||
|
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
|
||||||
|
|
||||||
|
// Sort the set to maintain proper order
|
||||||
|
return [...next].sort((a, b) => a - b)
|
||||||
|
},
|
||||||
|
[stickyIndexes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined)
|
||||||
|
|
||||||
|
const handleScrollbarHide = useCallback(
|
||||||
|
(isScrolling: boolean) => {
|
||||||
|
if (!autoHideScrollbar) return
|
||||||
|
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||||
|
if (isScrolling) {
|
||||||
|
setShowScrollbar(true)
|
||||||
|
} else {
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setShowScrollbar(false)
|
||||||
|
}, SCROLLBAR_AUTO_HIDE_DELAY)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[autoHideScrollbar]
|
||||||
|
)
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
...restOptions,
|
||||||
|
count: list.length,
|
||||||
|
getScrollElement: () => scrollerRef.current,
|
||||||
|
estimateSize,
|
||||||
|
rangeExtractor,
|
||||||
|
onChange: (instance, sync) => {
|
||||||
|
restOptions.onChange?.(instance, sync)
|
||||||
|
handleScrollbarHide(instance.isScrolling)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [autoHideScrollbar])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
measure: () => virtualizer.measure(),
|
||||||
|
scrollElement: () => virtualizer.scrollElement,
|
||||||
|
scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options),
|
||||||
|
scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options),
|
||||||
|
resizeItem: (index, size) => virtualizer.resizeItem(index, size),
|
||||||
|
getTotalSize: () => virtualizer.getTotalSize(),
|
||||||
|
getVirtualItems: () => virtualizer.getVirtualItems(),
|
||||||
|
getVirtualIndexes: () => virtualizer.getVirtualIndexes()
|
||||||
|
}),
|
||||||
|
[virtualizer]
|
||||||
|
)
|
||||||
|
|
||||||
|
const virtualItems = virtualizer.getVirtualItems()
|
||||||
|
const totalSize = virtualizer.getTotalSize()
|
||||||
|
const { horizontal } = restOptions
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollContainer
|
||||||
|
ref={scrollerRef}
|
||||||
|
className="dynamic-virtual-list"
|
||||||
|
role="region"
|
||||||
|
aria-label="Dynamic Virtual List"
|
||||||
|
aria-hidden={!showScrollbar}
|
||||||
|
$autoHide={autoHideScrollbar}
|
||||||
|
$show={showScrollbar}
|
||||||
|
style={{
|
||||||
|
overflow: 'auto',
|
||||||
|
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
|
||||||
|
...scrollerStyle
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: horizontal ? `${totalSize}px` : '100%',
|
||||||
|
height: !horizontal ? `${totalSize}px` : '100%'
|
||||||
|
}}>
|
||||||
|
{virtualItems.map((virtualItem) => {
|
||||||
|
const isItemSticky = stickyIndexes.includes(virtualItem.index)
|
||||||
|
const isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
...itemContainerStyle,
|
||||||
|
position: isItemActiveSticky ? 'sticky' : 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
zIndex: isItemSticky ? 1 : undefined,
|
||||||
|
...(horizontal
|
||||||
|
? {
|
||||||
|
transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`,
|
||||||
|
height: '100%'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
transform: isItemActiveSticky ? undefined : `translateY(${virtualItem.start}px)`,
|
||||||
|
width: '100%'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement} style={style}>
|
||||||
|
{children(list[virtualItem.index], virtualItem.index)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScrollContainer = styled.div<{ $autoHide: boolean; $show: boolean }>`
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
transition: background 0.3s ease-in-out;
|
||||||
|
will-change: background;
|
||||||
|
background: ${(props) => (props.$autoHide && !props.$show ? 'transparent' : 'var(--color-scrollbar-thumb)')};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const MemoizedDynamicVirtualList = memo(DynamicVirtualList) as <T>(
|
||||||
|
props: DynamicVirtualListProps<T>
|
||||||
|
) => React.ReactElement
|
||||||
|
|
||||||
|
export default MemoizedDynamicVirtualList
|
||||||
1
src/renderer/src/components/VirtualList/index.ts
Normal file
1
src/renderer/src/components/VirtualList/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as DynamicVirtualList, type DynamicVirtualListProps, type DynamicVirtualListRef } from './dynamic'
|
||||||
@ -4,7 +4,7 @@ exports[`DraggableVirtualList > snapshot > should match snapshot with custom sty
|
|||||||
<div>
|
<div>
|
||||||
<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"
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
`
|
`
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
`
|
`
|
||||||
|
|||||||
@ -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);
|
||||||
`
|
`
|
||||||
|
|||||||
@ -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);
|
||||||
`
|
`
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user