'use client' import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' import type { App } from '@/models/explore' import type { TryAppSelection } from '@/types/try-app' import { cn } from '@langgenius/dify-ui/cn' import { keepPreviousData, useInfiniteQuery, useQuery, useSuspenseQuery, } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { useAtomValue, useSetAtom } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNeedRefreshAppList } from '@/app/components/apps/storage' import { activeStepByStepTourGuideGroupAtom, activeStepByStepTourGuideIndexAtom, activeStepByStepTourTaskIdAtom, resolveStepByStepTourGuideGroupAtom, } from '@/app/components/step-by-step-tour/state' import { getStepByStepTourGuides, STEP_BY_STEP_TOUR_TARGETS, } from '@/app/components/step-by-step-tour/target-registry' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { CheckModal } from '@/hooks/use-pay' import { consoleQuery } from '@/service/client' import { normalizeAppPagination } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import { hasPermission } from '@/utils/permission' import { AppCard } from './app-card' import { AppCardSkeleton } from './app-card-skeleton' import { AppListCreationModals } from './app-list-creation-modals' import { AppListHeaderFilters } from './app-list-header-filters' import { AppListTagManagementModal } from './app-list-tag-management-modal' import { APP_LIST_GRID_CLASS_NAME, APP_LIST_SEARCH_DEBOUNCE_MS } from './constants' import Empty from './empty' import FirstEmptyState from './first-empty-state' import { useAppsQueryState } from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' import { StarredAppList } from './starred-app-list' import { StudioListHeader } from './studio-list-header' const STARRED_APP_LIMIT = 100 const STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT = 4 type AppListQuery = NonNullable type AppListSortBy = NonNullable type Props = Readonly<{ controlRefreshList?: number onCreateLearnDify?: (app: App) => void onTryLearnDify?: (params: TryAppSelection) => void }> function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Props) { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { onPlanInfoChanged } = useProviderContext() // oxlint-disable-next-line eslint-react/use-state -- custom URL query hook, not React.useState const { query: { category, keywords, creatorIDs }, setCategory, setKeywords, setCreatorIDs, } = useAppsQueryState() const [tagIDs, setTagIDs] = useState([]) const [sortBy, setSortBy] = useState('last_modified') const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS }) const containerRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false) const [showNewAppModal, setShowNewAppModal] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() const [needsRefreshAppList, setNeedsRefreshAppList] = useNeedRefreshAppList() const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) const activeStepByStepTourGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom) const resolveStepByStepTourGuideGroup = useSetAtom(resolveStepByStepTourGuideGroupAtom) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const handleDSLFileDropped = useCallback( (file: File) => { if (!canCreateApp) return setDroppedDSLFile(file) setShowCreateFromDSLModal(true) }, [canCreateApp], ) const { dragging } = useDSLDragDrop({ onDSLFileDropped: handleDSLFileDropped, containerRef, enabled: canCreateApp, }) const appListQuery = useMemo( () => ({ page: 1, limit: 30, name: debouncedKeywords, sort_by: sortBy, ...(tagIDs.length ? { tag_ids: tagIDs } : {}), ...(creatorIDs.length ? { creator_ids: creatorIDs } : {}), ...(category !== 'all' ? { mode: category } : {}), }), [category, creatorIDs, debouncedKeywords, sortBy, tagIDs], ) const { data, isLoading, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage, error, refetch, } = useInfiniteQuery({ ...consoleQuery.apps.get.infiniteOptions({ input: (pageParam) => ({ query: { ...appListQuery, page: Number(pageParam), }, }), getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: 1, placeholderData: keepPreviousData, }), select: (data) => ({ ...data, pages: data.pages.map(normalizeAppPagination), }), refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false, }) const starredAppListQuery = useMemo( () => ({ ...appListQuery, page: 1, limit: STARRED_APP_LIMIT, }), [appListQuery], ) const { data: starredAppList, refetch: refetchStarredAppList } = useQuery({ ...consoleQuery.apps.starred.get.queryOptions({ input: { query: starredAppListQuery, }, select: normalizeAppPagination, }), }) const refreshAppLists = useCallback(() => { void refetch() void refetchStarredAppList() }, [refetch, refetchStarredAppList]) useEffect(() => { if (controlRefreshList > 0) refetch() }, [controlRefreshList, refetch]) const anchorRef = useRef(null) useEffect(() => { if (needsRefreshAppList === '1') { setNeedsRefreshAppList(null) refetch() } }, [needsRefreshAppList, refetch, setNeedsRefreshAppList]) useEffect(() => { const hasMore = hasNextPage ?? true let observer: IntersectionObserver | undefined if (error) { if (observer) observer.disconnect() return } if (anchorRef.current && containerRef.current) { const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) observer = new IntersectionObserver( (entries) => { if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) fetchNextPage() }, { root: containerRef.current, rootMargin: `${dynamicMargin}px`, threshold: 0.1, }, ) observer.observe(anchorRef.current) } return () => observer?.disconnect() }, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage]) const pages = useMemo(() => data?.pages ?? [], [data?.pages]) const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages]) const starredApps = useMemo(() => starredAppList?.data ?? [], [starredAppList?.data]) const workflowOnlineUserAppIds = useMemo(() => { const appIds = new Set() apps.forEach((app) => { if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) appIds.add(app.id) }) return Array.from(appIds) }, [apps]) const { onlineUsersMap: workflowOnlineUsersMap } = useWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds, enabled: systemFeatures.enable_collaboration_mode, }) const hasResolvedFirstPage = pages.length > 0 const hasAnyApp = (pages[0]?.total ?? 0) > 0 const hasActiveFilters = category !== 'all' || tagIDs.length > 0 || keywords.trim().length > 0 || debouncedKeywords.trim().length > 0 || creatorIDs.length > 0 const showSkeleton = isLoading || (isFetching && pages.length === 0) const showFirstEmptyState = !showSkeleton && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters const showNoCreateEmptyState = !showSkeleton && !hasAnyApp && !canCreateApp && hasResolvedFirstPage && !hasActiveFilters const activeStudioGuideGroup = canCreateApp ? showFirstEmptyState ? 'studioEmpty' : hasAnyApp ? 'studioWithApps' : undefined : hasAnyApp ? 'studioNoCreateWithApps' : showNoCreateEmptyState ? 'studioNoCreateEmpty' : undefined const effectiveActiveStudioGuideGroup = activeStepByStepTourGuideGroup ?? activeStudioGuideGroup const activeStudioGuides = activeStepByStepTourTaskId === 'studio' && effectiveActiveStudioGuideGroup ? getStepByStepTourGuides('studio', effectiveActiveStudioGuideGroup) : [] const activeStudioGuide = activeStudioGuides[activeStepByStepTourGuideIndex ?? 0] const shouldOpenStepByStepTourCreateMenu = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate const shouldOpenStepByStepTourAppCardActionMenu = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard const shouldHighlightStepByStepTourNoCreateAppRow = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard const shouldHighlightStepByStepTourStarredAppRow = shouldHighlightStepByStepTourNoCreateAppRow && starredApps.length > 0 const shouldHighlightStepByStepTourAllAppsRow = shouldHighlightStepByStepTourNoCreateAppRow && !shouldHighlightStepByStepTourStarredAppRow const openCreateBlankModal = useCallback(() => { if (canCreateApp) setShowNewAppModal(true) }, [canCreateApp]) const openCreateTemplateDialog = useCallback(() => { if (canCreateApp) setShowNewAppTemplateDialog(true) }, [canCreateApp]) const openCreateFromDSLModal = useCallback(() => { if (canCreateApp) setShowCreateFromDSLModal(true) }, [canCreateApp]) useEffect(() => { if (activeStepByStepTourTaskId !== 'studio') return if (!hasResolvedFirstPage || showSkeleton || !activeStudioGuideGroup) return if (activeStepByStepTourGuideGroup === activeStudioGuideGroup) return resolveStepByStepTourGuideGroup({ taskId: 'studio', guideGroup: activeStudioGuideGroup, }) }, [ activeStepByStepTourGuideGroup, activeStepByStepTourTaskId, activeStudioGuideGroup, hasResolvedFirstPage, resolveStepByStepTourGuideGroup, showSkeleton, ]) return ( <>
{dragging && (
)}

{t(($) => $['menus.apps'], { ns: 'common' })}

} > setShowTagManagementModal(true)} showCreateButton={canCreateApp} stepByStepTourCreateMenuOpen={ activeStudioGuide ? shouldOpenStepByStepTourCreateMenu : undefined } stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate} stepByStepTourCreateMenuHighlightPart={ STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu } /> {showFirstEmptyState ? ( ) : ( <> {starredApps.length > 0 && ( )}
{showSkeleton ? ( ) : hasAnyApp ? ( apps.map((app, index) => ( setShowTagManagementModal(true)} stepByStepTourActionMenuOpen={ index === 0 ? shouldOpenStepByStepTourAppCardActionMenu : undefined } stepByStepTourCardTarget={ index === 0 ? shouldHighlightStepByStepTourAllAppsRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard : canCreateApp ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard : undefined : undefined } stepByStepTourCardHighlightPart={ index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && shouldHighlightStepByStepTourAllAppsRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard : undefined } stepByStepTourActionMenuHighlightPart={ index === 0 && shouldOpenStepByStepTourAppCardActionMenu ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu : undefined } /> )) ) : ( )} {isFetchingNextPage && }
)} {canCreateApp && !showFirstEmptyState && (
$['newApp.dropDSLToCreateApp'], { ns: 'app' })} > {t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })}
)}
{' '}
setShowTagManagementModal(false)} onTagsChange={refreshAppLists} /> ) } export default List