From 2ca5ce4a4669a2c95cb564d6e8f2f5d4b37e5bb8 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 24 Jan 2025 14:01:44 +0400 Subject: [PATCH 01/34] Fixes --- .../dashboard/components/AriaComponents/Inputs/Input/Input.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx index 3a1f25d9366a..f4e2c33dbf6f 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx @@ -78,6 +78,7 @@ export const Input = forwardRef(function Input< autoFocus = false, className, testId: testIdRaw, + label, ...inputProps } = props const form = Form.useFormContext(formRaw) @@ -140,6 +141,7 @@ export const Input = forwardRef(function Input< variants: fieldVariants, form: formInstance, })} + label={label} ref={ref} name={props.name} data-testid={testId} From 3085402c6c2a786aa1bf741da66b1f23a938d11f Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 24 Jan 2025 14:01:55 +0400 Subject: [PATCH 02/34] Fix input --- .../dashboard/components/AriaComponents/Inputs/Input/Input.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx index f4e2c33dbf6f..3a1f25d9366a 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx @@ -78,7 +78,6 @@ export const Input = forwardRef(function Input< autoFocus = false, className, testId: testIdRaw, - label, ...inputProps } = props const form = Form.useFormContext(formRaw) @@ -141,7 +140,6 @@ export const Input = forwardRef(function Input< variants: fieldVariants, form: formInstance, })} - label={label} ref={ref} name={props.name} data-testid={testId} From aa4acb3fbbb7cdd4e724daa0724ad75b48c17808 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 24 Jan 2025 14:16:59 +0400 Subject: [PATCH 03/34] Fix styling --- app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx index cb5c0ab75c83..5c1981cf331d 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx @@ -161,6 +161,8 @@ export const Text = memo( const textElementRef = React.useRef(null) const textContext = textProvider.useTextContext() + console.log('textContext', className) + const textClasses = TEXT_STYLE({ variant, font, From b94f1d742ef0f3664711a35e6e3f6f844d2dbed1 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 24 Jan 2025 14:17:30 +0400 Subject: [PATCH 04/34] Remove console.log --- app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx index 5c1981cf331d..cb5c0ab75c83 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx @@ -161,8 +161,6 @@ export const Text = memo( const textElementRef = React.useRef(null) const textContext = textProvider.useTextContext() - console.log('textContext', className) - const textClasses = TEXT_STYLE({ variant, font, From 6e97217e036e0a241895af74b2c3a1ca6e2a0629 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 27 Jan 2025 16:41:04 +0400 Subject: [PATCH 05/34] Draft breadcrumbs in topbar --- .../dashboard/assets/expand_arrow_left.svg | 3 + .../components/AriaComponents/Text/Text.tsx | 2 +- .../components/Breadcrumbs/Breadcrumbs.tsx | 4 +- .../dashboard/column/PathColumn.tsx | 6 +- .../components/AssetPanelToggle.tsx | 2 +- .../dashboard/layouts/CategorySwitcher.tsx | 2 +- app/gui/src/dashboard/layouts/Drive.tsx | 44 ++-- app/gui/src/dashboard/layouts/DriveBar.tsx | 239 ++++++++++-------- 8 files changed, 169 insertions(+), 133 deletions(-) create mode 100644 app/gui/src/dashboard/assets/expand_arrow_left.svg diff --git a/app/gui/src/dashboard/assets/expand_arrow_left.svg b/app/gui/src/dashboard/assets/expand_arrow_left.svg new file mode 100644 index 000000000000..23fbccd04a8a --- /dev/null +++ b/app/gui/src/dashboard/assets/expand_arrow_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx index cb5c0ab75c83..d081bf028c11 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx @@ -58,7 +58,7 @@ export const TEXT_STYLE = twv.tv({ 'text-[10.5px] leading-[16px] before:h-[2px] after:h-[2px] macos:before:h-[1px] macos:after:h-[3px] font-medium', h1: 'text-xl leading-[29px] before:h-0.5 after:h-[5px] macos:before:h-[3px] macos:after:h-[3px] font-bold', subtitle: - 'text-[13.5px] leading-[19px] before:h-[2px] after:h-[2px] macos:before:h-[1px] macos:after:h-[3px] font-bold', + 'text-[13.5px] leading-[20px] before:h-[2px] after:h-[2px] macos:before:h-[1px] macos:after:h-[3px] font-bold', caption: 'text-[8.5px] leading-[12px] before:h-[1px] after:h-[1px] macos:before:h-[0.5px] macos:after:h-[1.5px]', overline: diff --git a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx index adab77f3b586..d8e08e492651 100644 --- a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx +++ b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx @@ -38,7 +38,7 @@ export function Breadcrumbs(props: BreadcrumbsProps) { if (items != null && typeof children === 'function') { return ( - + {...props} items={items} children={children} /> ) @@ -47,7 +47,7 @@ export function Breadcrumbs(props: BreadcrumbsProps) { const itemsWithCollapsedItem = getItemsWithCollapsedItem(flattenChildren(children)) return ( - + {itemsWithCollapsedItem.map((item, index) => { const isLastItem = index === itemsWithCollapsedItem.length - 1 diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index ccfb4df7a115..fd6a0806ad93 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -163,9 +163,9 @@ export default function PathColumn(props: AssetColumnProps) { - - { - void newProject([templateId, templateName]) - }} - /> - - -
+
+
+ +
+ + + + + + + { + void newProject([templateId, templateName]) + }} + /> +
- {createAssetsVisualTooltip.tooltip} + > + {getText('newEmptyProject')} + +
+
+ {createAssetsVisualTooltip.tooltip} + + {pasteDataStatus} + {searchBar} - {pasteDataStatus} - {searchBar} - {assetPanelToggle} - +
) } } From 45395654479cb206781c17ab173b8cb11d474899 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 28 Jan 2025 12:10:03 +0400 Subject: [PATCH 06/34] Flat structure --- app/common/src/text/english.json | 1 + app/gui/src/dashboard/App.tsx | 6 +- .../AriaComponents/Button/ButtonGroup.tsx | 6 +- .../AriaComponents/Tooltip/Tooltip.tsx | 3 +- .../components/Breadcrumbs/Breadcrumbs.tsx | 8 +- .../components/dashboard/AssetRow.tsx | 15 +- .../dashboard/DirectoryNameColumn.tsx | 30 ++-- .../dashboard/components/dashboard/column.ts | 1 - .../dashboard/column/PathColumn.tsx | 4 + .../dashboard/hooks/searchParamsStateHooks.ts | 23 +-- app/gui/src/dashboard/hooks/timeoutHooks.ts | 32 +++- .../src/dashboard/layouts/AssetSearchBar.tsx | 2 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 29 ++-- .../dashboard/layouts/CategorySwitcher.tsx | 5 +- app/gui/src/dashboard/layouts/Drive.tsx | 20 ++- .../dashboard/layouts/Drive/assetTreeHooks.ts | 164 ++++++------------ .../layouts/Drive/directoryIdsHooks.ts | 19 +- app/gui/src/dashboard/layouts/DriveBar.tsx | 3 - .../src/dashboard/providers/DriveProvider.tsx | 60 +++++-- app/gui/src/dashboard/services/utilities.ts | 56 ++++++ app/gui/src/dashboard/utilities/path.ts | 1 + 21 files changed, 272 insertions(+), 216 deletions(-) create mode 100644 app/gui/src/dashboard/services/utilities.ts diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 9d590e7907bc..3c10f2c3fca2 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -3,6 +3,7 @@ "retry": "Retry", "hide": "Hide", "more": "More", + "open": "Open", "arbitraryFetchError": "An error occurred while fetching data", "arbitraryFetchImageError": "An error occurred while fetching an image", diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 87e94057adb5..52e317b6100f 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -266,7 +266,11 @@ export default function App(props: AppProps) { transition={toastify.Slide} limit={3} /> - + { if (array.length === 1) { - return <>{child} + return {child} } let position: PrivateJoinedButtonPosition = 'middle' @@ -144,7 +144,7 @@ function JoinedButtons(props: PropsWithChildren) { } return ( - + {child} ) diff --git a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx index ec0d8f313c86..9f75238ff5ab 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx @@ -78,6 +78,7 @@ export function Tooltip(props: TooltipProps) { variant, size, rounded, + variants = TOOLTIP_STYLES, ...ariaTooltipProps } = props @@ -90,7 +91,7 @@ export function Tooltip(props: TooltipProps) { containerPadding={containerPadding} UNSTABLE_portalContainer={root} className={aria.composeRenderProps(className, (classNames, values) => - TOOLTIP_STYLES({ className: classNames, variant, size, rounded, ...values }), + variants({ className: classNames, variant, size, rounded, ...values }), )} data-ignore-click-outside {...ariaTooltipProps} diff --git a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx index d8e08e492651..ce0d30a32f25 100644 --- a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx +++ b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx @@ -14,9 +14,9 @@ import { BreadcrumbCollapsedItem, BreadcrumbItem } from './BreadcrumbItem' import { getItemsWithCollapsedItem, isCollapsedItem } from './utilities' export const BREADCRUMBS_STYLES = tv({ - base: 'flex items-center gap-2 w-full', + base: 'flex items-center w-full', slots: { - separator: 'text-primary last:hidden', + separator: 'text-primary last:hidden w-2.5 h-2.5', }, }) @@ -62,12 +62,12 @@ export function Breadcrumbs(props: BreadcrumbsProps) { : item return ( - <> + {element} {!isLastItem ? : null} - + ) })}
diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 35521d4aa10c..704bce8b1fef 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -13,6 +13,7 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks' import type { DrivePastePayload } from '#/providers/DriveProvider' import { useDriveStore, + useSetCurrentDirectoryId, useSetDragTargetAssetId, useSetIsDraggingOverSelectedRow, useSetLabelsDragPayload, @@ -53,6 +54,7 @@ import * as permissions from '#/utilities/permissions' import * as tailwindMerge from '#/utilities/tailwindMerge' import Visibility from '#/utilities/Visibility' import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' +import { useTransition } from 'react' /** * The amount of time (in milliseconds) the drag item must be held over this component @@ -87,7 +89,6 @@ export interface AssetRowProps { readonly grabKeyboardFocus: (item: backendModule.AnyAsset) => void readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void readonly select: (item: backendModule.AnyAsset) => void - readonly isExpanded: boolean readonly onDragStart?: ( event: React.DragEvent, item: backendModule.AnyAsset, @@ -254,7 +255,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { columns, onClick, isPlaceholder, - isExpanded, type, asset, } = props @@ -262,6 +262,8 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { const { nodeMap, doCopy, doCut, doPaste } = state const { category, rootDirectoryId, backend } = state + const [isLoading, startTransition] = useTransition() + const driveStore = useDriveStore() const { user } = useFullUserSession() const setSelectedAssets = useSetSelectedAssets() @@ -276,6 +278,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { driveStore, ({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected, ) + const setCurrentDirectoryId = useSetCurrentDirectoryId() const draggableProps = dragAndDropHooks.useDraggable({ isDisabled: !selected }) const { setModal, unsetModal } = modalProvider.useSetModal() const [isDraggedOver, setIsDraggedOver] = React.useState(false) @@ -473,6 +476,13 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { tabIndex={0} data-selected={selected} data-id={asset.id} + onDoubleClick={() => { + if (asset.type === backendModule.AssetType.directory) { + startTransition(() => { + setCurrentDirectoryId(asset.id) + }) + } + }} ref={(element) => { rootRef.current = element @@ -616,7 +626,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { - storeState.expandedDirectoryIds.includes(item.id), - ) + const setCurrentDirectoryId = useSetCurrentDirectoryId() const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory')) @@ -86,19 +85,18 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { }} > diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index 66f51e5bbc03..44f9f517058e 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -10,16 +10,17 @@ import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' import { EMPTY_SET } from '#/utilities/set' -import type { - AnyAsset, - AssetId, - BackendType, - DirectoryAsset, - DirectoryId, - LabelName, +import { + type AnyAsset, + type AssetId, + type BackendType, + type DirectoryAsset, + type DirectoryId, + type LabelName, } from 'enso-common/src/services/Backend' import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' import { unsafeMutable } from 'enso-common/src/utilities/data/object' +import { useSearchParamsState } from '../hooks/searchParamsStateHooks' // ================== // === DriveStore === @@ -46,6 +47,14 @@ export interface LabelsDragPayload { readonly labels: readonly LabelName[] } +/** + * This interface is used to represent a single directory in the breadcrumbs. + */ +export interface DirectoryPath { + readonly id: DirectoryId + readonly name: string +} + /** The state of this zustand store. */ interface DriveStore { readonly resetAssetTableState: () => void @@ -84,6 +93,10 @@ interface DriveStore { export type ProjectsContextType = StoreApi const DriveContext = React.createContext(null) +const CurrentDirectoryIdContext = React.createContext<{ + readonly currentDirectoryId: DirectoryId | null + readonly setCurrentDirectoryId: (nextValue: DirectoryId | null) => void +} | null>(null) /** Props for a {@link DriveProvider}. */ export interface ProjectsProviderProps { @@ -103,6 +116,11 @@ export interface ProjectsProviderProps { export default function DriveProvider(props: ProjectsProviderProps) { const { children } = props + const [currentDirectoryId, setCurrentDirectoryId] = useSearchParamsState( + 'currentDirectoryId', + null, + ) + const [store] = React.useState(() => createStore((set, get) => ({ resetAssetTableState: () => { @@ -110,8 +128,8 @@ export default function DriveProvider(props: ProjectsProviderProps) { targetDirectory: null, selectedKeys: EMPTY_SET, visuallySelectedKeys: null, - expandedDirectoryIds: EMPTY_ARRAY, }) + setCurrentDirectoryId(null) }, targetDirectory: null, setTargetDirectory: (targetDirectory) => { @@ -200,9 +218,11 @@ export default function DriveProvider(props: ProjectsProviderProps) { const resetAssetTableState = useStore(store, (state) => state.resetAssetTableState) return ( - - {typeof children === 'function' ? children({ store, resetAssetTableState }) : children} - + + + {typeof children === 'function' ? children({ store, resetAssetTableState }) : children} + + ) } @@ -391,3 +411,21 @@ export function useToggleDirectoryExpansion() { } }) } + +/** The current directory ID. */ +export function useCurrentDirectoryId() { + const context = React.useContext(CurrentDirectoryIdContext) + + invariant(context, 'Current directory ID can only be used inside an `DriveProvider`.') + + return context.currentDirectoryId +} + +/** A function to set the current directory ID. */ +export function useSetCurrentDirectoryId() { + const context = React.useContext(CurrentDirectoryIdContext) + + invariant(context, 'Current directory ID can only be used inside an `DriveProvider`.') + + return context.setCurrentDirectoryId +} diff --git a/app/gui/src/dashboard/services/utilities.ts b/app/gui/src/dashboard/services/utilities.ts new file mode 100644 index 000000000000..c5cdc8bc9087 --- /dev/null +++ b/app/gui/src/dashboard/services/utilities.ts @@ -0,0 +1,56 @@ +/** + * @file Module containing utility functions related to any backend. + */ + +import { isDirectoryId, type DirectoryId } from './Backend' + +/** + * A directory in the path. + */ +export interface ParsedDirectoriesPath { + readonly id: DirectoryId + readonly name: string +} + +/** + * Parse the parents path and virtual parents path into a list of directories. + */ +export function parseDirectoriesPath(parentsPath: string, virtualParentsPath: string) { + // Parents path is a string of directory ids separated by slashes. + const splitPath = parentsPath.split('/').filter(isDirectoryId) + const rootDirectoryInPath = splitPath[0] + // Virtual parents path is a string of directory names separated by slashes. + // To match the ids with the names, we need to remove the first element of the split path. + // As the first element is the root directory, which is not a virtual parent. + const virtualParentsIds = splitPath.slice(1) + + const splitVirtualParentsPath = virtualParentsPath.split('/') + + const finalPath = (() => { + const result: ParsedDirectoriesPath[] = [] + + if (rootDirectoryInPath == null) { + return result + } + + result.push({ + id: rootDirectoryInPath, + // TODO: Get the name of the root directory from categories. + name: 'Root', + }) + + for (const [index, id] of virtualParentsIds.entries()) { + const name = splitVirtualParentsPath.at(index) + + if (name == null) { + continue + } + + result.push({ id, name }) + } + + return result + })() + + return { fullPath: finalPath } +} diff --git a/app/gui/src/dashboard/utilities/path.ts b/app/gui/src/dashboard/utilities/path.ts index d3d5638fa126..47440e5ec48b 100644 --- a/app/gui/src/dashboard/utilities/path.ts +++ b/app/gui/src/dashboard/utilities/path.ts @@ -2,6 +2,7 @@ import * as detect from 'enso-common/src/detect' import * as newtype from '#/utilities/newtype' +import { isDirectoryId, type DirectoryId } from '../services/Backend' // ============ // === Path === From fc1409c6769aaf21003e92205d4bcec585974ee9 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 6 Feb 2025 15:37:29 +0400 Subject: [PATCH 07/34] Next iteration --- app/common/src/services/Backend.ts | 53 +-- app/common/src/text/english.json | 2 +- app/gui/src/App.vue | 8 +- .../AriaComponents/Button/Button.tsx | 9 +- .../AriaComponents/Button/shared.tsx | 69 +++- .../components/AriaComponents/Button/types.ts | 23 +- .../components/Breadcrumbs/BreadcrumbItem.tsx | 363 +++++++++++------- .../Breadcrumbs/Breadcrumbs.stories.tsx | 48 ++- .../components/Breadcrumbs/Breadcrumbs.tsx | 134 +++---- .../components/dashboard/AssetRow.tsx | 13 +- .../dashboard/column/PathColumn.tsx | 79 +--- app/gui/src/dashboard/hooks/backendHooks.ts | 129 ++----- .../hooks/backendUploadFilesHooks.tsx | 356 ++++++++--------- .../src/dashboard/hooks/eventCallbackHooks.ts | 2 +- .../dashboard/hooks/searchParamsStateHooks.ts | 43 ++- .../layouts/AssetPanel/AssetPanel.tsx | 2 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 4 +- .../dashboard/layouts/CategorySwitcher.tsx | 22 +- .../layouts/Drive/Categories/Category.ts | 3 + .../Drive/Categories/categoriesHooks.tsx | 54 ++- .../dashboard/layouts/Drive/assetTreeHooks.ts | 77 +--- .../layouts/Drive/assetsTableItemsHooks.ts | 5 +- .../layouts/Drive/directoryIdsHooks.ts | 12 +- app/gui/src/dashboard/layouts/DriveBar.tsx | 177 ++++++--- app/gui/src/dashboard/layouts/UserBar.tsx | 18 +- .../src/dashboard/providers/DriveProvider.tsx | 28 +- .../src/dashboard/services/LocalBackend.ts | 12 +- app/gui/src/dashboard/services/utilities.ts | 65 +++- .../dashboard/utilities/tailwindVariants.ts | 24 ++ app/gui/src/entrypoint.ts | 46 +++ eslint.config.mjs | 2 +- 31 files changed, 1004 insertions(+), 878 deletions(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index be64fc96e295..5873ff7001ba 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -18,32 +18,33 @@ export const S3_CHUNK_SIZE_BYTES = 10_000_000 export type OrganizationId = newtype.Newtype<`organization-${string}`, 'OrganizationId'> export const OrganizationId = newtype.newtypeConstructor() /** Whether a given {@link string} is an {@link OrganizationId}. */ -export function isOrganizationId(id: string): id is OrganizationId { - return id.startsWith('organization-') +export function isOrganizationId(id: unknown): id is OrganizationId { + return typeof id === 'string' && id.startsWith('organization-') } /** Unique identifier for a user in an organization. */ export type UserId = newtype.Newtype export const UserId = newtype.newtypeConstructor() /** Whether a given {@link string} is an {@link UserId}. */ -export function isUserId(id: string): id is UserId { - return id.startsWith('user-') +export function isUserId(id: unknown): id is UserId { + return typeof id === 'string' && id.startsWith('user-') } /** Unique identifier for a user group. */ export type UserGroupId = newtype.Newtype<`usergroup-${string}`, 'UserGroupId'> export const UserGroupId = newtype.newtypeConstructor() /** Whether a given {@link string} is an {@link UserGroupId}. */ -export function isUserGroupId(id: string): id is UserGroupId { - return id.startsWith('usergroup-') +export function isUserGroupId(id: unknown): id is UserGroupId { + return typeof id === 'string' && id.startsWith('usergroup-') } /** Unique identifier for a directory. */ export type DirectoryId = newtype.Newtype<`directory-${string}`, 'DirectoryId'> export const DirectoryId = newtype.newtypeConstructor() -/** Whether a given {@link string} is an {@link DirectoryId}. */ -export function isDirectoryId(id: string): id is DirectoryId { - return id.startsWith('directory-') + +/** Whether a given {@link unknown} is an {@link DirectoryId}. */ +export function isDirectoryId(id: unknown): id is DirectoryId { + return typeof id === 'string' && id.startsWith('directory-') } /** @@ -997,17 +998,13 @@ function fileExtension(fileNameOrPath: string) { } /** Creates a {@link FileAsset} using the given values. */ -export function createPlaceholderFileAsset( - title: string, - parentId: DirectoryId, - assetPermissions: readonly AssetPermission[], -): FileAsset { +export function createPlaceholderFileAsset(title: string, parentId: DirectoryId): FileAsset { return { type: AssetType.file, id: FileId(createPlaceholderId()), title, parentId, - permissions: assetPermissions, + permissions: [], modifiedAt: dateTime.toRfc3339(new Date()), projectState: null, extension: fileExtension(title), @@ -1019,25 +1016,17 @@ export function createPlaceholderFileAsset( } /** Creates a {@link ProjectAsset} using the given values. */ -export function createPlaceholderProjectAsset( - title: string, - parentId: DirectoryId, - assetPermissions: readonly AssetPermission[], - user: User | null, - path: Path | null, -): ProjectAsset { +export function createPlaceholderProjectAsset(title: string, parentId: DirectoryId): ProjectAsset { return { type: AssetType.project, id: ProjectId(createPlaceholderId()), title, parentId, - permissions: assetPermissions, + permissions: [], modifiedAt: dateTime.toRfc3339(new Date()), projectState: { type: ProjectState.new, volumeId: '', - ...(user != null ? { openedBy: user.email } : {}), - ...(path != null ? { path } : {}), }, extension: null, labels: [], @@ -1051,14 +1040,13 @@ export function createPlaceholderProjectAsset( export function createPlaceholderDirectoryAsset( title: string, parentId: DirectoryId, - assetPermissions: readonly AssetPermission[], ): DirectoryAsset { return { type: AssetType.directory, id: DirectoryId(`directory-${createPlaceholderId()}` as const), title, parentId, - permissions: assetPermissions, + permissions: [], modifiedAt: dateTime.toRfc3339(new Date()), projectState: null, extension: null, @@ -1070,17 +1058,13 @@ export function createPlaceholderDirectoryAsset( } /** Creates a {@link SecretAsset} using the given values. */ -export function createPlaceholderSecretAsset( - title: string, - parentId: DirectoryId, - assetPermissions: readonly AssetPermission[], -): SecretAsset { +export function createPlaceholderSecretAsset(title: string, parentId: DirectoryId): SecretAsset { return { type: AssetType.secret, id: SecretId(createPlaceholderId()), title, parentId, - permissions: assetPermissions, + permissions: [], modifiedAt: dateTime.toRfc3339(new Date()), projectState: null, extension: null, @@ -1095,14 +1079,13 @@ export function createPlaceholderSecretAsset( export function createPlaceholderDatalinkAsset( title: string, parentId: DirectoryId, - assetPermissions: readonly AssetPermission[], ): DatalinkAsset { return { type: AssetType.datalink, id: DatalinkId(createPlaceholderId()), title, parentId, - permissions: assetPermissions, + permissions: [], modifiedAt: dateTime.toRfc3339(new Date()), projectState: null, extension: null, diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 3c10f2c3fca2..eb4294604628 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -2,8 +2,8 @@ "submit": "Submit", "retry": "Retry", "hide": "Hide", - "more": "More", "open": "Open", + "forward": "Forward", "arbitraryFetchError": "An error occurred while fetching data", "arbitraryFetchImageError": "An error occurred while fetching an image", diff --git a/app/gui/src/App.vue b/app/gui/src/App.vue index 10c95f54906f..5536c35aa48d 100644 --- a/app/gui/src/App.vue +++ b/app/gui/src/App.vue @@ -28,7 +28,13 @@ const appTooltips = provideTooltipRegistry() const appConfig = computed(() => { const config = mergeConfig(baseConfig, urlParams(), { - onUnrecognizedOption: (p) => console.warn('Unrecognized option:', p), + onUnrecognizedOption: (p) => { + const filtered = p.filter((p) => !p.startsWith('cloud-ide')) + + if (filtered.length > 0) { + console.warn('Unrecognized option:', filtered) + } + }, }) return config }) diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index cee8ed33c59b..f1cfbafaa0bf 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -17,7 +17,7 @@ import { StatelessSpinner } from '#/components/StatelessSpinner' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { forwardRef } from '#/utilities/react' import { ButtonGroup, ButtonGroupJoin } from './ButtonGroup' -import { useJoinedButtonPrivateContext, useMergedButtonStyles } from './shared' +import { ButtonGroupProvider, useJoinedButtonPrivateContext, useMergedButtonStyles } from './shared' import type { ButtonProps } from './types' import { BUTTON_STYLES } from './variants' @@ -257,15 +257,16 @@ export const Button = memo( ) as unknown as (( props: ButtonProps & { ref?: ForwardedRef }, ) => ReactNode) & { - // eslint-disable-next-line @typescript-eslint/naming-convention + /* eslint-disable @typescript-eslint/naming-convention */ Group: typeof ButtonGroup - // eslint-disable-next-line @typescript-eslint/naming-convention GroupJoin: typeof ButtonGroupJoin + GroupProvider: typeof ButtonGroupProvider + /* eslint-enable @typescript-eslint/naming-convention */ } Button.Group = ButtonGroup Button.GroupJoin = ButtonGroupJoin - +Button.GroupProvider = ButtonGroupProvider /** * Props for {@link ButtonContent}. */ diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/shared.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/shared.tsx index 9aebf59e5468..04e52b7586ed 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/shared.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/shared.tsx @@ -1,5 +1,5 @@ /** @file Context for a button group. */ -import { createContext, useContext, type PropsWithChildren } from 'react' +import { createContext, useContext, useMemo, type PropsWithChildren } from 'react' import type { ButtonGroupSharedButtonProps, PrivateJoinedButtonProps } from './types' import { type ButtonVariants } from './variants' @@ -16,9 +16,72 @@ const ButtonGroupContext = createContext({}) * Provider for a button group context */ export function ButtonGroupProvider(props: ButtonGroupContextType & PropsWithChildren) { - const { children, ...rest } = props + const { + children, + extraClickZone, + fullWidth, + iconOnly, + iconPosition, + isActive, + isDisabled, + isFocused, + isJoined, + isLoading, + isPressed, + loaderPosition, + loading, + position, + rounded, + showIconOnHover, + size, + variant, + variants, + } = props - return {children} + const contextValue = useMemo( + () => ({ + extraClickZone, + fullWidth, + iconOnly, + iconPosition, + isActive, + isDisabled, + isFocused, + isJoined, + isLoading, + isPressed, + loaderPosition, + loading, + position, + rounded, + showIconOnHover, + size, + variant, + variants, + }), + [ + extraClickZone, + fullWidth, + iconOnly, + iconPosition, + isActive, + isDisabled, + isFocused, + isJoined, + isLoading, + isPressed, + loaderPosition, + loading, + position, + rounded, + showIconOnHover, + size, + variant, + variants, + ], + ) + + return {children} } const EMPTY_CONTEXT: ButtonGroupContextType = {} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/types.ts b/app/gui/src/dashboard/components/AriaComponents/Button/types.ts index 72a1a7c5761b..084d14ba328e 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/types.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Button/types.ts @@ -77,7 +77,13 @@ export interface BaseButtonProps * Handler that is called when the press is released over the target. * If the handler returns a promise, the button will be in a loading state until the promise resolves. */ - readonly onPress?: ((event: aria.PressEvent) => Promise | void) | null | undefined + // Prettier is not able to format this line correctly + // prettier-ignore + readonly onPress?: + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + | ((event: aria.PressEvent) => Promise | unknown) + | null + | undefined readonly contentClassName?: string readonly isDisabled?: boolean readonly formnovalidate?: boolean @@ -94,11 +100,18 @@ export interface BaseButtonProps readonly addonEnd?: Addon } +/** + * A type that makes all properties of a type optional + */ +type WithUndefined = { + [K in keyof T]: T[K] | undefined +} + /** * Props that are shared between buttons in a button group. */ -export interface ButtonGroupSharedButtonProps extends ButtonVariants { - readonly isDisabled?: boolean - readonly isLoading?: boolean - readonly loaderPosition?: 'full' | 'icon' +export interface ButtonGroupSharedButtonProps extends WithUndefined { + readonly isDisabled?: boolean | undefined + readonly isLoading?: boolean | undefined + readonly loaderPosition?: 'full' | 'icon' | undefined } diff --git a/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx b/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx index 275719a75199..47d303869950 100644 --- a/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx +++ b/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx @@ -2,24 +2,36 @@ * @file Breadcrumbs component implementation. */ +import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useText } from '#/providers/TextProvider' import { tv, type VariantProps } from '#/utilities/tailwindVariants' -import { createLeafComponent } from '@react-aria/collections' -import { isValidElement } from 'react' -import * as aria from 'react-aria-components' +import { + createContext, + isValidElement, + useContext, + useRef, + type CSSProperties, + type Key, + type PropsWithChildren, + type ReactNode, +} from 'react' +import { useBreadcrumbItem, type AriaBreadcrumbItemProps } from 'react-aria' +import type * as aria from 'react-aria-components' +import invariant from 'tiny-invariant' import { Button, Menu, Text, type Addon, type IconProp, type TestIdProps } from '../AriaComponents' import { Icon as IconComponent, renderIcon } from '../Icon' export const BREADCRUMB_ITEM_STYLES = tv({ base: 'flex items-center gap-2', slots: { - link: 'max-w-48 block', + link: 'max-w-48', more: 'aspect-square', container: 'flex items-center gap-2', + icon: '-mb-0.5', }, variants: { isCurrent: { - true: { link: 'px-2' }, + true: { link: 'flex justify-center px-2 h-8' }, }, }, defaultVariants: { @@ -27,22 +39,67 @@ export const BREADCRUMB_ITEM_STYLES = tv({ }, }) +/** + * + */ +export interface BreadcrumbItemRenderProps { + readonly isCurrent: boolean + readonly isDisabled: boolean +} + /** * Props for {@link BreadcrumbItem} */ export interface BreadcrumbItemProps - extends Omit, + extends Omit, Omit, TestIdProps, VariantProps { - /** A unique id for the breadcrumb, which will be passed to `onAction` when the breadcrumb is pressed. */ - readonly id?: aria.Key | undefined + readonly id?: Key /** An optional suffix element to render after the breadcrumb content */ - readonly addonStart?: Addon - readonly addonEnd?: Addon - readonly icon?: IconProp + readonly addonStart?: Addon + readonly addonEnd?: Addon + readonly icon?: IconProp readonly isCurrent?: boolean readonly isDisabled?: boolean + readonly className?: string | ((renderProps: BreadcrumbItemRenderProps) => string) + readonly style?: CSSProperties | ((renderProps: BreadcrumbItemRenderProps) => CSSProperties) + readonly children: ReactNode | ((renderProps: BreadcrumbItemRenderProps) => ReactNode) +} + +/** + * Context props for {@link BreadcrumbItemProvider} + */ +export interface BreadcrumbItemContextType { + readonly isCurrent: boolean + /** + * Workaround to have optimized `onAction` callback using `useEventCallback` hook. + * And be able to check if `onAction` prop was specified and id is not. + */ + readonly onActionSpecified: boolean + readonly onAction: (key: Key) => void +} + +/** + * Context for the breadcrumb item. + */ +export const BreadcrumbItemContext = createContext({ + isCurrent: false, + onActionSpecified: false, + onAction: () => {}, +}) + +/** + * Provider for the breadcrumb item context. + */ +export function BreadcrumbItemProvider(props: PropsWithChildren) { + const { children, isCurrent, onAction, onActionSpecified } = props + + return ( + + {children} + + ) } /** @@ -51,15 +108,13 @@ export interface BreadcrumbItemProps export function BreadcrumbItem(props: BreadcrumbItemProps) { const { children, - id, variants = BREADCRUMB_ITEM_STYLES, className, style = {}, + isDisabled = false, addonStart, addonEnd, icon, - isDisabled = false, - isCurrent = false, href, hrefLang, target, @@ -67,8 +122,37 @@ export function BreadcrumbItem(props: BreadcrumbItemPro rel, ping, referrerPolicy, - ...itemProps } = props + const { id, ...breadcrumbItemProps } = props + + const { isCurrent, onAction, onActionSpecified } = useContext(BreadcrumbItemContext) + + const renderProps = { isCurrent, isDisabled } satisfies BreadcrumbItemRenderProps + + const ref = useRef(null) + const { itemProps } = useBreadcrumbItem({ elementType: 'div', ...breadcrumbItemProps }, ref) + + const onPress = useEventCallback(() => { + if (id == null) { + return + } + + onAction(id) + }) + + const iconComponent = (() => { + if (typeof icon === 'function') { + return icon(renderProps) + } + return icon + })() + + const shouldFail = onActionSpecified && id == null + + invariant( + !shouldFail, + 'When onAction is specified on `` component, the `id` prop must be specified on `` component.', + ) const linkProps = isCurrent ? @@ -88,61 +172,50 @@ export function BreadcrumbItem(props: BreadcrumbItemPro 'download' | 'href' | 'hrefLang' | 'ping' | 'referrerPolicy' | 'rel' | 'target' >) - const styles = variants({ - isCurrent, - }) + const styles = variants({ isCurrent }) + + const container = + isCurrent ? + + + + {icon} + + {typeof children === 'function' ? children(renderProps) : children} + + + : return ( - - styles.base({ - className: typeof className === 'function' ? className(renderProps) : className, - }) - } - style={style} - {...(id != null ? { id } : {})} +
  • - {(renderProps) => { - const container = - isCurrent ? - - - - {icon} - - {typeof children === 'function' ? children(renderProps) : children} - - - : - - return ( -
    - - {typeof addonStart === 'function' ? addonStart(renderProps) : addonStart} - - {container} - - {typeof addonEnd === 'function' ? addonEnd(renderProps) : addonEnd} - -
    - ) - }} - +
    + + {typeof addonStart === 'function' ? addonStart(renderProps) : addonStart} + + {container} + + {typeof addonEnd === 'function' ? addonEnd(renderProps) : addonEnd} + +
    +
  • ) } @@ -156,94 +229,90 @@ interface BreadcrumbCollapsedItemProps { /** The children to render */ readonly children: (item: T) => React.ReactNode readonly triggerLabel?: string + /** The callback to call when an item is selected */ + readonly onAction?: (key: Key) => void } /** * A collapsed breadcrumb item. Displays a menu with the items, that don't fit in the breadcrumbs list. * @internal */ -// eslint-disable-next-line no-restricted-syntax -export const BreadcrumbCollapsedItem = createLeafComponent( - 'BreadcrumbCollapsedItem', - function BreadcrumbCollapsedItem(props: BreadcrumbCollapsedItemProps) { - const { getText } = useText() - - const { items, children, triggerLabel = getText('more') } = props - - return ( - - - - - {(menuItem) => { - const breadcrumb = children(menuItem) - - if (isValidElement(breadcrumb) && breadcrumb.type === BreadcrumbItem) { - const { - testId, - id, - children: breadcrumbChildren, - href, - download, - target, - hrefLang, - isCurrent = false, - isDisabled = false, - 'aria-describedby': ariaDescribedby, - rel, - icon, - // eslint-disable-next-line no-restricted-syntax - } = breadcrumb.props as BreadcrumbItemProps +export function BreadcrumbCollapsedItem(props: BreadcrumbCollapsedItemProps) { + const { getText } = useText() - if (breadcrumbChildren == null) { - return null - } + const { items, children, triggerLabel = getText('more') } = props + const { onAction } = useContext(BreadcrumbItemContext) + + return ( + + + + + {(menuItem) => { + const breadcrumb = children(menuItem) + + if (isValidElement(breadcrumb) && breadcrumb.type === BreadcrumbItem) { + const { + testId, + id, + children: breadcrumbChildren, + href, + download, + target, + hrefLang, + isCurrent = false, + isDisabled = false, + 'aria-describedby': ariaDescribedby, + rel, + icon, // eslint-disable-next-line no-restricted-syntax - const linkProps = { - href, - download, - target, - hrefLang, - rel, - } as Pick - - return ( - { - if (typeof icon === 'function') { - return icon({ isCurrent, isDisabled }) - } - - return icon - }} - > - <> - {typeof breadcrumbChildren === 'function' ? - breadcrumbChildren({ - isCurrent, - isDisabled, - defaultChildren: <>, - }) - : breadcrumbChildren} - - - ) + } = breadcrumb.props as BreadcrumbItemProps + + if (breadcrumbChildren == null) { + return null } - return null - }} - - - ) - }, -) as (props: BreadcrumbCollapsedItemProps) => React.ReactNode + // eslint-disable-next-line no-restricted-syntax + const linkProps = { + href, + download, + target, + hrefLang, + rel, + } as Pick + + return ( + { + if (typeof icon === 'function') { + return icon({ isCurrent, isDisabled }) + } + + return icon + })()} + > + <> + {typeof breadcrumbChildren === 'function' ? + breadcrumbChildren({ isCurrent, isDisabled }) + : breadcrumbChildren} + + + ) + } + + return null + }} + + + ) +} diff --git a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.stories.tsx b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.stories.tsx index e078013b5af0..03da4f46a592 100644 --- a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.stories.tsx +++ b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.stories.tsx @@ -3,23 +3,23 @@ */ import ArrowDown from '#/assets/expand_arrow.svg' -import Folder from '#/assets/folder.svg' +import Folder from '#/assets/folder_filled.svg' import Add from '#/assets/plus.svg' import { Button, Menu } from '#/components/AriaComponents' import type { Meta, StoryObj } from '@storybook/react' -import { expect, userEvent, within } from '@storybook/test' +import { expect, fn, userEvent, within } from '@storybook/test' import { useState } from 'react' +import type { BreadcrumbsProps } from '.' import { Breadcrumbs } from '.' export default { title: 'Components/Breadcrumbs', component: Breadcrumbs, - parameters: { - layout: 'centered', - }, -} satisfies Meta + parameters: { layout: 'centered' }, + render: (args) => , +} satisfies Meta -type Story = StoryObj +type Story = StoryObj export const Default: Story = { render: () => ( @@ -151,17 +151,16 @@ export const Dynamic: Story = { const nextItem = { id: items.length + 1, name: `Item ${items.length + 1}`, - href: `https://google.com/${items.length + 1}`, + href: `https://google.com/search?q=${items.length + 1}`, isCurrent: true, } setItems([...items.map((item) => ({ ...item, isCurrent: false })), nextItem]) } return ( - - {(item) => ( + + {items.map((item) => ( {item.name} - )} + ))} ) }, @@ -554,3 +553,28 @@ export const WithCustomIcon: Story = { ), } + +export const WithOnAction: Story = { + args: { onAction: fn() }, + render: (args) => ( + + Home + Projects + Team + Documents + Reports + March 2025 + + ), + play: async ({ canvasElement, args }) => { + const { onAction } = args as BreadcrumbsProps + + const { getByText, getByLabelText } = within(canvasElement) + await userEvent.click(getByText('Reports')) + await expect(onAction).toHaveBeenCalledWith('Reports') + + await userEvent.click(getByLabelText('More')) + await userEvent.click(getByText('Team')) + await expect(onAction).toHaveBeenCalledWith('Team') + }, +} diff --git a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx index ce0d30a32f25..31df92ca1ada 100644 --- a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx +++ b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx @@ -3,55 +3,67 @@ */ import ArrowRight from '#/assets/expand_arrow_right.svg' +import { useEventCallback } from '#/hooks/eventCallbackHooks' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { createLeafComponent } from '@react-aria/collections' import { Fragment, type ReactElement } from 'react' import * as aria from 'react-aria-components' +import { + Fragment, + memo, + type Key, + type PropsWithChildren, + type ReactElement, + type ReactNode, +} from 'react' import flattenChildren from 'react-keyed-flatten-children' +import { useBreadcrumbs, type AriaBreadcrumbsProps } from '../aria' import { Button, type TestIdProps } from '../AriaComponents' import { Icon } from '../Icon' -import { BreadcrumbCollapsedItem, BreadcrumbItem } from './BreadcrumbItem' +import { BreadcrumbCollapsedItem, BreadcrumbItem, BreadcrumbItemProvider } from './BreadcrumbItem' import { getItemsWithCollapsedItem, isCollapsedItem } from './utilities' export const BREADCRUMBS_STYLES = tv({ base: 'flex items-center w-full', - slots: { - separator: 'text-primary last:hidden w-2.5 h-2.5', - }, + slots: { separator: 'text-primary last:hidden w-2.5 h-2.5 mt-[0.5px]' }, }) /** * Props for {@link Breadcrumbs} */ -export interface BreadcrumbsProps - extends aria.BreadcrumbsProps, +export interface BreadcrumbsProps + extends AriaBreadcrumbsProps, VariantProps, - TestIdProps {} + TestIdProps { + /** The breadcrumb items. */ + readonly children: ReactNode + /** Called when an item is acted upon (usually selection via press). */ + readonly onAction?: (key: Key) => void + readonly className?: string +} /** * A breadcrumb navigation component. */ -export function Breadcrumbs(props: BreadcrumbsProps) { - const { children, items, className, variants = BREADCRUMBS_STYLES, testId } = props +export function Breadcrumbs(props: BreadcrumbsProps) { + const { + children, + className, + variants = BREADCRUMBS_STYLES, + testId, + onAction = () => {}, + ...breadcrumbsProps + } = props const styles = variants() - if (items != null && typeof children === 'function') { - return ( - - {...props} items={items} children={children} /> - - ) - } - + const onActionStableCallback = useEventCallback(onAction) const itemsWithCollapsedItem = getItemsWithCollapsedItem(flattenChildren(children)) return ( - - - {itemsWithCollapsedItem.map((item, index) => { - const isLastItem = index === itemsWithCollapsedItem.length - 1 - + + + {itemsWithCollapsedItem.map((item, i, array) => { const element = isCollapsedItem(item) ? (props: BreadcrumbsProps) { return ( - {element} - {!isLastItem ? - - : null} + + {element} + + + ) })} - - + + ) } /** - * Props for {@link BreadcrumbsItemsCollection} + * Props for {@link BreadcrumbInner} */ -interface BreadcrumbsCollectionProps - extends aria.BreadcrumbsProps, - VariantProps, - TestIdProps { - /** The children to render */ - readonly children: (item: T) => React.ReactNode - /** The items to render */ - readonly items: Iterable +interface BreadcrumbInnerProps extends TestIdProps, AriaBreadcrumbsProps, PropsWithChildren { + readonly className?: string } /** - * A lazy collection of breadcrumb items. + * Internal component for rendering the breadcrumbs. + * @internal */ -function BreadcrumbsItemsCollection(props: BreadcrumbsCollectionProps) { - const { items, children, variants = BREADCRUMBS_STYLES, className } = props - - const styles = variants() +function BreadcrumbInner(props: BreadcrumbInnerProps) { + const { children, className, testId } = props - const itemsWithCollapsedItem = getItemsWithCollapsedItem(items) + const { navProps } = useBreadcrumbs(props) return ( - - {(item) => { - const separator = - if (isCollapsedItem(item)) { - return ( - - - {separator} - - ) - } - - return ( - <> - {children(item)} - {separator} - - ) - }} - +
      + {children} +
    ) } @@ -130,20 +119,17 @@ function BreadcrumbsItemsCollection(props: BreadcrumbsCollecti * Props for {@link BreadcrumbSeparator} */ interface BreadcrumbSeparatorProps { - readonly icon: string + readonly icon?: string readonly className?: string } /** * A separator between breadcrumb items. */ -// eslint-disable-next-line no-restricted-syntax -const BreadcrumbSeparator = createLeafComponent( - 'BreadcrumbSeparator', - function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) { - const { icon = ArrowRight, className } = props +const BreadcrumbSeparator = memo(function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) { + const { icon = ArrowRight, className } = props - return {icon} + return {icon} }, ) diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 704bce8b1fef..11462a59c60e 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -114,7 +114,7 @@ export interface AssetRowProps { } /** A row containing an {@link backendModule.AnyAsset}. */ -// eslint-disable-next-line no-restricted-syntax + export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { const { type, columns, depth, id } = props @@ -145,7 +145,7 @@ export interface AssetSpecialRowProps { } /** Renders a special asset row. */ -// eslint-disable-next-line no-restricted-syntax + const AssetSpecialRow = React.memo(function AssetSpecialRow(props: AssetSpecialRowProps) { const { type, columnsLength, depth } = props @@ -224,7 +224,7 @@ const AssetSpecialRow = React.memo(function AssetSpecialRow(props: AssetSpecialR type RealAssetRowProps = AssetRowProps & { readonly id: backendModule.RealAssetId } /** Renders a real asset row. */ -// eslint-disable-next-line no-restricted-syntax + const RealAssetRow = React.memo(function RealAssetRow(props: RealAssetRowProps) { const { id } = props @@ -479,7 +479,10 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { onDoubleClick={() => { if (asset.type === backendModule.AssetType.directory) { startTransition(() => { - setCurrentDirectoryId(asset.id) + setCurrentDirectoryId({ + current: asset.id, + parent: parentId, + }) }) } }} @@ -615,7 +618,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { event.preventDefault() event.stopPropagation() toggleDirectoryExpansion(directoryId, true) - void uploadFiles(Array.from(event.dataTransfer.files), directoryId, null) + void uploadFiles(Array.from(event.dataTransfer.files), directoryId) } } }} diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index 9a275a22ea2d..7949b4ac3360 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -1,5 +1,4 @@ /** @file A column displaying the path of the asset. */ -import FolderIcon from '#/assets/folder.svg' import FolderArrowIcon from '#/assets/folder_arrow.svg' import { Button, Popover, Text } from '#/components/AriaComponents' import SvgMask from '#/components/SvgMask' @@ -8,7 +7,7 @@ import { useCategoriesAPI, useCloudCategoryList } from '#/layouts/Drive/Categori import type { AnyCloudCategory } from '#/layouts/Drive/Categories/Category' import { useUser } from '#/providers/AuthProvider' import { useSetExpandedDirectoryIds, useSetSelectedAssets } from '#/providers/DriveProvider' -import { AssetType, DirectoryId, isDirectoryId } from '#/services/Backend' +import { AssetType, DirectoryId } from '#/services/Backend' import { parseDirectoriesPath } from '#/services/utilities' import { Fragment, useTransition } from 'react' import invariant from 'tiny-invariant' @@ -25,24 +24,17 @@ export default function PathColumn(props: AssetColumnProps) { const { setCategory } = useCategoriesAPI() const setSelectedAssets = useSetSelectedAssets() const setExpandedDirectoryIds = useSetExpandedDirectoryIds() + const { rootDirectoryId } = useUser() // Path navigation exist only for cloud categories. const { getCategoryByDirectoryId } = useCloudCategoryList() - const { getCategoryById } = useCategoriesAPI() - - // Parents path is a string of directory ids separated by slashes. - const splitPath = parentsPath.split('/').filter(isDirectoryId) - const rootDirectoryInPath = splitPath[0] - - const splitVirtualParentsPath = virtualParentsPath.split('/') - // Virtual parents path is a string of directory names separated by slashes. - // To match the ids with the names, we need to remove the first element of the split path. - // As the first element is the root directory, which is not a virtual parent. - const virtualParentsIds = splitPath.slice(1) - - const { rootDirectoryId } = useUser() - const { fullPath } = parseDirectoriesPath(parentsPath, virtualParentsPath) + const { finalPath } = parseDirectoriesPath({ + parentsPath, + virtualParentsPath, + rootDirectoryId, + getCategoryByDirectoryId, + }) const navigateToDirectory = useEventCallback((targetDirectory: DirectoryId) => { const targetDirectoryIndex = finalPath.findIndex(({ id }) => id === targetDirectory) @@ -84,55 +76,6 @@ export default function PathColumn(props: AssetColumnProps) { ]) }) - const finalPath = (() => { - const result: { - id: DirectoryId - categoryId: AnyCloudCategory['id'] | null - label: AnyCloudCategory['label'] - icon: AnyCloudCategory['icon'] - }[] = [] - - if (rootDirectoryInPath == null) { - return result - } - - const rootCategory = getCategoryByDirectoryId(rootDirectoryInPath) - - // If the root category is not found it might mean - // that user is no longer have access to this root directory. - // Usually this could happen if the user was removed from the organization - // or user group. - // This shouldn't happen though and these files should be filtered out - // by the backend. But we need to handle this case anyway. - if (rootCategory == null) { - return result - } - - result.push({ - id: rootDirectoryId, - categoryId: rootCategory.id, - label: rootCategory.label, - icon: rootCategory.icon, - }) - - for (const [index, id] of virtualParentsIds.entries()) { - const name = splitVirtualParentsPath.at(index) - - if (name == null) { - continue - } - - result.push({ - id, - label: name, - icon: FolderIcon, - categoryId: null, - }) - } - - return result - })() - if (finalPath.length === 0) { return <> } @@ -167,9 +110,9 @@ export default function PathColumn(props: AssetColumnProps) { @@ -305,9 +364,7 @@ export default function DriveBar(props: DriveBarProps) { icon={AddFolderIcon} isDisabled={shouldBeDisabled} aria-label={getText('newFolder')} - onPress={async () => { - await newFolder() - }} + onPress={() => newFolder(currentDirectoryId)} /> {isCloud && ( diff --git a/app/gui/src/dashboard/layouts/UserBar.tsx b/app/gui/src/dashboard/layouts/UserBar.tsx index b05cefaf9161..4c4ed59741d9 100644 --- a/app/gui/src/dashboard/layouts/UserBar.tsx +++ b/app/gui/src/dashboard/layouts/UserBar.tsx @@ -193,7 +193,7 @@ export function UserBarHelpSection(props: UserBarHelpSectionProps) { if ('url' in item) { if ('menu' in item) { return ( - + @@ -203,7 +203,11 @@ export function UserBarHelpSection(props: UserBarHelpSectionProps) { {item.menu.map((menuItem) => ( - + {getText(menuItem.name)} ))} @@ -214,12 +218,16 @@ export function UserBarHelpSection(props: UserBarHelpSectionProps) { } } else { return ( - + {item.menu.map((menuItem) => ( - + {getText(menuItem.name)} ))} @@ -229,7 +237,7 @@ export function UserBarHelpSection(props: UserBarHelpSectionProps) { } return ( - ) diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index 44f9f517058e..a930ffba7c86 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -93,10 +93,20 @@ interface DriveStore { export type ProjectsContextType = StoreApi const DriveContext = React.createContext(null) -const CurrentDirectoryIdContext = React.createContext<{ - readonly currentDirectoryId: DirectoryId | null - readonly setCurrentDirectoryId: (nextValue: DirectoryId | null) => void -} | null>(null) + +/** The current directory ID. */ +interface CurrentDirectoryIdContextType { + readonly currentDirectoryId: { + readonly current: DirectoryId | null + readonly parent: DirectoryId | null + } + readonly setCurrentDirectoryId: (nextValue: { + readonly current: DirectoryId | null + readonly parent: DirectoryId | null + }) => void +} + +const CurrentDirectoryIdContext = React.createContext(null) /** Props for a {@link DriveProvider}. */ export interface ProjectsProviderProps { @@ -116,10 +126,9 @@ export interface ProjectsProviderProps { export default function DriveProvider(props: ProjectsProviderProps) { const { children } = props - const [currentDirectoryId, setCurrentDirectoryId] = useSearchParamsState( - 'currentDirectoryId', - null, - ) + const [currentDirectoryId, setCurrentDirectoryId] = useSearchParamsState< + CurrentDirectoryIdContextType['currentDirectoryId'] + >('currentDirectoryId', { current: null, parent: null }) const [store] = React.useState(() => createStore((set, get) => ({ @@ -129,7 +138,7 @@ export default function DriveProvider(props: ProjectsProviderProps) { selectedKeys: EMPTY_SET, visuallySelectedKeys: null, }) - setCurrentDirectoryId(null) + setCurrentDirectoryId({ current: null, parent: null }) }, targetDirectory: null, setTargetDirectory: (targetDirectory) => { @@ -209,7 +218,6 @@ export default function DriveProvider(props: ProjectsProviderProps) { setNodeMap: (nodeMap) => { if (get().nodeMap.current !== nodeMap) { unsafeMutable(get().nodeMap).current = nodeMap - set({ nodeMap: get().nodeMap }) } }, })), diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index 67b5d5336b87..26c0c9f7db75 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -165,6 +165,14 @@ export default class LocalBackend extends Backend { const entries = await this.projectManager.listDirectory(parentIdRaw) result = entries .map((entry) => { + console.log('entry', { + entry, + parentId, + parentIdRaw, + rootDirectory: this.projectManager.rootDirectory, + parentsPath: entry.path.replace(this.projectManager.rootDirectory, ''), + virtualParentsPath: entry.path.replace(this.projectManager.rootDirectory, ''), + }) switch (entry.type) { case projectManager.FileSystemEntryType.DirectoryEntry: { return { @@ -178,8 +186,8 @@ export default class LocalBackend extends Backend { extension: null, labels: [], description: null, - parentsPath: '', - virtualParentsPath: '', + parentsPath: entry.path.replace(this.projectManager.rootDirectory, ''), + virtualParentsPath: entry.path.replace(this.projectManager.rootDirectory, ''), } satisfies backend.DirectoryAsset } case projectManager.FileSystemEntryType.ProjectEntry: { diff --git a/app/gui/src/dashboard/services/utilities.ts b/app/gui/src/dashboard/services/utilities.ts index c5cdc8bc9087..bd503ab39c6f 100644 --- a/app/gui/src/dashboard/services/utilities.ts +++ b/app/gui/src/dashboard/services/utilities.ts @@ -1,42 +1,70 @@ /** * @file Module containing utility functions related to any backend. */ - +import FolderIcon from '#/assets/folder.svg' +import type { AnyCategory } from '../layouts/Drive/Categories/Category' import { isDirectoryId, type DirectoryId } from './Backend' /** - * A directory in the path. + * Options for the parseDirectoriesPath function. */ -export interface ParsedDirectoriesPath { +export interface ParsedDirectoriesPathOptions { + readonly rootDirectoryId: DirectoryId + readonly getCategoryByDirectoryId: (id: DirectoryId) => AnyCategory | null + readonly parentsPath: string + readonly virtualParentsPath: string +} + +/** An item in the path. */ +export interface PathItem { readonly id: DirectoryId - readonly name: string + readonly categoryId: AnyCategory['id'] | null + readonly label: AnyCategory['label'] + readonly icon: AnyCategory['icon'] } /** - * Parse the parents path and virtual parents path into a list of directories. + * Parse the parents path and virtual parents path into a list of {@link PathItem}. */ -export function parseDirectoriesPath(parentsPath: string, virtualParentsPath: string) { +export function parseDirectoriesPath(options: ParsedDirectoriesPathOptions) { + const { getCategoryByDirectoryId, parentsPath, rootDirectoryId, virtualParentsPath } = options + // Parents path is a string of directory ids separated by slashes. + // e.g: parentsPath = 'directory-id1adsf/directory-id2adsf/directory-id3adsf' const splitPath = parentsPath.split('/').filter(isDirectoryId) - const rootDirectoryInPath = splitPath[0] + const rootDirectoryInPath = splitPath[0] ?? rootDirectoryId + + const splitVirtualParentsPath = virtualParentsPath.split('/') // Virtual parents path is a string of directory names separated by slashes. // To match the ids with the names, we need to remove the first element of the split path. // As the first element is the root directory, which is not a virtual parent. + // e.g: + // assume directory-id1adsf - the root directory(cloud/local) + // virtualParentsPath = 'parent1/parent2', splitVirtualParentsPath = ['parent1', 'parent2'] + // parentsPath = 'directory-id1adsf/directory-id2adsf/directory-id3adsf', splitPath = ['directory-id1adsf', 'directory-id2adsf', 'directory-id3adsf'] + // We remove the root directory from the split path (it doesn't exist in the virtual parents path) -> virtualParentsIds = ['directory-id2adsf', 'directory-id3adsf'] const virtualParentsIds = splitPath.slice(1) - const splitVirtualParentsPath = virtualParentsPath.split('/') - const finalPath = (() => { - const result: ParsedDirectoriesPath[] = [] + const result: PathItem[] = [] + + const rootCategory = getCategoryByDirectoryId(rootDirectoryInPath) - if (rootDirectoryInPath == null) { + // If the root category is not found it might mean + // that user is no longer have access to this root directory. + // Usually this could happen if the user was removed from the organization + // or user group. + // This shouldn't happen though and these files should be filtered out + // by the backend. But we need to handle this case anyway. + if (rootCategory == null) { return result } result.push({ - id: rootDirectoryInPath, - // TODO: Get the name of the root directory from categories. - name: 'Root', + id: rootDirectoryId, + categoryId: rootCategory.id, + label: rootCategory.label, + icon: rootCategory.icon, }) for (const [index, id] of virtualParentsIds.entries()) { @@ -46,11 +74,16 @@ export function parseDirectoriesPath(parentsPath: string, virtualParentsPath: st continue } - result.push({ id, name }) + result.push({ + id, + label: name, + icon: FolderIcon, + categoryId: rootCategory.id, + }) } return result })() - return { fullPath: finalPath } + return { finalPath } as const } diff --git a/app/gui/src/dashboard/utilities/tailwindVariants.ts b/app/gui/src/dashboard/utilities/tailwindVariants.ts index 434321b35cae..3ca6030d292a 100644 --- a/app/gui/src/dashboard/utilities/tailwindVariants.ts +++ b/app/gui/src/dashboard/utilities/tailwindVariants.ts @@ -30,5 +30,29 @@ export type VariantProps< // eslint-disable-next-line @typescript-eslint/no-explicit-any Component extends (...args: any) => any, > = Omit[0]>, 'class' | 'className'> & { + /** + * Custom styles for a component. + * + * You can use this to override the default styles for a component. + * @example + * ```tsx + * const COMPONENT_STYLES = tv({ + * base: 'block', + * slots: { + * root: 'bg-red-500', + * }, + * }) + * + * const OVERRIDES = tv({ + * extend: COMPONENT_STYLES, + * slots: { + * // overrides the slot bg, but keeps the base styles + * root: 'bg-blue-500', + * }, + * }) + * + * + * ``` + */ variants?: ExtractFunction | undefined } diff --git a/app/gui/src/entrypoint.ts b/app/gui/src/entrypoint.ts index 12b6dc95e473..06e98e7ac8d4 100644 --- a/app/gui/src/entrypoint.ts +++ b/app/gui/src/entrypoint.ts @@ -24,6 +24,10 @@ const SCAM_WARNING_TIMEOUT = 1000 const INITIAL_URL_KEY = `Enso-initial-url` function main() { + if (detect.IS_DEV_MODE) { + suppressReactAriaConsoleWarnings() + suppressVueDevToolsConsoleWarnings() + } setupScamWarning() setupSentry() configureAnimations() @@ -178,4 +182,46 @@ function imNotSureButPerhapsFixingRefreshingWithAuthentication() { } } +function suppressConsoleMessage( + message: string | RegExp | (string | RegExp)[] | ((...args: unknown[]) => boolean), + level: 'warn' | 'error' | 'log' | 'debug' | 'info' = 'warn', +) { + const originalConsoleMethod = console[level] + + console[level] = function overrideConsoleMethod(...args: unknown[]) { + let shouldSuppress = false + + switch (true) { + case typeof message === 'function': + shouldSuppress = message(...args) + break + case typeof message === 'string': + shouldSuppress = args[0] === message + break + case Array.isArray(message): + shouldSuppress = message.some((m) => + typeof m === 'string' ? args[0] === m : m.test(args[0] as string), + ) + break + default: + shouldSuppress = message.test(args[0] as string) + break + } + + if (shouldSuppress) { + return + } + + return originalConsoleMethod.apply(console, args) + } +} + +function suppressReactAriaConsoleWarnings() { + suppressConsoleMessage(/A PressResponder was rendered without a pressable child/) +} + +function suppressVueDevToolsConsoleWarnings() { + suppressConsoleMessage((...args) => args[1] === 'data-v-inspector', 'error') +} + main() diff --git a/eslint.config.mjs b/eslint.config.mjs index a6418ae15d4c..5e2478084480 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -103,7 +103,7 @@ const RESTRICTED_SYNTAXES = [ }, { // Matches non-functions. - selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches([init.callee.object.name=React][init.callee.property.name=forwardRef], :has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor]), :has(CallExpression[callee.name=newtypeConstructor])))`, + selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches([init.callee.object.name=React][init.callee.property.name=forwardRef], [init.callee.object.name=React][init.callee.property.name=memo], :has(CallExpression[callee.name=memo]), :has(CallExpression[callee.name=forwardRef]), :has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor]), :has(CallExpression[callee.name=newtypeConstructor])))`, message: 'Use `CONSTANT_CASE` for top-level constants that are not functions', }, { From 040fb1fee78d649992b893764eda0b630aebe387 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 18 Feb 2025 16:51:09 +0300 Subject: [PATCH 08/34] Flat assets tree --- .../AriaComponents/Menu/MenuItem.tsx | 25 +- .../components/Breadcrumbs/BreadcrumbItem.tsx | 42 +- .../components/Breadcrumbs/Breadcrumbs.tsx | 7 +- .../dashboard/components/SelectionBrush.tsx | 4 +- .../components/dashboard/AssetRow.tsx | 6 +- .../dashboard/DatalinkNameColumn.tsx | 4 +- .../dashboard/DirectoryNameColumn.tsx | 10 +- .../components/dashboard/FileNameColumn.tsx | 4 +- .../dashboard/ProjectNameColumn.tsx | 14 +- .../components/dashboard/SecretNameColumn.tsx | 4 +- .../dashboard/components/dashboard/column.ts | 2 - .../dashboard/layouts/AssetContextMenu.tsx | 17 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 228 +++------ .../layouts/AssetsTableContextMenu.tsx | 6 +- app/gui/src/dashboard/layouts/Drive.tsx | 78 +--- app/gui/src/dashboard/layouts/DriveBar.tsx | 437 ------------------ .../dashboard/layouts/GlobalContextMenu.tsx | 16 +- .../dashboard/Drive/DriveBar/DriveBar.tsx | 97 ++++ .../Drive/DriveBar/DriveBarNavigation.tsx | 157 +++++++ .../Drive/DriveBar/DriveBarToolbar.tsx | 320 +++++++++++++ .../pages/dashboard/Drive/DriveBar/index.ts | 1 + .../dashboard/pages/dashboard/Drive/index.ts | 1 + .../src/dashboard/providers/DriveProvider.tsx | 67 +-- 23 files changed, 744 insertions(+), 803 deletions(-) create mode 100644 app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBar.tsx create mode 100644 app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx create mode 100644 app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarToolbar.tsx create mode 100644 app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/index.ts create mode 100644 app/gui/src/dashboard/pages/dashboard/Drive/index.ts diff --git a/app/gui/src/dashboard/components/AriaComponents/Menu/MenuItem.tsx b/app/gui/src/dashboard/components/AriaComponents/Menu/MenuItem.tsx index 6687ebcaee24..adbe63a9b398 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Menu/MenuItem.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Menu/MenuItem.tsx @@ -17,13 +17,8 @@ import type { IconProp, TestIdProps } from '../types' export const MENU_ITEM_STYLES = tv({ base: 'group flex w-full cursor-default gap-3 rounded-3xl px-[14px] py-1 outline-none transition-colors duration-75 text-left', variants: { - isDisabled: { - true: 'cursor-not-allowed', - false: '', - }, - isPressed: { - true: 'bg-primary/5', - }, + isDisabled: { true: 'cursor-not-allowed', false: '' }, + isPressed: { true: 'bg-primary/5' }, }, slots: { checkContainer: 'block', @@ -37,12 +32,7 @@ export const MENU_ITEM_STYLES = tv({ className: 'flex flex-1 min-w-0 w-full text-primary', }), }, - compoundSlots: [ - { - slots: ['checkContainer', 'icon'], - className: 'mt-[3.5px] text-primary', - }, - ], + compoundSlots: [{ slots: ['checkContainer', 'icon'], className: 'mt-[3.5px] text-primary' }], defaultVariants: { isDisabled: false, isSelected: false }, }) @@ -162,7 +152,7 @@ interface MenuItemIconProps extends MenuItemRenderProps } /** Renders the icon for the menu item */ -// eslint-disable-next-line no-restricted-syntax + const MenuItemIcon = memo(function MenuItemIcon( props: MenuItemIconProps, ) { @@ -176,7 +166,7 @@ const MenuItemIcon = memo(function MenuItemIcon( }) /** Renders the selection indicator for the menu item */ -// eslint-disable-next-line no-restricted-syntax + const SelectionIndicator = memo(function SelectionIndicator( props: MenuItemRenderProps & { className?: string }, ) { @@ -192,7 +182,7 @@ const SelectionIndicator = memo(function SelectionIndicator( }) /** Renders the shortcut text for the menu item */ -// eslint-disable-next-line no-restricted-syntax + const ShortcutText = memo(function ShortcutText(props: { shortcut?: string | undefined className?: string @@ -211,7 +201,7 @@ const ShortcutText = memo(function ShortcutText(props: { }) /** Renders the submenu indicator */ -// eslint-disable-next-line no-restricted-syntax + const SubmenuIndicator = memo(function SubmenuIndicator(props: { hasSubmenu: boolean className?: string @@ -234,7 +224,6 @@ interface MenuItemContentProps extends MenuItemRenderProps { /** * Renders the content of the menu item. */ -// eslint-disable-next-line no-restricted-syntax const MenuItemContent = memo(function MenuItemContent(props: MenuItemContentProps) { const { title, description, ...renderProps } = props diff --git a/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx b/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx index 47d303869950..637b5b91c490 100644 --- a/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx +++ b/app/gui/src/dashboard/components/Breadcrumbs/BreadcrumbItem.tsx @@ -17,6 +17,8 @@ import { } from 'react' import { useBreadcrumbItem, type AriaBreadcrumbItemProps } from 'react-aria' import type * as aria from 'react-aria-components' +import type { DragAndDropHooks } from 'react-aria-components' +import type { DraggableCollectionState } from 'react-stately' import invariant from 'tiny-invariant' import { Button, Menu, Text, type Addon, type IconProp, type TestIdProps } from '../AriaComponents' import { Icon as IconComponent, renderIcon } from '../Icon' @@ -65,6 +67,7 @@ export interface BreadcrumbItemProps readonly className?: string | ((renderProps: BreadcrumbItemRenderProps) => string) readonly style?: CSSProperties | ((renderProps: BreadcrumbItemRenderProps) => CSSProperties) readonly children: ReactNode | ((renderProps: BreadcrumbItemRenderProps) => ReactNode) + readonly isLoading?: boolean } /** @@ -77,7 +80,7 @@ export interface BreadcrumbItemContextType { * And be able to check if `onAction` prop was specified and id is not. */ readonly onActionSpecified: boolean - readonly onAction: (key: Key) => void + readonly onAction: (key: Key) => Promise | void } /** @@ -132,12 +135,12 @@ export function BreadcrumbItem(props: BreadcrumbItemPro const ref = useRef(null) const { itemProps } = useBreadcrumbItem({ elementType: 'div', ...breadcrumbItemProps }, ref) - const onPress = useEventCallback(() => { + const onPress = useEventCallback(async () => { if (id == null) { return } - onAction(id) + await onAction(id) }) const iconComponent = (() => { @@ -159,15 +162,7 @@ export function BreadcrumbItem(props: BreadcrumbItemPro {} // This is safe because we're passing link props transparently // eslint-disable-next-line no-restricted-syntax - : ({ - href, - hrefLang, - target, - download, - rel, - ping, - referrerPolicy, - } as Pick< + : ({ href, hrefLang, target, download, rel, ping, referrerPolicy } as Pick< aria.LinkProps, 'download' | 'href' | 'hrefLang' | 'ping' | 'referrerPolicy' | 'rel' | 'target' >) @@ -219,6 +214,19 @@ export function BreadcrumbItem(props: BreadcrumbItemPro ) } +/** + * + */ +type DropHooks = Pick< + DragAndDropHooks, + | 'DragPreview' + | 'dropTargetDelegate' + | 'renderDropIndicator' + | 'useDropIndicator' + | 'useDroppableCollection' + | 'useDroppableItem' +> + /** * Props for {@link BreadcrumbCollapsedItem} */ @@ -231,6 +239,8 @@ interface BreadcrumbCollapsedItemProps { readonly triggerLabel?: string /** The callback to call when an item is selected */ readonly onAction?: (key: Key) => void + readonly dropHooks?: DropHooks | undefined + readonly draggableCollectionState?: DraggableCollectionState | undefined } /** @@ -240,7 +250,13 @@ interface BreadcrumbCollapsedItemProps { export function BreadcrumbCollapsedItem(props: BreadcrumbCollapsedItemProps) { const { getText } = useText() - const { items, children, triggerLabel = getText('more') } = props + const { + items, + children, + triggerLabel = getText('more'), + dropHooks, + draggableCollectionState, + } = props const { onAction } = useContext(BreadcrumbItemContext) diff --git a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx index 31df92ca1ada..10153a8331c0 100644 --- a/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx +++ b/app/gui/src/dashboard/components/Breadcrumbs/Breadcrumbs.tsx @@ -1,7 +1,6 @@ /** * @file Breadcrumbs component implementation. */ - import ArrowRight from '#/assets/expand_arrow_right.svg' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { tv, type VariantProps } from '#/utilities/tailwindVariants' @@ -17,7 +16,7 @@ import { type ReactNode, } from 'react' import flattenChildren from 'react-keyed-flatten-children' -import { useBreadcrumbs, type AriaBreadcrumbsProps } from '../aria' +import { useBreadcrumbs, type AriaBreadcrumbsProps, type DragAndDropHooks } from '../aria' import { Button, type TestIdProps } from '../AriaComponents' import { Icon } from '../Icon' import { BreadcrumbCollapsedItem, BreadcrumbItem, BreadcrumbItemProvider } from './BreadcrumbItem' @@ -38,8 +37,9 @@ export interface BreadcrumbsProps /** The breadcrumb items. */ readonly children: ReactNode /** Called when an item is acted upon (usually selection via press). */ - readonly onAction?: (key: Key) => void + readonly onAction?: (key: Key) => Promise | void readonly className?: string + readonly dragAndDropHooks?: DragAndDropHooks | undefined } /** @@ -52,6 +52,7 @@ export function Breadcrumbs(props: BreadcrumbsProps) { variants = BREADCRUMBS_STYLES, testId, onAction = () => {}, + dragAndDropHooks, ...breadcrumbsProps } = props diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 89343b5fd195..b22bdc5dff9e 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -97,7 +97,7 @@ const enum DIRECTION { /** * A selection brush to indicate the area being selected by the mouse drag action. */ -export function SelectionBrush(props: SelectionBrushV2Props) { +export const SelectionBrush = React.memo(function SelectionBrush(props: SelectionBrushV2Props) { const { targetRef, preventDrag = () => false, @@ -381,7 +381,7 @@ export function SelectionBrush(props: SelectionBrushV2Props) { /> ) -} +}) /** * Whether the current position is in the dead zone. diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 11462a59c60e..d9d7c249b49b 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -633,9 +633,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { backendType={backend.type} item={asset} depth={depth} - selected={selected} setSelected={setSelected} - isSoleSelected={isSoleSelected} state={state} rowState={rowState} setRowState={setRowState} @@ -646,7 +644,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { })} - {selected && allowContextMenu && ( + {/* {selected && allowContextMenu && ( // This is a copy of the context menu, since the context menu registers keyboard // shortcut handlers. This is a bit of a hack, however it is preferable to duplicating // the entire context menu (once for the keyboard actions, once for the JSX). @@ -661,7 +659,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { doCut={doCut} doPaste={doPaste} /> - )} + )} */} ) } diff --git a/app/gui/src/dashboard/components/dashboard/DatalinkNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DatalinkNameColumn.tsx index 6c97ad5900bf..ec26c16c3b67 100644 --- a/app/gui/src/dashboard/components/dashboard/DatalinkNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DatalinkNameColumn.tsx @@ -27,7 +27,7 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps { * This should never happen. */ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) { - const { item, selected, rowState, setRowState, isEditable, depth } = props + const { item, rowState, setRowState, isEditable, depth } = props const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible() const setIsEditing = (isEditingName: boolean) => { @@ -53,7 +53,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) { } }} onClick={(event) => { - if (eventModule.isSingleClick(event) && selected) { + if (eventModule.isSingleClick(event)) { setIsEditing(true) } else if (eventModule.isDoubleClick(event)) { event.stopPropagation() diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index 0a139a1cc7e5..735b1f1c3ebf 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -36,7 +36,7 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps { * This should never happen. */ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { - const { item, depth, selected, state, rowState, setRowState, isEditable } = props + const { item, depth, state, rowState, setRowState, isEditable } = props const [isLoading, startTransition] = useTransition() @@ -74,11 +74,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { } }} onClick={(event) => { - if ( - eventModule.isSingleClick(event) && - selected && - driveStore.getState().selectedKeys.size === 1 - ) { + if (eventModule.isSingleClick(event) && driveStore.getState().selectedKeys.size === 1) { event.stopPropagation() setIsEditing(true) } @@ -95,7 +91,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { className="mx-1 transition-transform duration-arrow" onPress={() => { startTransition(() => { - setCurrentDirectoryId(item.id) + setCurrentDirectoryId({ current: item.id, parent: item.parentId }) }) }} /> diff --git a/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx index f0b5b7a0ff47..df5e7c9f8299 100644 --- a/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/FileNameColumn.tsx @@ -31,7 +31,7 @@ export interface FileNameColumnProps extends column.AssetColumnProps { * This should never happen. */ export default function FileNameColumn(props: FileNameColumnProps) { - const { item, selected, state, rowState, setRowState, isEditable, depth } = props + const { item, state, rowState, setRowState, isEditable, depth } = props const { backend, nodeMap } = state const isCloud = backend.type === backendModule.BackendType.remote @@ -61,7 +61,7 @@ export default function FileNameColumn(props: FileNameColumnProps) { } }} onClick={(event) => { - if (eventModule.isSingleClick(event) && selected) { + if (eventModule.isSingleClick(event)) { if (!isCloud) { setIsEditing(true) } diff --git a/app/gui/src/dashboard/components/dashboard/ProjectNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/ProjectNameColumn.tsx index 725589c36e21..f610c31eeaaf 100644 --- a/app/gui/src/dashboard/components/dashboard/ProjectNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/ProjectNameColumn.tsx @@ -36,17 +36,8 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps { * This should never happen. */ export default function ProjectNameColumn(props: ProjectNameColumnProps) { - const { - item, - selected, - rowState, - setRowState, - state, - isEditable, - backendType, - isOpened, - isPlaceholder, - } = props + const { item, rowState, setRowState, state, isEditable, backendType, isOpened, isPlaceholder } = + props const { depth } = props const { backend, nodeMap } = state @@ -104,7 +95,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { } else if ( !isRunning && eventModule.isSingleClick(event) && - selected && driveStore.getState().selectedKeys.size === 1 ) { setIsEditing(true) diff --git a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx index 4efeb3503160..e4610b63664c 100644 --- a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx @@ -37,7 +37,7 @@ export interface SecretNameColumnProps extends column.AssetColumnProps { * This should never happen. */ export default function SecretNameColumn(props: SecretNameColumnProps) { - const { item, selected, state, rowState, setRowState, isEditable, depth } = props + const { item, state, rowState, setRowState, isEditable, depth } = props const { backend, nodeMap } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { getText } = useText() @@ -68,7 +68,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { } }} onClick={(event) => { - if (eventModule.isSingleClick(event) && selected) { + if (eventModule.isSingleClick(event)) { setIsEditing(true) } else if (eventModule.isDoubleClick(event) && isEditable) { event.stopPropagation() diff --git a/app/gui/src/dashboard/components/dashboard/column.ts b/app/gui/src/dashboard/components/dashboard/column.ts index 8fc314cb17b7..e6762804c52b 100644 --- a/app/gui/src/dashboard/components/dashboard/column.ts +++ b/app/gui/src/dashboard/components/dashboard/column.ts @@ -21,9 +21,7 @@ export interface AssetColumnProps { readonly item: AnyAsset readonly depth: number readonly backendType: BackendType - readonly selected: boolean readonly setSelected: (selected: boolean) => void - readonly isSoleSelected: boolean readonly state: AssetsTableState readonly rowState: AssetRowState readonly setRowState: Dispatch> diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx index 9a7c35cb2684..02396fb03ff0 100644 --- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx @@ -122,6 +122,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { ]), ) ) + const canPaste = !pasteData || !pasteDataParentKeys || !isCloud ? true @@ -169,6 +170,15 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { asset.projectState.openedBy != null && asset.projectState.openedBy !== user.email + console.log('asset', { + canManageThisAsset, + canEditThisAsset, + canAddToThisDirectory, + canPaste, + hasPasteData, + isCloud, + }) + const pasteMenuEntry = hasPasteData && canPaste && (