diff --git a/webapp/common-react/@dbeaver/react-data-grid/package.json b/webapp/common-react/@dbeaver/react-data-grid/package.json index bdf5ad3809..7585f1e4e7 100644 --- a/webapp/common-react/@dbeaver/react-data-grid/package.json +++ b/webapp/common-react/@dbeaver/react-data-grid/package.json @@ -8,6 +8,7 @@ "./*": "./lib/*.js" }, "scripts": { + "build": "tsc -b", "clear": "rimraf lib" }, "files": [ diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx b/webapp/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx index a8e723ddf9..82b7141010 100644 --- a/webapp/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx +++ b/webapp/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx @@ -1,6 +1,6 @@ /* * 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. @@ -8,6 +8,7 @@ import { memo, use, useMemo } from 'react'; import { Cell, type CellRendererProps } from 'react-data-grid'; +import { clsx } from '@dbeaver/ui-kit'; import { DataGridCellContext, type IDataGridCellRenderer } from './DataGridCellContext.js'; import { createCellMouseEvent } from './eventUtils.js'; import { DataGridCellInnerContext, type IDataGridCellInnerContext } from './DataGridCellInnerContext.js'; @@ -21,6 +22,7 @@ export const BaseCell = memo(function BaseCell(props: CellRen const dataColIdx = dndContext.getDataColIdxByKey(props.column.key); const rowIdx = props.rowIdx; const tooltip = useGridReactiveValue(cellContext?.cellTooltip, rowIdx, dataColIdx); + const cellClassName = useGridReactiveValue(cellContext?.getCellClassName, rowIdx, dataColIdx); function handleClick(event: React.MouseEvent) { props.onCellClick?.( @@ -85,10 +87,11 @@ export const BaseCell = memo(function BaseCell(props: CellRen const renderDefaultCell = useMemo( () => - ({ onClick, onDoubleClick, onContextMenu, ...rest }) => ( + ({ onClick, onDoubleClick, onContextMenu, className, ...rest }) => ( onClick?.(event)} onCellDoubleClick={(_, event) => onDoubleClick?.(event)} onCellContextMenu={(_, event) => onContextMenu?.(event)} @@ -96,11 +99,15 @@ export const BaseCell = memo(function BaseCell(props: CellRen isCellSelected={props.isCellSelected || rest.isFocused || false} /> ), - [...Object.values(props), tooltip], + [...Object.values(props), tooltip, cellClassName], ); const cellElement = useGridReactiveValue(cellContext?.cellElement, rowIdx, dataColIdx, mappedProps, renderDefaultCell); const innerCellContext = useMemo(() => ({ isFocused: props.isCellSelected }), [props.isCellSelected]); - return {cellElement ?? }; + return ( + + {cellElement ?? } + + ); }) as (props: CellRendererProps) => React.ReactNode; diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.css b/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.css index 526341c527..b1672cd663 100644 --- a/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.css +++ b/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.css @@ -1,6 +1,31 @@ @import url('@dbeaver/ui-kit/index.css'); @import url('react-data-grid/lib/styles.css') layer(components); +@layer components.rdg.Search { + .rdg-search-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + } + + .rdg-search-panel { + padding-bottom: 0 !important; + .search-panel__inputs { + max-width: var(--tw-container-lg); + } + } + + .rdg-cell-search-match { + background-color: #ffff0054; + } + + .rdg-cell-search-active { + background-color: #ff6a0054; + } +} + @layer components.rdg.Cell { .rdg { --dbv-kit-icon-btn-outline-width: 1px; diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx b/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx index 4de5295d95..2d58ba0522 100644 --- a/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx +++ b/webapp/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx @@ -1,12 +1,12 @@ /* * 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 { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { Activity, forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { DataGrid as DataGridBase, type ColumnOrColumnGroup, @@ -29,6 +29,9 @@ import { mapEditCellRenderer } from './mapEditCellRenderer.js'; import { DataGridRowContext, type IDataGridRowContext } from './DataGridRowContext.js'; import './DataGrid.css'; import { HeaderDnDContext, isColumn, useHeaderDnD } from './useHeaderDnD.js'; +import type { IGridSearchStorage } from './search/useGridSearch.js'; +import { GridSearchPanel } from './search/GridSearchPanel.js'; +import { useDataGridSearch } from './useDataGridSearch.js'; export interface ICellPosition { rowIdx: number; @@ -51,13 +54,22 @@ export interface DataGridProps extends IDataGridCellContext, IDataGridRowContext onEditorOpen?: (position: ICellPosition) => void; onCellKeyDown?: (position: ICellPosition, event: DataGridCellKeyboardEvent) => void; className?: string; + search?: { + isEnabled?: boolean; + isReadOnly?: boolean; + storage?: IGridSearchStorage; + }; } export interface DataGridRef { - selectCell: (position: ICellPosition) => void; + selectCell: (position: ICellPosition, options?: { deferred?: boolean }) => boolean; scrollToCell: (position: Partial) => void; openEditor: (position: ICellPosition) => void; + restoreFocus: () => void; getColumnsOrdered: () => readonly CalculatedColumn[]; + openSearch: () => void; + closeSearch: () => void; + refreshSearch: () => void; } const MAX_AUTO_SIZE_WIDTH = 350; @@ -92,11 +104,13 @@ export const DataGrid = forwardRef(function DataGrid onScrollToBottom, onFocus, onCellChange, + onCellChangeBatch, onColumnSort, onHeaderKeyDown, children, className, onCellKeyDown, + search: { isEnabled: searchEnabled, isReadOnly: searchReadOnly, storage: searchStorage } = {}, }, ref, ) { @@ -104,8 +118,26 @@ export const DataGrid = forwardRef(function DataGrid const rowsCount = useGridReactiveValue(rowCount); const columnsCount = useGridReactiveValue(columnCount); - const rowsCountRef = useRef(rowsCount); + const [prevRowsCount, setPrevRowsCount] = useState(rowsCount); const innerGridRef = useRef>(null); + const containerRef = useRef(null); + const deferredSelectRef = useRef | null>(null); + const { + searchOpen, + searchCellClassName, + searchPanelRef, + setSearchPanelRef, + isReplacingRef, + handleSearchOpen, + handleSearchClose, + onReplace, + handleReplacingChange, + } = useDataGridSearch({ containerRef, searchStorage, getCellEditable, onCellChangeBatch }); + + function scrollToCell(rowIdx: number, colIdx: number) { + innerGridRef.current?.scrollToCell({ idx: colIdx, rowIdx }); + } + const columns = new Array>(columnsCount) .fill(null as any) .map((_, i): ColumnOrColumnGroup => { @@ -141,15 +173,37 @@ export const DataGrid = forwardRef(function DataGrid return null; } + function restoreFocusInternal() { + const focusSink = containerRef.current?.querySelector('[aria-selected="true"]'); + focusSink?.focus(); + } + useImperativeHandle(ref, () => ({ - selectCell: (position: ICellPosition) => { + selectCell: (position: ICellPosition, options?: { deferred?: boolean }) => { + if (isReplacingRef.current) { + return false; + } + const columnKey = mapPositionToColumnKey(position); if (!columnKey) { - return; + return false; } - innerGridRef.current?.selectCellByKey({ columnKey, rowIdx: position.rowIdx }); + if (options?.deferred) { + if (deferredSelectRef.current !== null) { + clearTimeout(deferredSelectRef.current); + } + deferredSelectRef.current = setTimeout(() => { + deferredSelectRef.current = null; + innerGridRef.current?.selectCellByKey({ columnKey, rowIdx: position.rowIdx }); + requestAnimationFrame(restoreFocusInternal); + }, 1); + } else { + innerGridRef.current?.selectCellByKey({ columnKey, rowIdx: position.rowIdx }); + } + + return true; }, scrollToCell: (position: Partial) => { innerGridRef.current?.scrollToCell({ idx: position.colIdx && dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx }); @@ -168,14 +222,17 @@ export const DataGrid = forwardRef(function DataGrid }, ); }, + restoreFocus: restoreFocusInternal, getColumnsOrdered: () => innerGridRef.current?.getColumnsOrdered() ?? [], + openSearch: handleSearchOpen, + closeSearch: handleSearchClose, + refreshSearch: () => searchPanelRef.current?.refresh(), })); - if (rowsCountRef.current !== rowsCount) { - const previousRowCount = rowsCountRef.current; - rowsCountRef.current = rowsCount; + if (prevRowsCount !== rowsCount) { + setPrevRowsCount(rowsCount); - if (previousRowCount === 0) { + if (prevRowsCount === 0) { setColumnWidths(new Map()); } } @@ -189,6 +246,9 @@ export const DataGrid = forwardRef(function DataGrid ); function handleCellFocus(args: CellSelectArgs) { + if (isReplacingRef.current) { + return; + } onFocus?.({ colIdx: dndHeaderContext.getDataColIdxByKey(args.column.key), rowIdx: args.rowIdx }); } @@ -214,44 +274,59 @@ export const DataGrid = forwardRef(function DataGrid } return ( - - - - - getRowHeight(row.idx) : undefined} - rowKeyGetter={getRowId ? row => getRowId(row.idx) : undefined} - rowClass={getRowClass ? row => getRowClass(row.idx) : undefined} - columnWidths={columnWidths} - renderers={{ - renderRow: rowRenderer, - renderCell: cellRenderer, - noRowsFallback: children, +
+ + + + - - - - + > + getRowHeight(row.idx) : undefined} + rowKeyGetter={getRowId ? row => getRowId(row.idx) : undefined} + rowClass={getRowClass ? row => getRowClass(row.idx) : undefined} + columnWidths={columnWidths} + renderers={{ + renderRow: rowRenderer, + renderCell: cellRenderer, + noRowsFallback: children, + }} + onScroll={onScroll} + onSelectedCellChange={handleCellFocus} + onCellKeyDown={handleCellKeyDown} + onColumnWidthsChange={setColumnWidths} + /> + + + + + + + +
); }); diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/DataGridCellContext.ts b/webapp/common-react/@dbeaver/react-data-grid/src/DataGridCellContext.ts index b043c00a31..61e7cc547d 100644 --- a/webapp/common-react/@dbeaver/react-data-grid/src/DataGridCellContext.ts +++ b/webapp/common-react/@dbeaver/react-data-grid/src/DataGridCellContext.ts @@ -1,6 +1,6 @@ /* * 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. @@ -13,10 +13,16 @@ export interface IDataGridCellProps extends React.DetailedHTMLProps & { [key: string]: any }): React.ReactNode; } +export interface ICellChange { + rowIdx: number; + colIdx: number; + value: any; +} + export interface IDataGridCellContext { cellElement?: IGridReactiveValue< React.ReactNode, @@ -25,8 +31,10 @@ export interface IDataGridCellContext { cell?: IGridReactiveValue; cellText?: IGridReactiveValue; cellTooltip?: IGridReactiveValue; + getCellClassName?: IGridReactiveValue; getCellEditable?: (rowIdx: number, colIdx: number) => boolean; onCellChange?: (rowIdx: number, colIdx: number, value: any) => void; + onCellChangeBatch?: (changes: ICellChange[]) => void; } export const DataGridCellContext = createContext(null); diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/index.ts b/webapp/common-react/@dbeaver/react-data-grid/src/index.ts index 025c6193bc..f6d0ddc5e8 100644 --- a/webapp/common-react/@dbeaver/react-data-grid/src/index.ts +++ b/webapp/common-react/@dbeaver/react-data-grid/src/index.ts @@ -1,6 +1,6 @@ /* * 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. @@ -19,3 +19,5 @@ export * from './formatters/BooleanFormatter.js'; export * from './formatters/NumberFormatter.js'; export * from './formatters/DateFormatter.js'; export * from './formatters/BlobFormatter.js'; + +export * from './search/index.js'; diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchEngine.ts b/webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchEngine.ts new file mode 100644 index 0000000000..8e3cc4e008 --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchEngine.ts @@ -0,0 +1,89 @@ +/* + * 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. + */ + +export interface ISearchPatternOptions { + caseSensitive: boolean; + wholeWord: boolean; + regexp: boolean; +} + +export interface ICellMatch { + rowIdx: number; + colIdx: number; +} + +const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g; +const WORD_BOUNDARY_REGEX = '\\b'; +const GLOBAL_FLAG_REGEX = 'g'; +const IGNORE_CASE_FLAG_REGEX = 'i'; +const ESCAPE_REPLACE_REGEX = '\\$&'; + +/** Build regex from query string + flags. Returns null for empty/invalid input. */ +export function buildSearchPattern(query: string, options: ISearchPatternOptions): RegExp | null { + if (!query) { + return null; + } + + try { + if (options.regexp) { + const flags = options.caseSensitive ? GLOBAL_FLAG_REGEX : GLOBAL_FLAG_REGEX + IGNORE_CASE_FLAG_REGEX; + return new RegExp(query, flags); + } + + let escapedSearch = query.replace(ESCAPE_REGEX, ESCAPE_REPLACE_REGEX); + if (options.wholeWord) { + escapedSearch = `${WORD_BOUNDARY_REGEX}${escapedSearch}${WORD_BOUNDARY_REGEX}`; + } + + const flags = options.caseSensitive ? GLOBAL_FLAG_REGEX : GLOBAL_FLAG_REGEX + IGNORE_CASE_FLAG_REGEX; + return new RegExp(escapedSearch, flags); + } catch { + return null; + } +} + +/** Iterate all cells, return matches. O(rows * cols). */ +export function searchGrid( + query: string, + options: ISearchPatternOptions, + rowCount: number, + columnCount: number, + getCellText: (rowIdx: number, colIdx: number) => string, +): ICellMatch[] { + const searchPattern = buildSearchPattern(query, options); + + if (!searchPattern) { + return []; + } + + const matches: ICellMatch[] = []; + + for (let rowIdx = 0; rowIdx < rowCount; rowIdx++) { + for (let colIdx = 0; colIdx < columnCount; colIdx++) { + const cellText = getCellText(rowIdx, colIdx); + if (searchPattern.test(cellText)) { + matches.push({ rowIdx, colIdx }); + if (searchPattern.global) { + searchPattern.lastIndex = 0; + } + } + } + } + + return matches; +} + +/** Replace pattern in cell text. Returns new text and whether pattern still matches. */ +export function replaceInCell(cellText: string, pattern: RegExp, replaceValue: string): { newText: string; stillMatches: boolean } { + const patternCopy = new RegExp(pattern); + const newText = cellText.replace(patternCopy, replaceValue); + + const stillMatches = newText.search(pattern) !== -1; + + return { newText, stillMatches }; +} diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchPanel.tsx b/webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchPanel.tsx new file mode 100644 index 0000000000..56edadcb8a --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchPanel.tsx @@ -0,0 +1,104 @@ +/* + * 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 { forwardRef, useContext, useEffect, useImperativeHandle, useRef } from 'react'; +import { clsx, SearchPanel, type SearchPanelRef } from '@dbeaver/ui-kit'; + +import { DataGridCellContext, type ICellChange } from '../DataGridCellContext.js'; +import { DataGridRowContext } from '../DataGridRowContext.js'; +import type { IGridReactiveValue } from '../IGridReactiveValue.js'; +import { useGridReactiveValue } from '../useGridReactiveValue.js'; +import { type IGridSearchStorage, useGridSearch } from './useGridSearch.js'; + +export interface GridSearchPanelRef { + focus: () => void; + refresh: () => void; + getCellClassName: IGridReactiveValue; +} + +interface GridSearchPanelProps { + columnCount: number; + scrollToCell: (rowIdx: number, colIdx: number) => void; + onReplace: (updates: ICellChange[]) => void; + onClose: () => void; + onReplacingChange?: (isReplacing: boolean) => void; + isReadOnly?: boolean; + storage?: IGridSearchStorage; + open?: boolean; +} + +export const GridSearchPanel = forwardRef(function GridSearchPanel( + { columnCount, scrollToCell, onReplace, onClose, onReplacingChange, isReadOnly, storage, open }, + ref, +) { + const panelRef = useRef(null); + const { cellText } = useContext(DataGridCellContext) ?? {}; + const rowContext = useContext(DataGridRowContext); + const rowCount = useGridReactiveValue(rowContext?.rowCount); + + const { snapshot, actions, getCellClassName, replaceOpen } = useGridSearch({ + rowCount: rowCount ?? 0, + columnCount, + cellText, + scrollToCell, + onReplace, + onReplacingChange, + storage, + open, + }); + + useEffect(() => { + panelRef.current?.focus(); + }, []); + + useImperativeHandle( + ref, + () => ({ + focus: () => panelRef.current?.focus(), + refresh: () => actions.refresh(), + getCellClassName, + }), + [actions, getCellClassName], + ); + + function handleClose() { + actions.close(); + onClose(); + } + + return ( + = 0 ? snapshot.activeMatchIndex + 1 : 0, + }} + onQueryChange={actions.setQuery} + onCaseSensitiveToggle={actions.toggleCaseSensitive} + onWholeWordToggle={actions.toggleWholeWord} + onRegexToggle={actions.toggleRegex} + onFindNext={actions.findNext} + onFindPrevious={actions.findPrevious} + onReplaceChange={actions.setReplace} + onReplaceToggle={actions.setReplaceOpen} + onReplaceAll={actions.replaceAll} + onReplaceNext={actions.replaceActive} + onClose={handleClose} + /> + ); +}); diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/search/cellKey.ts b/webapp/common-react/@dbeaver/react-data-grid/src/search/cellKey.ts new file mode 100644 index 0000000000..c8b957d056 --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/search/cellKey.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export type CellKey = `${number}+${number}`; + +export function makeCellKey(rowIdx: number, colIdx: number): CellKey { + return `${rowIdx}+${colIdx}`; +} diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/search/index.ts b/webapp/common-react/@dbeaver/react-data-grid/src/search/index.ts new file mode 100644 index 0000000000..ce333ca1bd --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/search/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ + +export { buildSearchPattern, searchGrid, replaceInCell, type ISearchPatternOptions, type ICellMatch } from './GridSearchEngine.js'; +export { + useGridSearch, + type IGridSearchSnapshot, + type IGridSearchOptions, + type IGridSearchStorageState, + type IGridSearchStorage, +} from './useGridSearch.js'; diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/search/state.ts b/webapp/common-react/@dbeaver/react-data-grid/src/search/state.ts new file mode 100644 index 0000000000..a72ff6b759 --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/search/state.ts @@ -0,0 +1,153 @@ +/* + * 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 type { ICellMatch } from './GridSearchEngine.js'; + +export interface GridSearchQueryState { + query: string; + replace: string; + caseSensitive: boolean; + wholeWord: boolean; + regexp: boolean; +} + +export interface GridSearchMatchState { + matchedCells: ICellMatch[]; + activeMatchIdx: number; +} + +export interface GridSearchUiState { + replaceOpen: boolean; +} + +export interface GridSearchState extends GridSearchMatchState, GridSearchQueryState, GridSearchUiState { } + +export type GridSearchReducerAction = + | { type: 'SET_QUERY'; query: string } + | { type: 'SET_REPLACE'; replace: string } + | { type: 'TOGGLE_CASE_SENSITIVE' } + | { type: 'TOGGLE_WHOLE_WORD' } + | { type: 'TOGGLE_REGEX' } + | { type: 'SET_MATCHES'; matchedCells: ICellMatch[]; preserveActiveIndex: boolean } + | { type: 'SET_ACTIVE_MATCH'; index: number } + | { type: 'REMOVE_MATCH'; index: number } + | { type: 'SET_REPLACE_OPEN'; open: boolean }; + +export function computeActiveIdx(matchedCells: ICellMatch[], currentIdx: number, preserve: boolean): number { + if (preserve && currentIdx >= 0 && currentIdx < matchedCells.length) { + return currentIdx; + } + return matchedCells.length > 0 ? 0 : -1; +} + +export function computeActiveIdxAfterRemoval(matchCount: number, activeMatchIdx: number): number { + if (matchCount === 0) { + return -1; + } + + return Math.max(0, Math.min(activeMatchIdx, matchCount - 1)); +} + +export function computeActiveMatchIdx(matchCount: number, activeMatchIdx: number, offset = 0): number { + if (matchCount === 0) { + return -1; + } + + if (activeMatchIdx < 0 || activeMatchIdx >= matchCount) { + return offset >= 0 ? 0 : matchCount - 1; + } + + const shifted = activeMatchIdx + offset; + return ((shifted % matchCount) + matchCount) % matchCount; +} + +function areMatchesEqual(left: ICellMatch[], right: ICellMatch[]): boolean { + if (left === right) { + return true; + } + + if (left.length !== right.length) { + return false; + } + + if (left.some((left, i) => left.rowIdx !== right[i]!.rowIdx || left.colIdx !== right[i]!.colIdx)) { + return false; + } + + return true; +} + +export function gridSearchReducer(state: GridSearchState, action: GridSearchReducerAction): GridSearchState { + switch (action.type) { + case 'SET_QUERY': { + if (state.query === action.query) { + return state; + } + + if (!action.query) { + if (state.matchedCells.length === 0 && state.activeMatchIdx === -1) { + return { ...state, query: action.query }; + } + + return { + ...state, + query: action.query, + matchedCells: [], + activeMatchIdx: -1, + }; + } + + return { ...state, query: action.query }; + } + case 'SET_REPLACE': { + if (state.replace === action.replace) { + return state; + } + return { ...state, replace: action.replace }; + } + case 'TOGGLE_CASE_SENSITIVE': + return { ...state, caseSensitive: !state.caseSensitive }; + case 'TOGGLE_WHOLE_WORD': + return { ...state, wholeWord: !state.wholeWord }; + case 'TOGGLE_REGEX': + return { ...state, regexp: !state.regexp }; + case 'SET_MATCHES': { + const activeMatchIdx = computeActiveIdx(action.matchedCells, state.activeMatchIdx, action.preserveActiveIndex); + + if (activeMatchIdx === state.activeMatchIdx && areMatchesEqual(state.matchedCells, action.matchedCells)) { + return state; + } + + return { ...state, matchedCells: action.matchedCells, activeMatchIdx }; + } + case 'SET_ACTIVE_MATCH': { + if (state.matchedCells.length === 0) { + return state; + } + + const boundedIndex = Math.max(0, Math.min(action.index, state.matchedCells.length - 1)); + if (boundedIndex === state.activeMatchIdx) { + return state; + } + + return { ...state, activeMatchIdx: boundedIndex }; + } + case 'REMOVE_MATCH': { + const newMatches = [...state.matchedCells]; + newMatches.splice(action.index, 1); + const newActiveIdx = computeActiveIdxAfterRemoval(newMatches.length, state.activeMatchIdx); + return { ...state, matchedCells: newMatches, activeMatchIdx: newActiveIdx }; + } + case 'SET_REPLACE_OPEN': { + if (state.replaceOpen === action.open) { + return state; + } + return { ...state, replaceOpen: action.open }; + } + } +} diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/search/useGridSearch.ts b/webapp/common-react/@dbeaver/react-data-grid/src/search/useGridSearch.ts new file mode 100644 index 0000000000..2acde59add --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/search/useGridSearch.ts @@ -0,0 +1,475 @@ +/* + * 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 { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; + +import type { IGridReactiveValue } from '../IGridReactiveValue.js'; +import { buildSearchPattern, replaceInCell, searchGrid, type ICellMatch } from './GridSearchEngine.js'; +import type { ICellChange } from '../DataGridCellContext.js'; +import { makeCellKey } from './cellKey.js'; +import { useTrackedGridSearchCellSubscriptions } from './useTrackedGridSearchCellSubscriptions.js'; +import { + computeActiveIdx, + computeActiveIdxAfterRemoval, + computeActiveMatchIdx, + gridSearchReducer, + type GridSearchQueryState, + type GridSearchState, +} from './state.js'; + +export type { ICellMatch } from './GridSearchEngine.js'; + +const DEFAULT_DEBOUNCE_MS = 300; + +export interface IGridSearchStorageState extends GridSearchState { + open: boolean; +} + +export interface IGridSearchSnapshot extends GridSearchQueryState { + matchCount: number; + activeMatchIndex: number; +} + +export interface IGridSearchStorage { + get(): IGridSearchStorageState | undefined; + set(state: IGridSearchStorageState): void; + update(state: Partial): void; +} + +const MATCH_CLASS = 'rdg-cell-search-match'; +const ACTIVE_CLASS = 'rdg-cell-search-match rdg-cell-search-active'; + +function scrollToMatch(scrollToCell: (rowIdx: number, colIdx: number) => void, match: ICellMatch | undefined): void { + if (match) { + scrollToCell(match.rowIdx, match.colIdx); + } +} + +function getNextMatchAfterRemoval(matchedCells: ICellMatch[], activeMatchIdx: number): ICellMatch | undefined { + const nextActiveIdx = computeActiveIdxAfterRemoval(matchedCells.length - 1, activeMatchIdx); + if (nextActiveIdx < 0) { + return undefined; + } + + const actualIdx = nextActiveIdx >= activeMatchIdx ? nextActiveIdx + 1 : nextActiveIdx; + return matchedCells[actualIdx]; +} + +export interface IGridSearchActions { + setQuery: (value: string) => void; + setReplace: (value: string) => void; + toggleCaseSensitive: () => void; + toggleWholeWord: () => void; + toggleRegex: () => void; + findNext: () => void; + findPrevious: () => void; + replaceActive: () => void; + replaceAll: () => void; + setReplaceOpen: (open: boolean) => void; + refresh: () => void; + close: () => void; +} + +export interface IGridSearchOptions { + rowCount: number; + columnCount: number; + cellText?: IGridReactiveValue; + scrollToCell: (rowIdx: number, colIdx: number) => void; + onReplace: (updates: ICellChange[]) => void; + onReplacingChange?: (isReplacing: boolean) => void; + storage?: IGridSearchStorage; + open?: boolean; +} + +export interface IGridSearchResult { + snapshot: IGridSearchSnapshot; + actions: IGridSearchActions; + getCellClassName: IGridReactiveValue; + replaceOpen: boolean; +} + +function createInitialState(options: IGridSearchOptions): GridSearchState { + const cached = options.storage?.get(); + const hasCache = cached !== undefined && !!cached.query && cached.matchedCells.length > 0; + + return { + query: cached?.query ?? '', + replace: cached?.replace ?? '', + caseSensitive: cached?.caseSensitive ?? false, + wholeWord: cached?.wholeWord ?? false, + regexp: cached?.regexp ?? false, + replaceOpen: cached?.replaceOpen ?? false, + matchedCells: hasCache ? cached!.matchedCells : [], + activeMatchIdx: hasCache ? cached!.activeMatchIdx : -1, + }; +} + +function getCellText(options: IGridSearchOptions, rowIdx: number, colIdx: number): string { + return options.cellText?.get(rowIdx, colIdx) ?? ''; +} + +export function useGridSearch(options: IGridSearchOptions): IGridSearchResult { + const [state, dispatch] = useReducer(gridSearchReducer, options, createInitialState); + + const optionsRef = useRef(options); + const stateRef = useRef(state); + + const cellListenersRef = useRef(new Set<() => void>()); + const prevMatchedCellsRef = useRef(state.matchedCells); + const [initialDerived] = useState(() => { + const matchedSet = new Set(state.matchedCells.map(m => makeCellKey(m.rowIdx, m.colIdx))); + const activeMatch = state.matchedCells[state.activeMatchIdx]; + const activeMatchKey = activeMatch ? makeCellKey(activeMatch.rowIdx, activeMatch.colIdx) : null; + return { matchedSet, activeMatchKey }; + }); + const matchedSetRef = useRef(initialDerived.matchedSet); + const activeMatchKeyRef = useRef(initialDerived.activeMatchKey); + const queryRef = useRef(state.query); + + const lastGridSizeRef = useRef({ rowCount: options.rowCount, columnCount: options.columnCount }); + const isInitialMountRef = useRef(true); + + const runSearchAndScroll = useCallback((searchState: Omit, preserveActiveIndex: boolean): void => { + const opts = optionsRef.current; + const currentState = stateRef.current; + + const matches = searchGrid( + searchState.query, + { + caseSensitive: searchState.caseSensitive, + wholeWord: searchState.wholeWord, + regexp: searchState.regexp, + }, + opts.rowCount, + opts.columnCount, + (rowIdx, colIdx) => getCellText(opts, rowIdx, colIdx), + ); + + dispatch({ type: 'SET_MATCHES', matchedCells: matches, preserveActiveIndex }); + + const newActiveIdx = computeActiveIdx(matches, currentState.activeMatchIdx, preserveActiveIndex); + scrollToMatch(opts.scrollToCell, matches[newActiveIdx]); + }, []); + + const handleTrackedCellChange = useCallback((): void => { + const st = stateRef.current; + if (!st.query) { + return; + } + + runSearchAndScroll(st, true); + }, [runSearchAndScroll]); + + const { syncGridSubscriptions, clearTrackedCellSubscriptions } = useTrackedGridSearchCellSubscriptions({ + debounceMs: DEFAULT_DEBOUNCE_MS, + onTrackedCellChange: handleTrackedCellChange, + }); + + const actions = useMemo(() => { + function runSearchNow(preserveActiveIndex: boolean): void { + runSearchAndScroll(stateRef.current, preserveActiveIndex); + } + + return { + setQuery(value: string): void { + dispatch({ type: 'SET_QUERY', query: value }); + }, + + setReplace(value: string): void { + dispatch({ type: 'SET_REPLACE', replace: value }); + }, + + toggleCaseSensitive(): void { + dispatch({ type: 'TOGGLE_CASE_SENSITIVE' }); + }, + + toggleWholeWord(): void { + dispatch({ type: 'TOGGLE_WHOLE_WORD' }); + }, + + toggleRegex(): void { + dispatch({ type: 'TOGGLE_REGEX' }); + }, + + findNext(): void { + const st = stateRef.current; + if (st.matchedCells.length === 0) { + if (st.query) { + runSearchNow(false); + } + return; + } + const nextIdx = computeActiveMatchIdx(st.matchedCells.length, st.activeMatchIdx, 1); + dispatch({ type: 'SET_ACTIVE_MATCH', index: nextIdx }); + scrollToMatch(optionsRef.current.scrollToCell, st.matchedCells[nextIdx]); + }, + + findPrevious(): void { + const st = stateRef.current; + if (st.matchedCells.length === 0) { + if (st.query) { + runSearchNow(false); + } + return; + } + const prevIdx = computeActiveMatchIdx(st.matchedCells.length, st.activeMatchIdx, -1); + dispatch({ type: 'SET_ACTIVE_MATCH', index: prevIdx }); + scrollToMatch(optionsRef.current.scrollToCell, st.matchedCells[prevIdx]); + }, + + replaceActive(): void { + const st = stateRef.current; + if (st.activeMatchIdx < 0 || st.activeMatchIdx >= st.matchedCells.length) { + return; + } + + const match = st.matchedCells[st.activeMatchIdx]; + if (!match) { + return; + } + + const pattern = buildSearchPattern(st.query, { + caseSensitive: st.caseSensitive, + wholeWord: st.wholeWord, + regexp: st.regexp, + }); + if (!pattern) { + return; + } + + const opts = optionsRef.current; + opts.onReplacingChange?.(true); + try { + const cellValue = getCellText(opts, match.rowIdx, match.colIdx); + const { newText, stillMatches } = replaceInCell(cellValue, pattern, st.replace); + opts.onReplace([{ rowIdx: match.rowIdx, colIdx: match.colIdx, value: newText }]); + + if (!stillMatches) { + dispatch({ type: 'REMOVE_MATCH', index: st.activeMatchIdx }); + + const nextMatch = getNextMatchAfterRemoval(st.matchedCells, st.activeMatchIdx); + scrollToMatch(opts.scrollToCell, nextMatch); + } + } finally { + opts.onReplacingChange?.(false); + } + }, + + replaceAll(): void { + const st = stateRef.current; + if (st.matchedCells.length === 0) { + return; + } + + const pattern = buildSearchPattern(st.query, { + caseSensitive: st.caseSensitive, + wholeWord: st.wholeWord, + regexp: st.regexp, + }); + if (!pattern) { + return; + } + + const opts = optionsRef.current; + opts.onReplacingChange?.(true); + try { + const matches = [...st.matchedCells]; + const updates = matches.map(match => { + const cellValue = getCellText(opts, match.rowIdx, match.colIdx); + const { newText } = replaceInCell(cellValue, pattern, st.replace); + return { rowIdx: match.rowIdx, colIdx: match.colIdx, value: newText }; + }); + opts.onReplace(updates); + } finally { + opts.onReplacingChange?.(false); + } + + runSearchNow(false); + }, + + setReplaceOpen(open: boolean): void { + dispatch({ type: 'SET_REPLACE_OPEN', open }); + }, + + refresh(): void { + if (stateRef.current.query) { + runSearchNow(true); + } + }, + + close(): void { + const opts = optionsRef.current; + const st = stateRef.current; + // Sync state to storage explicitly (cannot rely on effect before potential unmount) + opts.storage?.set({ + matchedCells: st.matchedCells, + activeMatchIdx: st.activeMatchIdx, + query: st.query, + replace: st.replace, + caseSensitive: st.caseSensitive, + wholeWord: st.wholeWord, + regexp: st.regexp, + replaceOpen: st.replaceOpen, + open: false, + }); + }, + }; + }, [runSearchAndScroll]); + + const [getCellClassName] = useState>(() => ({ + get(rowIdx: number, colIdx: number): string | undefined { + if (!queryRef.current) { + return undefined; + } + + const key = makeCellKey(rowIdx, colIdx); + if (!matchedSetRef.current.has(key)) { + return undefined; + } + + if (key === activeMatchKeyRef.current) { + return ACTIVE_CLASS; + } + + return MATCH_CLASS; + }, + subscribe(onChange: () => void): () => void { + cellListenersRef.current.add(onChange); + return () => { + cellListenersRef.current.delete(onChange); + }; + }, + })); + + // --- Sync React state → refs, notify cell listeners --- + useEffect(() => { + optionsRef.current = options; + stateRef.current = state; + + // Only rebuild matchedSet when matchedCells identity changes + if (state.matchedCells !== prevMatchedCellsRef.current) { + matchedSetRef.current = new Set(state.matchedCells.map(m => makeCellKey(m.rowIdx, m.colIdx))); + prevMatchedCellsRef.current = state.matchedCells; + } + + const activeMatch = state.matchedCells[state.activeMatchIdx]; + activeMatchKeyRef.current = activeMatch ? makeCellKey(activeMatch.rowIdx, activeMatch.colIdx) : null; + queryRef.current = state.query; + + for (const listener of cellListenersRef.current) { + listener(); + } + }, [options, state]); + + // --- Storage sync --- + useEffect(() => { + optionsRef.current.storage?.set({ + matchedCells: state.matchedCells, + activeMatchIdx: state.activeMatchIdx, + query: state.query, + replace: state.replace, + caseSensitive: state.caseSensitive, + wholeWord: state.wholeWord, + regexp: state.regexp, + replaceOpen: state.replaceOpen, + open: optionsRef.current.open ?? true, + }); + }, [ + state.query, + state.replace, + state.caseSensitive, + state.wholeWord, + state.regexp, + state.replaceOpen, + state.matchedCells, + state.activeMatchIdx, + options.open, + ]); + + // --- Debounced search (reacts to query/flag changes) --- + useEffect(() => { + if (!state.query) { + clearTrackedCellSubscriptions(); + return; + } + + syncGridSubscriptions(options.cellText, options.rowCount, options.columnCount); + }, [state.query, options.cellText, options.rowCount, options.columnCount, syncGridSubscriptions, clearTrackedCellSubscriptions]); + + useEffect(() => { + if (isInitialMountRef.current) { + isInitialMountRef.current = false; + return; + } + + if (!state.query) { + clearTrackedCellSubscriptions(); + return; + } + + const searchState = { + query: state.query, + caseSensitive: state.caseSensitive, + wholeWord: state.wholeWord, + regexp: state.regexp, + }; + + const timeoutId = setTimeout(() => { + runSearchAndScroll(searchState, false); + }, DEFAULT_DEBOUNCE_MS); + + return () => clearTimeout(timeoutId); + }, [state.query, state.caseSensitive, state.wholeWord, state.regexp, runSearchAndScroll, clearTrackedCellSubscriptions]); + + // --- Re-search when grid size changes --- + useEffect(() => { + if (lastGridSizeRef.current.rowCount !== options.rowCount || lastGridSizeRef.current.columnCount !== options.columnCount) { + lastGridSizeRef.current = { rowCount: options.rowCount, columnCount: options.columnCount }; + actions.refresh(); + } + }, [options.rowCount, options.columnCount, actions]); + + // --- Initial search on mount --- + useEffect(() => { + const st = stateRef.current; + + // Cache was restored with matches — cell listeners will be notified by the state→ref sync effect + if (st.matchedCells.length > 0) { + return; + } + + if (!st.query) { + return; + } + + actions.refresh(); + }, [actions]); + + // --- Cleanup --- + useEffect( + () => () => { + cellListenersRef.current.clear(); + }, + [], + ); + + const snapshot = useMemo( + () => ({ + query: state.query, + replace: state.replace, + caseSensitive: state.caseSensitive, + wholeWord: state.wholeWord, + regexp: state.regexp, + matchCount: state.matchedCells.length, + activeMatchIndex: state.activeMatchIdx, + }), + [state.query, state.replace, state.caseSensitive, state.wholeWord, state.regexp, state.matchedCells, state.activeMatchIdx], + ); + + return { snapshot, actions, getCellClassName, replaceOpen: state.replaceOpen }; +} diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/search/useTrackedGridSearchCellSubscriptions.ts b/webapp/common-react/@dbeaver/react-data-grid/src/search/useTrackedGridSearchCellSubscriptions.ts new file mode 100644 index 0000000000..2437f2f5fe --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/search/useTrackedGridSearchCellSubscriptions.ts @@ -0,0 +1,101 @@ +/* + * 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 { useCallback, useEffect, useRef } from 'react'; + +import type { IGridReactiveValue } from '../IGridReactiveValue.js'; + +export interface IUseTrackedGridSearchCellSubscriptionsOptions { + debounceMs: number; + onTrackedCellChange: () => void; +} + +export interface IUseTrackedGridSearchCellSubscriptionsResult { + syncGridSubscriptions: (cellText: IGridReactiveValue | undefined, rowCount: number, columnCount: number) => void; + clearTrackedCellSubscriptions: () => void; +} + +export function useTrackedGridSearchCellSubscriptions({ + debounceMs, + onTrackedCellChange, +}: IUseTrackedGridSearchCellSubscriptionsOptions): IUseTrackedGridSearchCellSubscriptionsResult { + const trackedCellUnsubscribesRef = useRef void>>([]); + const searchRerunTimeoutRef = useRef | null>(null); + const subscriptionSourceRef = useRef | undefined>(undefined); + const subscriptionSizeRef = useRef({ rowCount: -1, columnCount: -1 }); + + const clearTrackedCellSubscriptions = useCallback((): void => { + if (searchRerunTimeoutRef.current !== null) { + clearTimeout(searchRerunTimeoutRef.current); + searchRerunTimeoutRef.current = null; + } + + for (const unsubscribe of trackedCellUnsubscribesRef.current) { + unsubscribe(); + } + + trackedCellUnsubscribesRef.current = []; + subscriptionSourceRef.current = undefined; + subscriptionSizeRef.current = { rowCount: -1, columnCount: -1 }; + }, []); + + const scheduleTrackedCellSearchRerun = useCallback((): void => { + if (searchRerunTimeoutRef.current !== null) { + clearTimeout(searchRerunTimeoutRef.current); + } + + searchRerunTimeoutRef.current = setTimeout(() => { + searchRerunTimeoutRef.current = null; + onTrackedCellChange(); + }, debounceMs); + }, [debounceMs, onTrackedCellChange]); + + const syncGridSubscriptions = useCallback( + (cellText: IGridReactiveValue | undefined, rowCount: number, columnCount: number): void => { + const sourceChanged = subscriptionSourceRef.current !== cellText; + const sizeChanged = subscriptionSizeRef.current.rowCount !== rowCount || subscriptionSizeRef.current.columnCount !== columnCount; + + if (!sourceChanged && !sizeChanged) { + return; + } + + clearTrackedCellSubscriptions(); + subscriptionSourceRef.current = cellText; + subscriptionSizeRef.current = { rowCount, columnCount }; + + if (!cellText) { + return; + } + + // Current implementation performs full-grid search (rows * cols), + // so we subscribe to all cells in the current grid. + // If partial/range search is introduced later, this should be revised. + const unsubscribes: Array<() => void> = []; + for (let rowIdx = 0; rowIdx < rowCount; rowIdx++) { + for (let colIdx = 0; colIdx < columnCount; colIdx++) { + unsubscribes.push(cellText.subscribe(scheduleTrackedCellSearchRerun, rowIdx, colIdx)); + } + } + + trackedCellUnsubscribesRef.current = unsubscribes; + }, + [clearTrackedCellSubscriptions, scheduleTrackedCellSearchRerun], + ); + + useEffect( + () => () => { + clearTrackedCellSubscriptions(); + }, + [clearTrackedCellSubscriptions], + ); + + return { + syncGridSubscriptions, + clearTrackedCellSubscriptions, + }; +} diff --git a/webapp/common-react/@dbeaver/react-data-grid/src/useDataGridSearch.ts b/webapp/common-react/@dbeaver/react-data-grid/src/useDataGridSearch.ts new file mode 100644 index 0000000000..751525a5bb --- /dev/null +++ b/webapp/common-react/@dbeaver/react-data-grid/src/useDataGridSearch.ts @@ -0,0 +1,95 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; + +import type { ICellChange } from './DataGridCellContext.js'; +import type { IGridReactiveValue } from './IGridReactiveValue.js'; +import type { GridSearchPanelRef } from './search/GridSearchPanel.js'; +import type { IGridSearchStorage } from './search/useGridSearch.js'; + +interface UseDataGridSearchOptions { + containerRef: React.RefObject; + searchStorage: IGridSearchStorage | undefined; + getCellEditable: ((rowIdx: number, colIdx: number) => boolean) | undefined; + onCellChangeBatch: ((updates: ICellChange[]) => void) | undefined; +} + +export function useDataGridSearch({ containerRef, searchStorage, getCellEditable, onCellChangeBatch }: UseDataGridSearchOptions): { + searchOpen: boolean; + searchCellClassName: IGridReactiveValue | undefined; + searchPanelRef: React.RefObject; + setSearchPanelRef: (instance: GridSearchPanelRef | null) => void; + isReplacingRef: React.RefObject; + handleSearchOpen: () => void; + handleSearchClose: () => void; + onReplace: (updates: { rowIdx: number; colIdx: number; value: string }[]) => void; + handleReplacingChange: (value: boolean) => void; +} { + const searchPanelRef = useRef(null); + const isReplacingRef = useRef(false); + + const [searchOpen, setSearchOpen] = useState(() => searchStorage?.get()?.open ?? false); + const [searchCellClassName, setSearchCellClassName] = useState>(); + + const setSearchPanelRef = useCallback((instance: GridSearchPanelRef | null) => { + searchPanelRef.current = instance; + setSearchCellClassName(instance?.getCellClassName); + }, []); + + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + + function handleKeyDown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.code === 'KeyF') { + e.preventDefault(); + setSearchOpen(true); + searchPanelRef.current?.focus(); + } + } + + el.addEventListener('keydown', handleKeyDown); + return () => el.removeEventListener('keydown', handleKeyDown); + }, [containerRef]); + + function handleSearchOpen() { + setSearchOpen(true); + searchPanelRef.current?.focus(); + } + + function handleSearchClose() { + setSearchOpen(false); + containerRef.current?.querySelector('[aria-selected="true"]')?.focus(); + } + + function onReplace(updates: { rowIdx: number; colIdx: number; value: string }[]) { + const validUpdates = updates.filter(u => getCellEditable?.(u.rowIdx, u.colIdx) !== false); + if (validUpdates.length > 0) { + onCellChangeBatch?.(validUpdates); + } + } + + function handleReplacingChange(value: boolean) { + isReplacingRef.current = value; + } + + return { + searchOpen, + searchCellClassName, + searchPanelRef, + setSearchPanelRef, + isReplacingRef, + handleSearchOpen, + handleSearchClose, + onReplace, + handleReplacingChange, + }; +} diff --git a/webapp/common-react/@dbeaver/ui-kit/assets/icons/preload/icons.svg b/webapp/common-react/@dbeaver/ui-kit/assets/icons/preload/icons.svg index c894bc667b..fe948931e0 100644 --- a/webapp/common-react/@dbeaver/ui-kit/assets/icons/preload/icons.svg +++ b/webapp/common-react/@dbeaver/ui-kit/assets/icons/preload/icons.svg @@ -372,4 +372,11 @@ fill="#3387C5" /> + + + + + \ No newline at end of file diff --git a/webapp/common-react/@dbeaver/ui-kit/src/Icon/Icon.tsx b/webapp/common-react/@dbeaver/ui-kit/src/Icon/Icon.tsx index 95346c4409..38ed6e6df7 100644 --- a/webapp/common-react/@dbeaver/ui-kit/src/Icon/Icon.tsx +++ b/webapp/common-react/@dbeaver/ui-kit/src/Icon/Icon.tsx @@ -1,6 +1,6 @@ /* * 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. @@ -13,6 +13,7 @@ type IconNames = | 'replace-all' | 'match-word' | 'case' + | 'chevron' | 'regex' | 'sort-asc' | 'sort-desc' diff --git a/webapp/packages/plugin-codemirror6/src/SearchPanel/SearchPanel.css b/webapp/common-react/@dbeaver/ui-kit/src/SearchPanel/SearchPanel.css similarity index 52% rename from webapp/packages/plugin-codemirror6/src/SearchPanel/SearchPanel.css rename to webapp/common-react/@dbeaver/ui-kit/src/SearchPanel/SearchPanel.css index 9600042d7d..902e714b60 100644 --- a/webapp/packages/plugin-codemirror6/src/SearchPanel/SearchPanel.css +++ b/webapp/common-react/@dbeaver/ui-kit/src/SearchPanel/SearchPanel.css @@ -3,58 +3,67 @@ --dbv-kit-control-outline-offset: -1px; --dbv-kit-control-outline-width: 1px; --dbv-kit-icon-btn-outline-offset: 0; - --dbv-kit-input-height: 26px; --dbv-kit-icon-btn-small-size: 20px; --dbv-kit-icon-btn-outline-width: 1px; display: flex; align-items: center; gap: var(--tw-spacing); + box-sizing: border-box; + padding: 7px 12px; + width: 100%; + min-width: 0; container-type: inline-size; min-width: 180px; padding: var(--tw-spacing); - - .dbv-kit-input { - border: 1px solid var(--theme-background); - } } .search-panel__row { display: flex; align-items: center; gap: var(--tw-spacing); - height: 26px; - - &:first-child { - margin-bottom: calc(var(--tw-spacing) / 2); - } + height: calc(var(--dbv-kit-input-height) + var(--tw-spacing)); } - .search-panel__input { - position: relative; - margin-bottom: calc(var(--tw-spacing) / 2); + .search-panel__input, + .search-panel__replace-input { + display: flex; + align-items: center; + background-color: var(--dbv-kit-input-background); + border: 1px solid var(--dbv-kit-input-border-color); + border-radius: var(--dbv-kit-input-radius); .dbv-kit-input { - padding-right: 88px; + border: none; + flex: 1 1 auto; + min-width: 0; + width: auto; } + .dbv-kit-icon-button { - --dbv-kit-icon-btn-primary-background: var(--theme-secondary); + --dbv-kit-icon-btn-primary-background: var(--theme-secondary, transparent); } } .search-panel__inputs { - flex-basis: 420px; + background-color: var(--dbv-kit-input-background); + display: flex; + flex-direction: column; + flex: 1 1 420px; min-width: 120px; + gap: calc(var(--tw-spacing) / 2); } .search-panel__buttons { - flex-basis: 80px; - flex-shrink: 0; + flex: 0 0 80px; + display: flex; + flex-direction: column; + justify-content: center; - @container (width >= 280px) { + @container (width >= 300px) { &:has(.search-panel__matches) { - flex-basis: 140px; + flex-basis: 150px; } } } @@ -65,7 +74,7 @@ font-size: 12px; flex-shrink: 0; - @container (width >= 280px) { + @container (width >= 300px) { display: inline; padding-inline: var(--tw-spacing); } diff --git a/webapp/common-react/@dbeaver/ui-kit/src/SearchPanel/SearchPanel.tsx b/webapp/common-react/@dbeaver/ui-kit/src/SearchPanel/SearchPanel.tsx new file mode 100644 index 0000000000..10f270904f --- /dev/null +++ b/webapp/common-react/@dbeaver/ui-kit/src/SearchPanel/SearchPanel.tsx @@ -0,0 +1,297 @@ +/* + * 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 { clsx } from 'clsx'; +import { forwardRef, type KeyboardEvent, useImperativeHandle, useRef, useState } from 'react'; +import { EventContext, EventStopPropagationFlag } from '@dbeaver/js-helpers'; +import { useTranslate } from '@dbeaver/react-translate'; + +import { Icon } from '../Icon/Icon.js'; +import { IconButton } from '../IconButton/IconButton.js'; +import { Input } from '../Input/Input.js'; + +import './SearchPanel.css'; + +export interface SearchPanelRef { + focus: () => void; +} + +export interface SearchPanelQuery { + search: string; + replace?: string; + caseSensitive?: boolean; + wholeWord?: boolean; + regexp?: boolean; +} + +export interface SearchPanelStrings { + searchPlaceholder: string; + replacePlaceholder: string; + toggleReplace: string; + caseSensitive: string; + wholeWord: string; + regex: string; + findPrevious: string; + findNext: string; + replace: string; + replaceAll: string; + close: string; + matchesOf: string; + matchesNone: string; +} + +export interface SearchPanelProps { + isReadOnly?: boolean; + query: SearchPanelQuery; + searchMatchesCount?: { count: number; current: number }; + className?: string; + enableReplace?: boolean; + enableRegex?: boolean; + enableWholeWord?: boolean; + enableCaseSensitive?: boolean; + defaultShowReplace?: boolean; + onQueryChange: (value: string) => void; + onCaseSensitiveToggle?: () => void; + onRegexToggle?: () => void; + onWholeWordToggle?: () => void; + onReplaceChange?: (value: string) => void; + onReplaceToggle?: (show: boolean) => void; + onFindNext: () => void; + onFindPrevious: () => void; + onReplaceAll?: () => void; + onReplaceNext?: () => void; + onClose: () => void; +} + +export const SearchPanel = forwardRef(function SearchPanel( + { + isReadOnly, + query, + className, + searchMatchesCount, + enableReplace = true, + enableRegex = true, + enableWholeWord = true, + enableCaseSensitive = true, + defaultShowReplace, + onQueryChange, + onCaseSensitiveToggle, + onRegexToggle, + onWholeWordToggle, + onReplaceChange, + onReplaceToggle, + onFindNext, + onFindPrevious, + onReplaceAll, + onReplaceNext, + onClose, + }, + ref, +) { + const [showReplace, setShowReplace] = useState(defaultShowReplace ?? false); + const translate = useTranslate(); + const inputRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + }), + [], + ); + + function handleToggleReplace() { + setShowReplace(prevShowReplace => { + const next = !prevShowReplace; + onReplaceToggle?.(next); + return next; + }); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose(); + } + } + + function handleKeyDownCapture(event: KeyboardEvent) { + if (event.key === 'Escape') { + EventContext.set(event.nativeEvent, EventStopPropagationFlag); + } + } + + function handleInputKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + if (event.shiftKey) { + onFindPrevious(); + } else { + onFindNext(); + } + } + } + + function handleReplaceKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + if (event.shiftKey) { + onReplaceAll?.(); + } else { + onReplaceNext?.(); + } + } + } + + return ( +
+ {!isReadOnly && enableReplace && ( + + + + )} + +
+
+ onQueryChange(event.target.value)} + /> +
+ {enableCaseSensitive && ( + + + + )} + + {enableWholeWord && ( + + + + )} + + {enableRegex && ( + + + + )} +
+
+ {showReplace && ( +
+ onReplaceChange?.(event.target.value)} + /> +
+ )} +
+
+
+ {query.search && searchMatchesCount && ( + + {searchMatchesCount.count > 0 + ? `${searchMatchesCount.current} ${translate('ui_search_panel_matches_of', 'of')} ${searchMatchesCount.count}` + : translate('ui_search_panel_matches_none', 'No matches')} + + )} + + + + + + + + + + + + +
+ + {showReplace && ( +
+ + + + + + + +
+ )} +
+
+ ); +}); diff --git a/webapp/common-react/@dbeaver/ui-kit/src/index.ts b/webapp/common-react/@dbeaver/ui-kit/src/index.ts index 2ac792e490..2bcc7f9a80 100644 --- a/webapp/common-react/@dbeaver/ui-kit/src/index.ts +++ b/webapp/common-react/@dbeaver/ui-kit/src/index.ts @@ -1,6 +1,6 @@ /* * 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. @@ -27,6 +27,13 @@ export { Checkbox, CheckboxBase, type CheckboxProps } from './Checkbox/Checkbox. export { ColorPicker } from './ColorPicker/ColorPicker.js'; export { ColorPickerBase, type ColorPickerProps } from './ColorPicker/ColorPickerBase.js'; export { Input, InputBase, type InputProps } from './Input/Input.js'; +export { + SearchPanel, + type SearchPanelProps, + type SearchPanelRef, + type SearchPanelQuery, + type SearchPanelStrings, +} from './SearchPanel/SearchPanel.js'; export { Select, useSelectContext, diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/eventContext.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/eventContext.ts new file mode 100644 index 0000000000..d8761947ed --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/eventContext.ts @@ -0,0 +1,71 @@ +/* + * 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. + */ + +const eventContext: unique symbol = Symbol('event-context'); +const typeSaver: unique symbol = Symbol('event-context-typeSaver'); + +export interface IEventFlag { + name: string; + [typeSaver]?: T; +} + +interface IEventMark { + flag: IEventFlag; + context: T; +} + +interface IEventWithContext { + [eventContext]: Array>; +} + +interface IEventContext { + set: (event: TEvent | IEventWithContext, flag: IEventFlag, context?: TContext) => IEventContext; + has: (event: TEvent | IEventWithContext, ...flags: Array>) => boolean; + get: (event: TEvent | IEventWithContext, flag: IEventFlag) => TContext | undefined; + create: (name: string) => IEventFlag; +} + +export const EventContext: IEventContext = { + set(event: TEvent | IEventWithContext, flag: IEventFlag, context: TContext) { + event = addContextToEvent(event); + + const mark = event[eventContext].find(mark => mark.flag === flag); + + if (mark) { + mark.context = context; + return this; + } + + event[eventContext].push({ flag, context }); + + return this; + }, + has(event: TEvent | IEventWithContext, ...flags: Array>) { + event = addContextToEvent(event); + + return flags.some(flag => (event as IEventWithContext)[eventContext].some(mark => mark.flag === flag)); + }, + get(event: TEvent | IEventWithContext, flag: IEventFlag) { + event = addContextToEvent(event); + + const mark = event[eventContext].find(mark => mark.flag === flag); + + return mark?.context; + }, + create(name: string) { + return { name }; + }, +}; + +function addContextToEvent(obj: any): IEventWithContext { + if (!obj[eventContext]) { + obj[eventContext] = []; + } + + return obj; +} diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/eventStopPropagationFlag.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/eventStopPropagationFlag.ts new file mode 100644 index 0000000000..fb2240a4c3 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/eventStopPropagationFlag.ts @@ -0,0 +1,11 @@ +/* + * 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 { EventContext } from './eventContext.js'; + +export const EventStopPropagationFlag = EventContext.create('stopPropagation'); diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts index f362b1caa0..e8565e0642 100644 --- a/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts @@ -8,6 +8,8 @@ export * from './ColorConvert.js'; export * from './debouncePromise.js'; +export * from './eventContext.js'; +export * from './eventStopPropagationFlag.js'; export * from './isDefined.js'; export * from './isNotNullDefined.js'; export * from './memoizeLast.js'; diff --git a/webapp/packages/core-events/package.json b/webapp/packages/core-events/package.json index d5c429d4bc..881ff53efe 100644 --- a/webapp/packages/core-events/package.json +++ b/webapp/packages/core-events/package.json @@ -29,6 +29,7 @@ "@cloudbeaver/core-sdk": "workspace:*", "@cloudbeaver/core-settings": "workspace:*", "@cloudbeaver/core-utils": "workspace:*", + "@dbeaver/js-helpers": "workspace:*", "mobx": "^6", "tslib": "^2" }, diff --git a/webapp/packages/core-events/src/EventContext/EventContext.ts b/webapp/packages/core-events/src/EventContext/EventContext.ts index 2ed5e2b535..5456f6ff2b 100644 --- a/webapp/packages/core-events/src/EventContext/EventContext.ts +++ b/webapp/packages/core-events/src/EventContext/EventContext.ts @@ -1,71 +1,9 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 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. */ -const eventContext: unique symbol = Symbol('event-context'); -const typeSaver: unique symbol = Symbol('event-context-typeSaver'); - -export interface IEventFlag { - name: string; - [typeSaver]?: T; -} - -interface IEventMark { - flag: IEventFlag; - context: T; -} - -interface IEventWithContext { - [eventContext]: Array>; -} - -interface IEventContext { - set: (event: TEvent | IEventWithContext, flag: IEventFlag, context?: TContext) => IEventContext; - has: (event: TEvent | IEventWithContext, ...flags: Array>) => boolean; - get: (event: TEvent | IEventWithContext, flag: IEventFlag) => TContext | undefined; - create: (name: string) => IEventFlag; -} - -export const EventContext: IEventContext = { - set(event: TEvent | IEventWithContext, flag: IEventFlag, context: TContext) { - event = addContextToEvent(event); - - const mark = event[eventContext].find(mark => mark.flag === flag); - - if (mark) { - mark.context = context; - return this; - } - - event[eventContext].push({ flag, context }); - - return this; - }, - has(event: TEvent | IEventWithContext, ...flags: Array>) { - event = addContextToEvent(event); - - return flags.some(flag => (event as IEventWithContext)[eventContext].some(mark => mark.flag === flag)); - }, - get(event: TEvent | IEventWithContext, flag: IEventFlag) { - event = addContextToEvent(event); - - const mark = event[eventContext].find(mark => mark.flag === flag); - - return mark?.context; - }, - create(name: string) { - return { name }; - }, -}; - -function addContextToEvent(obj: any): IEventWithContext { - if (!obj[eventContext]) { - obj[eventContext] = []; - } - - return obj; -} +export { EventContext, type IEventFlag } from '@dbeaver/js-helpers'; diff --git a/webapp/packages/core-events/src/EventContext/EventStopPropagationFlag.ts b/webapp/packages/core-events/src/EventContext/EventStopPropagationFlag.ts index 30cc23f47c..9b1f4fc563 100644 --- a/webapp/packages/core-events/src/EventContext/EventStopPropagationFlag.ts +++ b/webapp/packages/core-events/src/EventContext/EventStopPropagationFlag.ts @@ -1,10 +1,8 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 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 { EventContext } from './EventContext.js'; - -export const EventStopPropagationFlag = EventContext.create('stopPropagation'); +export { EventStopPropagationFlag } from '@dbeaver/js-helpers'; diff --git a/webapp/packages/core-events/tsconfig.json b/webapp/packages/core-events/tsconfig.json index 20e74f1c03..8ba425a907 100644 --- a/webapp/packages/core-events/tsconfig.json +++ b/webapp/packages/core-events/tsconfig.json @@ -13,6 +13,9 @@ { "path": "../../common-typescript/@dbeaver/cli" }, + { + "path": "../../common-typescript/@dbeaver/js-helpers" + }, { "path": "../core-cli" }, diff --git a/webapp/packages/core-theming/src/ThemeService.ts b/webapp/packages/core-theming/src/ThemeService.ts index 11999dccc1..d611359b56 100644 --- a/webapp/packages/core-theming/src/ThemeService.ts +++ b/webapp/packages/core-theming/src/ThemeService.ts @@ -1,6 +1,6 @@ /* * 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. @@ -22,6 +22,7 @@ import './styles/main/typography.pure.scss'; import './styles/UiIconButton.css'; import './styles/UiSpinner.css'; import './styles/UiInput.css'; +import './styles/UISearch.css'; import './styles/UiPopover.css'; import './styles/UiColorPicker.css'; import { FALLBACK_THEME_ID, themes } from './themes.js'; diff --git a/webapp/packages/core-theming/src/styles/UISearch.css b/webapp/packages/core-theming/src/styles/UISearch.css new file mode 100644 index 0000000000..5e741edec7 --- /dev/null +++ b/webapp/packages/core-theming/src/styles/UISearch.css @@ -0,0 +1,6 @@ +@layer components { + .search-panel__inputs { + --dbv-kit-input-border-color: var(--theme-input-border, #cccccc); + --dbv-kit-input-background: var(--theme-input-background, #ffffff); + } +} diff --git a/webapp/packages/plugin-codemirror6/src/ReactCodemirrorSearchPanel.tsx b/webapp/packages/plugin-codemirror6/src/ReactCodemirrorSearchPanel.tsx index e5c1e12cef..287ea9b3b0 100644 --- a/webapp/packages/plugin-codemirror6/src/ReactCodemirrorSearchPanel.tsx +++ b/webapp/packages/plugin-codemirror6/src/ReactCodemirrorSearchPanel.tsx @@ -1,6 +1,6 @@ /* * 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. @@ -21,9 +21,9 @@ import { import { observer } from 'mobx-react-lite'; import { useContext, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { SearchPanel, type SearchPanelRef } from '@dbeaver/ui-kit'; import { ReactCodemirrorContext } from './ReactCodemirrorContext.js'; -import { SearchPanel, type SearchPanelRef } from './SearchPanel/SearchPanel.js'; interface Props extends React.PropsWithChildren { className?: string; @@ -183,9 +183,9 @@ export const ReactCodemirrorSearchPanel: React.FC = observer(function Rea return createPortal( void; -} - -interface SearchPanelProps { - isReadOnly: boolean; - searchMatchesCount?: { count: number; current: number }; - queryState: SearchQuery; - ref: React.RefObject; - onQueryChange: (value: string) => void; - onCaseSensitiveToggle: () => void; - onRegexToggle: () => void; - onWholeWordToggle: () => void; - onReplaceChange: (value: string) => void; - onFindNext: () => void; - onFindPrevious: () => void; - onReplaceAll: () => void; - onReplaceNext: () => void; - onClose: () => void; -} - -export const SearchPanel = observer(function SearchPanel({ - isReadOnly, - searchMatchesCount, - queryState, - ref, - onQueryChange, - onCaseSensitiveToggle, - onRegexToggle, - onWholeWordToggle, - onReplaceChange, - onFindNext, - onFindPrevious, - onReplaceAll, - onReplaceNext, - onClose, -}: SearchPanelProps) { - const [showReplace, setShowReplace] = useState(false); - const translate = useTranslate(); - const inputRef = useRef(null); - - useImperativeHandle( - ref, - () => ({ - focus: () => { - inputRef.current?.focus(); - }, - }), - [], - ); - - function handleToggleReplace() { - setShowReplace(prev => !prev); - } - - function handleKeyDown(event: React.KeyboardEvent) { - if (event.key === 'Escape') { - onClose(); - } - } - - function handleInputKeyDown(event: React.KeyboardEvent) { - if (event.key === 'Enter') { - if (event.shiftKey) { - onFindPrevious(); - } else { - onFindNext(); - } - } - } - - function handleReplaceKeyDown(event: React.KeyboardEvent) { - if (event.key === 'Enter') { - if (event.shiftKey) { - onReplaceAll(); - } else { - onReplaceNext(); - } - } - } - - return ( -
- {!isReadOnly && ( - - - - )} - -
-
- onQueryChange(event.target.value)} - /> -
- - - - - - - - - - - -
-
- {showReplace && ( -
- onReplaceChange(event.target.value)} - /> -
- )} -
-
-
- {queryState.search && searchMatchesCount && ( - - {searchMatchesCount.count > 0 - ? `${searchMatchesCount.current} ${translate('plugin_codemirror_search_matches_of')} ${searchMatchesCount.count}` - : translate('plugin_codemirror_search_matches_none')} - - )} - - - - - - - - - - - - -
- - {showReplace && ( -
- - - - - - - -
- )} -
-
- ); -}); diff --git a/webapp/packages/plugin-data-grid/src/index.ts b/webapp/packages/plugin-data-grid/src/index.ts index f3626c11de..aea047fedc 100644 --- a/webapp/packages/plugin-data-grid/src/index.ts +++ b/webapp/packages/plugin-data-grid/src/index.ts @@ -1,6 +1,6 @@ /* * 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. @@ -21,10 +21,13 @@ export { type IGridReactiveValue, type DataGridRef, type ICellPosition, + type ICellChange, type IDataGridRowRenderer, type IDataGridCellRenderer, type IDataGridCellProps, type DataGridProps, + type IGridSearchStorageState, + type IGridSearchStorage, } from '@dbeaver/react-data-grid'; export { GrantManagementTable } from './GrantManagementTableLazy.js'; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx index e8c295a7c8..f77ce72278 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx @@ -20,6 +20,7 @@ import { type IDataGridRowRenderer, type IDataGridCellRenderer, type DataGridProps, + type ICellChange, } from '@cloudbeaver/plugin-data-grid'; import { DATA_CONTEXT_DV_PRESENTATION, @@ -42,8 +43,11 @@ import { GridSelectAction, GridViewAction, ResultSetCacheAction, + GridHistoryAction, type IGridEditActionData, type IGridDataKey, + type IHistoryEntry, + getKeyFromHistoryEntry, } from '@cloudbeaver/plugin-data-viewer'; import { CellRenderer } from './CellRenderer/CellRenderer.js'; @@ -57,6 +61,7 @@ import { TableDataContext } from './TableDataContext.js'; import { useGridDragging } from './useGridDragging.js'; import { useFormatting } from './useFormatting.js'; import { useGridSelectedCellsCopy } from './useGridSelectedCellsCopy.js'; +import { useSearchResultsCache } from './useSearchResultsCache.js'; import { useTableData } from './useTableData.js'; import { TableColumnHeader } from './TableColumnHeader/TableColumnHeader.js'; import { TableIndexColumnHeader } from './TableColumnHeader/TableIndexColumnHeader.js'; @@ -77,21 +82,14 @@ export const DataGridTable = observer(function DataGridT const selectionAction = model.source.getAction(resultIndex, IDatabaseDataSelectAction, GridSelectAction); const viewAction = model.source.getAction(resultIndex, IDatabaseDataViewAction, GridViewAction); const cacheAction = model.source.getAction(resultIndex, IDatabaseDataCacheAction, ResultSetCacheAction); + const historyAction = model.source.tryGetAction(resultIndex, GridHistoryAction); const tableData = useTableData(model as unknown as IDatabaseDataModel, resultIndex, dataGridDivRef); const formatting = useFormatting(tableData, cacheAction); + const searchResultsCache = useSearchResultsCache(cacheAction); const getHeaderOrder = useCallback(() => (dataGridRef.current?.getColumnsOrdered() ?? []).map(col => col.key), [dataGridRef]); const gridSelectionContext = useGridSelectionContext(tableData, selectionAction, getHeaderOrder); - const restoreFocus = useCallback( - function restoreFocus() { - const gridDiv = gridContainerRef.current; - const focusSink = gridDiv?.querySelector('[aria-selected="true"]'); - focusSink?.focus(); - }, - [gridContainerRef], - ); - function isGridInFocus(): boolean { const gridDiv = gridContainerRef.current; const focusSink = gridDiv?.querySelector('[aria-selected="true"]'); @@ -125,7 +123,7 @@ export const DataGridTable = observer(function DataGridT dataGridRef.current?.selectCell(pos); } }, - focusCell(key: Partial | null, initial = false) { + focusCell(key: Partial | null, initial = false, deferred = false) { if ((!key?.column || !key?.row) && initial) { const selectedElements = selectionAction.getSelectedElements(); @@ -150,9 +148,14 @@ export const DataGridTable = observer(function DataGridT const colIdx = tableData.getColumnIndexFromColumnKey(key.column!); const rowIdx = tableData.getRowIndexFromKey(key.row!); - focusSyncRef.current = { colIdx, rowIdx }; - - this.selectCell({ colIdx, rowIdx }); + if (deferred) { + if (dataGridRef.current?.selectCell({ colIdx, rowIdx }, { deferred: true })) { + focusSyncRef.current = { colIdx, rowIdx }; + } + } else { + focusSyncRef.current = { colIdx, rowIdx }; + this.selectCell({ colIdx, rowIdx }); + } }, })); @@ -176,7 +179,16 @@ export const DataGridTable = observer(function DataGridT useLayoutEffect(() => { function syncEditor(data: IGridEditActionData) { const editor = tableData.editor; - if (data.resultId !== editor?.result.id || !data.value || data.value.length === 0 || data.type === DatabaseEditChangeType.delete) { + + if (data.resultId !== editor?.result.id) { + return; + } + + if (data.revert) { + dataGridRef.current?.refreshSearch(); + } + + if (!data.value || data.value.length === 0 || data.type === DatabaseEditChangeType.delete) { return; } @@ -197,11 +209,7 @@ export const DataGridTable = observer(function DataGridT function syncFocus(data: DatabaseDataSelectActionsData>) { if (data.type === 'focus') { - // TODO: we need this delay to update focus after render rows update - setTimeout(() => { - handlers.focusCell(data.key); - setTimeout(() => restoreFocus(), 1); - }, 1); + handlers.focusCell(data.key, false, true); } } @@ -211,7 +219,37 @@ export const DataGridTable = observer(function DataGridT return () => { tableData.editor?.action.removeHandler(syncEditor); }; - }, [tableData.editor, selectionAction, handlers, tableData, restoreFocus]); + }, [tableData.editor, selectionAction, handlers, tableData]); + + useLayoutEffect(() => { + if (!historyAction) { + return; + } + + function handleHistoryChange(entry: IHistoryEntry) { + dataGridRef.current?.refreshSearch(); + + const key = getKeyFromHistoryEntry(entry); + if (!key) { + return; + } + + const colIdx = tableData.getColumnIndexFromColumnKey(key.column); + const rowIdx = tableData.getRowIndexFromKey(key.row); + + if (colIdx >= 0 && rowIdx >= 0) { + handlers.selectCell({ colIdx, rowIdx }, true); + } + } + + historyAction.onUndo.addHandler(handleHistoryChange); + historyAction.onRedo.addHandler(handleHistoryChange); + + return () => { + historyAction.onUndo.removeHandler(handleHistoryChange); + historyAction.onRedo.removeHandler(handleHistoryChange); + }; + }, [historyAction, tableData, handlers]); const handleFocusChange = (position: ICellPosition) => { focusedCell.current = position; @@ -253,9 +291,9 @@ export const DataGridTable = observer(function DataGridT simple, isGridInFocus, getDataGridApi: () => dataGridRef.current, - focus: restoreFocus, + focus: () => dataGridRef.current?.restoreFocus(), }), - [model, actions, resultIndex, simple, dataGridRef, restoreFocus], + [model, actions, resultIndex, simple, dataGridRef], ); const columnsCount = useCreateGridReactiveValue( @@ -430,6 +468,23 @@ export const DataGridTable = observer(function DataGridT tableData.editor?.set({ row, column }, value); } + function handleCellChangeBatch(changes: ICellChange[]) { + const updates = []; + + for (const { rowIdx, colIdx, value } of changes) { + const row = tableData.rows[rowIdx]; + const column = tableData.getColumn(colIdx)?.key; + + if (row && column) { + updates.push({ key: { row, column }, value }); + } + } + + if (updates.length > 0) { + tableData.editor?.setMany(updates); + } + } + function isCellEditable(rowIdx: number, colIdx: number): boolean { const row = tableData.rows[rowIdx]; const column = tableData.getColumn(colIdx)?.key; @@ -527,11 +582,19 @@ export const DataGridTable = observer(function DataGridT columnSortable={columnSortable} columnSortingState={columnSortingState} getRowId={rowIdx => (tableData.rows[rowIdx] ? GridDataKeysUtils.serialize(tableData.rows[rowIdx]) : '')} + search={{ + isEnabled: true, + isReadOnly: + model.isReadonly(resultIndex) || + !(isResultSetDataSource(model.source) && model.source.hasElementIdentifier(resultIndex)), + storage: searchResultsCache, + }} columnSortingMultiple onFocus={handleFocusChange} onScrollToBottom={handleScrollToBottom} onColumnSort={handleSort} onCellChange={handleCellChange} + onCellChangeBatch={handleCellChangeBatch} onCellKeyDown={handleCellKeyDown} onHeaderKeyDown={gridSelectedCellCopy.onKeydownHandler} /> diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useSearchResultsCache.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useSearchResultsCache.ts new file mode 100644 index 0000000000..7f0aabe558 --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useSearchResultsCache.ts @@ -0,0 +1,33 @@ +/* + * 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 { useMemo } from 'react'; + +import type { IGridSearchStorage, IGridSearchStorageState } from '@cloudbeaver/plugin-data-grid'; +import type { ResultSetCacheAction } from '@cloudbeaver/plugin-data-viewer'; + +const SEARCH_STORAGE_KEY = Symbol('grid-search-storage'); + +export function useSearchResultsCache(cacheAction: ResultSetCacheAction): IGridSearchStorage { + return useMemo( + () => ({ + get(): IGridSearchStorageState | undefined { + return cacheAction.getShared(SEARCH_STORAGE_KEY); + }, + set(state: IGridSearchStorageState): void { + cacheAction.setShared(SEARCH_STORAGE_KEY, state); + }, + update(partial: Partial): void { + const current = cacheAction.getShared(SEARCH_STORAGE_KEY); + if (current) { + cacheAction.setShared(SEARCH_STORAGE_KEY, { ...current, ...partial }); + } + }, + }), + [cacheAction], + ); +} diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseEditAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseEditAction.ts index 2ba572c24a..ad2a0c7b75 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseEditAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseEditAction.ts @@ -1,6 +1,6 @@ /* * 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. @@ -20,14 +20,13 @@ import type { import { makeObservable, observable } from 'mobx'; export abstract class DatabaseEditAction< - TKey = unknown, - TValue = unknown, - TUpdate extends IDatabaseDataEditApplyActionData = IDatabaseDataEditApplyActionData, - TResult extends IDatabaseDataResult = IDatabaseDataResult, - > + TKey = unknown, + TValue = unknown, + TUpdate extends IDatabaseDataEditApplyActionData = IDatabaseDataEditApplyActionData, + TResult extends IDatabaseDataResult = IDatabaseDataResult, +> extends DatabaseDataAction - implements IDatabaseDataEditAction -{ + implements IDatabaseDataEditAction { static dataFormat: ResultDataFormat[] | null = null; readonly action: ISyncExecutor>; @@ -54,6 +53,7 @@ export abstract class DatabaseEditAction< abstract getElementState(key: TKey): DatabaseEditChangeType | null; abstract get(key: TKey): TValue | undefined; abstract set(key: TKey, value: TValue): void; + abstract setMany(updates: Array<{ key: TKey; value: TValue }>): void; abstract add(...key: TKey[]): void; abstract duplicate(...key: TKey[]): void; abstract delete(...key: TKey[]): void; diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Document/DocumentEditAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Document/DocumentEditAction.ts index 001cbd8496..b3a2f2ab54 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Document/DocumentEditAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Document/DocumentEditAction.ts @@ -1,6 +1,6 @@ /* * 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. @@ -93,6 +93,10 @@ export class DocumentEditAction< this.removeUnchanged(key); } + setMany(updates: Array<{ key: TKey; value: TValue }>): void { + throw new Error('Not implemented'); + } + add(...key: TKey[]): void { throw new Error('Not implemented'); } diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts index 47f5dee5d1..43cffa51a8 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts @@ -172,9 +172,7 @@ export class GridEditAction< const prevValue = update.update[key.column.index] as TCell; this.historyManager.recordCellEdit({ - key, - value, - prevValue, + updates: [{ key, value, prevValue }], }); update.update[key.column.index] = value; @@ -195,6 +193,35 @@ export class GridEditAction< this.removeEmptyUpdate(update); } + setMany(updates: Array<{ key: TKey; value: TCell }>): void { + if (updates.length === 0) { + return; + } + + const historyUpdates: Array<{ key: TKey; prevValue: TCell; value: TCell }> = []; + const actionValues: Array<{ key: TKey; prevValue: TCell; value: TCell }> = []; + + for (const { key, value } of updates) { + const [update] = this.getOrCreateUpdate(key.row, DatabaseEditChangeType.update); + const prevValue = update.update[key.column.index] as TCell; + + historyUpdates.push({ key, prevValue, value }); + actionValues.push({ key, prevValue, value }); + + update.update[key.column.index] = value; + this.removeEmptyUpdate(update); + } + + this.historyManager.recordCellEdit({ updates: historyUpdates }); + + this.action.execute({ + resultId: this.result.id, + type: DatabaseEditChangeType.update, + revert: false, + value: actionValues, + }); + } + add(...keys: TKey[]): void { const result: TKey[] = []; const rowKeys = new Set(); diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditHistoryManager.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditHistoryManager.ts index 5cfef524ba..c6e2aadd6c 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditHistoryManager.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditHistoryManager.ts @@ -36,7 +36,13 @@ export class GridEditHistoryManager { } recordCellEdit(data: IGridHistoryCellUpdateData): void { - this.compressLastEditedCellHistory(data.key); + // Pass the key to skip compression while the same cell is still being edited. + // Without a key, compression always runs (e.g. for bulk edits). + if (data.updates.length === 1) { + this.compressLastEditedCellHistory(data.updates[0]!.key); + } else { + this.compressLastEditedCellHistory(); + } this.history.add({ source: GRID_HISTORY_SOURCE.EDIT_CELL, @@ -78,18 +84,27 @@ export class GridEditHistoryManager { return; } - const isEditingSameCell = key && GridDataKeysUtils.isElementsKeyEqual(currentHistoryEntry.data.key, key); + const currentUpdate = currentHistoryEntry.data.updates.length === 1 ? currentHistoryEntry.data.updates[0] : undefined; + + if (!currentUpdate) { + return; + } + + const isEditingSameCell = key && GridDataKeysUtils.isElementsKeyEqual(currentUpdate.key, key); if (isEditingSameCell) { return; } - this.compressCellEditHistory(currentHistoryEntry.data.key); + this.compressCellEditHistory(currentUpdate.key); } private compressCellEditHistory(key: TKey): void { this.history.compress( - entry => isGridHistoryEditCellData(entry) && GridDataKeysUtils.isElementsKeyEqual(entry.data.key, key), + entry => + isGridHistoryEditCellData(entry) && + entry.data.updates.length === 1 && + GridDataKeysUtils.isElementsKeyEqual(entry.data.updates[0]!.key, key), entries => { const firstEntry = entries[0]! as IHistoryEntry>; const lastEntry = entries[entries.length - 1]! as IHistoryEntry>; @@ -97,9 +112,13 @@ export class GridEditHistoryManager { return { source: GRID_HISTORY_SOURCE.EDIT_CELL, data: { - key, - value: lastEntry.data.value, - prevValue: firstEntry.data.prevValue, + updates: [ + { + key, + value: lastEntry.data.updates[0]!.value, + prevValue: firstEntry.data.updates[0]!.prevValue, + }, + ], }, }; }, diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryHandlers.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryHandlers.ts index 8e0eb81c76..0cfd3badae 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryHandlers.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryHandlers.ts @@ -29,12 +29,7 @@ function createUndoHandlers(): Record { if (isGridHistoryEditCellData(entry)) { - ops.setCells([ - { - key: entry.data.key, - value: entry.data.prevValue, - }, - ]); + ops.setCells(entry.data.updates.map(u => ({ key: u.key, value: u.prevValue }))); } }, [GRID_HISTORY_SOURCE.ADD_ROW]: (entry, ops) => { @@ -69,12 +64,7 @@ function createRedoHandlers(): Record { if (isGridHistoryEditCellData(entry)) { - ops.setCells([ - { - key: entry.data.key, - value: entry.data.value, - }, - ]); + ops.setCells(entry.data.updates.map(u => ({ key: u.key, value: u.value }))); } }, [GRID_HISTORY_SOURCE.ADD_ROW]: (entry, ops) => { diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryTypes.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryTypes.ts index 8b95041f28..11061db667 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryTypes.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryTypes.ts @@ -20,12 +20,16 @@ export interface IGridHistoryRow { +export interface IGridHistoryCellUpdate { key: TKey; prevValue: TCell; value: TCell; } +export interface IGridHistoryCellUpdateData { + updates: Array>; +} + export interface IGridHistoryRowUpdate extends IGridHistoryRow { prevValue: TCell[]; } @@ -73,3 +77,31 @@ export function isGridHistoryRevertData> { return entry.source === GRID_HISTORY_SOURCE.REVERT; } + +export function getKeyFromHistoryEntry(entry: IHistoryEntry): IGridDataKey | null { + if (isGridHistoryEditCellData(entry)) { + const lastUpdate = entry.data.updates[entry.data.updates.length - 1]; + return lastUpdate?.key ?? null; + } + if (isGridHistoryAddRowData(entry)) { + const firstRow = entry.data.rowEntries[0]; + return firstRow?.key ?? null; + } + if (isGridHistoryDeleteRowData(entry)) { + const firstRow = entry.data.rowEntries[0]; + return firstRow?.key ?? null; + } + if (isGridHistoryRevertData(entry)) { + const firstUpdate = entry.data.updates[0]; + if (firstUpdate) { + return firstUpdate.key; + } + const firstDeletion = entry.data.deletions[0]; + if (firstDeletion) { + return firstDeletion.key; + } + const firstAddition = entry.data.additions[0]; + return firstAddition?.key ?? null; + } + return null; +} 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 c21e23ebd7..c346447a61 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataCacheAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataCacheAction.ts @@ -1,6 +1,6 @@ /* * 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. @@ -9,13 +9,20 @@ import { createService } from '@cloudbeaver/core-di'; import type { IDatabaseDataAction } from '../IDatabaseDataAction.js'; import type { IDatabaseDataResult } from '../IDatabaseDataResult.js'; -export interface IDatabaseDataCacheAction - extends IDatabaseDataAction { +export interface IDatabaseDataCacheAction extends IDatabaseDataAction< + any, + TResult +> { has(key: TKey, scope: symbol): boolean; get(key: TKey, scope: symbol): T | undefined; set(key: TKey, scope: symbol, value: T): void; delete(key: TKey, scope: symbol): void; deleteAll(scope: symbol): void; + + hasShared(scope: symbol): boolean; + getShared(scope: symbol): T | undefined; + setShared(scope: symbol, value: T): void; + deleteShared(scope: symbol): void; } export const IDatabaseDataCacheAction = createService('IDatabaseDataCacheAction'); diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataEditAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataEditAction.ts index 0c8e53223f..50fcc68b53 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataEditAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataEditAction.ts @@ -1,6 +1,6 @@ /* * 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. @@ -55,6 +55,7 @@ export interface IDatabaseDataEditAction< getElementState: (key: TKey) => DatabaseEditChangeType | null; get: (key: TKey) => TValue | undefined; set: (key: TKey, value: TValue) => void; + setMany: (updates: Array<{ key: TKey; value: TValue }>) => void; add: (...key: TKey[]) => void; duplicate: (...key: TKey[]) => void; delete: (...key: TKey[]) => 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 03e00fde6b..53b1ebd695 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 @@ -1,6 +1,6 @@ /* * 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. @@ -18,6 +18,8 @@ import { injectable } from '@cloudbeaver/core-di'; import { IDatabaseDataResult } from '../../IDatabaseDataResult.js'; import type { IGridColumnKey, IGridDataKey, IGridRowKey } from '../Grid/IGridDataKey.js'; +const SHARED_CACHE_KEY = 'shared'; + @injectable(() => [IDatabaseDataSource, IDatabaseDataResult, ResultSetDataAction]) export class ResultSetCacheAction extends DatabaseDataAction @@ -41,10 +43,12 @@ export class ResultSetCacheAction set: action, setRow: action, setColumn: action, + setShared: action, delete: action, deleteAll: action, deleteRow: action, deleteColumn: action, + deleteShared: action, }); } @@ -153,6 +157,40 @@ export class ResultSetCacheAction } } + hasShared(scope: symbol): boolean { + const sharedCache = this.getSharedCache(); + + if (!sharedCache) { + return false; + } + + return sharedCache.has(scope); + } + + getShared(scope: symbol): T | undefined { + const sharedCache = this.getSharedCache(); + + if (!sharedCache) { + return; + } + + return sharedCache.get(scope); + } + + setShared(scope: symbol, value: T): void { + const sharedCache = this.getOrCreateSharedCache(); + + sharedCache.set(scope, value); + } + + deleteShared(scope: symbol): void { + const sharedCache = this.getSharedCache(); + + if (sharedCache) { + sharedCache.delete(scope); + } + } + override afterResultUpdate(): void { this.cache.clear(); } @@ -217,4 +255,19 @@ export class ResultSetCacheAction return keyCache; } + + private getSharedCache() { + return this.cache.get(SHARED_CACHE_KEY); + } + + private getOrCreateSharedCache() { + let sharedCache = this.getSharedCache(); + + if (!sharedCache) { + sharedCache = observable(new Map()); + this.cache.set(SHARED_CACHE_KEY, sharedCache); + } + + return sharedCache; + } } diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetEditAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetEditAction.ts index ca14ab9d15..183324484a 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetEditAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetEditAction.ts @@ -57,12 +57,20 @@ export class ResultSetEditAction extends GridEditAction): void { + super.setMany(updates.map(({ key, value }) => ({ key, value: this.transformValue(key, value) }))); + } + + private transformValue(key: IGridDataKey, value: IResultSetValue): IResultSetValue { const [update] = this.getOrCreateUpdate(key.row, DatabaseEditChangeType.update); const prevValue = update.source?.[key.column.index] as any; if (isResultSetContentValue(prevValue) && !isResultSetComplexValue(value)) { if ('text' in prevValue && !isNull(value)) { - value = createResultSetContentValue({ + return createResultSetContentValue({ text: String(value), contentLength: String(value).length, contentType: prevValue.contentType ?? 'text/plain', @@ -70,7 +78,7 @@ export class ResultSetEditAction extends GridEditAction { diff --git a/webapp/packages/plugin-data-viewer/src/index.ts b/webapp/packages/plugin-data-viewer/src/index.ts index 27b5e1c5e9..4d25f5a4c0 100644 --- a/webapp/packages/plugin-data-viewer/src/index.ts +++ b/webapp/packages/plugin-data-viewer/src/index.ts @@ -1,6 +1,6 @@ /* * 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. @@ -38,6 +38,8 @@ export * from './DatabaseDataModel/Actions/Grid/GridEditAction.js'; export * from './DatabaseDataModel/Actions/Grid/GridViewAction.js'; export * from './DatabaseDataModel/Actions/Grid/IGridDataKey.js'; export * from './DatabaseDataModel/Actions/Grid/GridSelectAction.js'; +export * from './DatabaseDataModel/Actions/Grid/GridHistoryAction.js'; +export * from './DatabaseDataModel/Actions/Grid/GridHistoryTypes.js'; export * from './DatabaseDataModel/Actions/Grid/compareGridRowKeys.js'; export * from './DatabaseDataModel/Actions/ResultSet/ResultSetEditAction.js'; export * from './DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction.js'; diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Table/Table.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Table/Table.tsx index 2b708abac3..00cb03be12 100644 --- a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Table/Table.tsx +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Table/Table.tsx @@ -38,7 +38,7 @@ export const Table = observer(function Table({ objects, hasNextPage, const tableState = useTable(); const tabLocalState = useTabLocalState(() => ({ scrollTop: 0, scrollLeft: 0 })); - const scrollBox = (tableContainer?.firstChild as HTMLDivElement | undefined) ?? null; + const scrollBox = tableContainer?.querySelector('[role="grid"]') ?? null; useControlledScroll(scrollBox, tabLocalState); const baseObject = objects.slice().sort((a, b) => (b.object?.properties?.length || 0) - (a.object?.properties?.length || 0)); diff --git a/webapp/packages/storybook/.storybook/global.css b/webapp/packages/storybook/.storybook/global.css index 388b137073..bbd4f61ff3 100644 --- a/webapp/packages/storybook/.storybook/global.css +++ b/webapp/packages/storybook/.storybook/global.css @@ -1,6 +1,50 @@ @import 'tailwindcss' layer(base) prefix(tw); @layer theme { + :root { + --theme-primary: #2a7cb4; + --theme-secondary: #f2f2f2; + --theme-background: #dedede; + --theme-surface: #ffffff; + --theme-error: #b00020; + --theme-on-primary: #ffffff; + --theme-on-secondary: #6d6d6d; + --theme-on-surface: #353535; + --theme-on-error: #fff; + --theme-text-primary-on-background: rgba(0, 0, 0, .87); + --theme-text-secondary-on-background: rgba(0, 0, 0, .45); + --theme-text-hint-on-background: rgba(0, 0, 0, .55); + --theme-text-disabled-on-background: rgba(0, 0, 0, .2); + --theme-text-icon-on-background: rgba(0, 0, 0, .38); + --theme-text-primary-on-light: rgba(0, 0, 0, .87); + --theme-text-secondary-on-light: rgba(0, 0, 0, .45); + --theme-text-hint-on-light: rgba(0, 0, 0, .55); + --theme-text-disabled-on-light: rgba(0, 0, 0, .2); + --theme-text-icon-on-light: rgba(0, 0, 0, .38); + --theme-text-primary-on-dark: white; + --theme-text-secondary-on-dark: rgba(255, 255, 255, .7); + --theme-text-hint-on-dark: rgba(255, 255, 255, .55); + --theme-text-disabled-on-dark: rgba(255, 255, 255, .45); + --theme-text-icon-on-dark: rgba(255, 255, 255, .45); + --theme-sub-secondary: #f8f8f8; + --theme-on-sub-secondary: #6d6d6d; + --theme-positive: #52c41a; + --theme-on-positive: #fff; + --theme-negative: #e73e52; + --theme-status: #ff9900; + --theme-link-color: #2a7cb4; + --theme-input-color: #353535; + --theme-input-color-disabled: #686868; + --theme-input-color-readonly: #353535; + --theme-input-color-placeholder: rgb(142.25, 142.25, 142.25); + --theme-input-border: #d9d9d9; + --theme-input-border-readonly: #d9d9d9; + --theme-input-border-disabled: #d9d9d9; + --theme-input-background: #ffffff; + --theme-input-background-disabled: #e9e9e9; + --theme-input-background-readonly: #e9e9e9 + } + main { color: var(--dbv-kit-color-foreground); background-color: var(--tw-color-gray-50); diff --git a/webapp/packages/storybook/src/stories/components/SearchPanel/SearchPanel.stories.tsx b/webapp/packages/storybook/src/stories/components/SearchPanel/SearchPanel.stories.tsx new file mode 100644 index 0000000000..408fe96273 --- /dev/null +++ b/webapp/packages/storybook/src/stories/components/SearchPanel/SearchPanel.stories.tsx @@ -0,0 +1,92 @@ +/* + * 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 { useRef, useState } from 'react'; + +import { SearchPanel, type SearchPanelQuery, type SearchPanelRef } from '@dbeaver/ui-kit'; +import icons from '@dbeaver/ui-kit/assets/icons/preload/icons.svg?raw'; + +const meta = { + title: 'Components/SearchPanel', + component: SearchPanel, +}; + +export default meta; + +function useMockSearch(initial: SearchPanelQuery) { + const [query, setQuery] = useState(initial); + const [matches, setMatches] = useState({ count: 5, current: 1 }); + + function updateQuery(next: Partial) { + const value = { ...query, ...next }; + setQuery(value); + if (!value.search) { + setMatches({ count: 0, current: 0 }); + } else { + setMatches(prev => ({ ...prev, count: Math.max(prev.count, 1), current: 1 })); + } + } + + return { query, matches, updateQuery, setMatches }; +} + +export const SearchOnly = () => { + const { query, matches, updateQuery, setMatches } = useMockSearch({ search: '', caseSensitive: false, wholeWord: false, regexp: false }); + const ref = useRef(null); + + return ( +
+
+ updateQuery({ search: value })} + onCaseSensitiveToggle={() => updateQuery({ caseSensitive: !query.caseSensitive })} + onWholeWordToggle={() => updateQuery({ wholeWord: !query.wholeWord })} + onRegexToggle={() => updateQuery({ regexp: !query.regexp })} + onFindNext={() => setMatches(prev => ({ ...prev, current: prev.count ? Math.min(prev.current + 1, prev.count) : 0 }))} + onFindPrevious={() => setMatches(prev => ({ ...prev, current: prev.count ? Math.max(prev.current - 1, 1) : 0 }))} + onClose={() => updateQuery({ search: '' })} + /> +
+ ); +}; + +export const SearchWithReplace = () => { + const { query, matches, updateQuery, setMatches } = useMockSearch({ + search: 'value', + replace: 'new', + caseSensitive: false, + wholeWord: false, + regexp: false, + }); + const ref = useRef(null); + + return ( +
+
+ updateQuery({ search: value })} + onReplaceChange={value => updateQuery({ replace: value })} + onCaseSensitiveToggle={() => updateQuery({ caseSensitive: !query.caseSensitive })} + onWholeWordToggle={() => updateQuery({ wholeWord: !query.wholeWord })} + onRegexToggle={() => updateQuery({ regexp: !query.regexp })} + onFindNext={() => setMatches(prev => ({ ...prev, current: prev.count ? Math.min(prev.current + 1, prev.count) : 0 }))} + onFindPrevious={() => setMatches(prev => ({ ...prev, current: prev.count ? Math.max(prev.current - 1, 1) : 0 }))} + onReplaceNext={() => updateQuery({ search: query.search })} + onReplaceAll={() => updateQuery({ search: query.search })} + onClose={() => updateQuery({ search: '' })} + /> +
+ ); +}; diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 6907ff854e..4db7043b9a 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -1720,6 +1720,7 @@ __metadata: "@cloudbeaver/core-utils": "workspace:*" "@cloudbeaver/tsconfig": "workspace:*" "@dbeaver/cli": "workspace:*" + "@dbeaver/js-helpers": "workspace:*" "@dbeaver/react-tests": "workspace:*" mobx: "npm:^6" rimraf: "npm:^6"