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 <viktor.kirst@dbeaver.com>
Co-authored-by: sergeyteleshev <iamsergeyteleshev@gmail.com>
This commit is contained in:
Sychev Andrey
2026-03-17 17:28:02 +01:00
committed by GitHub
co-authored by mr-anton-t Evgenia Viktor Kirst sergeyteleshev
parent 2abdbce8ff
commit 296e03ec74
48 changed files with 2111 additions and 459 deletions
@@ -8,6 +8,7 @@
"./*": "./lib/*.js"
},
"scripts": {
"build": "tsc -b",
"clear": "rimraf lib"
},
"files": [
@@ -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<TRow, TSummaryRow>(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<HTMLDivElement>) {
props.onCellClick?.(
@@ -85,10 +87,11 @@ export const BaseCell = memo(function BaseCell<TRow, TSummaryRow>(props: CellRen
const renderDefaultCell = useMemo<IDataGridCellRenderer>(
() =>
({ onClick, onDoubleClick, onContextMenu, ...rest }) => (
({ onClick, onDoubleClick, onContextMenu, className, ...rest }) => (
<Cell
{...props}
title={tooltip}
className={clsx(cellClassName, className)}
onCellClick={(_, event) => onClick?.(event)}
onCellDoubleClick={(_, event) => onDoubleClick?.(event)}
onCellContextMenu={(_, event) => onContextMenu?.(event)}
@@ -96,11 +99,15 @@ export const BaseCell = memo(function BaseCell<TRow, TSummaryRow>(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<IDataGridCellInnerContext>(() => ({ isFocused: props.isCellSelected }), [props.isCellSelected]);
return <DataGridCellInnerContext value={innerCellContext}>{cellElement ?? <Cell title={tooltip} {...props} />}</DataGridCellInnerContext>;
return (
<DataGridCellInnerContext value={innerCellContext}>
{cellElement ?? <Cell title={tooltip} className={cellClassName} {...props} />}
</DataGridCellInnerContext>
);
}) as <TRow, TSummaryRow>(props: CellRendererProps<TRow, TSummaryRow>) => React.ReactNode;
@@ -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;
@@ -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<ICellPosition>) => void;
openEditor: (position: ICellPosition) => void;
restoreFocus: () => void;
getColumnsOrdered: () => readonly CalculatedColumn<IInnerRow, unknown>[];
openSearch: () => void;
closeSearch: () => void;
refreshSearch: () => void;
}
const MAX_AUTO_SIZE_WIDTH = 350;
@@ -92,11 +104,13 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(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<DataGridRef, DataGridProps>(function DataGrid
const rowsCount = useGridReactiveValue(rowCount);
const columnsCount = useGridReactiveValue(columnCount);
const rowsCountRef = useRef(rowsCount);
const [prevRowsCount, setPrevRowsCount] = useState(rowsCount);
const innerGridRef = useRef<DataGridHandle<IInnerRow, unknown>>(null);
const containerRef = useRef<HTMLDivElement>(null);
const deferredSelectRef = useRef<ReturnType<typeof setTimeout> | 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<ColumnOrColumnGroup<IInnerRow, unknown>>(columnsCount)
.fill(null as any)
.map((_, i): ColumnOrColumnGroup<IInnerRow, unknown> => {
@@ -141,15 +173,37 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(function DataGrid
return null;
}
function restoreFocusInternal() {
const focusSink = containerRef.current?.querySelector<HTMLDivElement>('[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<ICellPosition>) => {
innerGridRef.current?.scrollToCell({ idx: position.colIdx && dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx });
@@ -168,14 +222,17 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(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<string, ColumnWidth>());
}
}
@@ -189,6 +246,9 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(function DataGrid
);
function handleCellFocus(args: CellSelectArgs<IInnerRow, unknown>) {
if (isReplacingRef.current) {
return;
}
onFocus?.({ colIdx: dndHeaderContext.getDataColIdxByKey(args.column.key), rowIdx: args.rowIdx });
}
@@ -214,44 +274,59 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(function DataGrid
}
return (
<HeaderDnDContext value={dndHeaderContext}>
<DataGridRowContext value={{ rowElement, rowCount, onScrollToBottom }}>
<DataGridCellContext value={{ cell, cellText, cellElement, cellTooltip, onCellChange }}>
<DataGridCellHeaderContext
value={{
headerElement,
headerText,
getHeaderDnD,
columnSortable,
onColumnSort,
columnSortingState,
onHeaderKeyDown,
columnSortingMultiple,
}}
>
<DataGridBase
ref={innerGridRef}
columns={dndHeaderContext.columns}
rows={rows}
className={className}
headerRowHeight={getHeaderHeight?.()}
rowHeight={getRowHeight ? row => 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,
<div ref={containerRef} className="rdg-search-container">
<HeaderDnDContext value={dndHeaderContext}>
<DataGridRowContext value={{ rowElement, rowCount, onScrollToBottom }}>
<DataGridCellContext value={{ cell, cellText, cellElement, cellTooltip, getCellClassName: searchCellClassName, onCellChange }}>
<DataGridCellHeaderContext
value={{
headerElement,
headerText,
getHeaderDnD,
columnSortable,
onColumnSort,
columnSortingState,
onHeaderKeyDown,
columnSortingMultiple,
}}
onScroll={onScroll}
onSelectedCellChange={handleCellFocus}
onCellKeyDown={handleCellKeyDown}
onColumnWidthsChange={setColumnWidths}
/>
</DataGridCellHeaderContext>
</DataGridCellContext>
</DataGridRowContext>
</HeaderDnDContext>
>
<DataGridBase
ref={innerGridRef}
columns={dndHeaderContext.columns}
rows={rows}
className={className}
headerRowHeight={getHeaderHeight?.()}
rowHeight={getRowHeight ? row => 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}
/>
<Activity mode={searchEnabled && searchOpen ? 'visible' : 'hidden'}>
<GridSearchPanel
ref={setSearchPanelRef}
columnCount={columnsCount}
isReadOnly={searchReadOnly}
scrollToCell={scrollToCell}
storage={searchStorage}
open={searchOpen}
onReplace={onReplace}
onClose={handleSearchClose}
onReplacingChange={handleReplacingChange}
/>
</Activity>
</DataGridCellHeaderContext>
</DataGridCellContext>
</DataGridRowContext>
</HeaderDnDContext>
</div>
);
});
@@ -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<React.HTMLAt
isFocused: boolean;
}
export interface IDataGridCellRenderer {
export interface IDataGridCellRenderer {
(propsOverride: Partial<IDataGridCellProps> & { [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<React.ReactNode, [rowIdx: number, colIdx: number]>;
cellText?: IGridReactiveValue<string, [rowIdx: number, colIdx: number]>;
cellTooltip?: IGridReactiveValue<string, [rowIdx: number, colIdx: number]>;
getCellClassName?: IGridReactiveValue<string | undefined, [rowIdx: number, colIdx: number]>;
getCellEditable?: (rowIdx: number, colIdx: number) => boolean;
onCellChange?: (rowIdx: number, colIdx: number, value: any) => void;
onCellChangeBatch?: (changes: ICellChange[]) => void;
}
export const DataGridCellContext = createContext<IDataGridCellContext | null>(null);
@@ -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';
@@ -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 };
}
@@ -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<string | undefined, [number, number]>;
}
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<GridSearchPanelRef, GridSearchPanelProps>(function GridSearchPanel(
{ columnCount, scrollToCell, onReplace, onClose, onReplacingChange, isReadOnly, storage, open },
ref,
) {
const panelRef = useRef<SearchPanelRef>(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 (
<SearchPanel
ref={panelRef}
className={clsx('rdg-search-panel tw:bg-(--theme-secondary)', isReadOnly && 'tw:px-0!')}
isReadOnly={isReadOnly}
query={{
search: snapshot.query,
replace: snapshot.replace,
caseSensitive: snapshot.caseSensitive,
wholeWord: snapshot.wholeWord,
regexp: snapshot.regexp,
}}
defaultShowReplace={replaceOpen}
searchMatchesCount={{
count: snapshot.matchCount,
current: snapshot.activeMatchIndex >= 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}
/>
);
});
@@ -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}`;
}
@@ -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';
@@ -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 };
}
}
}
@@ -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<IGridSearchStorageState>): 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<string, [rowIdx: number, colIdx: number]>;
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<string | undefined, [number, number]>;
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<GridSearchQueryState, 'replace'>, 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<IGridSearchActions>(() => {
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<IGridReactiveValue<string | undefined, [number, number]>>(() => ({
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<IGridSearchSnapshot>(
() => ({
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 };
}
@@ -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<string, [number, number]> | undefined, rowCount: number, columnCount: number) => void;
clearTrackedCellSubscriptions: () => void;
}
export function useTrackedGridSearchCellSubscriptions({
debounceMs,
onTrackedCellChange,
}: IUseTrackedGridSearchCellSubscriptionsOptions): IUseTrackedGridSearchCellSubscriptionsResult {
const trackedCellUnsubscribesRef = useRef<Array<() => void>>([]);
const searchRerunTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const subscriptionSourceRef = useRef<IGridReactiveValue<string, [number, number]> | 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<string, [number, number]> | 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,
};
}
@@ -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<HTMLDivElement | null>;
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<string | undefined, [number, number]> | undefined;
searchPanelRef: React.RefObject<GridSearchPanelRef | null>;
setSearchPanelRef: (instance: GridSearchPanelRef | null) => void;
isReplacingRef: React.RefObject<boolean>;
handleSearchOpen: () => void;
handleSearchClose: () => void;
onReplace: (updates: { rowIdx: number; colIdx: number; value: string }[]) => void;
handleReplacingChange: (value: boolean) => void;
} {
const searchPanelRef = useRef<GridSearchPanelRef | null>(null);
const isReplacingRef = useRef<boolean>(false);
const [searchOpen, setSearchOpen] = useState(() => searchStorage?.get()?.open ?? false);
const [searchCellClassName, setSearchCellClassName] = useState<IGridReactiveValue<string | undefined, [number, number]>>();
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<HTMLDivElement>('[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,
};
}
@@ -372,4 +372,11 @@
fill="#3387C5" />
</symbol>
<!-- COLOR PICKER ICONS -->
<!-- Chevron -->
<symbol id="icon-chevron_sm" viewBox="0 0 16 16"
fill="currentColor">
<path
d="M8.245 10.66L4 6.4155L5.41567 5.001L8.245 7.831L11.0743 5L12.49 6.4155L8.245 10.66Z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@@ -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'
@@ -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);
}
@@ -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<SearchPanelRef, SearchPanelProps>(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<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
}),
[],
);
function handleToggleReplace() {
setShowReplace(prevShowReplace => {
const next = !prevShowReplace;
onReplaceToggle?.(next);
return next;
});
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Escape') {
onClose();
}
}
function handleKeyDownCapture(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Escape') {
EventContext.set(event.nativeEvent, EventStopPropagationFlag);
}
}
function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
if (event.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
}
}
function handleReplaceKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
if (event.shiftKey) {
onReplaceAll?.();
} else {
onReplaceNext?.();
}
}
}
return (
<div className={clsx('search-panel', className)} onKeyDownCapture={handleKeyDownCapture} onKeyDown={handleKeyDown}>
{!isReadOnly && enableReplace && (
<IconButton
className={clsx(showReplace ? 'tw:h-[56px]!' : 'tw:h-[26px]!')}
size="small"
aria-label={translate('ui_search_panel_replace_toggle', 'Toggle replace')}
type="button"
title={translate('ui_search_panel_replace_toggle', 'Toggle replace')}
onClick={handleToggleReplace}
>
<Icon className={clsx(showReplace && 'tw:rotate-180')} size="small" name="chevron" />
</IconButton>
)}
<div className="search-panel__inputs">
<div className="search-panel__input">
<Input
ref={inputRef}
value={query.search}
placeholder={translate('ui_search_panel_input_placeholder', 'Find')}
main-field="true"
onKeyDown={handleInputKeyDown}
onChange={event => onQueryChange(event.target.value)}
/>
<div className="tw:pr-1 tw:flex tw:gap-1">
{enableCaseSensitive && (
<IconButton
variant={query.caseSensitive ? 'primary' : 'secondary'}
size="small"
type="button"
aria-label={translate('ui_search_panel_case_sensitive', 'Match case')}
title={translate('ui_search_panel_case_sensitive', 'Match case')}
className="tw:text-sm!"
onClick={onCaseSensitiveToggle}
>
<Icon name="case" />
</IconButton>
)}
{enableWholeWord && (
<IconButton
variant={query.wholeWord ? 'primary' : 'secondary'}
size="small"
type="button"
aria-label={translate('ui_search_panel_whole_word', 'Match whole word')}
title={translate('ui_search_panel_whole_word', 'Match whole word')}
className="tw:text-sm!"
onClick={onWholeWordToggle}
>
<Icon name="match-word" />
</IconButton>
)}
{enableRegex && (
<IconButton
variant={query.regexp ? 'primary' : 'secondary'}
size="small"
type="button"
aria-label={translate('ui_search_panel_literal', 'Use regex')}
title={translate('ui_search_panel_literal', 'Use regex')}
className="tw:text-sm!"
onClick={onRegexToggle}
>
<Icon name="regex" />
</IconButton>
)}
</div>
</div>
{showReplace && (
<div className="search-panel__replace-input">
<Input
value={query.replace}
placeholder={translate('ui_search_panel_replace', 'Replace')}
onKeyDown={handleReplaceKeyDown}
onChange={event => onReplaceChange?.(event.target.value)}
/>
</div>
)}
</div>
<div className="search-panel__buttons">
<div className="search-panel__row">
{query.search && searchMatchesCount && (
<span className="search-panel__matches">
{searchMatchesCount.count > 0
? `${searchMatchesCount.current} ${translate('ui_search_panel_matches_of', 'of')} ${searchMatchesCount.count}`
: translate('ui_search_panel_matches_none', 'No matches')}
</span>
)}
<IconButton
size="small"
type="button"
aria-label={translate('ui_search_panel_find_previous', 'Find previous')}
title={translate('ui_search_panel_find_previous', 'Find previous')}
className="search-panel__find"
onClick={onFindPrevious}
>
<Icon name="arrow-up" />
</IconButton>
<IconButton
size="small"
aria-label={translate('ui_search_panel_find_next', 'Find next')}
type="button"
title={translate('ui_search_panel_find_next', 'Find next')}
className="search-panel__find"
onClick={onFindNext}
>
<Icon name="arrow-down" />
</IconButton>
<IconButton
variant="secondary"
size="small"
aria-label={translate('ui_search_panel_close', 'Close')}
type="button"
title={translate('ui_search_panel_close', 'Close')}
onClick={onClose}
>
<Icon name="cross" size="medium" className="tw:h-[16px]" />
</IconButton>
</div>
{showReplace && (
<div className="search-panel__row">
<IconButton
size="small"
type="button"
aria-label={translate('ui_search_panel_replace', 'Replace')}
title={translate('ui_search_panel_replace', 'Replace')}
onClick={onReplaceNext}
>
<Icon name="replace" />
</IconButton>
<IconButton
size="small"
type="button"
aria-label={translate('ui_search_panel_replace_all', 'Replace all')}
title={translate('ui_search_panel_replace_all', 'Replace all')}
onClick={onReplaceAll}
>
<Icon name="replace-all" />
</IconButton>
</div>
)}
</div>
</div>
);
});
@@ -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,
@@ -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<T> {
name: string;
[typeSaver]?: T;
}
interface IEventMark<T> {
flag: IEventFlag<T>;
context: T;
}
interface IEventWithContext {
[eventContext]: Array<IEventMark<any>>;
}
interface IEventContext {
set: <TEvent, TContext = void>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>, context?: TContext) => IEventContext;
has: <TEvent>(event: TEvent | IEventWithContext, ...flags: Array<IEventFlag<any>>) => boolean;
get: <TEvent, TContext>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>) => TContext | undefined;
create: <T = void>(name: string) => IEventFlag<T>;
}
export const EventContext: IEventContext = {
set<TEvent, TContext = void>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>, 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<TEvent>(event: TEvent | IEventWithContext, ...flags: Array<IEventFlag<any>>) {
event = addContextToEvent(event);
return flags.some(flag => (event as IEventWithContext)[eventContext].some(mark => mark.flag === flag));
},
get<TEvent, TContext>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>) {
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;
}
@@ -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');
@@ -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';
+1
View File
@@ -29,6 +29,7 @@
"@cloudbeaver/core-sdk": "workspace:*",
"@cloudbeaver/core-settings": "workspace:*",
"@cloudbeaver/core-utils": "workspace:*",
"@dbeaver/js-helpers": "workspace:*",
"mobx": "^6",
"tslib": "^2"
},
@@ -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<T> {
name: string;
[typeSaver]?: T;
}
interface IEventMark<T> {
flag: IEventFlag<T>;
context: T;
}
interface IEventWithContext {
[eventContext]: Array<IEventMark<any>>;
}
interface IEventContext {
set: <TEvent, TContext = void>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>, context?: TContext) => IEventContext;
has: <TEvent>(event: TEvent | IEventWithContext, ...flags: Array<IEventFlag<any>>) => boolean;
get: <TEvent, TContext>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>) => TContext | undefined;
create: <T = void>(name: string) => IEventFlag<T>;
}
export const EventContext: IEventContext = {
set<TEvent, TContext = void>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>, 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<TEvent>(event: TEvent | IEventWithContext, ...flags: Array<IEventFlag<any>>) {
event = addContextToEvent(event);
return flags.some(flag => (event as IEventWithContext)[eventContext].some(mark => mark.flag === flag));
},
get<TEvent, TContext>(event: TEvent | IEventWithContext, flag: IEventFlag<TContext>) {
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';
@@ -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';
@@ -13,6 +13,9 @@
{
"path": "../../common-typescript/@dbeaver/cli"
},
{
"path": "../../common-typescript/@dbeaver/js-helpers"
},
{
"path": "../core-cli"
},
@@ -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';
@@ -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);
}
}
@@ -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<Props> = observer(function Rea
return createPortal(
<SearchPanel
isReadOnly={view.state.readOnly}
ref={searchPanelRef}
queryState={queryState}
isReadOnly={view.state.readOnly}
query={queryState}
searchMatchesCount={searchMatchesCount}
onQueryChange={handleQueryChange}
onCaseSensitiveToggle={handleCaseSensitiveToggle}
@@ -1,243 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 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 { SearchQuery } from '@codemirror/search';
import { observer } from 'mobx-react-lite';
import { useImperativeHandle, useRef, useState } from 'react';
import { Icon, useTranslate } from '@cloudbeaver/core-blocks';
import { clsx, IconButton, Input, Icon as UIKitIcon } from '@dbeaver/ui-kit';
import './SearchPanel.css';
export interface SearchPanelRef {
focus: () => void;
}
interface SearchPanelProps {
isReadOnly: boolean;
searchMatchesCount?: { count: number; current: number };
queryState: SearchQuery;
ref: React.RefObject<SearchPanelRef | null>;
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<HTMLInputElement>(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<HTMLInputElement>) {
if (event.key === 'Enter') {
if (event.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
}
}
function handleReplaceKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
if (event.shiftKey) {
onReplaceAll();
} else {
onReplaceNext();
}
}
}
return (
<div className="search-panel" onKeyDown={handleKeyDown}>
{!isReadOnly && (
<IconButton
className={clsx(showReplace ? 'tw:h-[56px]!' : 'tw:h-[26px]!')}
size="small"
aria-label={translate('plugin_codemirror_search_replace_toggle')}
type="button"
title={translate('plugin_codemirror_search_replace_toggle')}
onClick={handleToggleReplace}
>
<Icon className={clsx(showReplace && 'tw:rotate-180')} width={16} height={16} viewBox="0 0 16 16" name="arrow" />
</IconButton>
)}
<div className="search-panel__inputs">
<div className="search-panel__input">
<Input
ref={inputRef}
value={queryState.search}
placeholder={translate('plugin_codemirror_search_input_placeholder')}
main-field="true"
onKeyDown={handleInputKeyDown}
onChange={event => onQueryChange(event.target.value)}
/>
<div className="tw:absolute tw:top-1/2 tw:-translate-y-1/2 tw:right-2 tw:flex tw:gap-1">
<IconButton
variant={queryState.caseSensitive ? 'primary' : 'secondary'}
size="small"
type="button"
aria-label={translate('plugin_codemirror_search_case_sensitive')}
title={translate('plugin_codemirror_search_case_sensitive')}
className="tw:text-sm!"
onClick={onCaseSensitiveToggle}
>
<UIKitIcon name="case" />
</IconButton>
<IconButton
variant={queryState.wholeWord ? 'primary' : 'secondary'}
size="small"
type="button"
aria-label={translate('plugin_codemirror_search_whole_word')}
title={translate('plugin_codemirror_search_whole_word')}
className="tw:text-sm!"
onClick={onWholeWordToggle}
>
<UIKitIcon name="match-word" />
</IconButton>
<IconButton
variant={queryState.regexp ? 'primary' : 'secondary'}
size="small"
type="button"
aria-label={translate('plugin_codemirror_search_literal')}
title={translate('plugin_codemirror_search_literal')}
className="tw:text-sm!"
onClick={onRegexToggle}
>
<UIKitIcon name="regex" />
</IconButton>
</div>
</div>
{showReplace && (
<div className="search-panel__replace-input">
<Input
value={queryState.replace}
placeholder={translate('plugin_codemirror_search_replace')}
onKeyDown={handleReplaceKeyDown}
onChange={event => onReplaceChange(event.target.value)}
/>
</div>
)}
</div>
<div className="search-panel__buttons">
<div className="search-panel__row">
{queryState.search && searchMatchesCount && (
<span className="search-panel__matches">
{searchMatchesCount.count > 0
? `${searchMatchesCount.current} ${translate('plugin_codemirror_search_matches_of')} ${searchMatchesCount.count}`
: translate('plugin_codemirror_search_matches_none')}
</span>
)}
<IconButton
size="small"
type="button"
aria-label={translate('plugin_codemirror_search_find_previous')}
title={translate('plugin_codemirror_search_find_previous')}
className="search-panel__find"
onClick={onFindPrevious}
>
<UIKitIcon name="arrow-up" />
</IconButton>
<IconButton
size="small"
aria-label={translate('plugin_codemirror_search_find_next')}
type="button"
title={translate('plugin_codemirror_search_find_next')}
className="search-panel__find"
onClick={onFindNext}
>
<UIKitIcon name="arrow-down" />
</IconButton>
<IconButton
variant="secondary"
size="small"
aria-label={translate('plugin_codemirror_search_close')}
type="button"
title={translate('plugin_codemirror_search_close')}
onClick={onClose}
>
<UIKitIcon name="cross" size="medium" className="tw:h-[16px]" />
</IconButton>
</div>
{showReplace && (
<div className="search-panel__row">
<IconButton
size="small"
type="button"
aria-label={translate('plugin_codemirror_search_replace')}
title={translate('plugin_codemirror_search_replace')}
onClick={onReplaceNext}
>
<UIKitIcon name="replace" />
</IconButton>
<IconButton
size="small"
type="button"
aria-label={translate('plugin_codemirror_search_replace_all')}
title={translate('plugin_codemirror_search_replace_all')}
onClick={onReplaceAll}
>
<UIKitIcon name="replace-all" />
</IconButton>
</div>
)}
</div>
</div>
);
});
@@ -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';
@@ -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<IDataPresentationProps>(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<ResultSetDataSource>, 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<HTMLDivElement>('[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<IDataPresentationProps>(function DataGridT
dataGridRef.current?.selectCell(pos);
}
},
focusCell(key: Partial<IGridDataKey> | null, initial = false) {
focusCell(key: Partial<IGridDataKey> | null, initial = false, deferred = false) {
if ((!key?.column || !key?.row) && initial) {
const selectedElements = selectionAction.getSelectedElements();
@@ -150,9 +148,14 @@ export const DataGridTable = observer<IDataPresentationProps>(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<IDataPresentationProps>(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<IDataPresentationProps>(function DataGridT
function syncFocus(data: DatabaseDataSelectActionsData<Partial<IGridDataKey>>) {
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<IDataPresentationProps>(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<unknown>) {
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<IDataPresentationProps>(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<IDataPresentationProps>(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<IDataPresentationProps>(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}
/>
@@ -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<IGridSearchStorage>(
() => ({
get(): IGridSearchStorageState | undefined {
return cacheAction.getShared<IGridSearchStorageState>(SEARCH_STORAGE_KEY);
},
set(state: IGridSearchStorageState): void {
cacheAction.setShared(SEARCH_STORAGE_KEY, state);
},
update(partial: Partial<IGridSearchStorageState>): void {
const current = cacheAction.getShared<IGridSearchStorageState>(SEARCH_STORAGE_KEY);
if (current) {
cacheAction.setShared(SEARCH_STORAGE_KEY, { ...current, ...partial });
}
},
}),
[cacheAction],
);
}
@@ -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<any, TResult>
implements IDatabaseDataEditAction<TKey, TValue, TUpdate, TResult>
{
implements IDatabaseDataEditAction<TKey, TValue, TUpdate, TResult> {
static dataFormat: ResultDataFormat[] | null = null;
readonly action: ISyncExecutor<IDatabaseDataEditActionData<TKey, TValue>>;
@@ -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;
@@ -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');
}
@@ -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<string>();
@@ -36,7 +36,13 @@ export class GridEditHistoryManager<TKey extends IGridDataKey, TCell> {
}
recordCellEdit(data: IGridHistoryCellUpdateData<TKey, TCell>): 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<TKey extends IGridDataKey, TCell> {
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<TKey, TCell>(entry) && GridDataKeysUtils.isElementsKeyEqual(entry.data.key, key),
entry =>
isGridHistoryEditCellData<TKey, TCell>(entry) &&
entry.data.updates.length === 1 &&
GridDataKeysUtils.isElementsKeyEqual(entry.data.updates[0]!.key, key),
entries => {
const firstEntry = entries[0]! as IHistoryEntry<IGridHistoryCellUpdateData<TKey, TCell>>;
const lastEntry = entries[entries.length - 1]! as IHistoryEntry<IGridHistoryCellUpdateData<TKey, TCell>>;
@@ -97,9 +112,13 @@ export class GridEditHistoryManager<TKey extends IGridDataKey, TCell> {
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,
},
],
},
};
},
@@ -29,12 +29,7 @@ function createUndoHandlers<TKey extends IGridDataKey, TCell>(): Record<string,
return {
[GRID_HISTORY_SOURCE.EDIT_CELL]: (entry, ops) => {
if (isGridHistoryEditCellData<TKey, TCell>(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<TKey extends IGridDataKey, TCell>(): Record<string,
return {
[GRID_HISTORY_SOURCE.EDIT_CELL]: (entry, ops) => {
if (isGridHistoryEditCellData<TKey, TCell>(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) => {
@@ -20,12 +20,16 @@ export interface IGridHistoryRow<TKey extends IGridDataKey = IGridDataKey, TCell
value: TCell[];
}
export interface IGridHistoryCellUpdateData<TKey extends IGridDataKey = IGridDataKey, TCell = unknown> {
export interface IGridHistoryCellUpdate<TKey extends IGridDataKey = IGridDataKey, TCell = unknown> {
key: TKey;
prevValue: TCell;
value: TCell;
}
export interface IGridHistoryCellUpdateData<TKey extends IGridDataKey = IGridDataKey, TCell = unknown> {
updates: Array<IGridHistoryCellUpdate<TKey, TCell>>;
}
export interface IGridHistoryRowUpdate<TKey extends IGridDataKey = IGridDataKey, TCell = unknown> extends IGridHistoryRow<TKey, TCell> {
prevValue: TCell[];
}
@@ -73,3 +77,31 @@ export function isGridHistoryRevertData<TKey extends IGridDataKey = IGridDataKey
): entry is IHistoryEntry<IGridHistoryRevertData<TKey, TCell>> {
return entry.source === GRID_HISTORY_SOURCE.REVERT;
}
export function getKeyFromHistoryEntry(entry: IHistoryEntry<unknown>): 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;
}
@@ -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<TKey = unknown, TResult extends IDatabaseDataResult = IDatabaseDataResult>
extends IDatabaseDataAction<any, TResult> {
export interface IDatabaseDataCacheAction<TKey = unknown, TResult extends IDatabaseDataResult = IDatabaseDataResult> extends IDatabaseDataAction<
any,
TResult
> {
has(key: TKey, scope: symbol): boolean;
get<T>(key: TKey, scope: symbol): T | undefined;
set<T>(key: TKey, scope: symbol, value: T): void;
delete(key: TKey, scope: symbol): void;
deleteAll(scope: symbol): void;
hasShared(scope: symbol): boolean;
getShared<T>(scope: symbol): T | undefined;
setShared<T>(scope: symbol, value: T): void;
deleteShared(scope: symbol): void;
}
export const IDatabaseDataCacheAction = createService<IDatabaseDataCacheAction>('IDatabaseDataCacheAction');
@@ -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;
@@ -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<any, IDatabaseResultSet>
@@ -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<T>(scope: symbol): T | undefined {
const sharedCache = this.getSharedCache();
if (!sharedCache) {
return;
}
return sharedCache.get(scope);
}
setShared<T>(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;
}
}
@@ -57,12 +57,20 @@ export class ResultSetEditAction extends GridEditAction<SqlResultColumn, SqlResu
}
override set(key: IGridDataKey, value: IResultSetValue): void {
super.set(key, this.transformValue(key, value));
}
override setMany(updates: Array<{ key: IGridDataKey; value: IResultSetValue }>): 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<SqlResultColumn, SqlResu
}
}
super.set(key, value);
return value;
}
getBlobsToUpload(): Array<IResultSetBlobValue> {
@@ -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';
@@ -38,7 +38,7 @@ export const Table = observer<TableProps>(function Table({ objects, hasNextPage,
const tableState = useTable();
const tabLocalState = useTabLocalState<IScrollState>(() => ({ scrollTop: 0, scrollLeft: 0 }));
const scrollBox = (tableContainer?.firstChild as HTMLDivElement | undefined) ?? null;
const scrollBox = tableContainer?.querySelector<HTMLDivElement>('[role="grid"]') ?? null;
useControlledScroll(scrollBox, tabLocalState);
const baseObject = objects.slice().sort((a, b) => (b.object?.properties?.length || 0) - (a.object?.properties?.length || 0));
@@ -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);
@@ -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<SearchPanelQuery>(initial);
const [matches, setMatches] = useState({ count: 5, current: 1 });
function updateQuery(next: Partial<SearchPanelQuery>) {
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<SearchPanelRef>(null);
return (
<div className="tw:relative">
<div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: icons }} />
<SearchPanel
ref={ref}
query={query}
searchMatchesCount={matches}
isReadOnly
onQueryChange={value => 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: '' })}
/>
</div>
);
};
export const SearchWithReplace = () => {
const { query, matches, updateQuery, setMatches } = useMockSearch({
search: 'value',
replace: 'new',
caseSensitive: false,
wholeWord: false,
regexp: false,
});
const ref = useRef<SearchPanelRef>(null);
return (
<div className="tw:bg-gray-700/10 tw:rounded">
<div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: icons }} />
<SearchPanel
ref={ref}
query={query}
searchMatchesCount={matches}
onQueryChange={value => 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: '' })}
/>
</div>
);
};
+1
View File
@@ -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"