Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

[13685]: https://github.com/enso-org/enso/pull/13685
[13658]: https://github.com/enso-org/enso/pull/13658
Expand All @@ -26,6 +27,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

#### Enso Standard Library

Expand Down
44 changes: 32 additions & 12 deletions app/common/src/services/Backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ export interface PathResolveResponse extends Omit<AnyRealAsset, 'type' | 'ensoPa

/** Response from "assets/${assetId}" endpoint. */
export type AssetDetailsResponse<Id extends AssetId> =
| (Omit<Asset<AssetTypeFromId<Id>>, 'ensoPath'> & { readonly metadataId: MetadataId })
| (Asset<AssetTypeFromId<Id>> & { readonly metadataId: MetadataId })
| null

/** Whether the user is on a plan with multiple seats (i.e. a plan that supports multiple users). */
Expand Down Expand Up @@ -947,9 +947,7 @@ export interface Asset<Type extends AssetType = AssetType> {
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}>. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -1372,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
Expand Down Expand Up @@ -1849,7 +1851,7 @@ export default abstract class Backend {
/** Begin uploading a large file. */
abstract uploadFileStart(
params: UploadFileRequestParams,
file: File,
file: Blob,
abort?: AbortSignal,
): Promise<UploadLargeFileMetadata>
/** Upload a chunk of a large file. */
Expand All @@ -1864,6 +1866,14 @@ export default abstract class Backend {
body: UploadFileEndRequestBody,
abort?: AbortSignal,
): Promise<UploadedAsset>
/**
* Upload set of Images, resoliving any possible conflicts. The sum of file sizes may not
* exceed could message limit.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this say "cloud message limit"?

*/
abstract uploadImage(
parentDirectoryId: DirectoryId,
files: { data: Blob; name: string }[],
): Promise<UploadedImages>
/** Change the name of a file. */
abstract updateFile(fileId: FileId, body: UpdateFileRequestBody, title: string): Promise<void>

Expand Down Expand Up @@ -2003,6 +2013,16 @@ export default abstract class Backend {
)
}

protected postFormData<T = void>(
path: string,
payload: FormData,
options?: HttpClientPostOptions,
) {
return this.checkForAuthenticationError(() =>
this.client.postFormData<T>(this.resolvePath(path), payload, options),
)
}

/** Send a JSON HTTP PATCH request to the given path. */
protected patch<T = void>(path: string, payload: object) {
return this.checkForAuthenticationError(() =>
Expand Down
2 changes: 2 additions & 0 deletions app/common/src/services/Backend/remoteBackendPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
18 changes: 17 additions & 1 deletion app/common/src/services/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ export class HttpClient {
})
}

