Move some Drive state to zustand (#10913)

- Move Asset Panel open state to zustand
- This avoids re-rendering the entire Data Catalog view every time an asset is clicked
- Move Asset Panel props to zustand
- Move search suggestions to zustand
- Memoize `AssetRow` to avoid re-rendering every row when unnecessary

# Important Notes
It's not a perfect solution to the performance issues, however selection performance seems to have improved quite nicely.
This commit is contained in:
somebody1234 2024-09-08 17:38:13 +10:00 committed by GitHub
parent 9ec60299e4
commit 51733ee876
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 540 additions and 497 deletions

View File

@ -46,6 +46,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
onDragCancelRef.current = onDragCancel
const lastMouseEvent = React.useRef<MouseEvent | null>(null)
const parentBounds = React.useRef<DOMRect | null>(null)
const anchorRef = React.useRef<geometry.Coordinate2D | null>(null)
const [anchor, setAnchor] = React.useState<geometry.Coordinate2D | null>(null)
const [position, setPosition] = React.useState<geometry.Coordinate2D | null>(null)
const [lastSetAnchor, setLastSetAnchor] = React.useState<geometry.Coordinate2D | null>(null)
@ -74,6 +75,12 @@ export default function SelectionBrush(props: SelectionBrushProps) {
return eventModule.isElementInBounds(event, parentBounds.current, margin)
}
}
const unsetAnchor = () => {
if (anchorRef.current != null) {
anchorRef.current = null
setAnchor(null)
}
}
const onMouseDown = (event: MouseEvent) => {
if (
modalRef.current == null &&
@ -86,7 +93,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
didMoveWhileDraggingRef.current = false
lastMouseEvent.current = event
const newAnchor = { left: event.pageX, top: event.pageY }
setAnchor(newAnchor)
anchorRef.current = null
setLastSetAnchor(newAnchor)
setPosition(newAnchor)
}
@ -101,7 +108,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
isMouseDownRef.current = false
didMoveWhileDraggingRef.current = false
})
setAnchor(null)
unsetAnchor()
}
const onMouseMove = (event: MouseEvent) => {
if (!(event.buttons & 1)) {
@ -137,7 +144,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
if (isMouseDownRef.current) {
isMouseDownRef.current = false
onDragCancelRef.current()
setAnchor(null)
unsetAnchor()
}
}

View File

@ -9,7 +9,12 @@ import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
import {
useDriveStore,
useSetAssetPanelProps,
useSetIsAssetPanelTemporarilyVisible,
useSetSelectedKeys,
} from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -33,6 +38,7 @@ import * as backendModule from '#/services/Backend'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useFullUserSession } from '#/providers/AuthProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
@ -72,27 +78,47 @@ export interface AssetRowInnerProps {
}
/** Props for an {@link AssetRow}. */
export interface AssetRowProps
extends Readonly<Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'>> {
export interface AssetRowProps {
readonly isOpened: boolean
readonly item: assetTreeNode.AnyAssetTreeNode
readonly state: assetsTable.AssetsTableState
readonly hidden: boolean
readonly columns: columnUtils.Column[]
readonly isKeyboardSelected: boolean
readonly grabKeyboardFocus: () => void
readonly grabKeyboardFocus: (item: assetTreeNode.AnyAssetTreeNode) => void
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
readonly select: () => void
readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void>
readonly select: (item: assetTreeNode.AnyAssetTreeNode) => void
readonly onDragStart?: (
event: React.DragEvent<HTMLTableRowElement>,
item: assetTreeNode.AnyAssetTreeNode,
) => void
readonly onDragOver?: (
event: React.DragEvent<HTMLTableRowElement>,
item: assetTreeNode.AnyAssetTreeNode,
) => void
readonly onDragLeave?: (
event: React.DragEvent<HTMLTableRowElement>,
item: assetTreeNode.AnyAssetTreeNode,
) => void
readonly onDragEnd?: (
event: React.DragEvent<HTMLTableRowElement>,
item: assetTreeNode.AnyAssetTreeNode,
) => void
readonly onDrop?: (
event: React.DragEvent<HTMLTableRowElement>,
item: assetTreeNode.AnyAssetTreeNode,
) => void
readonly updateAssetRef: React.RefObject<
Record<backendModule.AssetId, (asset: backendModule.AnyAsset) => void>
>
}
/** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) {
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,
setAssetPanelProps,
doToggleDirectoryExpansion,
doCopy,
doCut,
@ -102,13 +128,14 @@ export default function AssetRow(props: AssetRowProps) {
doMove,
category,
} = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
const { scrollContainerRef, rootDirectoryId, backend } = state
const { visibilities } = state
const [item, setItem] = React.useState(rawItem)
const driveStore = useDriveStore()
const { user } = useFullUserSession()
const setSelectedKeys = useSetSelectedKeys()
const setAssetPanelProps = useSetAssetPanelProps()
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
(visuallySelectedKeys ?? selectedKeys).has(item.key),
)
@ -129,8 +156,8 @@ export default function AssetRow(props: AssetRowProps) {
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
const grabKeyboardFocusRef = React.useRef(grabKeyboardFocus)
grabKeyboardFocusRef.current = grabKeyboardFocus
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus)
const asset = item.item
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
@ -192,11 +219,22 @@ export default function AssetRow(props: AssetRowProps) {
React.useEffect(() => {
if (isKeyboardSelected) {
rootRef.current?.focus()
grabKeyboardFocusRef.current()
grabKeyboardFocusRef.current(item)
}
}, [isKeyboardSelected])
}, [grabKeyboardFocusRef, isKeyboardSelected, item])
React.useImperativeHandle(updateAssetRef, () => setAsset)
React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item }))
if (updateAssetRef.current) {
updateAssetRef.current[item.item.id] = setAsset
}
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]
}
}
}, [item.item.id, updateAssetRef])
React.useEffect(() => {
if (isSoleSelected) {
@ -616,7 +654,7 @@ export default function AssetRow(props: AssetRowProps) {
event.preventDefault()
event.stopPropagation()
if (!selected) {
select()
select(item)
}
setModal(
<AssetContextMenu
@ -644,7 +682,7 @@ export default function AssetRow(props: AssetRowProps) {
) {
event.preventDefault()
} else {
props.onDragStart?.(event)
props.onDragStart?.(event, item)
}
}}
onDragEnter={(event) => {
@ -657,19 +695,19 @@ export default function AssetRow(props: AssetRowProps) {
}, DRAG_EXPAND_DELAY_MS)
}
// Required because `dragover` does not fire on `mouseenter`.
props.onDragOver?.(event)
props.onDragOver?.(event, item)
onDragOver(event)
}}
onDragOver={(event) => {
if (state.category.type === 'trash') {
event.dataTransfer.dropEffect = 'none'
}
props.onDragOver?.(event)
props.onDragOver?.(event, item)
onDragOver(event)
}}
onDragEnd={(event) => {
clearDragState()
props.onDragEnd?.(event)
props.onDragEnd?.(event, item)
}}
onDragLeave={(event) => {
if (
@ -685,11 +723,11 @@ export default function AssetRow(props: AssetRowProps) {
) {
clearDragState()
}
props.onDragLeave?.(event)
props.onDragLeave?.(event, item)
}}
onDrop={(event) => {
if (state.category.type !== 'trash') {
props.onDrop?.(event)
props.onDrop?.(event, item)
clearDragState()
const [directoryKey, directoryId] =
item.type === backendModule.AssetType.directory ?
@ -816,4 +854,4 @@ export default function AssetRow(props: AssetRowProps) {
)
}
}
}
})

View File

@ -10,6 +10,7 @@ import EditableSpan from '#/components/EditableSpan'
import * as backendModule from '#/services/Backend'
import { useSetIsAssetPanelTemporarilyVisible } from '#/providers/DriveProvider'
import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
@ -27,8 +28,8 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.DatalinkAsset}.
* This should never happen. */
export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { setIsAssetPanelTemporarilyVisible } = state
const { item, setItem, selected, rowState, setRowState, isEditable } = props
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const inputBindings = inputBindingsProvider.useInputBindings()
if (item.type !== backendModule.AssetType.datalink) {
// eslint-disable-next-line no-restricted-syntax

View File

@ -1,23 +1,19 @@
/**
* @file useEventCallback shim
*/
/** @file `useEvent` shim. */
import { useCallback } from 'react'
import * as React from 'react'
import * as syncRef from '#/hooks/syncRefHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
/**
* useEvent shim.
* `useEvent` shim.
* @see https://github.com/reactjs/rfcs/pull/220
* @see https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#internal-implementation
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useEventCallback<Func extends (...args: any[]) => unknown>(callback: Func) {
const callbackRef = syncRef.useSyncRef(callback)
export function useEventCallback<Func extends (...args: never[]) => unknown>(callback: Func) {
const callbackRef = useSyncRef(callback)
// Make sure that the value of `this` provided for the call to fn is not `ref`
// This type assertion is safe, because it's a transparent wrapper around the original callback
// we mute react-hooks/exhaustive-deps because we don't need to update the callback when the callbackRef changes(it never does)
// eslint-disable-next-line react-hooks/exhaustive-deps, no-restricted-syntax
return React.useCallback(((...args) => callbackRef.current.apply(void 0, args)) as Func, [])
return useCallback(((...args) => callbackRef.current.apply(undefined, args)) as Func, [])
}

View File

@ -1,16 +1,9 @@
/**
* @file useSyncRef.ts
*
* A hook that returns a ref object whose `current` property is always in sync with the provided value.
*/
/** @file A hook that returns a ref object whose `current` property is always in sync with the provided value. */
import { type MutableRefObject, useRef } from 'react'
import * as React from 'react'
/**
* A hook that returns a ref object whose `current` property is always in sync with the provided value.
*/
export function useSyncRef<T>(value: T): Readonly<React.MutableRefObject<T>> {
const ref = React.useRef(value)
/** A hook that returns a ref object whose `current` property is always in sync with the provided value. */
export function useSyncRef<T>(value: T): Readonly<MutableRefObject<T>> {
const ref = useRef(value)
/*
Even though the react core team doesn't recommend setting ref values during the render (it might lead to deoptimizations), the reasoning behind this is:

View File

@ -16,6 +16,7 @@ import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import { useAssetPanelProps, useIsAssetPanelVisible } from '#/providers/DriveProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import LocalStorage from '#/utilities/LocalStorage'
import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -52,24 +53,28 @@ LocalStorage.register({
// === AssetPanel ===
// ==================
/** The subset of {@link AssetPanelProps} that are required to be supplied by the row. */
export interface AssetPanelRequiredProps {
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly item: assetTreeNode.AnyAssetTreeNode | null
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>> | null
}
/** Props for an {@link AssetPanel}. */
export interface AssetPanelProps extends AssetPanelRequiredProps {
readonly isVisible: boolean
readonly isReadonly?: boolean
export interface AssetPanelProps {
readonly backendType: backendModule.BackendType
readonly category: Category
}
/** A panel containing the description and settings for an asset. */
export default function AssetPanel(props: AssetPanelProps) {
const { isVisible, backend, isReadonly = false, item, setItem, category } = props
const { backendType, category } = props
const contextPropsRaw = useAssetPanelProps()
const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null
const { backend, item, setItem } = contextProps ?? {}
const isReadonly = category.type === 'trash'
const isCloud = backend?.type === backendModule.BackendType.remote
const isVisible = useIsAssetPanelVisible()
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
@ -112,82 +117,88 @@ export default function AssetPanel(props: AssetPanelProps) {
return (
<div
data-testid="asset-panel"
className={tailwindMerge.twMerge(
'pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel bg-white p-4 pl-asset-panel-l transition-[box-shadow] clip-path-left-shadow',
isVisible ? 'shadow-softer' : '',
'flex flex-col overflow-hidden transition-min-width duration-side-panel ease-in-out',
isVisible ? 'min-w-side-panel' : 'min-w',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
<ariaComponents.ButtonGroup className="mt-0.5 grow-0 basis-8">
{isCloud &&
item != null &&
item.item.type !== backendModule.AssetType.secret &&
item.item.type !== backendModule.AssetType.directory && (
<div
data-testid="asset-panel"
className={tailwindMerge.twMerge(
'pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel bg-white p-4 pl-asset-panel-l transition-[box-shadow] clip-path-left-shadow',
isVisible ? 'shadow-softer' : '',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
<ariaComponents.ButtonGroup className="mt-0.5 grow-0 basis-8">
{isCloud &&
item != null &&
item.item.type !== backendModule.AssetType.secret &&
item.item.type !== backendModule.AssetType.directory && (
<ariaComponents.Button
size="medium"
variant="outline"
className={tailwindMerge.twMerge(
'pointer-events-auto disabled:opacity-100',
tab === AssetPanelTab.versions && 'bg-primary/[8%] opacity-100',
)}
onPress={() => {
setTab((oldTab) =>
oldTab === AssetPanelTab.versions ?
AssetPanelTab.properties
: AssetPanelTab.versions,
)
}}
>
{getText('versions')}
</ariaComponents.Button>
)}
{isCloud && item != null && item.item.type === backendModule.AssetType.project && (
<ariaComponents.Button
size="medium"
variant="outline"
className={tailwindMerge.twMerge(
'pointer-events-auto disabled:opacity-100',
tab === AssetPanelTab.versions && 'bg-primary/[8%] opacity-100',
tab === AssetPanelTab.projectSessions && 'bg-primary/[8%] opacity-100',
)}
onPress={() => {
setTab((oldTab) =>
oldTab === AssetPanelTab.versions ?
oldTab === AssetPanelTab.projectSessions ?
AssetPanelTab.properties
: AssetPanelTab.versions,
: AssetPanelTab.projectSessions,
)
}}
>
{getText('versions')}
{getText('projectSessions')}
</ariaComponents.Button>
)}
{isCloud && item != null && item.item.type === backendModule.AssetType.project && (
<ariaComponents.Button
size="medium"
variant="outline"
isDisabled={tab === AssetPanelTab.projectSessions}
className={tailwindMerge.twMerge(
'pointer-events-auto disabled:opacity-100',
tab === AssetPanelTab.projectSessions && 'bg-primary/[8%] opacity-100',
{/* Spacing. The top right asset and user bars overlap this area. */}
<div className="grow" />
</ariaComponents.ButtonGroup>
{item == null || setItem == null || backend == null ?
<div className="grid grow place-items-center text-lg">
{getText('selectExactlyOneAssetToViewItsDetails')}
</div>
: <>
{tab === AssetPanelTab.properties && (
<AssetProperties
backend={backend}
isReadonly={isReadonly}
item={item}
setItem={setItem}
category={category}
/>
)}
onPress={() => {
setTab((oldTab) =>
oldTab === AssetPanelTab.projectSessions ?
AssetPanelTab.properties
: AssetPanelTab.projectSessions,
)
}}
>
{getText('projectSessions')}
</ariaComponents.Button>
)}
{/* Spacing. The top right asset and user bars overlap this area. */}
<div className="grow" />
</ariaComponents.ButtonGroup>
{item == null || setItem == null || backend == null ?
<div className="grid grow place-items-center text-lg">
{getText('selectExactlyOneAssetToViewItsDetails')}
</div>
: <>
{tab === AssetPanelTab.properties && (
<AssetProperties
backend={backend}
isReadonly={isReadonly}
item={item}
setItem={setItem}
category={category}
/>
)}
{tab === AssetPanelTab.versions && <AssetVersions backend={backend} item={item} />}
{tab === AssetPanelTab.projectSessions &&
item.type === backendModule.AssetType.project && (
<AssetProjectSessions backend={backend} item={item} />
)}
</>
}
{tab === AssetPanelTab.versions && <AssetVersions backend={backend} item={item} />}
{tab === AssetPanelTab.projectSessions &&
item.type === backendModule.AssetType.project && (
<AssetProjectSessions backend={backend} item={item} />
)}
</>
}
</div>
</div>
)
}

View File

@ -19,6 +19,7 @@ import SvgMask from '#/components/SvgMask'
import type Backend from '#/services/Backend'
import { useSuggestions } from '#/providers/DriveProvider'
import * as array from '#/utilities/array'
import AssetQuery from '#/utilities/AssetQuery'
import * as eventModule from '#/utilities/event'
@ -120,16 +121,16 @@ export interface AssetSearchBarProps {
readonly isCloud: boolean
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly suggestions: readonly Suggestion[]
}
/** A search bar containing a text input, and a list of suggestions. */
export default function AssetSearchBar(props: AssetSearchBarProps) {
const { backend, isCloud, query, setQuery, suggestions: rawSuggestions } = props
const { backend, isCloud, query, setQuery } = props
const { getText } = textProvider.useText()
const { modalRef } = modalProvider.useModalRef()
/** A cached query as of the start of tabbing. */
const baseQuery = React.useRef(query)
const rawSuggestions = useSuggestions()
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
const suggestionsRef = React.useRef(rawSuggestions)
const [selectedIndices, setSelectedIndices] = React.useState<ReadonlySet<number>>(

View File

@ -27,9 +27,12 @@ import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import {
useDriveStore,
useSetAssetPanelProps,
useSetCanCreateAssets,
useSetCanDownload,
useSetIsAssetPanelTemporarilyVisible,
useSetSelectedKeys,
useSetTargetDirectory,
useSetSuggestions,
useSetVisuallySelectedKeys,
} from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
@ -43,15 +46,14 @@ import AssetEventType from '#/events/AssetEventType'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import type * as assetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import { isLocalCategory, type Category } from '#/layouts/CategorySwitcher/Category'
import * as aria from '#/components/aria'
import type * as assetRow from '#/components/dashboard/AssetRow'
import AssetRow from '#/components/dashboard/AssetRow'
import { AssetRow } from '#/components/dashboard/AssetRow'
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import NameColumn from '#/components/dashboard/column/NameColumn'
@ -249,8 +251,6 @@ export interface AssetsTableState {
readonly setSortInfo: (sortInfo: sorting.SortInfo<columnUtils.SortableColumn> | null) => void
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly nodeMap: Readonly<
React.MutableRefObject<ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>>
>
@ -290,13 +290,8 @@ export interface AssetsTableProps {
readonly hidden: boolean
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly setSuggestions: React.Dispatch<
React.SetStateAction<readonly assetSearchBar.Suggestion[]>
>
readonly category: Category
readonly initialProjectName: string | null
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
readonly assetManagementApiRef: React.Ref<AssetManagementApi>
}
@ -312,12 +307,12 @@ export interface AssetManagementApi {
/** The table of project assets. */
export default function AssetsTable(props: AssetsTableProps) {
const { hidden, query, setQuery, category, assetManagementApiRef } = props
const { setSuggestions, initialProjectName } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const { initialProjectName, targetDirectoryNodeRef } = props
const openedProjects = projectsProvider.useLaunchedProjects()
const doOpenProject = projectHooks.useOpenProject()
const setCanDownload = useSetCanDownload()
const setSuggestions = useSetSuggestions()
const { user } = authProvider.useFullUserSession()
const backend = backendProvider.useBackend(category)
@ -331,10 +326,13 @@ export default function AssetsTable(props: AssetsTableProps) {
const previousCategoryRef = React.useRef(category)
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const setTargetDirectoryRaw = useSetTargetDirectory()
const setCanCreateAssets = useSetCanCreateAssets()
const didLoadingProjectManagerFail = backendProvider.useDidLoadingProjectManagerFail()
const reconnectToProjectManager = backendProvider.useReconnectToProjectManager()
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const setAssetPanelProps = useSetAssetPanelProps()
const hiddenColumns = columnUtils
.getColumnList(user, backend.type)
.filter((column) => !enabledColumns.has(column))
@ -865,9 +863,21 @@ export default function AssetsTable(props: AssetsTableProps) {
})
const setTargetDirectory = useEventCallback(
(page: AssetTreeNode<backendModule.DirectoryAsset> | null) => {
setTargetDirectoryRaw(page)
targetDirectoryNodeRef.current = page
(targetDirectory: AssetTreeNode<backendModule.DirectoryAsset> | null) => {
const targetDirectorySelfPermission =
targetDirectory == null ? null : (
permissions.tryFindSelfPermission(user, targetDirectory.item.permissions)
)
const canCreateAssets =
targetDirectory == null ?
category.type !== 'cloud' || user.plan == null || user.plan === backendModule.Plan.solo
: isLocalCategory(category) ||
(targetDirectorySelfPermission != null &&
permissions.canPermissionModifyDirectoryContents(
targetDirectorySelfPermission.permission,
))
setCanCreateAssets(canCreateAssets)
targetDirectoryNodeRef.current = targetDirectory
},
)
@ -1311,14 +1321,13 @@ export default function AssetsTable(props: AssetsTableProps) {
const selectionStartIndexRef = React.useRef<number | null>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const setMostRecentlySelectedIndex = React.useCallback(
(index: number | null, isKeyboard = false) => {
const setMostRecentlySelectedIndex = useEventCallback(
(index: number | null, isKeyboard: boolean = false) => {
React.startTransition(() => {
mostRecentlySelectedIndexRef.current = index
setKeyboardSelectedIndex(isKeyboard ? index : null)
})
},
[],
)
React.useEffect(() => {
@ -1579,7 +1588,7 @@ export default function AssetsTable(props: AssetsTableProps) {
// This is not a React component, even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax
const onAssetListEvent = (event: assetListEvent.AssetListEvent) => {
const onAssetListEvent = useEventCallback((event: assetListEvent.AssetListEvent) => {
switch (event.type) {
case AssetListEventType.newFolder: {
const parent = nodeMapRef.current.get(event.parentKey)
@ -2105,9 +2114,7 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
}
}
const onAssetListEventRef = React.useRef(onAssetListEvent)
onAssetListEventRef.current = onAssetListEvent
})
eventListProvider.useAssetListEventListener((event) => {
if (!isLoading) {
onAssetListEvent(event)
@ -2116,13 +2123,13 @@ export default function AssetsTable(props: AssetsTableProps) {
}
})
const doCopy = React.useCallback(() => {
const doCopy = useEventCallback(() => {
unsetModal()
const { selectedKeys } = driveStore.getState()
setPasteData({ type: PasteType.copy, data: selectedKeys })
}, [driveStore, unsetModal])
})
const doCut = React.useCallback(() => {
const doCut = useEventCallback(() => {
unsetModal()
if (pasteData != null) {
dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data })
@ -2131,9 +2138,9 @@ export default function AssetsTable(props: AssetsTableProps) {
setPasteData({ type: PasteType.move, data: selectedKeys })
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys })
setSelectedKeys(new Set())
}, [unsetModal, pasteData, driveStore, dispatchAssetEvent, setSelectedKeys])
})
const doPaste = React.useCallback(
const doPaste = useEventCallback(
(newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => {
unsetModal()
if (pasteData != null) {
@ -2163,7 +2170,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
},
[pasteData, doToggleDirectoryExpansion, unsetModal, dispatchAssetEvent, dispatchAssetListEvent],
)
const doRestore = useEventCallback(async (asset: backendModule.AnyAsset) => {
@ -2174,9 +2180,9 @@ export default function AssetsTable(props: AssetsTableProps) {
}
})
const hideColumn = React.useCallback((column: columnUtils.Column) => {
const hideColumn = useEventCallback((column: columnUtils.Column) => {
setEnabledColumns((columns) => set.withPresence(columns, column, false))
}, [])
})
const hiddenContextMenu = React.useMemo(
() => (
@ -2242,8 +2248,6 @@ export default function AssetsTable(props: AssetsTableProps) {
setSortInfo,
query,
setQuery,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
nodeMap: nodeMapRef,
pasteData: pasteDataRef,
hideColumn,
@ -2272,8 +2276,6 @@ export default function AssetsTable(props: AssetsTableProps) {
doRestore,
doMove,
hideColumn,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
setQuery,
],
)
@ -2334,14 +2336,14 @@ export default function AssetsTable(props: AssetsTableProps) {
if (queuedAssetEvents.length !== 0) {
queuedAssetListEventsRef.current = []
for (const event of queuedAssetEvents) {
onAssetListEventRef.current(event)
onAssetListEvent(event)
}
}
setSpinnerState(spinner.SpinnerState.initial)
}
}, [isLoading])
}, [isLoading, onAssetListEvent])
const calculateNewKeys = React.useCallback(
const calculateNewKeys = useEventCallback(
(
event: MouseEvent | React.MouseEvent,
keys: backendModule.AssetId[],
@ -2378,14 +2380,13 @@ export default function AssetsTable(props: AssetsTableProps) {
})(event, false)
return result
},
[driveStore, inputBindings],
)
const { startAutoScroll, endAutoScroll, onMouseEvent } = autoScrollHooks.useAutoScroll(rootRef)
const dragSelectionChangeLoopHandle = React.useRef(0)
const dragSelectionRangeRef = React.useRef<DragSelectionInfo | null>(null)
const onSelectionDrag = React.useCallback(
const onSelectionDrag = useEventCallback(
(rectangle: geometry.DetailedRectangle, event: MouseEvent) => {
startAutoScroll()
onMouseEvent(event)
@ -2431,37 +2432,30 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
},
[startAutoScroll, onMouseEvent, setVisuallySelectedKeys, displayItems, calculateNewKeys],
)
const onSelectionDragEnd = React.useCallback(
(event: MouseEvent) => {
endAutoScroll()
onMouseEvent(event)
const range = dragSelectionRangeRef.current
if (range != null) {
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
setSelectedKeys(calculateNewKeys(event, keys, () => []))
}
setVisuallySelectedKeys(null)
dragSelectionRangeRef.current = null
},
[
endAutoScroll,
onMouseEvent,
setVisuallySelectedKeys,
displayItems,
setSelectedKeys,
calculateNewKeys,
],
)
const onSelectionDragCancel = React.useCallback(() => {
const onSelectionDragEnd = useEventCallback((event: MouseEvent) => {
endAutoScroll()
onMouseEvent(event)
const range = dragSelectionRangeRef.current
if (range != null) {
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
setSelectedKeys(calculateNewKeys(event, keys, () => []))
}
setVisuallySelectedKeys(null)
dragSelectionRangeRef.current = null
}, [setVisuallySelectedKeys])
})
const onRowClick = React.useCallback(
const onSelectionDragCancel = useEventCallback(() => {
setVisuallySelectedKeys(null)
dragSelectionRangeRef.current = null
})
const grabRowKeyboardFocus = useEventCallback((item: assetTreeNode.AnyAssetTreeNode) => {
setSelectedKeys(new Set([item.key]))
})
const onRowClick = useEventCallback(
(innerRowProps: assetRow.AssetRowInnerProps, event: React.MouseEvent) => {
const { key } = innerRowProps
event.stopPropagation()
@ -2483,7 +2477,158 @@ export default function AssetsTable(props: AssetsTableProps) {
selectionStartIndexRef.current = null
}
},
[visibleItems, calculateNewKeys, setSelectedKeys, setMostRecentlySelectedIndex],
)
const selectRow = useEventCallback((item: assetTreeNode.AnyAssetTreeNode) => {
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
selectionStartIndexRef.current = null
setSelectedKeys(new Set([item.key]))
})
const onRowDragStart = useEventCallback(
(event: React.DragEvent<HTMLTableRowElement>, item: assetTreeNode.AnyAssetTreeNode) => {
startAutoScroll()
onMouseEvent(event)
let newSelectedKeys = driveStore.getState().selectedKeys
if (!newSelectedKeys.has(item.key)) {
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
selectionStartIndexRef.current = null
newSelectedKeys = new Set([item.key])
setSelectedKeys(newSelectedKeys)
}
const nodes = assetTree.preorderTraversal().filter((node) => newSelectedKeys.has(node.key))
const payload: drag.AssetRowsDragPayload = nodes.map((node) => ({
key: node.key,
asset: node.item,
}))
event.dataTransfer.setData(
mimeTypes.ASSETS_MIME_TYPE,
JSON.stringify(nodes.map((node) => node.key)),
)
drag.setDragImageToBlank(event)
drag.ASSET_ROWS.bind(event, payload)
setModal(
<DragModal
event={event}
className="flex flex-col rounded-default bg-selected-frame backdrop-blur-default"
onDragEnd={() => {
drag.ASSET_ROWS.unbind(payload)
}}
>
{nodes.map((node) => (
<NameColumn
key={node.key}
isOpened={false}
keyProp={node.key}
item={node.with({ depth: 0 })}
backendType={backend.type}
state={state}
// Default states.
isSoleSelected={false}
selected={false}
rowState={assetRowUtils.INITIAL_ROW_STATE}
// The drag placeholder cannot be interacted with.
setSelected={() => {}}
setItem={() => {}}
setRowState={() => {}}
isEditable={false}
/>
))}
</DragModal>,
)
},
)
const onRowDragOver = useEventCallback(
(event: React.DragEvent<HTMLTableRowElement>, item: assetTreeNode.AnyAssetTreeNode) => {
onMouseEvent(event)
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
event.stopPropagation()
const { selectedKeys } = driveStore.getState()
const idsReference = selectedKeys.has(item.key) ? selectedKeys : item.key
// This optimization is required in order to avoid severe lag on Firefox.
if (idsReference !== lastSelectedIdsRef.current) {
lastSelectedIdsRef.current = idsReference
const ids = typeof idsReference === 'string' ? new Set([idsReference]) : idsReference
let labelsPresent = 0
for (const selectedKey of ids) {
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
if (nodeLabels != null) {
for (const label of nodeLabels) {
if (payload.has(label)) {
labelsPresent += 1
}
}
}
}
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
window.setTimeout(() => {
dispatchAssetEvent({
type:
shouldAdd ?
AssetEventType.temporarilyAddLabels
: AssetEventType.temporarilyRemoveLabels,
ids,
labelNames: payload,
})
})
}
}
},
)
const onRowDragLeave = useEventCallback(() => {
setIsDraggingFiles(false)
})
const onRowDragEnd = useEventCallback(() => {
setIsDraggingFiles(false)
endAutoScroll()
lastSelectedIdsRef.current = null
const { selectedKeys } = driveStore.getState()
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeys,
labelNames: set.EMPTY_SET,
})
})
const onRowDrop = useEventCallback(
(event: React.DragEvent<HTMLTableRowElement>, item: assetTreeNode.AnyAssetTreeNode) => {
endAutoScroll()
const { selectedKeys } = driveStore.getState()
const ids = new Set(selectedKeys.has(item.key) ? selectedKeys : [item.key])
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
event.stopPropagation()
let labelsPresent = 0
for (const selectedKey of ids) {
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
if (nodeLabels != null) {
for (const label of nodeLabels) {
if (payload.has(label)) {
labelsPresent += 1
}
}
}
}
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
dispatchAssetEvent({
type: shouldAdd ? AssetEventType.addLabels : AssetEventType.removeLabels,
ids,
labelNames: payload,
})
} else {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids,
labelNames: set.EMPTY_SET,
})
}
},
)
const getAsset = useEventCallback(
@ -2513,9 +2658,11 @@ export default function AssetsTable(props: AssetsTableProps) {
setAsset,
}))
const columns = columnUtils
.getColumnList(user, backend.type)
.filter((column) => enabledColumns.has(column))
const columns = React.useMemo(
() =>
columnUtils.getColumnList(user, backend.type).filter((column) => enabledColumns.has(column)),
[backend.type, enabledColumns, user],
)
const headerRow = (
<tr ref={headerRowRef} className="sticky top-[1px] text-sm font-semibold">
@ -2541,20 +2688,11 @@ export default function AssetsTable(props: AssetsTableProps) {
</div>
</td>
</tr>
: displayItems.map((item, i) => {
: displayItems.map((item) => {
return (
<AssetRow
key={item.key + item.path}
updateAssetRef={(instance) => {
if (instance != null) {
updateAssetRef.current[item.item.id] = instance
} else {
// Hacky way to clear the reference to the asset on unmount.
// eventually once we pull the assets up in the tree, we can remove this.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete updateAssetRef.current[item.item.id]
}
}}
updateAssetRef={updateAssetRef}
isOpened={openedProjects.some(({ id }) => item.item.id === id)}
columns={columns}
item={item}
@ -2563,156 +2701,14 @@ export default function AssetsTable(props: AssetsTableProps) {
isKeyboardSelected={
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
}
grabKeyboardFocus={() => {
setSelectedKeys(new Set([item.key]))
setMostRecentlySelectedIndex(i, true)
}}
grabKeyboardFocus={grabRowKeyboardFocus}
onClick={onRowClick}
select={() => {
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
selectionStartIndexRef.current = null
setSelectedKeys(new Set([item.key]))
}}
onDragStart={(event) => {
startAutoScroll()
onMouseEvent(event)
event.dataTransfer.effectAllowed = 'move'
let newSelectedKeys = driveStore.getState().selectedKeys
if (!newSelectedKeys.has(item.key)) {
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
selectionStartIndexRef.current = null
newSelectedKeys = new Set([item.key])
setSelectedKeys(newSelectedKeys)
}
const nodes = assetTree
.preorderTraversal()
.filter((node) => newSelectedKeys.has(node.key))
const payload: drag.AssetRowsDragPayload = nodes.map((node) => ({
key: node.key,
asset: node.item,
}))
event.dataTransfer.setData(
mimeTypes.ASSETS_MIME_TYPE,
JSON.stringify(nodes.map((node) => node.key)),
)
drag.setDragImageToBlank(event)
drag.ASSET_ROWS.bind(event, payload)
setModal(
<DragModal
event={event}
className="flex flex-col rounded-default bg-selected-frame backdrop-blur-default"
onDragEnd={() => {
drag.ASSET_ROWS.unbind(payload)
}}
>
{nodes.map((node) => (
<NameColumn
key={node.key}
isOpened={false}
keyProp={node.key}
item={node.with({ depth: 0 })}
backendType={backend.type}
state={state}
// Default states.
isSoleSelected={false}
selected={false}
rowState={assetRowUtils.INITIAL_ROW_STATE}
// The drag placeholder cannot be interacted with.
setSelected={() => {}}
setItem={() => {}}
setRowState={() => {}}
isEditable={false}
/>
))}
</DragModal>,
)
}}
onDragOver={(event) => {
onMouseEvent(event)
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
event.stopPropagation()
const { selectedKeys } = driveStore.getState()
const idsReference = selectedKeys.has(item.key) ? selectedKeys : item.key
// This optimization is required in order to avoid severe lag on Firefox.
if (idsReference !== lastSelectedIdsRef.current) {
lastSelectedIdsRef.current = idsReference
const ids =
typeof idsReference === 'string' ? new Set([idsReference]) : idsReference
let labelsPresent = 0
for (const selectedKey of ids) {
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
if (nodeLabels != null) {
for (const label of nodeLabels) {
if (payload.has(label)) {
labelsPresent += 1
}
}
}
}
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
window.setTimeout(() => {
dispatchAssetEvent({
type:
shouldAdd ?
AssetEventType.temporarilyAddLabels
: AssetEventType.temporarilyRemoveLabels,
ids,
labelNames: payload,
})
})
}
}
}}
onDragLeave={() => {
setIsDraggingFiles(false)
}}
onDragEnd={() => {
setIsDraggingFiles(false)
endAutoScroll()
lastSelectedIdsRef.current = null
const { selectedKeys } = driveStore.getState()
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeys,
labelNames: set.EMPTY_SET,
})
}}
onDrop={(event) => {
setIsDraggingFiles(false)
endAutoScroll()
const { selectedKeys } = driveStore.getState()
const ids = new Set(selectedKeys.has(item.key) ? selectedKeys : [item.key])
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
event.stopPropagation()
let labelsPresent = 0
for (const selectedKey of ids) {
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
if (nodeLabels != null) {
for (const label of nodeLabels) {
if (payload.has(label)) {
labelsPresent += 1
}
}
}
}
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
dispatchAssetEvent({
type: shouldAdd ? AssetEventType.addLabels : AssetEventType.removeLabels,
ids,
labelNames: payload,
})
} else {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids,
labelNames: set.EMPTY_SET,
})
}
}}
select={selectRow}
onDragStart={onRowDragStart}
onDragOver={onRowDragOver}
onDragLeave={onRowDragLeave}
onDragEnd={onRowDragEnd}
onDrop={onRowDrop}
/>
)
})

View File

@ -10,14 +10,11 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetListEventType from '#/events/AssetListEventType'
import type * as assetPanel from '#/layouts/AssetPanel'
import AssetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import type * as assetsTable from '#/layouts/AssetsTable'
import AssetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
@ -31,6 +28,7 @@ import * as result from '#/components/Result'
import * as backendModule from '#/services/Backend'
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
import AssetQuery from '#/utilities/AssetQuery'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import * as download from '#/utilities/download'
@ -56,7 +54,6 @@ export default function Drive(props: DriveProps) {
const { category, setCategory, hidden, initialProjectName, assetsManagementApiRef } = props
const { isOffline } = offlineHooks.useOffline()
const { localStorage } = localStorageProvider.useLocalStorage()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useFullUserSession()
const localBackend = backendProvider.useLocalBackend()
@ -64,21 +61,12 @@ export default function Drive(props: DriveProps) {
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
const [assetPanelPropsRaw, setAssetPanelProps] =
React.useState<assetPanel.AssetPanelRequiredProps | null>(null)
const assetPanelProps =
backend.type === assetPanelPropsRaw?.backend?.type ? assetPanelPropsRaw : null
const [isAssetPanelEnabled, setIsAssetPanelEnabled] = React.useState(
() => localStorage.get('isAssetPanelVisible') ?? false,
)
const [isAssetPanelTemporarilyVisible, setIsAssetPanelTemporarilyVisible] = React.useState(false)
const organizationQuery = useSuspenseQuery({
queryKey: [backend.type, 'getOrganization'],
queryFn: () => backend.getOrganization(),
})
const organization = organizationQuery.data ?? null
const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory')
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
const rootDirectoryId = React.useMemo(() => {
switch (category.type) {
case 'user':
@ -105,12 +93,6 @@ export default function Drive(props: DriveProps) {
: isCloud && !user.isEnabled ? 'not-enabled'
: 'ok'
const isAssetPanelVisible = isAssetPanelEnabled || isAssetPanelTemporarilyVisible
React.useEffect(() => {
localStorage.set('isAssetPanelVisible', isAssetPanelEnabled)
}, [isAssetPanelEnabled, /* should never change */ localStorage])
const doUploadFiles = React.useCallback(
(files: File[]) => {
if (isCloud && isOffline) {
@ -234,17 +216,7 @@ export default function Drive(props: DriveProps) {
backend={backend}
query={query}
setQuery={setQuery}
suggestions={suggestions}
category={category}
isAssetPanelOpen={isAssetPanelVisible}
setIsAssetPanelOpen={(valueOrUpdater) => {
const newValue =
typeof valueOrUpdater === 'function' ?
valueOrUpdater(isAssetPanelVisible)
: valueOrUpdater
setIsAssetPanelTemporarilyVisible(false)
setIsAssetPanelEnabled(newValue)
}}
doEmptyTrash={doEmptyTrash}
doCreateProject={doCreateProject}
doUploadFiles={doUploadFiles}
@ -292,31 +264,13 @@ export default function Drive(props: DriveProps) {
query={query}
setQuery={setQuery}
category={category}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}
setAssetPanelProps={setAssetPanelProps}
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
targetDirectoryNodeRef={targetDirectoryNodeRef}
/>
}
</div>
</div>
<div
className={tailwindMerge.twMerge(
'flex flex-col overflow-hidden transition-min-width duration-side-panel ease-in-out',
isAssetPanelVisible ? 'min-w-side-panel' : 'min-w',
)}
>
<AssetPanel
isVisible={isAssetPanelVisible}
key={assetPanelProps?.item?.item.id}
backend={assetPanelProps?.backend ?? null}
item={assetPanelProps?.item ?? null}
setItem={assetPanelProps?.setItem ?? null}
category={category}
isReadonly={category.type === 'trash'}
/>
</div>
<AssetPanel backendType={backend.type} category={category} />
</div>
)
}

View File

@ -16,35 +16,25 @@ import { Button, ButtonGroup, DialogTrigger, useVisualTooltip } from '#/componen
import AssetEventType from '#/events/AssetEventType'
import { useOffline } from '#/hooks/offlineHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import AssetSearchBar, { type Suggestion } from '#/layouts/AssetSearchBar'
import AssetSearchBar from '#/layouts/AssetSearchBar'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import {
isCloudCategory,
isLocalCategory,
type Category,
} from '#/layouts/CategorySwitcher/Category'
import { isCloudCategory, type Category } from '#/layouts/CategorySwitcher/Category'
import StartModal from '#/layouts/StartModal'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useCanDownload, useTargetDirectory } from '#/providers/DriveProvider'
import {
useCanCreateAssets,
useCanDownload,
useIsAssetPanelVisible,
useSetIsAssetPanelPermanentlyVisible,
} from '#/providers/DriveProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import {
Plan,
ProjectState,
type CreatedProject,
type Project,
type ProjectId,
} from '#/services/Backend'
import { ProjectState, type CreatedProject, type Project, type ProjectId } from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery'
import {
canPermissionModifyDirectoryContents,
tryFindSelfPermission,
} from '#/utilities/permissions'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
// ================
@ -56,10 +46,7 @@ export interface DriveBarProps {
readonly backend: Backend
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly suggestions: readonly Suggestion[]
readonly category: Category
readonly isAssetPanelOpen: boolean
readonly setIsAssetPanelOpen: React.Dispatch<React.SetStateAction<boolean>>
readonly doEmptyTrash: () => void
readonly doCreateProject: (
templateId?: string | null,
@ -76,29 +63,21 @@ export interface DriveBarProps {
/** Displays the current directory path and permissions, upload and download buttons,
* and a column display mode switcher. */
export default function DriveBar(props: DriveBarProps) {
const { backend, query, setQuery, suggestions, category } = props
const { backend, query, setQuery, category } = props
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
const { isAssetPanelOpen, setIsAssetPanelOpen } = props
const { unsetModal } = useSetModal()
const { getText } = useText()
const { user } = useFullUserSession()
const inputBindings = useInputBindings()
const dispatchAssetEvent = useDispatchAssetEvent()
const targetDirectory = useTargetDirectory()
const canCreateAssets = useCanCreateAssets()
const isAssetPanelVisible = useIsAssetPanelVisible()
const setIsAssetPanelPermanentlyVisible = useSetIsAssetPanelPermanentlyVisible()
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const isCloud = isCloudCategory(category)
const { isOffline } = useOffline()
const canDownload = useCanDownload()
const targetDirectorySelfPermission =
targetDirectory == null ? null : tryFindSelfPermission(user, targetDirectory.item.permissions)
const canCreateAssets =
targetDirectory == null ?
category.type !== 'cloud' || user.plan == null || user.plan === Plan.solo
: isLocalCategory(category) ||
(targetDirectorySelfPermission != null &&
canPermissionModifyDirectoryContents(targetDirectorySelfPermission.permission))
const shouldBeDisabled = (isCloud && isOffline) || !canCreateAssets
const error =
!shouldBeDisabled ? null
@ -163,28 +142,22 @@ export default function DriveBar(props: DriveBarProps) {
}, [isFetching])
const searchBar = (
<AssetSearchBar
backend={backend}
isCloud={isCloud}
query={query}
setQuery={setQuery}
suggestions={suggestions}
/>
<AssetSearchBar backend={backend} isCloud={isCloud} query={query} setQuery={setQuery} />
)
const assetPanelToggle = (
<>
{/* Spacing. */}
<div className={!isAssetPanelOpen ? 'w-5' : 'hidden'} />
<div className={!isAssetPanelVisible ? 'w-5' : 'hidden'} />
<div className="absolute right-[15px] top-[27px] z-1">
<Button
size="medium"
variant="custom"
isActive={isAssetPanelOpen}
isActive={isAssetPanelVisible}
icon={RightPanelIcon}
aria-label={isAssetPanelOpen ? getText('openAssetPanel') : getText('closeAssetPanel')}
aria-label={isAssetPanelVisible ? getText('openAssetPanel') : getText('closeAssetPanel')}
onPress={() => {
setIsAssetPanelOpen((isOpen) => !isOpen)
setIsAssetPanelPermanentlyVisible(!isAssetPanelVisible)
}}
/>
</div>

View File

@ -4,8 +4,11 @@ import * as React from 'react'
import invariant from 'tiny-invariant'
import * as zustand from 'zustand'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type { AssetId, DirectoryAsset } from 'enso-common/src/services/Backend'
import type { AssetPanelContextProps } from '#/layouts/AssetPanel'
import type { Suggestion } from '#/layouts/AssetSearchBar'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import type { AssetId } from 'enso-common/src/services/Backend'
import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array'
// ==================
// === DriveStore ===
@ -13,14 +16,22 @@ import type { AssetId, DirectoryAsset } from 'enso-common/src/services/Backend'
/** The state of this zustand store. */
interface DriveStore {
readonly targetDirectory: AssetTreeNode<DirectoryAsset> | null
readonly setTargetDirectory: (targetDirectory: AssetTreeNode<DirectoryAsset> | null) => void
readonly canCreateAssets: boolean
readonly setCanCreateAssets: (canCreateAssets: boolean) => void
readonly canDownload: boolean
readonly setCanDownload: (canDownload: boolean) => void
readonly selectedKeys: ReadonlySet<AssetId>
readonly setSelectedKeys: (selectedKeys: ReadonlySet<AssetId>) => void
readonly visuallySelectedKeys: ReadonlySet<AssetId> | null
readonly setVisuallySelectedKeys: (visuallySelectedKeys: ReadonlySet<AssetId> | null) => void
readonly isAssetPanelPermanentlyVisible: boolean
readonly setIsAssetPanelPermanentlyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
readonly isAssetPanelTemporarilyVisible: boolean
readonly setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
readonly assetPanelProps: AssetPanelContextProps | null
readonly setAssetPanelProps: (assetPanelProps: AssetPanelContextProps | null) => void
readonly suggestions: readonly Suggestion[]
readonly setSuggestions: (suggestions: readonly Suggestion[]) => void
}
// =======================
@ -43,34 +54,73 @@ export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren>
* containing the current element is focused. */
export default function DriveProvider(props: ProjectsProviderProps) {
const { children } = props
const [store] = React.useState(() => {
return zustand.createStore<DriveStore>((set) => ({
targetDirectory: null,
setTargetDirectory: (targetDirectory) => {
set({ targetDirectory })
const { localStorage } = useLocalStorage()
const [store] = React.useState(() =>
zustand.createStore<DriveStore>((set, get) => ({
canCreateAssets: true,
setCanCreateAssets: (canCreateAssets) => {
if (get().canCreateAssets !== canCreateAssets) {
set({ canCreateAssets })
}
},
canDownload: false,
setCanDownload: (canDownload) => {
set({ canDownload })
if (get().canDownload !== canDownload) {
set({ canDownload })
}
},
selectedKeys: new Set(),
setSelectedKeys: (selectedKeys) => {
set({ selectedKeys })
if (
get().selectedKeys !== selectedKeys &&
(selectedKeys.size !== 0 || get().selectedKeys.size !== 0)
) {
set({ selectedKeys })
}
},
visuallySelectedKeys: null,
setVisuallySelectedKeys: (visuallySelectedKeys) => {
set({ visuallySelectedKeys })
if (
get().visuallySelectedKeys !== visuallySelectedKeys &&
(visuallySelectedKeys?.size !== 0 || get().visuallySelectedKeys?.size !== 0)
) {
set({ visuallySelectedKeys })
}
},
}))
})
isAssetPanelPermanentlyVisible: localStorage.get('isAssetPanelVisible') ?? false,
setIsAssetPanelPermanentlyVisible: (isAssetPanelPermanentlyVisible) => {
if (get().isAssetPanelPermanentlyVisible !== isAssetPanelPermanentlyVisible) {
set({ isAssetPanelPermanentlyVisible })
localStorage.set('isAssetPanelVisible', isAssetPanelPermanentlyVisible)
}
},
isAssetPanelTemporarilyVisible: false,
setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible) => {
if (get().isAssetPanelTemporarilyVisible !== isAssetPanelTemporarilyVisible) {
set({ isAssetPanelTemporarilyVisible })
}
},
assetPanelProps: null,
setAssetPanelProps: (assetPanelProps) => {
if (get().assetPanelProps !== assetPanelProps) {
set({ assetPanelProps })
}
},
suggestions: EMPTY_ARRAY,
setSuggestions: (suggestions) => {
if (
get().suggestions !== suggestions &&
(suggestions.length !== 0 || get().suggestions.length !== 0)
) {
set({ suggestions })
}
},
})),
)
return <DriveContext.Provider value={store}>{children}</DriveContext.Provider>
}
// =====================
// === useDriveStore ===
// =====================
/** The drive store. */
export function useDriveStore() {
const store = React.useContext(DriveContext)
@ -80,82 +130,105 @@ export function useDriveStore() {
return store
}
// ==========================
// === useTargetDirectory ===
// ==========================
/** A function to get the target directory of the Asset Table selection. */
export function useTargetDirectory() {
/** Whether assets can be created in the current directory. */
export function useCanCreateAssets() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.targetDirectory)
return zustand.useStore(store, (state) => state.canCreateAssets)
}
// =============================
// === useSetTargetDirectory ===
// =============================
/** A function to set the target directory of the Asset Table selection. */
export function useSetTargetDirectory() {
/** A function to set whether assets can be created in the current directory. */
export function useSetCanCreateAssets() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setTargetDirectory)
return zustand.useStore(store, (state) => state.setCanCreateAssets)
}
// ======================
// === useCanDownload ===
// ======================
/** Whether the current Asset Table selection is downloadble. */
export function useCanDownload() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.canDownload)
}
// =========================
// === useSetCanDownload ===
// =========================
/** A function to set whether the current Asset Table selection is downloadble. */
export function useSetCanDownload() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setCanDownload)
}
// =======================
// === useSelectedKeys ===
// =======================
/** The selected keys in the Asset Table. */
export function useSelectedKeys() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.selectedKeys)
}
// ==========================
// === useSetSelectedKeys ===
// ==========================
/** A function to set the selected keys of the Asset Table selection. */
export function useSetSelectedKeys() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setSelectedKeys)
}
// ===============================
// === useVisuallySelectedKeys ===
// ===============================
/** The visually selected keys in the Asset Table. */
export function useVisuallySelectedKeys() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.selectedKeys)
}
// ==================================
// === useSetVisuallySelectedKeys ===
// ==================================
/** A function to set the visually selected keys in the Asset Table. */
export function useSetVisuallySelectedKeys() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setVisuallySelectedKeys)
}
/** Whether the Asset Panel is toggled on. */
export function useIsAssetPanelPermanentlyVisible() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.isAssetPanelPermanentlyVisible)
}
/** A function to set whether the Asset Panel is toggled on. */
export function useSetIsAssetPanelPermanentlyVisible() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setIsAssetPanelPermanentlyVisible)
}
/** Whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
export function useIsAssetPanelTemporarilyVisible() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.isAssetPanelTemporarilyVisible)
}
/** A function to set whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
export function useSetIsAssetPanelTemporarilyVisible() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setIsAssetPanelTemporarilyVisible)
}
/** Whether the Asset Panel is currently visible, either temporarily or permanently. */
export function useIsAssetPanelVisible() {
const isAssetPanelPermanentlyVisible = useIsAssetPanelPermanentlyVisible()
const isAssetPanelTemporarilyVisible = useIsAssetPanelTemporarilyVisible()
return isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible
}
/** Props for the Asset Panel. */
export function useAssetPanelProps() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.assetPanelProps)
}
/** A function to set props for the Asset Panel. */
export function useSetAssetPanelProps() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setAssetPanelProps)
}
/** Search suggestions. */
export function useSuggestions() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.suggestions)
}
/** Set search suggestions. */
export function useSetSuggestions() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setSuggestions)
}