diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fc2c822abb..01dd5a6f5fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ is included][14028] - [Function docs in autocomplete in table expressions][14059] - [Many CLI arguments removed][14069] +- [Images may be added to assets descriptions][14247] - [Dragging edges from plus button on nodes is now possible][14246] - [JSON and SQL visualizations' content may be now selected and copied][14262] - [SQL visualization displays interpolated parameters properly][14262] @@ -32,6 +33,7 @@ [13976]: https://github.com/enso-org/enso/pull/13976 [14059]: https://github.com/enso-org/enso/pull/14059 [14069]: https://github.com/enso-org/enso/pull/14069 +[14247]: https://github.com/enso-org/enso/pull/14247 [14262]: https://github.com/enso-org/enso/pull/14262 [14246]: https://github.com/enso-org/enso/pull/14246 [14209]: https://github.com/enso-org/enso/pull/14209 diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index a0ef93795b4e..565c92bd4a3f 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -948,9 +948,7 @@ export interface Asset { readonly parentsPath: ParentsPath readonly virtualParentsPath: VirtualParentsPath /** The display path. */ - // TODO[ao]: As a rule, this should be always defined, but there is one place where we are unable - // to retrieve directory path easily. - readonly ensoPath: Type extends AssetType.directory ? EnsoPath | undefined : EnsoPath + readonly ensoPath: EnsoPath } /** A convenience alias for {@link Asset}<{@link AssetType.directory}>. */ @@ -1275,19 +1273,19 @@ export type AssetSortDirection = 'ascending' | 'descending' /** URL query string parameters for the "list directory" endpoint. */ export interface ListDirectoryRequestParams { readonly parentId: DirectoryId | null - readonly filterBy: FilterBy | null - readonly labels: readonly LabelName[] | null - readonly sortExpression: AssetSortExpression | null - readonly sortDirection: AssetSortDirection | null - readonly recentProjects: boolean + readonly filterBy?: FilterBy | null + readonly labels?: readonly LabelName[] | null + readonly sortExpression?: AssetSortExpression | null + readonly sortDirection?: AssetSortDirection | null + readonly recentProjects?: boolean /** * The root path of the directory to list. * This is used to list a subdirectory of a local root directory, * because a root could be any local folder on the machine. */ readonly rootPath?: Path | undefined - readonly from: PaginationToken | null - readonly pageSize: number | null + readonly from?: PaginationToken | null + readonly pageSize?: number | null } /** URL query string parameters for the "search directory" endpoint. */ @@ -1373,6 +1371,10 @@ export interface UploadedProject { /** A large asset (file or project) that has finished uploading. */ export type UploadedAsset = UploadedFile | UploadedArchive | UploadedProject +export interface UploadedImages { + files: { assetId: AssetId; title: string }[] +} + /** URL query string parameters for the "upload profile picture" endpoint. */ export interface UploadPictureRequestParams { readonly fileName: string | null @@ -1873,6 +1875,14 @@ export abstract class Backend { body: UploadFileEndRequestBody, abort?: AbortSignal, ): Promise + /** + * Upload set of Images, resolving any possible conflicts. The sum of file sizes may not + * exceed cloud message limit. + */ + abstract uploadImage( + parentDirectoryId: DirectoryId, + files: { data: Blob; name: string }[], + ): Promise /** Change the name of a file. */ abstract updateFile(fileId: FileId, body: UpdateFileRequestBody, title: string): Promise @@ -2012,6 +2022,16 @@ export abstract class Backend { ) } + protected postFormData( + path: string, + payload: FormData, + options?: HttpClientPostOptions, + ) { + return this.checkForAuthenticationError(() => + this.client.postFormData(this.resolvePath(path), payload, options), + ) + } + /** Send a JSON HTTP PATCH request to the given path. */ protected patch(path: string, payload: object) { return this.checkForAuthenticationError(() => diff --git a/app/common/src/services/Backend/remoteBackendPaths.ts b/app/common/src/services/Backend/remoteBackendPaths.ts index f4107f19001e..2f04a3964403 100644 --- a/app/common/src/services/Backend/remoteBackendPaths.ts +++ b/app/common/src/services/Backend/remoteBackendPaths.ts @@ -62,6 +62,8 @@ export const CREATE_PROJECT_PATH = 'projects' 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 "upload image" endpoint of the Cloud backend API */ +export const UPLOAD_IMAGE_PATH = 'images' /** 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. */ diff --git a/app/common/src/services/HttpClient.ts b/app/common/src/services/HttpClient.ts index 28a4c9ac41e6..ebc93ba25423 100644 --- a/app/common/src/services/HttpClient.ts +++ b/app/common/src/services/HttpClient.ts @@ -59,6 +59,18 @@ export class HttpClient { }) } + /** Send a multipart/form-data HTTP POST request to the specified URL. */ + async postFormData(url: string, payload: FormData, options?: HttpClientPostOptions) { + return this.request<'POST', T>({ + method: 'POST', + url, + payload, + mimetype: 'multipart/form-data', + keepalive: options?.keepalive ?? false, + abort: options?.abort, + }) + } + /** Send a base64-encoded binary HTTP POST request to the specified URL. */ async postBinary(url: string, payload: Blob, options?: HttpClientPostOptions) { return await this.request<'POST', T>({ @@ -129,7 +141,11 @@ export class HttpClient { const payload = options.payload if (payload != null) { const contentType = options.mimetype ?? 'application/json' - headers.set('Content-Type', contentType) + // multipart/form-data should have no content-type set. + // See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects#sending_files_using_a_formdata_object + if (contentType !== 'multipart/form-data') { + headers.set('Content-Type', contentType) + } } if (!navigator.onLine) { diff --git a/app/common/src/services/LocalBackend.ts b/app/common/src/services/LocalBackend.ts index b41787edc98d..61d5a6ef021a 100644 --- a/app/common/src/services/LocalBackend.ts +++ b/app/common/src/services/LocalBackend.ts @@ -678,7 +678,7 @@ export class LocalBackend extends backend.Backend { /** Begin uploading a large file. */ override async uploadFileStart( body: backend.UploadFileRequestParams, - file: File, + file: File | null, ): Promise { const parentPath = body.parentDirectoryId == null ? @@ -686,7 +686,7 @@ export class LocalBackend extends backend.Backend { : backend.extractTypeAndPath(body.parentDirectoryId).path const filePath = joinPath(parentPath, body.fileName) const uploadId = uniqueString() - const sourcePath = body.filePath ?? this.getFilePath?.(file) + const sourcePath = body.filePath ?? (file && this.getFilePath?.(file)) const searchParams = new URLSearchParams([ ['directory', newDirectoryId(parentPath)], ['file_name', body.fileName], @@ -733,6 +733,17 @@ export class LocalBackend extends backend.Backend { return Promise.resolve(file) } + /** + * Upload set of Images, resolving any possible conflicts. The sum of file sizes may not + * exceed cloud message limit. + */ + override uploadImage( + _parentDirectoryId: backend.DirectoryId, + _files: { data: Blob; name: string }[], + ): Promise { + this.invalidOperation() + } + /** Change the name of a file. */ override async updateFile( fileId: backend.FileId, diff --git a/app/common/src/services/RemoteBackend.ts b/app/common/src/services/RemoteBackend.ts index 35ecbdcbf907..95b3ffad77ec 100644 --- a/app/common/src/services/RemoteBackend.ts +++ b/app/common/src/services/RemoteBackend.ts @@ -356,7 +356,7 @@ export class RemoteBackend extends backend.Backend { return { assets: [], paginationToken: null } } const paramsString = new URLSearchParams( - query.recentProjects ? + query.recentProjects === true ? [['recent_projects', String(true)]] : [ ...(query.parentId != null ? [['parent_id', query.parentId]] : []), @@ -877,7 +877,7 @@ export class RemoteBackend extends backend.Backend { */ override async uploadFileStart( body: backend.UploadFileRequestParams, - file: File, + file: Blob, abort?: AbortSignal, ): Promise { const path = remoteBackendPaths.UPLOAD_FILE_START_PATH @@ -945,6 +945,28 @@ export class RemoteBackend extends backend.Backend { } } + /** + * Upload set of Images, resolving any possible conflicts. The sum of file sizes may not + * exceed cloud message limit. + */ + override async uploadImage( + parentDirectoryId: backend.DirectoryId, + files: { data: Blob; name: string }[], + ) { + const path = remoteBackendPaths.UPLOAD_IMAGE_PATH + const query = new URLSearchParams({ parentDirectoryId }) + const data = new FormData() + for (const file of files) { + data.append('image', file.data, file.name) + } + const response = await this.postFormData(`${path}?${query}`, data) + if (!response.ok) { + return this.throw(response, 'uploadImageBackendError') + } else { + return response.json() + } + } + /** Change the name of a file. */ override async updateFile(): Promise { await this.throw(null, 'updateFileNotImplementedBackendError') diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 62cc576425c4..4614db950f5a 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -159,6 +159,7 @@ "uploadFileStartBackendError": "Could not begin uploading large file", "uploadFileChunkBackendError": "Could not upload chunk of large file", "uploadFileEndBackendError": "Could not finish uploading large file", + "uploadImageBackendError": "Could not upload image", "updateFileNotImplementedBackendError": "Files currently cannot be renamed on the Cloud backend", "uploadFileWithNameBackendError": "Could not upload file '$0'", "getFileDetailsBackendError": "Could not get details of project '$0'", diff --git a/app/electron-client/tests/electronTest.ts b/app/electron-client/tests/electronTest.ts index 69747c493175..7ff10a2efb4e 100644 --- a/app/electron-client/tests/electronTest.ts +++ b/app/electron-client/tests/electronTest.ts @@ -131,7 +131,13 @@ export async function createNewProject(page: Page) { */ export async function closeWelcome(page: Page) { const welcomeProjectTab = page.getByRole('tab', { name: 'Getting Started with Enso' }) - await Promise.race([welcomeProjectTab.waitFor({ state: 'visible' }), page.waitForTimeout(3000)]) + const loadingIndicator = page.locator('.LoadingSpinner') + await Promise.race([ + welcomeProjectTab + .waitFor({ state: 'visible' }) + .then(() => loadingIndicator.waitFor({ state: 'hidden' })), + page.waitForTimeout(3000), + ]) if (await welcomeProjectTab.isVisible()) { await page.getByRole('tab', { name: 'Data Catalog' }).click() } diff --git a/app/gui/src/dashboard/hooks/backendHooks.ts b/app/gui/src/dashboard/hooks/backendHooks.ts index cf5417d20884..38b7dcd17f1f 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.ts +++ b/app/gui/src/dashboard/hooks/backendHooks.ts @@ -283,8 +283,8 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { sortDirection, filterBy, recentProjects: category.type === 'recent', - from, - pageSize, + from: from ?? null, + pageSize: pageSize ?? null, }, parentId ?? '(unknown)', ) diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx index c3ef0949027e..2112f99baa5c 100644 --- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx @@ -94,7 +94,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu( const copyAssets = useMutationCallback(copyAssetsMutationOptions(backend)) const downloadAssets = useMutationCallback(downloadAssetsMutationOptions(backend)) const self = permissions.tryFindSelfPermission(user, asset.permissions) - const encodedEnsoPath = asset.ensoPath ? encodeURI(asset.ensoPath) : undefined + const encodedEnsoPath = encodeURI(asset.ensoPath) const copyMutation = useCopy() const uploadFileToCloud = useUploadFileToCloud() const uploadFileToLocal = useUploadFileToLocal(category) @@ -131,12 +131,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu( }) const canPaste = - ( - !pasteDataParent || - !pasteData || - !isCloud || - (pasteDataParent.ensoPath != null && permissions.isTeamPath(pasteDataParent.ensoPath)) - ) ? + !pasteDataParent || !pasteData || !isCloud || permissions.isTeamPath(pasteDataParent.ensoPath) ? true : pasteData.data.assets.every((pasteAsset) => { const otherAsset = getAsset(pasteAsset.id) @@ -405,7 +400,6 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu( }, }, !isCloud && - encodedEnsoPath != null && systemApi && { action: 'openInFileBrowser', doAction: () => { @@ -413,7 +407,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu( systemApi.showItemInFolder(encodedEnsoPath) }, }, - encodedEnsoPath != null && { + { action: 'copyAsPath', doAction: () => { void goToDrive() diff --git a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetProperties.tsx b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetProperties.tsx index cc6886a5d500..f029c6117751 100644 --- a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetProperties.tsx +++ b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetProperties.tsx @@ -154,21 +154,19 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) { - {item.ensoPath != null && ( - - - - - )} + + + + {featureFlags.showDeveloperIds && (
- {getText('path')} - -
- - {item.ensoPath} - - -
-
+ {getText('path')} + +
+ + {item.ensoPath} + + +
+
diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 5444373fa0b1..2c44136b6723 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -290,7 +290,7 @@ function AssetsTable(props: AssetsTableProps) { [assetsPages.data?.pages], ) const fetchNextAssetPage = assetsPages.fetchNextPage - const isFetching = assetsPages.isLoading || assetsPages.isFetchingNextPage + const isFetching = assetsPages.isFetching const isCloud = backend.type === BackendType.remote const rootRef = useRef(null) @@ -298,6 +298,9 @@ function AssetsTable(props: AssetsTableProps) { const getPasteData = useEventCallback(() => driveStore.getState().pasteData) useEffect(() => { + // Do not request next page while refetching. This causes data not being updated. + // See https://github.com/TanStack/query/discussions/6709#discussioncomment-8142957 + if (isFetching) return const scrollerEl = scrollerRef.current if (!scrollerEl) return const tableEl = scrollerEl.children[0] @@ -305,7 +308,7 @@ function AssetsTable(props: AssetsTableProps) { if (scrollerEl.scrollTop + scrollerEl.clientHeight >= tableEl.scrollHeight) { void fetchNextAssetPage() } - }, [fetchNextAssetPage, assetsPages.data?.pages]) + }, [isFetching, fetchNextAssetPage, assetsPages.data?.pages]) useAssetsTableItems({ parentId: currentDirectoryId, assets }) @@ -1046,6 +1049,8 @@ function AssetsTable(props: AssetsTableProps) { className="h-full flex-1" shadowStartClassName="top-8" onScroll={(event) => { + // Do not request next page while refetching. This causes data not being updated. + // See https://github.com/TanStack/query/discussions/6709#discussioncomment-8142957 if (isFetching) return const element = event.currentTarget const tableEl = element.children[0] diff --git a/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx b/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx index da9742fc2c00..54f7f15eb7e0 100644 --- a/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx @@ -106,7 +106,7 @@ export function DriveBarNavigation() { useEffect(() => { if (directoryData?.asset != null) { rightPanel.updateContext('drive', (ctx) => { - ctx.defaultItem = { ...directoryData.asset, ensoPath: undefined } + ctx.defaultItem = directoryData.asset return ctx }) } diff --git a/app/gui/src/dashboard/pages/dashboard/components/AssetRow.tsx b/app/gui/src/dashboard/pages/dashboard/components/AssetRow.tsx index f2f61a09e02c..be7f9fbebcca 100644 --- a/app/gui/src/dashboard/pages/dashboard/components/AssetRow.tsx +++ b/app/gui/src/dashboard/pages/dashboard/components/AssetRow.tsx @@ -222,7 +222,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { // Assume the parent is the root directory. return true } - if (parent.ensoPath != null && isTeamPath(parent.ensoPath)) { + if (isTeamPath(parent.ensoPath)) { return true } // Assume user path; check permissions diff --git a/app/gui/src/project-view/components/DescriptionEditor.vue b/app/gui/src/project-view/components/DescriptionEditor.vue index 2abeb57b4355..54822759117c 100644 --- a/app/gui/src/project-view/components/DescriptionEditor.vue +++ b/app/gui/src/project-view/components/DescriptionEditor.vue @@ -29,8 +29,8 @@ async function updateDescription( asset: AssetDetailsResponse | undefined, description: string, ) { + descriptionEdited = false if (asset && description && asset.description !== description) { - descriptionEdited = false await editDescriptionMutation.mutateAsync([ asset.id, { diff --git a/app/gui/src/project-view/composables/backend.ts b/app/gui/src/project-view/composables/backend.ts index 390d9149cfc1..b953cf969045 100644 --- a/app/gui/src/project-view/composables/backend.ts +++ b/app/gui/src/project-view/composables/backend.ts @@ -169,7 +169,7 @@ export function useBackend(which: 'remote' | 'project') { Parameters, unknown > { - return useMutation(backendMutationOptions(method, backend, options)) + return useMutation(backendMutationOptions(method, backend, options)) } return { query, fetch, prefetch, ensureQueryData, mutation } diff --git a/app/gui/src/project-view/providers/asyncResources.ts b/app/gui/src/project-view/providers/asyncResources.ts index 879a4ad8c85a..16b44d0acf01 100644 --- a/app/gui/src/project-view/providers/asyncResources.ts +++ b/app/gui/src/project-view/providers/asyncResources.ts @@ -2,6 +2,7 @@ import { useBackends } from '$/providers/backends' import type { OpenedProjectsStore } from '$/providers/openedProjects' import { createContextStore } from '@/providers' import type { ToValue } from '@/util/reactivity' +import { useQueryClient } from '@tanstack/vue-query' import { andThen, mapOk, Ok, type Result } from 'enso-common/src/utilities/data/result' import { computed, onScopeDispose, toValue, type ComputedRef } from 'vue' import { @@ -38,9 +39,10 @@ export const [provideAsyncResources, useAsyncResources] = createContextStore( 'asyncResourceStore', (openedProjects: OpenedProjectsStore) => { const backends = useBackends() + const queryClient = useQueryClient() const { retainResource, releaseResource } = useResourceCache() const resolveResourceInContext = useAsyncResourceResolver(backends, openedProjects) - const uploadResource = useResourceUpload(openedProjects) + const uploadResource = useResourceUpload(openedProjects, backends.remoteBackend, queryClient) function finishResourceUpload( progress: UploadProgress, diff --git a/app/gui/src/project-view/providers/asyncResources/context.ts b/app/gui/src/project-view/providers/asyncResources/context.ts index f32baaa4d822..36010d12a416 100644 --- a/app/gui/src/project-view/providers/asyncResources/context.ts +++ b/app/gui/src/project-view/providers/asyncResources/context.ts @@ -1,7 +1,7 @@ import { useCurrentProject } from '$/components/WithCurrentProject.vue' import { useRightPanelData } from '$/providers/rightPanel' import type { ToValue } from '@/util/reactivity' -import { ProjectId } from 'enso-common/src/services/Backend' +import type { Asset, ProjectId } from 'enso-common/src/services/Backend' import { toValue } from 'vue' /** @@ -20,6 +20,7 @@ export type ResourceContext = { */ export interface ResourceContextSnapshot { project: ProjectId | undefined + asset: Asset | undefined basePathSegments: string[] | undefined } @@ -27,6 +28,7 @@ export interface ResourceContextSnapshot { export function captureResourceContext(context: ResourceContext): ResourceContextSnapshot { return { project: toValue(context.project), + asset: toValue(context.asset), basePathSegments: toValue(context.basePathSegments), } } @@ -41,6 +43,7 @@ export function useCurrentProjectResourceContext(): ResourceContext { if (currentProject != null) { return { project: () => currentProject.store.value.id, + asset: undefined, basePathSegments: () => { const fileName = currentProject.store.value.observedFileName if (fileName) return ['src', ...fileName.split('/')] @@ -50,6 +53,7 @@ export function useCurrentProjectResourceContext(): ResourceContext { const rightPanel = useRightPanelData(true) return { project: () => rightPanel?.focusedProject, + asset: () => rightPanel?.focusedAsset, // We display documentation of `main` function, so image access is relative to the main module. basePathSegments: ['src', 'Main.enso'], } diff --git a/app/gui/src/project-view/providers/asyncResources/upload.ts b/app/gui/src/project-view/providers/asyncResources/upload.ts index 96a67d788315..932604b28d64 100644 --- a/app/gui/src/project-view/providers/asyncResources/upload.ts +++ b/app/gui/src/project-view/providers/asyncResources/upload.ts @@ -1,9 +1,13 @@ import type { OpenedProjectsStore } from '$/providers/openedProjects' import type { Initialized as InitializedProject } from '$/providers/openedProjects/projectStates' +import { backendMutationOptions, backendQueryOptions } from '@/composables/backend' import { useProjectFiles } from '@/stores/projectFiles' +import { QueryClient, useMutation } from '@tanstack/vue-query' +import { type Asset, AssetType, DirectoryId } from 'enso-common/src/services/Backend' +import { RemoteBackend } from 'enso-common/src/services/RemoteBackend' import { unsafeKeys } from 'enso-common/src/utilities/data/object' import { Err, mapOk, Ok, type Result } from 'enso-common/src/utilities/data/result' -import { readUserSelectedFile } from 'enso-common/src/utilities/file' +import { getFolderPath, readUserSelectedFile } from 'enso-common/src/utilities/file' import type { FetchPartialProgress } from './AsyncResource' import type { ResourceContextSnapshot } from './context' @@ -87,12 +91,23 @@ const supportedResourceTypes = { * Part of 'asyncResources' store. * @internal */ -export function useResourceUpload(openedProjects: OpenedProjectsStore) { +export function useResourceUpload( + openedProjects: OpenedProjectsStore, + backend: RemoteBackend, + query: QueryClient, +) { + const createImgDirMutation = useMutation( + backendMutationOptions('createDirectory', backend), + query, + ) + const uploadImageMutation = useMutation(backendMutationOptions('uploadImage', backend), query) + async function uploadResourceToProject( project: InitializedProject, upload: UploadDefinition, ): Promise> { const api = useProjectFiles(project.store) + const rootId = await api.projectRootId if (!rootId) return Err('Cannot upload image: unknown project file tree root') @@ -105,14 +120,47 @@ export function useResourceUpload(openedProjects: OpenedProjectsStore) { if (!nameResult.ok) return nameResult const fullFilePath = { rootId, segments: [...UPLOAD_PATH_SEGMENTS, nameResult.value] } return Ok({ - resourceUrl: `/${fullFilePath.segments.map(encodeURI).join('/')}`, uploadData: upload.data, + resourceUrl: `/${fullFilePath.segments.map(encodeURI).join('/')}`, upload: upload.data.then((blob) => api.writeFileBinary(fullFilePath, blob)), }) } - async function uploadResourceToCloud(_data: UploadDefinition): Promise> { - return Err('Uploading documentation resources to cloud is not yet supported.') + async function uploadResourceToCloud( + data: UploadDefinition, + asset: Asset, + ): Promise> { + const directory = getFolderPath(asset.ensoPath) + try { + const parentContents = await query.fetchQuery( + backendQueryOptions('listDirectory', [{ parentId: asset.parentId }, ''], backend), + ) + let imagesDir = parentContents.assets.find( + (asset) => asset.type === AssetType.directory && asset.title === 'images', + )?.id as DirectoryId | undefined + if (imagesDir == null) { + imagesDir = ( + await createImgDirMutation.mutateAsync([ + { title: 'images', parentId: asset.parentId }, + false, + ]) + ).id + } + + const contents = await data.data + const uploadResult = await uploadImageMutation.mutateAsync([ + imagesDir, + [{ data: contents, name: data.filename }], + ]) + + return Ok({ + uploadData: data.data, + resourceUrl: encodeURI(`${directory}images/${uploadResult.files[0]?.title}`), + upload: Promise.resolve(Ok()), + }) + } catch (err) { + return Err(err) + } } async function uploadResource( @@ -127,8 +175,10 @@ export function useResourceUpload(openedProjects: OpenedProjectsStore) { openedProject?.state.status === 'initialized' ? openedProject.state : undefined if (initialized) { return uploadResourceToProject(initialized, data) + } else if (context.asset) { + return uploadResourceToCloud(data, context.asset) } else { - return uploadResourceToCloud(data) + return Err('Cannot upload resource: no Project nor asset in the context.') } } diff --git a/app/gui/src/project-view/stores/projectFiles.ts b/app/gui/src/project-view/stores/projectFiles.ts index fb2392f17a9d..b7f9c9efde54 100644 --- a/app/gui/src/project-view/stores/projectFiles.ts +++ b/app/gui/src/project-view/stores/projectFiles.ts @@ -1,6 +1,7 @@ import { type ProjectStore } from '$/providers/openedProjects/project' import { bytesToHex, Hash } from '@noble/hashes/utils' import { Err, Ok, type Result, withContext } from 'enso-common/src/utilities/data/result' +import { basenameAndExtension } from 'enso-common/src/utilities/file' import { Error as DataError } from 'ydoc-shared/binaryProtocol' import { ErrorCode, RemoteRpcError } from 'ydoc-shared/languageServer' import { type Path } from 'ydoc-shared/languageServerTypes' @@ -98,11 +99,11 @@ export function useProjectFiles(projectStore: ProjectStoreSubset) { const files = await lsRpc.listFiles(path) if (!files.ok) return files const existingNames = new Set(files.value.paths.map((path) => path.name)) - const { stem, extension = '' } = splitFilename(suggestedName) + const { basename, extension = '' } = basenameAndExtension(suggestedName) let candidate = suggestedName let num = 1 while (existingNames.has(candidate)) { - candidate = `${stem}_${num}.${extension}` + candidate = `${basename}_${num}.${extension}` num += 1 } return Ok(candidate) @@ -133,14 +134,3 @@ export function useProjectFiles(projectStore: ProjectStoreSubset) { assertChecksum, } } - -/** Split filename into stem and (optional) extension. */ -function splitFilename(fileName: string): { stem: string; extension?: string } { - const dotIndex = fileName.lastIndexOf('.') - if (dotIndex !== -1 && dotIndex !== 0) { - const stem = fileName.substring(0, dotIndex) - const extension = fileName.substring(dotIndex + 1) - return { stem, extension } - } - return { stem: fileName } -} diff --git a/app/gui/src/router/initialProject.ts b/app/gui/src/router/initialProject.ts index b1948d72bb7b..90bcb8d829ee 100644 --- a/app/gui/src/router/initialProject.ts +++ b/app/gui/src/router/initialProject.ts @@ -163,7 +163,7 @@ async function uploadProjectArchive( fileId: null, filePath: Path(filePath), }, - null!, + null, ) const endMetadata = await localBackend.uploadFileEnd({ ...metadata, diff --git a/app/gui/src/utils/backendQuery.ts b/app/gui/src/utils/backendQuery.ts index 98aa679d263e..6d320431a0b1 100644 --- a/app/gui/src/utils/backendQuery.ts +++ b/app/gui/src/utils/backendQuery.ts @@ -60,6 +60,7 @@ export type BackendMutationMethod = DefineBackendMethods< | 'uploadFileChunk' | 'uploadFileEnd' | 'uploadFileStart' + | 'uploadImage' | 'uploadOrganizationPicture' | 'uploadUserPicture' > @@ -136,6 +137,7 @@ export const INVALIDATION_MAP: Partial< updateProjectExecution: ['listProjectExecutions'], syncProjectExecution: ['listProjectExecutions'], deleteProjectExecution: ['listProjectExecutions'], + uploadImage: ['listDirectory', 'searchDirectory'], } /** For each backend method, an optional function defining how to create a query key from its arguments. */ diff --git a/app/ydoc-shared/src/languageServerTypes.ts b/app/ydoc-shared/src/languageServerTypes.ts index 0eb5b33c585d..a665dc7193a3 100644 --- a/app/ydoc-shared/src/languageServerTypes.ts +++ b/app/ydoc-shared/src/languageServerTypes.ts @@ -28,7 +28,7 @@ export interface Path { /** Path's root id. */ rootId: Uuid /** Path's segments. */ - segments: string[] + segments: readonly string[] } export interface FileEdit { diff --git a/eslint.config.mjs b/eslint.config.mjs index 81dd14163b4b..ae81649ffa90 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -487,7 +487,6 @@ const config = [ '@typescript-eslint/require-array-sort-compare': ['error', { ignoreStringArrays: true }], '@typescript-eslint/restrict-template-expressions': 'error', '@typescript-eslint/sort-type-constituents': 'error', - '@typescript-eslint/strict-boolean-expressions': 'error', '@typescript-eslint/switch-exhaustiveness-check': [ 'error', { allowDefaultCaseForExhaustiveSwitch: true },