(
assetRowUtils.INITIAL_ROW_STATE,
)
+ const cutAndPaste = useCutAndPaste(category)
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
@@ -343,7 +345,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
backend,
}),
select: (data) => data.state.type,
- enabled: asset.type === backendModule.AssetType.project && !isPlaceholder,
+ enabled: asset.type === backendModule.AssetType.project && !isPlaceholder && isOpened,
})
const toastAndLog = useToastAndLog()
@@ -668,34 +670,13 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
ref={(element) => {
rootRef.current = element
- requestAnimationFrame(() => {
- if (
- isSoleSelected &&
- !wasSoleSelectedRef.current &&
- element != null &&
- scrollContainerRef.current != null
- ) {
- const rect = element.getBoundingClientRect()
- const scrollRect = scrollContainerRef.current.getBoundingClientRect()
- const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
- const scrollDown = rect.bottom - scrollRect.bottom
-
- if (scrollUp < 0 || scrollDown > 0) {
- scrollContainerRef.current.scrollBy({
- top: scrollUp < 0 ? scrollUp : scrollDown,
- behavior: 'smooth',
- })
- }
- }
- wasSoleSelectedRef.current = isSoleSelected
- })
-
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
+ element.scrollIntoView({ block: 'nearest' })
element.focus()
}
}}
className={tailwindMerge.twMerge(
- 'h-table-row rounded-full transition-all ease-in-out rounded-rows-child [contain-intrinsic-size:40px] [content-visibility:auto]',
+ 'h-table-row rounded-full transition-all ease-in-out rounded-rows-child',
visibility,
(isDraggedOver || selected) && 'selected',
)}
@@ -831,6 +812,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
{
diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx
index bc364aecba7b..97523e49a5c4 100644
--- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx
+++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx
@@ -16,6 +16,7 @@ import SvgMask from '#/components/SvgMask'
import * as backendModule from '#/services/Backend'
+import { useStore } from '#/hooks/storeHooks'
import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
@@ -39,11 +40,13 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {
*/
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, depth, selected, state, rowState, setRowState, isEditable } = props
- const { backend, nodeMap, expandedDirectoryIds } = state
+ const { backend, nodeMap } = state
const { getText } = textProvider.useText()
const driveStore = useDriveStore()
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
- const isExpanded = expandedDirectoryIds.includes(item.id)
+ const isExpanded = useStore(driveStore, (storeState) =>
+ storeState.expandedDirectoryIds.includes(item.id),
+ )
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
@@ -68,8 +71,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
return (
{
@@ -94,7 +97,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
variant="custom"
aria-label={isExpanded ? getText('collapse') : getText('expand')}
tooltipPlacement="left"
- className={tailwindMerge.twMerge(
+ className={tailwindMerge.twJoin(
'm-0 hidden cursor-pointer border-0 transition-transform duration-arrow group-hover:m-name-column-icon group-hover:inline-block',
isExpanded && 'rotate-90',
)}
@@ -107,7 +110,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
data-testid="asset-row-name"
editable={rowState.isEditingName}
className={tailwindMerge.twMerge(
- 'grow cursor-pointer bg-transparent font-naming',
+ 'cursor-pointer bg-transparent font-naming',
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer',
)}
checkSubmittable={(newTitle) =>
diff --git a/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx
index a987d3a8ead1..249c7f971f02 100644
--- a/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx
+++ b/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx
@@ -59,8 +59,8 @@ export default function FileNameColumn(props: FileNameColumnProps) {
return (
{
diff --git a/app/gui/src/dashboard/components/dashboard/Label.tsx b/app/gui/src/dashboard/components/dashboard/Label.tsx
index b224ab0d1f23..7420a08b7ec0 100644
--- a/app/gui/src/dashboard/components/dashboard/Label.tsx
+++ b/app/gui/src/dashboard/components/dashboard/Label.tsx
@@ -6,6 +6,7 @@ import { Text } from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import { useHandleFocusMove } from '#/hooks/focusHooks'
import { useFocusDirection } from '#/providers/FocusDirectionProvider'
+import type { Label as BackendLabel } from '#/services/Backend'
import { lChColorToCssColor, type LChColor } from '#/services/Backend'
import { twMerge } from '#/utilities/tailwindMerge'
@@ -28,7 +29,11 @@ interface InternalLabelProps extends Readonly {
readonly draggable?: boolean
readonly color: LChColor
readonly title?: string
- readonly onPress: (event: MouseEvent | PressEvent) => void
+ readonly label?: BackendLabel
+ readonly onPress?: (
+ event: MouseEvent | PressEvent,
+ label?: BackendLabel,
+ ) => void
readonly onContextMenu?: (event: MouseEvent) => void
readonly onDragStart?: (event: DragEvent) => void
}
@@ -36,7 +41,7 @@ interface InternalLabelProps extends Readonly {
/** An label that can be applied to an asset. */
export default function Label(props: InternalLabelProps) {
const { active = false, isDisabled = false, color, negated = false, draggable, title } = props
- const { onPress, onDragStart, onContextMenu } = props
+ const { onPress, onDragStart, onContextMenu, label } = props
const { children: childrenRaw } = props
const focusDirection = useFocusDirection()
const handleFocusMove = useHandleFocusMove(focusDirection)
@@ -67,7 +72,7 @@ export default function Label(props: InternalLabelProps) {
style={{ backgroundColor: lChColorToCssColor(color) }}
onClick={(event) => {
event.stopPropagation()
- onPress(event)
+ onPress?.(event, label)
}}
onDragStart={(e) => {
onDragStart?.(e)
diff --git a/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx b/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx
index cf2b579b7266..41cc82eff65b 100644
--- a/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx
+++ b/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx
@@ -103,9 +103,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
const isOtherUserUsingProject =
isCloud && itemProjectState.openedBy != null && itemProjectState.openedBy !== user.email
+
const { data: users } = useBackendQuery(backend, 'listUsers', [], {
enabled: isOtherUserUsingProject,
})
+
const userOpeningProject = useMemo(
() =>
!isOtherUserUsingProject ? null : (
@@ -113,6 +115,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
),
[isOtherUserUsingProject, itemProjectState.openedBy, users],
)
+
const userOpeningProjectTooltip =
userOpeningProject == null ? null : getText('xIsUsingTheProject', userOpeningProject.name)
@@ -138,7 +141,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
const spinnerState = ((): SpinnerState => {
if (!isOpened) {
- return 'initial'
+ return 'loading-slow'
} else if (isError) {
return 'initial'
} else if (status == null) {
@@ -197,7 +200,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
/>
{
diff --git a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx
index db5703225981..3f4dc2352fba 100644
--- a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx
+++ b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx
@@ -52,7 +52,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
return (
{
diff --git a/app/gui/src/dashboard/components/dashboard/TheModal.tsx b/app/gui/src/dashboard/components/dashboard/TheModal.tsx
index 87959f5a5110..c2a3660c7596 100644
--- a/app/gui/src/dashboard/components/dashboard/TheModal.tsx
+++ b/app/gui/src/dashboard/components/dashboard/TheModal.tsx
@@ -15,13 +15,7 @@ export default function TheModal() {
return (
{modal && (
-
+
{/* This component suppresses the warning about the target not being pressable element. */}
diff --git a/app/gui/src/dashboard/components/dashboard/column.ts b/app/gui/src/dashboard/components/dashboard/column.ts
index 6ac35f49ce58..0394a3c6f974 100644
--- a/app/gui/src/dashboard/components/dashboard/column.ts
+++ b/app/gui/src/dashboard/components/dashboard/column.ts
@@ -1,5 +1,5 @@
/** @file Column types and column display modes. */
-import type { Dispatch, JSX, SetStateAction } from 'react'
+import { memo, type Dispatch, type JSX, type SetStateAction } from 'react'
import type { SortableColumn } from '#/components/dashboard/column/columnUtils'
import { Column } from '#/components/dashboard/column/columnUtils'
@@ -33,6 +33,7 @@ export interface AssetColumnProps {
readonly setRowState: Dispatch>
readonly isEditable: boolean
readonly isPlaceholder: boolean
+ readonly isExpanded: boolean
}
/** Props for a {@link AssetColumn}. */
@@ -56,12 +57,14 @@ export interface AssetColumn {
// =======================
/** React components for every column. */
-export const COLUMN_RENDERER: Readonly JSX.Element>> = {
- [Column.name]: NameColumn,
- [Column.modified]: ModifiedColumn,
- [Column.sharedWith]: SharedWithColumn,
- [Column.labels]: LabelsColumn,
- [Column.accessedByProjects]: PlaceholderColumn,
- [Column.accessedData]: PlaceholderColumn,
- [Column.docs]: DocsColumn,
+export const COLUMN_RENDERER: Readonly<
+ Record React.JSX.Element>>
+> = {
+ [Column.name]: memo(NameColumn),
+ [Column.modified]: memo(ModifiedColumn),
+ [Column.sharedWith]: memo(SharedWithColumn),
+ [Column.labels]: memo(LabelsColumn),
+ [Column.accessedByProjects]: memo(PlaceholderColumn),
+ [Column.accessedData]: memo(PlaceholderColumn),
+ [Column.docs]: memo(DocsColumn),
}
diff --git a/app/gui/src/dashboard/components/dashboard/column/DocsColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/DocsColumn.tsx
index af4e5b85b7bc..5c1ca6de7b94 100644
--- a/app/gui/src/dashboard/components/dashboard/column/DocsColumn.tsx
+++ b/app/gui/src/dashboard/components/dashboard/column/DocsColumn.tsx
@@ -8,7 +8,7 @@ export default function DocsColumn(props: column.AssetColumnProps) {
const { item } = props
return (
-
+
{item.description}
)
diff --git a/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx
index 985a3d40e2df..ff1c8bb4be0b 100644
--- a/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx
+++ b/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx
@@ -45,7 +45,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
self?.permission === permissions.PermissionAction.admin)
return (
-
+
{(item.labels ?? [])
.filter((label) => labelsByName.has(label))
.map((label) => (
@@ -93,7 +93,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
isDisabled
key={label}
color={labelsByName.get(label)?.color ?? backendModule.COLORS[0]}
- onPress={() => {}}
>
{label}
diff --git a/app/gui/src/dashboard/components/dashboard/column/ModifiedColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/ModifiedColumn.tsx
index c0a2722df0b5..3a6b86a5a25c 100644
--- a/app/gui/src/dashboard/components/dashboard/column/ModifiedColumn.tsx
+++ b/app/gui/src/dashboard/components/dashboard/column/ModifiedColumn.tsx
@@ -1,5 +1,5 @@
/** @file A column displaying the time at which the asset was last modified. */
-import { Text } from '#/components/aria'
+import { Text } from '#/components/AriaComponents'
import type { AssetColumnProps } from '#/components/dashboard/column'
import { formatDateTime } from '#/utilities/dateTime'
@@ -7,5 +7,9 @@ import { formatDateTime } from '#/utilities/dateTime'
export default function ModifiedColumn(props: AssetColumnProps) {
const { item } = props
- return {formatDateTime(new Date(item.modifiedAt))}
+ return (
+
+ {formatDateTime(new Date(item.modifiedAt))}
+
+ )
}
diff --git a/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx
index bfdba006f99b..0f7fba36c747 100644
--- a/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx
+++ b/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx
@@ -7,7 +7,6 @@ import type { AssetColumnProps } from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
import { PaywallDialogButton } from '#/components/Paywall'
import AssetEventType from '#/events/AssetEventType'
-import { useAssetStrict } from '#/hooks/backendHooks'
import { usePaywall } from '#/hooks/billing'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@@ -36,19 +35,14 @@ interface SharedWithColumnPropsInternal extends Pick {
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, state, isReadonly = false } = props
const { backend, category, setQuery } = state
- const asset = useAssetStrict({
- backend,
- assetId: item.id,
- parentId: item.parentId,
- category,
- })
+
const { user } = useFullUserSession()
const dispatchAssetEvent = useDispatchAssetEvent()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
- const assetPermissions = asset.permissions ?? []
+ const assetPermissions = item.permissions ?? []
const { setModal } = useSetModal()
- const self = tryFindSelfPermission(user, asset.permissions)
+ const self = tryFindSelfPermission(user, item.permissions)
const plusButtonRef = React.useRef(null)
const managesThisAsset =
!isReadonly &&
@@ -56,7 +50,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
(self?.permission === PermissionAction.own || self?.permission === PermissionAction.admin)
return (
-
+
{(category.type === 'trash' ?
assetPermissions.filter((permission) => permission.permission === PermissionAction.own)
: assetPermissions
@@ -103,11 +97,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
{
- dispatchAssetEvent({ type: AssetEventType.removeSelf, id: asset.id })
+ dispatchAssetEvent({ type: AssetEventType.removeSelf, id: item.id })
}}
/>,
)
diff --git a/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts b/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts
index 912b0678efa4..0f4aefa23085 100644
--- a/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts
+++ b/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts
@@ -64,18 +64,18 @@ export const COLUMN_SHOW_TEXT_ID: Readonly> = {
} satisfies { [C in Column]: `${C}ColumnShow` }
const COLUMN_CSS_CLASSES =
- 'text-left bg-clip-padding last:border-r-0 last:rounded-r-full last:w-full'
+ 'max-w-96 text-left bg-clip-padding last:border-r-0 last:rounded-r-full last:w-full'
const NORMAL_COLUMN_CSS_CLASSES = `px-cell-x py ${COLUMN_CSS_CLASSES}`
/** CSS classes for every column. */
export const COLUMN_CSS_CLASS: Readonly> = {
[Column.name]: `rounded-rows-skip-level min-w-drive-name-column h-full p-0 border-l-0 ${COLUMN_CSS_CLASSES}`,
- [Column.modified]: `min-w-drive-modified-column ${NORMAL_COLUMN_CSS_CLASSES}`,
- [Column.sharedWith]: `min-w-drive-shared-with-column ${NORMAL_COLUMN_CSS_CLASSES}`,
- [Column.labels]: `min-w-drive-labels-column ${NORMAL_COLUMN_CSS_CLASSES}`,
- [Column.accessedByProjects]: `min-w-drive-accessed-by-projects-column ${NORMAL_COLUMN_CSS_CLASSES}`,
- [Column.accessedData]: `min-w-drive-accessed-data-column ${NORMAL_COLUMN_CSS_CLASSES}`,
- [Column.docs]: `min-w-drive-docs-column ${NORMAL_COLUMN_CSS_CLASSES}`,
+ [Column.modified]: `min-w-drive-modified-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
+ [Column.sharedWith]: `min-w-drive-shared-with-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
+ [Column.labels]: `min-w-drive-labels-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
+ [Column.accessedByProjects]: `min-w-drive-accessed-by-projects-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
+ [Column.accessedData]: `min-w-drive-accessed-data-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
+ [Column.docs]: `min-w-drive-docs-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
}
// =====================
diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading.ts b/app/gui/src/dashboard/components/dashboard/columnHeading.ts
index 5cccaed315a6..2090dbfe3cbf 100644
--- a/app/gui/src/dashboard/components/dashboard/columnHeading.ts
+++ b/app/gui/src/dashboard/components/dashboard/columnHeading.ts
@@ -8,15 +8,19 @@ import LabelsColumnHeading from '#/components/dashboard/columnHeading/LabelsColu
import ModifiedColumnHeading from '#/components/dashboard/columnHeading/ModifiedColumnHeading'
import NameColumnHeading from '#/components/dashboard/columnHeading/NameColumnHeading'
import SharedWithColumnHeading from '#/components/dashboard/columnHeading/SharedWithColumnHeading'
+import { memo } from 'react'
export const COLUMN_HEADING: Readonly<
- Record React.JSX.Element>
+ Record<
+ columnUtils.Column,
+ React.MemoExoticComponent<(props: column.AssetColumnHeadingProps) => React.JSX.Element>
+ >
> = {
- [columnUtils.Column.name]: NameColumnHeading,
- [columnUtils.Column.modified]: ModifiedColumnHeading,
- [columnUtils.Column.sharedWith]: SharedWithColumnHeading,
- [columnUtils.Column.labels]: LabelsColumnHeading,
- [columnUtils.Column.accessedByProjects]: AccessedByProjectsColumnHeading,
- [columnUtils.Column.accessedData]: AccessedDataColumnHeading,
- [columnUtils.Column.docs]: DocsColumnHeading,
+ [columnUtils.Column.name]: memo(NameColumnHeading),
+ [columnUtils.Column.modified]: memo(ModifiedColumnHeading),
+ [columnUtils.Column.sharedWith]: memo(SharedWithColumnHeading),
+ [columnUtils.Column.labels]: memo(LabelsColumnHeading),
+ [columnUtils.Column.accessedByProjects]: memo(AccessedByProjectsColumnHeading),
+ [columnUtils.Column.accessedData]: memo(AccessedDataColumnHeading),
+ [columnUtils.Column.docs]: memo(DocsColumnHeading),
}
diff --git a/app/gui/src/dashboard/components/styled/FocusArea.tsx b/app/gui/src/dashboard/components/styled/FocusArea.tsx
index 06777015de09..7466fa67e361 100644
--- a/app/gui/src/dashboard/components/styled/FocusArea.tsx
+++ b/app/gui/src/dashboard/components/styled/FocusArea.tsx
@@ -1,26 +1,24 @@
/** @file An area that contains focusable children. */
-import * as React from 'react'
+import { type JSX, type RefCallback, useMemo, useRef, useState } from 'react'
-import * as detect from 'enso-common/src/detect'
+import { IS_DEV_MODE } from 'enso-common/src/detect'
import AreaFocusProvider from '#/providers/AreaFocusProvider'
-import FocusClassesProvider, * as focusClassProvider from '#/providers/FocusClassProvider'
-import type * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
+import FocusClassesProvider, { useFocusClasses } from '#/providers/FocusClassProvider'
+import type { FocusDirection } from '#/providers/FocusDirectionProvider'
import FocusDirectionProvider from '#/providers/FocusDirectionProvider'
-import * as navigator2DProvider from '#/providers/Navigator2DProvider'
+import { useNavigator2D } from '#/providers/Navigator2DProvider'
-import * as aria from '#/components/aria'
-import * as withFocusScope from '#/components/styled/withFocusScope'
+import { type DOMAttributes, useFocusManager, useFocusWithin } from '#/components/aria'
+import { withFocusScope } from '#/components/styled/withFocusScope'
+import { useEventCallback } from '#/hooks/eventCallbackHooks'
+import { useSyncRef } from '#/hooks/syncRefHooks'
-// =================
-// === FocusArea ===
-// =================
-
-/** Props returned by {@link aria.useFocusWithin}. */
+/** Props returned by {@link useFocusWithin}. */
export interface FocusWithinProps {
- readonly ref: React.RefCallback
- readonly onFocus: NonNullable['onFocus']>
- readonly onBlur: NonNullable['onBlur']>
+ readonly ref: RefCallback
+ readonly onFocus: NonNullable['onFocus']>
+ readonly onBlur: NonNullable['onBlur']>
}
/** Props for a {@link FocusArea} */
@@ -30,71 +28,81 @@ export interface FocusAreaProps {
/** Should ONLY be passed in exceptional cases. */
readonly focusDefaultClass?: string
readonly active?: boolean
- readonly direction: focusDirectionProvider.FocusDirection
- readonly children: (props: FocusWithinProps) => React.JSX.Element
+ readonly direction: FocusDirection
+ readonly children: (props: FocusWithinProps) => JSX.Element
}
/** An area that can be focused within. */
function FocusArea(props: FocusAreaProps) {
const { active = true, direction, children } = props
const { focusChildClass = 'focus-child', focusDefaultClass = 'focus-default' } = props
- const { focusChildClass: outerFocusChildClass } = focusClassProvider.useFocusClasses()
- const [areaFocus, setAreaFocus] = React.useState(false)
- const { focusWithinProps } = aria.useFocusWithin({ onFocusWithinChange: setAreaFocus })
- const focusManager = aria.useFocusManager()
- const navigator2D = navigator2DProvider.useNavigator2D()
- const rootRef = React.useRef(null)
- const cleanupRef = React.useRef(() => {})
- const focusChildClassRef = React.useRef(focusChildClass)
- focusChildClassRef.current = focusChildClass
- const focusDefaultClassRef = React.useRef(focusDefaultClass)
- focusDefaultClassRef.current = focusDefaultClass
+ const { focusChildClass: outerFocusChildClass } = useFocusClasses()
+ const [areaFocus, setAreaFocus] = useState(false)
+
+ const onChangeFocusWithin = useEventCallback((value: boolean) => {
+ if (value === areaFocus) return
+ setAreaFocus(value)
+ })
+
+ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: onChangeFocusWithin })
+ const focusManager = useFocusManager()
+ const navigator2D = useNavigator2D()
+ const rootRef = useRef(null)
+ const cleanupRef = useRef(() => {})
+ const focusChildClassRef = useSyncRef(focusChildClass)
+ const focusDefaultClassRef = useSyncRef(focusDefaultClass)
+
+ // The following group of functions are for suppressing `react-compiler` lints.
+ const cleanup = useEventCallback(() => {
+ cleanupRef.current()
+ })
+ const setRootRef = useEventCallback((value: HTMLElement | SVGElement | null) => {
+ rootRef.current = value
+ })
+ const setCleanupRef = useEventCallback((value: () => void) => {
+ cleanupRef.current = value
+ })
- let isRealRun = !detect.IS_DEV_MODE
- React.useEffect(() => {
- return () => {
- if (isRealRun) {
- cleanupRef.current()
- }
- // This is INTENTIONAL. It may not be causing problems now, but is a defensive measure
- // to make the implementation of this function consistent with the implementation of
- // `FocusRoot`.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- isRealRun = true
- }
- }, [])
+ const focusFirst = useEventCallback(() =>
+ focusManager?.focusFirst({
+ accept: (other) => other.classList.contains(focusChildClassRef.current),
+ }),
+ )
+ const focusLast = useEventCallback(() =>
+ focusManager?.focusLast({
+ accept: (other) => other.classList.contains(focusChildClassRef.current),
+ }),
+ )
+ const focusCurrent = useEventCallback(
+ () =>
+ focusManager?.focusFirst({
+ accept: (other) => other.classList.contains(focusDefaultClassRef.current),
+ }) ?? focusFirst(),
+ )
- const cachedChildren = React.useMemo(
+ const cachedChildren = useMemo(
() =>
// This is REQUIRED, otherwise `useFocusWithin` does not work with components from
// `react-aria-components`.
// eslint-disable-next-line no-restricted-syntax
children({
ref: (element) => {
- rootRef.current = element
- cleanupRef.current()
+ setRootRef(element)
+ cleanup()
if (active && element != null && focusManager != null) {
- const focusFirst = focusManager.focusFirst.bind(null, {
- accept: (other) => other.classList.contains(focusChildClassRef.current),
- })
- const focusLast = focusManager.focusLast.bind(null, {
- accept: (other) => other.classList.contains(focusChildClassRef.current),
- })
- const focusCurrent = () =>
- focusManager.focusFirst({
- accept: (other) => other.classList.contains(focusDefaultClassRef.current),
- }) ?? focusFirst()
- cleanupRef.current = navigator2D.register(element, {
- focusPrimaryChild: focusCurrent,
- focusWhenPressed:
- direction === 'horizontal' ?
- { right: focusFirst, left: focusLast }
- : { down: focusFirst, up: focusLast },
- })
+ setCleanupRef(
+ navigator2D.register(element, {
+ focusPrimaryChild: focusCurrent,
+ focusWhenPressed:
+ direction === 'horizontal' ?
+ { right: focusFirst, left: focusLast }
+ : { down: focusFirst, up: focusLast },
+ }),
+ )
} else {
- cleanupRef.current = () => {}
+ setCleanupRef(() => {})
}
- if (element != null && detect.IS_DEV_MODE) {
+ if (element != null && IS_DEV_MODE) {
if (active) {
element.dataset.focusArea = ''
} else {
@@ -104,7 +112,20 @@ function FocusArea(props: FocusAreaProps) {
},
...focusWithinProps,
} as FocusWithinProps),
- [active, direction, children, focusManager, focusWithinProps, navigator2D],
+ [
+ children,
+ focusWithinProps,
+ setRootRef,
+ cleanup,
+ active,
+ focusManager,
+ setCleanupRef,
+ navigator2D,
+ focusCurrent,
+ direction,
+ focusFirst,
+ focusLast,
+ ],
)
const result = (
@@ -118,4 +139,4 @@ function FocusArea(props: FocusAreaProps) {
}
/** An area that can be focused within. */
-export default withFocusScope.withFocusScope(FocusArea)
+export default withFocusScope(FocusArea)
diff --git a/app/gui/src/dashboard/components/styled/FocusRing.tsx b/app/gui/src/dashboard/components/styled/FocusRing.tsx
index 3690f41f9693..64449c506213 100644
--- a/app/gui/src/dashboard/components/styled/FocusRing.tsx
+++ b/app/gui/src/dashboard/components/styled/FocusRing.tsx
@@ -1,5 +1,4 @@
/** @file A styled focus ring. */
-import * as React from 'react'
import * as aria from '#/components/aria'
diff --git a/app/gui/src/dashboard/components/styled/FocusRoot.tsx b/app/gui/src/dashboard/components/styled/FocusRoot.tsx
index ef93442a4e96..2dc9ab041da8 100644
--- a/app/gui/src/dashboard/components/styled/FocusRoot.tsx
+++ b/app/gui/src/dashboard/components/styled/FocusRoot.tsx
@@ -30,20 +30,6 @@ function FocusRoot(props: FocusRootProps) {
const navigator2D = navigator2DProvider.useNavigator2D()
const cleanupRef = React.useRef(() => {})
- let isRealRun = !detect.IS_DEV_MODE
- React.useEffect(() => {
- return () => {
- if (isRealRun) {
- cleanupRef.current()
- }
- // This is INTENTIONAL. The first time this hook runs, when in Strict Mode, is *after* the ref
- // has already been set. This makes the focus root immediately unset itself,
- // which is incorrect behavior.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- isRealRun = true
- }
- }, [])
-
const cachedChildren = React.useMemo(
() =>
children({
diff --git a/app/gui/src/dashboard/components/styled/RadioGroup.tsx b/app/gui/src/dashboard/components/styled/RadioGroup.tsx
index 95bbe6dc4183..760e777d1e3e 100644
--- a/app/gui/src/dashboard/components/styled/RadioGroup.tsx
+++ b/app/gui/src/dashboard/components/styled/RadioGroup.tsx
@@ -112,17 +112,23 @@ function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef {
pointerX.current = event.clientX
@@ -97,13 +98,21 @@ export function useAutoScroll(
if (scrollContainer.scrollTop > 0) {
const distanceToTop = Math.max(0, pointerY.current - rect.top - insetTop)
if (distanceToTop < threshold) {
- scrollContainer.scrollTop -= Math.floor(speed / (distanceToTop + falloff))
+ unsafeWriteValue(
+ scrollContainer,
+ 'scrollTop',
+ scrollContainer.scrollTop - Math.floor(speed / (distanceToTop + falloff)),
+ )
}
}
if (scrollContainer.scrollTop + rect.height < scrollContainer.scrollHeight) {
const distanceToBottom = Math.max(0, rect.bottom - pointerY.current - insetBottom)
if (distanceToBottom < threshold) {
- scrollContainer.scrollTop += Math.floor(speed / (distanceToBottom + falloff))
+ unsafeWriteValue(
+ scrollContainer,
+ 'scrollTop',
+ scrollContainer.scrollTop + Math.floor(speed / (distanceToBottom + falloff)),
+ )
}
}
}
@@ -111,19 +120,27 @@ export function useAutoScroll(
if (scrollContainer.scrollLeft > 0) {
const distanceToLeft = Math.max(0, pointerX.current - rect.top - insetLeft)
if (distanceToLeft < threshold) {
- scrollContainer.scrollLeft -= Math.floor(speed / (distanceToLeft + falloff))
+ unsafeWriteValue(
+ scrollContainer,
+ 'scrollLeft',
+ scrollContainer.scrollLeft - Math.floor(speed / (distanceToLeft + falloff)),
+ )
}
}
if (scrollContainer.scrollLeft + rect.width < scrollContainer.scrollWidth) {
const distanceToRight = Math.max(0, rect.right - pointerX.current - insetRight)
if (distanceToRight < threshold) {
- scrollContainer.scrollLeft += Math.floor(speed / (distanceToRight + falloff))
+ unsafeWriteValue(
+ scrollContainer,
+ 'scrollLeft',
+ scrollContainer.scrollLeft + Math.floor(speed / (distanceToRight + falloff)),
+ )
}
}
}
animationFrameHandle.current = requestAnimationFrame(onAnimationFrame)
}
- }, [scrollContainerRef])
+ }, [optionsRef, scrollContainerRef])
const startAutoScroll = React.useCallback(() => {
if (!isScrolling.current) {
diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx
index b655c5b0096a..7d650d9048be 100644
--- a/app/gui/src/dashboard/hooks/backendHooks.tsx
+++ b/app/gui/src/dashboard/hooks/backendHooks.tsx
@@ -202,6 +202,8 @@ const INVALIDATION_MAP: Partial<
createDirectory: ['listDirectory'],
createSecret: ['listDirectory'],
updateSecret: ['listDirectory'],
+ updateProject: ['listDirectory'],
+ updateDirectory: ['listDirectory'],
createDatalink: ['listDirectory', 'getDatalink'],
uploadFileEnd: ['listDirectory'],
copyAsset: ['listDirectory', 'listAssetVersions'],
@@ -209,7 +211,6 @@ const INVALIDATION_MAP: Partial<
undoDeleteAsset: ['listDirectory'],
updateAsset: ['listDirectory', 'listAssetVersions'],
closeProject: ['listDirectory', 'listAssetVersions'],
- updateDirectory: ['listDirectory'],
}
/** The type of the corresponding mutation for the given backend method. */
@@ -318,6 +319,10 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) {
recentProjects: category.type === 'recent',
},
] as const,
+ // Setting stale time to Infinity to attaching a ton of
+ // setTimeouts to the query. Improves performance.
+ // Anyways, refetching is handled by another query.
+ staleTime: Infinity,
queryFn: async () => {
try {
return await backend.listDirectory(
@@ -1174,12 +1179,20 @@ export function useUploadFileMutation(backend: Backend, options: UploadFileMutat
toastAndLog('uploadLargeFileError', error)
},
} = options
- const uploadFileStartMutation = useMutation(backendMutationOptions(backend, 'uploadFileStart'))
+ const uploadFileStartMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'uploadFileStart'), [backend]),
+ )
const uploadFileChunkMutation = useMutation(
- backendMutationOptions(backend, 'uploadFileChunk', { retry: chunkRetries }),
+ useMemo(
+ () => backendMutationOptions(backend, 'uploadFileChunk', { retry: chunkRetries }),
+ [backend, chunkRetries],
+ ),
)
const uploadFileEndMutation = useMutation(
- backendMutationOptions(backend, 'uploadFileEnd', { retry: endRetries }),
+ useMemo(
+ () => backendMutationOptions(backend, 'uploadFileEnd', { retry: endRetries }),
+ [backend, endRetries],
+ ),
)
const [variables, setVariables] =
useState<[params: backendModule.UploadFileRequestParams, file: File]>()
diff --git a/app/gui/src/dashboard/hooks/contextMenuHooks.tsx b/app/gui/src/dashboard/hooks/contextMenuHooks.tsx
index 47467ebb923a..d3724f9c5172 100644
--- a/app/gui/src/dashboard/hooks/contextMenuHooks.tsx
+++ b/app/gui/src/dashboard/hooks/contextMenuHooks.tsx
@@ -5,12 +5,9 @@ import * as modalProvider from '#/providers/ModalProvider'
import ContextMenu from '#/components/ContextMenu'
import ContextMenus from '#/components/ContextMenus'
+import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
-// ======================
-// === contextMenuRef ===
-// ======================
-
/**
* Return a ref that attaches a context menu event listener.
* Should be used ONLY if the element does not expose an `onContextMenu` prop.
@@ -22,11 +19,11 @@ export function useContextMenuRef(
options: { enabled?: boolean } = {},
) {
const { setModal } = modalProvider.useSetModal()
- const createEntriesRef = React.useRef(createEntries)
- createEntriesRef.current = createEntries
+ const stableCreateEntries = useEventCallback(createEntries)
const optionsRef = useSyncRef(options)
const cleanupRef = React.useRef(() => {})
- const contextMenuRef = React.useMemo(
+
+ return React.useMemo(
() => (element: HTMLElement | null) => {
cleanupRef.current()
if (element == null) {
@@ -36,7 +33,7 @@ export function useContextMenuRef(
const { enabled = true } = optionsRef.current
if (enabled) {
const position = { pageX: event.pageX, pageY: event.pageY }
- const children = createEntriesRef.current(position)
+ const children = stableCreateEntries(position)
if (children != null) {
event.preventDefault()
event.stopPropagation()
@@ -64,7 +61,6 @@ export function useContextMenuRef(
}
}
},
- [key, label, optionsRef, setModal],
+ [stableCreateEntries, key, label, optionsRef, setModal],
)
- return contextMenuRef
}
diff --git a/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts b/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts
index 0a9280f36b1f..e4b2e187e8dd 100644
--- a/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts
+++ b/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts
@@ -1,22 +1,20 @@
-/**
- * @file
- *
- * This file contains the `useDebouncedCallback` hook which is used to debounce a callback function.
- */
+/** @file A hook to debounce a callback function. */
import * as React from 'react'
import { useEventCallback } from './eventCallbackHooks'
import { useUnmount } from './unmountHooks'
-/** Wrap a callback into debounce function */
+/** Wrap a callback into a debounced function */
export function useDebouncedCallback unknown>(
callback: Fn,
delay: number,
maxWait: number | null = null,
): DebouncedFunction {
const stableCallback = useEventCallback(callback)
+
const timeoutIdRef = React.useRef>()
const waitTimeoutIdRef = React.useRef>()
+
const lastCallRef = React.useRef<{ args: Parameters }>()
const clear = useEventCallback(() => {
diff --git a/app/gui/src/dashboard/hooks/debugHooks.ts b/app/gui/src/dashboard/hooks/debugHooks.ts
index 6d8efd4cb86c..cc0ccf240f4c 100644
--- a/app/gui/src/dashboard/hooks/debugHooks.ts
+++ b/app/gui/src/dashboard/hooks/debugHooks.ts
@@ -72,6 +72,8 @@ export function useMonitorDependencies(
console.groupEnd()
}
}
+ // Unavoidable. The ref must be updated only after logging is complete.
+ // eslint-disable-next-line react-compiler/react-compiler
oldDependenciesRef.current = dependencies
}
@@ -82,13 +84,15 @@ export function useMonitorDependencies(
/** A modified `useEffect` that logs the old and new values of changed dependencies. */
export function useDebugEffect(
effect: React.EffectCallback,
- deps: React.DependencyList,
+ dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[],
) {
- useMonitorDependencies(deps, description, dependencyDescriptions)
+ useMonitorDependencies(dependencies, description, dependencyDescriptions)
+ // Unavoidable as this is a wrapped hook.
+ // eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- React.useEffect(effect, deps)
+ React.useEffect(effect, dependencies)
}
// === useDebugMemo ===
@@ -96,13 +100,15 @@ export function useDebugEffect(
/** A modified `useMemo` that logs the old and new values of changed dependencies. */
export function useDebugMemo(
factory: () => T,
- deps: React.DependencyList,
+ dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[],
) {
- useMonitorDependencies(deps, description, dependencyDescriptions)
+ useMonitorDependencies(dependencies, description, dependencyDescriptions)
+ // Unavoidable as this is a wrapped hook.
+ // eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- return React.useMemo(factory, deps)
+ return React.useMemo(factory, dependencies)
}
// === useDebugCallback ===
@@ -110,11 +116,13 @@ export function useDebugMemo(
/** A modified `useCallback` that logs the old and new values of changed dependencies. */
export function useDebugCallback unknown>(
callback: T,
- deps: React.DependencyList,
+ dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[],
) {
- useMonitorDependencies(deps, description, dependencyDescriptions)
+ useMonitorDependencies(dependencies, description, dependencyDescriptions)
+ // Unavoidable as this is a wrapped hook.
+ // eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- return React.useCallback(callback, deps)
+ return React.useCallback(callback, dependencies)
}
diff --git a/app/gui/src/dashboard/hooks/intersectionHooks.ts b/app/gui/src/dashboard/hooks/intersectionHooks.ts
index fc9b76690f2b..8c66b4bd3601 100644
--- a/app/gui/src/dashboard/hooks/intersectionHooks.ts
+++ b/app/gui/src/dashboard/hooks/intersectionHooks.ts
@@ -1,10 +1,16 @@
/** @file Track changes in intersection ratio between an element and one of its ancestors. */
+import { useSyncRef } from '#/hooks/syncRefHooks'
import * as React from 'react'
// ============================
// === useIntersectionRatio ===
// ============================
+// UNSAFE. Only type-safe if the `transform` and `initialValue` arguments below are omitted
+// and the generic parameter is not explicitly specified.
+// eslint-disable-next-line no-restricted-syntax
+const DEFAULT_TRANSFORM = (ratio: number) => ratio as never
+
export function useIntersectionRatio(
rootRef: Readonly> | null,
targetRef: Readonly>,
@@ -35,11 +41,7 @@ export function useIntersectionRatio(
// `initialValue` is guaranteed to be the right type by the overloads.
// eslint-disable-next-line no-restricted-syntax
const [value, setValue] = React.useState((initialValue === undefined ? 0 : initialValue) as T)
- // eslint-disable-next-line no-restricted-syntax
- const transformRef = React.useRef(transform ?? ((ratio: number) => ratio as never))
- if (transform) {
- transformRef.current = transform
- }
+ const transformRef = useSyncRef(transform ?? DEFAULT_TRANSFORM)
React.useEffect(() => {
const root = rootRef?.current ?? document.body
@@ -88,7 +90,7 @@ export function useIntersectionRatio(
} else {
return
}
- }, [targetRef, rootRef, threshold])
+ }, [targetRef, rootRef, threshold, transformRef])
return value
}
diff --git a/app/gui/src/dashboard/hooks/measureHooks.ts b/app/gui/src/dashboard/hooks/measureHooks.ts
index e8d11ddb098e..a8d6e1f1e8ae 100644
--- a/app/gui/src/dashboard/hooks/measureHooks.ts
+++ b/app/gui/src/dashboard/hooks/measureHooks.ts
@@ -44,6 +44,11 @@ interface State {
readonly lastBounds: RectReadOnly
}
+/**
+ * A type that represents a callback that is called when the element is resized.
+ */
+export type OnResizeCallback = (bounds: RectReadOnly) => void
+
/**
* A type that represents the options for the useMeasure hook.
*/
@@ -53,20 +58,23 @@ export interface Options {
| { readonly scroll: number; readonly resize: number; readonly frame: number }
readonly scroll?: boolean
readonly offsetSize?: boolean
- readonly onResize?: (bounds: RectReadOnly) => void
+ readonly onResize?: OnResizeCallback
readonly maxWait?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
+ /**
+ * Whether to use RAF to measure the element.
+ */
+ readonly useRAF?: boolean
}
/**
* Custom hook to measure the size and position of an element
*/
export function useMeasure(options: Options = {}): Result {
- // eslint-disable-next-line @typescript-eslint/no-magic-numbers
- const { debounce = 0, scroll = false, offsetSize = false, onResize, maxWait = 500 } = options
+ const { onResize } = options
- const [bounds, set] = useState(() => ({
+ const [bounds, set] = useState({
left: 0,
top: 0,
width: 0,
@@ -75,14 +83,57 @@ export function useMeasure(options: Options = {}): Result {
right: 0,
x: 0,
y: 0,
- }))
+ })
+
+ const onResizeStableCallback = useEventCallback((nextBounds) => {
+ startTransition(() => {
+ set(nextBounds)
+ })
+
+ onResize?.(nextBounds)
+ })
+
+ const [ref, forceRefresh] = useMeasureCallback({ ...options, onResize: onResizeStableCallback })
+
+ return [ref, bounds, forceRefresh] as const
+}
+
+const DEFAULT_MAX_WAIT = 500
+
+/**
+ * Same as useMeasure, but doesn't rerender the component when the element is resized.
+ * Instead, it calls the `onResize` callback with the new bounds. This is useful when you want to
+ * measure the size of an element without causing a rerender.
+ */
+export function useMeasureCallback(options: Options & Required>) {
+ const {
+ debounce = 0,
+ scroll = false,
+ offsetSize = false,
+ onResize,
+ maxWait = DEFAULT_MAX_WAIT,
+ useRAF = true,
+ } = options
// keep all state in a ref
const state = useRef({
element: null,
scrollContainers: null,
- lastBounds: bounds,
+ lastBounds: {
+ left: 0,
+ top: 0,
+ width: 0,
+ height: 0,
+ bottom: 0,
+ right: 0,
+ x: 0,
+ y: 0,
+ },
})
+ // make sure to update state only as long as the component is truly mounted
+ const mounted = useRef(false)
+
+ const onResizeStableCallback = useEventCallback(onResize)
const scrollMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.scroll
const resizeMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.resize
@@ -92,12 +143,6 @@ export function useMeasure(options: Options = {}): Result {
const scrollDebounce = typeof debounce === 'number' ? debounce : debounce.scroll
const resizeDebounce = typeof debounce === 'number' ? debounce : debounce.resize
const frameDebounce = typeof debounce === 'number' ? debounce : debounce.frame
- // make sure to update state only as long as the component is truly mounted
- const mounted = useRef(false)
-
- useUnmount(() => {
- mounted.current = false
- })
const callback = useEventCallback(() => {
frame.read(() => {
@@ -113,21 +158,19 @@ export function useMeasure(options: Options = {}): Result {
}
if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {
- startTransition(() => {
- set((unsafeMutable(state.current).lastBounds = size))
- onResize?.(size)
- })
+ unsafeMutable(state.current).lastBounds = size
+ onResizeStableCallback(size)
}
})
})
- const [resizeObserver] = useState(() => new ResizeObserver(callback))
- const [mutationObserver] = useState(() => new MutationObserver(callback))
-
const frameDebounceCallback = useDebouncedCallback(callback, frameDebounce, frameMaxWait)
const resizeDebounceCallback = useDebouncedCallback(callback, resizeDebounce, resizeMaxWait)
const scrollDebounceCallback = useDebouncedCallback(callback, scrollDebounce, scrollMaxWait)
+ const [resizeObserver] = useState(() => new ResizeObserver(resizeDebounceCallback))
+ const [mutationObserver] = useState(() => new MutationObserver(resizeDebounceCallback))
+
const forceRefresh = useDebouncedCallback(callback, 0)
// cleanup current scroll-listeners / observers
@@ -152,7 +195,9 @@ export function useMeasure(options: Options = {}): Result {
attributeFilter: ['style', 'class'],
})
- frame.read(frameDebounceCallback, true)
+ if (useRAF) {
+ frame.read(frameDebounceCallback, true)
+ }
if (scroll && state.current.scrollContainers) {
state.current.scrollContainers.forEach((scrollContainer) => {
@@ -173,6 +218,9 @@ export function useMeasure(options: Options = {}): Result {
unsafeMutable(state.current).element = node
unsafeMutable(state.current).scrollContainers = findScrollContainers(node)
+
+ callback()
+
addListeners()
})
@@ -184,11 +232,11 @@ export function useMeasure(options: Options = {}): Result {
useEffect(() => {
removeListeners()
addListeners()
- }, [scroll, scrollDebounceCallback, resizeDebounceCallback, removeListeners, addListeners])
+ }, [useRAF, scroll, removeListeners, addListeners])
useUnmount(removeListeners)
- return [ref, bounds, forceRefresh]
+ return [ref, forceRefresh] as const
}
/**
diff --git a/app/gui/src/dashboard/hooks/offlineHooks.ts b/app/gui/src/dashboard/hooks/offlineHooks.ts
index 81294db7c521..0c2d509f57cd 100644
--- a/app/gui/src/dashboard/hooks/offlineHooks.ts
+++ b/app/gui/src/dashboard/hooks/offlineHooks.ts
@@ -1,8 +1,4 @@
-/**
- * @file
- *
- * Provides set of hooks to work with offline status
- */
+/** @file Hooks to work with offline status. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
@@ -53,7 +49,10 @@ export function useOfflineChange(
}
})
+ // Unavoidable.
+ // eslint-disable-next-line react-compiler/react-compiler
if (!triggeredImmediateRef.current) {
+ // eslint-disable-next-line react-compiler/react-compiler
triggeredImmediateRef.current = true
if (triggerImmediate === 'if-offline' && isOffline) {
diff --git a/app/gui/src/dashboard/hooks/projectHooks.ts b/app/gui/src/dashboard/hooks/projectHooks.ts
index 5093d1a6e50c..3c26b802b53d 100644
--- a/app/gui/src/dashboard/hooks/projectHooks.ts
+++ b/app/gui/src/dashboard/hooks/projectHooks.ts
@@ -39,6 +39,8 @@ const CLOUD_OPENING_INTERVAL_MS = 5_000
*/
const ACTIVE_SYNC_INTERVAL_MS = 100
+const DEFAULT_INTERVAL_MS = 120_000
+
/** Options for {@link createGetProjectDetailsQuery}. */
export interface CreateOpenedProjectQueryOptions {
readonly assetId: backendModule.Asset['id']
@@ -92,25 +94,37 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
networkMode: backend.type === backendModule.BackendType.remote ? 'online' : 'always',
refetchInterval: ({ state }) => {
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
+ const openingStates = [
+ backendModule.ProjectState.openInProgress,
+ backendModule.ProjectState.closing,
+ ]
if (state.status === 'error') {
return false
}
+
+ if (state.data == null) {
+ return false
+ }
+
if (isLocal) {
- if (state.data?.state.type === backendModule.ProjectState.opened) {
+ if (states.includes(state.data.state.type)) {
return OPENED_INTERVAL_MS
- } else {
- return ACTIVE_SYNC_INTERVAL_MS
- }
- } else {
- if (state.data == null) {
+ } else if (openingStates.includes(state.data.state.type)) {
return ACTIVE_SYNC_INTERVAL_MS
- } else if (states.includes(state.data.state.type)) {
- return OPENED_INTERVAL_MS
} else {
- return CLOUD_OPENING_INTERVAL_MS
+ return DEFAULT_INTERVAL_MS
}
}
+
+ // Cloud project
+ if (states.includes(state.data.state.type)) {
+ return OPENED_INTERVAL_MS
+ } else if (openingStates.includes(state.data.state.type)) {
+ return CLOUD_OPENING_INTERVAL_MS
+ } else {
+ return DEFAULT_INTERVAL_MS
+ }
},
})
}
@@ -299,6 +313,7 @@ export function useOpenProject() {
...openingProjectMutation.options,
scope: { id: project.id },
})
+
addLaunchedProject(project)
}
})
diff --git a/app/gui/src/dashboard/hooks/scrollHooks.ts b/app/gui/src/dashboard/hooks/scrollHooks.ts
index 8fcee8b82d37..76b7d0110fa7 100644
--- a/app/gui/src/dashboard/hooks/scrollHooks.ts
+++ b/app/gui/src/dashboard/hooks/scrollHooks.ts
@@ -1,11 +1,9 @@
/** @file Execute a function on scroll. */
-import * as React from 'react'
+import { useRef, useState, type MutableRefObject, type RefObject } from 'react'
+import { useSyncRef } from '#/hooks/syncRefHooks'
import useOnScroll from '#/hooks/useOnScroll'
-
-// ====================================
-// === useStickyTableHeaderOnScroll ===
-// ====================================
+import { unsafeWriteValue } from '#/utilities/write'
/** Options for the {@link useStickyTableHeaderOnScroll} hook. */
interface UseStickyTableHeaderOnScrollOptions {
@@ -19,21 +17,24 @@ interface UseStickyTableHeaderOnScrollOptions {
*
* NOTE: The returned event handler should be attached to the scroll container
* (the closest ancestor element with `overflow-y-auto`).
- * @param rootRef - a {@link React.useRef} to the scroll container
- * @param bodyRef - a {@link React.useRef} to the `tbody` element that needs to be clipped.
+ * @param rootRef - a {@link useRef} to the scroll container
+ * @param bodyRef - a {@link useRef} to the `tbody` element that needs to be clipped.
*/
export function useStickyTableHeaderOnScroll(
- rootRef: React.MutableRefObject,
- bodyRef: React.RefObject,
+ rootRef: MutableRefObject,
+ bodyRef: RefObject,
options: UseStickyTableHeaderOnScrollOptions = {},
) {
const { trackShadowClass = false } = options
- const trackShadowClassRef = React.useRef(trackShadowClass)
- trackShadowClassRef.current = trackShadowClass
- const [shadowClassName, setShadowClass] = React.useState('')
+ const trackShadowClassRef = useSyncRef(trackShadowClass)
+ const [shadowClassName, setShadowClass] = useState('')
const onScroll = useOnScroll(() => {
if (rootRef.current != null && bodyRef.current != null) {
- bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
+ unsafeWriteValue(
+ bodyRef.current.style,
+ 'clipPath',
+ `inset(${rootRef.current.scrollTop}px 0 0 0)`,
+ )
if (trackShadowClassRef.current) {
const isAtTop = rootRef.current.scrollTop === 0
const isAtBottom =
@@ -47,6 +48,6 @@ export function useStickyTableHeaderOnScroll(
setShadowClass(newShadowClass)
}
}
- }, [bodyRef, rootRef])
+ }, [bodyRef, rootRef, trackShadowClassRef])
return { onScroll, shadowClassName }
}
diff --git a/app/gui/src/dashboard/hooks/storeHooks.ts b/app/gui/src/dashboard/hooks/storeHooks.ts
index 5b712a4789c2..da9298e84978 100644
--- a/app/gui/src/dashboard/hooks/storeHooks.ts
+++ b/app/gui/src/dashboard/hooks/storeHooks.ts
@@ -72,7 +72,7 @@ export function useStore(
export function useTearingTransitionStore(
store: StoreApi,
selector: (state: State) => Slice,
- areEqual: AreEqual = 'object',
+ areEqual: AreEqual = 'shallow',
) {
const state = store.getState()
@@ -93,10 +93,12 @@ export function useTearingTransitionStore(
if (Object.is(prev[2], nextState) && prev[1] === store) {
return prev
}
+
const nextSlice = selector(nextState)
if (equalityFunction(prev[0], nextSlice) && prev[1] === store) {
return prev
}
+
return [nextSlice, store, nextState]
},
undefined,
diff --git a/app/gui/src/dashboard/hooks/syncRefHooks.ts b/app/gui/src/dashboard/hooks/syncRefHooks.ts
index 0739f22ba55f..159c184cd7ff 100644
--- a/app/gui/src/dashboard/hooks/syncRefHooks.ts
+++ b/app/gui/src/dashboard/hooks/syncRefHooks.ts
@@ -2,7 +2,7 @@
import { type MutableRefObject, useRef } from 'react'
/** A hook that returns a ref object whose `current` property is always in sync with the provided value. */
-export function useSyncRef(value: T): Readonly> {
+export function useSyncRef(value: T): MutableRefObject {
const ref = useRef(value)
/*
diff --git a/app/gui/src/dashboard/hooks/useLazyMemoHooks.ts b/app/gui/src/dashboard/hooks/useLazyMemoHooks.ts
index e6bb267cff25..88e15ac92869 100644
--- a/app/gui/src/dashboard/hooks/useLazyMemoHooks.ts
+++ b/app/gui/src/dashboard/hooks/useLazyMemoHooks.ts
@@ -1,14 +1,13 @@
-/**
- * @file
- *
- * A hook that returns a memoized function that will only be called once
- */
+/** @file A hook that returns a memoized function that will only be called once. */
import * as React from 'react'
const UNSET_VALUE = Symbol('unset')
/** A hook that returns a memoized function that will only be called once */
-export function useLazyMemoHooks(factory: T | (() => T), deps: React.DependencyList): () => T {
+export function useLazyMemoHooks(
+ factory: T | (() => T),
+ dependencies: React.DependencyList,
+): () => T {
return React.useMemo(() => {
let cachedValue: T | typeof UNSET_VALUE = UNSET_VALUE
@@ -19,8 +18,9 @@ export function useLazyMemoHooks(factory: T | (() => T), deps: React.Dependen
return cachedValue
}
- // We assume that the callback should change only when
- // the deps change.
+ // We assume that the callback should change only when the deps change.
+ // Unavoidable, the dependency list is pased through transparently.
+ // eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, deps)
+ }, dependencies)
}
diff --git a/app/gui/src/dashboard/hooks/useOnScroll.ts b/app/gui/src/dashboard/hooks/useOnScroll.ts
index 029d45e46d31..c5a37fca0d90 100644
--- a/app/gui/src/dashboard/hooks/useOnScroll.ts
+++ b/app/gui/src/dashboard/hooks/useOnScroll.ts
@@ -30,6 +30,8 @@ export default function useOnScroll(callback: () => void, dependencies: React.De
React.useLayoutEffect(() => {
updateClipPathRef.current()
+ // Unavoidable, the dependency list is pased through transparently.
+ // eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies)
diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx
index 99dca9473784..405e473616aa 100644
--- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx
+++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx
@@ -36,15 +36,12 @@ import * as backendModule from '#/services/Backend'
import * as localBackendModule from '#/services/LocalBackend'
import { useNewProject, useUploadFileWithToastMutation } from '#/hooks/backendHooks'
-import {
- usePasteData,
- useSetAssetPanelProps,
- useSetIsAssetPanelTemporarilyVisible,
-} from '#/providers/DriveProvider'
+import { usePasteData } from '#/providers/DriveProvider'
import { normalizePath } from '#/utilities/fileInfo'
import { mapNonNullish } from '#/utilities/nullable'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
+import { useSetAssetPanelProps, useSetIsAssetPanelTemporarilyVisible } from './AssetPanel'
// ========================
// === AssetContextMenu ===
diff --git a/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx b/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx
index 15bb4e8ca74f..83bc47d82b23 100644
--- a/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx
+++ b/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx
@@ -3,25 +3,30 @@ import { MarkdownViewer } from '#/components/MarkdownViewer'
import { Result } from '#/components/Result'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
-import type { AnyAsset, Asset } from '#/services/Backend'
+import type { Asset } from '#/services/Backend'
import { AssetType } from '#/services/Backend'
+import { useStore } from '#/utilities/zustand'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import * as ast from 'ydoc-shared/ast'
import { splitFileContents } from 'ydoc-shared/ensoFile'
import { versionContentQueryOptions } from '../AssetDiffView/useFetchVersionContent'
+import { assetPanelStore } from '../AssetPanel'
/** Props for a {@link AssetDocs}. */
export interface AssetDocsProps {
readonly backend: Backend
- readonly item: AnyAsset | null
}
/** Documentation display for an asset. */
export function AssetDocs(props: AssetDocsProps) {
- const { backend, item } = props
+ const { backend } = props
const { getText } = useText()
+ const { item } = useStore(assetPanelStore, (state) => ({ item: state.assetPanelProps.item }), {
+ unsafeEnableTransition: true,
+ })
+
if (item?.type !== AssetType.project) {
return
}
diff --git a/app/gui/src/dashboard/layouts/AssetPanel/AssetPanel.tsx b/app/gui/src/dashboard/layouts/AssetPanel/AssetPanel.tsx
index 2e218eac6942..fc20dad83203 100644
--- a/app/gui/src/dashboard/layouts/AssetPanel/AssetPanel.tsx
+++ b/app/gui/src/dashboard/layouts/AssetPanel/AssetPanel.tsx
@@ -9,71 +9,32 @@ import sessionsIcon from '#/assets/group.svg'
import inspectIcon from '#/assets/inspect.svg'
import versionsIcon from '#/assets/versions.svg'
+import { ErrorBoundary } from '#/components/ErrorBoundary'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useBackend } from '#/providers/BackendProvider'
-import {
- useAssetPanelProps,
- useAssetPanelSelectedTab,
- useIsAssetPanelExpanded,
- useIsAssetPanelHidden,
- useSetAssetPanelSelectedTab,
- useSetIsAssetPanelExpanded,
-} from '#/providers/DriveProvider'
import { useText } from '#/providers/TextProvider'
-import type Backend from '#/services/Backend'
-import LocalStorage from '#/utilities/LocalStorage'
-import type { AnyAsset, BackendType } from 'enso-common/src/services/Backend'
-import type { Spring } from 'framer-motion'
+import { useStore } from '#/utilities/zustand'
+import type { BackendType } from 'enso-common/src/services/Backend'
import { AnimatePresence, motion } from 'framer-motion'
import { memo, startTransition } from 'react'
-import { z } from 'zod'
import { AssetDocs } from '../AssetDocs'
import AssetProjectSessions from '../AssetProjectSessions'
-import type { AssetPropertiesSpotlight } from '../AssetProperties'
import AssetProperties from '../AssetProperties'
import AssetVersions from '../AssetVersions/AssetVersions'
import type { Category } from '../CategorySwitcher/Category'
+import {
+ assetPanelStore,
+ useIsAssetPanelExpanded,
+ useSetIsAssetPanelExpanded,
+} from './AssetPanelState'
import { AssetPanelTabs } from './components/AssetPanelTabs'
import { AssetPanelToggle } from './components/AssetPanelToggle'
+import { type AssetPanelTab } from './types'
const ASSET_SIDEBAR_COLLAPSED_WIDTH = 48
const ASSET_PANEL_WIDTH = 480
const ASSET_PANEL_TOTAL_WIDTH = ASSET_PANEL_WIDTH + ASSET_SIDEBAR_COLLAPSED_WIDTH
-/** Determines the content of the {@link AssetPanel}. */
-const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules', 'docs'] as const
-
-/** Determines the content of the {@link AssetPanel}. */
-type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]
-
-declare module '#/utilities/LocalStorage' {
- /** */
- interface LocalStorageData {
- readonly isAssetPanelVisible: boolean
- readonly isAssetPanelHidden: boolean
- readonly assetPanelTab: AssetPanelTab
- readonly assetPanelWidth: number
- }
-}
-
-const ASSET_PANEL_TAB_SCHEMA = z.enum(ASSET_PANEL_TABS)
-
-LocalStorage.register({
- assetPanelTab: { schema: ASSET_PANEL_TAB_SCHEMA },
- assetPanelWidth: { schema: z.number().int() },
- isAssetPanelHidden: { schema: z.boolean() },
- isAssetPanelVisible: { schema: z.boolean() },
-})
-
-/** Props supplied by the row. */
-export interface AssetPanelContextProps {
- readonly backend: Backend | null
- readonly selectedTab: AssetPanelTab
- readonly item: AnyAsset | null
- readonly path: string | null
- readonly spotlightOn: AssetPropertiesSpotlight | null
-}
-
/**
* Props for an {@link AssetPanel}.
*/
@@ -82,66 +43,76 @@ export interface AssetPanelProps {
readonly category: Category
}
-const DEFAULT_TRANSITION_OPTIONS: Spring = {
- type: 'spring',
- // eslint-disable-next-line @typescript-eslint/no-magic-numbers
- stiffness: 200,
- // eslint-disable-next-line @typescript-eslint/no-magic-numbers
- damping: 30,
- mass: 1,
- velocity: 0,
-}
-
/**
* The asset panel is a sidebar that can be expanded or collapsed.
* It is used to view and interact with assets in the drive.
*/
export function AssetPanel(props: AssetPanelProps) {
- const isHidden = useIsAssetPanelHidden()
+ const isHidden = useStore(assetPanelStore, (state) => state.isAssetPanelHidden, {
+ unsafeEnableTransition: true,
+ })
const isExpanded = useIsAssetPanelExpanded()
const panelWidth = isExpanded ? ASSET_PANEL_TOTAL_WIDTH : ASSET_SIDEBAR_COLLAPSED_WIDTH
const isVisible = !isHidden
+ const compensationWidth = isVisible ? panelWidth : 0
+
return (
-
+ // We use hex color here to avoid muliplying bg colors due to opacity.
+
+
+
{isVisible && (
- ({ opacity: 1, width }),
- exit: { opacity: 0, width: 0 },
- }}
- transition={DEFAULT_TRANSITION_OPTIONS}
- className="relative flex h-full flex-col shadow-softer clip-path-left-shadow"
- onClick={(event) => {
- // Prevent deselecting Assets Table rows.
- event.stopPropagation()
- }}
- >
-
-
+
)}
-
+
+
+ {isVisible && (
+ {
+ // Prevent deselecting Assets Table rows.
+ event.stopPropagation()
+ }}
+ >
+
+
+ )}
+
+
)
}
/**
* The internal implementation of the Asset Panel Tabs.
*/
-const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(props: AssetPanelProps) {
- const { category } = props
+const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(
+ props: AssetPanelProps & { panelWidth: number },
+) {
+ const { category, panelWidth } = props
- const { item, spotlightOn, path } = useAssetPanelProps()
+ const itemId = useStore(assetPanelStore, (state) => state.assetPanelProps.item?.id, {
+ unsafeEnableTransition: true,
+ })
- const selectedTab = useAssetPanelSelectedTab()
- const setSelectedTab = useSetAssetPanelSelectedTab()
- const isHidden = useIsAssetPanelHidden()
+ const selectedTab = useStore(assetPanelStore, (state) => state.selectedTab, {
+ unsafeEnableTransition: true,
+ })
+ const setSelectedTab = useStore(assetPanelStore, (state) => state.setSelectedTab, {
+ unsafeEnableTransition: true,
+ })
+ const isHidden = useStore(assetPanelStore, (state) => state.isAssetPanelHidden, {
+ unsafeEnableTransition: true,
+ })
const isReadonly = category.type === 'trash'
@@ -156,9 +127,12 @@ const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(props: Asset
const backend = useBackend(category)
+ const getTranslation = useEventCallback(() => ASSET_SIDEBAR_COLLAPSED_WIDTH)
+
return (
{isExpanded && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {/* We use hex color here to avoid muliplying bg colors due to opacity. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)}
-
+
void
+ readonly isAssetPanelPermanentlyVisible: boolean
+ readonly isAssetPanelExpanded: boolean
+ readonly setIsAssetPanelExpanded: (isAssetPanelExpanded: boolean) => void
+ readonly setIsAssetPanelPermanentlyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
+ readonly toggleIsAssetPanelPermanentlyVisible: () => void
+ readonly isAssetPanelTemporarilyVisible: boolean
+ readonly setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
+ readonly assetPanelProps: AssetPanelContextProps
+ readonly setAssetPanelProps: (assetPanelProps: Partial) => void
+ readonly isAssetPanelHidden: boolean
+ readonly setIsAssetPanelHidden: (isAssetPanelHidden: boolean) => void
+}
+
+export const assetPanelStore = zustand.createStore((set, get) => {
+ const localStorage = LocalStorage.getInstance()
+ return {
+ selectedTab: localStorage.get('assetPanelTab') ?? 'settings',
+ setSelectedTab: (tab) => {
+ set({ selectedTab: tab })
+ localStorage.set('assetPanelTab', tab)
+ },
+ isAssetPanelPermanentlyVisible: false,
+ toggleIsAssetPanelPermanentlyVisible: () => {
+ const state = get()
+ const next = !state.isAssetPanelPermanentlyVisible
+
+ state.setIsAssetPanelPermanentlyVisible(next)
+ },
+ setIsAssetPanelPermanentlyVisible: (isAssetPanelPermanentlyVisible) => {
+ if (get().isAssetPanelPermanentlyVisible !== isAssetPanelPermanentlyVisible) {
+ set({ isAssetPanelPermanentlyVisible })
+ localStorage.set('isAssetPanelVisible', isAssetPanelPermanentlyVisible)
+ }
+ },
+ isAssetPanelExpanded: false,
+ setIsAssetPanelExpanded: (isAssetPanelExpanded) => {
+ const state = get()
+
+ if (state.isAssetPanelPermanentlyVisible !== isAssetPanelExpanded) {
+ state.setIsAssetPanelPermanentlyVisible(isAssetPanelExpanded)
+ state.setIsAssetPanelTemporarilyVisible(false)
+ }
+
+ if (state.isAssetPanelHidden && isAssetPanelExpanded) {
+ state.setIsAssetPanelHidden(false)
+ }
+ },
+ isAssetPanelTemporarilyVisible: false,
+ setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible) => {
+ const state = get()
+
+ if (state.isAssetPanelHidden && isAssetPanelTemporarilyVisible) {
+ state.setIsAssetPanelHidden(false)
+ }
+
+ if (state.isAssetPanelTemporarilyVisible !== isAssetPanelTemporarilyVisible) {
+ set({ isAssetPanelTemporarilyVisible })
+ }
+ },
+ assetPanelProps: {
+ selectedTab: localStorage.get('assetPanelTab') ?? 'settings',
+ backend: null,
+ item: null,
+ spotlightOn: null,
+ path: null,
+ },
+ setAssetPanelProps: (assetPanelProps) => {
+ const current = get().assetPanelProps
+ if (current !== assetPanelProps) {
+ set({ assetPanelProps: { ...current, ...assetPanelProps } })
+ }
+ },
+ isAssetPanelHidden: localStorage.get('isAssetPanelHidden') ?? false,
+ setIsAssetPanelHidden: (isAssetPanelHidden) => {
+ const state = get()
+
+ if (state.isAssetPanelHidden !== isAssetPanelHidden) {
+ set({ isAssetPanelHidden })
+ localStorage.set('isAssetPanelHidden', isAssetPanelHidden)
+ }
+ },
+ }
+})
+
+/** Props supplied by the row. */
+export interface AssetPanelContextProps {
+ readonly backend: Backend | null
+ readonly selectedTab: AssetPanelTab
+ readonly item: AnyAsset | null
+ readonly path: string | null
+ readonly spotlightOn: AssetPropertiesSpotlight | null
+}
+
+/** Whether the Asset Panel is toggled on. */
+export function useIsAssetPanelPermanentlyVisible() {
+ return zustand.useStore(assetPanelStore, (state) => state.isAssetPanelPermanentlyVisible, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** A function to set whether the Asset Panel is toggled on. */
+export function useSetIsAssetPanelPermanentlyVisible() {
+ return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelPermanentlyVisible, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** Whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
+export function useIsAssetPanelTemporarilyVisible() {
+ return zustand.useStore(assetPanelStore, (state) => state.isAssetPanelTemporarilyVisible, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** A function to set whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
+export function useSetIsAssetPanelTemporarilyVisible() {
+ return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelTemporarilyVisible, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** Whether the Asset Panel is currently visible, either temporarily or permanently. */
+export function useIsAssetPanelVisible() {
+ const isAssetPanelPermanentlyVisible = useIsAssetPanelPermanentlyVisible()
+ const isAssetPanelTemporarilyVisible = useIsAssetPanelTemporarilyVisible()
+ return isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible
+}
+
+/** Whether the Asset Panel is expanded. */
+export function useIsAssetPanelExpanded() {
+ return zustand.useStore(
+ assetPanelStore,
+ ({ isAssetPanelPermanentlyVisible, isAssetPanelTemporarilyVisible }) =>
+ isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible,
+ { unsafeEnableTransition: true },
+ )
+}
+
+/** A function to set whether the Asset Panel is expanded. */
+export function useSetIsAssetPanelExpanded() {
+ return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelExpanded, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** Props for the Asset Panel. */
+export function useAssetPanelProps() {
+ return zustand.useStore(assetPanelStore, (state) => state.assetPanelProps, {
+ unsafeEnableTransition: true,
+ areEqual: 'shallow',
+ })
+}
+
+/** The selected tab of the Asset Panel. */
+export function useAssetPanelSelectedTab() {
+ return zustand.useStore(assetPanelStore, (state) => state.assetPanelProps.selectedTab, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** A function to set props for the Asset Panel. */
+export function useSetAssetPanelProps() {
+ return zustand.useStore(assetPanelStore, (state) => state.setAssetPanelProps, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** A function to reset the Asset Panel props to their default values. */
+export function useResetAssetPanelProps() {
+ return useEventCallback(() => {
+ const current = assetPanelStore.getState().assetPanelProps
+ if (current.item != null) {
+ assetPanelStore.setState({
+ assetPanelProps: {
+ selectedTab: current.selectedTab,
+ backend: null,
+ item: null,
+ spotlightOn: null,
+ path: null,
+ },
+ })
+ }
+ })
+}
+
+/** A function to set the selected tab of the Asset Panel. */
+export function useSetAssetPanelSelectedTab() {
+ return useEventCallback((selectedTab: AssetPanelContextProps['selectedTab']) => {
+ startTransition(() => {
+ const current = assetPanelStore.getState().assetPanelProps
+ if (current.selectedTab !== selectedTab) {
+ assetPanelStore.setState({
+ assetPanelProps: { ...current, selectedTab },
+ })
+ }
+ })
+ })
+}
+
+/** Whether the Asset Panel is hidden. */
+export function useIsAssetPanelHidden() {
+ return zustand.useStore(assetPanelStore, (state) => state.isAssetPanelHidden, {
+ unsafeEnableTransition: true,
+ })
+}
+
+/** A function to set whether the Asset Panel is hidden. */
+export function useSetIsAssetPanelHidden() {
+ return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelHidden, {
+ unsafeEnableTransition: true,
+ })
+}
diff --git a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelTabs.tsx b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelTabs.tsx
index a58c3973c754..6000758a8ec0 100644
--- a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelTabs.tsx
+++ b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelTabs.tsx
@@ -1,13 +1,13 @@
/** @file Tabs for the asset panel. Contains the visual state for the tabs and animations. */
import { AnimatedBackground } from '#/components/AnimatedBackground'
-import type { TabListProps, TabPanelProps, TabProps } from '#/components/aria'
+import type { TabListProps, TabPanelProps, TabPanelRenderProps, TabProps } from '#/components/aria'
import { Tab, TabList, TabPanel, Tabs, type TabsProps } from '#/components/aria'
import { useVisualTooltip } from '#/components/AriaComponents'
-import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Suspense } from '#/components/Suspense'
import SvgMask from '#/components/SvgMask'
import { AnimatePresence, motion } from 'framer-motion'
-import { memo, useRef } from 'react'
+import type { ReactNode } from 'react'
+import { memo, useCallback, useRef } from 'react'
/** Display a set of tabs. */
export function AssetPanelTabs(props: TabsProps) {
@@ -36,9 +36,9 @@ export interface AssetPanelTabProps extends TabProps {
const UNDERLAY_ELEMENT = (
<>
-
-
-
+
+
+
>
)
@@ -83,7 +83,7 @@ export const AssetPanelTab = memo(function AssetPanelTab(props: AssetPanelTabPro
variants={{ active: { opacity: 1 }, inactive: { opacity: 0 } }}
initial="inactive"
animate={!isActive && isHovered ? 'active' : 'inactive'}
- className="absolute inset-x-1.5 inset-y-1.5 -z-1 rounded-full bg-invert transition-colors duration-300"
+ className="absolute inset-x-1.5 inset-y-1.5 rounded-full bg-invert transition-colors duration-300"
/>
ReactNode)
}
+const SUSPENSE_LOADER_PROPS = { className: 'my-auto' }
/** Display a tab panel. */
-export function AssetPanelTabPanel(props: AssetPanelTabPanelProps) {
- const { children, id = '', resetKeys = [] } = props
+export const AssetPanelTabPanel = memo(function AssetPanelTabPanel(props: AssetPanelTabPanelProps) {
+ const { children, id = '' } = props
+
+ const renderTabPanel = useCallback(
+ (renderProps: TabPanelRenderProps) => {
+ const isSelected = renderProps.state.selectionManager.isSelected(id)
+
+ return (
+
+ {isSelected && (
+
+
+
+ {typeof children === 'function' ? children(renderProps) : children}
+
+
+
+ )}
+
+ )
+ },
+ [id, children],
+ )
return (
-
- {(renderProps) => {
- const isSelected = renderProps.state.selectionManager.isSelected(id)
-
- return (
-
- {isSelected && (
-
-
-
-
- {typeof children === 'function' ? children(renderProps) : children}
-
-
-
-
- )}
-
- )
- }}
+
+ {renderTabPanel}
)
-}
+})
AssetPanelTabs.Tab = AssetPanelTab
AssetPanelTabs.TabPanel = AssetPanelTabPanel
diff --git a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelToggle.tsx b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelToggle.tsx
index 1f1e9d193eab..b1141df59981 100644
--- a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelToggle.tsx
+++ b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelToggle.tsx
@@ -5,11 +5,12 @@
import RightPanelIcon from '#/assets/right_panel.svg'
import { Button } from '#/components/AriaComponents'
-import { useIsAssetPanelHidden, useSetIsAssetPanelHidden } from '#/providers/DriveProvider'
import { useText } from '#/providers/TextProvider'
-import type { Spring } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import { memo } from 'react'
+import { useIsAssetPanelHidden, useSetIsAssetPanelHidden } from '../AssetPanelState'
+
+import { useEventCallback } from '#/hooks/eventCallbackHooks'
/**
* Props for a {@link AssetPanelToggle}.
@@ -17,26 +18,20 @@ import { memo } from 'react'
export interface AssetPanelToggleProps {
readonly className?: string
readonly showWhen?: 'collapsed' | 'expanded'
-}
-
-const DEFAULT_TRANSITION_OPTIONS: Spring = {
- type: 'spring',
- // eslint-disable-next-line @typescript-eslint/no-magic-numbers
- stiffness: 200,
- // eslint-disable-next-line @typescript-eslint/no-magic-numbers
- damping: 30,
- mass: 1,
- velocity: 0,
+ readonly getTranslation?: () => number
}
const COLLAPSED_X_TRANSLATION = 16
-const EXPANDED_X_TRANSLATION = -16
/**
* Toggle for opening the asset panel.
*/
export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanelToggleProps) {
- const { className, showWhen = 'collapsed' } = props
+ const {
+ className,
+ showWhen = 'collapsed',
+ getTranslation = () => COLLAPSED_X_TRANSLATION,
+ } = props
const { getText } = useText()
const isAssetPanelHidden = useIsAssetPanelHidden()
@@ -44,6 +39,10 @@ export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanel
const canDisplay = showWhen === 'collapsed' ? isAssetPanelHidden : !isAssetPanelHidden
+ const toggleAssetPanel = useEventCallback(() => {
+ setIsAssetPanelHidden(!isAssetPanelHidden)
+ })
+
return (
{canDisplay && (
@@ -53,15 +52,14 @@ export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanel
initial={{
opacity: 0,
filter: 'blur(4px)',
- x: showWhen === 'collapsed' ? COLLAPSED_X_TRANSLATION : EXPANDED_X_TRANSLATION,
+ x: showWhen === 'collapsed' ? getTranslation() : -getTranslation(),
}}
animate={{ opacity: 1, filter: 'blur(0px)', x: 0 }}
exit={{
opacity: 0,
filter: 'blur(4px)',
- x: showWhen === 'collapsed' ? COLLAPSED_X_TRANSLATION : EXPANDED_X_TRANSLATION,
+ x: showWhen === 'collapsed' ? getTranslation() : -getTranslation(),
}}
- transition={DEFAULT_TRANSITION_OPTIONS}
>
|
- {asset.labels?.map((value) => {
+ {item.labels?.map((value) => {
const label = labels.find((otherLabel) => otherLabel.value === value)
return (
label != null && (
@@ -349,10 +350,10 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
noDialog
canReset
canCancel={false}
- id={asset.id}
- name={asset.title}
+ id={item.id}
+ name={item.title}
doCreate={async (name, value) => {
- await updateSecretMutation.mutateAsync([asset.id, { value }, name])
+ await updateSecretMutation.mutateAsync([item.id, { value }, name])
}}
/>
diff --git a/app/gui/src/dashboard/layouts/AssetSearchBar.tsx b/app/gui/src/dashboard/layouts/AssetSearchBar.tsx
index 4291440bdbda..de7460206867 100644
--- a/app/gui/src/dashboard/layouts/AssetSearchBar.tsx
+++ b/app/gui/src/dashboard/layouts/AssetSearchBar.tsx
@@ -4,6 +4,7 @@ import * as React from 'react'
import * as detect from 'enso-common/src/detect'
import FindIcon from '#/assets/find.svg'
+import { unsafeWriteValue } from '#/utilities/write'
import * as backendHooks from '#/hooks/backendHooks'
@@ -17,16 +18,17 @@ import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
+import { useEventCallback } from '#/hooks/eventCallbackHooks'
+import { useSyncRef } from '#/hooks/syncRefHooks'
import type Backend from '#/services/Backend'
-
-import { useSuggestions } from '#/providers/DriveProvider'
+import type { Label as BackendLabel } from '#/services/Backend'
import * as array from '#/utilities/array'
import AssetQuery from '#/utilities/AssetQuery'
import * as eventModule from '#/utilities/event'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
+import { createStore, useStore } from '#/utilities/zustand'
import { AnimatePresence, motion } from 'framer-motion'
-import { useEventCallback } from '../hooks/eventCallbackHooks'
// =============
// === Types ===
@@ -49,6 +51,7 @@ enum QuerySource {
/** A suggested query. */
export interface Suggestion {
+ readonly key: string
readonly render: () => React.ReactNode
readonly addToQuery: (query: AssetQuery) => AssetQuery
readonly deleteFromQuery: (query: AssetQuery) => AssetQuery
@@ -66,6 +69,25 @@ interface InternalTagsProps {
readonly setQuery: React.Dispatch>
}
+export const searchbarSuggestionsStore = createStore<{
+ readonly suggestions: readonly Suggestion[]
+ readonly setSuggestions: (suggestions: readonly Suggestion[]) => void
+}>((set) => ({
+ suggestions: [],
+ setSuggestions: (suggestions) => {
+ set({ suggestions })
+ },
+}))
+
+/**
+ * Sets the suggestions.
+ */
+export function useSetSuggestions() {
+ return useStore(searchbarSuggestionsStore, (state) => state.setSuggestions, {
+ unsafeEnableTransition: true,
+ })
+}
+
/** Tags (`name:`, `modified:`, etc.) */
function Tags(props: InternalTagsProps) {
const { isCloud, querySource, query, setQuery } = props
@@ -102,7 +124,7 @@ function Tags(props: InternalTagsProps) {
size="xsmall"
className="min-w-12"
onPress={() => {
- querySource.current = QuerySource.internal
+ unsafeWriteValue(querySource, 'current', QuerySource.internal)
setQuery(query.add({ [key]: [[]] }))
}}
>
@@ -130,13 +152,18 @@ export interface AssetSearchBarProps {
/** A search bar containing a text input, and a list of suggestions. */
function AssetSearchBar(props: AssetSearchBarProps) {
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 rawSuggestions = useStore(searchbarSuggestionsStore, (state) => state.suggestions, {
+ unsafeEnableTransition: true,
+ })
+
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
- const suggestionsRef = React.useRef(rawSuggestions)
+
+ const suggestionsRef = useSyncRef(suggestions)
+
const [selectedIndex, setSelectedIndex] = React.useState(null)
const [areSuggestionsVisible, privateSetAreSuggestionsVisible] = React.useState(false)
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
@@ -151,6 +178,13 @@ function AssetSearchBar(props: AssetSearchBarProps) {
})
})
+ React.useEffect(() => {
+ if (querySource.current !== QuerySource.tabbing) {
+ setSuggestions(rawSuggestions)
+ unsafeWriteValue(suggestionsRef, 'current', rawSuggestions)
+ }
+ }, [rawSuggestions, suggestionsRef])
+
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing) {
baseQuery.current = query
@@ -172,23 +206,19 @@ function AssetSearchBar(props: AssetSearchBarProps) {
}
}, [query])
- React.useEffect(() => {
- if (querySource.current !== QuerySource.tabbing) {
- setSuggestions(rawSuggestions)
- suggestionsRef.current = rawSuggestions
- }
- }, [rawSuggestions])
+ const selectedIndexDeps = useSyncRef({ query, setQuery, suggestions })
React.useEffect(() => {
+ const deps = selectedIndexDeps.current
if (
querySource.current === QuerySource.internal ||
querySource.current === QuerySource.tabbing
) {
- let newQuery = query
- const suggestion = selectedIndex == null ? null : suggestions[selectedIndex]
+ let newQuery = deps.query
+ const suggestion = selectedIndex == null ? null : deps.suggestions[selectedIndex]
if (suggestion != null) {
newQuery = suggestion.addToQuery(baseQuery.current)
- setQuery(newQuery)
+ deps.setQuery(newQuery)
}
searchRef.current?.focus()
const end = searchRef.current?.value.length ?? 0
@@ -197,9 +227,7 @@ function AssetSearchBar(props: AssetSearchBarProps) {
searchRef.current.value = newQuery.toString()
}
}
- // This effect MUST only run when `selectedIndex` changes.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedIndex])
+ }, [selectedIndex, selectedIndexDeps])
React.useEffect(() => {
const onSearchKeyDown = (event: KeyboardEvent) => {
@@ -270,7 +298,7 @@ function AssetSearchBar(props: AssetSearchBarProps) {
root?.removeEventListener('keydown', onSearchKeyDown)
document.removeEventListener('keydown', onKeyDown)
}
- }, [setQuery, modalRef, setAreSuggestionsVisible])
+ }, [setQuery, modalRef, setAreSuggestionsVisible, suggestionsRef])
// Reset `querySource` after all other effects have run.
React.useEffect(() => {
@@ -283,6 +311,30 @@ function AssetSearchBar(props: AssetSearchBarProps) {
}
}, [query, setQuery])
+ const onSearchFieldKeyDown = useEventCallback((event: aria.KeyboardEvent) => {
+ event.continuePropagation()
+ })
+
+ const searchFieldOnChange = useEventCallback((event: React.ChangeEvent) => {
+ if (querySource.current !== QuerySource.internal) {
+ querySource.current = QuerySource.typing
+ setQuery(AssetQuery.fromString(event.target.value))
+ }
+ })
+
+ const searchInputOnKeyDown = useEventCallback((event: React.KeyboardEvent) => {
+ if (
+ event.key === 'Enter' &&
+ !event.shiftKey &&
+ !event.altKey &&
+ !event.metaKey &&
+ !event.ctrlKey
+ ) {
+ // Clone the query to refresh results.
+ setQuery(query.clone())
+ }
+ })
+
return (
{(innerProps) => (
@@ -328,48 +380,15 @@ function AssetSearchBar(props: AssetSearchBarProps) {
src={FindIcon}
className="absolute left-2 top-[50%] z-1 mt-[1px] -translate-y-1/2 text-primary/40"
/>
-
- {
- event.continuePropagation()
- }}
- >
- {
- if (querySource.current !== QuerySource.internal) {
- querySource.current = QuerySource.typing
- setQuery(AssetQuery.fromString(event.target.value))
- }
- }}
- onKeyDown={(event) => {
- if (
- event.key === 'Enter' &&
- !event.shiftKey &&
- !event.altKey &&
- !event.metaKey &&
- !event.ctrlKey
- ) {
- // Clone the query to refresh results.
- setQuery(query.clone())
- }
- }}
- />
-
-
+
+
)}
@@ -377,6 +396,62 @@ function AssetSearchBar(props: AssetSearchBarProps) {
)
}
+/** Props for a {@link AssetSearchBarInput}. */
+interface AssetSearchBarInputProps {
+ readonly query: AssetQuery
+ readonly isCloud: boolean
+ readonly onSearchFieldKeyDown: (event: aria.KeyboardEvent) => void
+ readonly searchRef: React.RefObject
+ readonly searchFieldOnChange: (event: React.ChangeEvent) => void
+ readonly searchInputOnKeyDown: (event: React.KeyboardEvent) => void
+}
+
+/**
+ * Renders the search field.
+ */
+// eslint-disable-next-line no-restricted-syntax
+const AssetSearchBarInput = React.memo(function AssetSearchBarInput(
+ props: AssetSearchBarInputProps,
+) {
+ const {
+ query,
+ isCloud,
+ onSearchFieldKeyDown,
+ searchRef,
+ searchFieldOnChange,
+ searchInputOnKeyDown,
+ } = props
+ const { getText } = textProvider.useText()
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+})
+
/**
* Props for a {@link AssetSearchBarPopover}.
*/
@@ -416,12 +491,12 @@ function AssetSearchBarPopover(props: AssetSearchBarPopoverProps) {
return (
<>
-
+
{areSuggestionsVisible && (
{suggestions.map((suggestion, index) => (
{
- querySource.current = QuerySource.internal
+ unsafeWriteValue(querySource, 'current', QuerySource.internal)
setQuery(
selectedIndices.has(index) ?
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
@@ -566,6 +641,25 @@ const Labels = React.memo(function Labels(props: LabelsProps) {
const labels = backendHooks.useBackendQuery(backend, 'listTags', []).data ?? []
+ const labelOnPress = useEventCallback(
+ (event: aria.PressEvent | React.MouseEvent, label?: BackendLabel) => {
+ if (label == null) {
+ return
+ }
+ unsafeWriteValue(querySource, 'current', QuerySource.internal)
+ setQuery((oldQuery) => {
+ const newQuery = oldQuery.withToggled(
+ 'labels',
+ 'negativeLabels',
+ label.value,
+ event.shiftKey,
+ )
+ unsafeWriteValue(baseQuery, 'current', newQuery)
+ return newQuery
+ })
+ },
+ )
+
return (
<>
{isCloud && labels.length !== 0 && (
@@ -580,23 +674,12 @@ const Labels = React.memo(function Labels(props: LabelsProps) {
diff --git a/app/gui/src/dashboard/layouts/AssetVersions/AssetVersions.tsx b/app/gui/src/dashboard/layouts/AssetVersions/AssetVersions.tsx
index 2313857e7467..a3ff7ad56660 100644
--- a/app/gui/src/dashboard/layouts/AssetVersions/AssetVersions.tsx
+++ b/app/gui/src/dashboard/layouts/AssetVersions/AssetVersions.tsx
@@ -1,21 +1,22 @@
/** @file A list of previous versions of an asset. */
import * as React from 'react'
-import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
+import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
-import * as textProvider from '#/providers/TextProvider'
+import * as uniqueString from 'enso-common/src/utilities/uniqueString'
+import { Result } from '#/components/Result'
+import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import AssetVersion from '#/layouts/AssetVersions/AssetVersion'
-
+import * as textProvider from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
-import * as backendService from '#/services/Backend'
-
-import { Result } from '#/components/Result'
import type { AnyAsset } from '#/services/Backend'
+import * as backendService from '#/services/Backend'
import * as dateTime from '#/utilities/dateTime'
-import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
-import * as uniqueString from 'enso-common/src/utilities/uniqueString'
-import { assetVersionsQueryOptions } from './useAssetVersions.ts'
+import { noop } from '#/utilities/functions'
+import { useStore } from '#/utilities/zustand'
+import { assetPanelStore } from '../AssetPanel/AssetPanelState'
+import { assetVersionsQueryOptions } from './useAssetVersions'
// ==============================
// === AddNewVersionVariables ===
@@ -34,14 +35,17 @@ interface AddNewVersionVariables {
/** Props for a {@link AssetVersions}. */
export interface AssetVersionsProps {
readonly backend: Backend
- readonly item: AnyAsset | null
}
/**
* Display a list of previous versions of an asset.
*/
export default function AssetVersions(props: AssetVersionsProps) {
- const { item, backend } = props
+ const { backend } = props
+
+ const { item } = useStore(assetPanelStore, (state) => ({ item: state.assetPanelProps.item }), {
+ unsafeEnableTransition: true,
+ })
const { getText } = textProvider.useText()
@@ -82,13 +86,7 @@ function AssetVersionsInternal(props: AssetVersionsInternalProps) {
readonly backendService.S3ObjectVersion[]
>([])
- const versionsQuery = useSuspenseQuery(
- assetVersionsQueryOptions({
- assetId: item.id,
- backend,
- onError: (backendError) => toastAndLog('listVersionsError', backendError),
- }),
- )
+ const versionsQuery = useSuspenseQuery(assetVersionsQueryOptions({ assetId: item.id, backend }))
const latestVersion = versionsQuery.data.find((version) => version.isLatest)
@@ -140,7 +138,7 @@ function AssetVersionsInternal(props: AssetVersionsInternalProps) {
item={item}
backend={backend}
latestVersion={latestVersion}
- doRestore={() => {}}
+ doRestore={noop}
/>
)),
...versionsQuery.data.map((version, i) => (
diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx
index a1a40049dfe1..dba73cff8954 100644
--- a/app/gui/src/dashboard/layouts/AssetsTable.tsx
+++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx
@@ -1,5 +1,6 @@
/** @file Table displaying a list of projects. */
import {
+ memo,
startTransition,
useEffect,
useImperativeHandle,
@@ -16,7 +17,7 @@ import {
type SetStateAction,
} from 'react'
-import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-toastify'
import * as z from 'zod'
@@ -41,8 +42,8 @@ import NameColumn from '#/components/dashboard/column/NameColumn'
import { COLUMN_HEADING } from '#/components/dashboard/columnHeading'
import Label from '#/components/dashboard/Label'
import { ErrorDisplay } from '#/components/ErrorBoundary'
+import { IsolateLayout } from '#/components/IsolateLayout'
import SelectionBrush from '#/components/SelectionBrush'
-import { StatelessSpinner } from '#/components/StatelessSpinner'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import { ASSETS_MIME_TYPE } from '#/data/mimeTypes'
@@ -50,18 +51,23 @@ import AssetEventType from '#/events/AssetEventType'
import { useCutAndPaste, type AssetListEvent } from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import { useAutoScroll } from '#/hooks/autoScrollHooks'
-import {
- backendMutationOptions,
- listDirectoryQueryOptions,
- useBackendQuery,
- useRootDirectoryId,
- useUploadFiles,
-} from '#/hooks/backendHooks'
+import { backendMutationOptions, useBackendQuery, useUploadFiles } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useIntersectionRatio } from '#/hooks/intersectionHooks'
import { useOpenProject } from '#/hooks/projectHooks'
+import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
+import {
+ assetPanelStore,
+ useResetAssetPanelProps,
+ useSetAssetPanelProps,
+ useSetIsAssetPanelTemporarilyVisible,
+} from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
+import { useSetSuggestions } from '#/layouts/AssetSearchBar'
+import { useAssetsTableItems } from '#/layouts/AssetsTable/assetsTableItemsHooks'
+import { useAssetTree, type DirectoryQuery } from '#/layouts/AssetsTable/assetTreeHooks'
+import { useDirectoryIds } from '#/layouts/AssetsTable/directoryIdsHooks'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
import {
@@ -79,21 +85,15 @@ import {
} from '#/providers/BackendProvider'
import {
useDriveStore,
- useExpandedDirectoryIds,
- useResetAssetPanelProps,
- useSetAssetPanelProps,
useSetCanCreateAssets,
useSetCanDownload,
- useSetIsAssetPanelTemporarilyVisible,
useSetNewestFolderId,
useSetPasteData,
useSetSelectedKeys,
- useSetSuggestions,
useSetTargetDirectory,
useSetVisuallySelectedKeys,
useToggleDirectoryExpansion,
} from '#/providers/DriveProvider'
-import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { useSetModal } from '#/providers/ModalProvider'
@@ -102,14 +102,9 @@ import { useLaunchedProjects } from '#/providers/ProjectsProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import {
- assetIsDirectory,
assetIsProject,
AssetType,
BackendType,
- createRootDirectoryAsset,
- createSpecialEmptyAsset,
- createSpecialErrorAsset,
- createSpecialLoadingAsset,
getAssetPermissionName,
Plan,
ProjectId,
@@ -122,15 +117,15 @@ import {
type ProjectAsset,
} from '#/services/Backend'
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
-import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import type { AssetQueryKey } from '#/utilities/AssetQuery'
import AssetQuery from '#/utilities/AssetQuery'
+import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
-import AssetTreeNode from '#/utilities/AssetTreeNode'
import { toRfc3339 } from '#/utilities/dateTime'
import type { AssetRowsDragPayload } from '#/utilities/drag'
import { ASSET_ROWS, LABELS, setDragImageToBlank } from '#/utilities/drag'
import { fileExtension } from '#/utilities/fileInfo'
+import { noop } from '#/utilities/functions'
import type { DetailedRectangle } from '#/utilities/geometry'
import { DEFAULT_HANDLER } from '#/utilities/inputBindings'
import LocalStorage from '#/utilities/LocalStorage'
@@ -143,14 +138,9 @@ import {
import { document } from '#/utilities/sanitizedEventTargets'
import { EMPTY_SET, setPresence, withPresence } from '#/utilities/set'
import type { SortInfo } from '#/utilities/sorting'
-import { SortDirection } from '#/utilities/sorting'
-import { regexEscape } from '#/utilities/string'
import { twJoin, twMerge } from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
-
-// ============================
-// === Global configuration ===
-// ============================
+import { IndefiniteSpinner } from '../components/Spinner'
declare module '#/utilities/LocalStorage' {
/** */
@@ -163,10 +153,6 @@ LocalStorage.registerKey('enabledColumns', {
schema: z.nativeEnum(Column).array().readonly(),
})
-// =================
-// === Constants ===
-// =================
-
/**
* If the ratio of intersection between the main dropzone that should be visible, and the
* scrollable container, is below this value, then the backup dropzone will be shown.
@@ -182,11 +168,13 @@ const LOADING_SPINNER_SIZE_PX = 36
const SUGGESTIONS_FOR_NO: assetSearchBar.Suggestion[] = [
{
+ key: 'no:label',
render: () => 'no:label',
addToQuery: (query) => query.addToLastTerm({ nos: ['label'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ nos: ['label'] }),
},
{
+ key: 'no:description',
render: () => 'no:description',
addToQuery: (query) => query.addToLastTerm({ nos: ['description'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ nos: ['description'] }),
@@ -194,11 +182,13 @@ const SUGGESTIONS_FOR_NO: assetSearchBar.Suggestion[] = [
]
const SUGGESTIONS_FOR_HAS: assetSearchBar.Suggestion[] = [
{
+ key: 'has:label',
render: () => 'has:label',
addToQuery: (query) => query.addToLastTerm({ negativeNos: ['label'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ negativeNos: ['label'] }),
},
{
+ key: 'has:description',
render: () => 'has:description',
addToQuery: (query) => query.addToLastTerm({ negativeNos: ['description'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ negativeNos: ['description'] }),
@@ -206,26 +196,31 @@ const SUGGESTIONS_FOR_HAS: assetSearchBar.Suggestion[] = [
]
const SUGGESTIONS_FOR_TYPE: assetSearchBar.Suggestion[] = [
{
+ key: 'type:project',
render: () => 'type:project',
addToQuery: (query) => query.addToLastTerm({ types: ['project'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ types: ['project'] }),
},
{
+ key: 'type:folder',
render: () => 'type:folder',
addToQuery: (query) => query.addToLastTerm({ types: ['folder'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ types: ['folder'] }),
},
{
+ key: 'type:file',
render: () => 'type:file',
addToQuery: (query) => query.addToLastTerm({ types: ['file'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ types: ['file'] }),
},
{
+ key: 'type:secret',
render: () => 'type:secret',
addToQuery: (query) => query.addToLastTerm({ types: ['secret'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ types: ['secret'] }),
},
{
+ key: 'type:datalink',
render: () => 'type:datalink',
addToQuery: (query) => query.addToLastTerm({ types: ['datalink'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ types: ['datalink'] }),
@@ -233,31 +228,31 @@ const SUGGESTIONS_FOR_TYPE: assetSearchBar.Suggestion[] = [
]
const SUGGESTIONS_FOR_NEGATIVE_TYPE: assetSearchBar.Suggestion[] = [
{
+ key: 'type:project',
render: () => 'type:project',
addToQuery: (query) => query.addToLastTerm({ negativeTypes: ['project'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ negativeTypes: ['project'] }),
},
{
+ key: 'type:folder',
render: () => 'type:folder',
addToQuery: (query) => query.addToLastTerm({ negativeTypes: ['folder'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ negativeTypes: ['folder'] }),
},
{
+ key: 'type:file',
render: () => 'type:file',
addToQuery: (query) => query.addToLastTerm({ negativeTypes: ['file'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ negativeTypes: ['file'] }),
},
{
+ key: 'type:datalink',
render: () => 'type:datalink',
addToQuery: (query) => query.addToLastTerm({ negativeTypes: ['datalink'] }),
deleteFromQuery: (query) => query.deleteFromLastTerm({ negativeTypes: ['datalink'] }),
},
]
-// =========================
-// === DragSelectionInfo ===
-// =========================
-
/** Information related to a drag selection. */
interface DragSelectionInfo {
readonly initialIndex: number
@@ -265,15 +260,10 @@ interface DragSelectionInfo {
readonly end: number
}
-// ===================
-// === AssetsTable ===
-// ===================
-
/** State passed through from a {@link AssetsTable} to every cell. */
export interface AssetsTableState {
readonly backend: Backend
readonly rootDirectoryId: DirectoryId
- readonly expandedDirectoryIds: readonly DirectoryId[]
readonly scrollContainerRef: RefObject
readonly category: Category
readonly sortInfo: SortInfo | null
@@ -323,6 +313,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const setCanDownload = useSetCanDownload()
const setSuggestions = useSetSuggestions()
+ const queryClient = useQueryClient()
const { user } = useFullUserSession()
const backend = useBackend(category)
const { data: labels } = useBackendQuery(backend, 'listTags', [])
@@ -343,9 +334,18 @@ export default function AssetsTable(props: AssetsTableProps) {
const setAssetPanelProps = useSetAssetPanelProps()
const resetAssetPanelProps = useResetAssetPanelProps()
- const hiddenColumns = getColumnList(user, backend.type, category).filter(
- (column) => !enabledColumns.has(column),
+ const columns = useMemo(
+ () =>
+ getColumnList(user, backend.type, category).filter((column) => enabledColumns.has(column)),
+ [user, backend.type, category, enabledColumns],
+ )
+
+ const hiddenColumns = useMemo(
+ () =>
+ getColumnList(user, backend.type, category).filter((column) => !enabledColumns.has(column)),
+ [user, backend.type, category, enabledColumns],
)
+
const [sortInfo, setSortInfo] = useState | null>(null)
const driveStore = useDriveStore()
const setNewestFolderId = useSetNewestFolderId()
@@ -357,402 +357,45 @@ export default function AssetsTable(props: AssetsTableProps) {
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
const nameOfProjectToImmediatelyOpenRef = useRef(initialProjectName)
- const rootDirectoryId = useRootDirectoryId(backend, category)
-
- const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
- const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh')
- const assetsTableBackgroundRefreshInterval = useFeatureFlag(
- 'assetsTableBackgroundRefreshInterval',
- )
- const expandedDirectoryIdsRaw = useExpandedDirectoryIds()
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
- const expandedDirectoryIds = useMemo(
- () => [rootDirectoryId].concat(expandedDirectoryIdsRaw),
- [expandedDirectoryIdsRaw, rootDirectoryId],
+ const uploadFiles = useUploadFiles(backend, category)
+ const duplicateProjectMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'duplicateProject'), [backend]),
)
-
- const expandedDirectoryIdsSet = useMemo(
- () => new Set(expandedDirectoryIds),
- [expandedDirectoryIds],
+ const updateSecretMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'updateSecret'), [backend]),
+ )
+ const copyAssetMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'copyAsset'), [backend]),
+ )
+ const deleteAssetMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'deleteAsset'), [backend]),
+ )
+ const undoDeleteAssetMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'undoDeleteAsset'), [backend]),
+ )
+ const updateAssetMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'updateAsset'), [backend]),
+ )
+ const closeProjectMutation = useMutation(
+ useMemo(() => backendMutationOptions(backend, 'closeProject'), [backend]),
)
- const uploadFiles = useUploadFiles(backend, category)
- const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
- const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
- const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
- const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
- const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
- const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
- const closeProjectMutation = useMutation(backendMutationOptions(backend, 'closeProject'))
-
- const directories = useQueries({
- // We query only expanded directories, as we don't want to load the data for directories that are not visible.
- queries: expandedDirectoryIds.map((directoryId) => ({
- ...listDirectoryQueryOptions({
- backend,
- parentId: directoryId,
- category,
- }),
- enabled: !hidden,
- })),
- combine: (results) => {
- const rootQuery = results[expandedDirectoryIds.indexOf(rootDirectory.id)]
-
- return {
- rootDirectory: {
- isFetching: rootQuery?.isFetching ?? true,
- isLoading: rootQuery?.isLoading ?? true,
- isError: rootQuery?.isError ?? false,
- error: rootQuery?.error,
- data: rootQuery?.data,
- },
- directories: new Map(
- results.map((res, i) => [
- expandedDirectoryIds[i],
- {
- isFetching: res.isFetching,
- isLoading: res.isLoading,
- isError: res.isError,
- error: res.error,
- data: res.data,
- },
- ]),
- ),
- }
- },
- })
-
- // We use a different query to refetch the directory data in the background.
- // This reduces the amount of rerenders by batching them together, so they happen less often.
- useQuery({
- queryKey: [backend.type, 'refetchListDirectory'],
- queryFn: () => {
- return queryClient
- .refetchQueries({ queryKey: [backend.type, 'listDirectory'] })
- .then(() => null)
- },
- refetchInterval:
- enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false,
- refetchOnMount: 'always',
- refetchIntervalInBackground: false,
- refetchOnWindowFocus: true,
- enabled: !hidden,
- meta: { persist: false },
- })
-
- /** Return type of the query function for the listDirectory query. */
- type DirectoryQuery = typeof directories.rootDirectory.data
-
- const rootDirectoryContent = directories.rootDirectory.data
- const isLoading = directories.rootDirectory.isLoading && !directories.rootDirectory.isError
-
- const assetTree = useMemo(() => {
- const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath(user)
-
- // If the root directory is not loaded, then we cannot render the tree.
- // Return null, and wait for the root directory to load.
- if (rootDirectoryContent == null) {
- return AssetTreeNode.fromAsset(
- createRootDirectoryAsset(rootDirectoryId),
- ROOT_PARENT_DIRECTORY_ID,
- ROOT_PARENT_DIRECTORY_ID,
- -1,
- rootPath,
- null,
- )
- } else if (directories.rootDirectory.isError) {
- return AssetTreeNode.fromAsset(
- createRootDirectoryAsset(rootDirectoryId),
- ROOT_PARENT_DIRECTORY_ID,
- ROOT_PARENT_DIRECTORY_ID,
- -1,
- rootPath,
- null,
- ).with({
- children: [
- AssetTreeNode.fromAsset(
- createSpecialErrorAsset(rootDirectoryId),
- rootDirectoryId,
- rootDirectoryId,
- 0,
- '',
- ),
- ],
- })
- }
-
- const rootId = rootDirectory.id
-
- const children = rootDirectoryContent.map((content) => {
- /**
- * Recursively build assets tree. If a child is a directory, we search for its content
- * in the loaded data. If it is loaded, we append that data to the asset node
- * and do the same for the children.
- */
- const withChildren = (node: AnyAssetTreeNode, depth: number) => {
- const { item } = node
-
- if (assetIsDirectory(item)) {
- const childrenAssetsQuery = directories.directories.get(item.id)
-
- const nestedChildren = childrenAssetsQuery?.data?.map((child) =>
- AssetTreeNode.fromAsset(
- child,
- item.id,
- item.id,
- depth,
- `${node.path}/${child.title}`,
- null,
- child.id,
- ),
- )
-
- if (childrenAssetsQuery == null || childrenAssetsQuery.isLoading) {
- node = node.with({
- children: [
- AssetTreeNode.fromAsset(
- createSpecialLoadingAsset(item.id),
- item.id,
- item.id,
- depth,
- '',
- ),
- ],
- })
- } else if (childrenAssetsQuery.isError) {
- node = node.with({
- children: [
- AssetTreeNode.fromAsset(
- createSpecialErrorAsset(item.id),
- item.id,
- item.id,
- depth,
- '',
- ),
- ],
- })
- } else if (nestedChildren?.length === 0) {
- node = node.with({
- children: [
- AssetTreeNode.fromAsset(
- createSpecialEmptyAsset(item.id),
- item.id,
- item.id,
- depth,
- '',
- ),
- ],
- })
- } else if (nestedChildren != null) {
- node = node.with({
- children: nestedChildren.map((child) => withChildren(child, depth + 1)),
- })
- }
- }
-
- return node
- }
-
- const node = AssetTreeNode.fromAsset(
- content,
- rootId,
- rootId,
- 0,
- `${rootPath}/${content.title}`,
- null,
- content.id,
- )
-
- const ret = withChildren(node, 1)
- return ret
- })
-
- return new AssetTreeNode(
- rootDirectory,
- ROOT_PARENT_DIRECTORY_ID,
- ROOT_PARENT_DIRECTORY_ID,
- children,
- -1,
- rootPath,
- null,
- rootId,
- )
- }, [
+ const { rootDirectoryId, rootDirectory, expandedDirectoryIds } = useDirectoryIds({ category })
+ const { isLoading, isError, assetTree } = useAssetTree({
+ hidden,
category,
- backend,
- user,
- rootDirectoryContent,
- directories.rootDirectory.isError,
- directories.directories,
rootDirectory,
- rootDirectoryId,
- ])
-
- const filter = useMemo(() => {
- const globCache: Record = {}
- if (/^\s*$/.test(query.query)) {
- return null
- } else {
- return (node: AnyAssetTreeNode) => {
- if (
- node.item.type === AssetType.specialEmpty ||
- node.item.type === AssetType.specialLoading
- ) {
- return false
- }
- const assetType =
- node.item.type === AssetType.directory ? 'folder'
- : node.item.type === AssetType.datalink ? 'datalink'
- : String(node.item.type)
- const assetExtension =
- node.item.type !== AssetType.file ? null : fileExtension(node.item.title).toLowerCase()
- const assetModifiedAt = new Date(node.item.modifiedAt)
- const nodeLabels: readonly string[] = node.item.labels ?? []
- const lowercaseName = node.item.title.toLowerCase()
- const lowercaseDescription = node.item.description?.toLowerCase() ?? ''
- const owners =
- node.item.permissions
- ?.filter((permission) => permission.permission === PermissionAction.own)
- .map(getAssetPermissionName) ?? []
- const globMatch = (glob: string, match: string) => {
- const regex = (globCache[glob] =
- globCache[glob] ??
- new RegExp('^' + regexEscape(glob).replace(/(?:\\\*)+/g, '.*') + '$', 'i'))
- return regex.test(match)
- }
- const isAbsent = (type: string) => {
- switch (type) {
- case 'label':
- case 'labels': {
- return nodeLabels.length === 0
- }
- case 'name': {
- // Should never be true, but handle it just in case.
- return lowercaseName === ''
- }
- case 'description': {
- return lowercaseDescription === ''
- }
- case 'extension': {
- // Should never be true, but handle it just in case.
- return assetExtension === ''
- }
- }
- // Things like `no:name` and `no:owner` are never true.
- return false
- }
- const parseDate = (date: string) => {
- const lowercase = date.toLowerCase()
- switch (lowercase) {
- case 'today': {
- return new Date()
- }
- }
- return new Date(date)
- }
- const matchesDate = (date: string) => {
- const parsed = parseDate(date)
- return (
- parsed.getFullYear() === assetModifiedAt.getFullYear() &&
- parsed.getMonth() === assetModifiedAt.getMonth() &&
- parsed.getDate() === assetModifiedAt.getDate()
- )
- }
- const isEmpty = (values: string[]) =>
- values.length === 0 || (values.length === 1 && values[0] === '')
- const filterTag = (
- positive: string[][],
- negative: string[][],
- predicate: (value: string) => boolean,
- ) =>
- positive.every((values) => isEmpty(values) || values.some(predicate)) &&
- negative.every((values) => !values.some(predicate))
- return (
- filterTag(query.nos, query.negativeNos, (no) => isAbsent(no.toLowerCase())) &&
- filterTag(query.keywords, query.negativeKeywords, (keyword) =>
- lowercaseName.includes(keyword.toLowerCase()),
- ) &&
- filterTag(query.names, query.negativeNames, (name) => globMatch(name, lowercaseName)) &&
- filterTag(query.labels, query.negativeLabels, (label) =>
- nodeLabels.some((assetLabel) => globMatch(label, assetLabel)),
- ) &&
- filterTag(query.types, query.negativeTypes, (type) => type === assetType) &&
- filterTag(
- query.extensions,
- query.negativeExtensions,
- (extension) => extension.toLowerCase() === assetExtension,
- ) &&
- filterTag(query.descriptions, query.negativeDescriptions, (description) =>
- lowercaseDescription.includes(description.toLowerCase()),
- ) &&
- filterTag(query.modifieds, query.negativeModifieds, matchesDate) &&
- filterTag(query.owners, query.negativeOwners, (owner) =>
- owners.some((assetOwner) => globMatch(owner, assetOwner)),
- )
- )
- }
- }
- }, [query])
-
- const visibilities = useMemo(() => {
- const map = new Map()
- const processNode = (node: AnyAssetTreeNode) => {
- let displayState = Visibility.hidden
- const visible = filter?.(node) ?? true
- for (const child of node.children ?? []) {
- if (visible && child.item.type === AssetType.specialEmpty) {
- map.set(child.key, Visibility.visible)
- } else {
- processNode(child)
- }
- if (map.get(child.key) !== Visibility.hidden) {
- displayState = Visibility.faded
- }
- }
- if (visible) {
- displayState = Visibility.visible
- }
- map.set(node.key, displayState)
- return displayState
- }
- processNode(assetTree)
- return map
- }, [assetTree, filter])
-
- const displayItems = useMemo(() => {
- if (sortInfo == null) {
- return assetTree.preorderTraversal((children) =>
- children.filter((child) => expandedDirectoryIdsSet.has(child.directoryId)),
- )
- } else {
- const multiplier = sortInfo.direction === SortDirection.ascending ? 1 : -1
- let compare: (a: AnyAssetTreeNode, b: AnyAssetTreeNode) => number
- switch (sortInfo.field) {
- case Column.name: {
- compare = (a, b) => multiplier * a.item.title.localeCompare(b.item.title, 'en')
- break
- }
- case Column.modified: {
- compare = (a, b) => {
- const aOrder = Number(new Date(a.item.modifiedAt))
- const bOrder = Number(new Date(b.item.modifiedAt))
- return multiplier * (aOrder - bOrder)
- }
- break
- }
- }
- return assetTree.preorderTraversal((tree) =>
- [...tree].filter((child) => expandedDirectoryIdsSet.has(child.directoryId)).sort(compare),
- )
- }
- }, [assetTree, sortInfo, expandedDirectoryIdsSet])
-
- const visibleItems = useMemo(
- () => displayItems.filter((item) => visibilities.get(item.key) !== Visibility.hidden),
- [displayItems, visibilities],
- )
+ expandedDirectoryIds,
+ })
+ const { displayItems, visibleItems, visibilities } = useAssetsTableItems({
+ assetTree,
+ query,
+ sortInfo,
+ expandedDirectoryIds,
+ })
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [droppedFilesCount, setDroppedFilesCount] = useState(0)
@@ -771,8 +414,6 @@ export default function AssetsTable(props: AssetsTableProps) {
const isAssetContextMenuVisible =
category.type !== 'cloud' || user.plan == null || user.plan === Plan.solo
- const queryClient = useQueryClient()
-
const isMainDropzoneVisible = useIntersectionRatio(
rootRef,
mainDropzoneRef,
@@ -814,7 +455,10 @@ export default function AssetsTable(props: AssetsTableProps) {
if (item != null && item.isType(AssetType.directory)) {
setTargetDirectory(item)
}
- if (item != null && item.item.id !== driveStore.getState().assetPanelProps.item?.id) {
+ if (
+ item != null &&
+ item.item.id !== assetPanelStore.getState().assetPanelProps.item?.id
+ ) {
setAssetPanelProps({ backend, item: item.item, path: item.path })
setIsAssetPanelTemporarilyVisible(false)
}
@@ -869,6 +513,7 @@ export default function AssetsTable(props: AssetsTableProps) {
node: AnyAssetTreeNode,
key: AssetQueryKey = 'names',
): assetSearchBar.Suggestion => ({
+ key: node.item.id,
render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`,
addToQuery: (oldQuery) => oldQuery.addToLastTerm({ [key]: [node.item.title] }),
deleteFromQuery: (oldQuery) => oldQuery.deleteFromLastTerm({ [key]: [node.item.title] }),
@@ -884,12 +529,18 @@ export default function AssetsTable(props: AssetsTableProps) {
node.item.type !== AssetType.specialEmpty &&
node.item.type !== AssetType.specialLoading,
)
- const allVisible = (negative = false) =>
- allVisibleNodes().map((node) => nodeToSuggestion(node, negative ? 'negativeNames' : 'names'))
+
+ const allVisible = (negative = false) => {
+ return allVisibleNodes().map((node) =>
+ nodeToSuggestion(node, negative ? 'negativeNames' : 'names'),
+ )
+ }
+
const terms = AssetQuery.terms(query.query)
const term = terms.find((otherTerm) => otherTerm.values.length === 0) ?? terms[terms.length - 1]
const termValues = term?.values ?? []
const shouldOmitNames = terms.some((otherTerm) => otherTerm.tag === 'name')
+
if (termValues.length !== 0) {
setSuggestions(shouldOmitNames ? [] : allVisible())
} else {
@@ -932,6 +583,7 @@ export default function AssetsTable(props: AssetsTableProps) {
Array.from(
new Set(extensions),
(extension): assetSearchBar.Suggestion => ({
+ key: extension,
render: () =>
AssetQuery.termToString({
tag: `${negative ? '-' : ''}extension`,
@@ -960,6 +612,7 @@ export default function AssetsTable(props: AssetsTableProps) {
Array.from(
new Set(['today', ...modifieds]),
(modified): assetSearchBar.Suggestion => ({
+ key: modified,
render: () =>
AssetQuery.termToString({
tag: `${negative ? '-' : ''}modified`,
@@ -991,6 +644,7 @@ export default function AssetsTable(props: AssetsTableProps) {
Array.from(
new Set(owners),
(owner): assetSearchBar.Suggestion => ({
+ key: owner,
render: () =>
AssetQuery.termToString({
tag: `${negative ? '-' : ''}owner`,
@@ -1014,6 +668,7 @@ export default function AssetsTable(props: AssetsTableProps) {
setSuggestions(
(labels ?? []).map(
(label): assetSearchBar.Suggestion => ({
+ key: label.value,
render: () => (
|