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:
somebody1234 2024-10-22 01:31:29 +10:00 committed by GitHub
parent a5b223f33b
commit 171a95f17a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 731 additions and 299 deletions

View File

@ -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. */

View File

@ -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.",

View File

@ -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}. */

View File

@ -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) => {

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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()

View File

@ -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],
)
}

View File

@ -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"

View File

@ -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')))
}}
/>,
)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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],
)

View File

@ -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

View File

@ -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'),

View File

@ -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 })

View File

@ -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',

View File

@ -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>

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 })

View File

@ -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

View File

@ -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()

View File

@ -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)) {

View File

@ -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. */

View 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. */

View File

@ -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. */

View File

@ -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;

View File

@ -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)
}

View 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()
})
}

View 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>
)
}

View File

@ -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: {