Wrap asset creation in a Promise (#11396)

- Close https://github.com/enso-org/cloud-v2/issues/1560
- Switch from "event"s to async functions so that new asset creation can be awaited

# Important Notes
None
This commit is contained in:
somebody1234 2024-11-21 02:44:11 +10:00 committed by GitHub
parent d8330c9bc5
commit f0a04b4e52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1598 additions and 1528 deletions

View File

@ -834,17 +834,9 @@ function createPlaceholderId(from?: string): string {
return id as string
}
/**
* Whether a given {@link AssetId} is a placeholder id.
*/
/** Whether a given {@link AssetId} is a placeholder id. */
export function isPlaceholderId(id: AssetId) {
if (typeof id === 'string') {
return false
}
console.log('isPlaceholderId id', id, PLACEHOLDER_SIGNATURE in id)
return PLACEHOLDER_SIGNATURE in id
return typeof id !== 'string' && PLACEHOLDER_SIGNATURE in id
}
/**
@ -900,7 +892,7 @@ export function createPlaceholderProjectAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
organization: User | null,
user: User | null,
path: Path | null,
): ProjectAsset {
return {
@ -913,7 +905,7 @@ export function createPlaceholderProjectAsset(
projectState: {
type: ProjectState.new,
volumeId: '',
...(organization != null ? { openedBy: organization.email } : {}),
...(user != null ? { openedBy: user.email } : {}),
...(path != null ? { path } : {}),
},
extension: null,
@ -924,6 +916,72 @@ export function createPlaceholderProjectAsset(
}
}
/** Creates a {@link DirectoryAsset} using the given values. */
export function createPlaceholderDirectoryAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
): DirectoryAsset {
return {
type: AssetType.directory,
id: DirectoryId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
}
/** Creates a {@link SecretAsset} using the given values. */
export function createPlaceholderSecretAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
): SecretAsset {
return {
type: AssetType.secret,
id: SecretId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
}
/** Creates a {@link DatalinkAsset} using the given values. */
export function createPlaceholderDatalinkAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
): DatalinkAsset {
return {
type: AssetType.datalink,
id: DatalinkId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
}
/**
* Creates a {@link SpecialLoadingAsset}, with all irrelevant fields initialized to default
* values.

View File

@ -12,7 +12,6 @@ import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import * as actions from './actions'
import invariant from 'tiny-invariant'
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
// =================
@ -170,12 +169,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.AssetType.directory,
id: backend.DirectoryId('directory-' + uniqueString.uniqueString()),
projectState: null,
extension: null,
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -192,12 +194,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.ProjectState.closed,
volumeId: '',
},
extension: null,
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -208,12 +213,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.AssetType.file,
id: backend.FileId('file-' + uniqueString.uniqueString()),
projectState: null,
extension: '',
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -227,12 +235,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.AssetType.secret,
id: backend.SecretId('secret-' + uniqueString.uniqueString()),
projectState: null,
extension: null,
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -571,23 +582,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
invariant(
project,
`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
if (!project) {
throw new Error(`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
Please make sure that you've created the project before opening it.
------------------------------------------------------------------------------------------------
Existing projects: ${Array.from(assetMap.values())
.filter((asset) => asset.type === backend.AssetType.project)
.map((asset) => asset.id)
.join(', ')}`,
)
invariant(
project.projectState,
`Attempting to get a project that does not have a state. Usually it is a bug in the application.
.join(', ')}`)
}
if (!project.projectState) {
throw new Error(`Attempting to get a project that does not have a state. Usually it is a bug in the application.
------------------------------------------------------------------------------------------------
Tried to get: \n ${JSON.stringify(project, null, 2)}`,
)
Tried to get: \n ${JSON.stringify(project, null, 2)}`)
}
return {
organizationId: defaultOrganizationId,
@ -635,7 +644,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const body: Body = request.postDataJSON()
const parentId = body.parentDirectoryId
// Can be any asset ID.
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`)
const json: backend.CopyAssetResponse = {
asset: {
id,
@ -681,10 +690,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const project = assetMap.get(projectId)
invariant(
project,
`Tried to open a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it.`,
)
if (!project) {
throw new Error(
`Tried to open a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it.`,
)
}
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened

View File

@ -95,6 +95,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
</div>
{...errors}
</div>,
...errors,
)
} else {
children.push(

View File

@ -9,7 +9,11 @@ import BlankIcon from '#/assets/blank.svg'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
import {
useDriveStore,
useSetSelectedKeys,
useToggleDirectoryExpansion,
} from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -36,6 +40,7 @@ import {
backendQueryOptions,
useAsset,
useBackendMutationState,
useUploadFiles,
} from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
@ -274,7 +279,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
const { initialAssetEvents } = props
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
const { doToggleDirectoryExpansion } = state
const driveStore = useDriveStore()
const queryClient = useQueryClient()
@ -304,6 +308,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
assetRowUtils.INITIAL_ROW_STATE,
)
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
const isEditingName = innerRowState.isEditingName || isNewlyCreated
@ -343,6 +348,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
const toastAndLog = useToastAndLog()
const uploadFiles = useUploadFiles(backend, category)
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
@ -707,7 +713,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
window.setTimeout(() => {
setSelected(false)
})
doToggleDirectoryExpansion(asset.id, asset.id)
toggleDirectoryExpansion(asset.id)
}
}}
onContextMenu={(event) => {
@ -752,7 +758,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
}
if (asset.type === backendModule.AssetType.directory) {
dragOverTimeoutHandle.current = window.setTimeout(() => {
doToggleDirectoryExpansion(asset.id, asset.id, true)
toggleDirectoryExpansion(asset.id, true)
}, DRAG_EXPAND_DELAY_MS)
}
// Required because `dragover` does not fire on `mouseenter`.
@ -800,7 +806,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
event.preventDefault()
event.stopPropagation()
unsetModal()
doToggleDirectoryExpansion(directoryId, directoryId, true)
toggleDirectoryExpansion(directoryId, true)
const ids = payload
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
.map((dragItem) => dragItem.key)
@ -813,13 +819,8 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
} else if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(directoryId, directoryId, true)
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: directoryId,
parentId: directoryId,
files: Array.from(event.dataTransfer.files),
})
toggleDirectoryExpansion(directoryId, true)
void uploadFiles(Array.from(event.dataTransfer.files), directoryId, null)
}
}
}}

View File

@ -6,7 +6,7 @@ import FolderArrowIcon from '#/assets/folder_arrow.svg'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { useDriveStore } from '#/providers/DriveProvider'
import { useDriveStore, useToggleDirectoryExpansion } from '#/providers/DriveProvider'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
@ -38,10 +38,11 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {
* This should never happen.
*/
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, selected, state, rowState, setRowState, isEditable, depth } = props
const { backend, nodeMap, doToggleDirectoryExpansion, expandedDirectoryIds } = state
const { item, depth, selected, state, rowState, setRowState, isEditable } = props
const { backend, nodeMap, expandedDirectoryIds } = state
const { getText } = textProvider.useText()
const driveStore = useDriveStore()
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const isExpanded = expandedDirectoryIds.includes(item.id)
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
@ -98,7 +99,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
isExpanded && 'rotate-90',
)}
onPress={() => {
doToggleDirectoryExpansion(item.id, item.id)
toggleDirectoryExpansion(item.id)
}}
/>
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />

View File

