mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 22:21:40 +03:00
Multipart upload for large files (#11319)
- Close https://github.com/enso-org/cloud-v2/issues/1492 - Use multiplart upload for large files (>6MB) - Split file into chunks of *exactly* 10MB (backend currently returns a fixed number of chunks) - Call "start upload", then upload each chunk to S3 (currently sequentially), then "end upload" - Files <=6MB (lambda limit) still use the current endpoint # Important Notes None
This commit is contained in:
parent
a5b223f33b
commit
171a95f17a
@ -6,6 +6,9 @@ import * as newtype from '../utilities/data/newtype'
|
||||
import * as permissions from '../utilities/permissions'
|
||||
import * as uniqueString from '../utilities/uniqueString'
|
||||
|
||||
/** The size, in bytes, of the chunks which the backend accepts. */
|
||||
export const S3_CHUNK_SIZE_BYTES = 10_000_000
|
||||
|
||||
// ================
|
||||
// === Newtypes ===
|
||||
// ================
|
||||
@ -1236,6 +1239,50 @@ export interface UploadFileRequestParams {
|
||||
readonly parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "upload file start" endpoint. */
|
||||
export interface UploadFileStartRequestBody {
|
||||
readonly size: number
|
||||
readonly fileName: string
|
||||
}
|
||||
|
||||
/** Metadata required to uploading a large file. */
|
||||
export interface UploadLargeFileMetadata {
|
||||
readonly presignedUrls: readonly HttpsUrl[]
|
||||
readonly uploadId: string
|
||||
readonly sourcePath: S3FilePath
|
||||
}
|
||||
|
||||
/** Metadata for each multipart upload. */
|
||||
export interface S3MultipartPart {
|
||||
readonly eTag: string
|
||||
readonly partNumber: number
|
||||
}
|
||||
|
||||
/** HTTP request body for the "upload file end" endpoint. */
|
||||
export interface UploadFileEndRequestBody {
|
||||
readonly parentDirectoryId: DirectoryId | null
|
||||
readonly parts: readonly S3MultipartPart[]
|
||||
readonly sourcePath: S3FilePath
|
||||
readonly uploadId: string
|
||||
readonly assetId: AssetId | null
|
||||
readonly fileName: string
|
||||
}
|
||||
|
||||
/** A large file that has finished uploading. */
|
||||
export interface UploadedLargeFile {
|
||||
readonly id: FileId
|
||||
readonly project: null
|
||||
}
|
||||
|
||||
/** A large project that has finished uploading. */
|
||||
export interface UploadedLargeProject {
|
||||
readonly id: ProjectId
|
||||
readonly project: Project
|
||||
}
|
||||
|
||||
/** A large asset (file or project) that has finished uploading. */
|
||||
export type UploadedLargeAsset = UploadedLargeFile | UploadedLargeProject
|
||||
|
||||
/** URL query string parameters for the "upload profile picture" endpoint. */
|
||||
export interface UploadPictureRequestParams {
|
||||
readonly fileName: string | null
|
||||
@ -1520,8 +1567,15 @@ export default abstract class Backend {
|
||||
abstract checkResources(projectId: ProjectId, title: string): Promise<ResourceUsage>
|
||||
/** Return a list of files accessible by the current user. */
|
||||
abstract listFiles(): Promise<readonly FileLocator[]>
|
||||
/** Upload a file. */
|
||||
abstract uploadFile(params: UploadFileRequestParams, file: Blob): Promise<FileInfo>
|
||||
/** Begin uploading a large file. */
|
||||
abstract uploadFileStart(
|
||||
body: UploadFileRequestParams,
|
||||
file: File,
|
||||
): Promise<UploadLargeFileMetadata>
|
||||
/** Upload a chunk of a large file. */
|
||||
abstract uploadFileChunk(url: HttpsUrl, file: Blob, index: number): Promise<S3MultipartPart>
|
||||
/** Finish uploading a large file. */
|
||||
abstract uploadFileEnd(body: UploadFileEndRequestBody): Promise<UploadedLargeAsset>
|
||||
/** Change the name of a file. */
|
||||
abstract updateFile(fileId: FileId, body: UpdateFileRequestBody, title: string): Promise<void>
|
||||
/** Return file details. */
|
||||
|
@ -142,6 +142,9 @@
|
||||
"checkResourcesBackendError": "Could not get resource usage for project '$0'",
|
||||
"listFilesBackendError": "Could not list files",
|
||||
"uploadFileBackendError": "Could not upload file",
|
||||
"uploadFileStartBackendError": "Could not begin uploading large file",
|
||||
"uploadFileChunkBackendError": "Could not upload chunk of large file",
|
||||
"uploadFileEndBackendError": "Could not finish uploading large file",
|
||||
"updateFileNotImplementedBackendError": "Files currently cannot be renamed on the Cloud backend",
|
||||
"uploadFileWithNameBackendError": "Could not upload file '$0'",
|
||||
"getFileDetailsBackendError": "Could not get details of project '$0'",
|
||||
@ -271,6 +274,7 @@
|
||||
"options": "Options",
|
||||
"googleIcon": "Google icon",
|
||||
"gitHubIcon": "GitHub icon",
|
||||
"close": "Close",
|
||||
|
||||
"enterSecretPath": "Enter secret path",
|
||||
"enterText": "Enter text",
|
||||
@ -481,6 +485,11 @@
|
||||
"youHaveNoUserGroupsAdmin": "This organization has no user groups. You can create one using the button above.",
|
||||
"youHaveNoUserGroupsNonAdmin": "This organization has no user groups. You can create one using the button above.",
|
||||
"xIsUsingTheProject": "'$0' is currently using the project",
|
||||
"uploadLargeFileStatus": "Uploading file... ($0/$1MB)",
|
||||
"uploadLargeFileSuccess": "Finished uploading file.",
|
||||
"uploadLargeFileError": "Could not upload file",
|
||||
"closeWindowDialogTitle": "Close window?",
|
||||
"anUploadIsInProgress": "An upload is in progress.",
|
||||
|
||||
"enableMultitabs": "Enable Multi-Tabs",
|
||||
"enableMultitabsDescription": "Open multiple projects at the same time.",
|
||||
|
@ -144,6 +144,7 @@ interface PlaceholderOverrides {
|
||||
|
||||
readonly arbitraryFieldTooLarge: [maxSize: string]
|
||||
readonly arbitraryFieldTooSmall: [minSize: string]
|
||||
readonly uploadLargeFileStatus: [uploadedParts: number, totalParts: number]
|
||||
}
|
||||
|
||||
/** An tuple of `string` for placeholders for each {@link TextId}. */
|
||||
|
@ -38,6 +38,7 @@ const GLOB_TAG_ID = backend.TagId('*')
|
||||
const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*')
|
||||
/* eslint-enable no-restricted-syntax */
|
||||
const BASE_URL = 'https://mock/'
|
||||
const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/'
|
||||
|
||||
// ===============
|
||||
// === mockApi ===
|
||||
@ -723,30 +724,39 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
return
|
||||
}
|
||||
})
|
||||
await post(remoteBackendPaths.UPLOAD_FILE_PATH + '*', (_route, request) => {
|
||||
/** The type for the JSON request payload for this endpoint. */
|
||||
interface SearchParams {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
readonly file_name: string
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
readonly file_id?: backend.FileId
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
readonly parent_directory_id?: backend.DirectoryId
|
||||
await page.route(MOCK_S3_BUCKET_URL + '**', async (route, request) => {
|
||||
if (request.method() !== 'PUT') {
|
||||
await route.fallback()
|
||||
} else {
|
||||
await route.fulfill({
|
||||
headers: {
|
||||
'Access-Control-Expose-Headers': 'ETag',
|
||||
ETag: uniqueString.uniqueString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
await post(remoteBackendPaths.UPLOAD_FILE_START_PATH + '*', () => {
|
||||
return {
|
||||
sourcePath: backend.S3FilePath(''),
|
||||
uploadId: 'file-' + uniqueString.uniqueString(),
|
||||
presignedUrls: Array.from({ length: 10 }, () =>
|
||||
backend.HttpsUrl(`${MOCK_S3_BUCKET_URL}${uniqueString.uniqueString()}`),
|
||||
),
|
||||
} satisfies backend.UploadLargeFileMetadata
|
||||
})
|
||||
await post(remoteBackendPaths.UPLOAD_FILE_END_PATH + '*', (_route, request) => {
|
||||
// The type of the search params sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-restricted-syntax
|
||||
const searchParams: SearchParams = Object.fromEntries(
|
||||
new URL(request.url()).searchParams.entries(),
|
||||
) as never
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.UploadFileEndRequestBody = request.postDataJSON()
|
||||
|
||||
const file = addFile(
|
||||
searchParams.file_name,
|
||||
searchParams.parent_directory_id == null ?
|
||||
{}
|
||||
: { parentId: searchParams.parent_directory_id },
|
||||
)
|
||||
const file = addFile(body.fileName, {
|
||||
id: backend.FileId(body.uploadId),
|
||||
title: body.fileName,
|
||||
...(body.parentDirectoryId != null ? { parentId: body.parentDirectoryId } : {}),
|
||||
})
|
||||
|
||||
return { path: '', id: file.id, project: null } satisfies backend.FileInfo
|
||||
return { id: file.id, project: null } satisfies backend.UploadedLargeAsset
|
||||
})
|
||||
|
||||
await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => {
|
||||
|
@ -6,7 +6,7 @@ import { Button, Checkbox, Dropdown, Text } from '#/components/AriaComponents'
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import { useBackendQuery } from '#/hooks/backendHooks'
|
||||
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { constantValueOfSchema, getSchemaName, lookupDef } from '#/utilities/jsonSchema'
|
||||
import { asObject, singletonObjectOrNull } from '#/utilities/object'
|
||||
@ -37,7 +37,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { noBorder = false, isAbsent = false, value, onChange } = props
|
||||
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
||||
// but it is more convenient to avoid having plugin infrastructure.
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const remoteBackend = useRemoteBackend()
|
||||
const { getText } = useText()
|
||||
const [autocompleteText, setAutocompleteText] = useState(() =>
|
||||
typeof value === 'string' ? value : null,
|
||||
|
@ -1,8 +1,9 @@
|
||||
/** @file Hooks for interacting with the backend. */
|
||||
import { useMemo } from 'react'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
type Mutation,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query'
|
||||
import { toast } from 'react-toastify'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import {
|
||||
@ -18,8 +20,12 @@ import {
|
||||
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,
|
||||
@ -31,8 +37,13 @@ import {
|
||||
type UserGroupInfo,
|
||||
} from '#/services/Backend'
|
||||
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
||||
import { usePreventNavigation } from '#/utilities/preventNavigation'
|
||||
import { toRfc3339 } from 'enso-common/src/utilities/data/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 ===
|
||||
// ============================
|
||||
@ -83,7 +94,9 @@ export type MutationMethod = DefineBackendMethods<
|
||||
| 'updateProject'
|
||||
| 'updateSecret'
|
||||
| 'updateUser'
|
||||
| 'uploadFile'
|
||||
| 'uploadFileChunk'
|
||||
| 'uploadFileEnd'
|
||||
| 'uploadFileStart'
|
||||
| 'uploadOrganizationPicture'
|
||||
| 'uploadUserPicture'
|
||||
>
|
||||
@ -183,7 +196,7 @@ const INVALIDATION_MAP: Partial<
|
||||
createSecret: ['listDirectory'],
|
||||
updateSecret: ['listDirectory'],
|
||||
createDatalink: ['listDirectory', 'getDatalink'],
|
||||
uploadFile: ['listDirectory'],
|
||||
uploadFileEnd: ['listDirectory'],
|
||||
copyAsset: ['listDirectory', 'listAssetVersions'],
|
||||
deleteAsset: ['listDirectory', 'listAssetVersions'],
|
||||
undoDeleteAsset: ['listDirectory'],
|
||||
@ -276,6 +289,19 @@ export function useListUserGroupsWithUsers(
|
||||
}, [listUserGroupsQuery.data, listUsersQuery.data])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** Data for a specific asset. */
|
||||
export function useAssetPassiveListener(
|
||||
backendType: BackendType,
|
||||
@ -368,3 +394,194 @@ export function useBackendMutationState<Method extends MutationMethod, Result>(
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ createGetProjectDetailsQuery.createPassiveListener = (id: LaunchedProjectId) =>
|
||||
export function useOpenProjectMutation() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const session = authProvider.useFullUserSession()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const setProjectAsset = useSetProjectAsset()
|
||||
|
||||
@ -204,7 +204,7 @@ export function useOpenProjectMutation() {
|
||||
/** Mutation to close a project. */
|
||||
export function useCloseProjectMutation() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const setProjectAsset = useSetProjectAsset()
|
||||
|
||||
@ -249,7 +249,7 @@ export function useCloseProjectMutation() {
|
||||
/** Mutation to rename a project. */
|
||||
export function useRenameProjectMutation() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const updateLaunchedProjects = useUpdateLaunchedProjects()
|
||||
|
||||
|
@ -1,6 +1,4 @@
|
||||
/** @file */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastify from 'react-toastify'
|
||||
|
||||
import type * as text from 'enso-common/src/text'
|
||||
@ -8,6 +6,7 @@ import type * as text from 'enso-common/src/text'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import * as errorModule from '#/utilities/error'
|
||||
|
||||
// ===========================
|
||||
@ -21,6 +20,48 @@ export type ToastAndLogCallback = ReturnType<typeof useToastAndLog>
|
||||
// === useToastAndLog ===
|
||||
// ======================
|
||||
|
||||
/**
|
||||
* Return a function to send a toast with rendered error message. The same message is also logged
|
||||
* as an error.
|
||||
*/
|
||||
export function useToastAndLogWithId() {
|
||||
const { getText } = textProvider.useText()
|
||||
const logger = loggerProvider.useLogger()
|
||||
|
||||
return useEventCallback(
|
||||
<K extends text.TextId, T>(
|
||||
toastId: toastify.Id,
|
||||
textId: K | null,
|
||||
...[error, ...replacements]: text.Replacements[K] extends readonly [] ?
|
||||
[error?: Error | errorModule.MustNotBeKnown<T>]
|
||||
: [error: Error | errorModule.MustNotBeKnown<T> | null, ...replacements: text.Replacements[K]]
|
||||
) => {
|
||||
const messagePrefix =
|
||||
textId == null ? null
|
||||
// This is SAFE, as `replacements` is only `[]` if it was already `[]`.
|
||||
// See the above conditional type.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
: getText(textId, ...(replacements as text.Replacements[K]))
|
||||
const message =
|
||||
error == null ?
|
||||
`${messagePrefix ?? ''}.`
|
||||
// DO NOT explicitly pass the generic parameter anywhere else.
|
||||
// It is only being used here because this function also checks for
|
||||
// `MustNotBeKnown<T>`.
|
||||
: `${
|
||||
messagePrefix != null ? messagePrefix + ': ' : ''
|
||||
}${errorModule.getMessageOrToString<unknown>(error)}`
|
||||
toastify.toast.update(toastId, {
|
||||
type: 'error',
|
||||
render: message,
|
||||
isLoading: false,
|
||||
autoClose: null,
|
||||
})
|
||||
logger.error(message)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a function to send a toast with rendered error message. The same message is also logged
|
||||
* as an error.
|
||||
@ -29,7 +70,7 @@ export function useToastAndLog() {
|
||||
const { getText } = textProvider.useText()
|
||||
const logger = loggerProvider.useLogger()
|
||||
|
||||
return React.useCallback(
|
||||
return useEventCallback(
|
||||
<K extends text.TextId, T>(
|
||||
textId: K | null,
|
||||
...[error, ...replacements]: text.Replacements[K] extends readonly [] ?
|
||||
@ -55,6 +96,5 @@ export function useToastAndLog() {
|
||||
logger.error(message)
|
||||
return id
|
||||
},
|
||||
[getText, logger],
|
||||
)
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackendModule from '#/services/LocalBackend'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import { useUploadFileWithToastMutation } from '#/hooks/backendHooks'
|
||||
import {
|
||||
usePasteData,
|
||||
useSetAssetPanelProps,
|
||||
@ -101,9 +101,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
: isCloud ? encodeURI(pathRaw)
|
||||
: pathRaw
|
||||
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
|
||||
const uploadFileMutation = reactQuery.useMutation(
|
||||
backendMutationOptions(remoteBackend, 'uploadFile'),
|
||||
)
|
||||
const uploadFileToCloudMutation = useUploadFileWithToastMutation(remoteBackend)
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
@ -298,25 +296,27 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
hidden={hidden}
|
||||
action="uploadToCloud"
|
||||
doAction={async () => {
|
||||
if (remoteBackend == null) {
|
||||
toastAndLog('offlineUploadFilesError')
|
||||
} else {
|
||||
try {
|
||||
const projectResponse = await fetch(
|
||||
`./api/project-manager/projects/${localBackendModule.extractTypeAndId(asset.id).id}/enso-project`,
|
||||
)
|
||||
await uploadFileMutation.mutateAsync([
|
||||
{
|
||||
fileName: `${asset.title}.enso-project`,
|
||||
fileId: null,
|
||||
parentDirectoryId: null,
|
||||
},
|
||||
await projectResponse.blob(),
|
||||
])
|
||||
toast.toast.success(getText('uploadProjectToCloudSuccess'))
|
||||
} catch (error) {
|
||||
toastAndLog('uploadProjectToCloudError', error)
|
||||
}
|
||||
try {
|
||||
const projectResponse = await fetch(
|
||||
`./api/project-manager/projects/${localBackendModule.extractTypeAndId(asset.id).id}/enso-project`,
|
||||
)
|
||||
// This DOES NOT update the cloud assets list when it
|
||||
// completes, as the current backend is not the remote
|
||||
// (cloud) backend. The user may change to the cloud backend
|
||||
// while this request is in progress, however this is
|
||||
// uncommon enough that it is not worth the added complexity.
|
||||
const fileName = `${asset.title}.enso-project`
|
||||
await uploadFileToCloudMutation.mutateAsync(
|
||||
{
|
||||
fileName,
|
||||
fileId: null,
|
||||
parentDirectoryId: null,
|
||||
},
|
||||
new File([await projectResponse.blob()], fileName),
|
||||
)
|
||||
toast.toast.success(getText('uploadProjectToCloudSuccess'))
|
||||
} catch (error) {
|
||||
toastAndLog('uploadProjectToCloudError', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -337,8 +337,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
)}
|
||||
{(asset.type === backendModule.AssetType.secret ||
|
||||
asset.type === backendModule.AssetType.datalink) &&
|
||||
canEditThisAsset &&
|
||||
remoteBackend != null && (
|
||||
canEditThisAsset && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="edit"
|
||||
|
@ -55,7 +55,11 @@ import AssetEventType from '#/events/AssetEventType'
|
||||
import { useCutAndPaste, type AssetListEvent } from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import { useAutoScroll } from '#/hooks/autoScrollHooks'
|
||||
import { backendMutationOptions, backendQueryOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import {
|
||||
backendMutationOptions,
|
||||
useBackendQuery,
|
||||
useUploadFileWithToastMutation,
|
||||
} from '#/hooks/backendHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useIntersectionRatio } from '#/hooks/intersectionHooks'
|
||||
import { useOpenProject } from '#/hooks/projectHooks'
|
||||
@ -132,8 +136,7 @@ import {
|
||||
type ProjectAsset,
|
||||
type SecretAsset,
|
||||
} from '#/services/Backend'
|
||||
import LocalBackend, { extractTypeAndId, newProjectId } from '#/services/LocalBackend'
|
||||
import { UUID } from '#/services/ProjectManager'
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
|
||||
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
||||
import type { AssetQueryKey } from '#/utilities/AssetQuery'
|
||||
@ -431,7 +434,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
|
||||
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
|
||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
||||
const uploadFileMutation = useMutation(backendMutationOptions(backend, 'uploadFile'))
|
||||
const uploadFileMutation = useUploadFileWithToastMutation(backend)
|
||||
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
|
||||
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
|
||||
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
|
||||
@ -1722,83 +1725,30 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { extension } = extractProjectExtension(file.name)
|
||||
const title = stripProjectExtension(asset.title)
|
||||
|
||||
const assetNode = nodeMapRef.current.get(asset.id)
|
||||
|
||||
if (backend.type === BackendType.local && localBackend != null) {
|
||||
const directory = extractTypeAndId(assetNode?.directoryId ?? asset.parentId).id
|
||||
let id: string
|
||||
if (
|
||||
'backendApi' in window &&
|
||||
// This non-standard property is defined in Electron.
|
||||
'path' in file &&
|
||||
typeof file.path === 'string'
|
||||
) {
|
||||
const projectInfo = await window.backendApi.importProjectFromPath(
|
||||
file.path,
|
||||
directory,
|
||||
title,
|
||||
)
|
||||
id = projectInfo.id
|
||||
} else {
|
||||
const searchParams = new URLSearchParams({ directory, name: title }).toString()
|
||||
// Ideally this would use `file.stream()`, to minimize RAM
|
||||
// requirements. for uploading large projects. Unfortunately,
|
||||
// this requires HTTP/2, which is HTTPS-only, so it will not
|
||||
// work on `http://localhost`.
|
||||
const body =
|
||||
window.location.protocol === 'https:' ?
|
||||
file.stream()
|
||||
: await file.arrayBuffer()
|
||||
const path = `./api/upload-project?${searchParams}`
|
||||
const response = await fetch(path, { method: 'POST', body })
|
||||
id = await response.text()
|
||||
}
|
||||
const projectId = newProjectId(UUID(id))
|
||||
addIdToSelection(projectId)
|
||||
|
||||
await queryClient
|
||||
.fetchQuery(
|
||||
backendQueryOptions(backend, 'getProjectDetails', [
|
||||
projectId,
|
||||
asset.parentId,
|
||||
asset.title,
|
||||
]),
|
||||
)
|
||||
.catch((error) => {
|
||||
deleteAsset(projectId)
|
||||
toastAndLog('uploadProjectError', error)
|
||||
})
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [backend.type, 'listDirectory', asset.parentId],
|
||||
await uploadFileMutation
|
||||
.mutateAsync(
|
||||
{
|
||||
fileId,
|
||||
fileName: `${title}.${extension}`,
|
||||
parentDirectoryId: asset.parentId,
|
||||
},
|
||||
file,
|
||||
)
|
||||
.then(({ id }) => {
|
||||
addIdToSelection(id)
|
||||
})
|
||||
.catch((error) => {
|
||||
toastAndLog('uploadProjectError', error)
|
||||
})
|
||||
} else {
|
||||
uploadFileMutation
|
||||
.mutateAsync([
|
||||
{
|
||||
fileId,
|
||||
fileName: `${title}.${extension}`,
|
||||
parentDirectoryId: asset.parentId,
|
||||
},
|
||||
file,
|
||||
])
|
||||
.then(({ id }) => {
|
||||
addIdToSelection(id)
|
||||
})
|
||||
.catch((error) => {
|
||||
deleteAsset(asset.id)
|
||||
toastAndLog('uploadProjectError', error)
|
||||
})
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case assetIsFile(asset): {
|
||||
void uploadFileMutation
|
||||
.mutateAsync([
|
||||
await uploadFileMutation
|
||||
.mutateAsync(
|
||||
{ fileId, fileName: asset.title, parentDirectoryId: asset.parentId },
|
||||
file,
|
||||
])
|
||||
)
|
||||
.then(({ id }) => {
|
||||
addIdToSelection(id)
|
||||
})
|
||||
@ -1835,8 +1785,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
insertAssets(assets, event.parentId)
|
||||
|
||||
void Promise.all(assets.map((asset) => doUploadFile(asset, 'new')))
|
||||
} else {
|
||||
const siblingFilesByName = new Map(siblingFiles.map((file) => [file.title, file]))
|
||||
@ -1878,18 +1826,17 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
siblingProjectNames={siblingProjectsByName.keys()}
|
||||
nonConflictingFileCount={files.length - conflictingFiles.length}
|
||||
nonConflictingProjectCount={projects.length - conflictingProjects.length}
|
||||
doUpdateConflicting={(resolvedConflicts) => {
|
||||
for (const conflict of resolvedConflicts) {
|
||||
const isUpdating = conflict.current.title === conflict.new.title
|
||||
|
||||
const asset = isUpdating ? conflict.current : conflict.new
|
||||
|
||||
fileMap.set(asset.id, conflict.file)
|
||||
|
||||
void doUploadFile(asset, isUpdating ? 'update' : 'new')
|
||||
}
|
||||
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={() => {
|
||||
doUploadNonConflicting={async () => {
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
const newFiles = files
|
||||
@ -1923,9 +1870,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
const assets = [...newFiles, ...newProjects]
|
||||
|
||||
for (const asset of assets) {
|
||||
void doUploadFile(asset, 'new')
|
||||
}
|
||||
await Promise.allSettled(assets.map((asset) => doUploadFile(asset, 'new')))
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
@ -228,7 +228,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
const { category, setCategory } = props
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const setPage = useSetPage()
|
||||
const [, setSearchParams] = useSearchParams()
|
||||
|
@ -8,7 +8,7 @@ import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useBackend, useLocalBackend, useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useBackend, useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import {
|
||||
FilterBy,
|
||||
type AssetId,
|
||||
@ -155,7 +155,7 @@ export function canTransferBetweenCategories(from: Category, to: Category) {
|
||||
|
||||
/** A function to transfer a list of assets between categories. */
|
||||
export function useTransferBetweenCategories(currentCategory: Category) {
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const remoteBackend = useRemoteBackend()
|
||||
const localBackend = useLocalBackend()
|
||||
const backend = useBackend(currentCategory)
|
||||
const { user } = useFullUserSession()
|
||||
|
@ -51,6 +51,7 @@ import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import { ProjectState, type CreatedProject, type Project, type ProjectId } from '#/services/Backend'
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import { inputFiles } from '#/utilities/input'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
|
||||
// ================
|
||||
@ -367,9 +368,9 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
icon={DataUploadIcon}
|
||||
isDisabled={shouldBeDisabled}
|
||||
aria-label={getText('uploadFiles')}
|
||||
onPress={() => {
|
||||
unsetModal()
|
||||
uploadFilesRef.current?.click()
|
||||
onPress={async () => {
|
||||
const files = await inputFiles()
|
||||
doUploadFiles(Array.from(files))
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
@ -82,7 +82,7 @@ export interface EditorProps {
|
||||
export default function Editor(props: EditorProps) {
|
||||
const { project, hidden, isOpening, startProject, isOpeningFailed, openingError } = props
|
||||
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
|
||||
const projectStatusQuery = projectHooks.createGetProjectDetailsQuery({
|
||||
@ -170,9 +170,7 @@ function EditorInternal(props: EditorInternalProps) {
|
||||
|
||||
const logEvent = React.useCallback(
|
||||
(message: string, projectId?: string | null, metadata?: object | null) => {
|
||||
if (remoteBackend) {
|
||||
void remoteBackend.logEvent(message, projectId, metadata)
|
||||
}
|
||||
void remoteBackend.logEvent(message, projectId, metadata)
|
||||
},
|
||||
[remoteBackend],
|
||||
)
|
||||
|
@ -1,6 +1,4 @@
|
||||
/** @file A context menu available everywhere in the directory. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
@ -10,7 +8,6 @@ import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||
|
||||
@ -20,6 +17,7 @@ import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
import { useDriveStore } from '#/providers/DriveProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import { inputFiles } from '#/utilities/input'
|
||||
|
||||
/** Props for a {@link GlobalContextMenu}. */
|
||||
export interface GlobalContextMenuProps {
|
||||
@ -41,7 +39,6 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const filesInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const driveStore = useDriveStore()
|
||||
const hasPasteData = useStore(
|
||||
@ -51,51 +48,17 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
|
||||
return (
|
||||
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
|
||||
{!hidden && (
|
||||
<aria.Input
|
||||
ref={filesInputRef}
|
||||
multiple
|
||||
type="file"
|
||||
id="context_menu_file_input"
|
||||
className="hidden"
|
||||
onInput={(event) => {
|
||||
if (event.currentTarget.files != null) {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
files: Array.from(event.currentTarget.files),
|
||||
})
|
||||
unsetModal()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="uploadFiles"
|
||||
doAction={() => {
|
||||
if (filesInputRef.current?.isConnected === true) {
|
||||
filesInputRef.current.click()
|
||||
} else {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.style.display = 'none'
|
||||
document.body.appendChild(input)
|
||||
input.addEventListener('input', () => {
|
||||
if (input.files != null) {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
files: Array.from(input.files),
|
||||
})
|
||||
unsetModal()
|
||||
}
|
||||
})
|
||||
input.click()
|
||||
input.remove()
|
||||
}
|
||||
doAction={async () => {
|
||||
const files = await inputFiles()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
files: Array.from(files),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ContextMenuEntry
|
||||
|
@ -21,7 +21,7 @@ import * as backendProvider from '#/providers/BackendProvider'
|
||||
*/
|
||||
export function OpenAppWatcher() {
|
||||
const context = router.useOutletContext()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
|
||||
const { mutate: logUserOpenAppMutate } = reactQuery.useMutation({
|
||||
mutationFn: () => remoteBackend.logEvent('open_app'),
|
||||
|
@ -29,7 +29,7 @@ const LIST_USERS_STALE_TIME_MS = 60_000
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function MembersSettingsSection() {
|
||||
const { getText } = textProvider.useText()
|
||||
const backend = backendProvider.useRemoteBackendStrict()
|
||||
const backend = backendProvider.useRemoteBackend()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
@ -13,7 +13,7 @@ import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
import SearchBar from '#/layouts/SearchBar'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useLocalBackend, useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
@ -46,7 +46,7 @@ export interface SettingsProps {
|
||||
/** Settings screen. */
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const backend = useRemoteBackendStrict()
|
||||
const backend = useRemoteBackend()
|
||||
const localBackend = useLocalBackend()
|
||||
const [tab, setTab] = useSearchParamsState(
|
||||
'SettingsTab',
|
||||
|
@ -13,6 +13,7 @@ import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
import * as object from '#/utilities/object'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
@ -46,8 +47,8 @@ export interface DuplicateAssetsModalProps {
|
||||
readonly siblingProjectNames: Iterable<string>
|
||||
readonly nonConflictingFileCount: number
|
||||
readonly nonConflictingProjectCount: number
|
||||
readonly doUploadNonConflicting: () => void
|
||||
readonly doUpdateConflicting: (toUpdate: ConflictingAsset[]) => void
|
||||
readonly doUploadNonConflicting: () => Promise<void> | void
|
||||
readonly doUpdateConflicting: (toUpdate: ConflictingAsset[]) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** A modal for creating a new label. */
|
||||
@ -68,6 +69,19 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
const firstConflict = conflictingFiles[0] ?? conflictingProjects[0]
|
||||
const otherFilesCount = Math.max(0, conflictingFiles.length - 1)
|
||||
const otherProjectsCount = conflictingProjects.length - (conflictingFiles.length > 0 ? 0 : 1)
|
||||
const updateConflictingMutation = useMutation({
|
||||
mutationKey: ['updateConflicting'],
|
||||
mutationFn: async (...args: Parameters<typeof doUpdateConflicting>) => {
|
||||
await doUpdateConflicting(...args)
|
||||
},
|
||||
})
|
||||
const uploadNonConflictingMutation = useMutation({
|
||||
mutationKey: ['uploadNonConflicting'],
|
||||
mutationFn: async (...args: Parameters<typeof doUploadNonConflicting>) => {
|
||||
await doUploadNonConflicting(...args)
|
||||
},
|
||||
})
|
||||
const isLoading = uploadNonConflictingMutation.isPending || updateConflictingMutation.isPending
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const name of siblingFileNamesRaw) {
|
||||
@ -174,8 +188,8 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
<ariaComponents.Button
|
||||
variant="outline"
|
||||
isDisabled={didUploadNonConflicting}
|
||||
onPress={() => {
|
||||
doUploadNonConflicting()
|
||||
onPress={async () => {
|
||||
await doUploadNonConflicting()
|
||||
setDidUploadNonConflicting(true)
|
||||
}}
|
||||
>
|
||||
@ -202,8 +216,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.Button
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
doUpdateConflicting([firstConflict])
|
||||
onPress={async () => {
|
||||
switch (firstConflict.new.type) {
|
||||
case backendModule.AssetType.file: {
|
||||
setConflictingFiles((oldConflicts) => oldConflicts.slice(1))
|
||||
@ -214,6 +227,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
break
|
||||
}
|
||||
}
|
||||
await doUpdateConflicting([firstConflict])
|
||||
}}
|
||||
>
|
||||
{getText('update')}
|
||||
@ -261,9 +275,15 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button
|
||||
variant="submit"
|
||||
onPress={() => {
|
||||
doUploadNonConflicting()
|
||||
doUpdateConflicting([...conflictingFiles, ...conflictingProjects])
|
||||
loading={isLoading}
|
||||
onPress={async () => {
|
||||
await Promise.allSettled([
|
||||
uploadNonConflictingMutation.mutateAsync(),
|
||||
updateConflictingMutation.mutateAsync([
|
||||
...conflictingFiles,
|
||||
...conflictingProjects,
|
||||
]),
|
||||
])
|
||||
unsetModal()
|
||||
}}
|
||||
>
|
||||
@ -272,13 +292,13 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
|
||||
<ariaComponents.Button
|
||||
variant="accent"
|
||||
onPress={() => {
|
||||
loading={isLoading}
|
||||
onPress={async () => {
|
||||
const resolved = doRename([...conflictingFiles, ...conflictingProjects])
|
||||
|
||||
doUploadNonConflicting()
|
||||
|
||||
doUpdateConflicting(resolved)
|
||||
|
||||
await Promise.allSettled([
|
||||
uploadNonConflictingMutation.mutateAsync(),
|
||||
updateConflictingMutation.mutateAsync(resolved),
|
||||
])
|
||||
unsetModal()
|
||||
}}
|
||||
>
|
||||
@ -290,7 +310,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
getText('renameNewFiles')
|
||||
: getText('renameNewProjects')}
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button variant="outline" onPress={unsetModal}>
|
||||
<ariaComponents.Button variant="outline" loading={isLoading} onPress={unsetModal}>
|
||||
{getText('cancel')}
|
||||
</ariaComponents.Button>
|
||||
</ariaComponents.ButtonGroup>
|
||||
|
@ -32,7 +32,7 @@ export interface InviteUsersFormProps {
|
||||
export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
const { onSubmitted } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const backend = backendProvider.useRemoteBackendStrict()
|
||||
const backend = backendProvider.useRemoteBackend()
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
|
@ -7,7 +7,7 @@ import { SUPPORT_EMAIL, SUPPORT_EMAIL_URL } from '#/appUtils'
|
||||
import { Alert, Button, ButtonGroup, Dialog, Form, Text } from '#/components/AriaComponents'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import { useAuth, useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
@ -19,7 +19,7 @@ import { useMutation } from '@tanstack/react-query'
|
||||
export function InvitedToOrganizationModal() {
|
||||
const { getText } = useText()
|
||||
const { session } = useAuth()
|
||||
const backend = useRemoteBackendStrict()
|
||||
const backend = useRemoteBackend()
|
||||
const { user } = useFullUserSession()
|
||||
const shouldDisplay = user.newOrganizationName != null && user.newOrganizationInvite != null
|
||||
|
||||
|
@ -18,7 +18,7 @@ import { usePaywall } from '#/hooks/billing'
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
@ -76,7 +76,7 @@ export default function ManagePermissionsModal<Asset extends AnyAsset = AnyAsset
|
||||
) {
|
||||
const { backend, category, item: itemRaw, self, doRemoveSelf, eventTarget } = props
|
||||
const item = useAssetPassiveListenerStrict(backend.type, itemRaw.id, itemRaw.parentId, category)
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const remoteBackend = useRemoteBackend()
|
||||
const { user } = useFullUserSession()
|
||||
const { unsetModal } = useSetModal()
|
||||
const toastAndLog = useToastAndLog()
|
||||
|
@ -35,7 +35,7 @@ const PLANS_TO_SPECIFY_ORG_NAME = [backendModule.Plan.team, backendModule.Plan.e
|
||||
export function SetupOrganizationAfterSubscribe() {
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const backend = backendProvider.useRemoteBackendStrict()
|
||||
const backend = backendProvider.useRemoteBackend()
|
||||
const { session } = authProvider.useAuth()
|
||||
|
||||
const user = session != null && 'user' in session ? session.user : null
|
||||
|
@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { DIALOG_BACKGROUND } from '#/components/AriaComponents'
|
||||
import { usePaywall } from '#/hooks/billing'
|
||||
import { useAuth } from '#/providers/AuthProvider'
|
||||
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { Plan, PLANS } from '#/services/Backend'
|
||||
import type { VariantProps } from '#/utilities/tailwindVariants'
|
||||
@ -71,7 +71,7 @@ export function PlanSelector(props: PlanSelectorProps) {
|
||||
} = props
|
||||
|
||||
const { getText } = useText()
|
||||
const backend = useRemoteBackendStrict()
|
||||
const backend = useRemoteBackend()
|
||||
const { refetchSession } = useAuth()
|
||||
const { getPaywallLevel } = usePaywall({ plan: userPlan })
|
||||
|
||||
|
@ -17,7 +17,7 @@ import { DASHBOARD_PATH, LOGIN_PATH } from '#/appUtils'
|
||||
import { useIsFirstRender } from '#/hooks/mountHooks'
|
||||
|
||||
import { useAuth, UserSessionType, useUserSession } from '#/providers/AuthProvider'
|
||||
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
@ -142,7 +142,7 @@ const BASE_STEPS: Step[] = [
|
||||
/** Setup step for setting organization name. */
|
||||
component: function SetOrganizationNameStep({ goToNextStep, goToPreviousStep, session }) {
|
||||
const { getText } = textProvider.useText()
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const remoteBackend = useRemoteBackend()
|
||||
const userId = session && 'user' in session ? session.user.userId : null
|
||||
|
||||
const { data: defaultOrgName } = useSuspenseQuery({
|
||||
@ -248,7 +248,7 @@ const BASE_STEPS: Step[] = [
|
||||
/** Setup step for creating the first user group. */
|
||||
component: function CreateUserGroupStep({ goToNextStep, goToPreviousStep }) {
|
||||
const { getText } = textProvider.useText()
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const remoteBackend = useRemoteBackend()
|
||||
|
||||
const defaultUserGroupMaxLength = 64
|
||||
|
||||
|
@ -186,7 +186,7 @@ export interface AuthProviderProps {
|
||||
export default function AuthProvider(props: AuthProviderProps) {
|
||||
const { authService, onAuthenticated } = props
|
||||
const { children } = props
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const { cognito } = authService
|
||||
const { session, sessionQueryKey } = sessionProvider.useSession()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
|
@ -85,23 +85,11 @@ export default function BackendProvider(props: BackendProviderProps) {
|
||||
// === useRemoteBackend ===
|
||||
// ========================
|
||||
|
||||
/**
|
||||
* Get the Remote Backend. Since the RemoteBackend is always defined, `null` is never returned.
|
||||
* @deprecated Use {@link useRemoteBackendStrict} instead.
|
||||
*/
|
||||
export function useRemoteBackend() {
|
||||
return React.useContext(BackendContext).remoteBackend
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// === useRemoteBackendStrict ===
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* Get the Remote Backend.
|
||||
* @throws {Error} when no Remote Backend exists. This should never happen.
|
||||
*/
|
||||
export function useRemoteBackendStrict() {
|
||||
export function useRemoteBackend() {
|
||||
const remoteBackend = React.useContext(BackendContext).remoteBackend
|
||||
if (remoteBackend == null) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -129,7 +117,7 @@ export function useLocalBackend() {
|
||||
* This should never happen unless the build is misconfigured.
|
||||
*/
|
||||
export function useBackend(category: Category) {
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const remoteBackend = useRemoteBackend()
|
||||
const localBackend = useLocalBackend()
|
||||
|
||||
if (isCloudCategory(category)) {
|
||||
|
@ -14,6 +14,8 @@ import { download } from '#/utilities/download'
|
||||
import { tryGetMessage } from '#/utilities/error'
|
||||
import { getFileName, getFolderPath } from '#/utilities/fileInfo'
|
||||
import { getDirectoryAndName, joinPath } from '#/utilities/path'
|
||||
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
// =============================
|
||||
// === ipWithSocketToAddress ===
|
||||
@ -100,6 +102,8 @@ export function extractTypeAndId<Id extends backend.AssetId>(id: Id): AssetTypeA
|
||||
*/
|
||||
export default class LocalBackend extends Backend {
|
||||
readonly type = backend.BackendType.local
|
||||
/** All files that have been uploaded to the Project Manager. */
|
||||
uploadedFiles: Map<string, backend.UploadedLargeAsset> = new Map()
|
||||
private readonly projectManager: ProjectManager
|
||||
|
||||
/** Create a {@link LocalBackend}. */
|
||||
@ -647,26 +651,72 @@ export default class LocalBackend extends Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload a file. */
|
||||
override async uploadFile(
|
||||
params: backend.UploadFileRequestParams,
|
||||
file: Blob,
|
||||
): Promise<backend.FileInfo> {
|
||||
/**
|
||||
* Begin uploading a large file.
|
||||
*/
|
||||
override async uploadFileStart(
|
||||
body: backend.UploadFileRequestParams,
|
||||
file: File,
|
||||
): Promise<backend.UploadLargeFileMetadata> {
|
||||
const parentPath =
|
||||
params.parentDirectoryId == null ?
|
||||
body.parentDirectoryId == null ?
|
||||
this.projectManager.rootDirectory
|
||||
: extractTypeAndId(params.parentDirectoryId).id
|
||||
const path = joinPath(parentPath, params.fileName)
|
||||
const searchParams = new URLSearchParams([
|
||||
['file_name', params.fileName],
|
||||
...(params.parentDirectoryId == null ? [] : [['directory', parentPath]]),
|
||||
]).toString()
|
||||
await fetch(`${APP_BASE_URL}/api/upload-file?${searchParams}`, {
|
||||
method: 'POST',
|
||||
body: file,
|
||||
})
|
||||
// `project` MUST BE `null` as uploading projects uses a separate endpoint.
|
||||
return { path, id: newFileId(path), project: null }
|
||||
: extractTypeAndId(body.parentDirectoryId).id
|
||||
const filePath = joinPath(parentPath, body.fileName)
|
||||
const uploadId = uniqueString()
|
||||
if (backend.fileIsNotProject(file)) {
|
||||
const searchParams = new URLSearchParams([
|
||||
['file_name', body.fileName],
|
||||
...(body.parentDirectoryId == null ? [] : [['directory', parentPath]]),
|
||||
]).toString()
|
||||
const path = `${APP_BASE_URL}/api/upload-file?${searchParams}`
|
||||
await fetch(path, { method: 'POST', body: file })
|
||||
this.uploadedFiles.set(uploadId, { id: newFileId(filePath), project: null })
|
||||
} else {
|
||||
const title = backend.stripProjectExtension(body.fileName)
|
||||
let id: string
|
||||
if (
|
||||
'backendApi' in window &&
|
||||
// This non-standard property is defined in Electron.
|
||||
'path' in file &&
|
||||
typeof file.path === 'string'
|
||||
) {
|
||||
const projectInfo = await window.backendApi.importProjectFromPath(
|
||||
file.path,
|
||||
parentPath,
|
||||
title,
|
||||
)
|
||||
id = projectInfo.id
|
||||
} else {
|
||||
const searchParams = new URLSearchParams({
|
||||
directory: parentPath,
|
||||
name: title,
|
||||
}).toString()
|
||||
const path = `${APP_BASE_URL}/api/upload-project?${searchParams}`
|
||||
const response = await fetch(path, { method: 'POST', body: file })
|
||||
id = await response.text()
|
||||
}
|
||||
const projectId = newProjectId(projectManager.UUID(id))
|
||||
const project = await this.getProjectDetails(projectId, body.parentDirectoryId, body.fileName)
|
||||
this.uploadedFiles.set(uploadId, { id: projectId, project })
|
||||
}
|
||||
return { presignedUrls: [], uploadId, sourcePath: backend.S3FilePath('') }
|
||||
}
|
||||
|
||||
/** Upload a chunk of a large file. */
|
||||
override uploadFileChunk(): Promise<backend.S3MultipartPart> {
|
||||
// Do nothing, the entire file has already been uploaded in `uploadFileStart`.
|
||||
return Promise.resolve({ eTag: '', partNumber: 0 })
|
||||
}
|
||||
|
||||
/** Finish uploading a large file. */
|
||||
override uploadFileEnd(
|
||||
body: backend.UploadFileEndRequestBody,
|
||||
): Promise<backend.UploadedLargeAsset> {
|
||||
// Do nothing, the entire file has already been uploaded in `uploadFileStart`.
|
||||
const file = this.uploadedFiles.get(body.uploadId)
|
||||
invariant(file, 'Uploaded file not found')
|
||||
return Promise.resolve(file)
|
||||
}
|
||||
|
||||
/** Change the name of a file. */
|
||||
|
@ -171,7 +171,7 @@ export default class RemoteBackend extends Backend {
|
||||
throw textId
|
||||
} else {
|
||||
const error =
|
||||
response == null ?
|
||||
response == null || response.headers.get('Content-Type') !== 'application/json' ?
|
||||
{ message: 'unknown error' }
|
||||
// This is SAFE only when the response has been confirmed to have an erroring status code.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -889,25 +889,58 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file.
|
||||
* Begin uploading a large file.
|
||||
* @throws An error if a non-successful status code (not 200-299) was received.
|
||||
*/
|
||||
override async uploadFile(
|
||||
params: backend.UploadFileRequestParams,
|
||||
file: Blob,
|
||||
): Promise<backend.FileInfo> {
|
||||
const paramsString = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
|
||||
file_name: params.fileName,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
|
||||
...(params.fileId != null ? { file_id: params.fileId } : {}),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
|
||||
...(params.parentDirectoryId ? { parent_directory_id: params.parentDirectoryId } : {}),
|
||||
}).toString()
|
||||
const path = `${remoteBackendPaths.UPLOAD_FILE_PATH}?${paramsString}`
|
||||
const response = await this.postBinary<backend.FileInfo>(path, file)
|
||||
override async uploadFileStart(
|
||||
body: backend.UploadFileRequestParams,
|
||||
file: File,
|
||||
): Promise<backend.UploadLargeFileMetadata> {
|
||||
const path = remoteBackendPaths.UPLOAD_FILE_START_PATH
|
||||
const requestBody: backend.UploadFileStartRequestBody = {
|
||||
fileName: body.fileName,
|
||||
size: file.size,
|
||||
}
|
||||
const response = await this.post<backend.UploadLargeFileMetadata>(path, requestBody)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return await this.throw(response, 'uploadFileBackendError')
|
||||
return await this.throw(response, 'uploadFileStartBackendError')
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a chunk of a large file.
|
||||
* @throws An error if a non-successful status code (not 200-299) was received.
|
||||
*/
|
||||
override async uploadFileChunk(
|
||||
url: backend.HttpsUrl,
|
||||
file: Blob,
|
||||
index: number,
|
||||
): Promise<backend.S3MultipartPart> {
|
||||
const start = index * backend.S3_CHUNK_SIZE_BYTES
|
||||
const end = Math.min(start + backend.S3_CHUNK_SIZE_BYTES, file.size)
|
||||
const body = file.slice(start, end)
|
||||
const response = await fetch(url, { method: 'PUT', body })
|
||||
const eTag = response.headers.get('ETag')
|
||||
if (!responseIsSuccessful(response) || eTag == null) {
|
||||
return await this.throw(response, 'uploadFileChunkBackendError')
|
||||
} else {
|
||||
return { eTag, partNumber: index + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish uploading a large file.
|
||||
* @throws An error if a non-successful status code (not 200-299) was received.
|
||||
*/
|
||||
override async uploadFileEnd(
|
||||
body: backend.UploadFileEndRequestBody,
|
||||
): Promise<backend.UploadedLargeAsset> {
|
||||
const path = remoteBackendPaths.UPLOAD_FILE_END_PATH
|
||||
const response = await this.post<backend.UploadedLargeAsset>(path, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return await this.throw(response, 'uploadFileEndBackendError')
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -1246,7 +1279,8 @@ export default class RemoteBackend extends Backend {
|
||||
|
||||
/** Download from an arbitrary URL that is assumed to originate from this backend. */
|
||||
override async download(url: string, name?: string) {
|
||||
await download.downloadWithHeaders(url, this.client.defaultHeaders, name)
|
||||
download.download(url, name)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** Fetch the URL of the customer portal. */
|
||||
|
@ -48,8 +48,10 @@ export const LIST_PROJECTS_PATH = 'projects'
|
||||
export const CREATE_PROJECT_PATH = 'projects'
|
||||
/** Relative HTTP path to the "list files" endpoint of the Cloud backend API. */
|
||||
export const LIST_FILES_PATH = 'files'
|
||||
/** Relative HTTP path to the "upload file" endpoint of the Cloud backend API. */
|
||||
export const UPLOAD_FILE_PATH = 'files'
|
||||
/** Relative HTTP path to the "upload file start" endpoint of the Cloud backend API. */
|
||||
export const UPLOAD_FILE_START_PATH = 'files/upload/start'
|
||||
/** Relative HTTP path to the "upload file end" endpoint of the Cloud backend API. */
|
||||
export const UPLOAD_FILE_END_PATH = 'files/upload/end'
|
||||
/** Relative HTTP path to the "create secret" endpoint of the Cloud backend API. */
|
||||
export const CREATE_SECRET_PATH = 'secrets'
|
||||
/** Relative HTTP path to the "list secrets" endpoint of the Cloud backend API. */
|
||||
|
@ -444,7 +444,9 @@
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
font-family: 'Enso Prose', 'Enso', 'M PLUS 1', 'Roboto Light', sans-serif;
|
||||
--font-family: 'Enso Prose', 'Enso', 'M PLUS 1', 'Roboto Light', sans-serif;
|
||||
font-family: var(--font-family);
|
||||
--toastify-font-family: var(--font-family);
|
||||
font-feature-settings: normal;
|
||||
|
||||
@apply font-medium;
|
||||
|
@ -27,12 +27,10 @@ export async function downloadWithHeaders(
|
||||
name?: string,
|
||||
) {
|
||||
url = new URL(url, location.toString()).toString()
|
||||
if ('systemApi' in window) {
|
||||
window.systemApi.downloadURL(url, headers)
|
||||
} else {
|
||||
const response = await fetch(url, { headers })
|
||||
const body = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(body)
|
||||
download(objectUrl, name ?? url.match(/[^/]+$/)?.[0] ?? '')
|
||||
}
|
||||
// Avoid using `window.systemApi` because the name is lost.
|
||||
// Also, `systemApi.downloadURL` seems to not work at all currently.
|
||||
const response = await fetch(url, { headers })
|
||||
const body = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(body)
|
||||
download(objectUrl, name)
|
||||
}
|
||||
|
22
app/gui/src/dashboard/utilities/input.ts
Normal file
22
app/gui/src/dashboard/utilities/input.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/** @file Functions related to inputs. */
|
||||
|
||||
/**
|
||||
* Trigger a file input.
|
||||
*/
|
||||
export function inputFiles() {
|
||||
return new Promise<FileList>((resolve, reject) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.style.display = 'none'
|
||||
document.body.appendChild(input)
|
||||
input.addEventListener('input', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
resolve(input.files!)
|
||||
})
|
||||
input.addEventListener('cancel', () => {
|
||||
reject(new Error('File selection was cancelled.'))
|
||||
})
|
||||
input.click()
|
||||
input.remove()
|
||||
})
|
||||
}
|
76
app/gui/src/dashboard/utilities/preventNavigation.tsx
Normal file
76
app/gui/src/dashboard/utilities/preventNavigation.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
/** @file A React hook to prevent navigation. */
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { Button, ButtonGroup, Dialog, Text } from '#/components/AriaComponents'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { isOnElectron } from 'enso-common/src/detect'
|
||||
|
||||
let shouldClose = false
|
||||
|
||||
/** Options for {@link usePreventNavigation}. */
|
||||
export interface PreventNavigationOptions {
|
||||
readonly isEnabled?: boolean
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Prevent navigating away from a page. */
|
||||
export function usePreventNavigation(options: PreventNavigationOptions) {
|
||||
const { isEnabled = true, message } = options
|
||||
const { setModal } = useSetModal()
|
||||
const messageRef = useSyncRef(message)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEnabled) {
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!isOnElectron()) {
|
||||
// Browsers have their own `beforeunload` handling.
|
||||
event.preventDefault()
|
||||
} else if (!shouldClose) {
|
||||
event.preventDefault()
|
||||
setModal(<ConfirmCloseModal message={messageRef.current} />)
|
||||
} else {
|
||||
// Allow the window to close. Set `shouldClose` to false just in case something goes wrong.
|
||||
shouldClose = false
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}
|
||||
}
|
||||
}, [isEnabled, messageRef, setModal])
|
||||
}
|
||||
|
||||
/** Props for a {@link ConfirmCloseModal}. */
|
||||
interface ConfirmCloseModalProps {
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** A modal to confirm closing the window. */
|
||||
function ConfirmCloseModal(props: ConfirmCloseModalProps) {
|
||||
const { message } = props
|
||||
const { getText } = useText()
|
||||
const { unsetModal } = useSetModal()
|
||||
|
||||
return (
|
||||
<Dialog title={getText('closeWindowDialogTitle')} modalProps={{ defaultOpen: true }}>
|
||||
<Text>{message}</Text>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="delete"
|
||||
onPress={() => {
|
||||
shouldClose = true
|
||||
window.close()
|
||||
}}
|
||||
>
|
||||
{getText('close')}
|
||||
</Button>
|
||||
<Button variant="outline" onPress={unsetModal}>
|
||||
{getText('cancel')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
/** @file Configuration for Tailwind. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import animate from 'tailwindcss-animate'
|
||||
import reactAriaComponents from 'tailwindcss-react-aria-components'
|
||||
import plugin from 'tailwindcss/plugin.js'
|
||||
|
||||
const HERE_PATH = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
// The names come from a third-party API and cannot be changed.
|
||||
/* eslint-disable no-restricted-syntax, @typescript-eslint/naming-convention, @typescript-eslint/no-magic-numbers */
|
||||
export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
content: ['./src/**/*.tsx', './src/**/*.ts'],
|
||||
content: [`${HERE_PATH}/src/**/*.tsx`, `${HERE_PATH}/src/**/*.ts`],
|
||||
theme: {
|
||||
extend: {
|
||||
cursor: {
|
||||
|
Loading…
Reference in New Issue
Block a user