fix: virtualize main nav web apps

This commit is contained in:
yyh
2026-05-27 20:35:12 +08:00
parent 94a376a5a7
commit 83c943bc21
3 changed files with 163 additions and 38 deletions

View File

@ -53,10 +53,10 @@ export default function AppNavItem({
title={isMainNav ? name : undefined}
className={cn(
isMainNav
? 'group text-components-main-nav-text flex cursor-pointer items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors'
? 'group flex h-8 cursor-pointer items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors'
: 'flex h-8 items-center justify-between rounded-lg px-2 system-sm-medium text-sm font-normal text-components-menu-item-text mobile:justify-center mobile:px-1',
isMainNav
? (isSelected ? 'text-components-main-nav-text bg-state-base-hover' : 'hover:text-components-main-nav-text hover:bg-state-base-hover')
? (isSelected ? 'bg-state-base-hover' : 'hover:bg-state-base-hover')
: (isSelected ? 'bg-state-base-active text-components-menu-item-text-active' : 'hover:bg-state-base-hover hover:text-components-menu-item-text-hover'),
)}
onClick={() => {
@ -68,7 +68,7 @@ export default function AppNavItem({
<>
<div className={cn(isMainNav ? 'flex min-w-0 flex-1 items-center gap-2' : 'flex w-0 grow items-center space-x-2')}>
<AppIcon size="tiny" className={cn(isMainNav && 'size-5 rounded-md text-sm')} iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
<div className={cn(isMainNav ? 'text-components-main-nav-text min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' : 'truncate system-sm-regular text-components-menu-item-text')} title={isMainNav ? undefined : name}>{name}</div>
<div className={cn(isMainNav ? 'min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' : 'truncate system-sm-regular text-components-menu-item-text')} title={isMainNav ? undefined : name}>{name}</div>
</div>
<div className="h-6 shrink-0" onClick={e => e.stopPropagation()}>
<ItemOperation

View File

@ -111,6 +111,7 @@ const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockPathname = '/apps'
let mockInstalledApps: InstalledApp[] = []
let mockInstalledAppsPending = false
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'installed-1',
@ -180,6 +181,7 @@ describe('MainNav', () => {
localStorage.clear()
mockPathname = '/apps'
mockInstalledApps = []
mockInstalledAppsPending = false
;(usePathname as Mock).mockImplementation(() => mockPathname)
;(useRouter as Mock).mockReturnValue({
@ -209,7 +211,7 @@ describe('MainNav', () => {
],
})
;(useGetInstalledApps as Mock).mockImplementation(() => ({
isPending: false,
isPending: mockInstalledAppsPending,
data: { installed_apps: mockInstalledApps },
}))
;(useUninstallApp as Mock).mockReturnValue({
@ -546,6 +548,42 @@ describe('MainNav', () => {
expect(mockPush).toHaveBeenCalledWith('/explore/installed/installed-2')
})
it('renders web app skeleton rows while installed apps are loading', () => {
mockInstalledAppsPending = true
renderMainNav()
expect(screen.getByTestId('web-apps-skeleton')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByText('common.loading')).not.toBeInTheDocument()
})
it('virtualizes large installed web app lists', async () => {
const offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(320)
const offsetWidthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(240)
mockInstalledApps = Array.from({ length: 100 }, (_, index) => (
createInstalledApp({
id: `installed-${index}`,
app: {
...createInstalledApp().app,
id: `app-${index}`,
name: `Web App ${index}`,
},
})
))
try {
renderMainNav()
expect(await screen.findByText('Web App 0')).toBeInTheDocument()
expect(screen.queryByText('Web App 99')).not.toBeInTheDocument()
}
finally {
offsetHeightSpy.mockRestore()
offsetWidthSpy.mockRestore()
}
})
it('collapses and expands installed web apps from the section arrow', () => {
mockInstalledApps = [createInstalledApp()]

View File

@ -10,17 +10,48 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { toast } from '@langgenius/dify-ui/toast'
import { useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input'
import AppNavItem from '@/app/components/explore/sidebar/app-nav-item'
import { usePathname } from '@/next/navigation'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
const appNavItemHeight = 32
const appNavItemGap = 2
const virtualizationThreshold = 50
const webAppSkeletonClassName = 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
const webAppSkeletonWidths = ['w-24', 'w-32', 'w-20', 'w-28', 'w-36']
function WebAppsSkeleton() {
return (
<div aria-hidden="true" data-testid="web-apps-skeleton" className="space-y-0.5 pb-2">
{webAppSkeletonWidths.map((width, index) => (
<div key={index} className="flex h-8 items-center gap-2 rounded-lg py-0.5 pr-0.5 pl-2">
<div className={cn(webAppSkeletonClassName, 'size-5 shrink-0 rounded-md')} />
<div className="min-w-0 flex-1 py-1 pr-1">
<div className={cn(webAppSkeletonClassName, 'h-3', width)} />
</div>
<div className={cn(webAppSkeletonClassName, 'mr-1 h-3 w-3 shrink-0')} />
</div>
))}
</div>
)
}
const WebAppsSection = () => {
const { t } = useTranslation()
const pathname = usePathname()
const scrollRef = useRef<HTMLDivElement>(null)
const { data, isPending } = useGetInstalledApps()
const installedApps = useMemo(() => data?.installed_apps ?? [], [data?.installed_apps])
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
@ -38,6 +69,18 @@ const WebAppsSection = () => {
return installedApps.filter(item => item.app.name.toLowerCase().includes(normalizedSearch))
}, [installedApps, searchText])
const shouldVirtualize = filteredApps.length > virtualizationThreshold
const rowVirtualizer = useVirtualizer({
count: filteredApps.length,
estimateSize: () => appNavItemHeight,
gap: appNavItemGap,
getItemKey: index => filteredApps[index]?.id ?? index,
getScrollElement: () => scrollRef.current,
overscan: 6,
paddingEnd: 8,
})
const virtualRows = rowVirtualizer.getVirtualItems()
const handleDelete = async () => {
await uninstallApp(currentId)
@ -50,6 +93,30 @@ const WebAppsSection = () => {
toast.success(t('api.success', { ns: 'common' }))
}
const renderAppNavItem = ({ id, is_pinned, uninstallable, app }: (typeof filteredApps)[number]) => (
<AppNavItem
key={id}
variant="mainNav"
isMobile={false}
name={app.name}
icon_type={app.icon_type}
icon={app.icon}
icon_background={app.icon_background}
icon_url={app.icon_url}
id={id}
isSelected={pathname.endsWith(`/installed/${id}`)}
isPinned={is_pinned}
togglePin={() => {
void handleUpdatePinStatus(id, !is_pinned)
}}
uninstallable={uninstallable}
onDelete={(id) => {
setCurrentId(id)
setShowConfirm(true)
}}
/>
)
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between py-1 pr-2.5 pl-2">
@ -88,39 +155,59 @@ const WebAppsSection = () => {
</div>
)}
{appsExpanded && (
<div className="min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto px-2 pb-2">
{isPending && (
<div className="text-components-main-nav-text px-2 py-1 system-xs-regular">{t('loading', { ns: 'common' })}</div>
)}
{!isPending && filteredApps.length === 0 && (
<div className="text-components-main-nav-text px-2 py-1 system-xs-regular">
{searchText ? t('mainNav.webApps.noResults', { ns: 'common' }) : t('sidebar.noApps.title', { ns: 'explore' })}
</div>
)}
{filteredApps.map(({ id, is_pinned, uninstallable, app }) => (
<AppNavItem
key={id}
variant="mainNav"
isMobile={false}
name={app.name}
icon_type={app.icon_type}
icon={app.icon}
icon_background={app.icon_background}
icon_url={app.icon_url}
id={id}
isSelected={pathname.endsWith(`/installed/${id}`)}
isPinned={is_pinned}
togglePin={() => {
void handleUpdatePinStatus(id, !is_pinned)
}}
uninstallable={uninstallable}
onDelete={(id) => {
setCurrentId(id)
setShowConfirm(true)
}}
/>
))}
</div>
<ScrollAreaRoot className="relative min-h-0 flex-1 overflow-hidden overscroll-contain">
<ScrollAreaViewport
ref={scrollRef}
aria-busy={isPending}
aria-label={t('sidebar.webApps', { ns: 'explore' })}
className="overflow-x-hidden"
role="region"
>
<ScrollAreaContent className="min-w-0 px-2">
{isPending && (
<WebAppsSkeleton />
)}
{!isPending && filteredApps.length === 0 && (
<div className="px-2 py-1 system-xs-regular">
{searchText ? t('mainNav.webApps.noResults', { ns: 'common' }) : t('sidebar.noApps.title', { ns: 'explore' })}
</div>
)}
{!isPending && filteredApps.length > 0 && !shouldVirtualize && (
<div className="space-y-0.5 pb-2">
{filteredApps.map(renderAppNavItem)}
</div>
)}
{!isPending && shouldVirtualize && (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map((virtualRow) => {
const installedApp = filteredApps[virtualRow.index]!
return (
<div
key={virtualRow.key}
className="absolute top-0 left-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderAppNavItem(installedApp)}
</div>
)
})}
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent>