Skip to content

Conversation

@sergeyteleshev
Copy link
Contributor

closes dbeaver/pro#7614

Comment on lines 553 to 563
if (isSameKey) {
this.history.update(lastIndex, {
data: {
key,
value,
prevValue,
},
});

return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure about this, because you might change same cell few times with a delay and when you will revert it you will expect that it will revert to previous edit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I compress everything to 1 history item unless we are currently editing the cell. If we're editing the cell right now, it remembers each typed letter. Then, if we type something in another cell, it compresses all of the history for previous cells to 1 item by cell

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements comprehensive undo/redo functionality for the Data Editor in CloudBeaver. The implementation introduces a history tracking system that records all editing operations (cell edits, row additions/deletions, duplications, reverts, and cancellations) and allows users to undo and redo these operations using keyboard shortcuts (Ctrl+Z for undo, Ctrl+Shift+Z or Ctrl+Y for redo).

Changes:

  • Introduced a new history management system with GridHistoryAction, GridHistoryTypes, GridHistoryHandlers, and GridEditHistoryManager classes
  • Integrated history tracking into GridEditAction and ResultSetEditAction to record all editing operations
  • Added keyboard bindings and UI integration for undo/redo actions in the data viewer

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
webapp/packages/plugin-help/src/Shortcuts/SHORTCUTS_DATA.ts Added undo/redo shortcuts to the help documentation
webapp/packages/plugin-data-viewer/src/module.ts Registered GridHistoryAction in the dependency injection container
webapp/packages/plugin-data-viewer/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService.ts Added undo/redo key bindings, action handlers, and menu integration
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetEditAction.ts Integrated GridHistoryAction into ResultSetEditAction constructor
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryTypes.ts Defined type structures for different history entry types and type guard functions
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryHandlers.ts Implemented undo/redo handlers for each operation type
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryAction.ts Core history action class with undo/redo stack management and compression support
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditHistoryManager.ts Manager class that coordinates history recording with editor operations
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts Integrated history tracking into all editing operations and added internal methods for undo/redo
webapp/packages/plugin-data-viewer/src/DATA_VIEWER_KEY_BINDINGS.ts Defined key binding configurations for data viewer undo/redo
webapp/packages/core-utils/src/isNumber.ts Improved type guard to enable proper type narrowing

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@sergeyteleshev sergeyteleshev marked this pull request as ready for review January 21, 2026 17:47

export const ACTION_REDO = createAction('redo', {
label: 'ui_redo',
tooltip: 'ui_redo',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i thought that we decided to not duplicate same text in the tooltip?


export const ACTION_UNDO = createAction('undo', {
label: 'ui_undo',
tooltip: 'ui_undo',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Comment on lines 20 to 23
export interface IEditorDataAccess<TCell> {
getUpdate(row: IGridRowKey): { update: TCell[] } | undefined;
getRowValue(row: IGridRowKey): TCell[] | undefined;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not pass the required data via arguments?

Comment on lines 164 to 185
private getPrevCellValue(key: TKey, editorAccess: IEditorDataAccess<TCell>): TCell {
const update = editorAccess.getUpdate(key.row);
const currentHistoryEntry = this.history.getCurrentEntry();
const isEditingSameCell =
currentHistoryEntry &&
isGridHistoryEditCellData<TKey, TCell>(currentHistoryEntry) &&
GridDataKeysUtils.isElementsKeyEqual(currentHistoryEntry.data.key, key);
const initialValue = (update as any)?.source?.[key.column.index] as TCell;
const latestHistoryEntry = this.history
.getState()
.findLast(entry => isGridHistoryEditCellData<TKey, TCell>(entry) && GridDataKeysUtils.isElementsKeyEqual(entry.data.key, key));

if (isEditingSameCell && currentHistoryEntry && isGridHistoryEditCellData<TKey, TCell>(currentHistoryEntry)) {
return currentHistoryEntry.data.value;
}

if (latestHistoryEntry && isGridHistoryEditCellData<TKey, TCell>(latestHistoryEntry)) {
return latestHistoryEntry.data.value;
}

return initialValue;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is very complicated it will be better if source of event will pass prev value because it's easier to extract it there

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, getting the previous value based on the history is a bad idea, because history can be shrunk

Comment on lines 43 to 45
if (isGridHistoryDeleteRowData<TKey, TCell>(entry)) {
ops.revert([entry.data.key]);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try to add row, change something somewhere, delete this added row
revert changes

Comment on lines 133 to 138
export function handleUndo<TKey extends IGridDataKey, TCell>(entry: IHistoryEntry<unknown>, operations: IGridEditOperations<TKey, TCell>): void {
const handlers = createUndoHandlers<TKey, TCell>();
handlers[entry.source]?.(entry, operations);
}

export function handleRedo<TKey extends IGridDataKey, TCell>(entry: IHistoryEntry<unknown>, operations: IGridEditOperations<TKey, TCell>): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use more specific naming for these functions to avoid wrong suggestions in the IDE

Comment on lines 47 to 53
[GRID_HISTORY_SOURCE.DUPLICATE_ROW]: (entry, ops) => {
if (isGridHistoryDuplicateRowData<TKey, TCell>(entry)) {
for (const { key } of entry.data.keys) {
ops.deleteRow(key.row, key.column);
}
}
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is probably same as and add event but with batch of rows, please use single add event that support batching data, and probably same for delete

Comment on lines 40 to 50
export interface IGridHistoryRevertData<TKey extends IGridDataKey = IGridDataKey, TCell = unknown> {
updates: Array<{ key: TKey; prevValue: TCell; value: TCell }>;
deletions: Array<{ key: TKey; value?: TCell[] }>;
additions: Array<{ key: TKey; rowValue?: TCell[] }>;
}

export interface IGridHistoryCancelData<TKey extends IGridDataKey = IGridDataKey, TCell = unknown> {
updates: Array<{ key: TKey; prevValue: TCell[]; value: TCell[] }>;
deletions: Array<{ key: TKey; value?: TCell[] }>;
additions: Array<{ key: TKey; rowValue?: TCell[] }>;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be batched (or grouped) history items rather than history items themselves. Consider making it possible to perform "batch" undo/redo

@sergeyteleshev sergeyteleshev requested a review from Wroud January 23, 2026 14:18
applyPartialUpdate: action,
this.historyManager = new GridEditHistoryManager<TKey, TCell>(history as GridHistoryAction<IGridHistoryData<TKey, TCell>, TResult>);

makeObservable<this, 'editorData' | 'setCellsInternal' | 'setRowsInternal' | 'addRowsInternal' | 'deleteRowsInternal' | 'removeEditsInternal'>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably better to remove internal suffix. Its already private and cant be accessed outside of the class

entry => isGridHistoryEditCellData<TKey, TCell>(entry) && GridDataKeysUtils.isElementsKeyEqual(entry.data.key, key),
entries => {
const firstEntry = entries[0]!;
const lastEntry = entries[entries.length - 1]!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check for undefined, because we dont check length in this method and cant be sure

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is garantted by hisory.compress
this callback is called only if we have >= 2 items for compression

}

private mergeEntries(matchingIndices: number[], createCompressedEntry: CompressedEntryFactory<TData>, compressedEntityIndex?: number): void {
let targetIndex = compressedEntityIndex ?? matchingIndices[matchingIndices.length - 1]!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it guaranteed? When we get data from some other place and not in the function itself, its better to always check for undefined as someone might change method that executes this fn and it will throw.

We can do it when we are using data from the same fn

for example

function example() {
   this.set('me', 'happy');
	 return this.get('me')!;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is also garanteed but added check just in case

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants