From 4c5729fa54dfc9c48a7093ba7c6a2a0f3379ec5f Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 26 Dec 2024 16:39:14 +1000 Subject: [PATCH 01/31] Expand parent directories of opened projects --- .../dashboard/pages/dashboard/Dashboard.tsx | 109 +++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 05054a6deac3..6c3049dec970 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -45,12 +45,19 @@ import * as backendModule from '#/services/Backend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' -import { useSetCategory } from '#/providers/DriveProvider' +import { listDirectoryQueryOptions } from '#/hooks/backendHooks' +import { useSyncRef } from '#/hooks/syncRefHooks' +import { + useCategory, + useDriveStore, + useSetCategory, + useSetExpandedDirectoryIds, +} from '#/providers/DriveProvider' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' -import { usePrefetchQuery } from '@tanstack/react-query' +import { usePrefetchQuery, useQueryClient } from '@tanstack/react-query' import { DashboardTabPanels } from './DashboardTabPanels' // ================= @@ -71,6 +78,7 @@ export default function Dashboard(props: DashboardProps) { return ( + @@ -318,3 +326,100 @@ function DashboardInner(props: DashboardProps) { ) } + +/** Expand the list of parents for opened projects. */ +function OpenedProjectsParentsExpander() { + const queryClient = useQueryClient() + const remoteBackend = backendProvider.useRemoteBackend() + const localBackend = backendProvider.useLocalBackend() + const category = useCategory() + const driveStore = useDriveStore() + const launchedProjects = useLaunchedProjects() + const setExpandedDirectoryIds = useSetExpandedDirectoryIds() + const launchedProjectsRef = useSyncRef(launchedProjects) + + React.useEffect(() => { + switch (category.type) { + case 'cloud': + case 'team': + case 'user': { + const relevantProjects = launchedProjectsRef.current.filter( + (project) => project.type === backendModule.BackendType.remote, + ) + const promises = relevantProjects.map((project) => + queryClient.ensureQueryData( + listDirectoryQueryOptions({ + backend: remoteBackend, + parentId: project.parentId, + category, + }), + ), + ) + void Promise.allSettled(promises) + .then((projects) => + projects.flatMap((directoryResult, i) => { + const projectInfo = relevantProjects[i] + const project = + projectInfo && directoryResult.status === 'fulfilled' ? + directoryResult.value + .filter(backendModule.assetIsProject) + .find((asset) => asset.id === projectInfo.id) + : null + return project ? [project] : [] + }), + ) + .then((projects) => { + const expandedDirectoryIds = new Set(driveStore.getState().expandedDirectoryIds) + for (const project of projects) { + const parents = project.parentsPath.split('/').map(backendModule.DirectoryId) + for (const parent of parents) { + expandedDirectoryIds.add(parent) + } + } + setExpandedDirectoryIds([...expandedDirectoryIds]) + }) + break + } + case 'local': + case 'local-directory': { + if (!localBackend) { + break + } + const rootPath = 'rootPath' in category ? category.rootPath : localBackend.rootPath() + const relevantProjects = launchedProjectsRef.current.filter( + (project) => project.type === backendModule.BackendType.local, + ) + const expandedDirectoryIds = new Set(driveStore.getState().expandedDirectoryIds) + for (const project of relevantProjects) { + const path = localBackendModule.extractTypeAndId(project.parentId).id + const strippedPath = path.replace(rootPath, '') + if (strippedPath !== path) { + let parentPath = String(rootPath) + const parents = strippedPath.split('/') + for (const parent of parents) { + parentPath += `/${parent}` + expandedDirectoryIds.add(backendModule.DirectoryId(parentPath)) + } + } + } + setExpandedDirectoryIds([...expandedDirectoryIds]) + break + } + case 'recent': + case 'trash': { + // Ignored - directories should not be expanded here. + break + } + } + }, [ + category, + driveStore, + launchedProjectsRef, + localBackend, + queryClient, + remoteBackend, + setExpandedDirectoryIds, + ]) + + return null +} From edddae03170012cab9dddffe59b434fee056f72a Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 26 Dec 2024 19:34:18 +1000 Subject: [PATCH 02/31] Fix expanding local directories --- app/gui/src/dashboard/pages/dashboard/Dashboard.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 6c3049dec970..8daeeb09cb31 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -392,13 +392,15 @@ function OpenedProjectsParentsExpander() { const expandedDirectoryIds = new Set(driveStore.getState().expandedDirectoryIds) for (const project of relevantProjects) { const path = localBackendModule.extractTypeAndId(project.parentId).id - const strippedPath = path.replace(rootPath, '') + const strippedPath = path.replace(`${rootPath}/`, '') if (strippedPath !== path) { let parentPath = String(rootPath) const parents = strippedPath.split('/') for (const parent of parents) { parentPath += `/${parent}` - expandedDirectoryIds.add(backendModule.DirectoryId(parentPath)) + expandedDirectoryIds.add( + localBackendModule.newDirectoryId(backendModule.Path(parentPath)), + ) } } } From 8269f4eca26e38e1f423333d406c4a31e3ac4f0d Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 30 Dec 2024 18:03:14 +1000 Subject: [PATCH 03/31] Automatically expand "Users" or "Teams" root directory as appropriate --- .../src/dashboard/pages/dashboard/Dashboard.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 8daeeb09cb31..b518ea77a4ac 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -53,11 +53,13 @@ import { useSetCategory, useSetExpandedDirectoryIds, } from '#/providers/DriveProvider' +import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' import { usePrefetchQuery, useQueryClient } from '@tanstack/react-query' +import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' import { DashboardTabPanels } from './DashboardTabPanels' // ================= @@ -337,6 +339,13 @@ function OpenedProjectsParentsExpander() { const launchedProjects = useLaunchedProjects() const setExpandedDirectoryIds = useSetExpandedDirectoryIds() const launchedProjectsRef = useSyncRef(launchedProjects) + const { user } = authProvider.useFullUserSession() + + const userGroupDirectoryIds = new Set( + (user.userGroups ?? EMPTY_ARRAY).map((groupId) => + backendModule.DirectoryId(groupId.replace(/^usergroup-/, 'directory-')), + ), + ) React.useEffect(() => { switch (category.type) { @@ -372,6 +381,12 @@ function OpenedProjectsParentsExpander() { const expandedDirectoryIds = new Set(driveStore.getState().expandedDirectoryIds) for (const project of projects) { const parents = project.parentsPath.split('/').map(backendModule.DirectoryId) + const rootDirectoryId = parents[0] + if ((rootDirectoryId && userGroupDirectoryIds.has(rootDirectoryId)) ?? false) { + expandedDirectoryIds.add(TEAMS_DIRECTORY_ID) + } else { + expandedDirectoryIds.add(USERS_DIRECTORY_ID) + } for (const parent of parents) { expandedDirectoryIds.add(parent) } From e5259d50779f167c0d9a65e3a748ad6a1762ea0f Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 30 Dec 2024 18:05:58 +1000 Subject: [PATCH 04/31] Fix errors --- app/gui/src/dashboard/pages/dashboard/Dashboard.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index b518ea77a4ac..c0b8f5142136 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -341,13 +341,13 @@ function OpenedProjectsParentsExpander() { const launchedProjectsRef = useSyncRef(launchedProjects) const { user } = authProvider.useFullUserSession() - const userGroupDirectoryIds = new Set( - (user.userGroups ?? EMPTY_ARRAY).map((groupId) => - backendModule.DirectoryId(groupId.replace(/^usergroup-/, 'directory-')), - ), - ) - React.useEffect(() => { + const userGroupDirectoryIds = new Set( + (user.userGroups ?? EMPTY_ARRAY).map((groupId) => + backendModule.DirectoryId(groupId.replace(/^usergroup-/, 'directory-')), + ), + ) + switch (category.type) { case 'cloud': case 'team': @@ -436,6 +436,7 @@ function OpenedProjectsParentsExpander() { queryClient, remoteBackend, setExpandedDirectoryIds, + user.userGroups, ]) return null From b2b361bdd36ef28c9a3e0b0011902dacabcc067e Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 31 Dec 2024 17:25:57 +1000 Subject: [PATCH 05/31] Only expand ancestors of initially launched projects once --- .../src/dashboard/pages/dashboard/Dashboard.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index afe85e7bbc03..c55aca89025d 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -329,7 +329,7 @@ function OpenedProjectsParentsExpander() { const launchedProjectsRef = useSyncRef(launchedProjects) const { user } = authProvider.useFullUserSession() - React.useEffect(() => { + const updateOpenedProjects = eventCallbacks.useEventCallback(() => { const userGroupDirectoryIds = new Set( (user.userGroups ?? EMPTY_ARRAY).map(userGroupIdToDirectoryId), ) @@ -414,16 +414,11 @@ function OpenedProjectsParentsExpander() { break } } - }, [ - category, - driveStore, - launchedProjectsRef, - localBackend, - queryClient, - remoteBackend, - setExpandedDirectoryIds, - user.userGroups, - ]) + }) + + React.useEffect(() => { + updateOpenedProjects() + }, [updateOpenedProjects]) return null } From e943fe1d9ac200a76a11031fc34f1e802fbe899d Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 31 Dec 2024 22:35:46 +1000 Subject: [PATCH 06/31] WIP: Separate ancestor lists for each Drive sidebar category --- app/common/src/services/Backend.ts | 5 + .../dashboard/column/PathColumn.tsx | 4 +- .../layouts/Drive/Categories/Category.ts | 9 +- .../Drive/Categories/categoriesHooks.tsx | 15 +- .../layouts/Drive/directoryIdsHooks.tsx | 6 +- .../dashboard/pages/dashboard/Dashboard.tsx | 179 ++++++++++-------- .../src/dashboard/providers/DriveProvider.tsx | 47 +++-- .../src/dashboard/services/LocalBackend.ts | 13 +- 8 files changed, 160 insertions(+), 118 deletions(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 15e01e9a15fd..9f3483eb3c3b 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -134,6 +134,11 @@ export type UserPermissionIdentifier = UserGroupId | UserId export type Path = newtype.Newtype export const Path = newtype.newtypeConstructor() +/** Whether the given path is a descendant of another path. */ +export function isDescendantPath(path: Path, possibleAncestor: Path) { + return path.startsWith(`${possibleAncestor}/`) +} + const PLACEHOLDER_USER_GROUP_PREFIX = 'usergroup-placeholder-' /** diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index d3e9cac75164..fd3569ac829e 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -7,7 +7,7 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useCategoriesAPI, useCloudCategoryList } from '#/layouts/Drive/Categories/categoriesHooks' import type { AnyCloudCategory } from '#/layouts/Drive/Categories/Category' import { useUser } from '#/providers/AuthProvider' -import { useSetExpandedDirectoryIds, useSetSelectedKeys } from '#/providers/DriveProvider' +import { useSetExpandedDirectories, useSetSelectedKeys } from '#/providers/DriveProvider' import type { DirectoryId } from '#/services/Backend' import { isDirectoryId } from '#/services/Backend' import { Fragment, useTransition } from 'react' @@ -24,7 +24,7 @@ export default function PathColumn(props: AssetColumnProps) { const { setCategory } = useCategoriesAPI() const setSelectedKeys = useSetSelectedKeys() - const setExpandedDirectoryIds = useSetExpandedDirectoryIds() + const setExpandedDirectoryIds = useSetExpandedDirectories() // Path navigation exist only for cloud categories. const { getCategoryByDirectoryId } = useCloudCategoryList() diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts index 2eacd4715c39..708d81ef1ae9 100644 --- a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts +++ b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts @@ -25,7 +25,11 @@ import { newDirectoryId } from '#/services/LocalBackend' const PATH_SCHEMA = z.string().refine((s): s is Path => true) const DIRECTORY_ID_SCHEMA = z.string().refine((s): s is DirectoryId => true) -const EACH_CATEGORY_SCHEMA = z.object({ label: z.string(), icon: z.string() }) +const EACH_CATEGORY_SCHEMA = z.object({ + label: z.string(), + icon: z.string(), + rootPath: PATH_SCHEMA, +}) /** A category corresponding to the root of the user or organization. */ const CLOUD_CATEGORY_SCHEMA = z @@ -61,7 +65,6 @@ export const USER_CATEGORY_SCHEMA = z type: z.literal('user'), user: z.custom(() => true), id: z.custom(() => true), - rootPath: PATH_SCHEMA, homeDirectoryId: DIRECTORY_ID_SCHEMA, }) .merge(EACH_CATEGORY_SCHEMA) @@ -74,7 +77,6 @@ export const TEAM_CATEGORY_SCHEMA = z type: z.literal('team'), id: z.custom(() => true), team: z.custom(() => true), - rootPath: PATH_SCHEMA, homeDirectoryId: DIRECTORY_ID_SCHEMA, }) .merge(EACH_CATEGORY_SCHEMA) @@ -96,7 +98,6 @@ export const LOCAL_DIRECTORY_CATEGORY_SCHEMA = z .object({ type: z.literal('local-directory'), id: z.custom(() => true), - rootPath: PATH_SCHEMA, homeDirectoryId: DIRECTORY_ID_SCHEMA, }) .merge(EACH_CATEGORY_SCHEMA) diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx b/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx index d5d6675670be..91f02c8cd71d 100644 --- a/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx @@ -84,21 +84,23 @@ export function useCloudCategoryList() { const hasUserAndTeamSpaces = userHasUserAndTeamSpaces(user) + const homeDirectoryId = + hasUserAndTeamSpaces ? organizationIdToDirectoryId(organizationId) : userIdToDirectoryId(userId) + const cloudCategory: CloudCategory = { type: 'cloud', id: 'cloud', label: getText('cloudCategory'), icon: CloudIcon, - homeDirectoryId: - hasUserAndTeamSpaces ? - organizationIdToDirectoryId(organizationId) - : userIdToDirectoryId(userId), + rootPath: Path(`enso://${hasUserAndTeamSpaces ? `Users/${user.name}` : ''}`), + homeDirectoryId, } const recentCategory: RecentCategory = { type: 'recent', id: 'recent', label: getText('recentCategory'), + rootPath: Path(`(Recent)`), icon: RecentIcon, } @@ -106,6 +108,7 @@ export function useCloudCategoryList() { type: 'trash', id: 'trash', label: getText('trashCategory'), + rootPath: Path(`(Trash)`), icon: Trash2Icon, } @@ -219,6 +222,10 @@ export function useLocalCategoryList() { type: 'local', id: 'local', label: getText('localCategory'), + /** The root path of this category. */ + get rootPath() { + return localBackend?.rootPath() ?? Path('') + }, icon: ComputerIcon, } diff --git a/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx b/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx index 7138d9ad8d92..107903443bf5 100644 --- a/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx @@ -9,7 +9,7 @@ import { Path, createRootDirectoryAsset } from 'enso-common/src/services/Backend import type { Category } from '#/layouts/CategorySwitcher/Category' import { useFullUserSession } from '#/providers/AuthProvider' import { useBackend } from '#/providers/BackendProvider' -import { useExpandedDirectoryIds, useSetExpandedDirectoryIds } from '#/providers/DriveProvider' +import { useExpandedDirectories, useSetExpandedDirectories } from '#/providers/DriveProvider' import { useLocalStorageState } from '#/providers/LocalStorageProvider' /** Options for {@link useDirectoryIds}. */ @@ -35,8 +35,8 @@ export function useDirectoryIds(options: UseDirectoryIdsOptions) { * The root directory is not included as it might change when a user switches * between items in sidebar and we don't want to reset the expanded state using `useEffect`. */ - const privateExpandedDirectoryIds = useExpandedDirectoryIds() - const setExpandedDirectoryIds = useSetExpandedDirectoryIds() + const privateExpandedDirectoryIds = useExpandedDirectories() + const setExpandedDirectoryIds = useSetExpandedDirectories() const [localRootDirectory] = useLocalStorageState('localRootDirectory') diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index c55aca89025d..6333438af8b3 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -45,11 +45,11 @@ import * as backendModule from '#/services/Backend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' -import { listDirectoryQueryOptions } from '#/hooks/backendHooks' +import { listDirectoryQueryOptions, useBackendQuery } from '#/hooks/backendHooks' import { useSyncRef } from '#/hooks/syncRefHooks' import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' -import { useDriveStore, useSetExpandedDirectoryIds } from '#/providers/DriveProvider' -import { userGroupIdToDirectoryId } from '#/services/RemoteBackend' +import { useDriveStore, useSetExpandedDirectories } from '#/providers/DriveProvider' +import { userGroupIdToDirectoryId, userIdToDirectoryId } from '#/services/RemoteBackend' import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' @@ -57,6 +57,8 @@ import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' import { usePrefetchQuery, useQueryClient } from '@tanstack/react-query' import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' +import { unsafeEntries, unsafeMutable } from 'enso-common/src/utilities/data/object' +import invariant from 'tiny-invariant' import { DashboardTabPanels } from './DashboardTabPanels' // ================= @@ -325,99 +327,120 @@ function OpenedProjectsParentsExpander() { const category = categoriesAPI.category const driveStore = useDriveStore() const launchedProjects = useLaunchedProjects() - const setExpandedDirectoryIds = useSetExpandedDirectoryIds() + const setExpandedDirectories = useSetExpandedDirectories() const launchedProjectsRef = useSyncRef(launchedProjects) const { user } = authProvider.useFullUserSession() + const { data: userGroups } = useBackendQuery(remoteBackend, 'listUserGroups', []) + const { data: users } = useBackendQuery(remoteBackend, 'listUsers', []) - const updateOpenedProjects = eventCallbacks.useEventCallback(() => { + const updateOpenedProjects = eventCallbacks.useEventCallback(async () => { const userGroupDirectoryIds = new Set( (user.userGroups ?? EMPTY_ARRAY).map(userGroupIdToDirectoryId), ) - switch (category.type) { - case 'cloud': - case 'team': - case 'user': { - const relevantProjects = launchedProjectsRef.current.filter( - (project) => project.type === backendModule.BackendType.remote, - ) - const promises = relevantProjects.map((project) => - queryClient.ensureQueryData( - listDirectoryQueryOptions({ - backend: remoteBackend, - parentId: project.parentId, - category, - }), - ), - ) - void Promise.allSettled(promises) - .then((projects) => - projects.flatMap((directoryResult, i) => { - const projectInfo = relevantProjects[i] - const project = - projectInfo && directoryResult.status === 'fulfilled' ? - directoryResult.value - .filter(backendModule.assetIsProject) - .find((asset) => asset.id === projectInfo.id) - : null - return project ? [project] : [] - }), - ) - .then((projects) => { - const expandedDirectoryIds = new Set(driveStore.getState().expandedDirectoryIds) - for (const project of projects) { - const parents = project.parentsPath.split('/').filter(backendModule.isDirectoryId) - const rootDirectoryId = parents[0] - if ((rootDirectoryId && userGroupDirectoryIds.has(rootDirectoryId)) ?? false) { - expandedDirectoryIds.add(TEAMS_DIRECTORY_ID) - } else { - expandedDirectoryIds.add(USERS_DIRECTORY_ID) + const expandedDirectories = structuredClone(driveStore.getState().expandedDirectories) + + if (localBackend) { + const rootPath = 'rootPath' in category ? category.rootPath : localBackend.rootPath() + const localProjects = launchedProjectsRef.current.filter( + (project) => project.type === backendModule.BackendType.local, + ) + for (const project of localProjects) { + const path = localBackendModule.extractTypeAndId(project.parentId).id + const strippedPath = path.replace(`${rootPath}/`, '') + if (strippedPath !== path) { + let parentPath = String(rootPath) + const parents = strippedPath.split('/') + for (const parent of parents) { + parentPath += `/${parent}` + const currentParentPath = backendModule.Path(parentPath) + const currentParentId = localBackendModule.newDirectoryId(currentParentPath) + for (const [categoryRootPath, directoriesInCategory] of unsafeEntries( + expandedDirectories, + )) { + if (!backendModule.isDescendantPath(currentParentPath, categoryRootPath)) { + continue } - for (const parent of parents) { - expandedDirectoryIds.add(parent) + if (directoriesInCategory.includes(currentParentId)) { + continue } - } - setExpandedDirectoryIds([...expandedDirectoryIds]) - }) - break - } - case 'local': - case 'local-directory': { - if (!localBackend) { - break - } - const rootPath = 'rootPath' in category ? category.rootPath : localBackend.rootPath() - const relevantProjects = launchedProjectsRef.current.filter( - (project) => project.type === backendModule.BackendType.local, - ) - const expandedDirectoryIds = new Set(driveStore.getState().expandedDirectoryIds) - for (const project of relevantProjects) { - const path = localBackendModule.extractTypeAndId(project.parentId).id - const strippedPath = path.replace(`${rootPath}/`, '') - if (strippedPath !== path) { - let parentPath = String(rootPath) - const parents = strippedPath.split('/') - for (const parent of parents) { - parentPath += `/${parent}` - expandedDirectoryIds.add( - localBackendModule.newDirectoryId(backendModule.Path(parentPath)), - ) + const id = localBackendModule.newDirectoryId(currentParentPath) + // This is SAFE as the value has been `structuredClone`d above. + unsafeMutable(directoriesInCategory).push(id) } } } - setExpandedDirectoryIds([...expandedDirectoryIds]) - break } - case 'recent': - case 'trash': { - // Ignored - directories should not be expanded here. - break + } + + const cloudProjects = launchedProjectsRef.current.filter( + (project) => project.type === backendModule.BackendType.remote, + ) + const promises = cloudProjects.map((project) => + queryClient.ensureQueryData( + listDirectoryQueryOptions({ + backend: remoteBackend, + parentId: project.parentId, + category, + }), + ), + ) + const projects = await Promise.allSettled(promises) + const projects2 = projects.flatMap((directoryResult, i) => { + const projectInfo = cloudProjects[i] + const project = + projectInfo && directoryResult.status === 'fulfilled' ? + directoryResult.value + .filter(backendModule.assetIsProject) + .find((asset) => asset.id === projectInfo.id) + : null + return project ? [project] : [] + }) + for (const project of projects2) { + const parents = project.parentsPath.split('/').filter(backendModule.isDirectoryId) + const rootDirectoryId = parents[0] + const baseVirtualPath = (() => { + const userGroupName = userGroups?.find( + (userGroup) => userGroupIdToDirectoryId(userGroup.id) === rootDirectoryId, + )?.groupName + if (userGroupName != null) { + return `enso://Teams/${userGroupName}` + } + const userName = users?.find( + (otherUser) => userIdToDirectoryId(otherUser.userId) === rootDirectoryId, + )?.name + if (userName != null) { + return `enso://Users/${userGroupName}` + } + })() + const virtualPath = backendModule.Path(`${baseVirtualPath}/${project.virtualParentsPath}`) + invariant( + baseVirtualPath != null, + 'The root directory must be either a user directory or a team directory.', + ) + for (const [categoryRootPath, directoriesInCategoryRaw] of unsafeEntries( + expandedDirectories, + )) { + const directoriesInCategory = unsafeMutable(directoriesInCategoryRaw) + if (!backendModule.isDescendantPath(virtualPath, categoryRootPath)) { + continue + } + if ((rootDirectoryId && userGroupDirectoryIds.has(rootDirectoryId)) ?? false) { + directoriesInCategory.push(TEAMS_DIRECTORY_ID) + } else { + directoriesInCategory.push(USERS_DIRECTORY_ID) + } + for (const parent of parents) { + directoriesInCategory.push(parent) + } } } + + setExpandedDirectories(expandedDirectories) }) React.useEffect(() => { - updateOpenedProjects() + void updateOpenedProjects() }, [updateOpenedProjects]) return null diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index db4ad93eb21b..9ea9acd4656e 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -6,6 +6,7 @@ import invariant from 'tiny-invariant' import { useEventCallback } from '#/hooks/eventCallbackHooks' import type { Category } from '#/layouts/CategorySwitcher/Category' +import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' import { EMPTY_SET } from '#/utilities/set' @@ -14,8 +15,8 @@ import type { BackendType, DirectoryAsset, DirectoryId, + Path, } from 'enso-common/src/services/Backend' -import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' // ================== // === DriveStore === @@ -41,8 +42,8 @@ interface DriveStore { readonly setCanDownload: (canDownload: boolean) => void readonly pasteData: PasteData | null readonly setPasteData: (pasteData: PasteData | null) => void - readonly expandedDirectoryIds: readonly DirectoryId[] - readonly setExpandedDirectoryIds: (selectedKeys: readonly DirectoryId[]) => void + readonly expandedDirectories: Record + readonly setExpandedDirectories: (selectedKeys: Record) => void readonly selectedKeys: ReadonlySet readonly setSelectedKeys: (selectedKeys: ReadonlySet) => void readonly visuallySelectedKeys: ReadonlySet | null @@ -86,7 +87,6 @@ export default function DriveProvider(props: ProjectsProviderProps) { targetDirectory: null, selectedKeys: EMPTY_SET, visuallySelectedKeys: null, - expandedDirectoryIds: EMPTY_ARRAY, }) }, targetDirectory: null, @@ -119,10 +119,10 @@ export default function DriveProvider(props: ProjectsProviderProps) { set({ pasteData }) } }, - expandedDirectoryIds: EMPTY_ARRAY, - setExpandedDirectoryIds: (expandedDirectoryIds) => { - if (get().expandedDirectoryIds !== expandedDirectoryIds) { - set({ expandedDirectoryIds }) + expandedDirectories: {}, + setExpandedDirectories: (expandedDirectories) => { + if (get().expandedDirectories !== expandedDirectories) { + set({ expandedDirectories }) } }, selectedKeys: EMPTY_SET, @@ -215,15 +215,15 @@ export function useSetPasteData() { } /** The expanded directories in the Asset Table. */ -export function useExpandedDirectoryIds() { +export function useExpandedDirectories() { const store = useDriveStore() - return zustand.useStore(store, (state) => state.expandedDirectoryIds) + return zustand.useStore(store, (state) => state.expandedDirectories) } /** A function to set the expanded directoyIds in the Asset Table. */ -export function useSetExpandedDirectoryIds() { +export function useSetExpandedDirectories() { const store = useDriveStore() - return zustand.useStore(store, (state) => state.setExpandedDirectoryIds, { + return zustand.useStore(store, (state) => state.setExpandedDirectories, { unsafeEnableTransition: true, }) } @@ -259,20 +259,25 @@ export function useSetVisuallySelectedKeys() { /** Toggle whether a specific directory is expanded. */ export function useToggleDirectoryExpansion() { const driveStore = useDriveStore() - const setExpandedDirectoryIds = useSetExpandedDirectoryIds() + const { category } = useCategoriesAPI() + const setExpandedDirectories = useSetExpandedDirectories() - return useEventCallback((directoryId: DirectoryId, override?: boolean) => { - const expandedDirectoryIds = driveStore.getState().expandedDirectoryIds - const isExpanded = expandedDirectoryIds.includes(directoryId) + return useEventCallback((id: DirectoryId, override?: boolean) => { + const expandedDirectories = driveStore.getState().expandedDirectories + const isExpanded = expandedDirectories[category.rootPath]?.includes(id) ?? false const shouldExpand = override ?? !isExpanded if (shouldExpand !== isExpanded) { React.startTransition(() => { - if (shouldExpand) { - setExpandedDirectoryIds([...expandedDirectoryIds, directoryId]) - } else { - setExpandedDirectoryIds(expandedDirectoryIds.filter((id) => id !== directoryId)) - } + const expandedDirectoriesForCurrentCategory = expandedDirectories[category.rootPath] ?? [] + const newExpandedDirectoriesForCurrentCategory = + shouldExpand ? + [...expandedDirectoriesForCurrentCategory, id] + : expandedDirectoriesForCurrentCategory.filter((directoryId) => directoryId !== id) + setExpandedDirectories({ + ...expandedDirectories, + [category.rootPath]: newExpandedDirectoriesForCurrentCategory, + }) }) } }) diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index 10ca1ba80636..3321732136c8 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -150,6 +150,7 @@ export default class LocalBackend extends Backend { ): Promise { const parentIdRaw = query.parentId == null ? null : extractTypeAndId(query.parentId).id const parentId = query.parentId ?? newDirectoryId(this.projectManager.rootDirectory) + const parentsPath = extractTypeAndId(parentId).id // Catch the case where the directory does not exist. let result: backend.AnyAsset[] = [] @@ -170,8 +171,8 @@ export default class LocalBackend extends Backend { extension: null, labels: [], description: null, - parentsPath: '', - virtualParentsPath: '', + parentsPath, + virtualParentsPath: parentsPath, } satisfies backend.DirectoryAsset } case projectManager.FileSystemEntryType.ProjectEntry: { @@ -191,8 +192,8 @@ export default class LocalBackend extends Backend { extension: null, labels: [], description: null, - parentsPath: '', - virtualParentsPath: '', + parentsPath, + virtualParentsPath: parentsPath, } satisfies backend.ProjectAsset } case projectManager.FileSystemEntryType.FileEntry: { @@ -207,8 +208,8 @@ export default class LocalBackend extends Backend { extension: fileExtension(entry.path), labels: [], description: null, - parentsPath: '', - virtualParentsPath: '', + parentsPath, + virtualParentsPath: parentsPath, } satisfies backend.FileAsset } } From 2cee8ea8368633b2ae0b093f24c5a7fdbc36f762 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 2 Jan 2025 14:26:30 +1000 Subject: [PATCH 07/31] Fix errors --- .../dashboard/components/dashboard/DirectoryNameColumn.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index b197f53c31ab..54798ef4831b 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -38,12 +38,13 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps { */ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const { item, depth, selected, state, rowState, setRowState, isEditable } = props - const { backend, nodeMap } = state + const { backend, nodeMap, category } = state const { getText } = textProvider.useText() const driveStore = useDriveStore() const toggleDirectoryExpansion = useToggleDirectoryExpansion() - const isExpanded = useStore(driveStore, (storeState) => - storeState.expandedDirectoryIds.includes(item.id), + const isExpanded = useStore( + driveStore, + (storeState) => storeState.expandedDirectories[category.rootPath]?.includes(item.id) ?? false, ) const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory')) From 364c3af1bbcb6abe9efad127f085fcac9b662dea Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 3 Jan 2025 18:45:58 +1000 Subject: [PATCH 08/31] Fix errors --- .../dashboard/column/PathColumn.tsx | 27 +++++++++++++------ .../layouts/Drive/directoryIdsHooks.tsx | 4 +-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index fd3569ac829e..b143222b3b88 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -7,9 +7,14 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useCategoriesAPI, useCloudCategoryList } from '#/layouts/Drive/Categories/categoriesHooks' import type { AnyCloudCategory } from '#/layouts/Drive/Categories/Category' import { useUser } from '#/providers/AuthProvider' -import { useSetExpandedDirectories, useSetSelectedKeys } from '#/providers/DriveProvider' +import { + useDriveStore, + useSetExpandedDirectories, + useSetSelectedKeys, +} from '#/providers/DriveProvider' import type { DirectoryId } from '#/services/Backend' import { isDirectoryId } from '#/services/Backend' +import { unsafeMutable } from 'enso-common/src/utilities/data/object' import { Fragment, useTransition } from 'react' import invariant from 'tiny-invariant' import type { AssetColumnProps } from '../column' @@ -20,11 +25,12 @@ export default function PathColumn(props: AssetColumnProps) { const { virtualParentsPath, parentsPath } = item - const { getAssetNodeById } = state + const { getAssetNodeById, category } = state + const driveStore = useDriveStore() const { setCategory } = useCategoriesAPI() const setSelectedKeys = useSetSelectedKeys() - const setExpandedDirectoryIds = useSetExpandedDirectories() + const setExpandedDirectories = useSetExpandedDirectories() // Path navigation exist only for cloud categories. const { getCategoryByDirectoryId } = useCloudCategoryList() @@ -65,11 +71,16 @@ export default function PathColumn(props: AssetColumnProps) { const targetDirectoryNode = getAssetNodeById(targetDirectory) if (targetDirectoryNode == null && rootDirectoryInThePath.categoryId != null) { - // We reassign the variable only to make TypeScript happy here. - const categoryId = rootDirectoryInThePath.categoryId - - setCategory(categoryId) - setExpandedDirectoryIds(pathToDirectory.map(({ id }) => id).concat(targetDirectory)) + setCategory(rootDirectoryInThePath.categoryId) + const expandedDirectories = structuredClone(driveStore.getState().expandedDirectories) + // This is SAFE, as it is a fresh copy that has been deep cloned above. + const directoryList = expandedDirectories[category.rootPath] + if (directoryList) { + unsafeMutable(directoryList).push( + ...pathToDirectory.map(({ id }) => id).concat(targetDirectory), + ) + } + setExpandedDirectories(expandedDirectories) } setSelectedKeys(new Set([targetDirectory])) diff --git a/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx b/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx index 107903443bf5..29baf73c3079 100644 --- a/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx @@ -53,8 +53,8 @@ export function useDirectoryIds(options: UseDirectoryIdsOptions) { const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId]) const expandedDirectoryIds = useMemo( - () => [rootDirectoryId].concat(privateExpandedDirectoryIds), - [privateExpandedDirectoryIds, rootDirectoryId], + () => [rootDirectoryId].concat(privateExpandedDirectoryIds[category.rootPath] ?? []), + [category.rootPath, privateExpandedDirectoryIds, rootDirectoryId], ) return { setExpandedDirectoryIds, rootDirectoryId, rootDirectory, expandedDirectoryIds } as const From 1df6478cf0a02d182773e6eb9840541ee06f459b Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 3 Jan 2025 18:59:37 +1000 Subject: [PATCH 09/31] Fix bugs --- .../dashboard/pages/dashboard/Dashboard.tsx | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 6333438af8b3..305b00ee1892 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -46,7 +46,6 @@ import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' import { listDirectoryQueryOptions, useBackendQuery } from '#/hooks/backendHooks' -import { useSyncRef } from '#/hooks/syncRefHooks' import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import { useDriveStore, useSetExpandedDirectories } from '#/providers/DriveProvider' import { userGroupIdToDirectoryId, userIdToDirectoryId } from '#/services/RemoteBackend' @@ -324,11 +323,10 @@ function OpenedProjectsParentsExpander() { const remoteBackend = backendProvider.useRemoteBackend() const localBackend = backendProvider.useLocalBackend() const categoriesAPI = useCategoriesAPI() - const category = categoriesAPI.category + const { category, cloudCategories, localCategories } = categoriesAPI const driveStore = useDriveStore() const launchedProjects = useLaunchedProjects() const setExpandedDirectories = useSetExpandedDirectories() - const launchedProjectsRef = useSyncRef(launchedProjects) const { user } = authProvider.useFullUserSession() const { data: userGroups } = useBackendQuery(remoteBackend, 'listUserGroups', []) const { data: users } = useBackendQuery(remoteBackend, 'listUsers', []) @@ -339,26 +337,26 @@ function OpenedProjectsParentsExpander() { ) const expandedDirectories = structuredClone(driveStore.getState().expandedDirectories) + for (const otherCategory of [...cloudCategories.categories, ...localCategories.categories]) { + expandedDirectories[otherCategory.rootPath] ??= [] + } if (localBackend) { - const rootPath = 'rootPath' in category ? category.rootPath : localBackend.rootPath() - const localProjects = launchedProjectsRef.current.filter( + const localProjects = launchedProjects.filter( (project) => project.type === backendModule.BackendType.local, ) for (const project of localProjects) { const path = localBackendModule.extractTypeAndId(project.parentId).id - const strippedPath = path.replace(`${rootPath}/`, '') - if (strippedPath !== path) { - let parentPath = String(rootPath) - const parents = strippedPath.split('/') - for (const parent of parents) { - parentPath += `/${parent}` - const currentParentPath = backendModule.Path(parentPath) - const currentParentId = localBackendModule.newDirectoryId(currentParentPath) - for (const [categoryRootPath, directoriesInCategory] of unsafeEntries( - expandedDirectories, - )) { - if (!backendModule.isDescendantPath(currentParentPath, categoryRootPath)) { + for (const [rootPath, directoriesInCategory] of unsafeEntries(expandedDirectories)) { + const strippedPath = path.replace(`${rootPath}/`, '') + if (strippedPath !== path) { + let parentPath = String(rootPath) + const parents = strippedPath.split('/') + for (const parent of parents) { + parentPath += `/${parent}` + const currentParentPath = backendModule.Path(parentPath) + const currentParentId = localBackendModule.newDirectoryId(currentParentPath) + if (!backendModule.isDescendantPath(currentParentPath, rootPath)) { continue } if (directoriesInCategory.includes(currentParentId)) { @@ -373,7 +371,7 @@ function OpenedProjectsParentsExpander() { } } - const cloudProjects = launchedProjectsRef.current.filter( + const cloudProjects = launchedProjects.filter( (project) => project.type === backendModule.BackendType.remote, ) const promises = cloudProjects.map((project) => @@ -385,8 +383,8 @@ function OpenedProjectsParentsExpander() { }), ), ) - const projects = await Promise.allSettled(promises) - const projects2 = projects.flatMap((directoryResult, i) => { + const projectsSiblings = await Promise.allSettled(promises) + const projects = projectsSiblings.flatMap((directoryResult, i) => { const projectInfo = cloudProjects[i] const project = projectInfo && directoryResult.status === 'fulfilled' ? @@ -396,7 +394,7 @@ function OpenedProjectsParentsExpander() { : null return project ? [project] : [] }) - for (const project of projects2) { + for (const project of projects) { const parents = project.parentsPath.split('/').filter(backendModule.isDirectoryId) const rootDirectoryId = parents[0] const baseVirtualPath = (() => { From 21b348932a31ce6e987e611344ec7c68272ba527 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 3 Jan 2025 19:06:05 +1000 Subject: [PATCH 10/31] Remove obsolete test --- .../providers/__test__/DriveProvider.test.tsx | 83 ------------------- 1 file changed, 83 deletions(-) delete mode 100644 app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx diff --git a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx deleted file mode 100644 index f89b4704a117..000000000000 --- a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { DirectoryId } from '#/services/Backend' -import { act, renderHook, type RenderHookOptions, type RenderHookResult } from '#/test' -import { useState } from 'react' -import { describe, expect, it } from 'vitest' -import { useStore } from 'zustand' -import type { CategoryId } from '../../layouts/CategorySwitcher/Category' -import DriveProvider, { useDriveStore } from '../DriveProvider' - -function renderDriveProviderHook( - hook: (props: Props) => Result, - options?: Omit, 'wrapper'>, -): RenderHookResult { - let currentCategoryId: CategoryId = 'cloud' - let setCategoryId: (categoryId: CategoryId) => void - let doResetAssetTableState: () => void - - return renderHook( - (props) => { - const result = hook(props) - return { ...result, setCategoryId } - }, - { - wrapper: ({ children }) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const [category, setCategory] = useState(() => currentCategoryId) - currentCategoryId = category - setCategoryId = (nextCategoryId) => { - setCategory(nextCategoryId) - doResetAssetTableState() - } - - return ( - - {({ resetAssetTableState }) => { - doResetAssetTableState = resetAssetTableState - return children - }} - - ) - }, - ...options, - }, - ) -} - -describe('', () => { - it('Should reset expanded directory ids when category changes', () => { - const driveAPI = renderDriveProviderHook((setCategoryId: (categoryId: string) => void) => { - const store = useDriveStore() - return useStore( - store, - ({ - setExpandedDirectoryIds, - expandedDirectoryIds, - selectedKeys, - visuallySelectedKeys, - }) => ({ - expandedDirectoryIds, - setExpandedDirectoryIds, - setCategoryId, - selectedKeys, - visuallySelectedKeys, - }), - ) - }) - - act(() => { - driveAPI.result.current.setExpandedDirectoryIds([DirectoryId('directory-test-123')]) - }) - - expect(driveAPI.result.current.expandedDirectoryIds).toEqual([ - DirectoryId('directory-test-123'), - ]) - - act(() => { - driveAPI.result.current.setCategoryId('recent') - }) - - expect(driveAPI.result.current.expandedDirectoryIds).toEqual([]) - expect(driveAPI.result.current.selectedKeys).toEqual(new Set()) - expect(driveAPI.result.current.visuallySelectedKeys).toEqual(null) - }) -}) From 6adda386e59292284148add2025362defce8c878 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 6 Jan 2025 22:31:33 +1000 Subject: [PATCH 11/31] Replace `category` with `categoryType` in `listDirectoryQueryOptions` --- app/gui/src/dashboard/hooks/backendHooks.tsx | 44 +++++++++---------- app/gui/src/dashboard/layouts/Drive.tsx | 2 +- .../layouts/Drive/assetTreeHooks.tsx | 4 +- .../dashboard/pages/dashboard/Dashboard.tsx | 2 +- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx index 19679729b066..7b59d738cb24 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendHooks.tsx @@ -314,12 +314,12 @@ export function useListUserGroupsWithUsers( export interface ListDirectoryQueryOptions { readonly backend: Backend readonly parentId: DirectoryId - readonly category: Category + readonly categoryType: Category['type'] } /** Build a query options object to fetch the children of a directory. */ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { - const { backend, parentId, category } = options + const { backend, parentId, categoryType } = options return queryOptions({ queryKey: [ @@ -328,8 +328,8 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { parentId, { labels: null, - filterBy: CATEGORY_TO_FILTER_BY[category.type], - recentProjects: category.type === 'recent', + filterBy: CATEGORY_TO_FILTER_BY[categoryType], + recentProjects: categoryType === 'recent', }, ] as const, // Setting stale time to Infinity to attaching a ton of @@ -341,9 +341,9 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { return await backend.listDirectory( { parentId, - filterBy: CATEGORY_TO_FILTER_BY[category.type], + filterBy: CATEGORY_TO_FILTER_BY[categoryType], labels: null, - recentProjects: category.type === 'recent', + recentProjects: categoryType === 'recent', }, parentId, ) @@ -366,7 +366,7 @@ export function useAssetPassiveListener( backendType: BackendType, assetId: AssetId | null | undefined, parentId: DirectoryId | null | undefined, - category: Category, + categoryType: Category['type'], ) { const { data: asset } = useQuery({ queryKey: [ @@ -375,8 +375,8 @@ export function useAssetPassiveListener( parentId, { labels: null, - filterBy: CATEGORY_TO_FILTER_BY[category.type], - recentProjects: category.type === 'recent', + filterBy: CATEGORY_TO_FILTER_BY[categoryType], + recentProjects: categoryType === 'recent', }, ], initialData: undefined, @@ -448,9 +448,9 @@ export function useAssetPassiveListenerStrict( backendType: BackendType, assetId: AssetId | null | undefined, parentId: DirectoryId | null | undefined, - category: Category, + categoryType: Category['type'], ) { - const asset = useAssetPassiveListener(backendType, assetId, parentId, category) + const asset = useAssetPassiveListener(backendType, assetId, parentId, categoryType) invariant(asset, 'Asset not found') return asset } @@ -588,7 +588,7 @@ export function useRootDirectoryId(backend: Backend, category: Category) { } /** Return query data for the children of a directory, fetching it if it does not exist. */ -function useEnsureListDirectory(backend: Backend, category: Category) { +function useEnsureListDirectory(backend: Backend, categoryType: Category['type']) { const queryClient = useQueryClient() return useEventCallback(async (parentId: DirectoryId) => { return await queryClient.ensureQueryData( @@ -596,8 +596,8 @@ function useEnsureListDirectory(backend: Backend, category: Category) { { parentId, labels: null, - filterBy: CATEGORY_TO_FILTER_BY[category.type], - recentProjects: category.type === 'recent', + filterBy: CATEGORY_TO_FILTER_BY[categoryType], + recentProjects: categoryType === 'recent', }, '(unknown)', ]), @@ -609,9 +609,9 @@ function useEnsureListDirectory(backend: Backend, category: Category) { * Remove an asset from the React Query cache. Should only be called on * optimistically inserted assets. */ -function useDeleteAsset(backend: Backend, category: Category) { +function useDeleteAsset(backend: Backend, categoryType: Category['type']) { const queryClient = useQueryClient() - const ensureListDirectory = useEnsureListDirectory(backend, category) + const ensureListDirectory = useEnsureListDirectory(backend, categoryType) return useEventCallback(async (assetId: AssetId, parentId: DirectoryId) => { const siblings = await ensureListDirectory(parentId) @@ -625,8 +625,8 @@ function useDeleteAsset(backend: Backend, category: Category) { parentId, { labels: null, - filterBy: CATEGORY_TO_FILTER_BY[category.type], - recentProjects: category.type === 'recent', + filterBy: CATEGORY_TO_FILTER_BY[categoryType], + recentProjects: categoryType === 'recent', }, ], }) @@ -641,7 +641,7 @@ function useDeleteAsset(backend: Backend, category: Category) { /** A function to create a new folder. */ export function useNewFolder(backend: Backend, category: Category) { - const ensureListDirectory = useEnsureListDirectory(backend, category) + const ensureListDirectory = useEnsureListDirectory(backend, category.type) const toggleDirectoryExpansion = useToggleDirectoryExpansion() const setNewestFolderId = useSetNewestFolderId() const setSelectedKeys = useSetSelectedKeys() @@ -684,10 +684,10 @@ export function useNewFolder(backend: Backend, category: Category) { /** A function to create a new project. */ export function useNewProject(backend: Backend, category: Category) { - const ensureListDirectory = useEnsureListDirectory(backend, category) + const ensureListDirectory = useEnsureListDirectory(backend, category.type) const toastAndLog = useToastAndLog() const doOpenProject = useOpenProject() - const deleteAsset = useDeleteAsset(backend, category) + const deleteAsset = useDeleteAsset(backend, category.type) const toggleDirectoryExpansion = useToggleDirectoryExpansion() const { user } = useFullUserSession() @@ -847,7 +847,7 @@ export function useNewDatalink(backend: Backend, category: Category) { /** A function to upload files. */ export function useUploadFiles(backend: Backend, category: Category) { - const ensureListDirectory = useEnsureListDirectory(backend, category) + const ensureListDirectory = useEnsureListDirectory(backend, category.type) const toastAndLog = useToastAndLog() const toggleDirectoryExpansion = useToggleDirectoryExpansion() const { setModal } = useSetModal() diff --git a/app/gui/src/dashboard/layouts/Drive.tsx b/app/gui/src/dashboard/layouts/Drive.tsx index 5e794ed97d7f..95db8909117a 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -199,7 +199,7 @@ function DriveAssetsView(props: DriveAssetsViewProps) { const queryClient = useQueryClient() const rootDirectoryQuery = listDirectoryQueryOptions({ backend, - category, + categoryType: category.type, parentId: rootDirectoryId, }) diff --git a/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx b/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx index 80730b1ff068..defff7faa745 100644 --- a/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx @@ -53,7 +53,7 @@ export function useAssetTree(options: UseAssetTreeOptions) { ...listDirectoryQueryOptions({ backend, parentId: directoryId, - category, + categoryType: category.type, }), enabled: !hidden, })), @@ -129,7 +129,7 @@ export function useAssetTree(options: UseAssetTreeOptions) { queryKey: listDirectoryQueryOptions({ backend, parentId: directoryId, - category, + categoryType: category.type, }).queryKey, type: 'active', }) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 305b00ee1892..edfe232b8356 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -379,7 +379,7 @@ function OpenedProjectsParentsExpander() { listDirectoryQueryOptions({ backend: remoteBackend, parentId: project.parentId, - category, + categoryType: category.type, }), ), ) From eca79311f5021b4e5014070d9e305f4374035a94 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 7 Jan 2025 18:01:38 +1000 Subject: [PATCH 12/31] Move logic for computing ancestors from `Dashboard.tsx` to individual backends --- app/common/src/services/Backend.ts | 31 ++-- app/common/src/utilities/data/object.ts | 2 +- .../layouts/Drive/Categories/Category.ts | 9 +- .../dashboard/pages/dashboard/Dashboard.tsx | 132 ++++-------------- .../src/dashboard/providers/DriveProvider.tsx | 32 +++-- .../src/dashboard/services/LocalBackend.ts | 58 ++++++++ .../src/dashboard/services/RemoteBackend.ts | 51 ++++++- 7 files changed, 179 insertions(+), 136 deletions(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 042a022e5c19..b2472f8e981d 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -40,11 +40,14 @@ export function isUserGroupId(id: string): id is UserGroupId { /** 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}. */ +/** Whether a given {@link string} is a {@link DirectoryId}. */ export function isDirectoryId(id: string): id is DirectoryId { return id.startsWith('directory-') } +/** Unique identifier for a category. */ +export type CategoryId = 'cloud' | 'local' | 'recent' | 'trash' | DirectoryId | UserGroupId | UserId + /** * Unique identifier for an asset representing the items inside a directory for which the * request to retrive the items has not yet completed. @@ -66,6 +69,10 @@ export const ErrorAssetId = newtype.newtypeConstructor() /** Unique identifier for a user's project. */ export type ProjectId = newtype.Newtype export const ProjectId = newtype.newtypeConstructor() +/** Whether a given {@link string} is a {@link ProjectId}. */ +export function isProjectId(id: string): id is ProjectId { + return id.startsWith('project-') +} /** Unique identifier for an uploaded file. */ export type FileId = newtype.Newtype @@ -134,11 +141,6 @@ export type UserPermissionIdentifier = UserGroupId | UserId export type Path = newtype.Newtype export const Path = newtype.newtypeConstructor() -/** Whether the given path is a descendant of another path. */ -export function isDescendantPath(path: Path, possibleAncestor: Path) { - return path.startsWith(`${possibleAncestor}/`) -} - const PLACEHOLDER_USER_GROUP_PREFIX = 'usergroup-placeholder-' /** @@ -306,8 +308,10 @@ export interface UpdatedProject extends BaseProject { /** A user/organization's project containing and/or currently executing code. */ export interface ProjectRaw extends ListedProjectRaw { - readonly ide_version: VersionNumber | null - readonly engine_version: VersionNumber | null + readonly ideVersion: VersionNumber | null + readonly engineVersion: VersionNumber | null + readonly parentsPath: string + readonly virtualParentsPath: string } /** A user/organization's project containing and/or currently executing code. */ @@ -317,6 +321,8 @@ export interface Project extends ListedProject { readonly openedBy?: EmailAddress /** On the Remote (Cloud) Backend, this is a S3 url that is valid for only 120 seconds. */ readonly url?: HttpsUrl + readonly parentsPath: string + readonly virtualParentsPath: string } /** A user/organization's project containing and/or currently executing code. */ @@ -1774,6 +1780,15 @@ export default abstract class Backend { ): Promise /** Download from an arbitrary URL that is assumed to originate from this backend. */ abstract download(url: string, name?: string): Promise + /** + * The list of the asset's ancestors, if and only if the asset is in the given category. + * Note: The `null` in the return type exists to prevent accidentally implicitly returning + * `undefined`. + */ + abstract tryGetAssetAncestors( + asset: Pick, + category: CategoryId, + ): Promise /** * Get the URL for the customer portal. diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index 963cafed7b4e..2b0ce416890d 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -116,7 +116,7 @@ export function unsafeRemoveUndefined( export function mapEntries( object: Record, map: (key: K, value: V) => W, -): Readonly> { +): Record { // @ts-expect-error It is known that the set of keys is the same for the input and the output, // because the output is dynamically generated based on the input. return Object.fromEntries( diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts index 708d81ef1ae9..2eacd4715c39 100644 --- a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts +++ b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts @@ -25,11 +25,7 @@ import { newDirectoryId } from '#/services/LocalBackend' const PATH_SCHEMA = z.string().refine((s): s is Path => true) const DIRECTORY_ID_SCHEMA = z.string().refine((s): s is DirectoryId => true) -const EACH_CATEGORY_SCHEMA = z.object({ - label: z.string(), - icon: z.string(), - rootPath: PATH_SCHEMA, -}) +const EACH_CATEGORY_SCHEMA = z.object({ label: z.string(), icon: z.string() }) /** A category corresponding to the root of the user or organization. */ const CLOUD_CATEGORY_SCHEMA = z @@ -65,6 +61,7 @@ export const USER_CATEGORY_SCHEMA = z type: z.literal('user'), user: z.custom(() => true), id: z.custom(() => true), + rootPath: PATH_SCHEMA, homeDirectoryId: DIRECTORY_ID_SCHEMA, }) .merge(EACH_CATEGORY_SCHEMA) @@ -77,6 +74,7 @@ export const TEAM_CATEGORY_SCHEMA = z type: z.literal('team'), id: z.custom(() => true), team: z.custom(() => true), + rootPath: PATH_SCHEMA, homeDirectoryId: DIRECTORY_ID_SCHEMA, }) .merge(EACH_CATEGORY_SCHEMA) @@ -98,6 +96,7 @@ export const LOCAL_DIRECTORY_CATEGORY_SCHEMA = z .object({ type: z.literal('local-directory'), id: z.custom(() => true), + rootPath: PATH_SCHEMA, homeDirectoryId: DIRECTORY_ID_SCHEMA, }) .merge(EACH_CATEGORY_SCHEMA) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index edfe232b8356..dd885e2488df 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -45,19 +45,14 @@ import * as backendModule from '#/services/Backend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' -import { listDirectoryQueryOptions, useBackendQuery } from '#/hooks/backendHooks' import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import { useDriveStore, useSetExpandedDirectories } from '#/providers/DriveProvider' -import { userGroupIdToDirectoryId, userIdToDirectoryId } from '#/services/RemoteBackend' -import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' -import { usePrefetchQuery, useQueryClient } from '@tanstack/react-query' -import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' -import { unsafeEntries, unsafeMutable } from 'enso-common/src/utilities/data/object' -import invariant from 'tiny-invariant' +import { usePrefetchQuery } from '@tanstack/react-query' +import { mapEntries } from 'enso-common/src/utilities/data/object' import { DashboardTabPanels } from './DashboardTabPanels' // ================= @@ -319,121 +314,42 @@ function DashboardInner(props: DashboardProps) { /** Expand the list of parents for opened projects. */ function OpenedProjectsParentsExpander() { - const queryClient = useQueryClient() const remoteBackend = backendProvider.useRemoteBackend() const localBackend = backendProvider.useLocalBackend() const categoriesAPI = useCategoriesAPI() - const { category, cloudCategories, localCategories } = categoriesAPI + const { cloudCategories } = categoriesAPI const driveStore = useDriveStore() const launchedProjects = useLaunchedProjects() const setExpandedDirectories = useSetExpandedDirectories() - const { user } = authProvider.useFullUserSession() - const { data: userGroups } = useBackendQuery(remoteBackend, 'listUserGroups', []) - const { data: users } = useBackendQuery(remoteBackend, 'listUsers', []) const updateOpenedProjects = eventCallbacks.useEventCallback(async () => { - const userGroupDirectoryIds = new Set( - (user.userGroups ?? EMPTY_ARRAY).map(userGroupIdToDirectoryId), - ) - - const expandedDirectories = structuredClone(driveStore.getState().expandedDirectories) - for (const otherCategory of [...cloudCategories.categories, ...localCategories.categories]) { - expandedDirectories[otherCategory.rootPath] ??= [] - } - - if (localBackend) { - const localProjects = launchedProjects.filter( - (project) => project.type === backendModule.BackendType.local, - ) - for (const project of localProjects) { - const path = localBackendModule.extractTypeAndId(project.parentId).id - for (const [rootPath, directoriesInCategory] of unsafeEntries(expandedDirectories)) { - const strippedPath = path.replace(`${rootPath}/`, '') - if (strippedPath !== path) { - let parentPath = String(rootPath) - const parents = strippedPath.split('/') - for (const parent of parents) { - parentPath += `/${parent}` - const currentParentPath = backendModule.Path(parentPath) - const currentParentId = localBackendModule.newDirectoryId(currentParentPath) - if (!backendModule.isDescendantPath(currentParentPath, rootPath)) { - continue - } - if (directoriesInCategory.includes(currentParentId)) { - continue - } - const id = localBackendModule.newDirectoryId(currentParentPath) - // This is SAFE as the value has been `structuredClone`d above. - unsafeMutable(directoriesInCategory).push(id) - } - } - } - } - } - - const cloudProjects = launchedProjects.filter( - (project) => project.type === backendModule.BackendType.remote, - ) - const promises = cloudProjects.map((project) => - queryClient.ensureQueryData( - listDirectoryQueryOptions({ - backend: remoteBackend, - parentId: project.parentId, - categoryType: category.type, - }), - ), + const expandedDirectories = mapEntries( + driveStore.getState().expandedDirectories, + (_k, v) => new Set(v), ) - const projectsSiblings = await Promise.allSettled(promises) - const projects = projectsSiblings.flatMap((directoryResult, i) => { - const projectInfo = cloudProjects[i] - const project = - projectInfo && directoryResult.status === 'fulfilled' ? - directoryResult.value - .filter(backendModule.assetIsProject) - .find((asset) => asset.id === projectInfo.id) - : null - return project ? [project] : [] - }) - for (const project of projects) { - const parents = project.parentsPath.split('/').filter(backendModule.isDirectoryId) - const rootDirectoryId = parents[0] - const baseVirtualPath = (() => { - const userGroupName = userGroups?.find( - (userGroup) => userGroupIdToDirectoryId(userGroup.id) === rootDirectoryId, - )?.groupName - if (userGroupName != null) { - return `enso://Teams/${userGroupName}` - } - const userName = users?.find( - (otherUser) => userIdToDirectoryId(otherUser.userId) === rootDirectoryId, - )?.name - if (userName != null) { - return `enso://Users/${userGroupName}` - } - })() - const virtualPath = backendModule.Path(`${baseVirtualPath}/${project.virtualParentsPath}`) - invariant( - baseVirtualPath != null, - 'The root directory must be either a user directory or a team directory.', - ) - for (const [categoryRootPath, directoriesInCategoryRaw] of unsafeEntries( - expandedDirectories, - )) { - const directoriesInCategory = unsafeMutable(directoriesInCategoryRaw) - if (!backendModule.isDescendantPath(virtualPath, categoryRootPath)) { + const promises: Promise[] = [] + for (const cloudCategory of cloudCategories.categories) { + const set = (expandedDirectories[cloudCategory.id] ??= new Set()) + for (const project of launchedProjects) { + const backend = + project.type === backendModule.BackendType.remote ? remoteBackend : localBackend + if (!backend) { continue } - if ((rootDirectoryId && userGroupDirectoryIds.has(rootDirectoryId)) ?? false) { - directoriesInCategory.push(TEAMS_DIRECTORY_ID) - } else { - directoriesInCategory.push(USERS_DIRECTORY_ID) - } - for (const parent of parents) { - directoriesInCategory.push(parent) - } + promises.push( + backend.tryGetAssetAncestors(project, cloudCategory.id).then((ancestors) => { + if (!ancestors) { + return + } + for (const ancestor of ancestors) { + set.add(ancestor) + } + }), + ) } } + await Promise.all(promises) setExpandedDirectories(expandedDirectories) }) diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index 9ea9acd4656e..e16e123a6f51 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -5,7 +5,7 @@ import * as zustand from '#/utilities/zustand' import invariant from 'tiny-invariant' import { useEventCallback } from '#/hooks/eventCallbackHooks' -import type { Category } from '#/layouts/CategorySwitcher/Category' +import type { Category, CategoryId } from '#/layouts/CategorySwitcher/Category' import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' @@ -15,7 +15,6 @@ import type { BackendType, DirectoryAsset, DirectoryId, - Path, } from 'enso-common/src/services/Backend' // ================== @@ -42,8 +41,10 @@ interface DriveStore { readonly setCanDownload: (canDownload: boolean) => void readonly pasteData: PasteData | null readonly setPasteData: (pasteData: PasteData | null) => void - readonly expandedDirectories: Record - readonly setExpandedDirectories: (selectedKeys: Record) => void + readonly expandedDirectories: Record> + readonly setExpandedDirectories: ( + selectedKeys: Record>, + ) => void readonly selectedKeys: ReadonlySet readonly setSelectedKeys: (selectedKeys: ReadonlySet) => void readonly visuallySelectedKeys: ReadonlySet | null @@ -119,7 +120,12 @@ export default function DriveProvider(props: ProjectsProviderProps) { set({ pasteData }) } }, - expandedDirectories: {}, + expandedDirectories: { + cloud: new Set(), + local: new Set(), + recent: new Set(), + trash: new Set(), + }, setExpandedDirectories: (expandedDirectories) => { if (get().expandedDirectories !== expandedDirectories) { set({ expandedDirectories }) @@ -264,19 +270,21 @@ export function useToggleDirectoryExpansion() { return useEventCallback((id: DirectoryId, override?: boolean) => { const expandedDirectories = driveStore.getState().expandedDirectories - const isExpanded = expandedDirectories[category.rootPath]?.includes(id) ?? false + const isExpanded = expandedDirectories[category.id]?.has(id) ?? false const shouldExpand = override ?? !isExpanded if (shouldExpand !== isExpanded) { React.startTransition(() => { - const expandedDirectoriesForCurrentCategory = expandedDirectories[category.rootPath] ?? [] - const newExpandedDirectoriesForCurrentCategory = - shouldExpand ? - [...expandedDirectoriesForCurrentCategory, id] - : expandedDirectoriesForCurrentCategory.filter((directoryId) => directoryId !== id) + const directories = expandedDirectories[category.id] ?? [] + const newDirectories = new Set(directories) + if (shouldExpand) { + newDirectories.add(id) + } else { + newDirectories.delete(id) + } setExpandedDirectories({ ...expandedDirectories, - [category.rootPath]: newExpandedDirectoriesForCurrentCategory, + [category.id]: newDirectories, }) }) } diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index 3321732136c8..3b1504c2e2e8 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -91,6 +91,11 @@ export function extractTypeAndId(id: Id): AssetTypeA } } +/** Whether the given path is a descendant of another path. */ +export function isDescendantPath(path: backend.Path, possibleAncestor: backend.Path) { + return path.startsWith(`${possibleAncestor}/`) +} + // ==================== // === LocalBackend === // ==================== @@ -316,6 +321,7 @@ export default class LocalBackend extends Backend { const state = this.projectManager.projects.get(id) if (state == null) { const directoryId = directory == null ? null : extractTypeAndId(directory).id + const parentsPath = directoryId ?? this.rootPath() const entries = await this.projectManager.listDirectory(directoryId) const project = entries .flatMap((entry) => @@ -336,6 +342,8 @@ export default class LocalBackend extends Backend { name: project.name, engineVersion: version, ideVersion: version, + parentsPath, + virtualParentsPath: parentsPath, jsonAddress: null, binaryAddress: null, ydocAddress: null, @@ -347,6 +355,8 @@ export default class LocalBackend extends Backend { } } else { const cachedProject = await state.data + const directoryId = directory == null ? null : extractTypeAndId(directory).id + const parentsPath = directoryId ?? this.rootPath() return { name: cachedProject.projectName, engineVersion: { @@ -357,6 +367,8 @@ export default class LocalBackend extends Backend { lifecycle: backend.detectVersionLifecycle(cachedProject.engineVersion), value: cachedProject.engineVersion, }, + parentsPath, + virtualParentsPath: parentsPath, jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress), binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress), ydocAddress: null, @@ -754,6 +766,52 @@ export default class LocalBackend extends Backend { return Promise.resolve() } + /** The list of the asset's ancestors, if and only if the asset is in the given category. */ + override async tryGetAssetAncestors( + asset: Pick, + category: backend.CategoryId, + ): Promise { + await Promise.resolve() + switch (category) { + // This is a category for the Remote backend. + case 'cloud': + case 'recent': + case 'trash': { + return null + } + // For now, this function is used to determine whether an opened project's ancestors + // should be expanded. This is not required in + case 'local': + default: { + if (category === 'local') { + category = newDirectoryId(this.rootPath()) + } + if (backend.isDirectoryId(category)) { + const path = extractTypeAndId(asset.parentId).id + const strippedPath = path.replace(`${category}/`, '') + if (strippedPath === path) { + return null + } + let parentPath = String(category) + const parents = strippedPath.split('/') + const parentIds: backend.DirectoryId[] = [] + for (const parent of parents) { + parentPath += `/${parent}` + const currentParentPath = backend.Path(parentPath) + if (!isDescendantPath(currentParentPath, path)) { + continue + } + parentIds.push(newDirectoryId(currentParentPath)) + } + return parentIds + } else { + // This is a category for the Remote backend. + return null + } + } + } + } + /** Invalid operation. */ override restoreProject() { return this.invalidOperation() diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/gui/src/dashboard/services/RemoteBackend.ts index e63866ea8584..a7cf8f989854 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/gui/src/dashboard/services/RemoteBackend.ts @@ -851,8 +851,6 @@ export default class RemoteBackend extends Backend { const project = await response.json() return { ...project, - ideVersion: project.ide_version, - engineVersion: project.engine_version, jsonAddress: project.address != null ? backend.Address(`${project.address}json`) : null, binaryAddress: project.address != null ? backend.Address(`${project.address}binary`) : null, ydocAddress: project.address != null ? backend.Address(`${project.address}project`) : null, @@ -1338,6 +1336,55 @@ export default class RemoteBackend extends Backend { return Promise.resolve() } + /** The list of the asset's ancestors, if and only if the asset is in the given category. */ + override async tryGetAssetAncestors( + asset: Pick, + category: backend.CategoryId, + ): Promise { + const getAncestors = async () => { + if (!backend.isProjectId(asset.id)) { + return + } + const details = await this.getProjectDetails(asset.id, null) + return details.parentsPath.split('/').filter(backend.isDirectoryId) + } + switch (category) { + case 'cloud': { + return (await getAncestors()) ?? null + } + case 'recent': + case 'trash': { + // For now, this function is used to determine whether an opened project's ancestors + // should be expanded. This is not required in + return null + } + case 'local': { + // This is a category for the Local backend. + return null + } + default: { + if (isUserId(category)) { + const directoryId = userIdToDirectoryId(category) + const ancestors = await getAncestors() + if (ancestors?.[0]?.startsWith(`${directoryId}/`) !== true) { + return null + } + return ancestors.slice(1) + } else if (backend.isUserGroupId(category)) { + const directoryId = userGroupIdToDirectoryId(category) + const ancestors = await getAncestors() + if (ancestors?.[0]?.startsWith(`${directoryId}/`) !== true) { + return null + } + return ancestors.slice(1) + } else { + // This is a category for the Local backend. + return null + } + } + } + } + /** Fetch the URL of the customer portal. */ override async createCustomerPortalSession() { const response = await this.post( From 0fa64ce9b776857e8ef2b9c795275a533212cde5 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 7 Jan 2025 19:57:41 +1000 Subject: [PATCH 13/31] Extract computing initial expanded directories to a function --- .../Drive/Categories/categoriesHooks.tsx | 13 +-- .../dashboard/pages/dashboard/Dashboard.tsx | 83 ++++++++++++------- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx b/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx index 91f02c8cd71d..7668e1e3ed84 100644 --- a/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx @@ -92,7 +92,6 @@ export function useCloudCategoryList() { id: 'cloud', label: getText('cloudCategory'), icon: CloudIcon, - rootPath: Path(`enso://${hasUserAndTeamSpaces ? `Users/${user.name}` : ''}`), homeDirectoryId, } @@ -100,7 +99,6 @@ export function useCloudCategoryList() { type: 'recent', id: 'recent', label: getText('recentCategory'), - rootPath: Path(`(Recent)`), icon: RecentIcon, } @@ -108,7 +106,6 @@ export function useCloudCategoryList() { type: 'trash', id: 'trash', label: getText('trashCategory'), - rootPath: Path(`(Trash)`), icon: Trash2Icon, } @@ -222,10 +219,6 @@ export function useLocalCategoryList() { type: 'local', id: 'local', label: getText('localCategory'), - /** The root path of this category. */ - get rootPath() { - return localBackend?.rootPath() ?? Path('') - }, icon: ComputerIcon, } @@ -318,10 +311,8 @@ export function useCategories() { return { cloudCategories, localCategories, findCategoryById } } -/** - * Context value for the categories. - */ -interface CategoriesContextValue { +/** Context value for the categories. */ +export interface CategoriesContextValue { readonly cloudCategories: CloudCategoryResult readonly localCategories: LocalCategoryResult readonly category: Category diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index dd885e2488df..cd06ebdc8c28 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -10,7 +10,10 @@ import { DashboardTabBar } from './DashboardTabBar' import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as projectHooks from '#/hooks/projectHooks' -import { CategoriesProvider } from '#/layouts/Drive/Categories/categoriesHooks' +import { + CategoriesProvider, + type CategoriesContextValue, +} from '#/layouts/Drive/Categories/categoriesHooks' import DriveProvider from '#/providers/DriveProvider' import * as authProvider from '#/providers/AuthProvider' @@ -42,17 +45,18 @@ import Page from '#/components/Page' import ManagePermissionsModal from '#/modals/ManagePermissionsModal' import * as backendModule from '#/services/Backend' +import type LocalBackend from '#/services/LocalBackend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' -import { useDriveStore, useSetExpandedDirectories } from '#/providers/DriveProvider' +import { useSetExpandedDirectories } from '#/providers/DriveProvider' +import type RemoteBackend from '#/services/RemoteBackend' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' import { usePrefetchQuery } from '@tanstack/react-query' -import { mapEntries } from 'enso-common/src/utilities/data/object' import { DashboardTabPanels } from './DashboardTabPanels' // ================= @@ -312,44 +316,61 @@ function DashboardInner(props: DashboardProps) { ) } +/** Compute the initial set of expanded directories. */ +async function computeInitialExpandedDirectories( + launchedProjects: readonly LaunchedProject[], + categoriesAPI: CategoriesContextValue, + remoteBackend: RemoteBackend, + localBackend: LocalBackend | null, +) { + const { cloudCategories, localCategories } = categoriesAPI + const expandedDirectories: Record> = { + cloud: new Set(), + recent: new Set(), + trash: new Set(), + local: new Set(), + } + const promises: Promise[] = [] + for (const category of [...cloudCategories.categories, ...localCategories.categories]) { + const set = (expandedDirectories[category.id] ??= new Set()) + for (const project of launchedProjects) { + const backend = + project.type === backendModule.BackendType.remote ? remoteBackend : localBackend + if (!backend) { + continue + } + promises.push( + backend.tryGetAssetAncestors(project, category.id).then((ancestors) => { + if (!ancestors) { + return + } + for (const ancestor of ancestors) { + set.add(ancestor) + } + }), + ) + } + } + + await Promise.all(promises) + return expandedDirectories +} + /** Expand the list of parents for opened projects. */ function OpenedProjectsParentsExpander() { const remoteBackend = backendProvider.useRemoteBackend() const localBackend = backendProvider.useLocalBackend() const categoriesAPI = useCategoriesAPI() - const { cloudCategories } = categoriesAPI - const driveStore = useDriveStore() const launchedProjects = useLaunchedProjects() const setExpandedDirectories = useSetExpandedDirectories() const updateOpenedProjects = eventCallbacks.useEventCallback(async () => { - const expandedDirectories = mapEntries( - driveStore.getState().expandedDirectories, - (_k, v) => new Set(v), + const expandedDirectories = await computeInitialExpandedDirectories( + launchedProjects, + categoriesAPI, + remoteBackend, + localBackend, ) - const promises: Promise[] = [] - for (const cloudCategory of cloudCategories.categories) { - const set = (expandedDirectories[cloudCategory.id] ??= new Set()) - for (const project of launchedProjects) { - const backend = - project.type === backendModule.BackendType.remote ? remoteBackend : localBackend - if (!backend) { - continue - } - promises.push( - backend.tryGetAssetAncestors(project, cloudCategory.id).then((ancestors) => { - if (!ancestors) { - return - } - for (const ancestor of ancestors) { - set.add(ancestor) - } - }), - ) - } - } - - await Promise.all(promises) setExpandedDirectories(expandedDirectories) }) From 17eb1dfdc059c2861c157be7c02a68f911e06636 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 7 Jan 2025 21:34:36 +1000 Subject: [PATCH 14/31] Compute initial expanded directories in initial value store instead of in a `useEffect` --- .../dashboard/pages/dashboard/Dashboard.tsx | 88 +++---------------- .../src/dashboard/providers/DriveProvider.tsx | 83 +++++++++++++---- 2 files changed, 82 insertions(+), 89 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index cd06ebdc8c28..9ea32dfda42f 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -10,10 +10,7 @@ import { DashboardTabBar } from './DashboardTabBar' import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as projectHooks from '#/hooks/projectHooks' -import { - CategoriesProvider, - type CategoriesContextValue, -} from '#/layouts/Drive/Categories/categoriesHooks' +import { CategoriesProvider } from '#/layouts/Drive/Categories/categoriesHooks' import DriveProvider from '#/providers/DriveProvider' import * as authProvider from '#/providers/AuthProvider' @@ -45,13 +42,10 @@ import Page from '#/components/Page' import ManagePermissionsModal from '#/modals/ManagePermissionsModal' import * as backendModule from '#/services/Backend' -import type LocalBackend from '#/services/LocalBackend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' -import { useSetExpandedDirectories } from '#/providers/DriveProvider' -import type RemoteBackend from '#/services/RemoteBackend' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' @@ -74,15 +68,26 @@ export interface DashboardProps { /** The component that contains the entire UI. */ export default function Dashboard(props: DashboardProps) { + const projectsStore = useProjectsStore() + // MUST NOT be reactive as it should not cause the entire dashboard to re-render. + const launchedProjects = projectsStore.getState().launchedProjects + const categoriesAPI = useCategoriesAPI() + const remoteBackend = backendProvider.useRemoteBackend() + const localBackend = backendProvider.useLocalBackend() + return ( /* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here * due to modals being in `TheModal`. */ - + {({ resetAssetTableState }) => ( - @@ -315,68 +320,3 @@ function DashboardInner(props: DashboardProps) { ) } - -/** Compute the initial set of expanded directories. */ -async function computeInitialExpandedDirectories( - launchedProjects: readonly LaunchedProject[], - categoriesAPI: CategoriesContextValue, - remoteBackend: RemoteBackend, - localBackend: LocalBackend | null, -) { - const { cloudCategories, localCategories } = categoriesAPI - const expandedDirectories: Record> = { - cloud: new Set(), - recent: new Set(), - trash: new Set(), - local: new Set(), - } - const promises: Promise[] = [] - for (const category of [...cloudCategories.categories, ...localCategories.categories]) { - const set = (expandedDirectories[category.id] ??= new Set()) - for (const project of launchedProjects) { - const backend = - project.type === backendModule.BackendType.remote ? remoteBackend : localBackend - if (!backend) { - continue - } - promises.push( - backend.tryGetAssetAncestors(project, category.id).then((ancestors) => { - if (!ancestors) { - return - } - for (const ancestor of ancestors) { - set.add(ancestor) - } - }), - ) - } - } - - await Promise.all(promises) - return expandedDirectories -} - -/** Expand the list of parents for opened projects. */ -function OpenedProjectsParentsExpander() { - const remoteBackend = backendProvider.useRemoteBackend() - const localBackend = backendProvider.useLocalBackend() - const categoriesAPI = useCategoriesAPI() - const launchedProjects = useLaunchedProjects() - const setExpandedDirectories = useSetExpandedDirectories() - - const updateOpenedProjects = eventCallbacks.useEventCallback(async () => { - const expandedDirectories = await computeInitialExpandedDirectories( - launchedProjects, - categoriesAPI, - remoteBackend, - localBackend, - ) - setExpandedDirectories(expandedDirectories) - }) - - React.useEffect(() => { - void updateOpenedProjects() - }, [updateOpenedProjects]) - - return null -} diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index e16e123a6f51..db3d054d1dd1 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -6,15 +6,22 @@ import invariant from 'tiny-invariant' import { useEventCallback } from '#/hooks/eventCallbackHooks' import type { Category, CategoryId } from '#/layouts/CategorySwitcher/Category' -import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' +import { + useCategoriesAPI, + type CategoriesContextValue, +} from '#/layouts/Drive/Categories/categoriesHooks' +import type { LaunchedProject } from '#/providers/ProjectsProvider' +import type LocalBackend from '#/services/LocalBackend' +import type RemoteBackend from '#/services/RemoteBackend' import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' import { EMPTY_SET } from '#/utilities/set' -import type { - AssetId, +import { useSuspenseQuery } from '@tanstack/react-query' +import { BackendType, - DirectoryAsset, - DirectoryId, + type AssetId, + type DirectoryAsset, + type DirectoryId, } from 'enso-common/src/services/Backend' // ================== @@ -68,18 +75,69 @@ export interface ProjectsProviderProps { readonly store: ProjectsContextType readonly resetAssetTableState: () => void }) => React.ReactNode) + readonly launchedProjects: readonly LaunchedProject[] + readonly categoriesAPI: CategoriesContextValue + readonly remoteBackend: RemoteBackend + readonly localBackend: LocalBackend | null } -// ======================== -// === ProjectsProvider === -// ======================== +/** Compute the initial set of expanded directories. */ +async function computeInitialExpandedDirectories( + launchedProjects: readonly LaunchedProject[], + categoriesAPI: CategoriesContextValue, + remoteBackend: RemoteBackend, + localBackend: LocalBackend | null, +) { + const { cloudCategories, localCategories } = categoriesAPI + const expandedDirectories: Record> = { + cloud: new Set(), + recent: new Set(), + trash: new Set(), + local: new Set(), + } + const promises: Promise[] = [] + for (const category of [...cloudCategories.categories, ...localCategories.categories]) { + const set = (expandedDirectories[category.id] ??= new Set()) + for (const project of launchedProjects) { + const backend = project.type === BackendType.remote ? remoteBackend : localBackend + if (!backend) { + continue + } + promises.push( + backend.tryGetAssetAncestors(project, category.id).then((ancestors) => { + if (!ancestors) { + return + } + for (const ancestor of ancestors) { + set.add(ancestor) + } + }), + ) + } + } + + await Promise.all(promises) + return expandedDirectories +} /** * A React provider (and associated hooks) for determining whether the current area * containing the current element is focused. */ export default function DriveProvider(props: ProjectsProviderProps) { - const { children } = props + const { children, launchedProjects, categoriesAPI, remoteBackend, localBackend } = props + + const { data: initialExpandedDirectories } = useSuspenseQuery({ + queryKey: ['computeInitialExpandedDirectories'], + queryFn: () => + computeInitialExpandedDirectories( + launchedProjects, + categoriesAPI, + remoteBackend, + localBackend, + ), + staleTime: Infinity, + }) const [store] = React.useState(() => zustand.createStore((set, get) => ({ @@ -120,12 +178,7 @@ export default function DriveProvider(props: ProjectsProviderProps) { set({ pasteData }) } }, - expandedDirectories: { - cloud: new Set(), - local: new Set(), - recent: new Set(), - trash: new Set(), - }, + expandedDirectories: initialExpandedDirectories, setExpandedDirectories: (expandedDirectories) => { if (get().expandedDirectories !== expandedDirectories) { set({ expandedDirectories }) From 356cfec1782f5d5cfb84fcb5d85a8956a77cca0b Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 18:14:05 +1000 Subject: [PATCH 15/31] Fix cyclic dependencies between stores --- .../dashboard/pages/dashboard/Dashboard.tsx | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 9ea32dfda42f..8a251d705bc4 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -50,6 +50,7 @@ import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' +import { unsafeWriteValue } from '#/utilities/write' import { usePrefetchQuery } from '@tanstack/react-query' import { DashboardTabPanels } from './DashboardTabPanels' @@ -68,6 +69,31 @@ export interface DashboardProps { /** The component that contains the entire UI. */ export default function Dashboard(props: DashboardProps) { + const resetAssetTableStateRef = React.useRef(() => {}) + + const stableResetAssetTableState = eventCallbacks.useEventCallback(() => { + resetAssetTableStateRef.current() + }) + + return ( + /* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here + * due to modals being in `TheModal`. */ + + + + + + ) +} + +/** Props for a {@link DashboardInner2}. */ +interface DashboardInner2Props extends DashboardProps { + readonly resetAssetTableStateRef: React.MutableRefObject<() => void> +} + +/** The component that contains the entire UI. */ +function DashboardInner2(props: DashboardInner2Props) { + const { resetAssetTableStateRef } = props const projectsStore = useProjectsStore() // MUST NOT be reactive as it should not cause the entire dashboard to re-render. const launchedProjects = projectsStore.getState().launchedProjects @@ -84,15 +110,14 @@ export default function Dashboard(props: DashboardProps) { remoteBackend={remoteBackend} localBackend={localBackend} > - {({ resetAssetTableState }) => ( - + {({ resetAssetTableState }) => { + unsafeWriteValue(resetAssetTableStateRef, 'current', resetAssetTableState) + return ( - - - + - - )} + ) + }} ) } From d01fc610870c53ab0145b0a17189ff26a3617edf Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 19:00:38 +1000 Subject: [PATCH 16/31] Fix errors --- .../dashboard/DirectoryNameColumn.tsx | 2 +- .../components/dashboard/column/PathColumn.tsx | 17 +++++++++++------ .../layouts/Drive/directoryIdsHooks.tsx | 15 +++++++++------ .../src/dashboard/providers/DriveProvider.tsx | 4 ++++ app/gui/src/dashboard/services/LocalBackend.ts | 13 +++---------- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index 54798ef4831b..d3bdebbf3258 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -44,7 +44,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const toggleDirectoryExpansion = useToggleDirectoryExpansion() const isExpanded = useStore( driveStore, - (storeState) => storeState.expandedDirectories[category.rootPath]?.includes(item.id) ?? false, + (storeState) => storeState.expandedDirectories[category.id]?.has(item.id) ?? false, ) const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory')) diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index b143222b3b88..20678e697581 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -14,7 +14,7 @@ import { } from '#/providers/DriveProvider' import type { DirectoryId } from '#/services/Backend' import { isDirectoryId } from '#/services/Backend' -import { unsafeMutable } from 'enso-common/src/utilities/data/object' +import { mapEntries, unsafeMutable } from 'enso-common/src/utilities/data/object' import { Fragment, useTransition } from 'react' import invariant from 'tiny-invariant' import type { AssetColumnProps } from '../column' @@ -72,13 +72,18 @@ export default function PathColumn(props: AssetColumnProps) { if (targetDirectoryNode == null && rootDirectoryInThePath.categoryId != null) { setCategory(rootDirectoryInThePath.categoryId) - const expandedDirectories = structuredClone(driveStore.getState().expandedDirectories) + const expandedDirectories = mapEntries( + driveStore.getState().expandedDirectories, + (_k, v) => new Set(v), + ) // This is SAFE, as it is a fresh copy that has been deep cloned above. - const directoryList = expandedDirectories[category.rootPath] + const directoryList = expandedDirectories[category.id] if (directoryList) { - unsafeMutable(directoryList).push( - ...pathToDirectory.map(({ id }) => id).concat(targetDirectory), - ) + const mutableDirectoryList = unsafeMutable(directoryList) + const newItems = pathToDirectory.map(({ id }) => id).concat(targetDirectory) + for (const newItem of newItems) { + mutableDirectoryList.add(newItem) + } } setExpandedDirectories(expandedDirectories) } diff --git a/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx b/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx index 29baf73c3079..e2a8f90a1005 100644 --- a/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/directoryIdsHooks.tsx @@ -9,7 +9,7 @@ import { Path, createRootDirectoryAsset } from 'enso-common/src/services/Backend import type { Category } from '#/layouts/CategorySwitcher/Category' import { useFullUserSession } from '#/providers/AuthProvider' import { useBackend } from '#/providers/BackendProvider' -import { useExpandedDirectories, useSetExpandedDirectories } from '#/providers/DriveProvider' +import { useExpandedDirectories } from '#/providers/DriveProvider' import { useLocalStorageState } from '#/providers/LocalStorageProvider' /** Options for {@link useDirectoryIds}. */ @@ -35,8 +35,7 @@ export function useDirectoryIds(options: UseDirectoryIdsOptions) { * The root directory is not included as it might change when a user switches * between items in sidebar and we don't want to reset the expanded state using `useEffect`. */ - const privateExpandedDirectoryIds = useExpandedDirectories() - const setExpandedDirectoryIds = useSetExpandedDirectories() + const privateExpandedDirectories = useExpandedDirectories() const [localRootDirectory] = useLocalStorageState('localRootDirectory') @@ -53,9 +52,13 @@ export function useDirectoryIds(options: UseDirectoryIdsOptions) { const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId]) const expandedDirectoryIds = useMemo( - () => [rootDirectoryId].concat(privateExpandedDirectoryIds[category.rootPath] ?? []), - [category.rootPath, privateExpandedDirectoryIds, rootDirectoryId], + () => [rootDirectoryId, ...Array.from(privateExpandedDirectories[category.id] ?? [])], + [category.id, privateExpandedDirectories, rootDirectoryId], ) - return { setExpandedDirectoryIds, rootDirectoryId, rootDirectory, expandedDirectoryIds } as const + return { + rootDirectoryId, + rootDirectory, + expandedDirectoryIds, + } as const } diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index db3d054d1dd1..f85a6119ea09 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -137,6 +137,10 @@ export default function DriveProvider(props: ProjectsProviderProps) { localBackend, ), staleTime: Infinity, + meta: { + // The query is not JSON-serializable as-is, so it MUST NOT be persisted. + persist: false, + }, }) const [store] = React.useState(() => diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index 3b1504c2e2e8..2c03ca3a4a24 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -91,11 +91,6 @@ export function extractTypeAndId(id: Id): AssetTypeA } } -/** Whether the given path is a descendant of another path. */ -export function isDescendantPath(path: backend.Path, possibleAncestor: backend.Path) { - return path.startsWith(`${possibleAncestor}/`) -} - // ==================== // === LocalBackend === // ==================== @@ -787,20 +782,18 @@ export default class LocalBackend extends Backend { category = newDirectoryId(this.rootPath()) } if (backend.isDirectoryId(category)) { + const categoryPath = extractTypeAndId(category).id const path = extractTypeAndId(asset.parentId).id - const strippedPath = path.replace(`${category}/`, '') + const strippedPath = path.replace(`${categoryPath}/`, '') if (strippedPath === path) { return null } - let parentPath = String(category) + let parentPath = String(categoryPath) const parents = strippedPath.split('/') const parentIds: backend.DirectoryId[] = [] for (const parent of parents) { parentPath += `/${parent}` const currentParentPath = backend.Path(parentPath) - if (!isDescendantPath(currentParentPath, path)) { - continue - } parentIds.push(newDirectoryId(currentParentPath)) } return parentIds From 30918351d3075188e6c5495c23b73ce93a376d84 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 19:25:38 +1000 Subject: [PATCH 17/31] Expand full path to directory when clicking on breadcrumb --- .../components/dashboard/AssetRow.tsx | 8 +- .../dashboard/DirectoryNameColumn.tsx | 2 +- .../dashboard/column/PathColumn.tsx | 107 ++++++++---------- app/gui/src/dashboard/hooks/backendHooks.tsx | 14 +-- app/gui/src/dashboard/layouts/AssetsTable.tsx | 10 +- .../src/dashboard/providers/DriveProvider.tsx | 52 +++++---- 6 files changed, 99 insertions(+), 94 deletions(-) diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 2dbbbc374bb1..b608e577b304 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -712,7 +712,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { window.setTimeout(() => { setSelected(false) }) - toggleDirectoryExpansion(asset.id) + toggleDirectoryExpansion([asset.id]) } }} onContextMenu={(event) => { @@ -757,7 +757,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { } if (asset.type === backendModule.AssetType.directory) { dragOverTimeoutHandle.current = window.setTimeout(() => { - toggleDirectoryExpansion(asset.id, true) + toggleDirectoryExpansion([asset.id], true) }, DRAG_EXPAND_DELAY_MS) } // Required because `dragover` does not fire on `mouseenter`. @@ -805,7 +805,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { event.preventDefault() event.stopPropagation() unsetModal() - toggleDirectoryExpansion(directoryId, true) + toggleDirectoryExpansion([directoryId], true) const ids = payload .filter((payloadItem) => payloadItem.asset.parentId !== directoryId) .map((dragItem) => dragItem.key) @@ -818,7 +818,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { } else if (event.dataTransfer.types.includes('Files')) { event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion(directoryId, true) + toggleDirectoryExpansion([directoryId], true) void uploadFiles(Array.from(event.dataTransfer.files), directoryId, null) } } diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index d3bdebbf3258..d9ad7a5d8855 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -99,7 +99,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { isExpanded && 'rotate-90', )} onPress={() => { - toggleDirectoryExpansion(item.id) + toggleDirectoryExpansion([item.id]) }} /> diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index 20678e697581..1f832269a1a5 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -7,30 +7,30 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useCategoriesAPI, useCloudCategoryList } from '#/layouts/Drive/Categories/categoriesHooks' import type { AnyCloudCategory } from '#/layouts/Drive/Categories/Category' import { useUser } from '#/providers/AuthProvider' -import { - useDriveStore, - useSetExpandedDirectories, - useSetSelectedKeys, -} from '#/providers/DriveProvider' +import { useSetSelectedKeys, useToggleDirectoryExpansion } from '#/providers/DriveProvider' import type { DirectoryId } from '#/services/Backend' import { isDirectoryId } from '#/services/Backend' -import { mapEntries, unsafeMutable } from 'enso-common/src/utilities/data/object' import { Fragment, useTransition } from 'react' import invariant from 'tiny-invariant' import type { AssetColumnProps } from '../column' +/** Information for a path segment. */ +interface PathSegmentInfo { + readonly id: DirectoryId + readonly categoryId: AnyCloudCategory['id'] | null + readonly label: AnyCloudCategory['label'] + readonly icon: AnyCloudCategory['icon'] +} + /** A column displaying the path of the asset. */ export default function PathColumn(props: AssetColumnProps) { const { item, state } = props - const { virtualParentsPath, parentsPath } = item + const { getAssetNodeById } = state - const { getAssetNodeById, category } = state - - const driveStore = useDriveStore() const { setCategory } = useCategoriesAPI() const setSelectedKeys = useSetSelectedKeys() - const setExpandedDirectories = useSetExpandedDirectories() + const toggleDirectoryExpansion = useToggleDirectoryExpansion() // Path navigation exist only for cloud categories. const { getCategoryByDirectoryId } = useCloudCategoryList() @@ -47,57 +47,44 @@ export default function PathColumn(props: AssetColumnProps) { const { rootDirectoryId } = useUser() - const navigateToDirectory = useEventCallback((targetDirectory: DirectoryId) => { - const targetDirectoryIndex = finalPath.findIndex(({ id }) => id === targetDirectory) + const navigateToDirectory = useEventCallback( + (targetDirectory: DirectoryId, segments: readonly PathSegmentInfo[]) => { + const targetDirectoryIndex = finalPath.findIndex(({ id }) => id === targetDirectory) + const targetDirectoryInfo = finalPath[targetDirectoryIndex] - if (targetDirectoryIndex === -1) { - return - } + if (!targetDirectoryInfo) { + return + } - const pathToDirectory = finalPath - .slice(0, targetDirectoryIndex + 1) - .map(({ id, categoryId }) => ({ id, categoryId })) - - const rootDirectoryInThePath = pathToDirectory.at(0) - - // This should never happen, as we always have the root directory in the path. - // If it happens, it means you've skrewed up - invariant(rootDirectoryInThePath != null, 'Root directory id is null') - - // If the target directory is null, we assume that this directory is outside of the current tree (in another category) - // Which is the default, because the path path displays in the recent and trash folders. - // But sometimes the user might delete a directory with its whole content, and in that case we'll find it in the tree - // because the parent is always fetched before its children. - const targetDirectoryNode = getAssetNodeById(targetDirectory) - - if (targetDirectoryNode == null && rootDirectoryInThePath.categoryId != null) { - setCategory(rootDirectoryInThePath.categoryId) - const expandedDirectories = mapEntries( - driveStore.getState().expandedDirectories, - (_k, v) => new Set(v), - ) - // This is SAFE, as it is a fresh copy that has been deep cloned above. - const directoryList = expandedDirectories[category.id] - if (directoryList) { - const mutableDirectoryList = unsafeMutable(directoryList) - const newItems = pathToDirectory.map(({ id }) => id).concat(targetDirectory) - for (const newItem of newItems) { - mutableDirectoryList.add(newItem) - } + const pathToDirectory = finalPath + .slice(0, targetDirectoryIndex + 1) + .map(({ id, categoryId }) => ({ id, categoryId })) + + const rootDirectoryInThePath = pathToDirectory.at(0) + + // This should never happen, as we always have the root directory in the path. + // If it happens, it means you've skrewed up + invariant(rootDirectoryInThePath != null, 'Root directory id is null') + + // If the target directory is null, we assume that this directory is outside of the current tree (in another category) + // Which is the default, because the path path displays in the recent and trash folders. + // But sometimes the user might delete a directory with its whole content, and in that case we'll find it in the tree + // because the parent is always fetched before its children. + const targetDirectoryNode = getAssetNodeById(targetDirectory) + + if (targetDirectoryNode == null && rootDirectoryInThePath.categoryId != null) { + setCategory(rootDirectoryInThePath.categoryId) } - setExpandedDirectories(expandedDirectories) - } - setSelectedKeys(new Set([targetDirectory])) - }) + const newItems = segments.map(({ id }) => id).concat(targetDirectory) + toggleDirectoryExpansion(newItems, true, rootDirectoryInThePath.categoryId ?? undefined) + + setSelectedKeys(new Set([targetDirectory])) + }, + ) const finalPath = (() => { - const result: { - id: DirectoryId - categoryId: AnyCloudCategory['id'] | null - label: AnyCloudCategory['label'] - icon: AnyCloudCategory['icon'] - }[] = [] + const result: PathSegmentInfo[] = [] if (rootDirectoryInPath == null) { return result @@ -163,7 +150,9 @@ export default function PathColumn(props: AssetColumnProps) { id={lastItemInPath.id} label={lastItemInPath.label} icon={lastItemInPath.icon} - onNavigate={navigateToDirectory} + onNavigate={(id) => { + navigateToDirectory(id, finalPath) + }} /> ) @@ -196,7 +185,9 @@ export default function PathColumn(props: AssetColumnProps) { id={entry.id} label={entry.label} icon={entry.icon} - onNavigate={navigateToDirectory} + onNavigate={(id) => { + navigateToDirectory(id, finalPath.slice(0, index + 1)) + }} /> {index < finalPath.length - 1 && ( diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx index 7b59d738cb24..6a1e81e1d22c 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendHooks.tsx @@ -651,7 +651,7 @@ export function useNewFolder(backend: Backend, category: Category) { const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory')) return useEventCallback(async (parentId: DirectoryId, parentPath: string | null | undefined) => { - toggleDirectoryExpansion(parentId, true) + toggleDirectoryExpansion([parentId], true) const siblings = await ensureListDirectory(parentId) const directoryIndices = siblings .filter(backendModule.assetIsDirectory) @@ -709,7 +709,7 @@ export function useNewProject(backend: Backend, category: Category) { parentId: DirectoryId, parentPath: string | null | undefined, ) => { - toggleDirectoryExpansion(parentId, true) + toggleDirectoryExpansion([parentId], true) const siblings = await ensureListDirectory(parentId) const projectName = (() => { @@ -781,7 +781,7 @@ export function useNewSecret(backend: Backend, category: Category) { parentId: DirectoryId, parentPath: string | null | undefined, ) => { - toggleDirectoryExpansion(parentId, true) + toggleDirectoryExpansion([parentId], true) const placeholderItem = backendModule.createPlaceholderSecretAsset( name, parentId, @@ -820,7 +820,7 @@ export function useNewDatalink(backend: Backend, category: Category) { parentId: DirectoryId, parentPath: string | null | undefined, ) => { - toggleDirectoryExpansion(parentId, true) + toggleDirectoryExpansion([parentId], true) const placeholderItem = backendModule.createPlaceholderDatalinkAsset( name, parentId, @@ -961,7 +961,7 @@ export function useUploadFiles(backend: Backend, category: Category) { } if (duplicateFiles.length === 0 && duplicateProjects.length === 0) { - toggleDirectoryExpansion(parentId, true) + toggleDirectoryExpansion([parentId], true) const assets = [...files, ...projects].map(({ asset }) => asset) void Promise.all(assets.map((asset) => doUploadFile(asset, 'new'))) } else { @@ -1009,7 +1009,7 @@ export function useUploadFiles(backend: Backend, category: Category) { nonConflictingFileCount={files.length - conflictingFiles.length} nonConflictingProjectCount={projects.length - conflictingProjects.length} doUpdateConflicting={async (resolvedConflicts) => { - toggleDirectoryExpansion(parentId, true) + toggleDirectoryExpansion([parentId], true) await Promise.allSettled( resolvedConflicts.map((conflict) => { @@ -1021,7 +1021,7 @@ export function useUploadFiles(backend: Backend, category: Category) { ) }} doUploadNonConflicting={async () => { - toggleDirectoryExpansion(parentId, true) + toggleDirectoryExpansion([parentId], true) const newFiles = files .filter((file) => !siblingFileTitles.has(file.asset.title)) diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index a8829822c218..04c9547d0f66 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -852,7 +852,7 @@ function AssetsTable(props: AssetsTableProps) { resetAssetPanelProps() } if (asset.type === AssetType.directory) { - toggleDirectoryExpansion(asset.id, false) + toggleDirectoryExpansion([asset.id], false) } if (asset.type === AssetType.project && backend.type === BackendType.local) { @@ -920,7 +920,7 @@ function AssetsTable(props: AssetsTableProps) { case AssetType.directory: { event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion(item.item.id) + toggleDirectoryExpansion([item.item.id]) break } case AssetType.project: { @@ -976,7 +976,7 @@ function AssetsTable(props: AssetsTableProps) { // The folder is expanded; collapse it. event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion(item.item.id, false) + toggleDirectoryExpansion([item.item.id], false) } else if (prevIndex != null) { // Focus parent if there is one. let index = prevIndex - 1 @@ -1001,7 +1001,7 @@ function AssetsTable(props: AssetsTableProps) { // The folder is collapsed; expand it. event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion(item.item.id, true) + toggleDirectoryExpansion([item.item.id], true) } break } @@ -1250,7 +1250,7 @@ function AssetsTable(props: AssetsTableProps) { if (pasteData.data.ids.has(newParentKey)) { toast.error('Cannot paste a folder into itself.') } else { - toggleDirectoryExpansion(newParentId, true) + toggleDirectoryExpansion([newParentId], true) if (pasteData.type === 'copy') { const assets = Array.from(pasteData.data.ids, (id) => nodeMapRef.current.get(id)).flatMap( (asset) => (asset ? [asset.item] : []), diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index f85a6119ea09..d25eafff1a5c 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -325,25 +325,39 @@ export function useToggleDirectoryExpansion() { const { category } = useCategoriesAPI() const setExpandedDirectories = useSetExpandedDirectories() - return useEventCallback((id: DirectoryId, override?: boolean) => { - const expandedDirectories = driveStore.getState().expandedDirectories - const isExpanded = expandedDirectories[category.id]?.has(id) ?? false - const shouldExpand = override ?? !isExpanded - - if (shouldExpand !== isExpanded) { - React.startTransition(() => { - const directories = expandedDirectories[category.id] ?? [] - const newDirectories = new Set(directories) - if (shouldExpand) { - newDirectories.add(id) - } else { - newDirectories.delete(id) + return useEventCallback( + (ids: readonly DirectoryId[], override?: boolean, categoryId = category.id) => { + const expandedDirectories = driveStore.getState().expandedDirectories + const directories = expandedDirectories[categoryId] + let count = 0 + if (directories) { + for (const id of ids) { + if (directories.has(id)) { + count += 1 + } } - setExpandedDirectories({ - ...expandedDirectories, - [category.id]: newDirectories, + } + const isExpanded = count * 2 >= ids.length + const shouldExpand = override ?? !isExpanded + + if (shouldExpand !== isExpanded) { + React.startTransition(() => { + const newDirectories = new Set(directories) + if (shouldExpand) { + for (const id of ids) { + newDirectories.add(id) + } + } else { + for (const id of ids) { + newDirectories.delete(id) + } + } + setExpandedDirectories({ + ...expandedDirectories, + [categoryId]: newDirectories, + }) }) - }) - } - }) + } + }, + ) } From 8f4032e2015960ba7b36e12a1a8d6cab4bac18fb Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 19:27:09 +1000 Subject: [PATCH 18/31] Remove `useSetExpandedDirectories` --- app/gui/src/dashboard/providers/DriveProvider.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index d25eafff1a5c..e699fb3d9be2 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -283,14 +283,6 @@ export function useExpandedDirectories() { return zustand.useStore(store, (state) => state.expandedDirectories) } -/** A function to set the expanded directoyIds in the Asset Table. */ -export function useSetExpandedDirectories() { - const store = useDriveStore() - return zustand.useStore(store, (state) => state.setExpandedDirectories, { - unsafeEnableTransition: true, - }) -} - /** The selected keys in the Asset Table. */ export function useSelectedKeys() { const store = useDriveStore() @@ -323,7 +315,10 @@ export function useSetVisuallySelectedKeys() { export function useToggleDirectoryExpansion() { const driveStore = useDriveStore() const { category } = useCategoriesAPI() - const setExpandedDirectories = useSetExpandedDirectories() + const setExpandedDirectories = zustand.useStore( + driveStore, + (store) => store.setExpandedDirectories, + ) return useEventCallback( (ids: readonly DirectoryId[], override?: boolean, categoryId = category.id) => { From 58d59ec78ac937a92f9c7db875a645bc73d5c883 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 19:34:48 +1000 Subject: [PATCH 19/31] Add `useIsDirectoryExpanded` --- .../components/dashboard/DirectoryNameColumn.tsx | 12 ++++++------ app/gui/src/dashboard/providers/DriveProvider.tsx | 9 +++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index d9ad7a5d8855..ca9aba47ea1b 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -6,7 +6,11 @@ import FolderArrowIcon from '#/assets/folder_arrow.svg' import { backendMutationOptions } from '#/hooks/backendHooks' -import { useDriveStore, useToggleDirectoryExpansion } from '#/providers/DriveProvider' +import { + useDriveStore, + useIsDirectoryExpanded, + useToggleDirectoryExpansion, +} from '#/providers/DriveProvider' import * as textProvider from '#/providers/TextProvider' import type * as column from '#/components/dashboard/column' @@ -15,7 +19,6 @@ import EditableSpan from '#/components/EditableSpan' import * as backendModule from '#/services/Backend' import { Button } from '#/components/AriaComponents' -import { useStore } from '#/hooks/storeHooks' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' @@ -42,10 +45,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const { getText } = textProvider.useText() const driveStore = useDriveStore() const toggleDirectoryExpansion = useToggleDirectoryExpansion() - const isExpanded = useStore( - driveStore, - (storeState) => storeState.expandedDirectories[category.id]?.has(item.id) ?? false, - ) + const isExpanded = useIsDirectoryExpanded(item.id, category.id) const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory')) diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index e699fb3d9be2..4311a289be3d 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -283,6 +283,15 @@ export function useExpandedDirectories() { return zustand.useStore(store, (state) => state.expandedDirectories) } +/** Whether the given directory is expanded. */ +export function useIsDirectoryExpanded(directoryId: DirectoryId, categoryId: CategoryId): boolean { + const store = useDriveStore() + return zustand.useStore( + store, + (state) => state.expandedDirectories[categoryId]?.has(directoryId) ?? false, + ) +} + /** The selected keys in the Asset Table. */ export function useSelectedKeys() { const store = useDriveStore() From 6bcfaf76c334ee9160feb0937333b40fdfc4016f Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 19:37:32 +1000 Subject: [PATCH 20/31] WIP: Re-add `DriveProvider.test` --- .../src/dashboard/providers/DriveProvider.tsx | 11 ++- .../providers/__test__/DriveProvider.test.tsx | 88 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index 4311a289be3d..5c7701aa402c 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -11,8 +11,6 @@ import { type CategoriesContextValue, } from '#/layouts/Drive/Categories/categoriesHooks' import type { LaunchedProject } from '#/providers/ProjectsProvider' -import type LocalBackend from '#/services/LocalBackend' -import type RemoteBackend from '#/services/RemoteBackend' import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' import { EMPTY_SET } from '#/utilities/set' @@ -20,6 +18,7 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { BackendType, type AssetId, + type default as Backend, type DirectoryAsset, type DirectoryId, } from 'enso-common/src/services/Backend' @@ -77,16 +76,16 @@ export interface ProjectsProviderProps { }) => React.ReactNode) readonly launchedProjects: readonly LaunchedProject[] readonly categoriesAPI: CategoriesContextValue - readonly remoteBackend: RemoteBackend - readonly localBackend: LocalBackend | null + readonly remoteBackend: Backend + readonly localBackend: Backend | null } /** Compute the initial set of expanded directories. */ async function computeInitialExpandedDirectories( launchedProjects: readonly LaunchedProject[], categoriesAPI: CategoriesContextValue, - remoteBackend: RemoteBackend, - localBackend: LocalBackend | null, + remoteBackend: Backend, + localBackend: Backend | null, ) { const { cloudCategories, localCategories } = categoriesAPI const expandedDirectories: Record> = { diff --git a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx new file mode 100644 index 000000000000..119725548530 --- /dev/null +++ b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx @@ -0,0 +1,88 @@ +import { DirectoryId } from '#/services/Backend' +import { act, renderHook, type RenderHookOptions, type RenderHookResult } from '#/test' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import { useStore } from 'zustand' +import type { CategoryId } from '../../layouts/CategorySwitcher/Category' +import DriveProvider, { useDriveStore } from '../DriveProvider' + +function renderDriveProviderHook( + hook: (props: Props) => Result, + options?: Omit, 'wrapper'>, +): RenderHookResult { + let currentCategoryId: CategoryId = 'cloud' + let setCategoryId: (categoryId: CategoryId) => void + let doResetAssetTableState: () => void + + return renderHook( + (props) => { + const result = hook(props) + return { ...result, setCategoryId } + }, + { + wrapper: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [category, setCategory] = useState(() => currentCategoryId) + currentCategoryId = category + setCategoryId = (nextCategoryId) => { + setCategory(nextCategoryId) + doResetAssetTableState() + } + + return ( + + {({ resetAssetTableState }) => { + doResetAssetTableState = resetAssetTableState + return children + }} + + ) + }, + ...options, + }, + ) +} + +describe('', () => { + it('Should reset expanded directory ids when category changes', () => { + const driveAPI = renderDriveProviderHook((setCategoryId: (categoryId: string) => void) => { + const store = useDriveStore() + return useStore( + store, + ({ + setExpandedDirectoryIds, + expandedDirectoryIds, + selectedKeys, + visuallySelectedKeys, + }) => ({ + expandedDirectoryIds, + setExpandedDirectoryIds, + setCategoryId, + selectedKeys, + visuallySelectedKeys, + }), + ) + }) + + act(() => { + driveAPI.result.current.setExpandedDirectoryIds([DirectoryId('directory-test-123')]) + }) + + expect(driveAPI.result.current.expandedDirectoryIds).toEqual([ + DirectoryId('directory-test-123'), + ]) + + act(() => { + driveAPI.result.current.setCategoryId('recent') + }) + + expect(driveAPI.result.current.expandedDirectoryIds).toEqual([]) + expect(driveAPI.result.current.selectedKeys).toEqual(new Set()) + expect(driveAPI.result.current.visuallySelectedKeys).toEqual(null) + }) +}) From 98727bfd9b3343021338690bfc406c8a26f6bb72 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 20:10:30 +1000 Subject: [PATCH 21/31] WIP: Fix `DriveProvider.test` --- .../dashboard/pages/dashboard/Dashboard.tsx | 5 +- .../src/dashboard/providers/DriveProvider.tsx | 81 +++++++++++++++---- .../providers/__test__/DriveProvider.test.tsx | 40 +++++---- 3 files changed, 95 insertions(+), 31 deletions(-) diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 8a251d705bc4..1fe06518f40e 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -97,7 +97,7 @@ function DashboardInner2(props: DashboardInner2Props) { const projectsStore = useProjectsStore() // MUST NOT be reactive as it should not cause the entire dashboard to re-render. const launchedProjects = projectsStore.getState().launchedProjects - const categoriesAPI = useCategoriesAPI() + const { cloudCategories, localCategories } = useCategoriesAPI() const remoteBackend = backendProvider.useRemoteBackend() const localBackend = backendProvider.useLocalBackend() @@ -106,7 +106,8 @@ function DashboardInner2(props: DashboardInner2Props) { * due to modals being in `TheModal`. */ diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index 5c7701aa402c..9269a7de06e2 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -5,11 +5,13 @@ import * as zustand from '#/utilities/zustand' import invariant from 'tiny-invariant' import { useEventCallback } from '#/hooks/eventCallbackHooks' -import type { Category, CategoryId } from '#/layouts/CategorySwitcher/Category' -import { - useCategoriesAPI, - type CategoriesContextValue, -} from '#/layouts/Drive/Categories/categoriesHooks' +import type { + AnyCloudCategory, + AnyLocalCategory, + Category, + CategoryId, +} from '#/layouts/CategorySwitcher/Category' +import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import type { LaunchedProject } from '#/providers/ProjectsProvider' import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' @@ -51,6 +53,12 @@ interface DriveStore { readonly setExpandedDirectories: ( selectedKeys: Record>, ) => void + readonly isDirectoryExpanded: (directoryId: DirectoryId, categoryId: CategoryId) => boolean + readonly toggleDirectoryExpansion: ( + ids: readonly DirectoryId[], + shouldExpand: boolean, + categoryId: CategoryId, + ) => void readonly selectedKeys: ReadonlySet readonly setSelectedKeys: (selectedKeys: ReadonlySet) => void readonly visuallySelectedKeys: ReadonlySet | null @@ -75,7 +83,8 @@ export interface ProjectsProviderProps { readonly resetAssetTableState: () => void }) => React.ReactNode) readonly launchedProjects: readonly LaunchedProject[] - readonly categoriesAPI: CategoriesContextValue + readonly cloudCategories: readonly AnyCloudCategory[] + readonly localCategories: readonly AnyLocalCategory[] readonly remoteBackend: Backend readonly localBackend: Backend | null } @@ -83,11 +92,11 @@ export interface ProjectsProviderProps { /** Compute the initial set of expanded directories. */ async function computeInitialExpandedDirectories( launchedProjects: readonly LaunchedProject[], - categoriesAPI: CategoriesContextValue, + cloudCategories: readonly AnyCloudCategory[], + localCategories: readonly AnyLocalCategory[], remoteBackend: Backend, localBackend: Backend | null, ) { - const { cloudCategories, localCategories } = categoriesAPI const expandedDirectories: Record> = { cloud: new Set(), recent: new Set(), @@ -95,7 +104,7 @@ async function computeInitialExpandedDirectories( local: new Set(), } const promises: Promise[] = [] - for (const category of [...cloudCategories.categories, ...localCategories.categories]) { + for (const category of [...cloudCategories, ...localCategories]) { const set = (expandedDirectories[category.id] ??= new Set()) for (const project of launchedProjects) { const backend = project.type === BackendType.remote ? remoteBackend : localBackend @@ -124,14 +133,22 @@ async function computeInitialExpandedDirectories( * containing the current element is focused. */ export default function DriveProvider(props: ProjectsProviderProps) { - const { children, launchedProjects, categoriesAPI, remoteBackend, localBackend } = props + const { + children, + launchedProjects, + cloudCategories, + localCategories, + remoteBackend, + localBackend, + } = props const { data: initialExpandedDirectories } = useSuspenseQuery({ queryKey: ['computeInitialExpandedDirectories'], queryFn: () => computeInitialExpandedDirectories( launchedProjects, - categoriesAPI, + cloudCategories, + localCategories, remoteBackend, localBackend, ), @@ -187,6 +204,43 @@ export default function DriveProvider(props: ProjectsProviderProps) { set({ expandedDirectories }) } }, + isDirectoryExpanded: (directoryId, categoryId) => { + return get().expandedDirectories[categoryId]?.has(directoryId) ?? false + }, + toggleDirectoryExpansion: (ids, shouldExpand, categoryId) => { + const expandedDirectories = get().expandedDirectories + const directories = expandedDirectories[categoryId] + let count = 0 + if (directories) { + for (const id of ids) { + if (directories.has(id)) { + count += 1 + } + } + } + const isExpanded = count * 2 >= ids.length + + if (shouldExpand !== isExpanded) { + React.startTransition(() => { + const newDirectories = new Set(directories) + if (shouldExpand) { + for (const id of ids) { + newDirectories.add(id) + } + } else { + for (const id of ids) { + newDirectories.delete(id) + } + } + set({ + expandedDirectories: { + ...expandedDirectories, + [categoryId]: newDirectories, + }, + }) + }) + } + }, selectedKeys: EMPTY_SET, setSelectedKeys: (selectedKeys) => { set({ selectedKeys }) @@ -285,10 +339,7 @@ export function useExpandedDirectories() { /** Whether the given directory is expanded. */ export function useIsDirectoryExpanded(directoryId: DirectoryId, categoryId: CategoryId): boolean { const store = useDriveStore() - return zustand.useStore( - store, - (state) => state.expandedDirectories[categoryId]?.has(directoryId) ?? false, - ) + return zustand.useStore(store, (state) => state.isDirectoryExpanded(directoryId, categoryId)) } /** The selected keys in the Asset Table. */ diff --git a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx index 119725548530..b0770e1f7bd0 100644 --- a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx +++ b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx @@ -31,10 +31,14 @@ function renderDriveProviderHook( return ( {({ resetAssetTableState }) => { doResetAssetTableState = resetAssetTableState @@ -50,18 +54,18 @@ function renderDriveProviderHook( describe('', () => { it('Should reset expanded directory ids when category changes', () => { - const driveAPI = renderDriveProviderHook((setCategoryId: (categoryId: string) => void) => { + const driveAPI = renderDriveProviderHook((setCategoryId: (categoryId: CategoryId) => void) => { const store = useDriveStore() return useStore( store, ({ - setExpandedDirectoryIds, - expandedDirectoryIds, + toggleDirectoryExpansion, + expandedDirectories, selectedKeys, visuallySelectedKeys, }) => ({ - expandedDirectoryIds, - setExpandedDirectoryIds, + expandedDirectories, + toggleDirectoryExpansion, setCategoryId, selectedKeys, visuallySelectedKeys, @@ -70,19 +74,27 @@ describe('', () => { }) act(() => { - driveAPI.result.current.setExpandedDirectoryIds([DirectoryId('directory-test-123')]) + driveAPI.result.current.toggleDirectoryExpansion( + [DirectoryId('directory-test-123')], + true, + 'cloud', + ) }) - expect(driveAPI.result.current.expandedDirectoryIds).toEqual([ - DirectoryId('directory-test-123'), - ]) + expect(driveAPI.result.current.expandedDirectories).toEqual([DirectoryId('directory-test-123')]) act(() => { driveAPI.result.current.setCategoryId('recent') }) - expect(driveAPI.result.current.expandedDirectoryIds).toEqual([]) + expect(driveAPI.result.current.expandedDirectories).toEqual([]) expect(driveAPI.result.current.selectedKeys).toEqual(new Set()) expect(driveAPI.result.current.visuallySelectedKeys).toEqual(null) + + act(() => { + // Set the category back to the default category (`cloud`). + driveAPI.result.current.setCategoryId('cloud') + }) + // The original expanded directories should be back. }) }) From a7f81ae8a4e841058f71fe27128109c63ba07d61 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 20:22:53 +1000 Subject: [PATCH 22/31] Fix unnecessary dependencies of `DriveProvider` --- .../components/dashboard/AssetRow.tsx | 2 +- .../dashboard/DirectoryNameColumn.tsx | 2 +- .../dashboard/column/PathColumn.tsx | 20 +++++-------------- app/gui/src/dashboard/hooks/backendHooks.tsx | 14 ++++++------- app/gui/src/dashboard/layouts/AssetsTable.tsx | 17 +++++----------- .../src/dashboard/providers/DriveProvider.tsx | 4 +--- 6 files changed, 20 insertions(+), 39 deletions(-) diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index b608e577b304..47de9045dd18 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -712,7 +712,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { window.setTimeout(() => { setSelected(false) }) - toggleDirectoryExpansion([asset.id]) + toggleDirectoryExpansion([asset.id], category.id) } }} onContextMenu={(event) => { diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index ca9aba47ea1b..6dad49c3ab41 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -99,7 +99,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { isExpanded && 'rotate-90', )} onPress={() => { - toggleDirectoryExpansion([item.id]) + toggleDirectoryExpansion([item.id], category.id) }} /> diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index 1f832269a1a5..bc58edc29709 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -17,16 +17,15 @@ import type { AssetColumnProps } from '../column' /** Information for a path segment. */ interface PathSegmentInfo { readonly id: DirectoryId - readonly categoryId: AnyCloudCategory['id'] | null + readonly categoryId: AnyCloudCategory['id'] readonly label: AnyCloudCategory['label'] readonly icon: AnyCloudCategory['icon'] } /** A column displaying the path of the asset. */ export default function PathColumn(props: AssetColumnProps) { - const { item, state } = props + const { item } = props const { virtualParentsPath, parentsPath } = item - const { getAssetNodeById } = state const { setCategory } = useCategoriesAPI() const setSelectedKeys = useSetSelectedKeys() @@ -66,18 +65,9 @@ export default function PathColumn(props: AssetColumnProps) { // If it happens, it means you've skrewed up invariant(rootDirectoryInThePath != null, 'Root directory id is null') - // If the target directory is null, we assume that this directory is outside of the current tree (in another category) - // Which is the default, because the path path displays in the recent and trash folders. - // But sometimes the user might delete a directory with its whole content, and in that case we'll find it in the tree - // because the parent is always fetched before its children. - const targetDirectoryNode = getAssetNodeById(targetDirectory) - - if (targetDirectoryNode == null && rootDirectoryInThePath.categoryId != null) { - setCategory(rootDirectoryInThePath.categoryId) - } - + setCategory(rootDirectoryInThePath.categoryId) const newItems = segments.map(({ id }) => id).concat(targetDirectory) - toggleDirectoryExpansion(newItems, true, rootDirectoryInThePath.categoryId ?? undefined) + toggleDirectoryExpansion(newItems, rootDirectoryInThePath.categoryId, true) setSelectedKeys(new Set([targetDirectory])) }, @@ -120,7 +110,7 @@ export default function PathColumn(props: AssetColumnProps) { id, label: name, icon: FolderIcon, - categoryId: null, + categoryId: rootCategory.id, }) } diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx index 6a1e81e1d22c..a16d6ff0e786 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendHooks.tsx @@ -651,7 +651,7 @@ export function useNewFolder(backend: Backend, category: Category) { const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory')) return useEventCallback(async (parentId: DirectoryId, parentPath: string | null | undefined) => { - toggleDirectoryExpansion([parentId], true) + toggleDirectoryExpansion([parentId], category.id, true) const siblings = await ensureListDirectory(parentId) const directoryIndices = siblings .filter(backendModule.assetIsDirectory) @@ -709,7 +709,7 @@ export function useNewProject(backend: Backend, category: Category) { parentId: DirectoryId, parentPath: string | null | undefined, ) => { - toggleDirectoryExpansion([parentId], true) + toggleDirectoryExpansion([parentId], category.id, true) const siblings = await ensureListDirectory(parentId) const projectName = (() => { @@ -781,7 +781,7 @@ export function useNewSecret(backend: Backend, category: Category) { parentId: DirectoryId, parentPath: string | null | undefined, ) => { - toggleDirectoryExpansion([parentId], true) + toggleDirectoryExpansion([parentId], category.id, true) const placeholderItem = backendModule.createPlaceholderSecretAsset( name, parentId, @@ -820,7 +820,7 @@ export function useNewDatalink(backend: Backend, category: Category) { parentId: DirectoryId, parentPath: string | null | undefined, ) => { - toggleDirectoryExpansion([parentId], true) + toggleDirectoryExpansion([parentId], category.id, true) const placeholderItem = backendModule.createPlaceholderDatalinkAsset( name, parentId, @@ -961,7 +961,7 @@ export function useUploadFiles(backend: Backend, category: Category) { } if (duplicateFiles.length === 0 && duplicateProjects.length === 0) { - toggleDirectoryExpansion([parentId], true) + toggleDirectoryExpansion([parentId], category.id, true) const assets = [...files, ...projects].map(({ asset }) => asset) void Promise.all(assets.map((asset) => doUploadFile(asset, 'new'))) } else { @@ -1009,7 +1009,7 @@ export function useUploadFiles(backend: Backend, category: Category) { nonConflictingFileCount={files.length - conflictingFiles.length} nonConflictingProjectCount={projects.length - conflictingProjects.length} doUpdateConflicting={async (resolvedConflicts) => { - toggleDirectoryExpansion([parentId], true) + toggleDirectoryExpansion([parentId], category.id, true) await Promise.allSettled( resolvedConflicts.map((conflict) => { @@ -1021,7 +1021,7 @@ export function useUploadFiles(backend: Backend, category: Category) { ) }} doUploadNonConflicting={async () => { - toggleDirectoryExpansion([parentId], true) + toggleDirectoryExpansion([parentId], category.id, true) const newFiles = files .filter((file) => !siblingFileTitles.has(file.asset.title)) diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 04c9547d0f66..fd3679b03e1b 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -284,7 +284,6 @@ export interface AssetsTableState { readonly doDelete: (item: AnyAsset, forever: boolean) => Promise readonly doRestore: (item: AnyAsset) => Promise readonly doMove: (newParentKey: DirectoryId, item: AnyAsset) => Promise - readonly getAssetNodeById: (id: AssetId) => AnyAssetTreeNode | null } /** Data associated with a {@link AssetRow}, used for rendering. */ @@ -852,7 +851,7 @@ function AssetsTable(props: AssetsTableProps) { resetAssetPanelProps() } if (asset.type === AssetType.directory) { - toggleDirectoryExpansion([asset.id], false) + toggleDirectoryExpansion([asset.id], category.id, false) } if (asset.type === AssetType.project && backend.type === BackendType.local) { @@ -920,7 +919,7 @@ function AssetsTable(props: AssetsTableProps) { case AssetType.directory: { event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion([item.item.id]) + toggleDirectoryExpansion([item.item.id], category.id) break } case AssetType.project: { @@ -976,7 +975,7 @@ function AssetsTable(props: AssetsTableProps) { // The folder is expanded; collapse it. event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion([item.item.id], false) + toggleDirectoryExpansion([item.item.id], category.id, false) } else if (prevIndex != null) { // Focus parent if there is one. let index = prevIndex - 1 @@ -1001,7 +1000,7 @@ function AssetsTable(props: AssetsTableProps) { // The folder is collapsed; expand it. event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion([item.item.id], true) + toggleDirectoryExpansion([item.item.id], category.id, true) } break } @@ -1250,7 +1249,7 @@ function AssetsTable(props: AssetsTableProps) { if (pasteData.data.ids.has(newParentKey)) { toast.error('Cannot paste a folder into itself.') } else { - toggleDirectoryExpansion([newParentId], true) + toggleDirectoryExpansion([newParentId], category.id, true) if (pasteData.type === 'copy') { const assets = Array.from(pasteData.data.ids, (id) => nodeMapRef.current.get(id)).flatMap( (asset) => (asset ? [asset.item] : []), @@ -1327,10 +1326,6 @@ function AssetsTable(props: AssetsTableProps) { } } - const getAssetNodeById = useEventCallback( - (id: AssetId) => assetTree.preorderTraversal().find((node) => node.key === id) ?? null, - ) - const state = useMemo( // The type MUST be here to trigger excess property errors at typecheck time. () => ({ @@ -1350,7 +1345,6 @@ function AssetsTable(props: AssetsTableProps) { doDelete, doRestore, doMove, - getAssetNodeById, }), [ backend, @@ -1366,7 +1360,6 @@ function AssetsTable(props: AssetsTableProps) { doMove, hideColumn, setQuery, - getAssetNodeById, ], ) diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index 9269a7de06e2..e8e437405df2 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -11,7 +11,6 @@ import type { Category, CategoryId, } from '#/layouts/CategorySwitcher/Category' -import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import type { LaunchedProject } from '#/providers/ProjectsProvider' import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' @@ -373,14 +372,13 @@ export function useSetVisuallySelectedKeys() { /** Toggle whether a specific directory is expanded. */ export function useToggleDirectoryExpansion() { const driveStore = useDriveStore() - const { category } = useCategoriesAPI() const setExpandedDirectories = zustand.useStore( driveStore, (store) => store.setExpandedDirectories, ) return useEventCallback( - (ids: readonly DirectoryId[], override?: boolean, categoryId = category.id) => { + (ids: readonly DirectoryId[], categoryId: CategoryId, override?: boolean) => { const expandedDirectories = driveStore.getState().expandedDirectories const directories = expandedDirectories[categoryId] let count = 0 From 4602cc60aae20a9d74ae95b46a473067f64b2908 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 20:57:30 +1000 Subject: [PATCH 23/31] help --- app/gui/src/dashboard/test/testUtils.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/gui/src/dashboard/test/testUtils.tsx b/app/gui/src/dashboard/test/testUtils.tsx index f3e3e5e9c1d3..3e3cbe5e67dd 100644 --- a/app/gui/src/dashboard/test/testUtils.tsx +++ b/app/gui/src/dashboard/test/testUtils.tsx @@ -19,6 +19,7 @@ import { } from '@testing-library/react' import { createQueryClient } from 'enso-common/src/queryClient' import { useState, type PropsWithChildren, type ReactElement, type ReactNode } from 'react' +import invariant from 'tiny-invariant' /** * A wrapper that passes through its children. @@ -147,7 +148,8 @@ function renderHookWithRoot( hook: (props: Props) => Result, options?: Omit, 'queries'>, ): RenderHookWithRootResult { - let queryClient: QueryClient + let queryClient: QueryClient | undefined + console.log(':3') const result = renderHook(hook, { wrapper: ({ children }) => ( @@ -160,13 +162,9 @@ function renderHookWithRoot( ), ...options, }) + invariant(queryClient, 'QueryClient must exist') - return { - ...result, - // @ts-expect-error - This is safe because we render before returning the result, - // so the queryClient is guaranteed to be set. - queryClient, - } as const + return { ...result, queryClient } as const } /** From afdc27b628241e0f427db0ce975a95cc204bfebb Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 8 Jan 2025 23:25:46 +1000 Subject: [PATCH 24/31] WIP: Fix `DriveProvider.test` --- .../src/dashboard/providers/DriveProvider.tsx | 5 +- .../providers/__test__/DriveProvider.test.tsx | 84 +++++++++++-------- app/gui/src/dashboard/test/testUtils.tsx | 70 ++++++---------- 3 files changed, 73 insertions(+), 86 deletions(-) diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index e8e437405df2..29dce12559e2 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -112,10 +112,7 @@ async function computeInitialExpandedDirectories( } promises.push( backend.tryGetAssetAncestors(project, category.id).then((ancestors) => { - if (!ancestors) { - return - } - for (const ancestor of ancestors) { + for (const ancestor of ancestors ?? []) { set.add(ancestor) } }), diff --git a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx index b0770e1f7bd0..e2cf26afe4c8 100644 --- a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx +++ b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx @@ -1,5 +1,6 @@ +import { Suspense } from '#/components/Suspense' import { DirectoryId } from '#/services/Backend' -import { act, renderHook, type RenderHookOptions, type RenderHookResult } from '#/test' +import { act, renderHook, waitFor, type RenderHookOptions, type RenderHookResult } from '#/test' import { useState } from 'react' import { describe, expect, it } from 'vitest' import { useStore } from 'zustand' @@ -20,6 +21,7 @@ function renderDriveProviderHook( return { ...result, setCategoryId } }, { + ...options, wrapper: ({ children }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const [category, setCategory] = useState(() => currentCategoryId) @@ -28,51 +30,59 @@ function renderDriveProviderHook( setCategory(nextCategoryId) doResetAssetTableState() } + console.log('C2') return ( - - {({ resetAssetTableState }) => { - doResetAssetTableState = resetAssetTableState - return children - }} - + + + {({ resetAssetTableState }) => { + doResetAssetTableState = resetAssetTableState + console.log('C') + return children + }} + + ) }, - ...options, }, ) } -describe('', () => { - it('Should reset expanded directory ids when category changes', () => { - const driveAPI = renderDriveProviderHook((setCategoryId: (categoryId: CategoryId) => void) => { - const store = useDriveStore() - return useStore( - store, - ({ - toggleDirectoryExpansion, - expandedDirectories, - selectedKeys, - visuallySelectedKeys, - }) => ({ - expandedDirectories, - toggleDirectoryExpansion, - setCategoryId, - selectedKeys, - visuallySelectedKeys, - }), - ) - }) +describe('', async () => { + const driveAPI = renderDriveProviderHook((setCategoryId: (categoryId: CategoryId) => void) => { + const store = useDriveStore() + return useStore( + store, + ({ toggleDirectoryExpansion, expandedDirectories, selectedKeys, visuallySelectedKeys }) => ({ + expandedDirectories, + toggleDirectoryExpansion, + setCategoryId, + selectedKeys, + visuallySelectedKeys, + }), + ) + }) + + await waitFor(async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (driveAPI.result.current == null) { + await new Promise((resolve) => { + setTimeout(resolve, 1000) + }) + throw new Error() + } + }) + it('Should reset expanded directory ids when category changes', () => { act(() => { driveAPI.result.current.toggleDirectoryExpansion( [DirectoryId('directory-test-123')], diff --git a/app/gui/src/dashboard/test/testUtils.tsx b/app/gui/src/dashboard/test/testUtils.tsx index 3e3cbe5e67dd..5c227f7f7fe8 100644 --- a/app/gui/src/dashboard/test/testUtils.tsx +++ b/app/gui/src/dashboard/test/testUtils.tsx @@ -21,16 +21,12 @@ import { createQueryClient } from 'enso-common/src/queryClient' import { useState, type PropsWithChildren, type ReactElement, type ReactNode } from 'react' import invariant from 'tiny-invariant' -/** - * A wrapper that passes through its children. - */ +/** A wrapper that passes through its children. */ function PassThroughWrapper({ children }: PropsWithChildren) { return children } -/** - * A wrapper that provides the {@link UIProviders} context. - */ +/** A wrapper that provides the {@link UIProviders} context. */ function UIProvidersWrapper({ children, }: { @@ -47,25 +43,19 @@ function UIProvidersWrapper({ ) } -/** - * A wrapper that provides the {@link Form} context. - */ +/** A wrapper that provides the {@link Form} context. */ function FormWrapper( props: FormProps, ) { return
} -/** - * Result type for {@link renderWithRoot}. - */ +/** Result type for {@link renderWithRoot}. */ interface RenderWithRootResult extends RenderResult { readonly queryClient: QueryClient } -/** - * Custom render function for tests. - */ +/** Custom render function for tests. */ function renderWithRoot( ui: ReactElement, options?: Omit, @@ -75,6 +65,7 @@ function renderWithRoot( let queryClient: QueryClient const result = render(ui, { + ...rest, wrapper: ({ children }) => ( {({ queryClient: queryClientFromWrapper }) => { @@ -83,7 +74,6 @@ function renderWithRoot( }} ), - ...rest, }) return { @@ -94,16 +84,12 @@ function renderWithRoot( } as const } -/** - * Result type for {@link renderWithForm}. - */ +/** Result type for {@link renderWithForm}. */ interface RenderWithFormResult extends RenderWithRootResult { readonly form: FormInstance } -/** - * Adds a form wrapper to the component. - */ +/** Adds a form wrapper to the component. */ function renderWithForm( ui: ReactElement, options: Omit & { @@ -134,50 +120,44 @@ function renderWithForm( } as const } -/** - * Result type for {@link renderHookWithRoot}. - */ +/** Result type for {@link renderHookWithRoot}. */ interface RenderHookWithRootResult extends RenderHookResult { readonly queryClient: QueryClient } -/** - * A custom renderHook function for tests. - */ +/** A custom renderHook function for tests. */ function renderHookWithRoot( hook: (props: Props) => Result, options?: Omit, 'queries'>, ): RenderHookWithRootResult { let queryClient: QueryClient | undefined - console.log(':3') - const result = renderHook(hook, { - wrapper: ({ children }) => ( - - {({ queryClient: queryClientFromWrapper }) => { - queryClient = queryClientFromWrapper - return <>{children} - }} - - ), + const result = renderHook((props) => hook(props), { ...options, + wrapper: ({ children }) => { + children = options?.wrapper ? {children} : children + return ( + + {({ queryClient: queryClientFromWrapper }) => { + queryClient = queryClientFromWrapper + return <>{children} + }} + + ) + }, }) - invariant(queryClient, 'QueryClient must exist') + invariant(queryClient, '`QueryClient` must exist') return { ...result, queryClient } as const } -/** - * Result type for {@link renderHookWithForm}. - */ +/** Result type for {@link renderHookWithForm}. */ interface RenderHookWithFormResult extends RenderHookWithRootResult { readonly form: FormInstance } -/** - * A custom renderHook function for tests that provides the {@link Form} context. - */ +/** A custom renderHook function for tests that provides the {@link Form} context. */ function renderHookWithForm( hook: (props: Props) => Result, options: Omit, 'queries' | 'wrapper'> & { From 71a1b8c1207837342147844e130b6df529cb6c43 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 9 Jan 2025 01:00:32 +1000 Subject: [PATCH 25/31] Fix `DriveProvider.test` --- .../providers/__test__/DriveProvider.test.tsx | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx index e2cf26afe4c8..d6c5233a8c03 100644 --- a/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx +++ b/app/gui/src/dashboard/providers/__test__/DriveProvider.test.tsx @@ -2,6 +2,7 @@ import { Suspense } from '#/components/Suspense' import { DirectoryId } from '#/services/Backend' import { act, renderHook, waitFor, type RenderHookOptions, type RenderHookResult } from '#/test' import { useState } from 'react' +import invariant from 'tiny-invariant' import { describe, expect, it } from 'vitest' import { useStore } from 'zustand' import type { CategoryId } from '../../layouts/CategorySwitcher/Category' @@ -30,7 +31,6 @@ function renderDriveProviderHook( setCategory(nextCategoryId) doResetAssetTableState() } - console.log('C2') return ( @@ -46,7 +46,6 @@ function renderDriveProviderHook( > {({ resetAssetTableState }) => { doResetAssetTableState = resetAssetTableState - console.log('C') return children }} @@ -72,14 +71,9 @@ describe('', async () => { ) }) - await waitFor(async () => { + await waitFor(() => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (driveAPI.result.current == null) { - await new Promise((resolve) => { - setTimeout(resolve, 1000) - }) - throw new Error() - } + invariant(driveAPI.result.current != null) }) it('Should reset expanded directory ids when category changes', () => { @@ -91,20 +85,56 @@ describe('', async () => { ) }) - expect(driveAPI.result.current.expandedDirectories).toEqual([DirectoryId('directory-test-123')]) + expect(driveAPI.result.current.expandedDirectories.cloud).toEqual( + new Set(['directory-test-123']), + ) act(() => { - driveAPI.result.current.setCategoryId('recent') + driveAPI.result.current.setCategoryId('local') }) - expect(driveAPI.result.current.expandedDirectories).toEqual([]) + expect(driveAPI.result.current.expandedDirectories.local).toEqual(new Set()) expect(driveAPI.result.current.selectedKeys).toEqual(new Set()) expect(driveAPI.result.current.visuallySelectedKeys).toEqual(null) + act(() => { + driveAPI.result.current.toggleDirectoryExpansion( + [DirectoryId('directory-test-124')], + true, + 'local', + ) + }) + expect(driveAPI.result.current.expandedDirectories.local).toEqual( + new Set(['directory-test-124']), + ) + act(() => { // Set the category back to the default category (`cloud`). driveAPI.result.current.setCategoryId('cloud') }) - // The original expanded directories should be back. + // The original expanded directories should be retained. + expect(driveAPI.result.current.expandedDirectories.cloud).toEqual( + new Set(['directory-test-123']), + ) + act(() => { + driveAPI.result.current.toggleDirectoryExpansion( + // Allow removing extra directories + [DirectoryId('directory-test-123'), DirectoryId('directory-test-124')], + false, + 'cloud', + ) + }) + + act(() => { + driveAPI.result.current.setCategoryId('local') + }) + expect(driveAPI.result.current.expandedDirectories.local).toEqual( + new Set(['directory-test-124']), + ) + + act(() => { + driveAPI.result.current.setCategoryId('cloud') + }) + expect(driveAPI.result.current.expandedDirectories.cloud).toEqual(new Set()) }) }) From ffc6e15f508a255f55ffa9015d9fd862c416e8e6 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 9 Jan 2025 08:31:17 +1000 Subject: [PATCH 26/31] Fix type errors --- app/gui/src/dashboard/components/dashboard/AssetRow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 47de9045dd18..610b32fa659c 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -757,7 +757,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { } if (asset.type === backendModule.AssetType.directory) { dragOverTimeoutHandle.current = window.setTimeout(() => { - toggleDirectoryExpansion([asset.id], true) + toggleDirectoryExpansion([asset.id], category.id, true) }, DRAG_EXPAND_DELAY_MS) } // Required because `dragover` does not fire on `mouseenter`. @@ -805,7 +805,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { event.preventDefault() event.stopPropagation() unsetModal() - toggleDirectoryExpansion([directoryId], true) + toggleDirectoryExpansion([directoryId], category.id, true) const ids = payload .filter((payloadItem) => payloadItem.asset.parentId !== directoryId) .map((dragItem) => dragItem.key) @@ -818,7 +818,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { } else if (event.dataTransfer.types.includes('Files')) { event.preventDefault() event.stopPropagation() - toggleDirectoryExpansion([directoryId], true) + toggleDirectoryExpansion([directoryId], category.id, true) void uploadFiles(Array.from(event.dataTransfer.files), directoryId, null) } } From 25a05ca9071ed273016781771578501c6413077b Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 9 Jan 2025 10:44:42 +1000 Subject: [PATCH 27/31] Fix errors --- app/gui/src/dashboard/layouts/AssetsTable.tsx | 4 ---- app/gui/src/dashboard/services/LocalBackend.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 6ea2a19791ee..47d1a7ba77f4 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -1078,10 +1078,6 @@ function AssetsTable(props: AssetsTableProps) { } } - const getAssetNodeById = useEventCallback( - (id: AssetId) => assetTree.preorderTraversal().find((node) => node.item.id === id) ?? null, - ) - const hideColumn = useEventCallback((column: Column) => { setEnabledColumns((currentColumns) => withPresence(currentColumns, column, false)) }) diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index 0f68d92bbaf5..5cb8d3fccddc 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -321,7 +321,7 @@ export default class LocalBackend extends Backend { const { id, directory } = extractTypeAndId(projectId) const state = this.projectManager.projects.get(id) if (state == null) { - const parentsPath = directory ?? this.rootPath() + const parentsPath = directory const entries = await this.projectManager.listDirectory(parentsPath) const project = entries .flatMap((entry) => @@ -355,7 +355,7 @@ export default class LocalBackend extends Backend { } } else { const cachedProject = await state.data - const parentsPath = directory ?? this.rootPath() + const parentsPath = directory return { name: cachedProject.projectName, engineVersion: { From 571a60b7022c6c085bd61e02357f076ed8821864 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 9 Jan 2025 21:43:30 +1000 Subject: [PATCH 28/31] Fix integration tests --- .../integration-test/dashboard/actions/api.ts | 40 +++++++++++++------ .../dashboard/assetsTableFeatures.spec.ts | 8 +++- .../dashboard/hooks/backendBatchedHooks.ts | 1 + app/gui/src/dashboard/hooks/backendHooks.ts | 5 ++- .../layouts/Drive/assetTreeHooks.tsx | 25 +++++++----- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 8f12be78c4a3..348dd0018628 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -23,6 +23,16 @@ import invariant from 'tiny-invariant' // === Constants === // ================= +let lastTimestamp = 0 + +/** Return a new date that has not yet been returned. */ +function newUniqueDate(): Date { + const timestamp = Number(new Date()) + const newTimestamp = Math.max(timestamp, lastTimestamp + 1) + lastTimestamp = newTimestamp + return new Date(newTimestamp) +} + const __dirname = dirname(fileURLToPath(import.meta.url)) const MOCK_SVG = ` @@ -358,7 +368,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { projectState: null, extension: null, title, - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: dateTime.toRfc3339(newUniqueDate()), description: rest.description ?? '', labels: [], parentId, @@ -411,7 +421,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }, extension: null, title, - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: dateTime.toRfc3339(newUniqueDate()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, @@ -452,7 +462,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { projectState: null, extension: '', title: rest.title ?? '', - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: dateTime.toRfc3339(newUniqueDate()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, @@ -494,7 +504,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { projectState: null, extension: null, title: rest.title ?? '', - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: dateTime.toRfc3339(newUniqueDate()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, @@ -536,7 +546,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { projectState: null, extension: null, title: rest.title ?? '', - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: dateTime.toRfc3339(newUniqueDate()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, @@ -808,9 +818,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { break } } - filteredAssets.sort( - (a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type], - ) + filteredAssets.sort((a, b) => { + const order = backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type] + if (order !== 0) { + return order + } + return Number(new Date(b.modifiedAt)) - Number(new Date(a.modifiedAt)) + }) const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets } route.fulfill({ json }) @@ -875,14 +889,14 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { name: 'example project name', state: project.projectState, packageName: 'Project_root', - // eslint-disable-next-line camelcase - ide_version: null, - // eslint-disable-next-line camelcase - engine_version: { + ideVersion: null, + engineVersion: { value: '2023.2.1-nightly.2023.9.29', lifecycle: backend.VersionLifecycle.development, }, address: backend.Address('ws://localhost/'), + parentsPath: getParentPath(project.parentId), + virtualParentsPath: getVirtualParentPath(project.parentId, project.title), } satisfies backend.ProjectRaw }) @@ -1322,7 +1336,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: null, id, labels: [], - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: dateTime.toRfc3339(newUniqueDate()), parentId, permissions: [ { diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index e2506c04c0e1..0d5ed1a05358 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -130,13 +130,19 @@ test('can navigate to parent directory of an asset in the Recent category', ({ p api.addProject({ title: 'c', parentId: subDirectory.id }) }, }) + .driveTable.withRows(async (rows) => { + await test.expect(rows).toHaveText([/^d/, /^b/, /^a/]) + }) .driveTable.expandDirectory(0) .driveTable.expandDirectory(1) // Project in the nested directory (c) .driveTable.rightClickRow(2) .contextMenu.moveNonFolderToTrash() + .driveTable.withRows(async (rows) => { + await test.expect(rows).toHaveText([/^d/, /^e/, /^b/, /^a/]) + }) // Project in the root (a) - .driveTable.rightClickRow(2) + .driveTable.rightClickRow(3) .contextMenu.moveNonFolderToTrash() .goToCategory.trash() .driveTable.withPathColumnCell('a', async (cell) => { diff --git a/app/gui/src/dashboard/hooks/backendBatchedHooks.ts b/app/gui/src/dashboard/hooks/backendBatchedHooks.ts index 9fcb913ade89..3b988622718d 100644 --- a/app/gui/src/dashboard/hooks/backendBatchedHooks.ts +++ b/app/gui/src/dashboard/hooks/backendBatchedHooks.ts @@ -179,6 +179,7 @@ export function moveAssetsMutationOptions(backend: Backend) { [backend.type, 'listAssetVersions'], ], awaitInvalidates: true, + refetchType: 'all', }, }) } diff --git a/app/gui/src/dashboard/hooks/backendHooks.ts b/app/gui/src/dashboard/hooks/backendHooks.ts index 20158f867965..7d5ae895f0e0 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.ts +++ b/app/gui/src/dashboard/hooks/backendHooks.ts @@ -11,6 +11,7 @@ import { type MutationKey, type QueryClient, type QueryKey, + type UndefinedInitialDataOptions, type UnusedSkipTokenOptions, type UseMutationOptions, type UseQueryOptions, @@ -407,7 +408,7 @@ export interface ListDirectoryQueryOptions { export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { const { backend, parentId, categoryType } = options - return queryOptions({ + return { queryKey: [ backend.type, 'listDirectory', @@ -441,7 +442,7 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { } } }, - }) + } satisfies UndefinedInitialDataOptions[]> } /** The type of directory listings in the React Query cache. */ diff --git a/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx b/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx index 241f39cf169e..3936b942f11e 100644 --- a/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/assetTreeHooks.tsx @@ -47,14 +47,21 @@ export function useAssetTree(options: UseAssetTreeOptions) { 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({ + queries: expandedDirectoryIds.map((directoryId) => { + const queryOptions = listDirectoryQueryOptions({ backend, parentId: directoryId, categoryType: category.type, - }), - enabled: !hidden, - })), + }) + return { + ...queryOptions, + queryFn: async () => { + const children = await queryOptions.queryFn() + return { directoryId, children } + }, + enabled: !hidden, + } + }), combine: (results) => { const rootQuery = results[expandedDirectoryIds.indexOf(rootDirectory.id)] @@ -64,17 +71,17 @@ export function useAssetTree(options: UseAssetTreeOptions) { isLoading: rootQuery?.isLoading ?? true, isError: rootQuery?.isError ?? false, error: rootQuery?.error, - data: rootQuery?.data, + data: rootQuery?.data?.children, }, directories: new Map( - results.map((res, i) => [ - expandedDirectoryIds[i], + results.map((res) => [ + res.data?.directoryId, { isFetching: res.isFetching, isLoading: res.isLoading, isError: res.isError, error: res.error, - data: res.data, + data: res.data?.children, }, ]), ), From 6e6b5737bef31dd3a34af0f716f3008b9d1815ee Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 13 Jan 2025 23:43:27 +1000 Subject: [PATCH 29/31] Fix lint error --- app/ide-desktop/client/tasks/signArchivesMacOs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ide-desktop/client/tasks/signArchivesMacOs.ts b/app/ide-desktop/client/tasks/signArchivesMacOs.ts index db7753c1212c..86d3c2e9cf17 100644 --- a/app/ide-desktop/client/tasks/signArchivesMacOs.ts +++ b/app/ide-desktop/client/tasks/signArchivesMacOs.ts @@ -217,7 +217,7 @@ class ArchiveToSign implements Signable { const meta = 'META-INF/MANIFEST.MF' try { run('jar', ['-cfm', TEMPORARY_ARCHIVE_PATH, meta, '.'], workingDir) - } catch (err) { + } catch { run('jar', ['-cf', TEMPORARY_ARCHIVE_PATH, '.'], workingDir) } } else { From 41ae0836b52194270fd7ea73f8d79192a3395cd4 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 14 Jan 2025 19:48:59 +1000 Subject: [PATCH 30/31] Minor fixes --- app/common/src/appConfig.js | 2 +- app/common/src/detect.ts | 4 +--- .../integration-test/dashboard/assetsTableFeatures.spec.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/common/src/appConfig.js b/app/common/src/appConfig.js index 8dd7cef42a1e..c541c04f8c8f 100644 --- a/app/common/src/appConfig.js +++ b/app/common/src/appConfig.js @@ -124,7 +124,7 @@ export function getDefines() { } const DUMMY_DEFINES = { - 'process.env.NODE_ENV': 'production', + 'process.env.NODE_ENV': process.env.NODE_ENV ?? 'production', 'process.env.ENSO_CLOUD_ENVIRONMENT': 'production', 'process.env.ENSO_CLOUD_API_URL': 'https://mock', 'process.env.ENSO_CLOUD_SENTRY_DSN': diff --git a/app/common/src/detect.ts b/app/common/src/detect.ts index d2a4af76b34f..04c70cd1f3fd 100644 --- a/app/common/src/detect.ts +++ b/app/common/src/detect.ts @@ -122,9 +122,7 @@ export function browser(): Browser { } /** * Returns `true` if running in Electron, else `false`. - * This is used to determine whether to use a `MemoryRouter` (stores history in an array) - * or a `BrowserRouter` (stores history in the path of the URL). - * It is also used to determine whether to send custom state to Amplify for a workaround. + * It is used to determine whether to send custom state to Amplify for a workaround. */ export function isOnElectron() { return /electron/i.test(navigator.userAgent) diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index 0d5ed1a05358..0e72e3e5db6c 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -180,7 +180,7 @@ test("can't run a project in browser by default", ({ page }) => await expect(startProjectButton).toBeDisabled() })) -test("can't start an already running by another user", ({ page }) => +test("can't start a project already being run by another user", ({ page }) => mockAllAndLogin({ page, setupAPI: async (api) => { From 4c5aef3d52b4f7866b6855ebf9c963bf0fda5e55 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Sun, 19 Jan 2025 23:44:59 +1000 Subject: [PATCH 31/31] Fix integration tests --- app/gui/integration-test/dashboard/actions/api.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 348dd0018628..e13afb6df7a5 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -23,14 +23,13 @@ import invariant from 'tiny-invariant' // === Constants === // ================= -let lastTimestamp = 0 - /** Return a new date that has not yet been returned. */ function newUniqueDate(): Date { const timestamp = Number(new Date()) - const newTimestamp = Math.max(timestamp, lastTimestamp + 1) - lastTimestamp = newTimestamp - return new Date(newTimestamp) + while (Number(new Date()) <= timestamp) { + // Busy loop + } + return new Date() } const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -338,7 +337,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { ) const createUserGroupPermission = ( - userGroup: backend.UserGroupInfo, + userGroup: backend.UserGroup, permission: permissions.PermissionAction = permissions.PermissionAction.own, rest: Partial = {}, ): backend.UserGroupPermission =>