diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index f7602277c0f3..4ee695375a92 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -56,7 +56,7 @@ declare module '@tanstack/query-core' { /** Query Client type suitable for shared use in React and Vue. */ export type QueryClient = vueQuery.QueryClient -const DEFAULT_QUERY_STALE_TIME_MS = 2 * 60 * 1000 +const DEFAULT_QUERY_STALE_TIME_MS = Infinity const DEFAULT_QUERY_PERSIST_TIME_MS = 30 * 24 * 60 * 60 * 1000 // 30 days const DEFAULT_BUSTER = 'v1.1' diff --git a/app/gui/e2e/dashboard/actions/index.ts b/app/gui/e2e/dashboard/actions/index.ts index d8bf335db50c..6581a4bb1445 100644 --- a/app/gui/e2e/dashboard/actions/index.ts +++ b/app/gui/e2e/dashboard/actions/index.ts @@ -1,6 +1,5 @@ /** @file Various actions, locators, and constants used in end-to-end tests. */ import * as test from '@playwright/test' -import * as path from 'path' import { TEXTS } from 'enso-common/src/text' diff --git a/app/gui/e2e/dashboard/loginScreen.spec.ts b/app/gui/e2e/dashboard/loginScreen.spec.ts index 46617d207d52..ca0a5fb23940 100644 --- a/app/gui/e2e/dashboard/loginScreen.spec.ts +++ b/app/gui/e2e/dashboard/loginScreen.spec.ts @@ -1,14 +1,7 @@ /** @file Test the login flow. */ import * as test from '@playwright/test' -import { - INVALID_PASSWORD, - mockAll, - passAgreementsDialog, - TEXT, - VALID_EMAIL, - VALID_PASSWORD, -} from './actions' +import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions' // ============= // === Tests === diff --git a/app/gui/package.json b/app/gui/package.json index aa0e421a048d..0572a4b3d0e7 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -21,8 +21,7 @@ "build": "vite build", "build-cloud": "cross-env CLOUD_BUILD=true corepack pnpm run build", "preview": "vite preview", - "//": "max-warnings set to 41 to match the amount of warnings introduced by the new react compiler. Eventual goal is to remove all the warnings.", - "lint": "eslint . --cache --max-warnings=39", + "lint": "eslint . --max-warnings=0", "format": "prettier --version && prettier --write src/ && eslint . --fix", "dev:vite": "vite", "test": "corepack pnpm run /^^^^test:.*/", diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index b5a6870b9eaf..b4791b73f2eb 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -227,6 +227,27 @@ export default function App(props: AppProps) { }, }) + const queryClient = props.queryClient + + // Force all queries to be stale + // We don't use the `staleTime` option because it's not performant + // and triggers unnecessary setTimeouts. + reactQuery.useQuery({ + queryKey: ['refresh'], + queryFn: () => { + queryClient + .getQueryCache() + .getAll() + .forEach((query) => { + query.isStale = () => true + }) + + return null + }, + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + refetchInterval: 2 * 60 * 1000, + }) + // Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`. // Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` // will redirect the user between the login/register pages and the dashboard. @@ -279,9 +300,11 @@ function AppRouter(props: AppRouterProps) { const httpClient = useHttpClient() const logger = useLogger() const navigate = router.useNavigate() + const { getText } = textProvider.useText() const { localStorage } = localStorageProvider.useLocalStorage() const { setModal } = modalProvider.useSetModal() + const navigator2D = navigator2DProvider.useNavigator2D() const localBackend = React.useMemo( diff --git a/app/gui/src/dashboard/components/AnimatedBackground.tsx b/app/gui/src/dashboard/components/AnimatedBackground.tsx index da1babb13955..652f43c0ae6d 100644 --- a/app/gui/src/dashboard/components/AnimatedBackground.tsx +++ b/app/gui/src/dashboard/components/AnimatedBackground.tsx @@ -3,7 +3,7 @@ * * `` component visually highlights selected items by sliding a background into view when hovered over or clicked. */ -import type { Transition } from 'framer-motion' +import type { Transition, Variants } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion' import type { PropsWithChildren } from 'react' import { createContext, memo, useContext, useId, useMemo } from 'react' @@ -34,9 +34,9 @@ const DEFAULT_TRANSITION: Transition = { // eslint-disable-next-line @typescript-eslint/no-magic-numbers damping: 20, // eslint-disable-next-line @typescript-eslint/no-magic-numbers - mass: 0.1, + mass: 0.5, // eslint-disable-next-line @typescript-eslint/no-magic-numbers - velocity: 12, + velocity: 8, } /** `` component visually highlights selected items by sliding a background into view when hovered over or clicked. */ @@ -92,9 +92,16 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa animationClassName, children, isSelected, - underlayElement =
, + underlayElement: rawUnderlayElement, } = props + const defaultUnderlayElement = useMemo( + () =>
, + [animationClassName], + ) + + const underlayElement = rawUnderlayElement ?? defaultUnderlayElement + const context = useContext(AnimatedBackgroundContext) invariant(context, ' must be placed within an ') const { value: activeValue, transition, layoutId } = context @@ -107,7 +114,7 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa const isActive = isSelected ?? activeValue === value return ( -
+
- {children} +
{children}
) }) @@ -130,6 +137,11 @@ interface AnimatedBackgroundItemUnderlayProps { readonly transition: Transition } +const VARIANTS: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +} + /** * Underlay for {@link AnimatedBackground.Item}. */ @@ -145,11 +157,12 @@ const AnimatedBackgroundItemUnderlay = memo(function AnimatedBackgroundItemUnder {underlayElement} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index 282f5fb2389b..1307c0fc9fc4 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -1,17 +1,25 @@ /** @file A styled button. */ -import * as React from 'react' +import { + memo, + useLayoutEffect, + useRef, + useState, + type ForwardedRef, + type ReactElement, + type ReactNode, +} from 'react' -import * as focusHooks from '#/hooks/focusHooks' +import { useFocusChild } from '#/hooks/focusHooks' import * as aria from '#/components/aria' import { StatelessSpinner } from '#/components/StatelessSpinner' import SvgMask from '#/components/SvgMask' +import { TEXT_STYLE, useVisualTooltip } from '#/components/AriaComponents/Text' +import { Tooltip, TooltipTrigger } from '#/components/AriaComponents/Tooltip' import { forwardRef } from '#/utilities/react' import type { ExtractFunction, VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' -import { TEXT_STYLE, useVisualTooltip } from '../Text' -import { Tooltip, TooltipTrigger } from '../Tooltip' // ============== // === Button === @@ -36,14 +44,10 @@ interface PropsWithoutHref { export interface BaseButtonProps extends Omit, 'iconOnly'> { /** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */ - readonly tooltip?: React.ReactElement | string | false | null + readonly tooltip?: ReactElement | string | false | null readonly tooltipPlacement?: aria.Placement /** The icon to display in the button */ - readonly icon?: - | React.ReactElement - | string - | ((render: Render) => React.ReactElement | string | null) - | null + readonly icon?: ReactElement | string | ((render: Render) => ReactElement | string | null) | null /** When `true`, icon will be shown only when hovered. */ readonly showIconOnHover?: boolean /** @@ -267,6 +271,7 @@ export const BUTTON_STYLES = tv({ { size: 'medium', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4 h-4' } }, { size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4.5 h-4.5' } }, { size: 'hero', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-12 h-12' } }, + { fullWidth: false, class: { icon: 'flex-none' } }, { variant: 'link', isFocused: true, class: 'focus-visible:outline-offset-1' }, { variant: 'link', size: 'xxsmall', class: 'font-medium' }, @@ -279,217 +284,214 @@ export const BUTTON_STYLES = tv({ }) /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ -export const Button = forwardRef(function Button( - props: ButtonProps, - ref: React.ForwardedRef, -) { - const { - className, - contentClassName, - children, - variant, - icon, - loading = false, - isActive, - showIconOnHover, - iconPosition, - size, - fullWidth, - rounded, - tooltip, - tooltipPlacement, - testId, - loaderPosition = 'full', - extraClickZone: extraClickZoneProp, - onPress = () => {}, - variants = BUTTON_STYLES, - ...ariaProps - } = props - const focusChildProps = focusHooks.useFocusChild() +export const Button = memo( + forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { + const { + className, + contentClassName, + children, + variant, + icon, + loading = false, + isActive, + showIconOnHover, + iconPosition, + size, + fullWidth, + rounded, + tooltip, + tooltipPlacement, + testId, + loaderPosition = 'full', + extraClickZone: extraClickZoneProp, + onPress = () => {}, + variants = BUTTON_STYLES, + ...ariaProps + } = props + const focusChildProps = useFocusChild() - const [implicitlyLoading, setImplicitlyLoading] = React.useState(false) - const contentRef = React.useRef(null) - const loaderRef = React.useRef(null) + const [implicitlyLoading, setImplicitlyLoading] = useState(false) + const contentRef = useRef(null) + const loaderRef = useRef(null) - const isLink = ariaProps.href != null + const isLink = ariaProps.href != null - const Tag = isLink ? aria.Link : aria.Button + const Tag = isLink ? aria.Link : aria.Button - const goodDefaults = { - ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), - 'data-testid': testId, - } - - const isIconOnly = (children == null || children === '' || children === false) && icon != null - const shouldShowTooltip = (() => { - if (tooltip === false) { - return false - } else if (isIconOnly) { - return true - } else { - return tooltip != null + const goodDefaults = { + ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), + 'data-testid': testId, } - })() - const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null - const isLoading = loading || implicitlyLoading - const isDisabled = props.isDisabled ?? isLoading - const shouldUseVisualTooltip = shouldShowTooltip && isDisabled + const isIconOnly = (children == null || children === '' || children === false) && icon != null + const shouldShowTooltip = (() => { + if (tooltip === false) { + return false + } else if (isIconOnly) { + return true + } else { + return tooltip != null + } + })() + const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null - React.useLayoutEffect(() => { - const delay = 350 + const isLoading = loading || implicitlyLoading + const isDisabled = props.isDisabled ?? isLoading + const shouldUseVisualTooltip = shouldShowTooltip && isDisabled - if (isLoading) { - const loaderAnimation = loaderRef.current?.animate( - [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], - { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, - ) - const contentAnimation = - loaderPosition !== 'full' ? null : ( - contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 0, - easing: 'linear', - delay, - fill: 'forwards', - }) + useLayoutEffect(() => { + const delay = 350 + + if (isLoading) { + const loaderAnimation = loaderRef.current?.animate( + [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], + { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, ) + const contentAnimation = + loaderPosition !== 'full' ? null : ( + contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 0, + easing: 'linear', + delay, + fill: 'forwards', + }) + ) - return () => { - loaderAnimation?.cancel() - contentAnimation?.cancel() + return () => { + loaderAnimation?.cancel() + contentAnimation?.cancel() + } + } else { + return () => {} } - } else { - return () => {} - } - }, [isLoading, loaderPosition]) + }, [isLoading, loaderPosition]) - const handlePress = (event: aria.PressEvent): void => { - if (!isDisabled) { - const result = onPress?.(event) + const handlePress = (event: aria.PressEvent): void => { + if (!isDisabled) { + const result = onPress?.(event) - if (result instanceof Promise) { - setImplicitlyLoading(true) - void result.finally(() => { - setImplicitlyLoading(false) - }) + if (result instanceof Promise) { + setImplicitlyLoading(true) + void result.finally(() => { + setImplicitlyLoading(false) + }) + } } } - } - const styles = variants({ - isDisabled, - isActive, - loading: isLoading, - fullWidth, - size, - rounded, - variant, - iconPosition, - showIconOnHover, - extraClickZone: extraClickZoneProp, - iconOnly: isIconOnly, - }) + const styles = variants({ + isDisabled, + isActive, + loading: isLoading, + fullWidth, + size, + rounded, + variant, + iconPosition, + showIconOnHover, + extraClickZone: extraClickZoneProp, + iconOnly: isIconOnly, + }) - const childrenFactory = ( - render: aria.ButtonRenderProps | aria.LinkRenderProps, - ): React.ReactNode => { - const iconComponent = (() => { - if (isLoading && loaderPosition === 'icon') { - return ( - - - - ) - } else if (icon == null) { - return null - } else { - /* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */ - const actualIcon = typeof icon === 'function' ? icon(render) : icon - - if (typeof actualIcon === 'string') { - return + const childrenFactory = (render: aria.ButtonRenderProps | aria.LinkRenderProps): ReactNode => { + const iconComponent = (() => { + if (isLoading && loaderPosition === 'icon') { + return ( + + + + ) + } else if (icon == null) { + return null } else { - return {actualIcon} + /* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */ + const actualIcon = typeof icon === 'function' ? icon(render) : icon + + if (typeof actualIcon === 'string') { + return + } else { + return {actualIcon} + } } + })() + // Icon only button + if (isIconOnly) { + return {iconComponent} + } else { + // Default button + return ( + <> + {iconComponent} + + {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} + {typeof children === 'function' ? children(render) : children} + + + ) } - })() - // Icon only button - if (isIconOnly) { - return {iconComponent} - } else { - // Default button - return ( - <> - {iconComponent} - - {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} - {typeof children === 'function' ? children(render) : children} - - - ) } - } - const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ - targetRef: contentRef, - children: tooltipElement, - isDisabled: !shouldUseVisualTooltip, - ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), - }) + const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ + targetRef: contentRef, + children: tooltipElement, + isDisabled: !shouldUseVisualTooltip, + ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), + }) - const button = ( - ()(goodDefaults, ariaProps, focusChildProps, { - isDisabled, - // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger - // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered - onPressEnd: (e) => { - if (!isDisabled) { - handlePress(e) - } - }, - className: aria.composeRenderProps(className, (classNames, states) => - styles.base({ className: classNames, ...states }), - ), - })} - > - {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( - - - {} - {childrenFactory(render)} - - - {isLoading && loaderPosition === 'full' && ( - - + const button = ( + ()(goodDefaults, ariaProps, focusChildProps, { + isDisabled, + // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger + // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered + onPressEnd: (e) => { + if (!isDisabled) { + handlePress(e) + } + }, + className: aria.composeRenderProps(className, (classNames, states) => + styles.base({ className: classNames, ...states }), + ), + })} + > + {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( + + + {} + {childrenFactory(render)} - )} - - )} - - ) - return ( - tooltipElement == null ? button - : shouldUseVisualTooltip ? - <> - {button} - {visualTooltip} - - : - {button} + {isLoading && loaderPosition === 'full' && ( + + + + )} + + )} + + ) - - {tooltipElement} - - - ) -}) + return ( + tooltipElement == null ? button + : shouldUseVisualTooltip ? + <> + {button} + {visualTooltip} + + : + {button} + + + {tooltipElement} + + + ) + }), +) diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx index adbf93e67266..b1d60f264ea5 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx @@ -4,6 +4,7 @@ import { Button, type ButtonProps } from '#/components/AriaComponents/Button' import { useText } from '#/providers/TextProvider' import { twMerge } from '#/utilities/tailwindMerge' import { isOnMacOS } from 'enso-common/src/detect' +import { memo } from 'react' // =================== // === CloseButton === @@ -13,7 +14,7 @@ import { isOnMacOS } from 'enso-common/src/detect' export type CloseButtonProps = Omit /** A styled button with a close icon that appears on hover. */ -export function CloseButton(props: CloseButtonProps) { +export const CloseButton = memo(function CloseButton(props: CloseButtonProps) { const { getText } = useText() const { className, @@ -26,13 +27,14 @@ export function CloseButton(props: CloseButtonProps) { return (
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: () => (