-
Notifications
You must be signed in to change notification settings - Fork 508
[CB] Improve undo/redo behavior for Data Editor #4060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: devel
Are you sure you want to change the base?
[CB] Improve undo/redo behavior for Data Editor #4060
Conversation
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts
Outdated
Show resolved
Hide resolved
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts
Outdated
Show resolved
Hide resolved
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts
Outdated
Show resolved
Hide resolved
| if (isSameKey) { | ||
| this.history.update(lastIndex, { | ||
| data: { | ||
| key, | ||
| value, | ||
| prevValue, | ||
| }, | ||
| }); | ||
|
|
||
| return; | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryAction.ts
Outdated
Show resolved
Hide resolved
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryAction.ts
Outdated
Show resolved
Hide resolved
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridEditAction.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this 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.
...ges/plugin-data-viewer/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService.ts
Outdated
Show resolved
Hide resolved
...ges/plugin-data-viewer/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService.ts
Outdated
Show resolved
Hide resolved
webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridHistoryAction.ts
Outdated
Show resolved
Hide resolved
|
|
||
| export const ACTION_REDO = createAction('redo', { | ||
| label: 'ui_redo', | ||
| tooltip: 'ui_redo', |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same here
| export interface IEditorDataAccess<TCell> { | ||
| getUpdate(row: IGridRowKey): { update: TCell[] } | undefined; | ||
| getRowValue(row: IGridRowKey): TCell[] | undefined; | ||
| } |
There was a problem hiding this comment.
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?
| 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; | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
| if (isGridHistoryDeleteRowData<TKey, TCell>(entry)) { | ||
| ops.revert([entry.data.key]); | ||
| } |
There was a problem hiding this comment.
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
| 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 { |
There was a problem hiding this comment.
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
| [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); | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
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
| 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[] }>; | ||
| } |
There was a problem hiding this comment.
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
…this history" This reverts commit 5f1799f.
…th deleting added row
| applyPartialUpdate: action, | ||
| this.historyManager = new GridEditHistoryManager<TKey, TCell>(history as GridHistoryAction<IGridHistoryData<TKey, TCell>, TResult>); | ||
|
|
||
| makeObservable<this, 'editorData' | 'setCellsInternal' | 'setRowsInternal' | 'addRowsInternal' | 'deleteRowsInternal' | 'removeEditsInternal'>( |
There was a problem hiding this comment.
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]!; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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]!; |
There was a problem hiding this comment.
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')!;
}
There was a problem hiding this comment.
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
closes dbeaver/pro#7614