/** Send a multipart/form-data HTTP POST request to the specified URL. */
async postFormData<T = void>(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<T = void>(url: string, payload: Blob, options?: HttpClientPostOptions) {
return await this.request<'POST', T>({
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions app/common/src/text/english.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
4 changes: 2 additions & 2 deletions app/gui/src/dashboard/hooks/backendHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
)
Expand Down
12 changes: 3 additions & 9 deletions app/gui/src/dashboard/layouts/AssetContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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)
Expand Down Expand Up @@ -116,12 +116,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)
Expand Down Expand Up @@ -395,15 +390,14 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
},
},
!isCloud &&
encodedEnsoPath != null &&
systemApi && {
action: 'openInFileBrowser',
doAction: () => {
void goToDrive()
systemApi.showItemInFolder(encodedEnsoPath)
},
},
encodedEnsoPath != null && {
{
action: 'copyAsPath',
doAction: () => {
void goToDrive()
Expand Down
3 changes: 1 addition & 2 deletions app/gui/src/dashboard/pages/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,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
Expand Down
19 changes: 15 additions & 4 deletions app/gui/src/dashboard/services/LocalBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,15 +666,15 @@ export default class LocalBackend extends Backend {
/** Begin uploading a large file. */
override async uploadFileStart(
body: backend.UploadFileRequestParams,
file: File,
file: Blob | null,
): Promise<backend.UploadLargeFileMetadata> {
const parentPath =
body.parentDirectoryId == null ?
this.projectManager.rootDirectory
: 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],
Expand All @@ -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,
Expand All @@ -719,6 +719,17 @@ export default class LocalBackend extends Backend {
return Promise.resolve(file)
}

/**
* Upload set of Images, resoliving any possible conflicts. The sum of file sizes may not
* exceed could message limit.
*/
override uploadImage(
_parentDirectoryId: backend.DirectoryId,
_files: { data: Blob; name: string }[],
): Promise<backend.UploadedImages> {
this.invalidOperation()
}

/** Change the name of a file. */
override async updateFile(
fileId: backend.FileId,
Expand Down
28 changes: 25 additions & 3 deletions app/gui/src/dashboard/services/RemoteBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,11 +342,11 @@ export default class RemoteBackend extends Backend {
query: backend.ListDirectoryRequestParams,
title: string,
): Promise<backend.ListDirectoryResponseBody> {
if (query.recentProjects && query.from) {
if (query.recentProjects === true && query.from) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the reason for this change. Looking at the type, this is boolean | undefined, so === true is the same as a truthiness check, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's some dashboard's lint forbidding me simple truthiness check. But at this point, I may as good just disable this check,

return { assets: [], paginationToken: null }
}
const paramsString = new URLSearchParams(
query.recentProjects ?
query.recentProjects === true ?
[['recent_projects', String(true)]]
: [
...(query.parentId != null ? [['parent_id', query.parentId]] : []),
Expand Down Expand Up @@ -867,7 +867,7 @@ export default class RemoteBackend extends Backend {
*/
override async uploadFileStart(
body: backend.UploadFileRequestParams,
file: File,
file: Blob,
abort?: AbortSignal,
): Promise<backend.UploadLargeFileMetadata> {
const path = remoteBackendPaths.UPLOAD_FILE_START_PATH
Expand Down Expand Up @@ -935,6 +935,28 @@ export default class RemoteBackend extends Backend {
}
}

/**
* Upload set of Images, resoliving any possible conflicts. The sum of file sizes may not
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same typos as above

* exceed could 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<backend.UploadedImages>(`${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<void> {
await this.throw(null, 'updateFileNotImplementedBackendError')
Expand Down
10 changes: 9 additions & 1 deletion app/gui/src/project-view/components/DescriptionEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 { type ResourceContext } from '@/providers/asyncResources/context'
import { useStringSync } from '@/util/codemirror'
import { ResultComponent } from '@/util/react'
import { EditorView } from '@codemirror/view'
Expand All @@ -19,6 +20,12 @@ const backendForAsset = computed(
(rightPanel.context?.category && backendForType(rightPanel.context.category.backend)) ?? null,
)

const resourceContext = computed<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'] }),
Expand All @@ -29,8 +36,8 @@ async function updateDescription(
asset: AssetDetailsResponse<AssetId> | undefined,
description: string,
) {
descriptionEdited = false
if (asset && description && asset.description !== description) {
descriptionEdited = false
await editDescriptionMutation.mutateAsync([
asset.id,
{
Expand Down Expand Up @@ -97,6 +104,7 @@ function editorReadyCallback(view: EditorView) {
:extensions="syncExt"
contentTestId="asset-panel-description"
:editorReadyCallback="editorReadyCallback"
:resourceContext="resourceContext"
/>
<ResultComponent
v-else
Expand Down
2 changes: 2 additions & 0 deletions app/gui/src/project-view/components/MarkdownEditor.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { ResourceContext } from '@/providers/asyncResources/context'
import type { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { defineAsyncComponent } from 'vue'
Expand All @@ -12,6 +13,7 @@ const { toolbar = true, ...props } = defineProps<{
contentTestId?: string
scrollerTestId?: string | undefined
editorReadyCallback?: ((view: EditorView) => void) | undefined
resourceContext?: ResourceContext
}>()

defineOptions({
Expand Down
Loading
Loading