From 296e03ec74661e14143e98f66db7d9afa52816cf Mon Sep 17 00:00:00 2001 From: Sychev Andrey <44414066+SychevAndrey@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:28:02 +0100 Subject: [PATCH] 7023-cb-find-and-replace-new (#4085) * dbeaver/pro#7023 feat: add SearchPanel component and related styles to UI-kit add chevron Icon to UI-kit (used in Search Panel) * dbeaver/pro#7023 feat: add SearchPanel story * dbeaver/pro#7023 feat: update SearchPanel integration in CodeMirror * dbeaver/pro#7023 feat: add onReplaceToggle prop to SearchPanel for toggle functionality * dbeaver/pro#7023 fix: replace-input styles * dbeaver/pro#7023 feat: implement search functionality in DataGrid v2 * dbeaver/pro#7023 fix: update react-data-grid workspace reference in yarn.lock * dbeaver/pro#7023 feat: don't focus cell on replace * dbeaver/pro#7023 feat: add searchReadOnly prop to DataGrid and GridSearchPanel components * dbeaver/pro#7023 fix: update styles for search panel * dbeaver/pro#7023 feat: add theme variables for improved styling in storybook * dbeaver/pro#7023 feat: update search panel styles in datagrid * dbeaver/pro#7023 feat: improve search functionality by resetting active match index and triggering search on find actions * dbeaver/pro#7023 refactor: useGridSearch * dbeaver/pro#7023 fix: search panel styles * dbeaver/pro#7023 feat: implement grid search state management * dbeaver/pro#7023 feat: add refresh functionality to search components * dbeaver/pro#7023 feat: add history action to refresh search results on undo/redo * dbeaver/pro#7023 feat: add batch cell update functionality to data grid That functionality comes in handy when we want to use Undo and Replace All functions together: Before that commit we used to perform N operations where N is a number of replaced cells. And history would record them as atomic operations, so pressing Undo would revert only the last replace. setMany enables batch editing and batch reverting, so now we can Replace All and then revert it by using one Undo operation * dbeaver/pro#7023 refactor: remove unnecessary eslint disable comment in useGridSearch * dbeaver/pro#7023 feat: select affected cell on history change * dbeaver/pro#7023 feat: add searchReadOnly prop to DataGridTable for conditional read-only state * dbeaver/pro#7023 refactor: simplify isReadOnly logic in GridSearchPanel * dbeaver/pro#7023 refactor: use constants for flags * dbeaver/pro#7023 refactor: update focus and refresh methods in GridSearchPanel * dbeaver/pro#7023 refactor: consolidate grid search state update logic * dbeaver/pro#7023 refactor: update scrollToCell method signature and related usages * dbeaver/pro#7023 refactor: update handleCellChangeBatch to use ICellChange type * dbeaver/pro#7023 refactor: remove unwanted code * dbeaver/pro#7023 feat: refactor search functionality to use standalone functions * dbeaver/pro#7023 feat: implement useDataGridSearch hook to avoid bloating the DataGrid component with the search-related logic * dbeaver/pro#7023 feat: update search functionality to use onReplace for cell value updates * dbeaver/pro#7023 feat: use Activity component for search panel visibility in DataGrid * dbeaver/pro#7023 refactor: use common value transformation fn in set and setMany methods * dbeaver/pro#7023 refactor: rename methods for "unnkeyed" cache * dbeaver/pro#7023 refactor: unify cell edit history handling and remove batch edit references * dbeaver/pro#7023 fix: update searchReadOnly logic * dbeaver/pro#7023 refactor: replace rowsCountRef with useState (Error: Cannot access refs during render) * dbeaver/pro#7023 refactor: expose getCellClassName via useImperativeHandle Replaced onCellClassNameChange prop + useEffect with useImperativeHandle as suggested in code review. Using useState setter directly as callback ref either caused infinite re-renders (without deps) or lost context synchronization entirely (with stable deps). Settled on useCallback ref that sets both useRef (for method access) and useState (for context propagation). * dbeaver/pro#7023 fix: Cmd+F not re-focusing the search panel when already open * dbeaver/pro#7023 refactor: cell edit history compression * dbeaver/pro#7023 fix: refresh search state on revert * dbeaver/pro#7023 refactor: move focus blocking during replace into grid component Previously DataGridTable checked isReplacing() before syncing focus/selection, leaking grid internals Now the grid's selectCell blocks during replacing internally and supports deferred mode (setTimeout + rAF) for post-render focus sync. * dbeaver/pro#7023 refactor: restructure grid search logic with useReducer and improve state management * dbeaver/pro#7023 refactor: improve grid search moved ref synchronization out of render, unified search+scroll into one shared path, added a SET_MATCHES no-op guard to reduce unnecessary updates, removed duplicate open=false persistence from useDataGridSearch (single owner now in useGridSearch). * dbeaver/pro#7023 refactor: update search state types * dbeaver/pro#7023 refactor: restructure grid search state interfaces for improved clarity * dbeaver/pro#7023 refactor: optimize match retrieval logic after removal in grid search * dbeaver/pro#7023 refactor: sync refs and computed props in one effect * dbeaver/pro#7023 fix: Esc double handling between SearchPanel and CaptureView hotkeys * dbeaver/pro#7023 refactor: fix review comments in useGridSearch * dbeaver/pro#7023 refactor: unify hotkey stop propagation via shared EventContext/EventStopPropagationFlag * dbeaver/pro#7023 refactor: pass reactive cellText to useGridSearch * dbeaver/pro#7023 refactor: update grid search reducer actions and navigation logic reuse SET_ACTIVE_MATCH and computeActiveMatchIdx for consistency * dbeaver/pro#7023 refactor: improve equality check for cell matches * dbeaver/pro#7023 feat: implement cell change tracking subscriptions and cleanup logic * dbeaver/pro#7023 refactor: remove redundant clearTrackedCellSubscriptions Cleanup implemented in useTrackedGridSearchCellSubscriptions hook * dbeaver/pro#7023 fix: cancel stale deferred selectCell to prevent infinite focus loop When multiple rows are deleted, competing deferred selectCell timeouts cause focusSyncRef mismatch and endless focus-change cycle. * dbeaver/pro#7023 fix: update keyboard shortcut for search to use 'KeyF' * dbeaver/pro#7023 feat: make search disabled by default * dbeaver/pro#7023 fix: layout * dbeaver/pro#7023 fix: update scrollBox selection method in Table component --------- Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com> Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com> Co-authored-by: Viktor Kirst Co-authored-by: sergeyteleshev --- .../@dbeaver/react-data-grid/package.json | 1 + .../@dbeaver/react-data-grid/src/BaseCell.tsx | 15 +- .../@dbeaver/react-data-grid/src/DataGrid.css | 25 + .../@dbeaver/react-data-grid/src/DataGrid.tsx | 173 +++++-- .../src/DataGridCellContext.ts | 12 +- .../@dbeaver/react-data-grid/src/index.ts | 4 +- .../src/search/GridSearchEngine.ts | 89 ++++ .../src/search/GridSearchPanel.tsx | 104 ++++ .../react-data-grid/src/search/cellKey.ts | 13 + .../react-data-grid/src/search/index.ts | 16 + .../react-data-grid/src/search/state.ts | 153 ++++++ .../src/search/useGridSearch.ts | 475 ++++++++++++++++++ .../useTrackedGridSearchCellSubscriptions.ts | 101 ++++ .../react-data-grid/src/useDataGridSearch.ts | 95 ++++ .../ui-kit/assets/icons/preload/icons.svg | 7 + .../@dbeaver/ui-kit/src/Icon/Icon.tsx | 3 +- .../ui-kit}/src/SearchPanel/SearchPanel.css | 51 +- .../ui-kit/src/SearchPanel/SearchPanel.tsx | 297 +++++++++++ .../common-react/@dbeaver/ui-kit/src/index.ts | 9 +- .../@dbeaver/js-helpers/src/eventContext.ts | 71 +++ .../src/eventStopPropagationFlag.ts | 11 + .../@dbeaver/js-helpers/src/index.ts | 2 + webapp/packages/core-events/package.json | 1 + .../src/EventContext/EventContext.ts | 66 +-- .../EventContext/EventStopPropagationFlag.ts | 6 +- webapp/packages/core-events/tsconfig.json | 3 + .../packages/core-theming/src/ThemeService.ts | 3 +- .../core-theming/src/styles/UISearch.css | 6 + .../src/ReactCodemirrorSearchPanel.tsx | 8 +- .../src/SearchPanel/SearchPanel.tsx | 243 --------- webapp/packages/plugin-data-grid/src/index.ts | 5 +- .../src/DataGrid/DataGridTable.tsx | 107 +++- .../src/DataGrid/useSearchResultsCache.ts | 33 ++ .../Actions/DatabaseEditAction.ts | 16 +- .../Actions/Document/DocumentEditAction.ts | 6 +- .../Actions/Grid/GridEditAction.ts | 33 +- .../Actions/Grid/GridEditHistoryManager.ts | 33 +- .../Actions/Grid/GridHistoryHandlers.ts | 14 +- .../Actions/Grid/GridHistoryTypes.ts | 34 +- .../Actions/IDatabaseDataCacheAction.ts | 13 +- .../Actions/IDatabaseDataEditAction.ts | 3 +- .../Actions/ResultSet/ResultSetCacheAction.ts | 55 +- .../Actions/ResultSet/ResultSetEditAction.ts | 12 +- .../packages/plugin-data-viewer/src/index.ts | 4 +- .../ObjectPropertyTable/Table/Table.tsx | 2 +- .../packages/storybook/.storybook/global.css | 44 ++ .../SearchPanel/SearchPanel.stories.tsx | 92 ++++ webapp/yarn.lock | 1 + 48 files changed, 2111 insertions(+), 459 deletions(-) create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchEngine.ts create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/search/GridSearchPanel.tsx create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/search/cellKey.ts create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/search/index.ts create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/search/state.ts create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/search/useGridSearch.ts create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/search/useTrackedGridSearchCellSubscriptions.ts create mode 100644 webapp/common-react/@dbeaver/react-data-grid/src/useDataGridSearch.ts rename webapp/{packages/plugin-codemirror6 => common-react/@dbeaver/ui-kit}/src/SearchPanel/SearchPanel.css (52%) create mode 100644 webapp/common-react/@dbeaver/ui-kit/src/SearchPanel/SearchPanel.tsx create mode 100644 webapp/common-typescript/@dbeaver/js-helpers/src/eventContext.ts create mode 100644 webapp/common-typescript/@dbeaver/js-helpers/src/eventStopPropagationFlag.ts create mode 100644 webapp/packages/core-theming/src/styles/UISearch.css delete mode 100644 webapp/packages/plugin-codemirror6/src/SearchPanel/SearchPanel.tsx create mode 100644 webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useSearchResultsCache.ts create mode 100644 webapp/packages/storybook/src/stories/components/SearchPanel/SearchPanel.stories.tsx 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"