Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6de0ac9
dbeaver/pro#8670 adds multi-cells copy-paste
sergeyteleshev Mar 25, 2026
3c9f907
refactor the helper
sergeyteleshev Mar 25, 2026
ad99fe5
ignores cells to copy (readonly & LoBs)
sergeyteleshev Mar 25, 2026
aa6aba9
reduces amount of code
sergeyteleshev Mar 25, 2026
9356335
cleanup
sergeyteleshev Mar 25, 2026
ec6134c
Merge branch 'devel' into 8670-cb-multi-paste-support-in-data-editor
sergeyteleshev Mar 26, 2026
b4c6464
adds ability to copy/paste LOBs
sergeyteleshev Mar 26, 2026
4a8c1fa
adds getCellTextValue helper
sergeyteleshev Mar 26, 2026
adc229c
moves isCellEditable to helper to remove logic duplication
sergeyteleshev Mar 26, 2026
9e26475
split clipboard helper to a few ones
sergeyteleshev Mar 26, 2026
75ee835
removes getCellValue
sergeyteleshev Mar 26, 2026
66c274f
build fix
sergeyteleshev Mar 26, 2026
845d823
cleanup
sergeyteleshev Mar 27, 2026
54209e1
support contiguous copy paste
sergeyteleshev Mar 27, 2026
2112e89
Revert "support contiguous copy paste"
sergeyteleshev Mar 27, 2026
89a81ff
Merge branch 'devel' into 8670-cb-multi-paste-support-in-data-editor
sergeyteleshev Mar 27, 2026
d90218c
removes helpers
sergeyteleshev Mar 27, 2026
46d5a49
Merge branch 'devel' into 8670-cb-multi-paste-support-in-data-editor
sergeyteleshev Mar 27, 2026
7af517f
Merge branch 'devel' into 8670-cb-multi-paste-support-in-data-editor
sergeyteleshev Mar 30, 2026
ce1e709
Merge branch 'devel' into 8670-cb-multi-paste-support-in-data-editor
sergeyteleshev Mar 30, 2026
214257a
Merge branch 'devel' into 8670-cb-multi-paste-support-in-data-editor
sergeyteleshev Apr 1, 2026
92cd1c2
Merge branch 'devel' into 8670-cb-multi-paste-support-in-data-editor
sergeyteleshev Apr 5, 2026
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
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 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 { createContext } from 'react';
import type { IGridReactiveValue } from './IGridReactiveValue.js';
import type { DataGridCellKeyboardEvent } from './DataGrid.js';

export interface IDataGridHeaderCellContext {
headerElement?: IGridReactiveValue<React.ReactNode, [colIdx: number]>;
Expand All @@ -23,7 +24,7 @@ export interface IDataGridHeaderCellContext {
columnSortable?: IGridReactiveValue<boolean, [colIdx: number]>;
columnSortingMultiple?: boolean;
onColumnSort?: (colIdx: number, order: 'asc' | 'desc' | null, isMultiple: boolean) => void;
onHeaderKeyDown?: (event: React.KeyboardEvent) => void;
onHeaderKeyDown?: (event: DataGridCellKeyboardEvent) => void;
}

export const DataGridCellHeaderContext = createContext<IDataGridHeaderCellContext | null>(null);
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { useGridReactiveValue } from '../useGridReactiveValue.js';
import { HeaderDnDContext } from '../useHeaderDnD.js';
import { OrderButton } from './OrderButton.js';
import type { DataGridCellKeyboardEvent } from '../DataGrid.js';

interface Props {
colIdx: number;
Expand Down Expand Up @@ -67,11 +68,11 @@
return;
}

const nextSortState = sortingState === 'asc' ? 'desc' : sortingState === 'desc' ? null : 'asc';

Check warning on line 71 in webapp/common-react/@dbeaver/react-data-grid/src/renderers/HeaderCellContentRenderer.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Do not nest ternary expressions
onColumnSort(colIdx, nextSortState, e.ctrlKey || e.metaKey);
}

function onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
function onKeyDown(event: DataGridCellKeyboardEvent) {
onHeaderKeyDown?.(event);

if ((event.key === 'Enter' || event.key === ' ') && isColumnSortable && onColumnSort) {
Expand Down
1 change: 1 addition & 0 deletions webapp/packages/plugin-data-grid/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
type DataGridProps,
type IGridSearchStorageState,
type IGridSearchStorage,
type DataGridCellKeyboardEvent,
} from '@dbeaver/react-data-grid';

export { GrantManagementTable } from './GrantManagementTableLazy.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,7 @@ export class DataGridContextMenuCellEditingService {
actions.edit(key);
break;
case ACTION_DATA_GRID_EDITING_SET_TO_NULL:
for (const element of selectedElements) {
// TODO wait for search merge to implement setMany here in order to have batched changes for undo/redo
editor.set(element, null);
}
editor.setMany(selectedElements.map(key => ({ key, value: null })));
break;
case ACTION_DATA_GRID_EDITING_ADD_ROW:
editor.add(...selectedElements);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
DataViewerPresentationType,
type IDatabaseDataModel,
type IDataPresentationProps,
isBooleanValuePresentationAvailable,
GridDataKeysUtils,
ResultSetDataSource,
getNextOrder,
Expand Down Expand Up @@ -61,13 +60,14 @@ import { FormattingContext } from './FormattingContext.js';
import { TableDataContext } from './TableDataContext.js';
import { useGridDragging } from './useGridDragging.js';
import { useFormatting } from './useFormatting.js';
import { useGridSelectedCellsCopy } from './useGridSelectedCellsCopy.js';
import { useGridSelectedCellsClipboard } from './useGridSelectedCellsClipboard.js';
import { useSearchResultsCache } from './useSearchResultsCache.js';
import { useTableData } from './useTableData.js';
import { TableColumnHeader } from './TableColumnHeader/TableColumnHeader.js';
import { TableIndexColumnHeader } from './TableColumnHeader/TableIndexColumnHeader.js';
import { clsx } from '@dbeaver/ui-kit';
import type { ColumnDropSide } from './getDropSide.js';
import { GridCellsHelper } from './gridCellsHelper.js';

const ROW_HEIGHT = 24;
export const HEADER_HEIGHT = 32;
Expand All @@ -91,6 +91,7 @@ export const DataGridTable = observer<IDataPresentationProps>(function DataGridT
const searchResultsCache = useSearchResultsCache(cacheAction);
const getHeaderOrder = useCallback(() => (dataGridRef.current?.getColumnsOrdered() ?? []).map(col => col.key), [dataGridRef]);
const gridSelectionContext = useGridSelectionContext(tableData, selectionAction, getHeaderOrder);
const hasElementIdentifier = isResultSetDataSource(model.source) ? model.source.hasElementIdentifier(resultIndex) : false;

const columnDnDState = useObservableRef<IColumnDnDState>(
() => ({
Expand Down Expand Up @@ -184,7 +185,12 @@ export const DataGridTable = observer<IDataPresentationProps>(function DataGridT
},
}));

const gridSelectedCellCopy = useGridSelectedCellsCopy(tableData, selectionAction as unknown as DatabaseSelectAction, gridSelectionContext);
const gridSelectedCellsClipboard = useGridSelectedCellsClipboard(
tableData,
selectionAction as DatabaseSelectAction,
gridSelectionContext,
hasElementIdentifier,
);
const { onMouseDownHandler, onMouseMoveHandler } = useGridDragging({
onDragStart: startPosition => {
handlers.selectCell(startPosition);
Expand Down Expand Up @@ -518,30 +524,7 @@ export const DataGridTable = observer<IDataPresentationProps>(function DataGridT
return false;
}

const cell = { row, column };

const editionState = tableData.getEditionState(cell);

const source = gridContext.model.source;
const hasElementIdentifier = isResultSetDataSource(source) ? source.hasElementIdentifier(tableData.view.resultIndex) : false;
if (!hasElementIdentifier && editionState !== DatabaseEditChangeType.add) {
return false;
}

const holder = tableData.getCellHolder(cell);
if (tableData.format.isBinary(holder) || tableData.format.isGeometry(holder) || tableData.dataContent.isTextTruncated(holder)) {
return false;
}

const resultColumn = tableData.getColumnInfo(cell.column);

if (!resultColumn || holder.value === undefined) {
return false;
}

const handleByBooleanFormatter = isBooleanValuePresentationAvailable(holder.value, resultColumn);

return !(handleByBooleanFormatter || tableData.isCellReadonly(cell));
return GridCellsHelper.isCellEditable({ row, column }, tableData, hasElementIdentifier);
}

function getColumnKey(colIdx: number) {
Expand All @@ -559,7 +542,7 @@ export const DataGridTable = observer<IDataPresentationProps>(function DataGridT
}

const handleCellKeyDown: DataGridProps['onCellKeyDown'] = (_, event) => {
gridSelectedCellCopy.onKeydownHandler(event);
gridSelectedCellsClipboard.onKeydownHandler(event);
const cell = selectionAction.getFocusedElement();

if (EventContext.has(event, EventStopPropagationFlag) || model.isReadonly(resultIndex) || !cell) {
Expand Down Expand Up @@ -621,7 +604,9 @@ export const DataGridTable = observer<IDataPresentationProps>(function DataGridT
onCellChange={handleCellChange}
onCellChangeBatch={handleCellChangeBatch}
onCellKeyDown={handleCellKeyDown}
onHeaderKeyDown={gridSelectedCellCopy.onKeydownHandler}
onHeaderKeyDown={event => {
gridSelectedCellsClipboard.onKeydownHandler(event);
}}
/>
</div>
</FormattingContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { DatabaseEditChangeType, type IGridDataKey, isBooleanValuePresentationAvailable } from '@cloudbeaver/plugin-data-viewer';

import type { ITableData } from './TableDataContext.js';

export type CellUpdate = { key: IGridDataKey; value: string };

const COLUMN_SIGNATURE_SEPARATOR = '***column-separator***';

export const GridCellsHelper = {
getColumnSignature(row: IGridDataKey[], tableData: ITableData): string {
return row.map(cell => tableData.getColumnIndexFromColumnKey(cell.column)).join(COLUMN_SIGNATURE_SEPARATOR);
},
isCellEditable(key: IGridDataKey, tableData: ITableData, hasElementIdentifier: boolean): boolean {
const editionState = tableData.getEditionState(key);

if (!hasElementIdentifier && editionState !== DatabaseEditChangeType.add) {
return false;
}

const holder = tableData.getCellHolder(key);

if (tableData.format.isBinary(holder) || tableData.format.isGeometry(holder) || tableData.dataContent.isTextTruncated(holder)) {
return false;
}

const resultColumn = tableData.getColumnInfo(key.column);

if (!resultColumn || holder.value === undefined) {
return false;
}

return !(isBooleanValuePresentationAvailable(holder.value, resultColumn) || tableData.isCellReadonly(key));
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { DatabaseSelectAction, type IGridDataKey, GridDataKeysUtils, getCellTextValue } from '@cloudbeaver/plugin-data-viewer';

import type { IDataGridSelectionContext } from './DataGridSelection/DataGridSelectionContext.js';
import type { ITableData } from './TableDataContext.js';
import { type CellUpdate, GridCellsHelper } from './gridCellsHelper.js';
import { GridSelectionHelper } from './gridSelectionHelper.js';

const CELL_COLUMN_SEPARATOR = '\t';
const ROW_LINE_SEPARATOR = '\r\n';
const CLIPBOARD_LINE_SEPARATOR_REGEX = /\r?\n/;

export const GridClipboardHelper = {
isClipboardTarget(event: React.KeyboardEvent): boolean {
const role = (document.activeElement as HTMLElement | null)?.getAttribute('role');

return role === 'gridcell' || role === 'columnheader' || event.target === event.currentTarget;
},
getValueFromSelectedCells(tableData: ITableData, selectedCells: Map<string, IGridDataKey[]>, focusedCell?: IGridDataKey | null): string | null {
if (selectedCells.size === 0) {
return focusedCell ? getCellTextValue(tableData.getCellHolder(focusedCell), tableData.format, tableData.dataContent) : null;
}

if (!GridSelectionHelper.isContiguousSelection(selectedCells, tableData)) {
return null;
}

const orderedRows = [...selectedCells.values()];

const selectedColumnKeys = new Set(orderedRows.flatMap(row => row.map(cell => GridDataKeysUtils.serialize(cell.column))));
const selectedColumns = tableData.view.columnKeys.filter(column => selectedColumnKeys.has(GridDataKeysUtils.serialize(column)));

if (selectedColumns.length === 0) {
return null;
}

return orderedRows
.map(rowSelection => {
const rowCells = new Map(rowSelection.map(key => [GridDataKeysUtils.serialize(key.column), key]));

return selectedColumns
.map(column => {
const cellKey = rowCells.get(GridDataKeysUtils.serialize(column));
return cellKey ? getCellTextValue(tableData.getCellHolder(cellKey), tableData.format, tableData.dataContent) : '';
})
.join(CELL_COLUMN_SEPARATOR);
})
.join(ROW_LINE_SEPARATOR);
},
parseClipboard(text: string): string[][] {
return text
.split(CLIPBOARD_LINE_SEPARATOR_REGEX)
.filter(row => row.length > 0)
.map(row => row.split(CELL_COLUMN_SEPARATOR));
},
getPastedCells(
clipboardText: string,
selectionContext: IDataGridSelectionContext,
selectionAction: DatabaseSelectAction | undefined,
tableData: ITableData,
hasElementIdentifier: boolean,
): CellUpdate[] {
const clipboardData = this.parseClipboard(clipboardText);

if (clipboardData.length === 0) {
return [];
}

const targetCells = GridSelectionHelper.getSelectedCells(selectionContext, selectionAction);

if (targetCells.length === 0) {
return [];
}

return this.mapClipboardToSelection(clipboardData, targetCells, tableData, hasElementIdentifier);
},
mapClipboardToGrid(clipboard: string[][], targetGrid: IGridDataKey[][]): CellUpdate[] {
const clipCols = clipboard[0]?.length ?? 0;

return targetGrid
.slice(0, clipboard.length)
.flatMap((row, tRow) => row.slice(0, clipCols).map((key, tCol) => ({ key, value: clipboard[tRow]![tCol]! })));
},
mapClipboardToSelection(clipboard: string[][], targets: IGridDataKey[], tableData: ITableData, hasElementIdentifier: boolean): CellUpdate[] {
const clipCols = clipboard[0]?.length ?? 0;

if (clipboard.length === 0 || clipCols === 0) {
return [];
}

let updates: CellUpdate[];

if (clipboard.length === 1 && clipCols === 1) {
const value = clipboard[0]![0]!;
updates = targets.map(key => ({ key, value }));
} else {
updates = GridSelectionHelper.getSelectionSegments(targets, tableData).flatMap(segmentGrid => this.mapClipboardToGrid(clipboard, segmentGrid));
}

return updates.filter(
({ key, value }) =>
GridCellsHelper.isCellEditable(key, tableData, hasElementIdentifier) && tableData.format.getText(tableData.format.get(key)) !== value,
);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { DatabaseSelectAction, GridDataKeysUtils, type IGridDataKey } from '@cloudbeaver/plugin-data-viewer';

import type { IDataGridSelectionContext } from './DataGridSelection/DataGridSelectionContext.js';
import type { ITableData } from './TableDataContext.js';
import { GridCellsHelper } from './gridCellsHelper.js';

export const GridSelectionHelper = {
isContiguousSelection(selectedCells: Map<string, IGridDataKey[]>, tableData: ITableData): boolean {
if (selectedCells.size === 0) {
return true;
}

const cells = [...selectedCells.values()].flat();
return this.getSelectionSegments(cells, tableData).length === 1;
},
getSelectedCells(selectionContext: IDataGridSelectionContext, selectionAction: DatabaseSelectAction | undefined): IGridDataKey[] {
const selectedCells = Array.from(selectionContext.selectedCells.values()).flat();

if (selectedCells.length > 0) {
return selectedCells;
}

const focused = selectionAction?.getFocusedElement() as IGridDataKey | null;
return focused ? [focused] : [];
},
getSelectionSegments(cells: IGridDataKey[], tableData: ITableData): IGridDataKey[][][] {
if (cells.length === 0) {
return [];
}

const rowMap = new Map<string, IGridDataKey[]>();

for (const cell of cells) {
const rowKey = GridDataKeysUtils.serialize(cell.row);
if (!rowMap.has(rowKey)) {
rowMap.set(rowKey, []);
}
rowMap.get(rowKey)!.push(cell);
}

const grid = Array.from(rowMap.values());

if (grid.length === 0) {
return [];
}

const segments: IGridDataKey[][][] = [];
let currentSegmentRows: IGridDataKey[][] = [grid[0]!];
let prevRowIndex = tableData.getRowIndexFromKey(grid[0]![0]!.row);
let prevColSignature = GridCellsHelper.getColumnSignature(grid[0]!, tableData);

for (let r = 1; r < grid.length; r++) {
const row = grid[r]!;
const rowIndex = tableData.getRowIndexFromKey(row[0]!.row);
const colSignature = GridCellsHelper.getColumnSignature(row, tableData);

if (rowIndex === prevRowIndex + 1 && colSignature === prevColSignature) {
currentSegmentRows.push(row);
} else {
segments.push(currentSegmentRows);
currentSegmentRows = [row];
}

prevRowIndex = rowIndex;
prevColSignature = colSignature;
}

segments.push(currentSegmentRows);

return segments;
},
};
Loading
Loading