@ -2,13 +2,7 @@
/** Possible types of changes to the file list. */
enum AssetListEventType {
newFolder = 'new-folder',
newProject = 'new-project',
uploadFiles = 'upload-files',
newDatalink = 'new-datalink',
newSecret = 'new-secret',
duplicateProject = 'duplicate-project',
closeFolder = 'close-folder',
copy = 'copy',
move = 'move',
delete = 'delete',

View File

@ -20,13 +20,7 @@ interface AssetListBaseEvent<Type extends AssetListEventType> {
/** All possible events. */
interface AssetListEvents {
readonly newFolder: AssetListNewFolderEvent
readonly newProject: AssetListNewProjectEvent
readonly uploadFiles: AssetListUploadFilesEvent
readonly newSecret: AssetListNewSecretEvent
readonly newDatalink: AssetListNewDatalinkEvent
readonly duplicateProject: AssetListDuplicateProjectEvent
readonly closeFolder: AssetListCloseFolderEvent
readonly copy: AssetListCopyEvent
readonly move: AssetListMoveEvent
readonly delete: AssetListDeleteEvent
@ -45,46 +39,6 @@ type SanityCheck<
} = AssetListEvents,
> = [T]
/** A signal to create a new directory. */
interface AssetListNewFolderEvent extends AssetListBaseEvent<AssetListEventType.newFolder> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
}
/** A signal to create a new project. */
interface AssetListNewProjectEvent extends AssetListBaseEvent<AssetListEventType.newProject> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly templateId: string | null
readonly datalinkId: backend.DatalinkId | null
readonly preferredName: string | null
readonly onCreated?: (project: backend.CreatedProject, parentId: backend.DirectoryId) => void
readonly onError?: () => void
}
/** A signal to upload files. */
interface AssetListUploadFilesEvent extends AssetListBaseEvent<AssetListEventType.uploadFiles> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly files: File[]
}
/** A signal to create a new secret. */
interface AssetListNewDatalinkEvent extends AssetListBaseEvent<AssetListEventType.newDatalink> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly name: string
readonly value: unknown
}
/** A signal to create a new secret. */
interface AssetListNewSecretEvent extends AssetListBaseEvent<AssetListEventType.newSecret> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly name: string
readonly value: string
}
/** A signal to duplicate a project. */
interface AssetListDuplicateProjectEvent
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
@ -94,12 +48,6 @@ interface AssetListDuplicateProjectEvent
readonly versionId: backend.S3ObjectVersionId
}
/** A signal to close (collapse) a folder. */
interface AssetListCloseFolderEvent extends AssetListBaseEvent<AssetListEventType.closeFolder> {
readonly id: backend.DirectoryId
readonly key: backend.DirectoryId
}
/** A signal that files should be copied. */
interface AssetListCopyEvent extends AssetListBaseEvent<AssetListEventType.copy> {
readonly newParentKey: backend.DirectoryId

View File

@ -1,646 +0,0 @@
/** @file Hooks for interacting with the backend. */
import { useId, useMemo, useState } from 'react'
import {
queryOptions,
useMutation,
useMutationState,
useQuery,
type Mutation,
type MutationKey,
type UseMutationOptions,
type UseQueryOptions,
type UseQueryResult,
} from '@tanstack/react-query'
import { toast } from 'react-toastify'
import invariant from 'tiny-invariant'
import {
backendQueryOptions as backendQueryOptionsBase,
type BackendMethods,
} from 'enso-common/src/backendQuery'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useToastAndLog, useToastAndLogWithId } from '#/hooks/toastAndLogHooks'
import { CATEGORY_TO_FILTER_BY, type Category } from '#/layouts/CategorySwitcher/Category'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import {
AssetType,
BackendType,
type AssetId,
type DirectoryAsset,
type DirectoryId,
type User,
type UserGroupInfo,
} from '#/services/Backend'
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import { usePreventNavigation } from '#/utilities/preventNavigation'
import { toRfc3339 } from '../utilities/dateTime'
// The number of bytes in 1 megabyte.
const MB_BYTES = 1_000_000
const S3_CHUNK_SIZE_MB = Math.round(backendModule.S3_CHUNK_SIZE_BYTES / MB_BYTES)
// ============================
// === DefineBackendMethods ===
// ============================
/** Ensure that the given type contains only names of backend methods. */
type DefineBackendMethods<T extends keyof Backend> = T
// ======================
// === MutationMethod ===
// ======================
/** Names of methods corresponding to mutations. */
export type MutationMethod = DefineBackendMethods<
| 'acceptInvitation'
| 'associateTag'
| 'changeUserGroup'
| 'closeProject'
| 'copyAsset'
| 'createCheckoutSession'
| 'createDatalink'
| 'createDirectory'
| 'createPermission'
| 'createProject'
| 'createSecret'
| 'createTag'
| 'createUser'
| 'createUserGroup'
| 'declineInvitation'
| 'deleteAsset'
| 'deleteDatalink'
| 'deleteInvitation'
| 'deleteTag'
| 'deleteUser'
| 'deleteUserGroup'
| 'duplicateProject'
| 'inviteUser'
| 'logEvent'
| 'openProject'
| 'removeUser'
| 'resendInvitation'
| 'restoreUser'
| 'undoDeleteAsset'
| 'updateAsset'
| 'updateDirectory'
| 'updateFile'
| 'updateOrganization'
| 'updateProject'
| 'updateSecret'
| 'updateUser'
| 'uploadFileChunk'
| 'uploadFileEnd'
| 'uploadFileStart'
| 'uploadOrganizationPicture'
| 'uploadUserPicture'
>
// =======================
// === useBackendQuery ===
// =======================
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>> | undefined>
/** Wrap a backend method call in a React Query. */
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) {
// @ts-expect-error This call is generic over the presence or absence of `inputData`.
return queryOptions<Awaited<ReturnType<Backend[Method]>>>({
...options,
...backendQueryOptionsBase(backend, method, args, options?.queryKey),
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
queryFn: () => (backend?.[method] as any)?.(...args),
})
}
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<Awaited<ReturnType<Backend[Method]>> | undefined>
/** Wrap a backend method call in a React Query. */
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) {
return useQuery(backendQueryOptions(backend, method, args, options))
}
// ==========================
// === useBackendMutation ===
// ==========================
const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries')
const INVALIDATION_MAP: Partial<
Record<MutationMethod, readonly (BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
> = {
createUser: ['usersMe'],
updateUser: ['usersMe'],
deleteUser: ['usersMe'],
restoreUser: ['usersMe'],
uploadUserPicture: ['usersMe'],
updateOrganization: ['getOrganization'],
uploadOrganizationPicture: ['getOrganization'],
createUserGroup: ['listUserGroups'],
deleteUserGroup: ['listUserGroups'],
changeUserGroup: ['listUsers'],
createTag: ['listTags'],
deleteTag: ['listTags'],
associateTag: ['listDirectory'],
acceptInvitation: [INVALIDATE_ALL_QUERIES],
declineInvitation: ['usersMe'],
createProject: ['listDirectory'],
duplicateProject: ['listDirectory'],
createDirectory: ['listDirectory'],
createSecret: ['listDirectory'],
updateSecret: ['listDirectory'],
createDatalink: ['listDirectory', 'getDatalink'],
uploadFileEnd: ['listDirectory'],
copyAsset: ['listDirectory', 'listAssetVersions'],
deleteAsset: ['listDirectory', 'listAssetVersions'],
undoDeleteAsset: ['listDirectory'],
updateAsset: ['listDirectory', 'listAssetVersions'],
closeProject: ['listDirectory', 'listAssetVersions'],
updateDirectory: ['listDirectory'],
}
/** The type of the corresponding mutation for the given backend method. */
export type BackendMutation<Method extends MutationMethod> = Mutation<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<
Awaited<ReturnType<Backend[Method]>> | undefined,
Error,
Parameters<Backend[Method]>
>
/** Wrap a backend method call in a React Query Mutation. */
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>> {
return {
...options,
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])],
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
mutationFn: (args) => (backend?.[method] as any)?.(...args),
networkMode: backend?.type === BackendType.local ? 'always' : 'online',
meta: {
invalidates: [
...(options?.meta?.invalidates ?? []),
...(INVALIDATION_MAP[method]?.map((queryMethod) =>
queryMethod === INVALIDATE_ALL_QUERIES ? [backend?.type] : [backend?.type, queryMethod],
) ?? []),
],
awaitInvalidates: options?.meta?.awaitInvalidates ?? true,
},
}
}
// ==================================
// === useListUserGroupsWithUsers ===
// ==================================
/** A user group, as well as the users that are a part of the user group. */
export interface UserGroupInfoWithUsers extends UserGroupInfo {
readonly users: readonly User[]
}
/** A list of user groups, taking into account optimistic state. */
export function useListUserGroupsWithUsers(
backend: Backend,
): readonly UserGroupInfoWithUsers[] | null {
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
return useMemo(() => {
if (listUserGroupsQuery.data == null || listUsersQuery.data == null) {
return null
} else {
const result = listUserGroupsQuery.data.map((userGroup) => {
const usersInGroup: readonly User[] = listUsersQuery.data.filter((user) =>
user.userGroups?.includes(userGroup.id),
)
return { ...userGroup, users: usersInGroup }
})
return result
}
}, [listUserGroupsQuery.data, listUsersQuery.data])
}
/**
*
*/
export interface ListDirectoryQueryOptions {
readonly backend: Backend
readonly parentId: DirectoryId
readonly category: Category
}
/**
* Builds a query options object to fetch the children of a directory.
*/
export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) {
const { backend, parentId, category } = options
return queryOptions({
queryKey: [
backend.type,
'listDirectory',
parentId,
{
labels: null,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
recentProjects: category.type === 'recent',
},
] as const,
queryFn: async () => {
try {
return await backend.listDirectory(
{
parentId,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
labels: null,
recentProjects: category.type === 'recent',
},
parentId,
)
} catch {
throw Object.assign(new Error(), { parentId })
}
},
meta: { persist: false },
})
}
/**
* Upload progress for {@link useUploadFileMutation}.
*/
export interface UploadFileMutationProgress {
/**
* Whether this is the first progress update.
* Useful to determine whether to create a new toast or to update an existing toast.
*/
readonly event: 'begin' | 'chunk' | 'end'
readonly sentMb: number
readonly totalMb: number
}
/**
*
*/
export interface UseAssetOptions extends ListDirectoryQueryOptions {
readonly assetId: AssetId
}
/** Data for a specific asset. */
export function useAsset(options: UseAssetOptions) {
const { parentId, assetId } = options
const { data: asset } = useQuery({
...listDirectoryQueryOptions(options),
select: (data) => data.find((child) => child.id === assetId),
})
if (asset) {
return asset
}
const shared = {
parentId,
projectState: null,
extension: null,
description: '',
modifiedAt: toRfc3339(new Date()),
permissions: [],
labels: [],
parentsPath: '',
virtualParentsPath: '',
}
switch (true) {
case assetId === USERS_DIRECTORY_ID: {
return {
...shared,
id: assetId,
title: 'Users',
type: AssetType.directory,
} satisfies DirectoryAsset
}
case assetId === TEAMS_DIRECTORY_ID: {
return {
...shared,
id: assetId,
title: 'Teams',
type: AssetType.directory,
} satisfies DirectoryAsset
}
case backendModule.isLoadingAssetId(assetId): {
return {
...shared,
id: assetId,
title: '',
type: AssetType.specialLoading,
} satisfies backendModule.SpecialLoadingAsset
}
case backendModule.isEmptyAssetId(assetId): {
return {
...shared,
id: assetId,
title: '',
type: AssetType.specialEmpty,
} satisfies backendModule.SpecialEmptyAsset
}
case backendModule.isErrorAssetId(assetId): {
return {
...shared,
id: assetId,
title: '',
type: AssetType.specialError,
} satisfies backendModule.SpecialErrorAsset
}
default: {
return
}
}
}
/** Data for a specific asset. */
export function useAssetStrict(options: UseAssetOptions) {
const asset = useAsset(options)
invariant(
asset,
`Expected asset to be defined, but got undefined, Asset ID: ${JSON.stringify(options.assetId)}`,
)
return asset
}
/** Return matching in-flight mutations */
export function useBackendMutationState<Method extends MutationMethod, Result>(
backend: Backend,
method: Method,
options: {
mutationKey?: MutationKey
predicate?: (mutation: BackendMutation<Method>) => boolean
select?: (mutation: BackendMutation<Method>) => Result
} = {},
) {
const { mutationKey, predicate, select } = options
return useMutationState({
filters: {
...backendMutationOptions(backend, method, mutationKey ? { mutationKey } : {}),
predicate: (mutation: BackendMutation<Method>) =>
mutation.state.status === 'pending' && (predicate?.(mutation) ?? true),
},
// This is UNSAFE when the `Result` parameter is explicitly specified in the
// generic parameter list.
// eslint-disable-next-line no-restricted-syntax
select: select as (mutation: Mutation<unknown, Error, unknown, unknown>) => Result,
})
}
/** Options for {@link useUploadFileMutation}. */
export interface UploadFileMutationOptions {
/**
* Defaults to 3.
* Controls the default value of {@link UploadFileMutationOptions['chunkRetries']}
* and {@link UploadFileMutationOptions['endRetries']}.
*/
readonly retries?: number
/** Defaults to {@link UploadFileMutationOptions['retries']}. */
readonly chunkRetries?: number
/** Defaults to {@link UploadFileMutationOptions['retries']}. */
readonly endRetries?: number
/** Called for all progress updates (`onBegin`, `onChunkSuccess` and `onSuccess`). */
readonly onProgress?: (progress: UploadFileMutationProgress) => void
/** Called before any mutations are sent. */
readonly onBegin?: (progress: UploadFileMutationProgress) => void
/** Called after each successful chunk upload mutation. */
readonly onChunkSuccess?: (progress: UploadFileMutationProgress) => void
/** Called after the entire mutation succeeds. */
readonly onSuccess?: (progress: UploadFileMutationProgress) => void
/** Called after any mutations fail. */
readonly onError?: (error: unknown) => void
/** Called after `onSuccess` or `onError`, depending on whether the mutation succeeded. */
readonly onSettled?: (progress: UploadFileMutationProgress | null, error: unknown) => void
}
/**
* Call "upload file" mutations for a file.
* Always uses multipart upload for Cloud backend.
* Shows toasts to update progress.
*/
export function useUploadFileWithToastMutation(
backend: Backend,
options: UploadFileMutationOptions = {},
) {
const toastId = useId()
const { getText } = useText()
const toastAndLog = useToastAndLogWithId()
const { onBegin, onChunkSuccess, onSuccess, onError } = options
const mutation = useUploadFileMutation(backend, {
...options,
onBegin: (progress) => {
onBegin?.(progress)
const { sentMb, totalMb } = progress
toast.loading(getText('uploadLargeFileStatus', sentMb, totalMb), {
toastId,
position: 'bottom-right',
})
},
onChunkSuccess: (progress) => {
onChunkSuccess?.(progress)
const { sentMb, totalMb } = progress
const text = getText('uploadLargeFileStatus', sentMb, totalMb)
toast.update(toastId, { render: text })
},
onSuccess: (progress) => {
onSuccess?.(progress)
toast.update(toastId, {
type: 'success',
render: getText('uploadLargeFileSuccess'),
isLoading: false,
autoClose: null,
})
},
onError: (error) => {
onError?.(error)
toastAndLog(toastId, 'uploadLargeFileError', error)
},
})
usePreventNavigation({ message: getText('anUploadIsInProgress'), isEnabled: mutation.isPending })
return mutation
}
/**
* Call "upload file" mutations for a file.
* Always uses multipart upload for Cloud backend.
*/
export function useUploadFileMutation(backend: Backend, options: UploadFileMutationOptions = {}) {
const toastAndLog = useToastAndLog()
const {
retries = 3,
chunkRetries = retries,
endRetries = retries,
onError = (error) => {
toastAndLog('uploadLargeFileError', error)
},
} = options
const uploadFileStartMutation = useMutation(backendMutationOptions(backend, 'uploadFileStart'))
const uploadFileChunkMutation = useMutation(
backendMutationOptions(backend, 'uploadFileChunk', { retry: chunkRetries }),
)
const uploadFileEndMutation = useMutation(
backendMutationOptions(backend, 'uploadFileEnd', { retry: endRetries }),
)
const [variables, setVariables] =
useState<[params: backendModule.UploadFileRequestParams, file: File]>()
const [sentMb, setSentMb] = useState(0)
const [totalMb, setTotalMb] = useState(0)
const mutateAsync = useEventCallback(
async (body: backendModule.UploadFileRequestParams, file: File) => {
setVariables([body, file])
const fileSizeMb = Math.ceil(file.size / MB_BYTES)
options.onBegin?.({ event: 'begin', sentMb: 0, totalMb: fileSizeMb })
setSentMb(0)
setTotalMb(fileSizeMb)
try {
const { sourcePath, uploadId, presignedUrls } = await uploadFileStartMutation.mutateAsync([
body,
file,
])
const parts: backendModule.S3MultipartPart[] = []
for (const [url, i] of Array.from(
presignedUrls,
(presignedUrl, index) => [presignedUrl, index] as const,
)) {
parts.push(await uploadFileChunkMutation.mutateAsync([url, file, i]))
const newSentMb = Math.min((i + 1) * S3_CHUNK_SIZE_MB, fileSizeMb)
setSentMb(newSentMb)
options.onChunkSuccess?.({
event: 'chunk',
sentMb: newSentMb,
totalMb: fileSizeMb,
})
}
const result = await uploadFileEndMutation.mutateAsync([
{
parentDirectoryId: body.parentDirectoryId,
parts,
sourcePath: sourcePath,
uploadId: uploadId,
assetId: body.fileId,
fileName: body.fileName,
},
])
setSentMb(fileSizeMb)
const progress: UploadFileMutationProgress = {
event: 'end',
sentMb: fileSizeMb,
totalMb: fileSizeMb,
}
options.onSuccess?.(progress)
options.onSettled?.(progress, null)
return result
} catch (error) {
onError(error)
options.onSettled?.(null, error)
throw error
}
},
)
const mutate = useEventCallback((params: backendModule.UploadFileRequestParams, file: File) => {
void mutateAsync(params, file)
})
return {
sentMb,
totalMb,
variables,
mutate,
mutateAsync,
context: uploadFileEndMutation.context,
data: uploadFileEndMutation.data,
failureCount:
uploadFileEndMutation.failureCount +
uploadFileChunkMutation.failureCount +
uploadFileStartMutation.failureCount,
failureReason:
uploadFileEndMutation.failureReason ??
uploadFileChunkMutation.failureReason ??
uploadFileStartMutation.failureReason,
isError:
uploadFileStartMutation.isError ||
uploadFileChunkMutation.isError ||
uploadFileEndMutation.isError,
error:
uploadFileEndMutation.error ?? uploadFileChunkMutation.error ?? uploadFileStartMutation.error,
isPaused:
uploadFileStartMutation.isPaused ||
uploadFileChunkMutation.isPaused ||
uploadFileEndMutation.isPaused,
isPending:
uploadFileStartMutation.isPending ||
uploadFileChunkMutation.isPending ||
uploadFileEndMutation.isPending,
isSuccess: uploadFileEndMutation.isSuccess,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import * as backendModule from '#/services/Backend'
import * as localBackendModule from '#/services/LocalBackend'
import { useUploadFileWithToastMutation } from '#/hooks/backendHooks'
import { useNewProject, useUploadFileWithToastMutation } from '#/hooks/backendHooks'
import {
usePasteData,
useSetAssetPanelProps,
@ -105,6 +105,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const newProject = useNewProject(backend, category)
const systemApi = window.systemApi
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
@ -225,14 +227,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="useInNewProject"
doAction={() => {
dispatchAssetListEvent({
type: AssetListEventType.newProject,
parentId: asset.parentId,
parentKey: asset.parentId,
templateId: null,
datalinkId: asset.id,
preferredName: asset.title,
})
void newProject(
{ templateName: asset.title, datalinkId: asset.id },
asset.parentId,
path,
)
}}
/>
)}
@ -513,9 +512,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
<GlobalContextMenu
hidden={hidden}
backend={backend}
category={category}
rootDirectoryId={rootDirectoryId}
directoryKey={asset.id}
directoryId={asset.id}
path={path}
doPaste={doPaste}
/>
)}

