From 688380dfff9ad20dd2fef9a5047221b0b2a96dae Mon Sep 17 00:00:00 2001 From: Aleksei Potsetsuev Date: Mon, 26 Feb 2024 20:40:34 +0800 Subject: [PATCH 1/3] CB-4681 fix: content downloading in value panel --- .../core-utils/src/downloadFromURL.ts | 32 ++++ webapp/packages/core-utils/src/getMIME.ts | 6 +- webapp/packages/core-utils/src/index.ts | 1 + .../Actions/IDatabaseDataCacheAction.ts | 1 + .../ResultSet/IResultSetDataContentAction.ts | 4 +- .../Actions/ResultSet/ResultSetCacheAction.ts | 13 +- .../ResultSet/ResultSetDataContentAction.ts | 108 ++++++------ .../ImageValue/ImageValuePresentation.tsx | 155 +++++++++--------- .../TextValue/TextValuePresentation.tsx | 5 +- .../TextValue/useTextValue.ts | 6 +- 10 files changed, 183 insertions(+), 148 deletions(-) create mode 100644 webapp/packages/core-utils/src/downloadFromURL.ts diff --git a/webapp/packages/core-utils/src/downloadFromURL.ts b/webapp/packages/core-utils/src/downloadFromURL.ts new file mode 100644 index 0000000000..0ee73cb201 --- /dev/null +++ b/webapp/packages/core-utils/src/downloadFromURL.ts @@ -0,0 +1,32 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export function downloadFromURL(url: string): Promise { + const req = new XMLHttpRequest(); + req.open('GET', url, true); + req.responseType = 'blob'; + + let resolve: (value: Blob) => void; + let reject: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + req.onload = () => { + resolve(req.response); + }; + + req.onerror = e => { + reject(e); + }; + + req.send(); + + return promise; +} diff --git a/webapp/packages/core-utils/src/getMIME.ts b/webapp/packages/core-utils/src/getMIME.ts index 98416f0609..ac700a5b07 100644 --- a/webapp/packages/core-utils/src/getMIME.ts +++ b/webapp/packages/core-utils/src/getMIME.ts @@ -6,9 +6,9 @@ * you may not use this file except in compliance with the License. */ -export function getMIME(binary: string): string | null { +export function getMIME(binary: string): string { if (binary.length === 0) { - return null; + return 'application/octet-stream'; } switch (binary[0]) { @@ -21,7 +21,7 @@ export function getMIME(binary: string): string | null { case 'U': return 'image/webp'; default: - return null; + return 'application/octet-stream'; } } diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index e5a850255b..f0eee79ad3 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -56,6 +56,7 @@ export * from './isMapsEqual'; export * from './isObjectsEqual'; export * from './openCenteredPopup'; export * from './download'; +export * from './downloadFromURL'; export * from './getTextFileReadingProcess'; export * from './getTextBetween'; export * from './timestampToDate'; diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataCacheAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataCacheAction.ts index e260829165..94c5732055 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataCacheAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataCacheAction.ts @@ -13,4 +13,5 @@ export interface IDatabaseDataCacheAction(key: TKey, scope: symbol): T | undefined; set(key: TKey, scope: symbol, value: T): void; delete(key: TKey, scope: symbol): void; + deleteAll(scope: symbol): void; } diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/IResultSetDataContentAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/IResultSetDataContentAction.ts index 6a6c53cbca..cfccf6646c 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/IResultSetDataContentAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/IResultSetDataContentAction.ts @@ -13,8 +13,8 @@ export interface IResultSetDataContentAction { isTextTruncated: (element: IResultSetElementKey) => boolean; isDownloadable: (element: IResultSetElementKey) => boolean; getFileDataUrl: (element: IResultSetElementKey) => Promise; - resolveFileDataUrl: (element: IResultSetElementKey) => Promise; - retrieveFileDataUrlFromCache: (element: IResultSetElementKey) => string | undefined; + resolveFileDataUrl: (element: IResultSetElementKey) => Promise; + retrieveBlobFromCache: (element: IResultSetElementKey) => Blob | undefined; downloadFileData: (element: IResultSetElementKey) => Promise; clearCache: () => void; } diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetCacheAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetCacheAction.ts index c1af786deb..4cde45648b 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetCacheAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetCacheAction.ts @@ -5,7 +5,7 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { makeObservable, observable } from 'mobx'; +import { action, makeObservable, observable } from 'mobx'; import { ResultDataFormat } from '@cloudbeaver/core-sdk'; @@ -33,6 +33,11 @@ export class ResultSetCacheAction makeObservable(this, { cache: observable, + set: action, + setRow: action, + delete: action, + deleteAll: action, + deleteRow: action, }); } @@ -94,6 +99,12 @@ export class ResultSetCacheAction } } + deleteAll(scope: symbol) { + for (const [, keyCache] of this.cache) { + keyCache.delete(scope); + } + } + deleteRow(key: IResultSetRowKey, scope: symbol) { const keyCache = this.getRowCache(key); diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts index b59d45de85..5cfa33e7b9 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts @@ -9,7 +9,7 @@ import { makeObservable, observable } from 'mobx'; import { QuotasService } from '@cloudbeaver/core-root'; import { GraphQLService, ResultDataFormat } from '@cloudbeaver/core-sdk'; -import { bytesToSize, download, GlobalConstants, isNotNullDefined } from '@cloudbeaver/core-utils'; +import { bytesToSize, download, downloadFromURL, GlobalConstants, isNotNullDefined } from '@cloudbeaver/core-utils'; import { DatabaseDataAction } from '../../DatabaseDataAction'; import type { IDatabaseDataSource } from '../../IDatabaseDataSource'; @@ -18,15 +18,15 @@ import { databaseDataAction } from '../DatabaseDataActionDecorator'; import type { IResultSetDataContentAction } from './IResultSetDataContentAction'; import type { IResultSetElementKey } from './IResultSetDataKey'; import { isResultSetContentValue } from './isResultSetContentValue'; +import { ResultSetCacheAction } from './ResultSetCacheAction'; import { ResultSetDataAction } from './ResultSetDataAction'; -import { ResultSetDataKeysUtils } from './ResultSetDataKeysUtils'; import { IResultSetValue, ResultSetFormatAction } from './ResultSetFormatAction'; -import { ResultSetViewAction } from './ResultSetViewAction'; const RESULT_VALUE_PATH = 'sql-result-value'; +const CONTENT_CACHE_KEY = Symbol('content-cache-key'); interface ICacheEntry { - url?: string; + blob?: Blob; fullText?: string; } @@ -34,20 +34,18 @@ interface ICacheEntry { export class ResultSetDataContentAction extends DatabaseDataAction implements IResultSetDataContentAction { static dataFormat = [ResultDataFormat.Resultset]; - private readonly cache: Map>; activeElement: IResultSetElementKey | null; constructor( source: IDatabaseDataSource, - private readonly view: ResultSetViewAction, private readonly data: ResultSetDataAction, private readonly format: ResultSetFormatAction, private readonly graphQLService: GraphQLService, private readonly quotasService: QuotasService, + private readonly cache: ResultSetCacheAction, ) { super(source); - this.cache = new Map(); this.activeElement = null; makeObservable(this, { @@ -82,10 +80,8 @@ export class ResultSetDataContentAction extends DatabaseDataAction { try { this.activeElement = element; - const fileName = await this.loadFileName(this.result, column.position, row); - return this.generateFileDataUrl(fileName); + return await this.loadDataURL(this.result, column.position, row); } finally { this.activeElement = null; } @@ -177,57 +160,70 @@ export class ResultSetDataContentAction extends DatabaseDataAction) { - const hash = this.getHash(element); - const cachedElement = this.cache.get(hash) ?? {}; - this.cache.set(hash, { ...cachedElement, ...partialCache }); + async downloadFileData(element: IResultSetElementKey) { + const url = await this.getFileDataUrl(element); + download(url); } - retrieveFileFullTextFromCache(element: IResultSetElementKey) { - const hash = this.getHash(element); - return this.cache.get(hash)?.fullText; + clearCache() { + this.cache.deleteAll(CONTENT_CACHE_KEY); } - retrieveFileDataUrlFromCache(element: IResultSetElementKey) { - const hash = this.getHash(element); - return this.cache.get(hash)?.url; + dispose(): void { + this.clearCache(); } - async downloadFileData(element: IResultSetElementKey) { - const url = await this.getFileDataUrl(element); - download(url); + private async loadFileFullText(result: IDatabaseResultSet, columnIndex: number, row: IResultSetValue[]) { + if (!result.id) { + throw new Error("Result's id must be provided"); + } + + const response = await this.graphQLService.sdk.sqlReadStringValue({ + resultsId: result.id, + connectionId: result.connectionId, + contextId: result.contextId, + columnIndex, + row: { + data: row, + }, + }); + + return response.text; } - clearCache() { - this.cache.clear(); + private updateCache(element: IResultSetElementKey, partialCache: Partial) { + const cachedElement = this.getCache(element) ?? {}; + this.setCache(element, { ...cachedElement, ...partialCache }); } - private generateFileDataUrl(fileName: string) { - return `${GlobalConstants.serviceURI}/${RESULT_VALUE_PATH}/${fileName}`; + private getCache(element: IResultSetElementKey) { + return this.cache.get(element, CONTENT_CACHE_KEY); } - private getHash(element: IResultSetElementKey) { - return ResultSetDataKeysUtils.serializeElementKey(element); + private setCache(element: IResultSetElementKey, value: ICacheEntry) { + this.cache.set(element, CONTENT_CACHE_KEY, value); } - private async loadFileName(result: IDatabaseResultSet, columnIndex: number, row: IResultSetValue[]) { + private async loadDataURL(result: IDatabaseResultSet, columnIndex: number, row: IResultSetValue[]) { if (!result.id) { throw new Error("Result's id must be provided"); } - const response = await this.graphQLService.sdk.getResultsetDataURL({ + const { url } = await this.graphQLService.sdk.getResultsetDataURL({ resultsId: result.id, connectionId: result.connectionId, contextId: result.contextId, @@ -237,6 +233,6 @@ export class ResultSetDataContentAction extends DatabaseDataAction void; - onSave?: () => void; - onUpload?: () => void; -} - -const Tools = observer(function Tools({ loading, stretch, onToggleStretch, onSave, onUpload }) { - const translate = useTranslate(); - - return ( - - - {onSave && } - {onUpload && } - - - {onToggleStretch && ( - - - - )} - - ); -}); - export const ImageValuePresentation: TabContainerPanelComponent> = observer( function ImageValuePresentation({ model, resultIndex }) { const translate = useTranslate(); @@ -113,35 +82,49 @@ export const ImageValuePresentation: TabContainerPanelComponent { - if (!data.selectedCell) { - return; - } - try { - await data.contentAction.resolveFileDataUrl(data.selectedCell); - } catch (exception: any) { - notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error'); - } - }; - - const valueSize = bytesToSize(isResultSetContentValue(value) ? value.contentLength ?? 0 : 0); - const isDownloadable = data.selectedCell && data.contentAction.isDownloadable(data.selectedCell); + const valueSize = bytesToSize(isResultSetContentValue(data.cellValue) ? data.cellValue.contentLength ?? 0 : 0); + const isTruncatedMessageDisplay = data.truncated && !data.src; + const isDownloadable = isTruncatedMessageDisplay && data.selectedCell && data.contentAction.isDownloadable(data.selectedCell); + const isCacheDownloading = + !!data.selectedCell && + !!data.contentAction.activeElement && + ResultSetDataKeysUtils.isElementsKeyEqual(data.contentAction.activeElement, data.selectedCell); return ( - {data.shouldShowImage && } - {data.truncated ? ( + {data.src && } + {isTruncatedMessageDisplay && ( {isDownloadable && ( - )} - ) : null} + )} + + + + {data.canSave && ( + + )} + {data.canUpload && ( + + )} + + + + + - ); }, diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index 99ac3c0a04..090cc9ff9d 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -127,7 +127,6 @@ export const TextValuePresentation: TabContainerPanelComponent getTypeExtension(contentType!) ?? [], [contentType]); const extensions = useCodemirrorExtensions(undefined, typeExtension); @@ -188,9 +187,9 @@ export const TextValuePresentation: TabContainerPanelComponent - {firstSelectedCell && contentAction.isTextTruncated(firstSelectedCell) ? ( + {textValueData.isTruncated ? ( - {shouldShowPasteButton && ( + {textValueData.isTextColumn && (