diff --git a/src/renderer/src/components/DraggableList/virtual-list.tsx b/src/renderer/src/components/DraggableList/virtual-list.tsx index 08234dd96d..c8d868f1ba 100644 --- a/src/renderer/src/components/DraggableList/virtual-list.tsx +++ b/src/renderer/src/components/DraggableList/virtual-list.tsx @@ -35,6 +35,7 @@ interface DraggableVirtualListProps { ref?: React.Ref className?: string style?: React.CSSProperties + scrollerStyle?: React.CSSProperties itemStyle?: React.CSSProperties itemContainerStyle?: React.CSSProperties droppableProps?: Partial @@ -43,6 +44,7 @@ interface DraggableVirtualListProps { 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({ ref, className, style, + scrollerStyle, itemStyle, itemContainerStyle, droppableProps, @@ -67,6 +70,7 @@ function DraggableVirtualList({ onDragEnd, list, itemKey, + estimateSize: _estimateSize, overscan = 5, header, children @@ -88,12 +92,15 @@ function DraggableVirtualList({ count: list?.length ?? 0, getScrollElement: useCallback(() => parentRef.current, []), getItemKey: itemKey, - estimateSize: useCallback(() => 50, []), + estimateSize: useCallback((index) => _estimateSize?.(index) ?? 50, [_estimateSize]), overscan }) return ( -
+
{header} ({ {...provided.droppableProps} className="virtual-scroller" style={{ + ...scrollerStyle, height: '100%', width: '100%', overflowY: 'auto', diff --git a/src/renderer/src/components/ModelList/ManageModelsList.tsx b/src/renderer/src/components/ModelList/ManageModelsList.tsx index bede6b5b74..1100f6575b 100644 --- a/src/renderer/src/components/ModelList/ManageModelsList.tsx +++ b/src/renderer/src/components/ModelList/ManageModelsList.tsx @@ -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 = ({ modelGroups, provider, onAddModel, onRemoveModel }) => { const { t } = useTranslation() - const scrollerRef = useRef(null) - const activeStickyIndexRef = useRef(0) const [collapsedGroups, setCollapsedGroups] = useState(new Set()) const handleGroupToggle = useCallback((groupName: string) => { @@ -74,33 +72,6 @@ const ManageModelsList: React.FC = ({ 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 = ({ modelGroups, provid [provider, onRemoveModel, onAddModel, t] ) - const virtualItems = virtualizer.getVirtualItems() - return ( - -
- {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 - + 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 ( -
- {row.type === 'group' ? ( - handleGroupToggle(row.groupName)}> - - - {row.groupName} - - {row.models.length} - - - {renderGroupTools(row.models)} - - ) : ( -
- -
- )} -
+ handleGroupToggle(row.groupName)}> + + + {row.groupName} + + {row.models.length} + + + {renderGroupTools(row.models)} + ) - })} -
-
+ } + + return ( + + ) + }} + ) } @@ -262,18 +201,12 @@ const ModelListItem: React.FC = 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; ` diff --git a/src/renderer/src/components/ModelList/ModelListGroup.tsx b/src/renderer/src/components/ModelList/ModelListGroup.tsx index 1e92a7f9b3..6b717da8cf 100644 --- a/src/renderer/src/components/ModelList/ModelListGroup.tsx +++ b/src/renderer/src/components/ModelList/ModelListGroup.tsx @@ -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 = ({ onRemoveGroup }) => { const { t } = useTranslation() - const scrollerRef = useRef(null) - const [isExpanded, setIsExpanded] = useState(defaultOpen) + const listRef = useRef(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 ( @@ -80,45 +66,28 @@ const ModelListGroup: React.FC = ({ /> }> - -
-
- {virtualItems.map((virtualItem) => { - const model = models[virtualItem.index] - return ( -
- status.model.id === model.id)} - onEdit={onEditModel} - onRemove={onRemoveModel} - disabled={disabled} - /> -
- ) - })} -
-
-
+ 52, [])} // 44px item + 8px padding + overscan={5} + scrollerStyle={{ + maxHeight: '390px', + padding: '4px 16px' + }} + itemContainerStyle={{ + padding: '4px 0' + }}> + {(model) => ( + status.model.id === model.id)} + onEdit={onEditModel} + onRemove={onRemoveModel} + disabled={disabled} + /> + )} +
) @@ -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) diff --git a/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx b/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx new file mode 100644 index 0000000000..c9cbbf7ddc --- /dev/null +++ b/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx @@ -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) =>
{item.content}
+ } + + // Test component for ref testing + const TestComponentWithRef: React.FC<{ + onRefReady?: (ref: DynamicVirtualListRef | null) => void + listProps?: any + }> = ({ onRefReady, listProps = {} }) => { + const ref = useRef(null) + + React.useEffect(() => { + onRefReady?.(ref.current) + }, [onRefReady]) + + return + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('basic rendering', () => { + it('snapshot test', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should apply custom scroller styles', () => { + const customStyle = { backgroundColor: 'red', height: '400px' } + render() + + 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() + + 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() + + // Should render without errors + expect(screen.getByTestId('item-0')).toBeInTheDocument() + + // Should handle dynamic item count changes + rerender() + 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() + }).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() + + // 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() + + // 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() + + // 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() + }).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() + }).not.toThrow() + + expect(screen.getByTestId('item-0')).toBeInTheDocument() + }) + }) + + describe('ref api', () => { + let refInstance: DynamicVirtualListRef | null = null + + beforeEach(async () => { + render( + { + 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() + + // 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() + + // 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() + }).not.toThrow() + + // Null ref + expect(() => { + render() + }).not.toThrow() + + // Zero estimate size + expect(() => { + render( 0} />) + }).not.toThrow() + + // Items without expected properties + const itemsWithoutContent = [{ id: '1' }, { id: '2' }] as any[] + expect(() => { + render( +
No content
} + /> + ) + }).not.toThrow() + }) + }) + + describe('auto hide scrollbar', () => { + it('should always show scrollbar when autoHideScrollbar is false', () => { + render() + + 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() + + 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() + }) + }) +}) diff --git a/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap new file mode 100644 index 0000000000..c5567f9b08 --- /dev/null +++ b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap @@ -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); +} + +
+
+
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+
+ Item 3 +
+
+
+
+
+`; diff --git a/src/renderer/src/components/VirtualList/dynamic.tsx b/src/renderer/src/components/VirtualList/dynamic.tsx new file mode 100644 index 0000000000..07fe5b1703 --- /dev/null +++ b/src/renderer/src/components/VirtualList/dynamic.tsx @@ -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, + | '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 extends InheritedVirtualizerOptions { + ref?: React.Ref + + /** + * 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(props: DynamicVirtualListProps) { + const { + ref, + list, + children, + size, + estimateSize, + isSticky, + rangeExtractor: customRangeExtractor, + itemContainerStyle, + scrollerStyle, + autoHideScrollbar = false, + ...restOptions + } = props + + const [showScrollbar, setShowScrollbar] = useState(!autoHideScrollbar) + const timeoutRef = useRef(null) + const internalScrollerRef = useRef(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 ( + +
+ {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 ( +
+ {children(list[virtualItem.index], virtualItem.index)} +
+ ) + })} +
+
+ ) +} + +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 ( + props: DynamicVirtualListProps +) => React.ReactElement + +export default MemoizedDynamicVirtualList diff --git a/src/renderer/src/components/VirtualList/index.ts b/src/renderer/src/components/VirtualList/index.ts new file mode 100644 index 0000000000..4fa3e65ed1 --- /dev/null +++ b/src/renderer/src/components/VirtualList/index.ts @@ -0,0 +1 @@ +export { default as DynamicVirtualList, type DynamicVirtualListProps, type DynamicVirtualListRef } from './dynamic' diff --git a/src/renderer/src/components/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap index 90028e7323..17a207ef30 100644 --- a/src/renderer/src/components/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap +++ b/src/renderer/src/components/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap @@ -4,7 +4,7 @@ exports[`DraggableVirtualList > snapshot > should match snapshot with custom sty
= ({ id, list, files }) => { + const estimateSize = useCallback(() => 75, []) + if (id === FileTypes.IMAGE && files?.length && files?.length > 0) { return (
@@ -78,38 +80,29 @@ const FileList: React.FC = ({ id, list, files }) => { } return ( - {(item) => ( -
- -
+ )} -
+ ) } diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 2db9f7bfe8..670cb2638c 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -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 = ({ assistant: _assistant, activeTopic, setActiveTopic, const singlealone = topicPosition === 'right' && position === 'right' return ( - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> @@ -521,7 +521,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, ) }} - + ) } diff --git a/src/renderer/src/pages/knowledge/KnowledgePage.tsx b/src/renderer/src/pages/knowledge/KnowledgePage.tsx index 5547abbb2b..284fb3d724 100644 --- a/src/renderer/src/pages/knowledge/KnowledgePage.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgePage.tsx @@ -104,36 +104,34 @@ const KnowledgePage: FC = () => { - - setIsDragging(true)} - onDragEnd={() => setIsDragging(false)}> - {(base: KnowledgeBase) => ( - -
- } - title={base.name} - onClick={() => setSelectedBase(base)} - /> -
-
- )} -
- {!isDragging && ( - - - - {t('button.add')} - - + setIsDragging(true)} + onDragEnd={() => setIsDragging(false)}> + {(base: KnowledgeBase) => ( + +
+ } + title={base.name} + onClick={() => setSelectedBase(base)} + /> +
+
)} -
-
+ + {!isDragging && ( + + + + {t('button.add')} + + + )} +
{bases.length === 0 ? ( @@ -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; diff --git a/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx b/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx index 2044199930..1225482a24 100644 --- a/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx +++ b/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx @@ -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 = ({ 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 = ({ selectedBase, progres {directoryItems.length === 0 && } - {directoryItems.reverse().map((item) => ( - window.api.file.openPath(item.content as string)}> - - {item.content as string} - - - ), - ext: '.folder', - extra: getDisplayTime(item), - actions: ( - - {item.uniqueId &&
) }} - + )} diff --git a/src/renderer/src/pages/knowledge/items/KnowledgeNotes.tsx b/src/renderer/src/pages/knowledge/items/KnowledgeNotes.tsx index ca3f8c4d17..4a4696cc49 100644 --- a/src/renderer/src/pages/knowledge/items/KnowledgeNotes.tsx +++ b/src/renderer/src/pages/knowledge/items/KnowledgeNotes.tsx @@ -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 = ({ 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 = ({ selectedBase }) => { {noteItems.length === 0 && } - {noteItems.reverse().map((note) => ( - handleEditNote(note)}>{(note.content as string).slice(0, 50)}..., - ext: '.txt', - extra: getDisplayTime(note), - actions: ( - -