View File

@ -16,15 +16,8 @@ import {
type SetStateAction,
} from 'react'
import {
useMutation,
useQueries,
useQuery,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-toastify'
import invariant from 'tiny-invariant'
import * as z from 'zod'
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
@ -61,7 +54,8 @@ import {
backendMutationOptions,
listDirectoryQueryOptions,
useBackendQuery,
useUploadFileWithToastMutation,
useRootDirectoryId,
useUploadFiles,
} from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useIntersectionRatio } from '#/hooks/intersectionHooks'
@ -76,7 +70,6 @@ import {
type Category,
} from '#/layouts/CategorySwitcher/Category'
import DragModal from '#/modals/DragModal'
import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import {
@ -86,6 +79,7 @@ import {
} from '#/providers/BackendProvider'
import {
useDriveStore,
useExpandedDirectoryIds,
useResetAssetPanelProps,
useSetAssetPanelProps,
useSetCanCreateAssets,
@ -97,10 +91,11 @@ import {
useSetSuggestions,
useSetTargetDirectory,
useSetVisuallySelectedKeys,
useToggleDirectoryExpansion,
} from '#/providers/DriveProvider'
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { useLocalStorage, useLocalStorageState } from '#/providers/LocalStorageProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useNavigator2D } from '#/providers/Navigator2DProvider'
import { useLaunchedProjects } from '#/providers/ProjectsProvider'
@ -108,39 +103,24 @@ import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import {
assetIsDirectory,
assetIsFile,
assetIsProject,
AssetType,
BackendType,
createPlaceholderAssetId,
createPlaceholderFileAsset,
createPlaceholderProjectAsset,
createRootDirectoryAsset,
createSpecialEmptyAsset,
createSpecialErrorAsset,
createSpecialLoadingAsset,
DatalinkId,
DirectoryId,
escapeSpecialCharacters,
extractProjectExtension,
fileIsNotProject,
fileIsProject,
getAssetPermissionName,
Path,
Plan,
ProjectId,
ProjectState,
SecretId,
stripProjectExtension,
type AnyAsset,
type AssetId,
type DatalinkAsset,
type DirectoryAsset,
type DirectoryId,
type LabelName,
type ProjectAsset,
type SecretAsset,
} from '#/services/Backend'
import LocalBackend from '#/services/LocalBackend'
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import type { AssetQueryKey } from '#/utilities/AssetQuery'
@ -302,11 +282,6 @@ export interface AssetsTableState {
readonly setQuery: Dispatch<SetStateAction<AssetQuery>>
readonly nodeMap: Readonly<MutableRefObject<ReadonlyMap<AssetId, AnyAssetTreeNode>>>
readonly hideColumn: (column: Column) => void
readonly doToggleDirectoryExpansion: (
directoryId: DirectoryId,
key: DirectoryId,
override?: boolean,
) => void
readonly doCopy: () => void
readonly doCut: () => void
readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => void
@ -380,24 +355,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const { data: users } = useBackendQuery(backend, 'listUsers', [])
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
const organizationQuery = useSuspenseQuery({
queryKey: [backend.type, 'getOrganization'],
queryFn: () => backend.getOrganization(),
})
const organization = organizationQuery.data
const nameOfProjectToImmediatelyOpenRef = useRef(initialProjectName)
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
const rootDirectoryId = useMemo(() => {
const localRootPath = localRootDirectory != null ? Path(localRootDirectory) : null
const id =
'homeDirectoryId' in category ?
category.homeDirectoryId
: backend.rootDirectoryId(user, organization, localRootPath)
invariant(id, 'Missing root directory')
return id
}, [category, backend, user, organization, localRootDirectory])
const rootDirectoryId = useRootDirectoryId(backend, category)
const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
@ -405,16 +365,12 @@ export default function AssetsTable(props: AssetsTableProps) {
const assetsTableBackgroundRefreshInterval = useFeatureFlag(
'assetsTableBackgroundRefreshInterval',
)
/**
* The expanded directories in the asset tree.
* We don't include the root directory 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, setExpandedDirectoryIds] = useState<DirectoryId[]>(() => [])
const expandedDirectoryIdsRaw = useExpandedDirectoryIds()
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const expandedDirectoryIds = useMemo(
() => [rootDirectoryId].concat(privateExpandedDirectoryIds),
[privateExpandedDirectoryIds, rootDirectoryId],
() => [rootDirectoryId].concat(expandedDirectoryIdsRaw),
[expandedDirectoryIdsRaw, rootDirectoryId],
)
const expandedDirectoryIdsSet = useMemo(
@ -422,13 +378,9 @@ export default function AssetsTable(props: AssetsTableProps) {
[expandedDirectoryIds],
)
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
const uploadFiles = useUploadFiles(backend, category)
const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory'))
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const uploadFileMutation = useUploadFileWithToastMutation(backend)
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
@ -1193,28 +1145,6 @@ export default function AssetsTable(props: AssetsTableProps) {
[driveStore, resetAssetPanelProps, setIsAssetPanelTemporarilyVisible],
)
const doToggleDirectoryExpansion = useEventCallback(
(directoryId: DirectoryId, _key: DirectoryId, override?: boolean) => {
const isExpanded = expandedDirectoryIdsSet.has(directoryId)
const shouldExpand = override ?? !isExpanded
if (shouldExpand !== isExpanded) {
startTransition(() => {
if (shouldExpand) {
setExpandedDirectoryIds((currentExpandedDirectoryIds) => [
...currentExpandedDirectoryIds,
directoryId,
])
} else {
setExpandedDirectoryIds((currentExpandedDirectoryIds) =>
currentExpandedDirectoryIds.filter((id) => id !== directoryId),
)
}
})
}
},
)
const doCopyOnBackend = useEventCallback(
async (newParentId: DirectoryId | null, asset: AnyAsset) => {
try {
@ -1252,11 +1182,7 @@ export default function AssetsTable(props: AssetsTableProps) {
resetAssetPanelProps()
}
if (asset.type === AssetType.directory) {
dispatchAssetListEvent({
type: AssetListEventType.closeFolder,
id: asset.id,
key: asset.id,
})
toggleDirectoryExpansion(asset.id, false)
}
try {
if (asset.type === AssetType.project && backend.type === BackendType.local) {
@ -1326,7 +1252,7 @@ export default function AssetsTable(props: AssetsTableProps) {
case AssetType.directory: {
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(item.item.id, item.key)
toggleDirectoryExpansion(item.item.id)
break
}
case AssetType.project: {
@ -1378,7 +1304,7 @@ export default function AssetsTable(props: AssetsTableProps) {
// The folder is expanded; collapse it.
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(item.item.id, item.key, false)
toggleDirectoryExpansion(item.item.id, false)
} else if (prevIndex != null) {
// Focus parent if there is one.
let index = prevIndex - 1
@ -1403,7 +1329,7 @@ export default function AssetsTable(props: AssetsTableProps) {
// The folder is collapsed; expand it.
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(item.item.id, item.key, true)
toggleDirectoryExpansion(item.item.id, true)
}
break
}
@ -1493,23 +1419,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [setMostRecentlySelectedIndex])
const getNewProjectName = useEventCallback(
(templateName: string | null, parentKey: DirectoryId | null) => {
const prefix = `${templateName ?? 'New Project'} `
const projectNameTemplate = new RegExp(`^${prefix}(?<projectIndex>\\d+)$`)
const siblings =
parentKey == null ?
assetTree.children ?? []
: nodeMapRef.current.get(parentKey)?.children ?? []
const projectIndices = siblings
.map((node) => node.item)
.filter(assetIsProject)
.map((item) => projectNameTemplate.exec(item.title)?.groups?.projectIndex)
.map((maybeIndex) => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
return `${prefix}${Math.max(0, ...projectIndices) + 1}`
},
)
const deleteAsset = useEventCallback((assetId: AssetId) => {
const asset = nodeMapRef.current.get(assetId)?.item
@ -1529,379 +1438,6 @@ export default function AssetsTable(props: AssetsTableProps) {
const onAssetListEvent = useEventCallback((event: AssetListEvent) => {
switch (event.type) {
case AssetListEventType.newFolder: {
const parent = nodeMapRef.current.get(event.parentKey)
const siblings = parent?.children ?? []
const directoryIndices = siblings
.map((node) => node.item)
.filter(assetIsDirectory)
.map((item) => /^New Folder (?<directoryIndex>\d+)$/.exec(item.title))
.map((match) => match?.groups?.directoryIndex)
.map((maybeIndex) => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
const title = `New Folder ${Math.max(0, ...directoryIndices) + 1}`
const placeholderItem: DirectoryAsset = {
type: AssetType.directory,
id: DirectoryId(uniqueString()),
title,
modifiedAt: toRfc3339(new Date()),
parentId: event.parentId,
permissions: tryCreateOwnerPermission(
`${parent?.path ?? ''}/${title}`,
category,
user,
users ?? [],
userGroups ?? [],
),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
void createDirectoryMutation
.mutateAsync([{ parentId: placeholderItem.parentId, title: placeholderItem.title }])
.then(({ id }) => {
setNewestFolderId(id)
setSelectedKeys(new Set([id]))
})
break
}
case AssetListEventType.newProject: {
const parent = nodeMapRef.current.get(event.parentKey)
const projectName = getNewProjectName(event.preferredName, event.parentId)
const dummyId = createPlaceholderAssetId(AssetType.project)
const path =
backend instanceof LocalBackend ? backend.joinPath(event.parentId, projectName) : null
const placeholderItem: ProjectAsset = {
type: AssetType.project,
id: dummyId,
title: projectName,
modifiedAt: toRfc3339(new Date()),
parentId: event.parentId,
permissions: tryCreateOwnerPermission(
`${parent?.path ?? ''}/${projectName}`,
category,
user,
users ?? [],
userGroups ?? [],
),
projectState: {
type: ProjectState.placeholder,
volumeId: '',
openedBy: user.email,
...(path != null ? { path } : {}),
},
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
void createProjectMutation
.mutateAsync([
{
parentDirectoryId: placeholderItem.parentId,
projectName: placeholderItem.title,
...(event.templateId == null ? {} : { projectTemplateName: event.templateId }),
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
},
])
.catch((error) => {
event.onError?.()
deleteAsset(placeholderItem.id)
toastAndLog('createProjectError', error)
throw error
})
.then((createdProject) => {
event.onCreated?.(createdProject, placeholderItem.parentId)
doOpenProject({
id: createdProject.projectId,
type: backend.type,
parentId: placeholderItem.parentId,
title: placeholderItem.title,
})
})
break
}
case AssetListEventType.uploadFiles: {
const localBackend = backend instanceof LocalBackend ? backend : null
const reversedFiles = Array.from(event.files).reverse()
const parent = nodeMapRef.current.get(event.parentKey)
const siblingNodes = parent?.children ?? []
const siblings = siblingNodes.map((node) => node.item)
const siblingFiles = siblings.filter(assetIsFile)
const siblingProjects = siblings.filter(assetIsProject)
const siblingFileTitles = new Set(siblingFiles.map((asset) => asset.title))
const siblingProjectTitles = new Set(siblingProjects.map((asset) => asset.title))
const ownerPermission = tryCreateOwnerPermission(
parent?.path ?? '',
category,
user,
users ?? [],
userGroups ?? [],
)
const files = reversedFiles.filter(fileIsNotProject).map((file) => {
const asset = createPlaceholderFileAsset(
escapeSpecialCharacters(file.name),
event.parentId,
ownerPermission,
)
return { asset, file }
})
const projects = reversedFiles.filter(fileIsProject).map((file) => {
const basename = escapeSpecialCharacters(stripProjectExtension(file.name))
const asset = createPlaceholderProjectAsset(
basename,
event.parentId,
ownerPermission,
user,
localBackend?.joinPath(event.parentId, basename) ?? null,
)
return { asset, file }
})
const duplicateFiles = files.filter((file) => siblingFileTitles.has(file.asset.title))
const duplicateProjects = projects.filter((project) =>
siblingProjectTitles.has(stripProjectExtension(project.asset.title)),
)
const fileMap = new Map<AssetId, File>([
...files.map(({ asset, file }) => [asset.id, file] as const),
...projects.map(({ asset, file }) => [asset.id, file] as const),
])
const uploadedFileIds: AssetId[] = []
const addIdToSelection = (id: AssetId) => {
uploadedFileIds.push(id)
const newIds = new Set(uploadedFileIds)
setSelectedKeys(newIds)
}
const doUploadFile = async (asset: AnyAsset, method: 'new' | 'update') => {
const file = fileMap.get(asset.id)
if (file != null) {
const fileId = method === 'new' ? null : asset.id
switch (true) {
case assetIsProject(asset): {
const { extension } = extractProjectExtension(file.name)
const title = escapeSpecialCharacters(stripProjectExtension(asset.title))
await uploadFileMutation
.mutateAsync(
{
fileId,
fileName: `${title}.${extension}`,
parentDirectoryId: asset.parentId,
},
file,
)
.then(({ id }) => {
addIdToSelection(id)
})
.catch((error) => {
toastAndLog('uploadProjectError', error)
})
break
}
case assetIsFile(asset): {
const title = escapeSpecialCharacters(asset.title)
await uploadFileMutation
.mutateAsync({ fileId, fileName: title, parentDirectoryId: asset.parentId }, file)
.then(({ id }) => {
addIdToSelection(id)
})
break
}
default:
break
}
}
}
if (duplicateFiles.length === 0 && duplicateProjects.length === 0) {
const assets = [...files, ...projects].map(({ asset }) => asset)
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
void Promise.all(assets.map((asset) => doUploadFile(asset, 'new')))
} else {
const siblingFilesByName = new Map(siblingFiles.map((file) => [file.title, file]))
const siblingProjectsByName = new Map(
siblingProjects.map((project) => [project.title, project]),
)
const conflictingFiles = duplicateFiles.map((file) => ({
// This is SAFE, as `duplicateFiles` only contains files that have siblings
// with the same name.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
current: siblingFilesByName.get(file.asset.title)!,
new: createPlaceholderFileAsset(file.asset.title, event.parentId, ownerPermission),
file: file.file,
}))
const conflictingProjects = duplicateProjects.map((project) => {
const basename = stripProjectExtension(project.asset.title)
return {
// This is SAFE, as `duplicateProjects` only contains projects that have
// siblings with the same name.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
current: siblingProjectsByName.get(basename)!,
new: createPlaceholderProjectAsset(
basename,
event.parentId,
ownerPermission,
user,
localBackend?.joinPath(event.parentId, basename) ?? null,
),
file: project.file,
}
})
setModal(
<DuplicateAssetsModal
parentKey={event.parentKey}
parentId={event.parentId}
conflictingFiles={conflictingFiles}
conflictingProjects={conflictingProjects}
siblingFileNames={siblingFilesByName.keys()}
siblingProjectNames={siblingProjectsByName.keys()}
nonConflictingFileCount={files.length - conflictingFiles.length}
nonConflictingProjectCount={projects.length - conflictingProjects.length}
doUpdateConflicting={async (resolvedConflicts) => {
await Promise.allSettled(
resolvedConflicts.map((conflict) => {
const isUpdating = conflict.current.title === conflict.new.title
const asset = isUpdating ? conflict.current : conflict.new
fileMap.set(asset.id, conflict.file)
return doUploadFile(asset, isUpdating ? 'update' : 'new')
}),
)
}}
doUploadNonConflicting={async () => {
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
const newFiles = files
.filter((file) => !siblingFileTitles.has(file.asset.title))
.map((file) => {
const asset = createPlaceholderFileAsset(
file.asset.title,
event.parentId,
ownerPermission,
)
fileMap.set(asset.id, file.file)
return asset
})
const newProjects = projects
.filter(
(project) =>
!siblingProjectTitles.has(stripProjectExtension(project.asset.title)),
)
.map((project) => {
const basename = stripProjectExtension(project.asset.title)
const asset = createPlaceholderProjectAsset(
basename,
event.parentId,
ownerPermission,
user,
localBackend?.joinPath(event.parentId, basename) ?? null,
)
fileMap.set(asset.id, project.file)
return asset
})
const assets = [...newFiles, ...newProjects]
await Promise.allSettled(assets.map((asset) => doUploadFile(asset, 'new')))
}}
/>,
)
}
break
}
case AssetListEventType.newDatalink: {
const parent = nodeMapRef.current.get(event.parentKey)
const placeholderItem: DatalinkAsset = {
type: AssetType.datalink,
id: DatalinkId(uniqueString()),
title: event.name,
modifiedAt: toRfc3339(new Date()),
parentId: event.parentId,
permissions: tryCreateOwnerPermission(
`${parent?.path ?? ''}/${event.name}`,
category,
user,
users ?? [],
userGroups ?? [],
),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
createDatalinkMutation.mutate([
{
parentDirectoryId: placeholderItem.parentId,
datalinkId: null,
name: placeholderItem.title,
value: event.value,
},
])
break
}
case AssetListEventType.newSecret: {
const parent = nodeMapRef.current.get(event.parentKey)
const placeholderItem: SecretAsset = {
type: AssetType.secret,
id: SecretId(uniqueString()),
title: event.name,
modifiedAt: toRfc3339(new Date()),
parentId: event.parentId,
permissions: tryCreateOwnerPermission(
`${parent?.path ?? ''}/${event.name}`,
category,
user,
users ?? [],
userGroups ?? [],
),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
createSecretMutation.mutate([
{
parentDirectoryId: placeholderItem.parentId,
name: placeholderItem.title,
value: event.value,
},
])
break
}
case AssetListEventType.duplicateProject: {
const parent = nodeMapRef.current.get(event.parentKey)
const siblings = parent?.children ?? []
@ -1998,10 +1534,6 @@ export default function AssetsTable(props: AssetsTableProps) {
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: event.id })
break
}
case AssetListEventType.closeFolder: {
doToggleDirectoryExpansion(event.id, event.key, false)
break
}
}
})
@ -2050,7 +1582,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (pasteData.data.ids.has(newParentKey)) {
toast.error('Cannot paste a folder into itself.')
} else {
doToggleDirectoryExpansion(newParentId, newParentKey, 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] : []),
@ -2124,12 +1656,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event.dataTransfer.files),
})
void uploadFiles(Array.from(event.dataTransfer.files), rootDirectoryId, rootDirectoryId)
}
}
@ -2147,7 +1674,6 @@ export default function AssetsTable(props: AssetsTableProps) {
setQuery,
nodeMap: nodeMapRef,
hideColumn,
doToggleDirectoryExpansion,
doCopy,
doCut,
doPaste,
@ -2162,7 +1688,6 @@ export default function AssetsTable(props: AssetsTableProps) {
category,
sortInfo,
query,
doToggleDirectoryExpansion,
doCopy,
doCut,
doPaste,
@ -2702,12 +2227,7 @@ export default function AssetsTable(props: AssetsTableProps) {
>
<FileTrigger
onSelect={(event) => {
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event ?? []),
})
void uploadFiles(Array.from(event ?? []), rootDirectoryId, rootDirectoryId)
}}
>
<Button

View File

@ -245,9 +245,11 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
<GlobalContextMenu
hidden={hidden}
backend={backend}
category={category}
rootDirectoryId={rootDirectoryId}
directoryKey={null}
directoryId={null}
path={null}
doPaste={doPaste}
/>
)}

View File

@ -1,8 +1,6 @@
/** @file The directory header bar and directory item listing. */
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as appUtils from '#/appUtils'
import * as offlineHooks from '#/hooks/offlineHooks'
@ -26,16 +24,10 @@ import Labels from '#/layouts/Labels'
import * as ariaComponents from '#/components/AriaComponents'
import * as result from '#/components/Result'
import * as backendModule from '#/services/Backend'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useDriveStore } from '#/providers/DriveProvider'
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
import AssetQuery from '#/utilities/AssetQuery'
import * as download from '#/utilities/download'
import * as github from '#/utilities/github'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useSuspenseQuery } from '@tanstack/react-query'
// =============
// === Drive ===
@ -62,27 +54,6 @@ export default function Drive(props: DriveProps) {
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const organizationQuery = useSuspenseQuery({
queryKey: [backend.type, 'getOrganization'],
queryFn: () => backend.getOrganization(),
})
const organization = organizationQuery.data ?? null
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
const rootDirectoryId = React.useMemo(() => {
switch (category.type) {
case 'user':
case 'team': {
return category.homeDirectoryId
}
default: {
const localRootPath =
localRootDirectory != null ? backendModule.Path(localRootDirectory) : null
const id = backend.rootDirectoryId(user, organization, localRootPath)
invariant(id, 'Missing root directory')
return id
}
}
}, [category, backend, user, organization, localRootDirectory])
const isCloud = categoryModule.isCloudCategory(category)
const supportLocalBackend = localBackend != null
@ -91,82 +62,10 @@ export default function Drive(props: DriveProps) {
: isCloud && !user.isEnabled ? 'not-enabled'
: 'ok'
const driveStore = useDriveStore()
const getTargetDirectory = React.useCallback(
() => driveStore.getState().targetDirectory,
[driveStore],
)
const doUploadFiles = useEventCallback((files: File[]) => {
if (isCloud && isOffline) {
// This should never happen, however display a nice error message in case it does.
toastAndLog('offlineUploadFilesError')
} else {
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
files,
})
}
})
const doEmptyTrash = React.useCallback(() => {
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
}, [dispatchAssetListEvent])
const doCreateProject = useEventCallback(
(
templateId: string | null = null,
templateName: string | null = null,
onCreated?: (
project: backendModule.CreatedProject,
parentId: backendModule.DirectoryId,
) => void,
onError?: () => void,
) => {
dispatchAssetListEvent({
type: AssetListEventType.newProject,
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
templateId,
datalinkId: null,
preferredName: templateName,
...(onCreated ? { onCreated } : {}),
...(onError ? { onError } : {}),
})
},
)
const doCreateDirectory = useEventCallback(() => {
dispatchAssetListEvent({
type: AssetListEventType.newFolder,
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
})
})
const doCreateSecret = useEventCallback((name: string, value: string) => {
dispatchAssetListEvent({
type: AssetListEventType.newSecret,
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
name,
value,
})
})
const doCreateDatalink = useEventCallback((name: string, value: unknown) => {
dispatchAssetListEvent({
type: AssetListEventType.newDatalink,
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
name,
value,
})
})
switch (status) {
case 'not-enabled': {
return (
@ -216,11 +115,6 @@ export default function Drive(props: DriveProps) {
setQuery={setQuery}
category={category}
doEmptyTrash={doEmptyTrash}
doCreateProject={doCreateProject}
doUploadFiles={doUploadFiles}
doCreateDirectory={doCreateDirectory}
doCreateSecret={doCreateSecret}
doCreateDatalink={doCreateDatalink}
/>
<div className="flex flex-1 gap-drive overflow-hidden">

View File

@ -4,7 +4,7 @@
*/
import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import { useMutation } from '@tanstack/react-query'
import AddDatalinkIcon from '#/assets/add_datalink.svg'
import AddFolderIcon from '#/assets/add_folder.svg'
@ -12,7 +12,6 @@ import AddKeyIcon from '#/assets/add_key.svg'
import DataDownloadIcon from '#/assets/data_download.svg'
import DataUploadIcon from '#/assets/data_upload.svg'
import Plus2Icon from '#/assets/plus2.svg'
import { Input as AriaInput } from '#/components/aria'
import {
Button,
ButtonGroup,
@ -21,8 +20,16 @@ import {
useVisualTooltip,
} from '#/components/AriaComponents'
import AssetEventType from '#/events/AssetEventType'
import {
useNewDatalink,
useNewFolder,
useNewProject,
useNewSecret,
useRootDirectoryId,
useUploadFiles,
} from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useOffline } from '#/hooks/offlineHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
import AssetSearchBar from '#/layouts/AssetSearchBar'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
@ -35,13 +42,16 @@ import StartModal from '#/layouts/StartModal'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useCanCreateAssets, useCanDownload, usePasteData } from '#/providers/DriveProvider'
import {
useCanCreateAssets,
useCanDownload,
useDriveStore,
usePasteData,
} from '#/providers/DriveProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import type { DirectoryId } from '#/services/Backend'
import { ProjectState, type CreatedProject, type ProjectId } from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery'
import { inputFiles } from '#/utilities/input'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
@ -58,16 +68,6 @@ export interface DriveBarProps {
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly category: Category
readonly doEmptyTrash: () => void
readonly doCreateProject: (
templateId?: string | null,
templateName?: string | null,
onCreated?: (project: CreatedProject, parentId: DirectoryId) => void,
onError?: () => void,
) => void
readonly doCreateDirectory: () => void
readonly doCreateSecret: (name: string, value: string) => void
readonly doCreateDatalink: (name: string, value: unknown) => void
readonly doUploadFiles: (files: File[]) => void
}
/**
@ -75,9 +75,7 @@ export interface DriveBarProps {
* and a column display mode switcher.
*/
export default function DriveBar(props: DriveBarProps) {
const { backend, query, setQuery, category } = props
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
const { backend, query, setQuery, category, doEmptyTrash } = props
const [startModalDefaultOpen, , resetStartModalDefaultOpen] = useSearchParamsState(
'startModalDefaultOpen',
@ -86,11 +84,11 @@ export default function DriveBar(props: DriveBarProps) {
const { unsetModal } = useSetModal()
const { getText } = useText()
const driveStore = useDriveStore()
const inputBindings = useInputBindings()
const dispatchAssetEvent = useDispatchAssetEvent()
const canCreateAssets = useCanCreateAssets()
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const isCloud = isCloudCategory(category)
const { isOffline } = useOffline()
const canDownload = useCanDownload()
@ -105,12 +103,6 @@ export default function DriveBar(props: DriveBarProps) {
targetRef: createAssetButtonsRef,
overlayPositionProps: { placement: 'top' },
})
const [isCreatingProjectFromTemplate, setIsCreatingProjectFromTemplate] = React.useState(false)
const [isCreatingProject, setIsCreatingProject] = React.useState(false)
const [createdProjectId, setCreatedProjectId] = React.useState<{
projectId: ProjectId
parentId: DirectoryId
} | null>(null)
const pasteData = usePasteData()
const effectivePasteData =
(
@ -120,60 +112,64 @@ export default function DriveBar(props: DriveBarProps) {
pasteData
: null
const getTargetDirectory = useEventCallback(() => driveStore.getState().targetDirectory)
const rootDirectoryId = useRootDirectoryId(backend, category)
const newFolderRaw = useNewFolder(backend, category)
const newFolder = useEventCallback(async () => {
const parent = getTargetDirectory()
return await newFolderRaw(parent?.directoryId ?? rootDirectoryId, parent?.path)
})
const uploadFilesRaw = useUploadFiles(backend, category)
const uploadFiles = useEventCallback(async (files: readonly File[]) => {
const parent = getTargetDirectory()
await uploadFilesRaw(files, parent?.directoryId ?? rootDirectoryId, parent?.path)
})
const newSecretRaw = useNewSecret(backend, category)
const newSecret = useEventCallback(async (name: string, value: string) => {
const parent = getTargetDirectory()
return await newSecretRaw(name, value, parent?.directoryId ?? rootDirectoryId, parent?.path)
})
const newDatalinkRaw = useNewDatalink(backend, category)
const newDatalink = useEventCallback(async (name: string, value: unknown) => {
const parent = getTargetDirectory()
return await newDatalinkRaw(name, value, parent?.directoryId ?? rootDirectoryId, parent?.path)
})
const newProjectRaw = useNewProject(backend, category)
const newProjectMutation = useMutation({
mutationKey: ['newProject'],
mutationFn: async ([templateId, templateName]: [
templateId: string | null | undefined,
templateName: string | null | undefined,
]) => {
const parent = getTargetDirectory()
return await newProjectRaw(
{ templateName, templateId },
parent?.directoryId ?? rootDirectoryId,
parent?.path,
)
},
})
const newProject = newProjectMutation.mutateAsync
const isCreatingProject = newProjectMutation.isPending
React.useEffect(() => {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
...(isCloud ?
{
newFolder: () => {
doCreateDirectory()
void newFolder()
},
}
: {}),
newProject: () => {
setIsCreatingProject(true)
doCreateProject(
null,
null,
(project, parentId) => {
setCreatedProjectId({ projectId: project.projectId, parentId })
},
() => {
setIsCreatingProject(false)
},
)
void newProject([null, null])
},
uploadFiles: () => {
uploadFilesRef.current?.click()
void inputFiles().then((files) => uploadFiles(Array.from(files)))
},
})
}, [isCloud, doCreateDirectory, doCreateProject, inputBindings])
const createdProjectQuery = useQuery({
...createGetProjectDetailsQuery({
// This is safe because we disable the query when `createdProjectId` is `null`.
// see `enabled` property below.
// eslint-disable-next-line no-restricted-syntax
assetId: createdProjectId?.projectId as ProjectId,
// eslint-disable-next-line no-restricted-syntax
parentId: createdProjectId?.parentId as DirectoryId,
backend,
}),
enabled: createdProjectId != null,
})
const isFetching =
(createdProjectQuery.isLoading ||
(createdProjectQuery.data &&
createdProjectQuery.data.state.type !== ProjectState.opened &&
createdProjectQuery.data.state.type !== ProjectState.closing)) ??
false
React.useEffect(() => {
if (!isFetching) {
setIsCreatingProject(false)
setIsCreatingProjectFromTemplate(false)
}
}, [isFetching])
}, [inputBindings, isCloud, newFolder, newProject, uploadFiles])
const searchBar = (
<AssetSearchBar backend={backend} isCloud={isCloud} query={query} setQuery={setQuery} />
@ -246,9 +242,8 @@ export default function DriveBar(props: DriveBarProps) {
<Button
size="medium"
variant="accent"
isDisabled={shouldBeDisabled || isCreatingProject || isCreatingProjectFromTemplate}
isDisabled={shouldBeDisabled || isCreatingProject}
icon={Plus2Icon}
loading={isCreatingProjectFromTemplate}
loaderPosition="icon"
>
{getText('startWithATemplate')}
@ -256,40 +251,18 @@ export default function DriveBar(props: DriveBarProps) {
<StartModal
createProject={(templateId, templateName) => {
setIsCreatingProjectFromTemplate(true)
doCreateProject(
templateId,
templateName,
({ projectId }, parentId) => {
setCreatedProjectId({ projectId, parentId })
},
() => {
setIsCreatingProjectFromTemplate(false)
},
)
void newProject([templateId, templateName])
}}
/>
</DialogTrigger>
<Button
size="medium"
variant="outline"
isDisabled={shouldBeDisabled || isCreatingProject || isCreatingProjectFromTemplate}
isDisabled={shouldBeDisabled || isCreatingProject}
icon={Plus2Icon}
loading={isCreatingProject}
loaderPosition="icon"
onPress={() => {
setIsCreatingProject(true)
doCreateProject(
null,
null,
({ projectId }, parentId) => {
setCreatedProjectId({ projectId, parentId })
setIsCreatingProject(false)
},
() => {
setIsCreatingProject(false)
},
)
onPress={async () => {
await newProject([null, null])
}}
>
{getText('newEmptyProject')}
@ -301,8 +274,8 @@ export default function DriveBar(props: DriveBarProps) {
icon={AddFolderIcon}
isDisabled={shouldBeDisabled}
aria-label={getText('newFolder')}
onPress={() => {
doCreateDirectory()
onPress={async () => {
await newFolder()
}}
/>
{isCloud && (
@ -314,7 +287,13 @@ export default function DriveBar(props: DriveBarProps) {
isDisabled={shouldBeDisabled}
aria-label={getText('newSecret')}
/>
<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />
<UpsertSecretModal
id={null}
name={null}
doCreate={async (name, value) => {
await newSecret(name, value)
}}
/>
</DialogTrigger>
)}
@ -327,23 +306,13 @@ export default function DriveBar(props: DriveBarProps) {
isDisabled={shouldBeDisabled}
aria-label={getText('newDatalink')}
/>
<UpsertDatalinkModal doCreate={doCreateDatalink} />
<UpsertDatalinkModal
doCreate={async (name, value) => {
await newDatalink(name, value)
}}
/>
</DialogTrigger>
)}
<AriaInput
ref={uploadFilesRef}
type="file"
multiple
className="hidden"
onInput={(event) => {
if (event.currentTarget.files != null) {
doUploadFiles(Array.from(event.currentTarget.files))
}
// Clear the list of selected files, otherwise `onInput` will not be
// dispatched again if the same file is selected.
event.currentTarget.value = ''
}}
/>
<Button
variant="icon"
size="medium"
@ -352,7 +321,7 @@ export default function DriveBar(props: DriveBarProps) {
aria-label={getText('uploadFiles')}
onPress={async () => {
const files = await inputFiles()
doUploadFiles(Array.from(files))
await uploadFiles(Array.from(files))
}}
/>
<Button

View File

@ -1,34 +1,38 @@
/** @file A context menu available everywhere in the directory. */
import { useStore } from 'zustand'
import AssetListEventType from '#/events/AssetListEventType'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useDispatchAssetListEvent } from '#/layouts/AssetsTable/EventListProvider'
import {
useNewDatalink,
useNewFolder,
useNewProject,
useNewSecret,
useUploadFiles,
} from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import { useDriveStore } from '#/providers/DriveProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
import { BackendType } from '#/services/Backend'
import { BackendType, type DirectoryId } from '#/services/Backend'
import { inputFiles } from '#/utilities/input'
/** Props for a {@link GlobalContextMenu}. */
export interface GlobalContextMenuProps {
readonly hidden?: boolean
readonly backend: Backend
readonly rootDirectoryId: backendModule.DirectoryId
readonly directoryKey: backendModule.DirectoryId | null
readonly directoryId: backendModule.DirectoryId | null
readonly doPaste: (
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId,
) => void
readonly category: Category
readonly rootDirectoryId: DirectoryId
readonly directoryKey: DirectoryId | null
readonly directoryId: DirectoryId | null
readonly path: string | null
readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => void
}
/** A context menu available everywhere in the directory. */
@ -40,15 +44,17 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
const {
hidden = false,
backend,
category,
directoryKey = null,
directoryId = null,
path,
rootDirectoryId,
} = props
const { doPaste } = props
const { getText } = useText()
const { setModal, unsetModal } = useSetModal()
const dispatchAssetListEvent = useDispatchAssetListEvent()
const isCloud = backend.type === BackendType.remote
const driveStore = useDriveStore()
const hasPasteData = useStore(
@ -56,7 +62,28 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
(storeState) => (storeState.pasteData?.data.ids.size ?? 0) > 0,
)
const isCloud = backend.type === BackendType.remote
const newFolderRaw = useNewFolder(backend, category)
const newFolder = useEventCallback(async () => {
return await newFolderRaw(directoryId ?? rootDirectoryId, path)
})
const newSecretRaw = useNewSecret(backend, category)
const newSecret = useEventCallback(async (name: string, value: string) => {
return await newSecretRaw(name, value, directoryId ?? rootDirectoryId, path)
})
const newProjectRaw = useNewProject(backend, category)
const newProject = useEventCallback(
async (templateId: string | null | undefined, templateName: string | null | undefined) => {
return await newProjectRaw({ templateName, templateId }, directoryId ?? rootDirectoryId, path)
},
)
const newDatalinkRaw = useNewDatalink(backend, category)
const newDatalink = useEventCallback(async (name: string, value: unknown) => {
return await newDatalinkRaw(name, value, directoryId ?? rootDirectoryId, path)
})
const uploadFilesRaw = useUploadFiles(backend, category)
const uploadFiles = useEventCallback(async (files: readonly File[]) => {
await uploadFilesRaw(files, directoryId ?? rootDirectoryId, path)
})
return (
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
@ -65,12 +92,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
action="uploadFiles"
doAction={async () => {
const files = await inputFiles()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: directoryKey ?? rootDirectoryId,
parentId: directoryId ?? rootDirectoryId,
files: Array.from(files),
})
await uploadFiles(Array.from(files))
}}
/>
<ContextMenuEntry
@ -78,14 +100,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
action="newProject"
doAction={() => {
unsetModal()
dispatchAssetListEvent({
type: AssetListEventType.newProject,
parentKey: directoryKey ?? rootDirectoryId,
parentId: directoryId ?? rootDirectoryId,
templateId: null,
datalinkId: null,
preferredName: null,
})
void newProject(null, null)
}}
/>
<ContextMenuEntry
@ -93,11 +108,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
action="newFolder"
doAction={() => {
unsetModal()
dispatchAssetListEvent({
type: AssetListEventType.newFolder,
parentKey: directoryKey ?? rootDirectoryId,
parentId: directoryId ?? rootDirectoryId,
})
void newFolder()
}}
/>
{isCloud && (
@ -109,14 +120,8 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
<UpsertSecretModal
id={null}
name={null}
doCreate={(name, value) => {
dispatchAssetListEvent({
type: AssetListEventType.newSecret,
parentKey: directoryKey ?? rootDirectoryId,
parentId: directoryId ?? rootDirectoryId,
name,
value,
})
doCreate={async (name, value) => {
await newSecret(name, value)
}}
/>,
)
@ -130,14 +135,8 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
doAction={() => {
setModal(
<UpsertDatalinkModal
doCreate={(name, value) => {
dispatchAssetListEvent({
type: AssetListEventType.newDatalink,
parentKey: directoryKey ?? rootDirectoryId,
parentId: directoryId ?? rootDirectoryId,
name,
value,
})
doCreate={async (name, value) => {
await newDatalink(name, value)
}}
/>,
)

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as zustand from '#/utilities/zustand'
import invariant from 'tiny-invariant'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type { AssetPanelContextProps } from '#/layouts/AssetPanel'
import type { Suggestion } from '#/layouts/AssetSearchBar'
import type { Category } from '#/layouts/CategorySwitcher/Category'
@ -18,7 +19,6 @@ import type {
DirectoryId,
} from 'enso-common/src/services/Backend'
import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array'
import { useEventCallback } from '../hooks/eventCallbackHooks'
// ==================
// === DriveStore ===
@ -45,6 +45,8 @@ interface DriveStore {
readonly setCanDownload: (canDownload: boolean) => void
readonly pasteData: PasteData<DrivePastePayload> | null
readonly setPasteData: (pasteData: PasteData<DrivePastePayload> | null) => void
readonly expandedDirectoryIds: readonly DirectoryId[]
readonly setExpandedDirectoryIds: (selectedKeys: readonly DirectoryId[]) => void
readonly selectedKeys: ReadonlySet<AssetId>
readonly setSelectedKeys: (selectedKeys: ReadonlySet<AssetId>) => void
readonly visuallySelectedKeys: ReadonlySet<AssetId> | null
@ -137,6 +139,12 @@ export default function DriveProvider(props: ProjectsProviderProps) {
set({ pasteData })
}
},
expandedDirectoryIds: EMPTY_ARRAY,
setExpandedDirectoryIds: (expandedDirectoryIds) => {
if (get().expandedDirectoryIds !== expandedDirectoryIds) {
set({ expandedDirectoryIds })
}
},
selectedKeys: EMPTY_SET,
setSelectedKeys: (selectedKeys) => {
if (get().selectedKeys !== selectedKeys) {
@ -299,13 +307,25 @@ export function useSetPasteData() {
return zustand.useStore(store, (state) => state.setPasteData)
}
/** The expanded directories in the Asset Table. */
export function useExpandedDirectoryIds() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.expandedDirectoryIds)
}
/** A function to set the expanded directoyIds in the Asset Table. */
export function useSetExpandedDirectoryIds() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setExpandedDirectoryIds)
}
/** The selected keys in the Asset Table. */
export function useSelectedKeys() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.selectedKeys)
}
/** A function to set the selected keys of the Asset Table selection. */
/** A function to set the selected keys in the Asset Table. */
export function useSetSelectedKeys() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setSelectedKeys)
@ -482,3 +502,25 @@ export function useSetIsAssetPanelHidden() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setIsAssetPanelHidden)
}
/** Toggle whether a specific directory is expanded. */
export function useToggleDirectoryExpansion() {
const driveStore = useDriveStore()
const setExpandedDirectoryIds = useSetExpandedDirectoryIds()
return useEventCallback((directoryId: DirectoryId, override?: boolean) => {
const expandedDirectoryIds = driveStore.getState().expandedDirectoryIds
const isExpanded = expandedDirectoryIds.includes(directoryId)
const shouldExpand = override ?? !isExpanded
if (shouldExpand !== isExpanded) {
React.startTransition(() => {
if (shouldExpand) {
setExpandedDirectoryIds([...expandedDirectoryIds, directoryId])
} else {
setExpandedDirectoryIds(expandedDirectoryIds.filter((id) => id !== directoryId))
}
})
}
})
}