diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index 01deef7ff3..e435e7be0f 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -215,6 +215,7 @@ export * from './useActivationDelay'; export * from './useAdministrationSettings'; export * from './useInterval'; export * from './useStyles'; +export * from './useSuspense'; export * from './BlocksLocaleService'; export * from './Snackbars/NotificationMark'; export * from './Snackbars/SnackbarMarkups/SnackbarWrapper'; diff --git a/webapp/packages/core-blocks/src/useSuspense.ts b/webapp/packages/core-blocks/src/useSuspense.ts new file mode 100644 index 0000000000..2b9bed6b68 --- /dev/null +++ b/webapp/packages/core-blocks/src/useSuspense.ts @@ -0,0 +1,120 @@ +/* + * 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. + */ +import { action, IReactionDisposer, observable, reaction } from 'mobx'; + +import { useObservableRef } from './useObservableRef'; + +interface IObservedValueMetadata { + reaction: IReactionDisposer | null; + promise: Promise | null; + error: Error | null; + value: TValue | symbol; + version: number; + run(args: TArgs): void; +} + +interface ISuspense { + observedValue(key: string, args: () => TArgs, loader: (args: TArgs) => Promise): () => TValue; +} + +interface ISuspenseState extends ISuspense { + observedValueMetadata: Map>; +} + +const VALUE_NOT_SET = Symbol('value not set'); + +/** + * Experimental, use to pass suspended value getter to the child components + * + * (!!!) Don't access the suspended value in the same component where useSuspense is declared + * @returns + */ +export function useSuspense(): ISuspense { + const state = useObservableRef( + () => ({ + observedValueMetadata: new Map(), + + observedValue(key: string, args: () => TArgs, loader: (args: TArgs) => Promise): () => TValue { + let metadata = this.observedValueMetadata.get(key) as IObservedValueMetadata | undefined; + + if (!metadata) { + metadata = observable>( + { + reaction: null, + promise: null, + error: null, + version: 0, + value: VALUE_NOT_SET, + run(args: TArgs): void { + try { + this.promise = loader(args); + const version = ++this.version; + + this.promise + .then(value => { + if (this.version === version) { + this.value = value; + this.error = null; + } + }) + .catch(exception => { + if (this.version === version) { + this.error = exception; + } + }) + .finally(() => { + if (this.version === version) { + this.promise = null; + } + }); + } catch (exception: any) { + this.error = exception; + } + }, + }, + { + promise: observable.ref, + error: observable.ref, + value: observable.ref, + run: action.bound, + }, + ); + + metadata!.run(args()); + + metadata!.reaction = reaction(args, metadata!.run); + + this.observedValueMetadata.set(key, metadata!); + } + + return () => { + if (metadata!.promise) { + throw metadata!.promise; + } + + if (metadata!.error) { + throw metadata!.error; + } + + if (metadata!.value === VALUE_NOT_SET) { + metadata!.run(args()); + throw metadata!.promise; + } + + return metadata!.value as TValue; + }; + }, + }), + { + observedValue: action.bound, + }, + false, + ); + + return state; +} diff --git a/webapp/packages/core-utils/src/base64ToHex.ts b/webapp/packages/core-utils/src/base64ToHex.ts index ba6a09ccb8..96ce7f15ad 100644 --- a/webapp/packages/core-utils/src/base64ToHex.ts +++ b/webapp/packages/core-utils/src/base64ToHex.ts @@ -5,15 +5,10 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ +import { textToHex } from './textToHex'; // be careful with this when you calculate a big size blobs // it can block the main thread and cause freezes export function base64ToHex(base64String: string): string { - const raw = atob(base64String); - let result = ''; - for (let i = 0; i < raw.length; i++) { - const hex = raw.charCodeAt(i).toString(16); - result += hex.length === 2 ? hex : `0${hex}`; - } - return result.toUpperCase(); + return textToHex(atob(base64String)); } diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index e4275bfaac..c8da180740 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -68,6 +68,7 @@ export * from './schemaValidationError'; export * from './setByPath'; export * from './svgToDataUri'; export * from './TempMap'; +export * from './textToHex'; export * from './uriToBlob'; export * from './utf8ToBase64'; export * from './createLastPromiseGetter'; diff --git a/webapp/packages/core-utils/src/textToHex.test.ts b/webapp/packages/core-utils/src/textToHex.test.ts new file mode 100644 index 0000000000..4a3e2bf1c0 --- /dev/null +++ b/webapp/packages/core-utils/src/textToHex.test.ts @@ -0,0 +1,13 @@ +import { textToHex } from './textToHex'; + +const value = 'test value'; + +describe('textToHex', () => { + it('should return a hex string', () => { + expect(textToHex(value)).toBe('746573742076616C7565'); + }); + + it('should return an empty string', () => { + expect(textToHex('')).toBe(''); + }); +}); diff --git a/webapp/packages/core-utils/src/textToHex.ts b/webapp/packages/core-utils/src/textToHex.ts new file mode 100644 index 0000000000..a33d276855 --- /dev/null +++ b/webapp/packages/core-utils/src/textToHex.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +// be careful with this when you calculate a big size blobs +// it can block the main thread and cause freezes +export function textToHex(raw: string): string { + let result = ''; + for (let i = 0; i < raw.length; i++) { + const hex = raw.charCodeAt(i).toString(16); + result += hex.length === 2 ? hex : `0${hex}`; + } + return result.toUpperCase(); +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx index 4a8933c217..2a849e020d 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx @@ -9,12 +9,12 @@ import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react-lite'; import { useMemo } from 'react'; -import { ActionIconButton, Button, Container, Fill, s, useObservableRef, useS, useTranslate } from '@cloudbeaver/core-blocks'; +import { ActionIconButton, Button, Container, Fill, Loader, s, useObservableRef, useS, useSuspense, useTranslate } from '@cloudbeaver/core-blocks'; import { selectFiles } from '@cloudbeaver/core-browser'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { type TabContainerPanelComponent, useTabLocalState } from '@cloudbeaver/core-ui'; -import { bytesToSize, download, getMIME, isImageFormat, isValidUrl, throttle } from '@cloudbeaver/core-utils'; +import { blobToBase64, bytesToSize, download, getMIME, isImageFormat, isValidUrl, throttle } from '@cloudbeaver/core-utils'; import { createResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/createResultSetBlobValue'; import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey'; @@ -34,6 +34,7 @@ import styles from './ImageValuePresentation.m.css'; export const ImageValuePresentation: TabContainerPanelComponent> = observer( function ImageValuePresentation({ model, resultIndex }) { const translate = useTranslate(); + const suspense = useSuspense(); const notificationService = useService(NotificationService); const style = useS(styles); @@ -82,10 +83,10 @@ export const ImageValuePresentation: TabContainerPanelComponent throttle(() => data.download(), 1000, false), []); + const srcGetter = suspense.observedValue( + 'src', + () => data.src, + async src => { + if (src instanceof Blob) { + return await blobToBase64(src); + } + return src; + }, + ); return ( - {data.src && } - {isTruncatedMessageDisplay && ( - - {isDownloadable && ( - - )} - - )} + + {data.src && } + {isTruncatedMessageDisplay && ( + + {isDownloadable && ( + + )} + + )} + @@ -241,3 +252,18 @@ export const ImageValuePresentation: TabContainerPanelComponent string | null; +} + +export const ImageRenderer = observer(function ImageRenderer({ srcGetter, className }) { + const src = srcGetter(); + + if (!src) { + return null; + } + + return ; +}); diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.m.css b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.m.css index 6b0da42e73..ba0ba863e7 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.m.css +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.m.css @@ -1,7 +1,12 @@ -.link { - margin-left: 4px; -} +/* + * 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. + */ .limitWord { text-transform: lowercase; + display: contents; } diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.tsx index 88c39b8b8a..51866d8bde 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/QuotaPlaceholder.tsx @@ -41,25 +41,22 @@ export const QuotaPlaceholder: React.FC> = observ return ( - {translate('data_viewer_presentation_value_content_was_truncated')} - - {translate('data_viewer_presentation_value_content_truncated_placeholder')} - - {admin ? ( - - {translate('ui_limit')} - - ) : ( - translate('ui_limit') - )} - - + {translate('data_viewer_presentation_value_content_truncated_placeholder')} +   + + {admin ? ( + + {translate('ui_limit')} + + ) : ( + translate('ui_limit') + )} + {children} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/MAX_BLOB_PREVIEW_SIZE.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/MAX_BLOB_PREVIEW_SIZE.ts new file mode 100644 index 0000000000..9595f830e8 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/MAX_BLOB_PREVIEW_SIZE.ts @@ -0,0 +1,9 @@ +/* + * 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 const MAX_BLOB_PREVIEW_SIZE = 10 * 1024; diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValueEditor.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValueEditor.tsx new file mode 100644 index 0000000000..3f4648d291 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValueEditor.tsx @@ -0,0 +1,41 @@ +/* + * 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. + */ +import { observer } from 'mobx-react-lite'; +import { useMemo } from 'react'; + +import { useService } from '@cloudbeaver/core-di'; +import { EditorLoader, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6'; + +import { DataViewerService } from '../../DataViewerService'; +import { getTypeExtension } from './getTypeExtension'; + +interface Props { + contentType: string; + readonly: boolean; + lineWrapping: boolean; + valueGetter: () => string; + onChange: (value: string) => void; +} + +export const TextValueEditor = observer(function TextValueEditor({ contentType, valueGetter, readonly, lineWrapping, onChange }) { + const value = valueGetter(); + const typeExtension = useMemo(() => getTypeExtension(contentType!) ?? [], [contentType]); + const extensions = useCodemirrorExtensions(undefined, typeExtension); + const dataViewerService = useService(DataViewerService); + + return ( + + ); +}); 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 ecb9672d99..cff13cd880 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -7,53 +7,39 @@ */ import { observable } from 'mobx'; import { observer } from 'mobx-react-lite'; -import { useMemo } from 'react'; -import { ActionIconButton, Button, Container, Fill, Group, s, SContext, StyleRegistry, useS, useTranslate } from '@cloudbeaver/core-blocks'; +import { ActionIconButton, Container, Fill, Group, Loader, s, SContext, StyleRegistry, useS, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { TabContainerPanelComponent, TabList, TabsState, TabStyles, TabUnderlineStyleRegistry, useTabLocalState } from '@cloudbeaver/core-ui'; -import { bytesToSize, isNotNullDefined } from '@cloudbeaver/core-utils'; -import { EditorLoader, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6'; -import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; import { useResultSetActions } from '../../DatabaseDataModel/Actions/ResultSet/useResultSetActions'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; -import { DataViewerService } from '../../DataViewerService'; import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; -import { QuotaPlaceholder } from '../QuotaPlaceholder'; import { getDefaultLineWrapping } from './getDefaultLineWrapping'; -import { getTypeExtension } from './getTypeExtension'; import styles from './shared/TextValuePresentation.m.css'; import TextValuePresentationTab from './shared/TextValuePresentationTab.m.css'; +import { TextValueEditor } from './TextValueEditor'; import { TextValuePresentationService } from './TextValuePresentationService'; +import { TextValueTruncatedMessage } from './TextValueTruncatedMessage'; import { useTextValue } from './useTextValue'; -const DEFAULT_CONTENT_TYPE = 'text/plain'; - const tabRegistry: StyleRegistry = [...TabUnderlineStyleRegistry, [TabStyles, { mode: 'append', styles: [TextValuePresentationTab] }]]; export const TextValuePresentation: TabContainerPanelComponent> = observer( function TextValuePresentation({ model, resultIndex, dataFormat }) { const translate = useTranslate(); - const dataViewerService = useService(DataViewerService); const notificationService = useService(NotificationService); const textValuePresentationService = useService(TextValuePresentationService); const style = useS(styles); const selection = model.source.getAction(resultIndex, ResultSetSelectAction); const activeElements = selection.getActiveElements(); const firstSelectedCell = activeElements.length ? activeElements[0] : undefined; - const activeTabs = textValuePresentationService.tabs.getDisplayed({ - dataFormat: dataFormat, - model: model, - resultIndex: resultIndex, - }); const { contentAction, editAction, formatAction } = useResultSetActions({ model, resultIndex, }); - const contentValue = firstSelectedCell ? formatAction.get(firstSelectedCell) : null; const state = useTabLocalState(() => observable({ lineWrapping: null as boolean | null, @@ -68,50 +54,20 @@ export const TextValuePresentation: TabContainerPanelComponent 0 && !activeTabs.some(tab => tab.key === contentType)) { - contentType = activeTabs[0].key; - } - - const autoLineWrapping = getDefaultLineWrapping(contentType); - const lineWrapping = state.lineWrapping ?? autoLineWrapping; - - const textValueData = useTextValue({ + const textValueInfo = useTextValue({ model, resultIndex, - currentContentType: contentType, + dataFormat, + currentContentType: state.currentContentType, elementKey: firstSelectedCell, }); + const autoLineWrapping = getDefaultLineWrapping(textValueInfo.contentType); + const lineWrapping = state.lineWrapping ?? autoLineWrapping; + const isSelectedCellReadonly = firstSelectedCell && (formatAction.isReadOnly(firstSelectedCell) || formatAction.isBinary(firstSelectedCell)); const isReadonlyByResultIndex = model.isReadonly(resultIndex) || model.isDisabled(resultIndex) || !firstSelectedCell; const isReadonly = isSelectedCellReadonly || isReadonlyByResultIndex; - const valueSize = - isResultSetContentValue(contentValue) && isNotNullDefined(contentValue.contentLength) ? bytesToSize(contentValue.contentLength) : undefined; const canSave = firstSelectedCell && contentAction.isDownloadable(firstSelectedCell); - const typeExtension = useMemo(() => getTypeExtension(contentType!) ?? [], [contentType]); - const extensions = useCodemirrorExtensions(undefined, typeExtension); function valueChangeHandler(newValue: string) { if (firstSelectedCell && !isReadonly) { @@ -133,7 +89,7 @@ export const TextValuePresentation: TabContainerPanelComponent selectTabHandler(tab.tabId)} @@ -161,28 +117,18 @@ export const TextValuePresentation: TabContainerPanelComponent - - - - {textValueData.isTruncated ? ( - - {textValueData.isTextColumn && ( - - - - )} - - ) : null} + + + + + + {firstSelectedCell && } {canSave && ( diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts index 7a337125b6..30d0b744e8 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts @@ -48,21 +48,21 @@ export class TextValuePresentationBootstrap extends Bootstrap { name: 'data_viewer_presentation_value_text_html_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, - isHidden: (_, context) => isBlobPresentationAvailable(context), + // isHidden: (_, context) => isBlobPresentationAvailable(context), }); this.textValuePresentationService.add({ key: 'text/xml', name: 'data_viewer_presentation_value_text_xml_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, - isHidden: (_, context) => isBlobPresentationAvailable(context), + // isHidden: (_, context) => isBlobPresentationAvailable(context), }); this.textValuePresentationService.add({ key: 'application/json', name: 'data_viewer_presentation_value_text_json_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, - isHidden: (_, context) => isBlobPresentationAvailable(context), + // isHidden: (_, context) => isBlobPresentationAvailable(context), }); this.textValuePresentationService.add({ diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValueTruncatedMessage.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValueTruncatedMessage.tsx new file mode 100644 index 0000000000..6ed479d0de --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValueTruncatedMessage.tsx @@ -0,0 +1,70 @@ +/* + * 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. + */ +import { observer } from 'mobx-react-lite'; + +import { Button, Container, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { bytesToSize, isNotNullDefined } from '@cloudbeaver/core-utils'; + +import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey'; +import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue'; +import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; +import { useResultSetActions } from '../../DatabaseDataModel/Actions/ResultSet/useResultSetActions'; +import type { IDatabaseDataModel } from '../../DatabaseDataModel/IDatabaseDataModel'; +import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; +import { QuotaPlaceholder } from '../QuotaPlaceholder'; +import { MAX_BLOB_PREVIEW_SIZE } from './MAX_BLOB_PREVIEW_SIZE'; + +interface Props { + resultIndex: number; + model: IDatabaseDataModel; + elementKey: IResultSetElementKey; +} + +export const TextValueTruncatedMessage = observer(function TextValueTruncatedMessage({ model, resultIndex, elementKey }) { + const translate = useTranslate(); + const notificationService = useService(NotificationService); + const { contentAction, formatAction } = useResultSetActions({ model, resultIndex }); + const contentValue = formatAction.get(elementKey); + let isTruncated = contentAction.isTextTruncated(elementKey); + const isCacheLoaded = !!contentAction.retrieveFullTextFromCache(elementKey); + const limitInfo = elementKey ? contentAction.getLimitInfo(elementKey) : null; + + if (isResultSetBlobValue(contentValue)) { + isTruncated ||= contentValue.blob.size > (limitInfo?.limit ?? MAX_BLOB_PREVIEW_SIZE); + } + + if (!isTruncated || isCacheLoaded) { + return null; + } + const isTextColumn = formatAction.isText(elementKey); + + const valueSize = + isResultSetContentValue(contentValue) && isNotNullDefined(contentValue.contentLength) ? bytesToSize(contentValue.contentLength) : undefined; + + async function pasteFullText() { + try { + await contentAction.getFileFullText(elementKey); + } catch (exception) { + notificationService.logException(exception as any, 'data_viewer_presentation_value_content_paste_error'); + } + } + + return ( + + {isTextColumn && ( + + + + )} + + ); +}); diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/formatText.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/formatText.ts new file mode 100644 index 0000000000..ba75276ec3 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/formatText.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ +import { textToHex } from '@cloudbeaver/core-utils'; + +export function formatText(type: string, value: string) { + try { + switch (type) { + case 'application/json': + return JSON.stringify(JSON.parse(value), null, 2); + case 'text/xml': + case 'text/html': + return value; + case 'application/octet-stream;type=hex': + return textToHex(value); + case 'application/octet-stream;type=base64': + case 'application/octet-stream': + return btoa(value); + default: + return value; + } + } catch { + return value; + } +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts index 578bacb05e..e65ce49ba7 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ import { isResultSetBinaryValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBinaryValue'; -import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; +import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; import { ResultSetViewAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetViewAction'; import type { IDatabaseDataResult } from '../../DatabaseDataModel/IDatabaseDataResult'; @@ -28,7 +28,7 @@ export function isBlobPresentationAvailable(context: IDataValuePanelProps ({ - format(type: string, value: string) { - try { - switch (type) { - case 'application/json': - return JSON.stringify(JSON.parse(value), null, 2); - case 'text/xml': - case 'text/html': - return value; - default: - return value; - } - } catch { - return value; - } - }, - formatBlob(type: string, value: IResultSetContentValue) { - if (!value.binary) { - return value.text; - } - - switch (type) { - case 'application/octet-stream;type=base64': - return value.binary; - case 'application/octet-stream;type=hex': - return base64ToHex(value.binary); - default: - return value.text; - } - }, - }), - false, - ); -} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts index f487f8af50..8a57c2d417 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts @@ -5,82 +5,144 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ +import { observable } from 'mobx'; + +import { useObservableRef, useSuspense } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; -import { NotificationService } from '@cloudbeaver/core-events'; -import { isNotNullDefined } from '@cloudbeaver/core-utils'; +import type { ResultDataFormat } from '@cloudbeaver/core-sdk'; +import { blobToBase64, isNotNullDefined, removeMetadataFromDataURL } from '@cloudbeaver/core-utils'; import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey'; +import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue'; import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; import { useResultSetActions } from '../../DatabaseDataModel/Actions/ResultSet/useResultSetActions'; import type { IDatabaseDataModel } from '../../DatabaseDataModel/IDatabaseDataModel'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; -import { useAutoFormat } from './useAutoFormat'; +import { formatText } from './formatText'; +import { MAX_BLOB_PREVIEW_SIZE } from './MAX_BLOB_PREVIEW_SIZE'; +import { TextValuePresentationService } from './TextValuePresentationService'; interface IUseTextValueArgs { resultIndex: number; model: IDatabaseDataModel; - currentContentType: string; + dataFormat: ResultDataFormat | null; + currentContentType: string | null; elementKey?: IResultSetElementKey; } -interface IUseTextValue { - textValue: string; - isTruncated: boolean; - isTextColumn: boolean; - pasteFullText(): Promise; +interface ITextValueInfo { + valueGetter: () => string; + contentType: string; } -export function useTextValue({ model, resultIndex, currentContentType, elementKey }: IUseTextValueArgs): IUseTextValue { +const DEFAULT_CONTENT_TYPE = 'text/plain'; + +export function useTextValue({ model, dataFormat, resultIndex, currentContentType, elementKey }: IUseTextValueArgs): ITextValueInfo { const { formatAction, editAction, contentAction } = useResultSetActions({ model, resultIndex }); - const formatter = useAutoFormat(); - const isTextColumn = elementKey ? formatAction.isText(elementKey) : false; + const suspense = useSuspense(); const contentValue = elementKey ? formatAction.get(elementKey) : null; - const isBinary = elementKey ? formatAction.isBinary(elementKey) : false; - const isTruncated = elementKey ? contentAction.isTextTruncated(elementKey) : false; - const cachedFullText = elementKey ? contentAction.retrieveFullTextFromCache(elementKey) : ''; - const notificationService = useService(NotificationService); - - const result: IUseTextValue = { - textValue: '', - isTruncated, - isTextColumn, - async pasteFullText() { - if (!elementKey) { - return; - } + const textValuePresentationService = useService(TextValuePresentationService); + const activeTabs = textValuePresentationService.tabs.getDisplayed({ + dataFormat: dataFormat, + model: model, + resultIndex: resultIndex, + }); + const limitInfo = elementKey ? contentAction.getLimitInfo(elementKey) : null; - try { - await contentAction.getFileFullText(elementKey); - } catch (exception) { - notificationService.logException(exception as any, 'data_viewer_presentation_value_content_paste_error'); - } + const observedContentValue = useObservableRef( + { + contentValue, + limitInfo, }, - }; + { contentValue: observable.ref, limitInfo: observable.ref }, + ); + + let contentType = currentContentType; + let autoContentType = DEFAULT_CONTENT_TYPE; + let contentValueType; - if (!isNotNullDefined(elementKey)) { - return result; + if (isResultSetContentValue(contentValue)) { + contentValueType = contentValue.contentType; } - if (isTextColumn && cachedFullText) { - result.textValue = cachedFullText; - result.isTruncated = false; + if (isResultSetBlobValue(contentValue)) { + contentValueType = contentValue.blob.type; } - if (editAction.isElementEdited(elementKey)) { - result.textValue = formatAction.getText(elementKey); + if (contentValueType) { + switch (contentValueType) { + case 'text/json': + autoContentType = 'application/json'; + break; + case 'application/octet-stream': + autoContentType = 'application/octet-stream;type=base64'; + break; + default: + autoContentType = contentValueType; + break; + } } - if (isBinary && isResultSetContentValue(contentValue)) { - const value = formatter.formatBlob(currentContentType, contentValue); + if (contentType === null) { + contentType = autoContentType ?? DEFAULT_CONTENT_TYPE; + } - if (value) { - result.textValue = value; - } + if (activeTabs.length > 0 && !activeTabs.some(tab => tab.key === contentType)) { + contentType = activeTabs[0].key; } - if (!result.textValue) { - result.textValue = formatter.format(currentContentType, formatAction.getText(elementKey)); + const parsedBlobValueGetter = suspense.observedValue( + 'value-blob', + () => ({ + blob: isResultSetBlobValue(observedContentValue.contentValue) ? observedContentValue.contentValue.blob : null, + limit: observedContentValue.limitInfo?.limit, + }), + async ({ blob, limit }) => { + if (!blob) { + return null; + } + const dataURL = await blobToBase64(blob, limit ?? MAX_BLOB_PREVIEW_SIZE); + + if (!dataURL) { + return null; + } + + return removeMetadataFromDataURL(dataURL); + }, + ); + + function valueGetter() { + let value = ''; + + if (!isNotNullDefined(elementKey)) { + return value; + } + + const contentValue = formatAction.get(elementKey); + const isBinary = formatAction.isBinary(elementKey); + const cachedFullText = contentAction.retrieveFullTextFromCache(elementKey); + + if (isBinary && isResultSetContentValue(contentValue)) { + if (contentValue.binary) { + value = atob(contentValue.binary); + } else if (contentValue.text) { + value = contentValue.text; + } + } else if (isResultSetBlobValue(contentValue)) { + value = atob(parsedBlobValueGetter() ?? ''); + } else { + value = cachedFullText || formatAction.getText(elementKey); + } + + if (!editAction.isElementEdited(elementKey) || isBinary) { + value = formatText(contentType!, value); + } + + return value; } - return result; + return { + valueGetter, + contentType, + }; } diff --git a/webapp/packages/plugin-data-viewer/src/locales/en.ts b/webapp/packages/plugin-data-viewer/src/locales/en.ts index afeb6a8def..8252fcf1f7 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/en.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/en.ts @@ -37,8 +37,7 @@ export default [ ['data_viewer_presentation_value_image_fit', 'Fit Window'], ['data_viewer_presentation_value_image_original_size', 'Original Size'], ['data_viewer_presentation_value_boolean_placeholder', "Can't show current value as boolean"], - ['data_viewer_presentation_value_content_truncated_placeholder', 'The size of the value exceeds the'], - ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], + ['data_viewer_presentation_value_content_truncated_placeholder', 'The value was truncated because of the'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], ['data_viewer_script_preview', 'Script'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/it.ts b/webapp/packages/plugin-data-viewer/src/locales/it.ts index a35769e675..8a952d8490 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/it.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/it.ts @@ -33,8 +33,7 @@ export default [ ['data_viewer_presentation_value_image_fit', 'Adatta alla Finestra'], ['data_viewer_presentation_value_image_original_size', 'Dimensioni Originali'], ['data_viewer_presentation_value_boolean_placeholder', 'Non posso rappresentare il valore corrente come booleano'], - ['data_viewer_presentation_value_content_truncated_placeholder', 'The size of the value exceeds the'], - ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], + ['data_viewer_presentation_value_content_truncated_placeholder', 'The value was truncated because of the'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], ['data_viewer_refresh_result_set', 'Refresh result set'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/ru.ts b/webapp/packages/plugin-data-viewer/src/locales/ru.ts index 307b45cd6b..8792885dc2 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/ru.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/ru.ts @@ -31,8 +31,7 @@ export default [ ['data_viewer_presentation_value_image_fit', 'Растянуть'], ['data_viewer_presentation_value_image_original_size', 'Оригинальный размер'], ['data_viewer_presentation_value_boolean_placeholder', 'Не удалось отобразить текущее значение как boolean'], - ['data_viewer_presentation_value_content_truncated_placeholder', 'Размер значения превышает'], - ['data_viewer_presentation_value_content_was_truncated', 'Значение было обрезано'], + ['data_viewer_presentation_value_content_truncated_placeholder', 'Значение обрезано потому что превышает'], ['data_viewer_presentation_value_content_download_error', 'Не удалось загрузить файл'], ['data_viewer_presentation_value_content_paste_error', 'Не удалось загрузить весь текст'], ['data_viewer_script_preview', 'Скрипт'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/zh.ts b/webapp/packages/plugin-data-viewer/src/locales/zh.ts index d5bf26d577..7c8babb48c 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/zh.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/zh.ts @@ -37,8 +37,7 @@ export default [ ['data_viewer_presentation_value_image_fit', '适应窗口'], ['data_viewer_presentation_value_image_original_size', '原始尺寸'], ['data_viewer_presentation_value_boolean_placeholder', '无法将当前值显示为布尔值'], - ['data_viewer_presentation_value_content_truncated_placeholder', 'The size of the value exceeds the'], - ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], + ['data_viewer_presentation_value_content_truncated_placeholder', 'The value was truncated because of the'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], ['data_viewer_script_preview', '脚本'],