From 23f3034f8beec67b058a7d37c735ade94a226708 Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Wed, 6 Aug 2025 11:47:57 +0200 Subject: [PATCH 01/13] Working somehow --- app/common/src/services/Backend.ts | 20 ++-- .../dashboard/pages/dashboard/Dashboard.tsx | 3 +- .../src/dashboard/services/LocalBackend.ts | 8 +- .../src/dashboard/services/RemoteBackend.ts | 2 +- .../components/DescriptionEditor.vue | 8 ++ .../components/MarkdownEditor.vue | 2 + .../MarkdownEditor/MarkdownEditorImpl.vue | 6 +- .../src/project-view/composables/backend.ts | 2 +- .../project-view/providers/asyncResources.ts | 4 +- .../providers/asyncResources/context.ts | 6 +- .../providers/asyncResources/upload.ts | 108 +++++++++++++++++- .../src/project-view/stores/projectFiles.ts | 16 +-- 12 files changed, 142 insertions(+), 43 deletions(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 73c33b8a4684..a4632cf53d09 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -947,9 +947,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}>. */ @@ -1274,19 +1272,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. */ @@ -1849,7 +1847,7 @@ export default abstract class Backend { /** Begin uploading a large file. */ abstract uploadFileStart( params: UploadFileRequestParams, - file: File, + file: Blob, abort?: AbortSignal, ): Promise /** Upload a chunk of a large file. */ diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 632a42cd4246..1f29c2d1058a 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -117,8 +117,7 @@ export function Dashboard(props: DashboardProps) { fileId: null, filePath: backendModule.Path(initialLocalProjectPath), }, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - null!, + null, ) const endMetadata = await localBackend.uploadFileEnd({ ...metadata, diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index c3d82b89efa7..adc813ec8a98 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -666,7 +666,7 @@ export default class LocalBackend extends Backend { /** Begin uploading a large file. */ override async uploadFileStart( body: backend.UploadFileRequestParams, - file: File, + file: Blob | null, ): Promise { const parentPath = body.parentDirectoryId == null ? @@ -674,7 +674,7 @@ export default class LocalBackend extends Backend { : backend.extractTypeAndPath(body.parentDirectoryId).path const filePath = joinPath(parentPath, body.fileName) const uploadId = uniqueString() - const sourcePath = body.filePath ?? window.api?.system?.getFilePath(file) + const sourcePath = body.filePath const searchParams = new URLSearchParams([ ['directory', newDirectoryId(parentPath)], ['file_name', body.fileName], @@ -688,12 +688,12 @@ export default class LocalBackend extends Backend { if (!response.ok) { return this.throw(response, 'uploadFileBackendError') } - if (backend.fileIsProject(file)) { + if (backend.fileNameIsProject(body.fileName)) { const projectPath = backend.Path(await response.text()) const projectId = newProjectId(projectPath) const project = await this.getProjectDetails(projectId) this.uploadedFiles.set(uploadId, { id: projectId, project, jobId: null }) - } else if (backend.fileIsArchive(file)) { + } else if (backend.fileNameIsArchive(body.fileName)) { this.uploadedFiles.set(uploadId, { id: newFileId(filePath), project: null, diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/gui/src/dashboard/services/RemoteBackend.ts index f2f552a70250..7959d8c581c1 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/gui/src/dashboard/services/RemoteBackend.ts @@ -867,7 +867,7 @@ export default class RemoteBackend extends Backend { */ override async uploadFileStart( body: backend.UploadFileRequestParams, - file: File, + file: Blob, abort?: AbortSignal, ): Promise { const path = remoteBackendPaths.UPLOAD_FILE_START_PATH diff --git a/app/gui/src/project-view/components/DescriptionEditor.vue b/app/gui/src/project-view/components/DescriptionEditor.vue index 2df527f6d103..b849986a3057 100644 --- a/app/gui/src/project-view/components/DescriptionEditor.vue +++ b/app/gui/src/project-view/components/DescriptionEditor.vue @@ -6,6 +6,7 @@ import { isOnElectron } from '$/utils/detect' import MarkdownEditor from '@/components/MarkdownEditor.vue' import { backendMutationOptions } from '@/composables/backend' import { useEvent } from '@/composables/events' +import { ResourceContext } from '@/providers/asyncResources/context' import { useStringSync } from '@/util/codemirror' import { ResultComponent } from '@/util/react' import { EditorView } from '@codemirror/view' @@ -19,6 +20,12 @@ const backendForAsset = computed( (rightPanel.context?.category && backendForType(rightPanel.context.category.backend)) ?? null, ) +const resourceContext: ResourceContext = { + project: undefined, + asset: rightPanel.focusedAsset, + basePathSegments: undefined, +} + // Provide an extra `mutationKey` so that it has its own loading state. const editDescriptionMutation = useMutation( backendMutationOptions('updateAsset', backendForAsset, { mutationKey: ['editDescription'] }), @@ -97,6 +104,7 @@ function editorReadyCallback(view: EditorView) { :extensions="syncExt" contentTestId="asset-panel-description" :editorReadyCallback="editorReadyCallback" + :resourceContext="resourceContext" /> +import { ResourceContext } from '@/providers/asyncResources/context' import type { Extension } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { defineAsyncComponent } from 'vue' @@ -12,6 +13,7 @@ const { toolbar = true, ...props } = defineProps<{ contentTestId?: string scrollerTestId?: string | undefined editorReadyCallback?: ((view: EditorView) => void) | undefined + resourceContext?: ResourceContext }>() defineOptions({ diff --git a/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue b/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue index 2ba8c2913008..7187b4ee0149 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue +++ b/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue @@ -13,7 +13,7 @@ import { useFormatActions } from '@/components/MarkdownEditor/formatActions' import SelectionDropdown from '@/components/SelectionDropdown.vue' import VueHostRender, { VueHostInstance } from '@/components/VueHostRender.vue' import { type StartedUpload, useAsyncResources } from '@/providers/asyncResources' -import { useCurrentProjectResourceContext } from '@/providers/asyncResources/context' +import { type ResourceContext } from '@/providers/asyncResources/context' import { type AnyUploadSource, selectResourceFiles } from '@/providers/asyncResources/upload' import { useCodeMirror, useEditorFocus } from '@/util/codemirror' import { highlightStyle } from '@/util/codemirror/highlight' @@ -32,6 +32,7 @@ const { contentTestId, scrollerTestId, editorReadyCallback = () => {}, + resourceContext = { project: undefined, asset: undefined, basePathSegments: undefined }, } = defineProps<{ toolbar?: boolean | undefined readonly?: boolean | undefined @@ -48,10 +49,9 @@ const { * defined as signal) */ editorReadyCallback?: ((view: EditorView) => void) | undefined + resourceContext?: ResourceContext | undefined }>() defineOptions({ inheritAttrs: false }) - -const resourceContext = useCurrentProjectResourceContext() const res = useAsyncResources(true) async function selectAndUpload() { diff --git a/app/gui/src/project-view/composables/backend.ts b/app/gui/src/project-view/composables/backend.ts index 491e7aa8d651..e7f9f163a043 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 8c615b69de15..eed0d73e4be4 100644 --- a/app/gui/src/project-view/providers/asyncResources.ts +++ b/app/gui/src/project-view/providers/asyncResources.ts @@ -3,6 +3,7 @@ import type { OpenedProjectsStore } from '$/providers/openedProjects' import { createContextStore } from '@/providers' import { andThen, mapOk, Ok, type Result } from '@/util/data/result' import type { ToValue } from '@/util/reactivity' +import { useQueryClient } from '@tanstack/vue-query' import { computed, onScopeDispose, toValue, type ComputedRef } from 'vue' import { AsyncResource, @@ -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 d527e8fa59ad..57302c3bec41 100644 --- a/app/gui/src/project-view/providers/asyncResources/context.ts +++ b/app/gui/src/project-view/providers/asyncResources/context.ts @@ -1,4 +1,4 @@ -import { ProjectId } from '#/services/Backend' +import { Asset, ProjectId } from '#/services/Backend' import { useCurrentProject } from '$/components/WithCurrentProject.vue' import { useRightPanelData } from '$/providers/rightPanel' import type { ToValue } from '@/util/reactivity' @@ -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 8ccbe87a87e1..edb31e10313a 100644 --- a/app/gui/src/project-view/providers/asyncResources/upload.ts +++ b/app/gui/src/project-view/providers/asyncResources/upload.ts @@ -1,8 +1,16 @@ +import { type Asset, AssetType, DirectoryId } from '#/services/Backend' +import RemoteBackend from '#/services/RemoteBackend' import { unsafeKeys } from '#/utilities/object' import type { OpenedProject, OpenedProjectsStore } from '$/providers/openedProjects' -import { readUserSelectedFile } from '$/utils/file' +import { backendMutationOptions, backendQueryOptions } from '@/composables/backend' import { useProjectFiles } from '@/stores/projectFiles' import { Err, mapOk, Ok, type Result } from '@/util/data/result' +import { QueryClient, useMutation } from '@tanstack/vue-query' +import { + basenameAndExtension, + getFolderPath, + readUserSelectedFile, +} from 'enso-common/src/utilities/file' import type { FetchPartialProgress } from './AsyncResource' import type { ResourceContextSnapshot } from './context' @@ -86,7 +94,25 @@ 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 uploadFileStartMutation = useMutation( + backendMutationOptions('uploadFileStart', backend), + query, + ) + const uploadFileChunkMutation = useMutation( + backendMutationOptions('uploadFileChunk', backend), + query, + ) + const uploadFileEndMutation = useMutation(backendMutationOptions('uploadFileEnd', backend), query) + async function uploadResourceToProject( project: OpenedProject, upload: UploadDefinition, @@ -104,14 +130,82 @@ 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 pickUniqueName(dir: DirectoryId, suggestedName: string) { + const existingAssets = await query.fetchQuery( + backendQueryOptions('listDirectory', [{ parentId: dir }, ''], backend), + ) + const existingNames = new Set(existingAssets.assets.map((asset) => asset.title)) + const { basename, extension } = basenameAndExtension(suggestedName) + let candidate = suggestedName + for (let i = 0; existingNames.has(candidate); i++) { + candidate = `${basename}_${i}.${extension}` + } + return candidate + } + + async function uploadResourceToCloud( + data: UploadDefinition, + asset: Asset, + ): Promise> { + 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 directory = getFolderPath(asset.ensoPath) + const fileName = await pickUniqueName(imagesDir, data.filename) + + const doUpload = async () => { + try { + const contents = await data.data + const { sourcePath, uploadId, presignedUrls } = await uploadFileStartMutation.mutateAsync( + [{ fileId: null, fileName, parentDirectoryId: imagesDir }, contents], + ) + + const parts = await Promise.all( + presignedUrls.map((url, i) => uploadFileChunkMutation.mutateAsync([url, contents, i])), + ) + await uploadFileEndMutation.mutateAsync([ + { + parentDirectoryId: imagesDir, + parts, + sourcePath: sourcePath, + uploadId: uploadId, + assetId: null, + fileName, + }, + ]) + return Ok() + } catch (err) { + return Err(err) + } + } + + return Ok({ + uploadData: data.data, + resourceUrl: `${directory}/images/${fileName}`, + upload: doUpload(), + }) + } catch (err) { + return Err(err) + } } async function uploadResource( @@ -121,8 +215,10 @@ export function useResourceUpload(openedProjects: OpenedProjectsStore) { const openedProject = context.project && openedProjects.get(context.project) if (openedProject) { return uploadResourceToProject(openedProject, 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 e8ed1310eae3..feee39d35afd 100644 --- a/app/gui/src/project-view/stores/projectFiles.ts +++ b/app/gui/src/project-view/stores/projectFiles.ts @@ -1,5 +1,6 @@ import { type ProjectStore } from '$/providers/openedProjects/project' import { bytesToHex, Hash } from '@noble/hashes/utils' +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 } -} From 7191eb08169e2aa4ef447d4a1a5f1e7a6cb58551 Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Tue, 4 Nov 2025 14:47:27 +0100 Subject: [PATCH 02/13] Use new cloud endpoint --- app/common/src/services/Backend.ts | 21 +++++- .../services/Backend/remoteBackendPaths.ts | 2 + app/common/src/services/HttpClient.ts | 18 ++++- app/common/src/text/english.json | 1 + app/gui/src/dashboard/hooks/backendHooks.ts | 4 +- .../Drive/DriveBar/DriveBarNavigation.tsx | 2 +- .../src/dashboard/services/LocalBackend.ts | 8 +++ .../src/dashboard/services/RemoteBackend.ts | 13 ++++ .../components/DescriptionEditor.vue | 6 +- .../components/MarkdownEditor.vue | 2 +- .../providers/asyncResources/context.ts | 2 +- .../providers/asyncResources/upload.ts | 69 ++++--------------- .../src/project-view/stores/projectFiles.ts | 2 +- app/gui/src/utils/backendQuery.ts | 1 + app/ydoc-shared/src/languageServerTypes.ts | 2 +- 15 files changed, 84 insertions(+), 69 deletions(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index a4632cf53d09..a367ff1f068b 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -630,7 +630,7 @@ export interface PathResolveResponse extends Omit = - | (Omit>, 'ensoPath'> & { readonly metadataId: MetadataId }) + | (Asset> & { readonly metadataId: MetadataId }) | null /** Whether the user is on a plan with multiple seats (i.e. a plan that supports multiple users). */ @@ -1370,6 +1370,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 @@ -1862,6 +1866,11 @@ export default abstract class Backend { body: UploadFileEndRequestBody, abort?: AbortSignal, ): Promise + abstract uploadImage( + parentDirectoryId: DirectoryId, + file: Blob, + filename: string, + ): Promise /** Change the name of a file. */ abstract updateFile(fileId: FileId, body: UpdateFileRequestBody, title: string): Promise @@ -2001,6 +2010,16 @@ export default 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/text/english.json b/app/common/src/text/english.json index 45ba411bbdb0..90867004216d 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/gui/src/dashboard/hooks/backendHooks.ts b/app/gui/src/dashboard/hooks/backendHooks.ts index 78948db7196a..4798256f6d25 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.ts +++ b/app/gui/src/dashboard/hooks/backendHooks.ts @@ -282,8 +282,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/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx b/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx index 6e8b10614071..56a4aee53089 100644 --- a/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarNavigation.tsx @@ -102,7 +102,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/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index adc813ec8a98..f76c21672b64 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -719,6 +719,14 @@ export default class LocalBackend extends Backend { return Promise.resolve(file) } + override uploadImage( + _parentDirectoryId: backend.DirectoryId, + _file: Blob, + _filename: string, + ): Promise { + throw Error('Not implemented') + } + /** Change the name of a file. */ override async updateFile( fileId: backend.FileId, diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/gui/src/dashboard/services/RemoteBackend.ts index 7959d8c581c1..900d02606382 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/gui/src/dashboard/services/RemoteBackend.ts @@ -935,6 +935,19 @@ export default class RemoteBackend extends Backend { } } + override async uploadImage(parentDirectoryId: backend.DirectoryId, file: Blob, filename: string) { + const path = remoteBackendPaths.UPLOAD_IMAGE_PATH + const query = new URLSearchParams({ parentDirectoryId }) + const data = new FormData() + data.append('image', file, filename) + 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/gui/src/project-view/components/DescriptionEditor.vue b/app/gui/src/project-view/components/DescriptionEditor.vue index b849986a3057..b3b6e04edfe3 100644 --- a/app/gui/src/project-view/components/DescriptionEditor.vue +++ b/app/gui/src/project-view/components/DescriptionEditor.vue @@ -6,7 +6,7 @@ import { isOnElectron } from '$/utils/detect' import MarkdownEditor from '@/components/MarkdownEditor.vue' import { backendMutationOptions } from '@/composables/backend' import { useEvent } from '@/composables/events' -import { ResourceContext } from '@/providers/asyncResources/context' +import { type ResourceContext } from '@/providers/asyncResources/context' import { useStringSync } from '@/util/codemirror' import { ResultComponent } from '@/util/react' import { EditorView } from '@codemirror/view' @@ -20,11 +20,11 @@ const backendForAsset = computed( (rightPanel.context?.category && backendForType(rightPanel.context.category.backend)) ?? null, ) -const resourceContext: ResourceContext = { +const resourceContext = computed(() => ({ project: undefined, asset: rightPanel.focusedAsset, basePathSegments: undefined, -} +})) // Provide an extra `mutationKey` so that it has its own loading state. const editDescriptionMutation = useMutation( diff --git a/app/gui/src/project-view/components/MarkdownEditor.vue b/app/gui/src/project-view/components/MarkdownEditor.vue index 3a95c172262a..52e27410a884 100644 --- a/app/gui/src/project-view/components/MarkdownEditor.vue +++ b/app/gui/src/project-view/components/MarkdownEditor.vue @@ -1,5 +1,5 @@