diff --git a/app/gui/package.json b/app/gui/package.json index 31ae3141140f..7a20a9c5f554 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -44,6 +44,7 @@ "playwright:install": "playwright install chromium" }, "dependencies": { + "@tanstack/react-virtual": "3.13.6", "@ag-grid-community/client-side-row-model": "^32.3.3", "@ag-grid-community/core": "^32.3.3", "@ag-grid-community/styles": "^32.3.3", diff --git a/app/gui/src/dashboard/components/Page.tsx b/app/gui/src/dashboard/components/Page.tsx index c1e230bd76eb..fa34654ec648 100644 --- a/app/gui/src/dashboard/components/Page.tsx +++ b/app/gui/src/dashboard/components/Page.tsx @@ -1,10 +1,6 @@ /** @file A page. */ import * as React from 'react' -import * as authProvider from '#/providers/AuthProvider' - -import Chat from '#/layouts/Chat' -import ChatPlaceholder from '#/layouts/ChatPlaceholder' import InfoBar from '#/layouts/InfoBar' import TheModal from '#/components/dashboard/TheModal' @@ -18,13 +14,8 @@ export interface PageProps extends Readonly { /** A page. */ export default function Page(props: PageProps) { - const { hideInfoBar = false, children, hideChat = false } = props + const { hideInfoBar = false, children } = props const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false) - const session = authProvider.useUserSession() - - const doCloseChat = () => { - setIsHelpChatOpen(false) - } return ( <> @@ -34,18 +25,6 @@ export default function Page(props: PageProps) { )} - {!hideChat && ( - <> - {/* `session.accessToken` MUST be present in order for the `Chat` component to work. */} - {( - !hideInfoBar && - session?.type === authProvider.UserSessionType.full && - $config.CHAT_URL != null - ) ? - - : } - - )}
diff --git a/app/gui/src/dashboard/components/Scroller/Scroller.tsx b/app/gui/src/dashboard/components/Scroller/Scroller.tsx index c818514749a0..b37f3c5fc45f 100644 --- a/app/gui/src/dashboard/components/Scroller/Scroller.tsx +++ b/app/gui/src/dashboard/components/Scroller/Scroller.tsx @@ -167,6 +167,7 @@ export function Scroller(props: ScrollerProps) { const [measureRef] = useMeasureCallback({ isDisabled: !showShadows, + useRAF: false, onResize: () => { const container = containerRef.current diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 7426418c564d..898bdca998a0 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -31,12 +31,6 @@ import * as backendModule from '#/services/Backend' import { Text } from '#/components/AriaComponents' import { IndefiniteSpinner } from '#/components/Spinner' -import { - useDeleteAssetsMutationState, - useMoveAssetsMutationState, - useRestoreAssetsMutationState, -} from '#/hooks/backendBatchedHooks' -import { useBackendMutationState } from '#/hooks/backendHooks' import { useDragDelayAction } from '#/hooks/dragDelayHooks' import { BUSY_PROJECT_STATES } from '#/hooks/projectHooks' import { useSyncRef } from '#/hooks/syncRefHooks' @@ -228,18 +222,26 @@ export function RealAssetRow(props: RealAssetRowProps) { const { user } = useFullUserSession() const setSelectedAssets = useSetSelectedAssets() const getAsset = useGetAsset() - const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedIds }) => - (visuallySelectedKeys ?? selectedIds).has(id), - ) - const isSoleSelected = useStore( - driveStore, - ({ selectedIds, visuallySelectedKeys }) => - selected && (visuallySelectedKeys ?? selectedIds).size === 1, - ) - const allowContextMenu = useStore( - driveStore, - ({ selectedIds }) => selectedIds.size === 0 || !selected || isSoleSelected, - ) + const { isSoleSelected, isNewlyCreated, allowContextMenu, selected, insertionVisibility } = + useStore(driveStore, ({ visuallySelectedKeys, selectedIds, newestFolderId, pasteData }) => { + const selected = (visuallySelectedKeys ?? selectedIds).has(id) + const isSoleSelected = selected && (visuallySelectedKeys ?? selectedIds).size === 1 + const isNewlyCreated = selected && newestFolderId === item.id + + return { + selected, + isSoleSelected, + isNewlyCreated, + allowContextMenu: isSoleSelected, + insertionVisibility: + ( + pasteData?.type === 'move' && + pasteData.data.assets.some((asset) => asset.id === item.id) + ) ? + Visibility.faded + : Visibility.visible, + } + }) const setCurrentDirectoryId = useSetCurrentDirectoryId() const draggableProps = dragAndDropHooks.useDraggable({ isDisabled: !selected }) const { setModal, unsetModal } = modalProvider.useSetModal() @@ -253,55 +255,46 @@ export function RealAssetRow(props: RealAssetRowProps) { ) const setLabelsDragPayload = useSetLabelsDragPayload() - const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === item.id) const isEditingName = innerRowState.isEditingName || isNewlyCreated const rowState = object.merge(innerRowState, { isEditingName }) - const isDeletingSingleAsset = - useBackendMutationState(backend, 'deleteAsset', { - predicate: ({ state: { variables } }) => variables?.[0] === item.id, - select: () => null, - }).length !== 0 - const isDeletingMultipleAssets = - useDeleteAssetsMutationState(backend, { - predicate: ({ state: { variables: [assetIds = []] = [] } }) => assetIds.includes(item.id), - select: () => null, - }).length !== 0 + const isDeletingSingleAsset = false + // useBackendMutationState(backend, 'deleteAsset', { + // predicate: ({ state: { variables } }) => variables?.[0] === item.id, + // select: () => null, + // }).length !== 0 + const isDeletingMultipleAssets = false + // useDeleteAssetsMutationState(backend, { + // predicate: ({ state: { variables: [assetIds = []] = [] } }) => assetIds.includes(item.id), + // select: () => null, + // }).length !== 0 const isDeleting = isDeletingSingleAsset || isDeletingMultipleAssets - const isRestoringSingleAsset = - useBackendMutationState(backend, 'undoDeleteAsset', { - predicate: ({ state: { variables } }) => variables?.[0] === item.id, - select: () => null, - }).length !== 0 - const isRestoringMultipleAssets = - useRestoreAssetsMutationState(backend, { - predicate: ({ state: { variables = { ids: [], parentId: null } } }) => - variables.ids.includes(item.id), - select: () => null, - }).length !== 0 + const isRestoringSingleAsset = false + // useBackendMutationState(backend, 'undoDeleteAsset', { + // predicate: ({ state: { variables } }) => variables?.[0] === item.id, + // select: () => null, + // }).length !== 0 + const isRestoringMultipleAssets = false + // useRestoreAssetsMutationState(backend, { + // predicate: ({ state: { variables = { ids: [], parentId: null } } }) => + // variables.ids.includes(item.id), + // select: () => null, + // }).length !== 0 const isRestoring = isRestoringSingleAsset || isRestoringMultipleAssets - const isUpdatingSingleAsset = - useBackendMutationState(backend, 'updateAsset', { - predicate: ({ state: { variables } }) => variables?.[0] === item.id, - select: () => null, - }).length !== 0 - const isMovingMultipleAssets = - useMoveAssetsMutationState(backend, { - predicate: ({ state: { variables: [assetIds = []] = [] } }) => assetIds.includes(item.id), - select: () => null, - }).length !== 0 + const isUpdatingSingleAsset = false + // useBackendMutationState(backend, 'updateAsset', { + // predicate: ({ state: { variables } }) => variables?.[0] === item.id, + // select: () => null, + // }).length !== 0 + const isMovingMultipleAssets = false + // useMoveAssetsMutationState(backend, { + // predicate: ({ state: { variables: [assetIds = []] = [] } }) => assetIds.includes(item.id), + // select: () => null, + // }).length !== 0 const isUpdating = isUpdatingSingleAsset || isMovingMultipleAssets - const insertionVisibility = useStore(driveStore, (driveState) => { - return ( - driveState.pasteData?.type === 'move' && - driveState.pasteData.data.assets.some((asset) => asset.id === item.id) - ) ? - Visibility.faded - : Visibility.visible - }) const visibility = isDeleting || isRestoring || isUpdating ? Visibility.faded : insertionVisibility @@ -424,7 +417,7 @@ export function RealAssetRow(props: RealAssetRowProps) { } }} className={tailwindMerge.twMerge( - 'h-table-row rounded-full transition-all ease-in-out rounded-rows-child [contain-intrinsic-size:44px] [content-visibility:auto]', + 'h-table-row rounded-full transition-all ease-in-out rounded-rows-child', visibility, (isDraggedOver || selected) && 'selected', )} diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index 2e56ae4a8d1f..7a73380d3210 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -3,13 +3,14 @@ import FolderIcon from '#/assets/folder.svg' import { Button } from '#/components/AriaComponents' import type { AssetColumnProps } from '#/components/dashboard/column' import EditableSpan from '#/components/EditableSpan' -import { useGetAssetChildren } from '#/layouts/Drive/assetsTableItemsHooks' +import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useDriveStore, useSetCurrentDirectoryId } from '#/providers/DriveProvider' import { useText } from '#/providers/TextProvider' import { titleSchema, type DirectoryAsset } from '#/services/Backend' import { merger } from '#/utilities/object' -import { twMerge } from '#/utilities/tailwindMerge' +import { twJoin } from '#/utilities/tailwindMerge' import { useTransition } from 'react' +import { useGetAssetChildren } from '../../layouts/Drive/assetsTableItemsHooks' /** Props for a {@link DirectoryNameColumn}. */ export interface DirectoryNameColumnProps extends AssetColumnProps { @@ -30,7 +31,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const setCurrentDirectoryId = useSetCurrentDirectoryId() const getAssetChildren = useGetAssetChildren() - const setIsEditing = (isEditingName: boolean) => { + const setIsEditing = useEventCallback((isEditingName: boolean) => { if (isEditable) { setRowState(merger({ isEditingName })) } @@ -38,12 +39,29 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { if (!isEditingName) { driveStore.setState({ newestFolderId: null }) } - } + }) - const doRename = async (newTitle: string) => { + const doRename = useEventCallback(async (newTitle: string) => { await renameAsset(item.id, newTitle) setIsEditing(false) - } + }) + + const onPress = useEventCallback(() => { + startNavigation(() => { + setCurrentDirectoryId({ current: item.id, parent: item.parentId }) + }) + }) + + const schema = useEventCallback(() => { + return titleSchema({ + asset: item, + siblings: getAssetChildren(item.parentId), + }) + }) + + const onCancel = useEventCallback(() => { + setIsEditing(false) + }) return (
{ - startNavigation(() => { - setCurrentDirectoryId({ current: item.id, parent: item.parentId }) - }) - }} + onPress={onPress} /> - titleSchema({ - asset: item, - siblings: getAssetChildren(item.parentId), - }) - } + schema={schema} onSubmit={doRename} - onCancel={() => { - setIsEditing(false) - }} + onCancel={onCancel} > {item.title} diff --git a/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts b/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts index 8a20e89817d2..7cc424e1d9a7 100644 --- a/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts +++ b/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts @@ -60,7 +60,7 @@ const NORMAL_COLUMN_CSS_CLASSES = `px-cell-x py ${COLUMN_CSS_CLASSES}` /** CSS classes for every column. */ export const COLUMN_CSS_CLASS: Readonly> = { - [Column.name]: `z-10 sticky left-0 bg-dashboard rounded-rows-skip-level min-w-96 h-full p-0 border-l-0 after:absolute after:right-0 after:top-0 after:bottom-0 after:border-r-[1.5px] after:border-primary/5 ${COLUMN_CSS_CLASSES}`, + [Column.name]: `bg-dashboard rounded-rows-skip-level min-w-96 h-full p-0 border-l-0 after:absolute after:right-0 after:top-0 after:bottom-0 after:border-r-[1.5px] after:border-primary/5 ${COLUMN_CSS_CLASSES}`, [Column.modified]: `min-w-drive-modified-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`, [Column.sharedWith]: `min-w-drive-shared-with-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`, [Column.labels]: `min-w-drive-labels-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`, diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 8a914f9986f6..22387907deb0 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -1,4 +1,5 @@ /** @file Table displaying a list of projects. */ +import { useVirtualizer } from '@tanstack/react-virtual' import { Children, cloneElement, @@ -25,7 +26,7 @@ import * as z from 'zod' import DropFilesImage from '#/assets/drop_files.svg' import { FileTrigger, mergeProps } from '#/components/aria' import { Button, Text } from '#/components/AriaComponents' -import type { AssetRowInnerProps } from '#/components/dashboard/AssetRow' +import type { AssetRowInnerProps, AssetRowProps } from '#/components/dashboard/AssetRow' import { AssetRow } from '#/components/dashboard/AssetRow' import { INITIAL_ROW_STATE } from '#/components/dashboard/AssetRow/assetRowUtils' import type { SortableColumn } from '#/components/dashboard/column/columnUtils' @@ -197,7 +198,6 @@ function AssetsTable(props: AssetsTableProps) { const { query, setQuery, category } = props const { initialProjectName } = props - const openedProjects = useLaunchedProjects() const openProjectLocally = useOpenProjectLocally() const setCanDownload = useSetCanDownload() const setSuggestions = useSetSuggestions() @@ -614,7 +614,6 @@ function AssetsTable(props: AssetsTableProps) { const [keyboardSelectedIndex, setKeyboardSelectedIndex] = useState(null) const mostRecentlySelectedIndexRef = useRef(null) const selectionStartIndexRef = useRef(null) - const bodyRef = useRef(null) const setMostRecentlySelectedIndex = useEventCallback( (index: number | null, isKeyboard: boolean = false) => { @@ -1283,38 +1282,6 @@ function AssetsTable(props: AssetsTableProps) { ) - const itemRows = visibleItems.map((item) => { - const isOpenedByYou = openedProjects.some(({ id }) => item.id === id) - const isOpenedOnTheBackend = - item.projectState?.type != null ? IS_OPENING_OR_OPENED[item.projectState.type] : false - return ( - - ) - }) - const dropzoneText = isDraggingFiles ? droppedFilesCount === 1 ? @@ -1322,32 +1289,30 @@ function AssetsTable(props: AssetsTableProps) { : getText('assetsDropFilesDescription', droppedFilesCount) : getText('assetsDropzoneDescription') - const specialEmptyText = - query.query !== '' ? getText('noFilesMatchTheCurrentFilters') - : currentDirectoryId !== category.homeDirectoryId ? getText('thisFolderIsEmpty') - : null - const table = (
- - +
+ {headerRow} - - {itemRows} - - - - +
- - {category.type === 'trash' ? - (specialEmptyText ?? getText('yourTrashIsEmpty')) - : category.type === 'recent' ? - (specialEmptyText ?? getText('youHaveNoRecentProjects')) - : (specialEmptyText ?? getText('youHaveNoFiles'))} - -
@@ -1426,7 +1391,7 @@ function AssetsTable(props: AssetsTableProps) {
- +
{ + readonly visibleItems: readonly AnyAsset[] + readonly rootRef: RefObject + readonly keyboardSelectedIndex: number | null +} + +/** + * + */ +function useVirtualItems(visibleItems: readonly AnyAsset[], rootRef: RefObject) { + 'use no memo' + const { getVirtualItems, getTotalSize } = useVirtualizer({ + count: visibleItems.length, + getScrollElement: () => rootRef.current, + estimateSize: () => ROW_HEIGHT_PX, + overscan: 8, + useAnimationFrameWithResizeObserver: true, + }) + + return { + items: getVirtualItems(), + totalSize: getTotalSize(), + } +} + +/** + * A component that renders a virtualized table of assets. + */ +function AssetsTableVirtualizer(props: AssetsTableVirtualizerProps) { + const { visibleItems, rootRef, keyboardSelectedIndex, columns, state, ...rest } = props + const { category, currentDirectoryId, query } = state + + const { getText } = useText() + + const openedProjects = useLaunchedProjects() + const bodyRef = useRef(null) + + const { items, totalSize } = useVirtualItems(visibleItems, rootRef) + + const itemRows = items.map((virtualItem, index) => { + const item = visibleItems[virtualItem.index] + + if (item == null) { + return null + } + + const isOpenedByYou = openedProjects.some(({ id }) => item.id === id) + const isOpenedOnTheBackend = + item.projectState?.type != null ? IS_OPENING_OR_OPENED[item.projectState.type] : false + + return ( +
+ +
+ ) + }) + + const specialEmptyText = + query.query !== '' ? getText('noFilesMatchTheCurrentFilters') + : currentDirectoryId !== category.homeDirectoryId ? getText('thisFolderIsEmpty') + : null + + return ( + + {itemRows} + + + + {category.type === 'trash' ? + (specialEmptyText ?? getText('yourTrashIsEmpty')) + : category.type === 'recent' ? + (specialEmptyText ?? getText('youHaveNoRecentProjects')) + : (specialEmptyText ?? getText('youHaveNoFiles'))} + + + + + ) +} + /** Props for the {@link HiddenColumn} component. */ interface HiddenColumnProps { readonly column: Column diff --git a/app/gui/src/dashboard/modals/DragModal.tsx b/app/gui/src/dashboard/modals/DragModal.tsx index 664f474d4f35..03856e29c5d8 100644 --- a/app/gui/src/dashboard/modals/DragModal.tsx +++ b/app/gui/src/dashboard/modals/DragModal.tsx @@ -79,7 +79,7 @@ export default function DragModal(props: DragModalProps) {