From bf7bdea6f642d129a8e77204f67ac065a8cc9ec6 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 22 Oct 2024 14:02:41 +0300 Subject: [PATCH 01/76] Batch refetches for different `listDirectory` queries --- app/gui/src/dashboard/layouts/AssetsTable.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 3cff8b6e2260..33d8d8c4be76 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -20,6 +20,7 @@ import { queryOptions, useMutation, useQueries, + useQuery, useQueryClient, useSuspenseQuery, } from '@tanstack/react-query' @@ -465,24 +466,11 @@ export default function AssetsTable(props: AssetsTableProps) { } }, - refetchInterval: - enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false, - refetchOnMount: 'always', - refetchIntervalInBackground: false, - refetchOnWindowFocus: true, - enabled: !hidden, meta: { persist: false }, }), ), - [ - hidden, - backend, - category, - expandedDirectoryIds, - assetsTableBackgroundRefreshInterval, - enableAssetsTableBackgroundRefresh, - ], + [hidden, backend, category, expandedDirectoryIds], ), combine: (results) => { const rootQuery = results[expandedDirectoryIds.indexOf(rootDirectory.id)] @@ -509,6 +497,20 @@ export default function AssetsTable(props: AssetsTableProps) { }, }) + // We use a different query to refetch the directory data in the background. + // This reduces the amount of rerenders by batching them together, so they happen less often. + useQuery({ + queryKey: [backend.type, 'refetchListDirectory'], + queryFn: () => queryClient.refetchQueries({ queryKey: [backend.type, 'listDirectory'] }), + refetchInterval: + enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false, + refetchOnMount: 'always', + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + enabled: !hidden, + meta: { persist: false }, + }) + /** Return type of the query function for the listDirectory query. */ type DirectoryQuery = typeof directories.rootDirectory.data From 855a1998ed84f910eef479c7c35afd6196f73bc5 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 22 Oct 2024 20:59:14 +1000 Subject: [PATCH 02/76] Smarter memoization for `AssetRow` --- app/common/src/utilities/data/object.ts | 29 + .../components/dashboard/AssetRow.tsx | 1388 +++++++++-------- 2 files changed, 736 insertions(+), 681 deletions(-) diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index 10dd6186f929..8f01b327a32b 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -9,6 +9,26 @@ export type Mutable = { -readonly [K in keyof T]: T[K] } +/** Whether two values are deeply equal. */ +export function deepEqual(a: T, b: T) { + if (Object.is(a, b)) return true + if (typeof a !== typeof b) return false + if (typeof a !== 'object' || typeof b !== 'object') return Object.is(a, b) + if (Array.isArray(a) && Array.isArray(b) && a.length !== b.length) return false + // They cannot both be null because the `Object.is(a, b)` case above ensures they are not equal. + if (a == null || b == null) return false + for (const k in a) { + if (!(k in b)) return false + } + for (const k in b) { + if (!(k in a)) return true + } + for (const k in a) { + if (!deepEqual(a[k], b[k])) return false + } + return false +} + // ============= // === merge === // ============= @@ -59,6 +79,15 @@ export function unsafeMutable(object: T): { -readonly [K in ke // === unsafeEntries === // ===================== +/** + * Return the entries of an object. UNSAFE only when it is possible for an object to have + * extra keys. + */ +export function unsafeKeys(object: T): readonly (keyof T)[] { + // @ts-expect-error This is intentionally a wrapper function with a different type. + return Object.keys(object) +} + /** * Return the entries of an object. UNSAFE only when it is possible for an object to have * extra keys. diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 099779de379f..0c353a16e1e9 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -114,735 +114,761 @@ export interface AssetRowProps { } /** A row containing an {@link backendModule.AnyAsset}. */ -export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { - const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props - const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props - const { - nodeMap, - doToggleDirectoryExpansion, - doCopy, - doCut, - doPaste, - doDelete: doDeleteRaw, - doRestore, - doMove, - category, - } = state - const { scrollContainerRef, rootDirectoryId, backend } = state - const { visibilities } = state - - const [item, setItem] = React.useState(rawItem) - const driveStore = useDriveStore() - const queryClient = useQueryClient() - const { user } = useFullUserSession() - const setSelectedKeys = useSetSelectedKeys() - const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) => - (visuallySelectedKeys ?? selectedKeys).has(item.key), - ) - const isSoleSelected = useStore( - driveStore, - ({ selectedKeys }) => selected && selectedKeys.size === 1, - ) - const allowContextMenu = useStore( - driveStore, - ({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected, - ) - const wasSoleSelectedRef = React.useRef(isSoleSelected) - const draggableProps = dragAndDropHooks.useDraggable() - const { setModal, unsetModal } = modalProvider.useSetModal() - const { getText } = textProvider.useText() - const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() - const cutAndPaste = useCutAndPaste(category) - const [isDraggedOver, setIsDraggedOver] = React.useState(false) - const rootRef = React.useRef(null) - const dragOverTimeoutHandle = React.useRef(null) - const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus) - const asset = item.item - const [innerRowState, setRowState] = React.useState( - assetRowUtils.INITIAL_ROW_STATE, - ) - - const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id) - const isEditingName = innerRowState.isEditingName || isNewlyCreated - - const rowState = React.useMemo(() => { - return object.merge(innerRowState, { isEditingName }) - }, [isEditingName, innerRowState]) - - const nodeParentKeysRef = React.useRef<{ - readonly nodeMap: WeakRef> - readonly parentKeys: Map - } | null>(null) - - const isDeleting = - useBackendMutationState(backend, 'deleteAsset', { - predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, - }).length !== 0 - const isRestoring = - useBackendMutationState(backend, 'undoDeleteAsset', { - predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, - }).length !== 0 - const isCloud = isCloudCategory(category) - - const { data: projectState } = useQuery({ - // This is SAFE, as `isOpened` is only true for projects. - // eslint-disable-next-line no-restricted-syntax - ...createGetProjectDetailsQuery.createPassiveListener(item.item.id as backendModule.ProjectId), - select: (data) => data?.state.type, - enabled: item.type === backendModule.AssetType.project, - }) - - const toastAndLog = useToastAndLog() - - const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission')) - const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) - - const outerVisibility = visibilities.get(item.key) - const insertionVisibility = useStore(driveStore, (driveState) => - driveState.pasteData?.type === 'move' && driveState.pasteData.data.ids.has(item.key) ? - Visibility.faded - : Visibility.visible, - ) - const createPermissionVariables = createPermissionMutation.variables?.[0] - const isRemovingSelf = - createPermissionVariables != null && - createPermissionVariables.action == null && - createPermissionVariables.actorsIds[0] === user.userId - const visibility = - isRemovingSelf ? Visibility.hidden - : outerVisibility === Visibility.visible ? insertionVisibility - : outerVisibility ?? insertionVisibility - const hidden = isDeleting || isRestoring || hiddenRaw || visibility === Visibility.hidden - - const setSelected = useEventCallback((newSelected: boolean) => { - const { selectedKeys } = driveStore.getState() - setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected)) - }) - - React.useEffect(() => { - setItem(rawItem) - }, [rawItem]) - - const rawItemRef = useSyncRef(rawItem) - React.useEffect(() => { - // Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to update the - // parent's state while avoiding re-rendering the parent. - rawItemRef.current.item = asset - }, [asset, rawItemRef]) - const setAsset = setAssetHooks.useSetAsset(asset, setItem) - - React.useEffect(() => { - if (selected && insertionVisibility !== Visibility.visible) { - setSelected(false) - } - }, [selected, insertionVisibility, setSelected]) - - React.useEffect(() => { - if (isKeyboardSelected) { - rootRef.current?.focus() - grabKeyboardFocusRef.current(item) - } - }, [grabKeyboardFocusRef, isKeyboardSelected, item]) +export const AssetRow = React.memo( + function AssetRow(props: AssetRowProps) { + const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props + const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props + const { + nodeMap, + doToggleDirectoryExpansion, + doCopy, + doCut, + doPaste, + doDelete: doDeleteRaw, + doRestore, + doMove, + category, + } = state + const { scrollContainerRef, rootDirectoryId, backend } = state + const { visibilities } = state + + const [item, setItem] = React.useState(rawItem) + const driveStore = useDriveStore() + const queryClient = useQueryClient() + const { user } = useFullUserSession() + const setSelectedKeys = useSetSelectedKeys() + const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) => + (visuallySelectedKeys ?? selectedKeys).has(item.key), + ) + const isSoleSelected = useStore( + driveStore, + ({ selectedKeys }) => selected && selectedKeys.size === 1, + ) + const allowContextMenu = useStore( + driveStore, + ({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected, + ) + const wasSoleSelectedRef = React.useRef(isSoleSelected) + const draggableProps = dragAndDropHooks.useDraggable() + const { setModal, unsetModal } = modalProvider.useSetModal() + const { getText } = textProvider.useText() + const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() + const cutAndPaste = useCutAndPaste(category) + const [isDraggedOver, setIsDraggedOver] = React.useState(false) + const rootRef = React.useRef(null) + const dragOverTimeoutHandle = React.useRef(null) + const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus) + const asset = item.item + const [innerRowState, setRowState] = React.useState( + assetRowUtils.INITIAL_ROW_STATE, + ) - React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item })) + const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id) + const isEditingName = innerRowState.isEditingName || isNewlyCreated + + const rowState = React.useMemo(() => { + return object.merge(innerRowState, { isEditingName }) + }, [isEditingName, innerRowState]) + + const nodeParentKeysRef = React.useRef<{ + readonly nodeMap: WeakRef> + readonly parentKeys: Map + } | null>(null) + + const isDeleting = + useBackendMutationState(backend, 'deleteAsset', { + predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, + }).length !== 0 + const isRestoring = + useBackendMutationState(backend, 'undoDeleteAsset', { + predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, + }).length !== 0 + const isCloud = isCloudCategory(category) + + const { data: projectState } = useQuery({ + ...createGetProjectDetailsQuery.createPassiveListener( + // This is SAFE, as `isOpened` is only true for projects. + // eslint-disable-next-line no-restricted-syntax + item.item.id as backendModule.ProjectId, + ), + select: (data) => data?.state.type, + enabled: item.type === backendModule.AssetType.project, + }) + + const toastAndLog = useToastAndLog() + + const createPermissionMutation = useMutation( + backendMutationOptions(backend, 'createPermission'), + ) + const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) - if (updateAssetRef.current) { - updateAssetRef.current[item.item.id] = setAsset - } + const outerVisibility = visibilities.get(item.key) + const insertionVisibility = useStore(driveStore, (driveState) => + driveState.pasteData?.type === 'move' && driveState.pasteData.data.ids.has(item.key) ? + Visibility.faded + : Visibility.visible, + ) + const createPermissionVariables = createPermissionMutation.variables?.[0] + const isRemovingSelf = + createPermissionVariables != null && + createPermissionVariables.action == null && + createPermissionVariables.actorsIds[0] === user.userId + const visibility = + isRemovingSelf ? Visibility.hidden + : outerVisibility === Visibility.visible ? insertionVisibility + : outerVisibility ?? insertionVisibility + const hidden = isDeleting || isRestoring || hiddenRaw || visibility === Visibility.hidden + + const setSelected = useEventCallback((newSelected: boolean) => { + const { selectedKeys } = driveStore.getState() + setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected)) + }) + + React.useEffect(() => { + setItem(rawItem) + }, [rawItem]) + + const rawItemRef = useSyncRef(rawItem) + React.useEffect(() => { + // Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to update the + // parent's state while avoiding re-rendering the parent. + rawItemRef.current.item = asset + }, [asset, rawItemRef]) + const setAsset = setAssetHooks.useSetAsset(asset, setItem) + + React.useEffect(() => { + if (selected && insertionVisibility !== Visibility.visible) { + setSelected(false) + } + }, [selected, insertionVisibility, setSelected]) - React.useEffect(() => { - return () => { - if (updateAssetRef.current) { - // eslint-disable-next-line react-hooks/exhaustive-deps, @typescript-eslint/no-dynamic-delete - delete updateAssetRef.current[item.item.id] + React.useEffect(() => { + if (isKeyboardSelected) { + rootRef.current?.focus() + grabKeyboardFocusRef.current(item) } + }, [grabKeyboardFocusRef, isKeyboardSelected, item]) + + React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item })) + + if (updateAssetRef.current) { + updateAssetRef.current[item.item.id] = setAsset } - }, [item.item.id, updateAssetRef]) - - const doDelete = React.useCallback( - (forever = false) => { - void doDeleteRaw(item.item, forever) - }, - [doDeleteRaw, item.item], - ) - - const clearDragState = React.useCallback(() => { - setIsDraggedOver(false) - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - }, []) - - const onDragOver = (event: React.DragEvent) => { - const directoryKey = - item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey - const payload = drag.ASSET_ROWS.lookup(event) - const isPayloadMatch = - payload != null && payload.every((innerItem) => innerItem.key !== directoryKey) - const canPaste = (() => { - if (!isPayloadMatch) { - return false - } else { - if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) { - const parentKeys = new Map( - Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [ - id, - otherAsset.directoryKey, - ]), - ) - nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys } + + React.useEffect(() => { + return () => { + if (updateAssetRef.current) { + // eslint-disable-next-line react-hooks/exhaustive-deps, @typescript-eslint/no-dynamic-delete + delete updateAssetRef.current[item.item.id] } - return payload.every((payloadItem) => { - const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key) - const parent = parentKey == null ? null : nodeMap.current.get(parentKey) - if (!parent) { - return false - } else if (permissions.isTeamPath(parent.path)) { - return true - } else { - // Assume user path; check permissions - const permission = permissions.tryFindSelfPermission(user, item.item.permissions) - return ( - permission != null && - permissions.canPermissionModifyDirectoryContents(permission.permission) + } + }, [item.item.id, updateAssetRef]) + + const doDelete = React.useCallback( + (forever = false) => { + void doDeleteRaw(item.item, forever) + }, + [doDeleteRaw, item.item], + ) + + const clearDragState = React.useCallback(() => { + setIsDraggedOver(false) + setRowState((oldRowState) => + oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? + oldRowState + : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), + ) + }, []) + + const onDragOver = (event: React.DragEvent) => { + const directoryKey = + item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey + const payload = drag.ASSET_ROWS.lookup(event) + const isPayloadMatch = + payload != null && payload.every((innerItem) => innerItem.key !== directoryKey) + const canPaste = (() => { + if (!isPayloadMatch) { + return false + } else { + if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) { + const parentKeys = new Map( + Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [ + id, + otherAsset.directoryKey, + ]), ) + nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys } } - }) - } - })() - if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) { - event.preventDefault() - if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') { - setIsDraggedOver(true) + return payload.every((payloadItem) => { + const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key) + const parent = parentKey == null ? null : nodeMap.current.get(parentKey) + if (!parent) { + return false + } else if (permissions.isTeamPath(parent.path)) { + return true + } else { + // Assume user path; check permissions + const permission = permissions.tryFindSelfPermission(user, item.item.permissions) + return ( + permission != null && + permissions.canPermissionModifyDirectoryContents(permission.permission) + ) + } + }) + } + })() + if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) { + event.preventDefault() + if ( + item.item.type === backendModule.AssetType.directory && + state.category.type !== 'trash' + ) { + setIsDraggedOver(true) + } } } - } - eventListProvider.useAssetEventListener(async (event) => { - switch (event.type) { - case AssetEventType.move: { - if (event.ids.has(item.key)) { - await doMove(event.newParentKey, item.item) + eventListProvider.useAssetEventListener(async (event) => { + switch (event.type) { + case AssetEventType.move: { + if (event.ids.has(item.key)) { + await doMove(event.newParentKey, item.item) + } + break } - break - } - case AssetEventType.delete: { - if (event.ids.has(item.key)) { - doDelete(false) + case AssetEventType.delete: { + if (event.ids.has(item.key)) { + doDelete(false) + } + break } - break - } - case AssetEventType.deleteForever: { - if (event.ids.has(item.key)) { - doDelete(true) + case AssetEventType.deleteForever: { + if (event.ids.has(item.key)) { + doDelete(true) + } + break } - break - } - case AssetEventType.restore: { - if (event.ids.has(item.key)) { - await doRestore(item.item) + case AssetEventType.restore: { + if (event.ids.has(item.key)) { + await doRestore(item.item) + } + break } - break - } - case AssetEventType.download: - case AssetEventType.downloadSelected: { - if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) { - if (isCloud) { - switch (asset.type) { - case backendModule.AssetType.project: { - try { - const details = await queryClient.fetchQuery( - backendQueryOptions(backend, 'getProjectDetails', [ - asset.id, - asset.parentId, - asset.title, - ]), - ) - if (details.url != null) { - await backend.download(details.url, `${asset.title}.enso-project`) - } else { - const error: unknown = getText('projectHasNoSourceFilesPhrase') + case AssetEventType.download: + case AssetEventType.downloadSelected: { + if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) { + if (isCloud) { + switch (asset.type) { + case backendModule.AssetType.project: { + try { + const details = await queryClient.fetchQuery( + backendQueryOptions(backend, 'getProjectDetails', [ + asset.id, + asset.parentId, + asset.title, + ]), + ) + if (details.url != null) { + await backend.download(details.url, `${asset.title}.enso-project`) + } else { + const error: unknown = getText('projectHasNoSourceFilesPhrase') + toastAndLog('downloadProjectError', error, asset.title) + } + } catch (error) { toastAndLog('downloadProjectError', error, asset.title) } - } catch (error) { - toastAndLog('downloadProjectError', error, asset.title) + break } - break - } - case backendModule.AssetType.file: { - try { - const details = await queryClient.fetchQuery( - backendQueryOptions(backend, 'getFileDetails', [asset.id, asset.title]), - ) - if (details.url != null) { - await backend.download(details.url, asset.title) - } else { - const error: unknown = getText('fileNotFoundPhrase') + case backendModule.AssetType.file: { + try { + const details = await queryClient.fetchQuery( + backendQueryOptions(backend, 'getFileDetails', [asset.id, asset.title]), + ) + if (details.url != null) { + await backend.download(details.url, asset.title) + } else { + const error: unknown = getText('fileNotFoundPhrase') + toastAndLog('downloadFileError', error, asset.title) + } + } catch (error) { toastAndLog('downloadFileError', error, asset.title) } - } catch (error) { - toastAndLog('downloadFileError', error, asset.title) + break } - break - } - case backendModule.AssetType.datalink: { - try { - const value = await queryClient.fetchQuery( - backendQueryOptions(backend, 'getDatalink', [asset.id, asset.title]), - ) - const fileName = `${asset.title}.datalink` - download( - URL.createObjectURL( - new File([JSON.stringify(value)], fileName, { - type: 'application/json+x-enso-data-link', - }), - ), - fileName, - ) - } catch (error) { - toastAndLog('downloadDatalinkError', error, asset.title) + case backendModule.AssetType.datalink: { + try { + const value = await queryClient.fetchQuery( + backendQueryOptions(backend, 'getDatalink', [asset.id, asset.title]), + ) + const fileName = `${asset.title}.datalink` + download( + URL.createObjectURL( + new File([JSON.stringify(value)], fileName, { + type: 'application/json+x-enso-data-link', + }), + ), + fileName, + ) + } catch (error) { + toastAndLog('downloadDatalinkError', error, asset.title) + } + break + } + default: { + toastAndLog('downloadInvalidTypeError') + break } - break } - default: { - toastAndLog('downloadInvalidTypeError') - break + } else { + if (asset.type === backendModule.AssetType.project) { + const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id + const uuid = localBackend.extractTypeAndId(asset.id).id + const queryString = new URLSearchParams({ projectsDirectory }).toString() + await backend.download( + `./api/project-manager/projects/${uuid}/enso-project?${queryString}`, + `${asset.title}.enso-project`, + ) } } - } else { - if (asset.type === backendModule.AssetType.project) { - const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id - const uuid = localBackend.extractTypeAndId(asset.id).id - const queryString = new URLSearchParams({ projectsDirectory }).toString() - await backend.download( - `./api/project-manager/projects/${uuid}/enso-project?${queryString}`, - `${asset.title}.enso-project`, - ) - } } + break } - break - } - case AssetEventType.removeSelf: { - // This is not triggered from the asset list, so it uses `item.id` instead of `key`. - if (event.id === asset.id && user.isEnabled) { - try { - await createPermissionMutation.mutateAsync([ - { - action: null, - resourceId: asset.id, - actorsIds: [user.userId], - }, - ]) - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - } catch (error) { - toastAndLog(null, error) + case AssetEventType.removeSelf: { + // This is not triggered from the asset list, so it uses `item.id` instead of `key`. + if (event.id === asset.id && user.isEnabled) { + try { + await createPermissionMutation.mutateAsync([ + { + action: null, + resourceId: asset.id, + actorsIds: [user.userId], + }, + ]) + dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) + } catch (error) { + toastAndLog(null, error) + } } + break } - break - } - case AssetEventType.temporarilyAddLabels: { - const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === labels && - oldRowState.temporarilyRemovedLabels === set.EMPTY_SET - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: labels, - temporarilyRemovedLabels: set.EMPTY_SET, - }), - ) - break - } - case AssetEventType.temporarilyRemoveLabels: { - const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === set.EMPTY_SET && - oldRowState.temporarilyRemovedLabels === labels - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: set.EMPTY_SET, - temporarilyRemovedLabels: labels, - }), - ) - break - } - case AssetEventType.addLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(item.key) && - (labels == null || [...event.labelNames].some((label) => !labels.includes(label))) - ) { - const newLabels = [ - ...(labels ?? []), - ...[...event.labelNames].filter((label) => labels?.includes(label) !== true), - ] - setAsset(object.merger({ labels: newLabels })) - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - setAsset(object.merger({ labels })) - toastAndLog(null, error) - } + case AssetEventType.temporarilyAddLabels: { + const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET + setRowState((oldRowState) => + ( + oldRowState.temporarilyAddedLabels === labels && + oldRowState.temporarilyRemovedLabels === set.EMPTY_SET + ) ? + oldRowState + : object.merge(oldRowState, { + temporarilyAddedLabels: labels, + temporarilyRemovedLabels: set.EMPTY_SET, + }), + ) + break } - break - } - case AssetEventType.removeLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(item.key) && - labels != null && - [...event.labelNames].some((label) => labels.includes(label)) - ) { - const newLabels = labels.filter((label) => !event.labelNames.has(label)) - setAsset(object.merger({ labels: newLabels })) - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - setAsset(object.merger({ labels })) - toastAndLog(null, error) + case AssetEventType.temporarilyRemoveLabels: { + const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET + setRowState((oldRowState) => + ( + oldRowState.temporarilyAddedLabels === set.EMPTY_SET && + oldRowState.temporarilyRemovedLabels === labels + ) ? + oldRowState + : object.merge(oldRowState, { + temporarilyAddedLabels: set.EMPTY_SET, + temporarilyRemovedLabels: labels, + }), + ) + break + } + case AssetEventType.addLabels: { + setRowState((oldRowState) => + oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? + oldRowState + : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), + ) + const labels = asset.labels + if ( + event.ids.has(item.key) && + (labels == null || [...event.labelNames].some((label) => !labels.includes(label))) + ) { + const newLabels = [ + ...(labels ?? []), + ...[...event.labelNames].filter((label) => labels?.includes(label) !== true), + ] + setAsset(object.merger({ labels: newLabels })) + try { + await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) + } catch (error) { + setAsset(object.merger({ labels })) + toastAndLog(null, error) + } } + break } - break - } - case AssetEventType.deleteLabel: { - setAsset((oldAsset) => { - const oldLabels = oldAsset.labels ?? [] - const labels: backendModule.LabelName[] = [] - - for (const label of oldLabels) { - if (label !== event.labelName) { - labels.push(label) + case AssetEventType.removeLabels: { + setRowState((oldRowState) => + oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? + oldRowState + : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), + ) + const labels = asset.labels + if ( + event.ids.has(item.key) && + labels != null && + [...event.labelNames].some((label) => labels.includes(label)) + ) { + const newLabels = labels.filter((label) => !event.labelNames.has(label)) + setAsset(object.merger({ labels: newLabels })) + try { + await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) + } catch (error) { + setAsset(object.merger({ labels })) + toastAndLog(null, error) } } + break + } + case AssetEventType.deleteLabel: { + setAsset((oldAsset) => { + const oldLabels = oldAsset.labels ?? [] + const labels: backendModule.LabelName[] = [] + + for (const label of oldLabels) { + if (label !== event.labelName) { + labels.push(label) + } + } - return oldLabels.length !== labels.length ? object.merge(oldAsset, { labels }) : oldAsset - }) - break - } - case AssetEventType.setItem: { - if (asset.id === event.id) { - setAsset(event.valueOrUpdater) + return oldLabels.length !== labels.length ? + object.merge(oldAsset, { labels }) + : oldAsset + }) + break + } + case AssetEventType.setItem: { + if (asset.id === event.id) { + setAsset(event.valueOrUpdater) + } + break + } + default: { + return } - break - } - default: { - return - } - } - }, item.initialAssetEvents) - - switch (asset.type) { - case backendModule.AssetType.directory: - case backendModule.AssetType.project: - case backendModule.AssetType.file: - case backendModule.AssetType.datalink: - case backendModule.AssetType.secret: { - const innerProps: AssetRowInnerProps = { - key: item.key, - item, - setItem, - state, - rowState, - setRowState, } - return ( - <> - {!hidden && ( - - { - rootRef.current = element - - requestAnimationFrame(() => { - if ( - isSoleSelected && - !wasSoleSelectedRef.current && - element != null && - scrollContainerRef.current != null - ) { - const rect = element.getBoundingClientRect() - const scrollRect = scrollContainerRef.current.getBoundingClientRect() - const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX) - const scrollDown = rect.bottom - scrollRect.bottom - - if (scrollUp < 0 || scrollDown > 0) { - scrollContainerRef.current.scrollBy({ - top: scrollUp < 0 ? scrollUp : scrollDown, - behavior: 'smooth', - }) + }, item.initialAssetEvents) + + switch (asset.type) { + case backendModule.AssetType.directory: + case backendModule.AssetType.project: + case backendModule.AssetType.file: + case backendModule.AssetType.datalink: + case backendModule.AssetType.secret: { + const innerProps: AssetRowInnerProps = { + key: item.key, + item, + setItem, + state, + rowState, + setRowState, + } + return ( + <> + {!hidden && ( + + { + rootRef.current = element + + requestAnimationFrame(() => { + if ( + isSoleSelected && + !wasSoleSelectedRef.current && + element != null && + scrollContainerRef.current != null + ) { + const rect = element.getBoundingClientRect() + const scrollRect = scrollContainerRef.current.getBoundingClientRect() + const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX) + const scrollDown = rect.bottom - scrollRect.bottom + + if (scrollUp < 0 || scrollDown > 0) { + scrollContainerRef.current.scrollBy({ + top: scrollUp < 0 ? scrollUp : scrollDown, + behavior: 'smooth', + }) + } } - } - wasSoleSelectedRef.current = isSoleSelected - }) - - if (isKeyboardSelected && element?.contains(document.activeElement) === false) { - element.focus() - } - }} - className={tailwindMerge.twMerge( - 'h-table-row rounded-full transition-all ease-in-out rounded-rows-child', - visibility, - (isDraggedOver || selected) && 'selected', - )} - {...draggableProps} - onClick={(event) => { - unsetModal() - onClick(innerProps, event) - if ( - item.type === backendModule.AssetType.directory && - eventModule.isDoubleClick(event) && - !rowState.isEditingName - ) { - // This must be processed on the next tick, otherwise it will be overridden - // by the default click handler. - window.setTimeout(() => { - setSelected(false) + wasSoleSelectedRef.current = isSoleSelected }) - doToggleDirectoryExpansion(item.item.id, item.key) - } - }} - onContextMenu={(event) => { - if (allowContextMenu) { - event.preventDefault() - event.stopPropagation() - if (!selected) { - select(item) + + if (isKeyboardSelected && element?.contains(document.activeElement) === false) { + element.focus() } - setModal( - , - ) - } - }} - onDragStart={(event) => { - if ( - rowState.isEditingName || - (projectState !== backendModule.ProjectState.closed && - projectState !== backendModule.ProjectState.created && - projectState != null) - ) { - event.preventDefault() - } else { - props.onDragStart?.(event, item) - } - }} - onDragEnter={(event) => { - if (dragOverTimeoutHandle.current != null) { - window.clearTimeout(dragOverTimeoutHandle.current) - } - if (item.type === backendModule.AssetType.directory) { - dragOverTimeoutHandle.current = window.setTimeout(() => { - doToggleDirectoryExpansion(item.item.id, item.key, true) - }, DRAG_EXPAND_DELAY_MS) - } - // Required because `dragover` does not fire on `mouseenter`. - props.onDragOver?.(event, item) - onDragOver(event) - }} - onDragOver={(event) => { - if (state.category.type === 'trash') { - event.dataTransfer.dropEffect = 'none' - } - props.onDragOver?.(event, item) - onDragOver(event) - }} - onDragEnd={(event) => { - clearDragState() - props.onDragEnd?.(event, item) - }} - onDragLeave={(event) => { - if ( - dragOverTimeoutHandle.current != null && - (!(event.relatedTarget instanceof Node) || - !event.currentTarget.contains(event.relatedTarget)) - ) { - window.clearTimeout(dragOverTimeoutHandle.current) - } - if ( - event.relatedTarget instanceof Node && - !event.currentTarget.contains(event.relatedTarget) - ) { - clearDragState() - } - props.onDragLeave?.(event, item) - }} - onDrop={(event) => { - if (state.category.type !== 'trash') { - props.onDrop?.(event, item) - clearDragState() - const [directoryKey, directoryId] = - item.type === backendModule.AssetType.directory ? - [item.key, item.item.id] - : [item.directoryKey, item.directoryId] - const payload = drag.ASSET_ROWS.lookup(event) + }} + className={tailwindMerge.twMerge( + 'h-table-row rounded-full transition-all ease-in-out rounded-rows-child', + visibility, + (isDraggedOver || selected) && 'selected', + )} + {...draggableProps} + onClick={(event) => { + unsetModal() + onClick(innerProps, event) if ( - payload != null && - payload.every((innerItem) => innerItem.key !== directoryKey) + item.type === backendModule.AssetType.directory && + eventModule.isDoubleClick(event) && + !rowState.isEditingName ) { + // This must be processed on the next tick, otherwise it will be overridden + // by the default click handler. + window.setTimeout(() => { + setSelected(false) + }) + doToggleDirectoryExpansion(item.item.id, item.key) + } + }} + onContextMenu={(event) => { + if (allowContextMenu) { event.preventDefault() event.stopPropagation() - unsetModal() - doToggleDirectoryExpansion(directoryId, directoryKey, true) - const ids = payload - .filter((payloadItem) => payloadItem.asset.parentId !== directoryId) - .map((dragItem) => dragItem.key) - cutAndPaste( - directoryKey, - directoryId, - { backendType: backend.type, ids: new Set(ids), category }, - nodeMap.current, + if (!selected) { + select(item) + } + setModal( + , ) - } else if (event.dataTransfer.types.includes('Files')) { + } + }} + onDragStart={(event) => { + if ( + rowState.isEditingName || + (projectState !== backendModule.ProjectState.closed && + projectState !== backendModule.ProjectState.created && + projectState != null) + ) { event.preventDefault() - event.stopPropagation() - doToggleDirectoryExpansion(directoryId, directoryKey, true) - dispatchAssetListEvent({ - type: AssetListEventType.uploadFiles, - parentKey: directoryKey, - parentId: directoryId, - files: Array.from(event.dataTransfer.files), - }) + } else { + props.onDragStart?.(event, item) } - } + }} + onDragEnter={(event) => { + if (dragOverTimeoutHandle.current != null) { + window.clearTimeout(dragOverTimeoutHandle.current) + } + if (item.type === backendModule.AssetType.directory) { + dragOverTimeoutHandle.current = window.setTimeout(() => { + doToggleDirectoryExpansion(item.item.id, item.key, true) + }, DRAG_EXPAND_DELAY_MS) + } + // Required because `dragover` does not fire on `mouseenter`. + props.onDragOver?.(event, item) + onDragOver(event) + }} + onDragOver={(event) => { + if (state.category.type === 'trash') { + event.dataTransfer.dropEffect = 'none' + } + props.onDragOver?.(event, item) + onDragOver(event) + }} + onDragEnd={(event) => { + clearDragState() + props.onDragEnd?.(event, item) + }} + onDragLeave={(event) => { + if ( + dragOverTimeoutHandle.current != null && + (!(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget)) + ) { + window.clearTimeout(dragOverTimeoutHandle.current) + } + if ( + event.relatedTarget instanceof Node && + !event.currentTarget.contains(event.relatedTarget) + ) { + clearDragState() + } + props.onDragLeave?.(event, item) + }} + onDrop={(event) => { + if (state.category.type !== 'trash') { + props.onDrop?.(event, item) + clearDragState() + const [directoryKey, directoryId] = + item.type === backendModule.AssetType.directory ? + [item.key, item.item.id] + : [item.directoryKey, item.directoryId] + const payload = drag.ASSET_ROWS.lookup(event) + if ( + payload != null && + payload.every((innerItem) => innerItem.key !== directoryKey) + ) { + event.preventDefault() + event.stopPropagation() + unsetModal() + doToggleDirectoryExpansion(directoryId, directoryKey, true) + const ids = payload + .filter((payloadItem) => payloadItem.asset.parentId !== directoryId) + .map((dragItem) => dragItem.key) + cutAndPaste( + directoryKey, + directoryId, + { backendType: backend.type, ids: new Set(ids), category }, + nodeMap.current, + ) + } else if (event.dataTransfer.types.includes('Files')) { + event.preventDefault() + event.stopPropagation() + doToggleDirectoryExpansion(directoryId, directoryKey, true) + dispatchAssetListEvent({ + type: AssetListEventType.uploadFiles, + parentKey: directoryKey, + parentId: directoryId, + files: Array.from(event.dataTransfer.files), + }) + } + } + }} + > + {columns.map((column) => { + const Render = columnModule.COLUMN_RENDERER[column] + return ( + + + + ) + })} + + + )} + {selected && allowContextMenu && !hidden && ( + // This is a copy of the context menu, since the context menu registers keyboard + // shortcut handlers. This is a bit of a hack, however it is preferable to duplicating + // the entire context menu (once for the keyboard actions, once for the JSX). + - )} - {selected && allowContextMenu && !hidden && ( - // This is a copy of the context menu, since the context menu registers keyboard - // shortcut handlers. This is a bit of a hack, however it is preferable to duplicating - // the entire context menu (once for the keyboard actions, once for the JSX). -