Move selected rows state of Data Catalog to zustand store (#10637)

- Eliminates lag when using drag-to-select (the `SelectionBrush`) by moving the state into a zustand store.
- This avoids the lag because now the entire Data Catalog no longer has to rerender, because the state is no longer stored in the `AssetsTable` component that contains all the rows (and would therefore rerender all the rows when its state changes)

# Important Notes
- The lag is present on Chromium, but any lag in general is generally more visible on Firefox, so it's highly recommended to test on Firefox as well as Electron
- On current develop, *any* drag selection should be enough to trigger the lag (typically 200ms JS + 200ms rendering). If it's not reproducible, then you may need to create more assets.
This commit is contained in:
somebody1234 2024-08-01 17:58:15 +10:00 committed by GitHub
parent 42ba5ee8a2
commit 636d0d11bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 315 additions and 173 deletions

View File

@ -49,6 +49,7 @@ import * as backendHooks from '#/hooks/backendHooks'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider, { useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider'
import DriveProvider from '#/providers/DriveProvider'
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
import * as httpClientProvider from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
@ -549,6 +550,9 @@ function AppRouter(props: AppRouterProps) {
{result}
</rootComponent.Root>
)
// Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
// due to modals being in `TheModal`.
result = <DriveProvider>{result}</DriveProvider>
result = (
<offlineNotificationManager.OfflineNotificationManager>
{result}

View File

@ -1,14 +1,18 @@
/** @file A table row for an arbitrary asset. */
import * as React from 'react'
import { useStore } from 'zustand'
import BlankIcon from '#/assets/blank.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -34,7 +38,6 @@ import * as localBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import AssetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
import * as drag from '#/utilities/drag'
@ -79,29 +82,35 @@ export interface AssetRowProps
readonly state: assetsTable.AssetsTableState
readonly hidden: boolean
readonly columns: columnUtils.Column[]
readonly selected: boolean
readonly setSelected: (selected: boolean) => void
readonly isSoleSelected: boolean
readonly isKeyboardSelected: boolean
readonly grabKeyboardFocus: () => void
readonly allowContextMenu: boolean
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
readonly onContextMenu?: (
props: AssetRowInnerProps,
event: React.MouseEvent<HTMLTableRowElement>,
) => void
readonly select: () => void
readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void>
}
/** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) {
const { selected, isSoleSelected, isKeyboardSelected, isOpened } = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
const { visibilities } = state
const [item, setItem] = React.useState(rawItem)
const driveStore = useDriveStore()
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 draggableProps = dragAndDropHooks.useDraggable()
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
@ -110,7 +119,6 @@ export default function AssetRow(props: AssetRowProps) {
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const [item, setItem] = React.useState(rawItem)
const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
const grabKeyboardFocusRef = React.useRef(grabKeyboardFocus)
@ -120,9 +128,8 @@ export default function AssetRow(props: AssetRowProps) {
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }),
)
const key = AssetTreeNode.getKey(item)
const isCloud = backend.type === backendModule.BackendType.remote
const outerVisibility = visibilities.get(key)
const outerVisibility = visibilities.get(item.key)
const visibility =
outerVisibility == null || outerVisibility === Visibility.visible ?
insertionVisibility
@ -147,6 +154,11 @@ export default function AssetRow(props: AssetRowProps) {
const openProjectMutate = openProjectMutation.mutateAsync
const closeProjectMutate = closeProjectMutation.mutateAsync
const setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState()
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
})
React.useEffect(() => {
setItem(rawItem)
}, [rawItem])
@ -570,30 +582,30 @@ export default function AssetRow(props: AssetRowProps) {
break
}
case AssetEventType.temporarilyAddLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
setRowState((oldRowState) =>
(
oldRowState.temporarilyAddedLabels === labels &&
oldRowState.temporarilyRemovedLabels === set.EMPTY
oldRowState.temporarilyRemovedLabels === set.EMPTY_SET
) ?
oldRowState
: object.merge(oldRowState, {
temporarilyAddedLabels: labels,
temporarilyRemovedLabels: set.EMPTY,
temporarilyRemovedLabels: set.EMPTY_SET,
}),
)
break
}
case AssetEventType.temporarilyRemoveLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
setRowState((oldRowState) =>
(
oldRowState.temporarilyAddedLabels === set.EMPTY &&
oldRowState.temporarilyAddedLabels === set.EMPTY_SET &&
oldRowState.temporarilyRemovedLabels === labels
) ?
oldRowState
: object.merge(oldRowState, {
temporarilyAddedLabels: set.EMPTY,
temporarilyAddedLabels: set.EMPTY_SET,
temporarilyRemovedLabels: labels,
}),
)
@ -601,9 +613,9 @@ export default function AssetRow(props: AssetRowProps) {
}
case AssetEventType.addLabels: {
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY ?
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY }),
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
const labels = asset.labels
if (
@ -626,9 +638,9 @@ export default function AssetRow(props: AssetRowProps) {
}
case AssetEventType.removeLabels: {
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY ?
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY }),
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
const labels = asset.labels
if (
@ -677,9 +689,9 @@ export default function AssetRow(props: AssetRowProps) {
const clearDragState = React.useCallback(() => {
setIsDraggedOver(false)
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY ?
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY }),
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
}, [])
@ -707,7 +719,14 @@ export default function AssetRow(props: AssetRowProps) {
case backendModule.AssetType.file:
case backendModule.AssetType.datalink:
case backendModule.AssetType.secret: {
const innerProps: AssetRowInnerProps = { key, item, setItem, state, rowState, setRowState }
const innerProps: AssetRowInnerProps = {
key: item.key,
item,
setItem,
state,
rowState,
setRowState,
}
return (
<>
{!hidden && (
@ -758,7 +777,9 @@ export default function AssetRow(props: AssetRowProps) {
if (allowContextMenu) {
event.preventDefault()
event.stopPropagation()
onContextMenu?.(innerProps, event)
if (!selected) {
select()
}
setModal(
<AssetContextMenu
innerProps={innerProps}
@ -774,8 +795,6 @@ export default function AssetRow(props: AssetRowProps) {
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
/>,
)
} else {
onContextMenu?.(innerProps, event)
}
}}
onDragStart={(event) => {
@ -872,7 +891,7 @@ export default function AssetRow(props: AssetRowProps) {
return (
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
<Render
keyProp={key}
keyProp={item.key}
isOpened={isOpened}
backendType={backend.type}
item={item}
@ -898,7 +917,7 @@ export default function AssetRow(props: AssetRowProps) {
<AssetContextMenu
hidden
innerProps={{
key,
key: item.key,
item,
setItem,
state,

View File

@ -9,6 +9,6 @@ export const INITIAL_ROW_STATE: assetsTable.AssetRowState = Object.freeze({
// Ignored. This MUST be replaced by the row component. It should also update `visibility`.
},
isEditingName: false,
temporarilyAddedLabels: set.EMPTY,
temporarilyRemovedLabels: set.EMPTY,
temporarilyAddedLabels: set.EMPTY_SET,
temporarilyRemovedLabels: set.EMPTY_SET,
})

View File

@ -8,6 +8,7 @@ import * as backendHooks from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import { useDriveStore } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
@ -43,11 +44,12 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, selectedKeys, nodeMap } = state
const { backend, nodeMap } = state
const { doToggleDirectoryExpansion } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const driveStore = useDriveStore()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.directory) {
// eslint-disable-next-line no-restricted-syntax
@ -165,7 +167,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
} else if (
eventModule.isSingleClick(event) &&
selected &&
selectedKeys.current.size === 1
driveStore.getState().selectedKeys.size === 1
) {
event.stopPropagation()
setIsEditing(true)

View File

@ -11,6 +11,7 @@ import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import { useDriveStore } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
@ -59,13 +60,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
backendType,
isOpened,
} = props
const { backend, selectedKeys, nodeMap } = state
const { backend, nodeMap } = state
const client = reactQuery.useQueryClient()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const driveStore = useDriveStore()
const doOpenProject = projectHooks.useOpenProject()
if (item.type !== backendModule.AssetType.project) {
@ -321,7 +323,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
!isRunning &&
eventModule.isSingleClick(event) &&
selected &&
selectedKeys.current.size === 1
driveStore.getState().selectedKeys.size === 1
) {
setIsEditing(true)
} else if (eventModule.isDoubleClick(event)) {

View File

@ -16,6 +16,12 @@ import useOnScroll from '#/hooks/useOnScroll'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import {
useDriveStore,
useSetCanDownload,
useSetSelectedKeys,
useSetVisuallySelectedKeys,
} from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
@ -310,7 +316,6 @@ const CATEGORY_TO_FILTER_BY: Readonly<Record<Category, backendModule.FilterBy |
export interface AssetsTableState {
readonly backend: Backend
readonly rootDirectoryId: backendModule.DirectoryId
readonly selectedKeys: React.MutableRefObject<ReadonlySet<backendModule.AssetId>>
readonly scrollContainerRef: React.RefObject<HTMLElement>
readonly visibilities: ReadonlyMap<backendModule.AssetId, Visibility>
readonly category: Category
@ -356,7 +361,6 @@ export interface AssetsTableProps {
readonly setSuggestions: React.Dispatch<
React.SetStateAction<readonly assetSearchBar.Suggestion[]>
>
readonly setCanDownload: (canDownload: boolean) => void
readonly category: Category
readonly initialProjectName: string | null
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
@ -375,12 +379,13 @@ export interface AssetManagementApi {
/** The table of project assets. */
export default function AssetsTable(props: AssetsTableProps) {
const { hidden, query, setQuery, setCanDownload, category, assetManagementApiRef } = props
const { hidden, query, setQuery, category, assetManagementApiRef } = props
const { setSuggestions, initialProjectName } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const openedProjects = projectsProvider.useLaunchedProjects()
const doOpenProject = projectHooks.useOpenProject()
const setCanDownload = useSetCanDownload()
const { user } = authProvider.useNonPartialUserSession()
const backend = backendProvider.useBackend(category)
@ -400,10 +405,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
const [sortInfo, setSortInfo] =
React.useState<sorting.SortInfo<columnUtils.SortableColumn> | null>(null)
const [selectedKeys, setSelectedKeysRaw] = React.useState<ReadonlySet<backendModule.AssetId>>(
() => new Set(),
)
const selectedKeysRef = React.useRef(selectedKeys)
const driveStore = useDriveStore()
const setSelectedKeys = useSetSelectedKeys()
const setVisuallySelectedKeys = useSetVisuallySelectedKeys()
const updateAssetRef = React.useRef<
Record<backendModule.AnyAsset['id'], (asset: backendModule.AnyAsset) => void>
>({})
@ -637,6 +641,7 @@ export default function AssetsTable(props: AssetsTableProps) {
})
React.useEffect(() => {
const { selectedKeys } = driveStore.getState()
if (selectedKeys.size === 0) {
targetDirectoryNodeRef.current = null
} else if (selectedKeys.size === 1) {
@ -676,7 +681,7 @@ export default function AssetsTable(props: AssetsTableProps) {
targetDirectoryNodeRef.current = node
}
}
}, [targetDirectoryNodeRef, selectedKeys])
}, [driveStore, targetDirectoryNodeRef])
React.useEffect(() => {
const nodeToSuggestion = (
@ -881,39 +886,37 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [hidden, inputBindings, dispatchAssetEvent])
const setSelectedKeys = React.useCallback(
(newSelectedKeys: ReadonlySet<backendModule.AssetId>) => {
selectedKeysRef.current = newSelectedKeys
setSelectedKeysRaw(newSelectedKeys)
if (!isCloud) {
setCanDownload(
newSelectedKeys.size !== 0 &&
Array.from(newSelectedKeys).every((key) => {
React.useEffect(
() =>
driveStore.subscribe(({ selectedKeys }) => {
let newCanDownload: boolean
if (!isCloud) {
newCanDownload =
selectedKeys.size !== 0 &&
Array.from(selectedKeys).every((key) => {
const node = nodeMapRef.current.get(key)
return node?.item.type === backendModule.AssetType.project
}),
)
} else {
setCanDownload(
newSelectedKeys.size !== 0 &&
Array.from(newSelectedKeys).every((key) => {
})
} else {
newCanDownload =
selectedKeys.size !== 0 &&
Array.from(selectedKeys).every((key) => {
const node = nodeMapRef.current.get(key)
return (
node?.item.type === backendModule.AssetType.project ||
node?.item.type === backendModule.AssetType.file ||
node?.item.type === backendModule.AssetType.datalink
)
}),
)
}
},
[isCloud, setCanDownload],
})
}
const currentCanDownload = driveStore.getState().canDownload
if (currentCanDownload !== newCanDownload) {
setCanDownload(newCanDownload)
}
}),
[driveStore, isCloud, setCanDownload],
)
const clearSelectedKeys = React.useCallback(() => {
setSelectedKeys(new Set())
}, [setSelectedKeys])
const overwriteNodes = React.useCallback(
(newAssets: backendModule.AnyAsset[]) => {
setInitialized(true)
@ -1050,12 +1053,16 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [enabledColumns, initialized, localStorage])
React.useEffect(() => {
if (selectedKeysRef.current.size !== 1) {
setAssetPanelProps(null)
setIsAssetPanelTemporarilyVisible(false)
}
}, [selectedKeysRef.current.size, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
React.useEffect(
() =>
driveStore.subscribe(({ selectedKeys }) => {
if (selectedKeys.size !== 1) {
setAssetPanelProps(null)
setIsAssetPanelTemporarilyVisible(false)
}
}),
[driveStore, setAssetPanelProps, setIsAssetPanelTemporarilyVisible],
)
const directoryListAbortControllersRef = React.useRef(
new Map<backendModule.DirectoryId, AbortController>(),
@ -1211,14 +1218,15 @@ 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 onKeyDown = (event: React.KeyboardEvent) => {
const { selectedKeys } = driveStore.getState()
const prevIndex = mostRecentlySelectedIndexRef.current
const item = prevIndex == null ? null : visibleItems[prevIndex]
if (selectedKeysRef.current.size === 1 && item != null) {
if (selectedKeys.size === 1 && item != null) {
switch (event.key) {
case 'Enter':
case ' ': {
if (event.key === ' ' && event.ctrlKey) {
const keys = selectedKeysRef.current
const keys = selectedKeys
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
} else {
switch (item.type) {
@ -1311,7 +1319,7 @@ export default function AssetsTable(props: AssetsTableProps) {
switch (event.key) {
case ' ': {
if (event.ctrlKey && item != null) {
const keys = selectedKeysRef.current
const keys = selectedKeys
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
}
break
@ -1754,8 +1762,9 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case AssetListEventType.willDelete: {
if (selectedKeysRef.current.has(event.key)) {
const newSelectedKeys = new Set(selectedKeysRef.current)
const { selectedKeys } = driveStore.getState()
if (selectedKeys.has(event.key)) {
const newSelectedKeys = new Set(selectedKeys)
newSelectedKeys.delete(event.key)
setSelectedKeys(newSelectedKeys)
}
@ -1828,18 +1837,20 @@ export default function AssetsTable(props: AssetsTableProps) {
const doCopy = React.useCallback(() => {
unsetModal()
setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })
}, [unsetModal])
const { selectedKeys } = driveStore.getState()
setPasteData({ type: PasteType.copy, data: selectedKeys })
}, [driveStore, unsetModal])
const doCut = React.useCallback(() => {
unsetModal()
if (pasteData != null) {
dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data })
}
setPasteData({ type: PasteType.move, data: selectedKeysRef.current })
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeysRef.current })
const { selectedKeys } = driveStore.getState()
setPasteData({ type: PasteType.move, data: selectedKeys })
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys })
setSelectedKeys(new Set())
}, [pasteData, setSelectedKeys, unsetModal, dispatchAssetEvent])
}, [unsetModal, pasteData, driveStore, dispatchAssetEvent, setSelectedKeys])
const doPaste = React.useCallback(
(newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => {
@ -1885,8 +1896,6 @@ export default function AssetsTable(props: AssetsTableProps) {
backend={backend}
category={category}
pasteData={pasteData}
selectedKeys={selectedKeys}
clearSelectedKeys={clearSelectedKeys}
nodeMapRef={nodeMapRef}
rootDirectoryId={rootDirectoryId}
event={{ pageX: 0, pageY: 0 }}
@ -1895,17 +1904,7 @@ export default function AssetsTable(props: AssetsTableProps) {
doPaste={doPaste}
/>
),
[
backend,
rootDirectoryId,
category,
selectedKeys,
pasteData,
doCopy,
doCut,
doPaste,
clearSelectedKeys,
],
[backend, rootDirectoryId, category, pasteData, doCopy, doCut, doPaste],
)
const onDropzoneDragOver = (event: React.DragEvent<Element>) => {
@ -1944,7 +1943,6 @@ export default function AssetsTable(props: AssetsTableProps) {
backend,
rootDirectoryId,
visibilities,
selectedKeys: selectedKeysRef,
scrollContainerRef: rootRef,
category,
hasPasteData: pasteData != null,
@ -2014,15 +2012,16 @@ export default function AssetsTable(props: AssetsTableProps) {
selectAdditional: () => {},
selectAdditionalRange: () => {},
[inputBindingsModule.DEFAULT_HANDLER]: () => {
if (selectedKeysRef.current.size !== 0) {
setSelectedKeys(new Set())
const { selectedKeys } = driveStore.getState()
if (selectedKeys.size !== 0) {
setSelectedKeys(set.EMPTY_SET)
setMostRecentlySelectedIndex(null)
}
},
},
false,
),
[setSelectedKeys, inputBindings, setMostRecentlySelectedIndex],
[setSelectedKeys, inputBindings, setMostRecentlySelectedIndex, driveStore],
)
React.useEffect(() => {
@ -2057,13 +2056,15 @@ export default function AssetsTable(props: AssetsTableProps) {
result = new Set(getRange())
},
selectAdditionalRange: () => {
result = new Set([...selectedKeysRef.current, ...getRange()])
const { selectedKeys } = driveStore.getState()
result = new Set([...selectedKeys, ...getRange()])
},
selectAdditional: () => {
const newSelectedKeys = new Set(selectedKeysRef.current)
const { selectedKeys } = driveStore.getState()
const newSelectedKeys = new Set(selectedKeys)
let count = 0
for (const key of keys) {
if (selectedKeysRef.current.has(key)) {
if (selectedKeys.has(key)) {
count += 1
}
}
@ -2079,13 +2080,9 @@ export default function AssetsTable(props: AssetsTableProps) {
})(event, false)
return result
},
[inputBindings],
[driveStore, inputBindings],
)
// Only non-`null` when it is different to`selectedKeys`.
const [visuallySelectedKeysOverride, setVisuallySelectedKeysOverride] =
React.useState<ReadonlySet<backendModule.AssetId> | null>(null)
const { startAutoScroll, endAutoScroll, onMouseEvent } = autoScrollHooks.useAutoScroll(rootRef)
const dragSelectionChangeLoopHandle = React.useRef(0)
@ -2129,14 +2126,14 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
if (range == null) {
setVisuallySelectedKeysOverride(null)
setVisuallySelectedKeys(null)
} else {
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
setVisuallySelectedKeysOverride(calculateNewKeys(event, keys, () => []))
setVisuallySelectedKeys(calculateNewKeys(event, keys, () => []))
}
}
},
[startAutoScroll, onMouseEvent, displayItems, calculateNewKeys],
[startAutoScroll, onMouseEvent, setVisuallySelectedKeys, displayItems, calculateNewKeys],
)
const onSelectionDragEnd = React.useCallback(
@ -2148,24 +2145,29 @@ export default function AssetsTable(props: AssetsTableProps) {
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
setSelectedKeys(calculateNewKeys(event, keys, () => []))
}
setVisuallySelectedKeysOverride(null)
setVisuallySelectedKeys(null)
dragSelectionRangeRef.current = null
},
[endAutoScroll, onMouseEvent, displayItems, setSelectedKeys, calculateNewKeys],
[
endAutoScroll,
onMouseEvent,
setVisuallySelectedKeys,
displayItems,
setSelectedKeys,
calculateNewKeys,
],
)
const onSelectionDragCancel = React.useCallback(() => {
setVisuallySelectedKeysOverride(null)
setVisuallySelectedKeys(null)
dragSelectionRangeRef.current = null
}, [])
}, [setVisuallySelectedKeys])
const onRowClick = React.useCallback(
(innerRowProps: assetRow.AssetRowInnerProps, event: React.MouseEvent) => {
const { key } = innerRowProps
event.stopPropagation()
const newIndex = visibleItems.findIndex(
(innerItem) => AssetTreeNode.getKey(innerItem) === key,
)
const newIndex = visibleItems.findIndex((innerItem) => innerItem.key === key)
const getRange = () => {
if (mostRecentlySelectedIndexRef.current == null) {
return [key]
@ -2174,7 +2176,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const index2 = newIndex
const startIndex = Math.min(index1, index2)
const endIndex = Math.max(index1, index2) + 1
return visibleItems.slice(startIndex, endIndex).map(AssetTreeNode.getKey)
return visibleItems.slice(startIndex, endIndex).map((innerItem) => innerItem.key)
}
}
setSelectedKeys(calculateNewKeys(event, [key], getRange))
@ -2233,13 +2235,9 @@ export default function AssetsTable(props: AssetsTableProps) {
</td>
</tr>
: displayItems.map((item, i) => {
const key = AssetTreeNode.getKey(item)
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
const isSoleSelected = isSelected && selectedKeys.size === 1
return (
<AssetRow
key={key}
key={item.key}
updateAssetRef={(instance) => {
if (instance != null) {
updateAssetRef.current[item.item.id] = instance
@ -2255,37 +2253,27 @@ export default function AssetsTable(props: AssetsTableProps) {
item={item}
state={state}
hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
selected={isSelected}
setSelected={(selected) => {
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
}}
isSoleSelected={isSoleSelected}
isKeyboardSelected={
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
}
grabKeyboardFocus={() => {
setSelectedKeys(new Set([key]))
setSelectedKeys(new Set([item.key]))
setMostRecentlySelectedIndex(i, true)
}}
allowContextMenu={selectedKeysRef.current.size === 0 || !isSelected || isSoleSelected}
onClick={onRowClick}
onContextMenu={(_innerProps, event) => {
if (!isSelected) {
event.preventDefault()
event.stopPropagation()
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
selectionStartIndexRef.current = null
setSelectedKeys(new Set([key]))
}
select={() => {
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
selectionStartIndexRef.current = null
setSelectedKeys(new Set([item.key]))
}}
onDragStart={(event) => {
startAutoScroll()
onMouseEvent(event)
let newSelectedKeys = selectedKeysRef.current
if (!newSelectedKeys.has(key)) {
let newSelectedKeys = driveStore.getState().selectedKeys
if (!newSelectedKeys.has(item.key)) {
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
selectionStartIndexRef.current = null
newSelectedKeys = new Set([key])
newSelectedKeys = new Set([item.key])
setSelectedKeys(newSelectedKeys)
}
const nodes = assetTree
@ -2337,8 +2325,8 @@ export default function AssetsTable(props: AssetsTableProps) {
if (payload != null) {
event.preventDefault()
event.stopPropagation()
const idsReference =
selectedKeysRef.current.has(key) ? selectedKeysRef.current : key
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
@ -2372,17 +2360,17 @@ export default function AssetsTable(props: AssetsTableProps) {
onDragEnd={() => {
endAutoScroll()
lastSelectedIdsRef.current = null
const { selectedKeys } = driveStore.getState()
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeysRef.current,
labelNames: set.EMPTY,
ids: selectedKeys,
labelNames: set.EMPTY_SET,
})
}}
onDrop={(event) => {
endAutoScroll()
const ids = new Set(
selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key],
)
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()
@ -2408,7 +2396,7 @@ export default function AssetsTable(props: AssetsTableProps) {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids,
labelNames: set.EMPTY,
labelNames: set.EMPTY_SET,
})
}
}}
@ -2434,8 +2422,6 @@ export default function AssetsTable(props: AssetsTableProps) {
backend={backend}
category={category}
pasteData={pasteData}
selectedKeys={selectedKeys}
clearSelectedKeys={clearSelectedKeys}
nodeMapRef={nodeMapRef}
event={event}
rootDirectoryId={rootDirectoryId}
@ -2453,10 +2439,11 @@ export default function AssetsTable(props: AssetsTableProps) {
!event.currentTarget.contains(event.relatedTarget)
) {
lastSelectedIdsRef.current = null
const { selectedKeys } = driveStore.getState()
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeysRef.current,
labelNames: set.EMPTY,
ids: selectedKeys,
labelNames: set.EMPTY_SET,
})
}
}}

View File

@ -3,6 +3,7 @@
import * as React from 'react'
import * as authProvider from '#/providers/AuthProvider'
import { useSelectedKeys, useSetSelectedKeys } from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -24,6 +25,7 @@ import * as backendModule from '#/services/Backend'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import type * as pasteDataModule from '#/utilities/pasteData'
import * as permissions from '#/utilities/permissions'
import { EMPTY_SET } from '#/utilities/set'
import * as uniqueString from '#/utilities/uniqueString'
// =================
@ -37,8 +39,6 @@ export interface AssetsTableContextMenuProps {
readonly category: Category
readonly rootDirectoryId: backendModule.DirectoryId
readonly pasteData: pasteDataModule.PasteData<ReadonlySet<backendModule.AssetId>> | null
readonly selectedKeys: ReadonlySet<backendModule.AssetId>
readonly clearSelectedKeys: () => void
readonly nodeMapRef: React.MutableRefObject<
ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>
>
@ -54,7 +54,7 @@ export interface AssetsTableContextMenuProps {
/** A context menu for an `AssetsTable`, when no row is selected, or multiple rows
* are selected. */
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
const { hidden = false, backend, category, pasteData, selectedKeys, clearSelectedKeys } = props
const { hidden = false, backend, category, pasteData } = props
const { nodeMapRef, event, rootDirectoryId } = props
const { doCopy, doCut, doPaste } = props
const { user } = authProvider.useNonPartialUserSession()
@ -62,6 +62,8 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
const { getText } = textProvider.useText()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const isCloud = categoryModule.isCloud(category)
const selectedKeys = useSelectedKeys()
const setSelectedKeys = useSetSelectedKeys()
// This works because all items are mutated, ensuring their value stays
// up to date.
@ -93,7 +95,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
: getText('deleteSelectedAssetsActionText', selectedKeys.size)
}
doDelete={() => {
clearSelectedKeys()
setSelectedKeys(EMPTY_SET)
dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
}}
/>,
@ -134,7 +136,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
: getText('deleteSelectedAssetsForeverActionText', selectedKeys.size)
}
doDelete={() => {
clearSelectedKeys()
setSelectedKeys(EMPTY_SET)
dispatchAssetEvent({
type: AssetEventType.deleteForever,
ids: selectedKeys,

View File

@ -81,7 +81,6 @@ export default function Drive(props: DriveProps) {
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
const [canDownload, setCanDownload] = React.useState(false)
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
const [assetPanelPropsRaw, setAssetPanelProps] =
React.useState<assetPanel.AssetPanelRequiredProps | null>(null)
@ -262,7 +261,6 @@ export default function Drive(props: DriveProps) {
setQuery={setQuery}
suggestions={suggestions}
category={category}
canDownload={canDownload}
isAssetPanelOpen={isAssetPanelVisible}
setIsAssetPanelOpen={(valueOrUpdater) => {
const newValue =
@ -318,7 +316,6 @@ export default function Drive(props: DriveProps) {
hidden={hidden}
query={query}
setQuery={setQuery}
setCanDownload={setCanDownload}
category={category}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}

View File

@ -15,6 +15,7 @@ import RightPanelIcon from '#/assets/right_panel.svg'
import * as offlineHooks from '#/hooks/offlineHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useCanDownload } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -51,7 +52,6 @@ export interface DriveBarProps {
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly suggestions: readonly assetSearchBar.Suggestion[]
readonly category: Category
readonly canDownload: boolean
readonly isAssetPanelOpen: boolean
readonly setIsAssetPanelOpen: React.Dispatch<React.SetStateAction<boolean>>
readonly doEmptyTrash: () => void
@ -70,7 +70,7 @@ 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, canDownload } = props
const { backend, query, setQuery, suggestions, category } = props
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
const { isAssetPanelOpen, setIsAssetPanelOpen } = props
@ -81,6 +81,7 @@ export default function DriveBar(props: DriveBarProps) {
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const isCloud = categoryModule.isCloud(category)
const { isOffline } = offlineHooks.useOffline()
const canDownload = useCanDownload()
const [isCreatingProjectFromTemplate, setIsCreatingProjectFromTemplate] = React.useState(false)
const [isCreatingProject, setIsCreatingProject] = React.useState(false)
const [createdProjectId, setCreatedProjectId] = React.useState<ProjectId | null>(null)

View File

@ -0,0 +1,134 @@
/** @file The React provider (and associated hooks) for Data Catalog state. */
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as zustand from 'zustand'
import type { AssetId } from 'enso-common/src/services/Backend'
// ==================
// === DriveStore ===
// ==================
/** The state of this zustand store. */
interface DriveStore {
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
}
// =======================
// === ProjectsContext ===
// =======================
/** State contained in a `ProjectsContext`. */
export interface ProjectsContextType extends zustand.StoreApi<DriveStore> {}
const DriveContext = React.createContext<ProjectsContextType | null>(null)
/** Props for a {@link DriveProvider}. */
export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren> {}
// ========================
// === ProjectsProvider ===
// ========================
/** A React provider (and associated hooks) for determining whether the current area
* containing the current element is focused. */
export default function DriveProvider(props: ProjectsProviderProps) {
const { children } = props
const [store] = React.useState(() => {
return zustand.createStore<DriveStore>((set) => ({
canDownload: false,
setCanDownload: (canDownload) => {
set({ canDownload })
},
selectedKeys: new Set(),
setSelectedKeys: (selectedKeys) => {
set({ selectedKeys })
},
visuallySelectedKeys: null,
setVisuallySelectedKeys: (visuallySelectedKeys) => {
set({ visuallySelectedKeys })
},
}))
})
return <DriveContext.Provider value={store}>{children}</DriveContext.Provider>
}
// =====================
// === useDriveStore ===
// =====================
/** The drive store. */
export function useDriveStore() {
const store = React.useContext(DriveContext)
invariant(store, 'Drive store can only be used inside an `DriveProvider`.')
return store
}
// ======================
// === 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)
}

View File

@ -58,12 +58,6 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
this.type = item.type
}
/** Get an {@link AssetTreeNode.key} from an {@link AssetTreeNode}. Useful for React,
* becausse references of static functions do not change. */
static getKey(this: void, node: AssetTreeNode) {
return node.key
}
/** Return a positive number if `a > b`, a negative number if `a < b`, and zero if `a === b`.
* Uses {@link backendModule.compareAssets} internally. */
static compare(this: void, a: AssetTreeNode, b: AssetTreeNode) {

View File

@ -5,7 +5,7 @@
// === Constants ===
// =================
export const EMPTY: ReadonlySet<never> = new Set<never>()
export const EMPTY_SET: ReadonlySet<never> = new Set<never>()
/** Adds the value if `presence` is `true`; deletes the value if `presence` is `false`.
*