mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 11:52:59 +03:00
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:
parent
d8330c9bc5
commit
f0a04b4e52
@ -834,17 +834,9 @@ function createPlaceholderId(from?: string): string {
|
|||||||
return id as 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) {
|
export function isPlaceholderId(id: AssetId) {
|
||||||
if (typeof id === 'string') {
|
return typeof id !== 'string' && PLACEHOLDER_SIGNATURE in id
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('isPlaceholderId id', id, PLACEHOLDER_SIGNATURE in id)
|
|
||||||
|
|
||||||
return PLACEHOLDER_SIGNATURE in id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -900,7 +892,7 @@ export function createPlaceholderProjectAsset(
|
|||||||
title: string,
|
title: string,
|
||||||
parentId: DirectoryId,
|
parentId: DirectoryId,
|
||||||
assetPermissions: readonly AssetPermission[],
|
assetPermissions: readonly AssetPermission[],
|
||||||
organization: User | null,
|
user: User | null,
|
||||||
path: Path | null,
|
path: Path | null,
|
||||||
): ProjectAsset {
|
): ProjectAsset {
|
||||||
return {
|
return {
|
||||||
@ -913,7 +905,7 @@ export function createPlaceholderProjectAsset(
|
|||||||
projectState: {
|
projectState: {
|
||||||
type: ProjectState.new,
|
type: ProjectState.new,
|
||||||
volumeId: '',
|
volumeId: '',
|
||||||
...(organization != null ? { openedBy: organization.email } : {}),
|
...(user != null ? { openedBy: user.email } : {}),
|
||||||
...(path != null ? { path } : {}),
|
...(path != null ? { path } : {}),
|
||||||
},
|
},
|
||||||
extension: null,
|
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
|
* Creates a {@link SpecialLoadingAsset}, with all irrelevant fields initialized to default
|
||||||
* values.
|
* values.
|
||||||
|
@ -12,7 +12,6 @@ import * as uniqueString from 'enso-common/src/utilities/uniqueString'
|
|||||||
|
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
|
|
||||||
import invariant from 'tiny-invariant'
|
|
||||||
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
|
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@ -170,12 +169,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
type: backend.AssetType.directory,
|
type: backend.AssetType.directory,
|
||||||
id: backend.DirectoryId('directory-' + uniqueString.uniqueString()),
|
id: backend.DirectoryId('directory-' + uniqueString.uniqueString()),
|
||||||
projectState: null,
|
projectState: null,
|
||||||
|
extension: null,
|
||||||
title,
|
title,
|
||||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||||
description: null,
|
description: null,
|
||||||
labels: [],
|
labels: [],
|
||||||
parentId: defaultDirectoryId,
|
parentId: defaultDirectoryId,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
parentsPath: '',
|
||||||
|
virtualParentsPath: '',
|
||||||
},
|
},
|
||||||
rest,
|
rest,
|
||||||
)
|
)
|
||||||
@ -192,12 +194,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
type: backend.ProjectState.closed,
|
type: backend.ProjectState.closed,
|
||||||
volumeId: '',
|
volumeId: '',
|
||||||
},
|
},
|
||||||
|
extension: null,
|
||||||
title,
|
title,
|
||||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||||
description: null,
|
description: null,
|
||||||
labels: [],
|
labels: [],
|
||||||
parentId: defaultDirectoryId,
|
parentId: defaultDirectoryId,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
parentsPath: '',
|
||||||
|
virtualParentsPath: '',
|
||||||
},
|
},
|
||||||
rest,
|
rest,
|
||||||
)
|
)
|
||||||
@ -208,12 +213,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
type: backend.AssetType.file,
|
type: backend.AssetType.file,
|
||||||
id: backend.FileId('file-' + uniqueString.uniqueString()),
|
id: backend.FileId('file-' + uniqueString.uniqueString()),
|
||||||
projectState: null,
|
projectState: null,
|
||||||
|
extension: '',
|
||||||
title,
|
title,
|
||||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||||
description: null,
|
description: null,
|
||||||
labels: [],
|
labels: [],
|
||||||
parentId: defaultDirectoryId,
|
parentId: defaultDirectoryId,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
parentsPath: '',
|
||||||
|
virtualParentsPath: '',
|
||||||
},
|
},
|
||||||
rest,
|
rest,
|
||||||
)
|
)
|
||||||
@ -227,12 +235,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
type: backend.AssetType.secret,
|
type: backend.AssetType.secret,
|
||||||
id: backend.SecretId('secret-' + uniqueString.uniqueString()),
|
id: backend.SecretId('secret-' + uniqueString.uniqueString()),
|
||||||
projectState: null,
|
projectState: null,
|
||||||
|
extension: null,
|
||||||
title,
|
title,
|
||||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||||
description: null,
|
description: null,
|
||||||
labels: [],
|
labels: [],
|
||||||
parentId: defaultDirectoryId,
|
parentId: defaultDirectoryId,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
parentsPath: '',
|
||||||
|
virtualParentsPath: '',
|
||||||
},
|
},
|
||||||
rest,
|
rest,
|
||||||
)
|
)
|
||||||
@ -571,23 +582,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
|
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
|
||||||
const project = assetMap.get(projectId)
|
const project = assetMap.get(projectId)
|
||||||
|
|
||||||
invariant(
|
if (!project) {
|
||||||
project,
|
throw new Error(`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
|
||||||
`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.
|
Please make sure that you've created the project before opening it.
|
||||||
------------------------------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
Existing projects: ${Array.from(assetMap.values())
|
Existing projects: ${Array.from(assetMap.values())
|
||||||
.filter((asset) => asset.type === backend.AssetType.project)
|
.filter((asset) => asset.type === backend.AssetType.project)
|
||||||
.map((asset) => asset.id)
|
.map((asset) => asset.id)
|
||||||
.join(', ')}`,
|
.join(', ')}`)
|
||||||
)
|
}
|
||||||
invariant(
|
if (!project.projectState) {
|
||||||
project.projectState,
|
throw new Error(`Attempting to get a project that does not have a state. Usually it is a bug in the application.
|
||||||
`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 {
|
return {
|
||||||
organizationId: defaultOrganizationId,
|
organizationId: defaultOrganizationId,
|
||||||
@ -635,7 +644,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
const body: Body = request.postDataJSON()
|
const body: Body = request.postDataJSON()
|
||||||
const parentId = body.parentDirectoryId
|
const parentId = body.parentDirectoryId
|
||||||
// Can be any asset ID.
|
// 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 = {
|
const json: backend.CopyAssetResponse = {
|
||||||
asset: {
|
asset: {
|
||||||
id,
|
id,
|
||||||
@ -681,10 +690,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
|
|
||||||
const project = assetMap.get(projectId)
|
const project = assetMap.get(projectId)
|
||||||
|
|
||||||
invariant(
|
if (!project) {
|
||||||
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.`,
|
`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) {
|
if (project?.projectState) {
|
||||||
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened
|
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened
|
||||||
|
@ -95,6 +95,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
{...errors}
|
{...errors}
|
||||||
</div>,
|
</div>,
|
||||||
|
...errors,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
children.push(
|
children.push(
|
||||||
|
@ -9,7 +9,11 @@ import BlankIcon from '#/assets/blank.svg'
|
|||||||
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
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 modalProvider from '#/providers/ModalProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
@ -36,6 +40,7 @@ import {
|
|||||||
backendQueryOptions,
|
backendQueryOptions,
|
||||||
useAsset,
|
useAsset,
|
||||||
useBackendMutationState,
|
useBackendMutationState,
|
||||||
|
useUploadFiles,
|
||||||
} from '#/hooks/backendHooks'
|
} from '#/hooks/backendHooks'
|
||||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||||
@ -274,7 +279,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
|||||||
const { initialAssetEvents } = props
|
const { initialAssetEvents } = props
|
||||||
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
|
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
|
||||||
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
|
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
|
||||||
const { doToggleDirectoryExpansion } = state
|
|
||||||
|
|
||||||
const driveStore = useDriveStore()
|
const driveStore = useDriveStore()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@ -304,6 +308,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
|||||||
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
|
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
|
||||||
assetRowUtils.INITIAL_ROW_STATE,
|
assetRowUtils.INITIAL_ROW_STATE,
|
||||||
)
|
)
|
||||||
|
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
|
||||||
|
|
||||||
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
|
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
|
||||||
const isEditingName = innerRowState.isEditingName || isNewlyCreated
|
const isEditingName = innerRowState.isEditingName || isNewlyCreated
|
||||||
@ -343,6 +348,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
|||||||
|
|
||||||
const toastAndLog = useToastAndLog()
|
const toastAndLog = useToastAndLog()
|
||||||
|
|
||||||
|
const uploadFiles = useUploadFiles(backend, category)
|
||||||
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
|
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
|
||||||
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
||||||
|
|
||||||
@ -707,7 +713,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setSelected(false)
|
setSelected(false)
|
||||||
})
|
})
|
||||||
doToggleDirectoryExpansion(asset.id, asset.id)
|
toggleDirectoryExpansion(asset.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
@ -752,7 +758,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
|||||||
}
|
}
|
||||||
if (asset.type === backendModule.AssetType.directory) {
|
if (asset.type === backendModule.AssetType.directory) {
|
||||||
dragOverTimeoutHandle.current = window.setTimeout(() => {
|
dragOverTimeoutHandle.current = window.setTimeout(() => {
|
||||||
doToggleDirectoryExpansion(asset.id, asset.id, true)
|
toggleDirectoryExpansion(asset.id, true)
|
||||||
}, DRAG_EXPAND_DELAY_MS)
|
}, DRAG_EXPAND_DELAY_MS)
|
||||||
}
|
}
|
||||||
// Required because `dragover` does not fire on `mouseenter`.
|
// Required because `dragover` does not fire on `mouseenter`.
|
||||||
@ -800,7 +806,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
unsetModal()
|
unsetModal()
|
||||||
doToggleDirectoryExpansion(directoryId, directoryId, true)
|
toggleDirectoryExpansion(directoryId, true)
|
||||||
const ids = payload
|
const ids = payload
|
||||||
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
|
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
|
||||||
.map((dragItem) => dragItem.key)
|
.map((dragItem) => dragItem.key)
|
||||||
@ -813,13 +819,8 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
|||||||
} else if (event.dataTransfer.types.includes('Files')) {
|
} else if (event.dataTransfer.types.includes('Files')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
doToggleDirectoryExpansion(directoryId, directoryId, true)
|
toggleDirectoryExpansion(directoryId, true)
|
||||||
dispatchAssetListEvent({
|
void uploadFiles(Array.from(event.dataTransfer.files), directoryId, null)
|
||||||
type: AssetListEventType.uploadFiles,
|
|
||||||
parentKey: directoryId,
|
|
||||||
parentId: directoryId,
|
|
||||||
files: Array.from(event.dataTransfer.files),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -6,7 +6,7 @@ import FolderArrowIcon from '#/assets/folder_arrow.svg'
|
|||||||
|
|
||||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||||
|
|
||||||
import { useDriveStore } from '#/providers/DriveProvider'
|
import { useDriveStore, useToggleDirectoryExpansion } from '#/providers/DriveProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
@ -38,10 +38,11 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {
|
|||||||
* This should never happen.
|
* This should never happen.
|
||||||
*/
|
*/
|
||||||
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||||
const { item, selected, state, rowState, setRowState, isEditable, depth } = props
|
const { item, depth, selected, state, rowState, setRowState, isEditable } = props
|
||||||
const { backend, nodeMap, doToggleDirectoryExpansion, expandedDirectoryIds } = state
|
const { backend, nodeMap, expandedDirectoryIds } = state
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const driveStore = useDriveStore()
|
const driveStore = useDriveStore()
|
||||||
|
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
|
||||||
const isExpanded = expandedDirectoryIds.includes(item.id)
|
const isExpanded = expandedDirectoryIds.includes(item.id)
|
||||||
|
|
||||||
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
|
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
|
||||||
@ -98,7 +99,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
|||||||
isExpanded && 'rotate-90',
|
isExpanded && 'rotate-90',
|
||||||
)}
|
)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
doToggleDirectoryExpansion(item.id, item.id)
|
toggleDirectoryExpansion(item.id)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />
|
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />
|
||||||
|
@ -2,13 +2,7 @@
|
|||||||
|
|
||||||
/** Possible types of changes to the file list. */
|
/** Possible types of changes to the file list. */
|
||||||
enum AssetListEventType {
|
enum AssetListEventType {
|
||||||
newFolder = 'new-folder',
|
|
||||||
newProject = 'new-project',
|
|
||||||
uploadFiles = 'upload-files',
|
|
||||||
newDatalink = 'new-datalink',
|
|
||||||
newSecret = 'new-secret',
|
|
||||||
duplicateProject = 'duplicate-project',
|
duplicateProject = 'duplicate-project',
|
||||||
closeFolder = 'close-folder',
|
|
||||||
copy = 'copy',
|
copy = 'copy',
|
||||||
move = 'move',
|
move = 'move',
|
||||||
delete = 'delete',
|
delete = 'delete',
|
||||||
|
@ -20,13 +20,7 @@ interface AssetListBaseEvent<Type extends AssetListEventType> {
|
|||||||
|
|
||||||
/** All possible events. */
|
/** All possible events. */
|
||||||
interface AssetListEvents {
|
interface AssetListEvents {
|
||||||
readonly newFolder: AssetListNewFolderEvent
|
|
||||||
readonly newProject: AssetListNewProjectEvent
|
|
||||||
readonly uploadFiles: AssetListUploadFilesEvent
|
|
||||||
readonly newSecret: AssetListNewSecretEvent
|
|
||||||
readonly newDatalink: AssetListNewDatalinkEvent
|
|
||||||
readonly duplicateProject: AssetListDuplicateProjectEvent
|
readonly duplicateProject: AssetListDuplicateProjectEvent
|
||||||
readonly closeFolder: AssetListCloseFolderEvent
|
|
||||||
readonly copy: AssetListCopyEvent
|
readonly copy: AssetListCopyEvent
|
||||||
readonly move: AssetListMoveEvent
|
readonly move: AssetListMoveEvent
|
||||||
readonly delete: AssetListDeleteEvent
|
readonly delete: AssetListDeleteEvent
|
||||||
@ -45,46 +39,6 @@ type SanityCheck<
|
|||||||
} = AssetListEvents,
|
} = AssetListEvents,
|
||||||
> = [T]
|
> = [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. */
|
/** A signal to duplicate a project. */
|
||||||
interface AssetListDuplicateProjectEvent
|
interface AssetListDuplicateProjectEvent
|
||||||
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
|
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
|
||||||
@ -94,12 +48,6 @@ interface AssetListDuplicateProjectEvent
|
|||||||
readonly versionId: backend.S3ObjectVersionId
|
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. */
|
/** A signal that files should be copied. */
|
||||||
interface AssetListCopyEvent extends AssetListBaseEvent<AssetListEventType.copy> {
|
interface AssetListCopyEvent extends AssetListBaseEvent<AssetListEventType.copy> {
|
||||||
readonly newParentKey: backend.DirectoryId
|
readonly newParentKey: backend.DirectoryId
|
||||||
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
1276
app/gui/src/dashboard/hooks/backendHooks.tsx
Normal file
1276
app/gui/src/dashboard/hooks/backendHooks.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
|||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
import * as localBackendModule from '#/services/LocalBackend'
|
import * as localBackendModule from '#/services/LocalBackend'
|
||||||
|
|
||||||
import { useUploadFileWithToastMutation } from '#/hooks/backendHooks'
|
import { useNewProject, useUploadFileWithToastMutation } from '#/hooks/backendHooks'
|
||||||
import {
|
import {
|
||||||
usePasteData,
|
usePasteData,
|
||||||
useSetAssetPanelProps,
|
useSetAssetPanelProps,
|
||||||
@ -105,6 +105,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||||
|
|
||||||
|
const newProject = useNewProject(backend, category)
|
||||||
|
|
||||||
const systemApi = window.systemApi
|
const systemApi = window.systemApi
|
||||||
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
||||||
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
||||||
@ -225,14 +227,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action="useInNewProject"
|
action="useInNewProject"
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
dispatchAssetListEvent({
|
void newProject(
|
||||||
type: AssetListEventType.newProject,
|
{ templateName: asset.title, datalinkId: asset.id },
|
||||||
parentId: asset.parentId,
|
asset.parentId,
|
||||||
parentKey: asset.parentId,
|
path,
|
||||||
templateId: null,
|
)
|
||||||
datalinkId: asset.id,
|
|
||||||
preferredName: asset.title,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -513,9 +512,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
<GlobalContextMenu
|
<GlobalContextMenu
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
backend={backend}
|
backend={backend}
|
||||||
|
category={category}
|
||||||
rootDirectoryId={rootDirectoryId}
|
rootDirectoryId={rootDirectoryId}
|
||||||
directoryKey={asset.id}
|
directoryKey={asset.id}
|
||||||
directoryId={asset.id}
|
directoryId={asset.id}
|
||||||
|
path={path}
|
||||||
doPaste={doPaste}
|
doPaste={doPaste}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -16,15 +16,8 @@ import {
|
|||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
import {
|
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
useMutation,
|
|
||||||
useQueries,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
useSuspenseQuery,
|
|
||||||
} from '@tanstack/react-query'
|
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import invariant from 'tiny-invariant'
|
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
|
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
|
||||||
@ -61,7 +54,8 @@ import {
|
|||||||
backendMutationOptions,
|
backendMutationOptions,
|
||||||
listDirectoryQueryOptions,
|
listDirectoryQueryOptions,
|
||||||
useBackendQuery,
|
useBackendQuery,
|
||||||
useUploadFileWithToastMutation,
|
useRootDirectoryId,
|
||||||
|
useUploadFiles,
|
||||||
} from '#/hooks/backendHooks'
|
} from '#/hooks/backendHooks'
|
||||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||||
import { useIntersectionRatio } from '#/hooks/intersectionHooks'
|
import { useIntersectionRatio } from '#/hooks/intersectionHooks'
|
||||||
@ -76,7 +70,6 @@ import {
|
|||||||
type Category,
|
type Category,
|
||||||
} from '#/layouts/CategorySwitcher/Category'
|
} from '#/layouts/CategorySwitcher/Category'
|
||||||
import DragModal from '#/modals/DragModal'
|
import DragModal from '#/modals/DragModal'
|
||||||
import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal'
|
|
||||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||||
import {
|
import {
|
||||||
@ -86,6 +79,7 @@ import {
|
|||||||
} from '#/providers/BackendProvider'
|
} from '#/providers/BackendProvider'
|
||||||
import {
|
import {
|
||||||
useDriveStore,
|
useDriveStore,
|
||||||
|
useExpandedDirectoryIds,
|
||||||
useResetAssetPanelProps,
|
useResetAssetPanelProps,
|
||||||
useSetAssetPanelProps,
|
useSetAssetPanelProps,
|
||||||
useSetCanCreateAssets,
|
useSetCanCreateAssets,
|
||||||
@ -97,10 +91,11 @@ import {
|
|||||||
useSetSuggestions,
|
useSetSuggestions,
|
||||||
useSetTargetDirectory,
|
useSetTargetDirectory,
|
||||||
useSetVisuallySelectedKeys,
|
useSetVisuallySelectedKeys,
|
||||||
|
useToggleDirectoryExpansion,
|
||||||
} from '#/providers/DriveProvider'
|
} from '#/providers/DriveProvider'
|
||||||
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
|
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
|
||||||
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||||
import { useLocalStorage, useLocalStorageState } from '#/providers/LocalStorageProvider'
|
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||||
import { useSetModal } from '#/providers/ModalProvider'
|
import { useSetModal } from '#/providers/ModalProvider'
|
||||||
import { useNavigator2D } from '#/providers/Navigator2DProvider'
|
import { useNavigator2D } from '#/providers/Navigator2DProvider'
|
||||||
import { useLaunchedProjects } from '#/providers/ProjectsProvider'
|
import { useLaunchedProjects } from '#/providers/ProjectsProvider'
|
||||||
@ -108,39 +103,24 @@ import { useText } from '#/providers/TextProvider'
|
|||||||
import type Backend from '#/services/Backend'
|
import type Backend from '#/services/Backend'
|
||||||
import {
|
import {
|
||||||
assetIsDirectory,
|
assetIsDirectory,
|
||||||
assetIsFile,
|
|
||||||
assetIsProject,
|
assetIsProject,
|
||||||
AssetType,
|
AssetType,
|
||||||
BackendType,
|
BackendType,
|
||||||
createPlaceholderAssetId,
|
|
||||||
createPlaceholderFileAsset,
|
|
||||||
createPlaceholderProjectAsset,
|
|
||||||
createRootDirectoryAsset,
|
createRootDirectoryAsset,
|
||||||
createSpecialEmptyAsset,
|
createSpecialEmptyAsset,
|
||||||
createSpecialErrorAsset,
|
createSpecialErrorAsset,
|
||||||
createSpecialLoadingAsset,
|
createSpecialLoadingAsset,
|
||||||
DatalinkId,
|
|
||||||
DirectoryId,
|
|
||||||
escapeSpecialCharacters,
|
|
||||||
extractProjectExtension,
|
|
||||||
fileIsNotProject,
|
|
||||||
fileIsProject,
|
|
||||||
getAssetPermissionName,
|
getAssetPermissionName,
|
||||||
Path,
|
|
||||||
Plan,
|
Plan,
|
||||||
ProjectId,
|
ProjectId,
|
||||||
ProjectState,
|
ProjectState,
|
||||||
SecretId,
|
|
||||||
stripProjectExtension,
|
|
||||||
type AnyAsset,
|
type AnyAsset,
|
||||||
type AssetId,
|
type AssetId,
|
||||||
type DatalinkAsset,
|
|
||||||
type DirectoryAsset,
|
type DirectoryAsset,
|
||||||
|
type DirectoryId,
|
||||||
type LabelName,
|
type LabelName,
|
||||||
type ProjectAsset,
|
type ProjectAsset,
|
||||||
type SecretAsset,
|
|
||||||
} from '#/services/Backend'
|
} from '#/services/Backend'
|
||||||
import LocalBackend from '#/services/LocalBackend'
|
|
||||||
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
|
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
|
||||||
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
||||||
import type { AssetQueryKey } from '#/utilities/AssetQuery'
|
import type { AssetQueryKey } from '#/utilities/AssetQuery'
|
||||||
@ -302,11 +282,6 @@ export interface AssetsTableState {
|
|||||||
readonly setQuery: Dispatch<SetStateAction<AssetQuery>>
|
readonly setQuery: Dispatch<SetStateAction<AssetQuery>>
|
||||||
readonly nodeMap: Readonly<MutableRefObject<ReadonlyMap<AssetId, AnyAssetTreeNode>>>
|
readonly nodeMap: Readonly<MutableRefObject<ReadonlyMap<AssetId, AnyAssetTreeNode>>>
|
||||||
readonly hideColumn: (column: Column) => void
|
readonly hideColumn: (column: Column) => void
|
||||||
readonly doToggleDirectoryExpansion: (
|
|
||||||
directoryId: DirectoryId,
|
|
||||||
key: DirectoryId,
|
|
||||||
override?: boolean,
|
|
||||||
) => void
|
|
||||||
readonly doCopy: () => void
|
readonly doCopy: () => void
|
||||||
readonly doCut: () => void
|
readonly doCut: () => void
|
||||||
readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => 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: users } = useBackendQuery(backend, 'listUsers', [])
|
||||||
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
|
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 nameOfProjectToImmediatelyOpenRef = useRef(initialProjectName)
|
||||||
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
|
const rootDirectoryId = useRootDirectoryId(backend, category)
|
||||||
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 rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
|
const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
|
||||||
|
|
||||||
@ -405,16 +365,12 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const assetsTableBackgroundRefreshInterval = useFeatureFlag(
|
const assetsTableBackgroundRefreshInterval = useFeatureFlag(
|
||||||
'assetsTableBackgroundRefreshInterval',
|
'assetsTableBackgroundRefreshInterval',
|
||||||
)
|
)
|
||||||
/**
|
const expandedDirectoryIdsRaw = useExpandedDirectoryIds()
|
||||||
* The expanded directories in the asset tree.
|
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
|
||||||
* 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 expandedDirectoryIds = useMemo(
|
const expandedDirectoryIds = useMemo(
|
||||||
() => [rootDirectoryId].concat(privateExpandedDirectoryIds),
|
() => [rootDirectoryId].concat(expandedDirectoryIdsRaw),
|
||||||
[privateExpandedDirectoryIds, rootDirectoryId],
|
[expandedDirectoryIdsRaw, rootDirectoryId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const expandedDirectoryIdsSet = useMemo(
|
const expandedDirectoryIdsSet = useMemo(
|
||||||
@ -422,13 +378,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
[expandedDirectoryIds],
|
[expandedDirectoryIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
|
const uploadFiles = useUploadFiles(backend, category)
|
||||||
const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
|
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 updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
|
||||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
|
||||||
const uploadFileMutation = useUploadFileWithToastMutation(backend)
|
|
||||||
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
|
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
|
||||||
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
|
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
|
||||||
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
|
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
|
||||||
@ -1193,28 +1145,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
[driveStore, resetAssetPanelProps, setIsAssetPanelTemporarilyVisible],
|
[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(
|
const doCopyOnBackend = useEventCallback(
|
||||||
async (newParentId: DirectoryId | null, asset: AnyAsset) => {
|
async (newParentId: DirectoryId | null, asset: AnyAsset) => {
|
||||||
try {
|
try {
|
||||||
@ -1252,11 +1182,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
resetAssetPanelProps()
|
resetAssetPanelProps()
|
||||||
}
|
}
|
||||||
if (asset.type === AssetType.directory) {
|
if (asset.type === AssetType.directory) {
|
||||||
dispatchAssetListEvent({
|
toggleDirectoryExpansion(asset.id, false)
|
||||||
type: AssetListEventType.closeFolder,
|
|
||||||
id: asset.id,
|
|
||||||
key: asset.id,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (asset.type === AssetType.project && backend.type === BackendType.local) {
|
if (asset.type === AssetType.project && backend.type === BackendType.local) {
|
||||||
@ -1326,7 +1252,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
case AssetType.directory: {
|
case AssetType.directory: {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
doToggleDirectoryExpansion(item.item.id, item.key)
|
toggleDirectoryExpansion(item.item.id)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AssetType.project: {
|
case AssetType.project: {
|
||||||
@ -1378,7 +1304,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
// The folder is expanded; collapse it.
|
// The folder is expanded; collapse it.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
doToggleDirectoryExpansion(item.item.id, item.key, false)
|
toggleDirectoryExpansion(item.item.id, false)
|
||||||
} else if (prevIndex != null) {
|
} else if (prevIndex != null) {
|
||||||
// Focus parent if there is one.
|
// Focus parent if there is one.
|
||||||
let index = prevIndex - 1
|
let index = prevIndex - 1
|
||||||
@ -1403,7 +1329,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
// The folder is collapsed; expand it.
|
// The folder is collapsed; expand it.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
doToggleDirectoryExpansion(item.item.id, item.key, true)
|
toggleDirectoryExpansion(item.item.id, true)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -1493,23 +1419,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}
|
}
|
||||||
}, [setMostRecentlySelectedIndex])
|
}, [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 deleteAsset = useEventCallback((assetId: AssetId) => {
|
||||||
const asset = nodeMapRef.current.get(assetId)?.item
|
const asset = nodeMapRef.current.get(assetId)?.item
|
||||||
|
|
||||||
@ -1529,379 +1438,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
|
|
||||||
const onAssetListEvent = useEventCallback((event: AssetListEvent) => {
|
const onAssetListEvent = useEventCallback((event: AssetListEvent) => {
|
||||||
switch (event.type) {
|
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: {
|
case AssetListEventType.duplicateProject: {
|
||||||
const parent = nodeMapRef.current.get(event.parentKey)
|
const parent = nodeMapRef.current.get(event.parentKey)
|
||||||
const siblings = parent?.children ?? []
|
const siblings = parent?.children ?? []
|
||||||
@ -1998,10 +1534,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: event.id })
|
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: event.id })
|
||||||
break
|
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)) {
|
if (pasteData.data.ids.has(newParentKey)) {
|
||||||
toast.error('Cannot paste a folder into itself.')
|
toast.error('Cannot paste a folder into itself.')
|
||||||
} else {
|
} else {
|
||||||
doToggleDirectoryExpansion(newParentId, newParentKey, true)
|
toggleDirectoryExpansion(newParentId, true)
|
||||||
if (pasteData.type === 'copy') {
|
if (pasteData.type === 'copy') {
|
||||||
const assets = Array.from(pasteData.data.ids, (id) => nodeMapRef.current.get(id)).flatMap(
|
const assets = Array.from(pasteData.data.ids, (id) => nodeMapRef.current.get(id)).flatMap(
|
||||||
(asset) => (asset ? [asset.item] : []),
|
(asset) => (asset ? [asset.item] : []),
|
||||||
@ -2124,12 +1656,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
if (event.dataTransfer.types.includes('Files')) {
|
if (event.dataTransfer.types.includes('Files')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
dispatchAssetListEvent({
|
void uploadFiles(Array.from(event.dataTransfer.files), rootDirectoryId, rootDirectoryId)
|
||||||
type: AssetListEventType.uploadFiles,
|
|
||||||
parentKey: rootDirectoryId,
|
|
||||||
parentId: rootDirectoryId,
|
|
||||||
files: Array.from(event.dataTransfer.files),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2147,7 +1674,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
setQuery,
|
setQuery,
|
||||||
nodeMap: nodeMapRef,
|
nodeMap: nodeMapRef,
|
||||||
hideColumn,
|
hideColumn,
|
||||||
doToggleDirectoryExpansion,
|
|
||||||
doCopy,
|
doCopy,
|
||||||
doCut,
|
doCut,
|
||||||
doPaste,
|
doPaste,
|
||||||
@ -2162,7 +1688,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
category,
|
category,
|
||||||
sortInfo,
|
sortInfo,
|
||||||
query,
|
query,
|
||||||
doToggleDirectoryExpansion,
|
|
||||||
doCopy,
|
doCopy,
|
||||||
doCut,
|
doCut,
|
||||||
doPaste,
|
doPaste,
|
||||||
@ -2702,12 +2227,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
>
|
>
|
||||||
<FileTrigger
|
<FileTrigger
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
dispatchAssetListEvent({
|
void uploadFiles(Array.from(event ?? []), rootDirectoryId, rootDirectoryId)
|
||||||
type: AssetListEventType.uploadFiles,
|
|
||||||
parentKey: rootDirectoryId,
|
|
||||||
parentId: rootDirectoryId,
|
|
||||||
files: Array.from(event ?? []),
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
@ -245,9 +245,11 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
<GlobalContextMenu
|
<GlobalContextMenu
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
backend={backend}
|
backend={backend}
|
||||||
|
category={category}
|
||||||
rootDirectoryId={rootDirectoryId}
|
rootDirectoryId={rootDirectoryId}
|
||||||
directoryKey={null}
|
directoryKey={null}
|
||||||
directoryId={null}
|
directoryId={null}
|
||||||
|
path={null}
|
||||||
doPaste={doPaste}
|
doPaste={doPaste}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
/** @file The directory header bar and directory item listing. */
|
/** @file The directory header bar and directory item listing. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import invariant from 'tiny-invariant'
|
|
||||||
|
|
||||||
import * as appUtils from '#/appUtils'
|
import * as appUtils from '#/appUtils'
|
||||||
|
|
||||||
import * as offlineHooks from '#/hooks/offlineHooks'
|
import * as offlineHooks from '#/hooks/offlineHooks'
|
||||||
@ -26,16 +24,10 @@ import Labels from '#/layouts/Labels'
|
|||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
import * as result from '#/components/Result'
|
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 AssetQuery from '#/utilities/AssetQuery'
|
||||||
import * as download from '#/utilities/download'
|
import * as download from '#/utilities/download'
|
||||||
import * as github from '#/utilities/github'
|
import * as github from '#/utilities/github'
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Drive ===
|
// === Drive ===
|
||||||
@ -62,27 +54,6 @@ export default function Drive(props: DriveProps) {
|
|||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||||
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
|
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 isCloud = categoryModule.isCloudCategory(category)
|
||||||
const supportLocalBackend = localBackend != null
|
const supportLocalBackend = localBackend != null
|
||||||
|
|
||||||
@ -91,82 +62,10 @@ export default function Drive(props: DriveProps) {
|
|||||||
: isCloud && !user.isEnabled ? 'not-enabled'
|
: isCloud && !user.isEnabled ? 'not-enabled'
|
||||||
: 'ok'
|
: '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(() => {
|
const doEmptyTrash = React.useCallback(() => {
|
||||||
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
|
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
|
||||||
}, [dispatchAssetListEvent])
|
}, [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) {
|
switch (status) {
|
||||||
case 'not-enabled': {
|
case 'not-enabled': {
|
||||||
return (
|
return (
|
||||||
@ -216,11 +115,6 @@ export default function Drive(props: DriveProps) {
|
|||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
category={category}
|
category={category}
|
||||||
doEmptyTrash={doEmptyTrash}
|
doEmptyTrash={doEmptyTrash}
|
||||||
doCreateProject={doCreateProject}
|
|
||||||
doUploadFiles={doUploadFiles}
|
|
||||||
doCreateDirectory={doCreateDirectory}
|
|
||||||
doCreateSecret={doCreateSecret}
|
|
||||||
doCreateDatalink={doCreateDatalink}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 gap-drive overflow-hidden">
|
<div className="flex flex-1 gap-drive overflow-hidden">
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import * as React from 'react'
|
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 AddDatalinkIcon from '#/assets/add_datalink.svg'
|
||||||
import AddFolderIcon from '#/assets/add_folder.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 DataDownloadIcon from '#/assets/data_download.svg'
|
||||||
import DataUploadIcon from '#/assets/data_upload.svg'
|
import DataUploadIcon from '#/assets/data_upload.svg'
|
||||||
import Plus2Icon from '#/assets/plus2.svg'
|
import Plus2Icon from '#/assets/plus2.svg'
|
||||||
import { Input as AriaInput } from '#/components/aria'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
@ -21,8 +20,16 @@ import {
|
|||||||
useVisualTooltip,
|
useVisualTooltip,
|
||||||
} from '#/components/AriaComponents'
|
} from '#/components/AriaComponents'
|
||||||
import AssetEventType from '#/events/AssetEventType'
|
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 { useOffline } from '#/hooks/offlineHooks'
|
||||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
|
||||||
import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
|
import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
|
||||||
import AssetSearchBar from '#/layouts/AssetSearchBar'
|
import AssetSearchBar from '#/layouts/AssetSearchBar'
|
||||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||||
@ -35,13 +42,16 @@ import StartModal from '#/layouts/StartModal'
|
|||||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
|
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
|
||||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
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 { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||||
import { useSetModal } from '#/providers/ModalProvider'
|
import { useSetModal } from '#/providers/ModalProvider'
|
||||||
import { useText } from '#/providers/TextProvider'
|
import { useText } from '#/providers/TextProvider'
|
||||||
import type Backend from '#/services/Backend'
|
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 type AssetQuery from '#/utilities/AssetQuery'
|
||||||
import { inputFiles } from '#/utilities/input'
|
import { inputFiles } from '#/utilities/input'
|
||||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||||
@ -58,16 +68,6 @@ export interface DriveBarProps {
|
|||||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||||
readonly category: Category
|
readonly category: Category
|
||||||
readonly doEmptyTrash: () => void
|
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.
|
* and a column display mode switcher.
|
||||||
*/
|
*/
|
||||||
export default function DriveBar(props: DriveBarProps) {
|
export default function DriveBar(props: DriveBarProps) {
|
||||||
const { backend, query, setQuery, category } = props
|
const { backend, query, setQuery, category, doEmptyTrash } = props
|
||||||
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
|
|
||||||
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
|
|
||||||
|
|
||||||
const [startModalDefaultOpen, , resetStartModalDefaultOpen] = useSearchParamsState(
|
const [startModalDefaultOpen, , resetStartModalDefaultOpen] = useSearchParamsState(
|
||||||
'startModalDefaultOpen',
|
'startModalDefaultOpen',
|
||||||
@ -86,11 +84,11 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
|
|
||||||
const { unsetModal } = useSetModal()
|
const { unsetModal } = useSetModal()
|
||||||
const { getText } = useText()
|
const { getText } = useText()
|
||||||
|
const driveStore = useDriveStore()
|
||||||
const inputBindings = useInputBindings()
|
const inputBindings = useInputBindings()
|
||||||
const dispatchAssetEvent = useDispatchAssetEvent()
|
const dispatchAssetEvent = useDispatchAssetEvent()
|
||||||
const canCreateAssets = useCanCreateAssets()
|
const canCreateAssets = useCanCreateAssets()
|
||||||
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
|
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
|
||||||
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
|
|
||||||
const isCloud = isCloudCategory(category)
|
const isCloud = isCloudCategory(category)
|
||||||
const { isOffline } = useOffline()
|
const { isOffline } = useOffline()
|
||||||
const canDownload = useCanDownload()
|
const canDownload = useCanDownload()
|
||||||
@ -105,12 +103,6 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
targetRef: createAssetButtonsRef,
|
targetRef: createAssetButtonsRef,
|
||||||
overlayPositionProps: { placement: 'top' },
|
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 pasteData = usePasteData()
|
||||||
const effectivePasteData =
|
const effectivePasteData =
|
||||||
(
|
(
|
||||||
@ -120,60 +112,64 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
pasteData
|
pasteData
|
||||||
: null
|
: 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(() => {
|
React.useEffect(() => {
|
||||||
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
||||||
...(isCloud ?
|
...(isCloud ?
|
||||||
{
|
{
|
||||||
newFolder: () => {
|
newFolder: () => {
|
||||||
doCreateDirectory()
|
void newFolder()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
newProject: () => {
|
newProject: () => {
|
||||||
setIsCreatingProject(true)
|
void newProject([null, null])
|
||||||
doCreateProject(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
(project, parentId) => {
|
|
||||||
setCreatedProjectId({ projectId: project.projectId, parentId })
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
setIsCreatingProject(false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
uploadFiles: () => {
|
uploadFiles: () => {
|
||||||
uploadFilesRef.current?.click()
|
void inputFiles().then((files) => uploadFiles(Array.from(files)))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [isCloud, doCreateDirectory, doCreateProject, inputBindings])
|
}, [inputBindings, isCloud, newFolder, newProject, uploadFiles])
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
const searchBar = (
|
const searchBar = (
|
||||||
<AssetSearchBar backend={backend} isCloud={isCloud} query={query} setQuery={setQuery} />
|
<AssetSearchBar backend={backend} isCloud={isCloud} query={query} setQuery={setQuery} />
|
||||||
@ -246,9 +242,8 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
<Button
|
<Button
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
isDisabled={shouldBeDisabled || isCreatingProject || isCreatingProjectFromTemplate}
|
isDisabled={shouldBeDisabled || isCreatingProject}
|
||||||
icon={Plus2Icon}
|
icon={Plus2Icon}
|
||||||
loading={isCreatingProjectFromTemplate}
|
|
||||||
loaderPosition="icon"
|
loaderPosition="icon"
|
||||||
>
|
>
|
||||||
{getText('startWithATemplate')}
|
{getText('startWithATemplate')}
|
||||||
@ -256,40 +251,18 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
|
|
||||||
<StartModal
|
<StartModal
|
||||||
createProject={(templateId, templateName) => {
|
createProject={(templateId, templateName) => {
|
||||||
setIsCreatingProjectFromTemplate(true)
|
void newProject([templateId, templateName])
|
||||||
doCreateProject(
|
|
||||||
templateId,
|
|
||||||
templateName,
|
|
||||||
({ projectId }, parentId) => {
|
|
||||||
setCreatedProjectId({ projectId, parentId })
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
setIsCreatingProjectFromTemplate(false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<Button
|
<Button
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
isDisabled={shouldBeDisabled || isCreatingProject || isCreatingProjectFromTemplate}
|
isDisabled={shouldBeDisabled || isCreatingProject}
|
||||||
icon={Plus2Icon}
|
icon={Plus2Icon}
|
||||||
loading={isCreatingProject}
|
|
||||||
loaderPosition="icon"
|
loaderPosition="icon"
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
setIsCreatingProject(true)
|
await newProject([null, null])
|
||||||
doCreateProject(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
({ projectId }, parentId) => {
|
|
||||||
setCreatedProjectId({ projectId, parentId })
|
|
||||||
setIsCreatingProject(false)
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
setIsCreatingProject(false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('newEmptyProject')}
|
{getText('newEmptyProject')}
|
||||||
@ -301,8 +274,8 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
icon={AddFolderIcon}
|
icon={AddFolderIcon}
|
||||||
isDisabled={shouldBeDisabled}
|
isDisabled={shouldBeDisabled}
|
||||||
aria-label={getText('newFolder')}
|
aria-label={getText('newFolder')}
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
doCreateDirectory()
|
await newFolder()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isCloud && (
|
{isCloud && (
|
||||||
@ -314,7 +287,13 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
isDisabled={shouldBeDisabled}
|
isDisabled={shouldBeDisabled}
|
||||||
aria-label={getText('newSecret')}
|
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>
|
</DialogTrigger>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -327,23 +306,13 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
isDisabled={shouldBeDisabled}
|
isDisabled={shouldBeDisabled}
|
||||||
aria-label={getText('newDatalink')}
|
aria-label={getText('newDatalink')}
|
||||||
/>
|
/>
|
||||||
<UpsertDatalinkModal doCreate={doCreateDatalink} />
|
<UpsertDatalinkModal
|
||||||
|
doCreate={async (name, value) => {
|
||||||
|
await newDatalink(name, value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</DialogTrigger>
|
</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
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
size="medium"
|
size="medium"
|
||||||
@ -352,7 +321,7 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
aria-label={getText('uploadFiles')}
|
aria-label={getText('uploadFiles')}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const files = await inputFiles()
|
const files = await inputFiles()
|
||||||
doUploadFiles(Array.from(files))
|
await uploadFiles(Array.from(files))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
@ -1,34 +1,38 @@
|
|||||||
/** @file A context menu available everywhere in the directory. */
|
/** @file A context menu available everywhere in the directory. */
|
||||||
import { useStore } from 'zustand'
|
import { useStore } from 'zustand'
|
||||||
|
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
|
||||||
|
|
||||||
import ContextMenu from '#/components/ContextMenu'
|
import ContextMenu from '#/components/ContextMenu'
|
||||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||||
|
|
||||||
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
|
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
|
||||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
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 { useDriveStore } from '#/providers/DriveProvider'
|
||||||
import { useSetModal } from '#/providers/ModalProvider'
|
import { useSetModal } from '#/providers/ModalProvider'
|
||||||
import { useText } from '#/providers/TextProvider'
|
import { useText } from '#/providers/TextProvider'
|
||||||
import type * as backendModule from '#/services/Backend'
|
|
||||||
import type Backend 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'
|
import { inputFiles } from '#/utilities/input'
|
||||||
|
|
||||||
/** Props for a {@link GlobalContextMenu}. */
|
/** Props for a {@link GlobalContextMenu}. */
|
||||||
export interface GlobalContextMenuProps {
|
export interface GlobalContextMenuProps {
|
||||||
readonly hidden?: boolean
|
readonly hidden?: boolean
|
||||||
readonly backend: Backend
|
readonly backend: Backend
|
||||||
readonly rootDirectoryId: backendModule.DirectoryId
|
readonly category: Category
|
||||||
readonly directoryKey: backendModule.DirectoryId | null
|
readonly rootDirectoryId: DirectoryId
|
||||||
readonly directoryId: backendModule.DirectoryId | null
|
readonly directoryKey: DirectoryId | null
|
||||||
readonly doPaste: (
|
readonly directoryId: DirectoryId | null
|
||||||
newParentKey: backendModule.DirectoryId,
|
readonly path: string | null
|
||||||
newParentId: backendModule.DirectoryId,
|
readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => void
|
||||||
) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A context menu available everywhere in the directory. */
|
/** A context menu available everywhere in the directory. */
|
||||||
@ -40,15 +44,17 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
|||||||
const {
|
const {
|
||||||
hidden = false,
|
hidden = false,
|
||||||
backend,
|
backend,
|
||||||
|
category,
|
||||||
directoryKey = null,
|
directoryKey = null,
|
||||||
directoryId = null,
|
directoryId = null,
|
||||||
|
path,
|
||||||
rootDirectoryId,
|
rootDirectoryId,
|
||||||
} = props
|
} = props
|
||||||
const { doPaste } = props
|
const { doPaste } = props
|
||||||
|
|
||||||
const { getText } = useText()
|
const { getText } = useText()
|
||||||
const { setModal, unsetModal } = useSetModal()
|
const { setModal, unsetModal } = useSetModal()
|
||||||
const dispatchAssetListEvent = useDispatchAssetListEvent()
|
const isCloud = backend.type === BackendType.remote
|
||||||
|
|
||||||
const driveStore = useDriveStore()
|
const driveStore = useDriveStore()
|
||||||
const hasPasteData = useStore(
|
const hasPasteData = useStore(
|
||||||
@ -56,7 +62,28 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
|||||||
(storeState) => (storeState.pasteData?.data.ids.size ?? 0) > 0,
|
(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 (
|
return (
|
||||||
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
|
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
|
||||||
@ -65,12 +92,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
|||||||
action="uploadFiles"
|
action="uploadFiles"
|
||||||
doAction={async () => {
|
doAction={async () => {
|
||||||
const files = await inputFiles()
|
const files = await inputFiles()
|
||||||
dispatchAssetListEvent({
|
await uploadFiles(Array.from(files))
|
||||||
type: AssetListEventType.uploadFiles,
|
|
||||||
parentKey: directoryKey ?? rootDirectoryId,
|
|
||||||
parentId: directoryId ?? rootDirectoryId,
|
|
||||||
files: Array.from(files),
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
@ -78,14 +100,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
|||||||
action="newProject"
|
action="newProject"
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetListEvent({
|
void newProject(null, null)
|
||||||
type: AssetListEventType.newProject,
|
|
||||||
parentKey: directoryKey ?? rootDirectoryId,
|
|
||||||
parentId: directoryId ?? rootDirectoryId,
|
|
||||||
templateId: null,
|
|
||||||
datalinkId: null,
|
|
||||||
preferredName: null,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
@ -93,11 +108,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
|||||||
action="newFolder"
|
action="newFolder"
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetListEvent({
|
void newFolder()
|
||||||
type: AssetListEventType.newFolder,
|
|
||||||
parentKey: directoryKey ?? rootDirectoryId,
|
|
||||||
parentId: directoryId ?? rootDirectoryId,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isCloud && (
|
{isCloud && (
|
||||||
@ -109,14 +120,8 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
|||||||
<UpsertSecretModal
|
<UpsertSecretModal
|
||||||
id={null}
|
id={null}
|
||||||
name={null}
|
name={null}
|
||||||
doCreate={(name, value) => {
|
doCreate={async (name, value) => {
|
||||||
dispatchAssetListEvent({
|
await newSecret(name, value)
|
||||||
type: AssetListEventType.newSecret,
|
|
||||||
parentKey: directoryKey ?? rootDirectoryId,
|
|
||||||
parentId: directoryId ?? rootDirectoryId,
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
@ -130,14 +135,8 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
|||||||
doAction={() => {
|
doAction={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<UpsertDatalinkModal
|
<UpsertDatalinkModal
|
||||||
doCreate={(name, value) => {
|
doCreate={async (name, value) => {
|
||||||
dispatchAssetListEvent({
|
await newDatalink(name, value)
|
||||||
type: AssetListEventType.newDatalink,
|
|
||||||
parentKey: directoryKey ?? rootDirectoryId,
|
|
||||||
parentId: directoryId ?? rootDirectoryId,
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
@ -4,6 +4,7 @@ import * as React from 'react'
|
|||||||
import * as zustand from '#/utilities/zustand'
|
import * as zustand from '#/utilities/zustand'
|
||||||
import invariant from 'tiny-invariant'
|
import invariant from 'tiny-invariant'
|
||||||
|
|
||||||
|
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||||
import type { AssetPanelContextProps } from '#/layouts/AssetPanel'
|
import type { AssetPanelContextProps } from '#/layouts/AssetPanel'
|
||||||
import type { Suggestion } from '#/layouts/AssetSearchBar'
|
import type { Suggestion } from '#/layouts/AssetSearchBar'
|
||||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||||
@ -18,7 +19,6 @@ import type {
|
|||||||
DirectoryId,
|
DirectoryId,
|
||||||
} from 'enso-common/src/services/Backend'
|
} from 'enso-common/src/services/Backend'
|
||||||
import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array'
|
import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array'
|
||||||
import { useEventCallback } from '../hooks/eventCallbackHooks'
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// === DriveStore ===
|
// === DriveStore ===
|
||||||
@ -45,6 +45,8 @@ interface DriveStore {
|
|||||||
readonly setCanDownload: (canDownload: boolean) => void
|
readonly setCanDownload: (canDownload: boolean) => void
|
||||||
readonly pasteData: PasteData<DrivePastePayload> | null
|
readonly pasteData: PasteData<DrivePastePayload> | null
|
||||||
readonly setPasteData: (pasteData: PasteData<DrivePastePayload> | null) => void
|
readonly setPasteData: (pasteData: PasteData<DrivePastePayload> | null) => void
|
||||||
|
readonly expandedDirectoryIds: readonly DirectoryId[]
|
||||||
|
readonly setExpandedDirectoryIds: (selectedKeys: readonly DirectoryId[]) => void
|
||||||
readonly selectedKeys: ReadonlySet<AssetId>
|
readonly selectedKeys: ReadonlySet<AssetId>
|
||||||
readonly setSelectedKeys: (selectedKeys: ReadonlySet<AssetId>) => void
|
readonly setSelectedKeys: (selectedKeys: ReadonlySet<AssetId>) => void
|
||||||
readonly visuallySelectedKeys: ReadonlySet<AssetId> | null
|
readonly visuallySelectedKeys: ReadonlySet<AssetId> | null
|
||||||
@ -137,6 +139,12 @@ export default function DriveProvider(props: ProjectsProviderProps) {
|
|||||||
set({ pasteData })
|
set({ pasteData })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
expandedDirectoryIds: EMPTY_ARRAY,
|
||||||
|
setExpandedDirectoryIds: (expandedDirectoryIds) => {
|
||||||
|
if (get().expandedDirectoryIds !== expandedDirectoryIds) {
|
||||||
|
set({ expandedDirectoryIds })
|
||||||
|
}
|
||||||
|
},
|
||||||
selectedKeys: EMPTY_SET,
|
selectedKeys: EMPTY_SET,
|
||||||
setSelectedKeys: (selectedKeys) => {
|
setSelectedKeys: (selectedKeys) => {
|
||||||
if (get().selectedKeys !== selectedKeys) {
|
if (get().selectedKeys !== selectedKeys) {
|
||||||
@ -299,13 +307,25 @@ export function useSetPasteData() {
|
|||||||
return zustand.useStore(store, (state) => state.setPasteData)
|
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. */
|
/** The selected keys in the Asset Table. */
|
||||||
export function useSelectedKeys() {
|
export function useSelectedKeys() {
|
||||||
const store = useDriveStore()
|
const store = useDriveStore()
|
||||||
return zustand.useStore(store, (state) => state.selectedKeys)
|
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() {
|
export function useSetSelectedKeys() {
|
||||||
const store = useDriveStore()
|
const store = useDriveStore()
|
||||||
return zustand.useStore(store, (state) => state.setSelectedKeys)
|
return zustand.useStore(store, (state) => state.setSelectedKeys)
|
||||||
@ -482,3 +502,25 @@ export function useSetIsAssetPanelHidden() {
|
|||||||
const store = useDriveStore()
|
const store = useDriveStore()
|
||||||
return zustand.useStore(store, (state) => state.setIsAssetPanelHidden)
|
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user