diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 0a255f725ac..dfd1a7b1581 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -174,6 +174,8 @@ const mockProviderContextState = vi.hoisted(() => ({ } as Partial, })) +vi.mock('@tanstack/react-virtual') + vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), })) diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx index 7f6a0035011..533116c3788 100644 --- a/web/app/components/main-nav/components/web-apps-section.tsx +++ b/web/app/components/main-nav/components/web-apps-section.tsx @@ -27,8 +27,9 @@ import { } from '@langgenius/dify-ui/scroll-area' import { toast } from '@langgenius/dify-ui/toast' import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query' +import { useVirtualizer } from '@tanstack/react-virtual' import { useAtomValue } from 'jotai' -import { Fragment, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel' @@ -43,12 +44,27 @@ import { hasPermission } from '@/utils/permission' const emptyInstalledApps: InstalledAppResponse[] = [] +const appNavItemHeight = 32 +const appNavItemGap = 2 +const appNavSeparatorHeight = 16.5 + const getPreloadDistance = (scrollContainer: Element) => Math.max(160, Math.min(scrollContainer.clientHeight * 0.25, 320)) const selectInstalledApps = (data: InfiniteData) => data.pages.flatMap((page) => page.installed_apps) +type WebAppListRow = + | { + key: string + kind: 'app' + app: InstalledAppResponse + } + | { + key: string + kind: 'separator' + } + const WebAppsSectionContent = () => { const { t } = useTranslation() const pathname = usePathname() @@ -83,7 +99,34 @@ const WebAppsSectionContent = () => { consoleQuery.installedApps.byInstalledAppId.patch.mutationOptions(), ) - const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length + const webAppRows = useMemo(() => { + const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length + + return installedApps.flatMap((app, index) => { + const rows: WebAppListRow[] = [{ key: app.id, kind: 'app', app }] + + if (index === pinnedAppsCount - 1 && index !== installedApps.length - 1) + rows.push({ key: `${app.id}-separator`, kind: 'separator' }) + + return rows + }) + }, [installedApps]) + const getWebAppRowKey = useCallback( + (index: number) => webAppRows[index]?.key ?? index, + [webAppRows], + ) + + const rowVirtualizer = useVirtualizer({ + count: webAppRows.length, + estimateSize: (index) => + webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight, + gap: appNavItemGap, + getItemKey: getWebAppRowKey, + getScrollElement: () => scrollRef.current, + overscan: 6, + paddingEnd: 8, + }) + const canLoadMore = !installedAppsQuery.isFetching && !installedAppsQuery.error const handleSearchTextChange = (value: string) => { @@ -132,7 +175,6 @@ const WebAppsSectionContent = () => { const renderAppNavItem = (installedApp: (typeof installedApps)[number]) => ( $['mainNav.webApps.openApp'], { ns: 'common', @@ -143,6 +185,11 @@ const WebAppsSectionContent = () => { onDelete={setUninstallDialogAppId} /> ) + const renderRow = (row: WebAppListRow) => { + if (row.kind === 'separator') return + + return renderAppNavItem(row.app) + } return ( { {t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })} )} - {installedApps.length > 0 && ( -
- {installedApps.map((installedApp, index) => ( - - {renderAppNavItem(installedApp)} - {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && ( - - )} - - ))} + {webAppRows.length > 0 && ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = webAppRows[virtualRow.index]! + + return ( +
+ {renderRow(row)} +
+ ) + })}
)} {installedAppsQuery.hasNextPage && (