mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-10-16 14:50:15 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438475a99c | ||
|
|
f967a20edf |
+31
-1
@@ -384,6 +384,20 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data"
|
||||
dependencies:
|
||||
"@dbeaver/cli": "workspace:^"
|
||||
"@dbeaver/tsconfig": "workspace:^"
|
||||
async-mutex: "npm:^0"
|
||||
nanoevents: "npm:^9"
|
||||
rimraf: "npm:^6"
|
||||
typescript: "npm:^5"
|
||||
vitest: "npm:^3"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig, @dbeaver/tsconfig@workspace:^":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig"
|
||||
@@ -1988,6 +2002,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-mutex@npm:^0":
|
||||
version: 0.5.0
|
||||
resolution: "async-mutex@npm:0.5.0"
|
||||
dependencies:
|
||||
tslib: "npm:^2.4.0"
|
||||
checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axe-core@npm:^4.10.2":
|
||||
version: 4.10.3
|
||||
resolution: "axe-core@npm:4.10.3"
|
||||
@@ -5127,6 +5150,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoevents@npm:^9":
|
||||
version: 9.1.0
|
||||
resolution: "nanoevents@npm:9.1.0"
|
||||
checksum: 10c0/5fb48e6fc1d3102daddaeffd6eada907c25b1c8554dcc648e9cb0a72979a1ab3ee56ffa2d2b3e566a8e561a9e2992a3783493c61cfaaa096b2986d56dbbc1ca5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoid@npm:^3.3.8":
|
||||
version: 3.3.11
|
||||
resolution: "nanoid@npm:3.3.11"
|
||||
@@ -6512,7 +6542,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^2, tslib@npm:^2.3.0":
|
||||
"tslib@npm:^2, tslib@npm:^2.3.0, tslib@npm:^2.4.0":
|
||||
version: 2.8.1
|
||||
resolution: "tslib@npm:2.8.1"
|
||||
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# @dbeaver/table-data
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@dbeaver/table-data",
|
||||
"type": "module",
|
||||
"sideEffects": [],
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./*": "./lib/*.js"
|
||||
},
|
||||
"scripts": {
|
||||
"clear": "rimraf lib",
|
||||
"build": "tsc -b",
|
||||
"test": "dbeaver-test"
|
||||
},
|
||||
"files": [
|
||||
"package.json",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"lib",
|
||||
"!lib/**/*.d.ts.map",
|
||||
"!lib/**/*.test.js",
|
||||
"!lib/**/*.test.d.ts",
|
||||
"!lib/**/*.test.d.ts.map",
|
||||
"!lib/**/*.test.js.map",
|
||||
"!lib/tests",
|
||||
"!.tsbuildinfo"
|
||||
],
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"devDependencies": {
|
||||
"@dbeaver/cli": "workspace:^",
|
||||
"@dbeaver/tsconfig": "workspace:^",
|
||||
"rimraf": "^6",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3"
|
||||
},
|
||||
"dependencies": {
|
||||
"async-mutex": "^0",
|
||||
"nanoevents": "^9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ITableDataset } from './interfaces/ITableDataset.js';
|
||||
import type { ITableDatasetManager } from './interfaces/ITableDatasetManager.js';
|
||||
|
||||
export class TableDatasetManager<TColumn, TValue> implements ITableDatasetManager<TColumn, TValue> {
|
||||
get datasets(): ITableDataset<TColumn, TValue>[] {
|
||||
return this.#datasets;
|
||||
}
|
||||
|
||||
#datasets: ITableDataset<TColumn, TValue>[] = [];
|
||||
|
||||
constructor() {
|
||||
this.#datasets = [];
|
||||
}
|
||||
|
||||
setDatasets(datasets: ITableDataset<TColumn, TValue>[]): void {
|
||||
this.#datasets = datasets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Mutex } from 'async-mutex';
|
||||
import type { ITableSource } from './interfaces/ITableSource.js';
|
||||
import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents';
|
||||
import type { TableSourceEvents } from './interfaces/TableSourceEvents.js';
|
||||
import type { ITableDatasetManager } from './interfaces/ITableDatasetManager.js';
|
||||
import type { ITableSourceOptions } from './interfaces/ITableSourceOptions.js';
|
||||
|
||||
export abstract class TableSource<TColumn, TValue, TOptions extends ITableSourceOptions = ITableSourceOptions>
|
||||
implements ITableSource<TColumn, TValue, TOptions>
|
||||
{
|
||||
get isLoading(): boolean {
|
||||
return this.#loadingInProgress;
|
||||
}
|
||||
get isOutdated(): boolean {
|
||||
return this.#outdated;
|
||||
}
|
||||
error: Error | null;
|
||||
options: Partial<TOptions>;
|
||||
|
||||
#outdated: boolean;
|
||||
#loadingInProgress = false;
|
||||
#loadingPending = false;
|
||||
readonly #mutex: Mutex;
|
||||
readonly #emitter: Emitter<TableSourceEvents>;
|
||||
constructor(readonly datasetManager: ITableDatasetManager<TColumn, TValue>) {
|
||||
this.options = {};
|
||||
this.error = null;
|
||||
this.#outdated = false;
|
||||
this.#mutex = new Mutex();
|
||||
this.#emitter = createNanoEvents();
|
||||
}
|
||||
|
||||
setOptions(options: TOptions): void {
|
||||
this.options = options;
|
||||
this.setOutdated();
|
||||
}
|
||||
|
||||
setOutdated(): void {
|
||||
this.setOutdatedValue(true);
|
||||
if (this.#mutex.isLocked()) {
|
||||
this.#mutex.runExclusive(() => {
|
||||
this.setOutdatedValue(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
const release = await this.#mutex.acquire();
|
||||
try {
|
||||
this.setError(null);
|
||||
await this.saveData();
|
||||
this.emit('saved');
|
||||
} catch (error) {
|
||||
this.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
throw this.error;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.#loadingInProgress) {
|
||||
this.#loadingPending = true;
|
||||
return await this.#mutex.waitForUnlock();
|
||||
}
|
||||
this.setLoadingInProgress(true);
|
||||
try {
|
||||
// this waits for any save to finish, then runs loadData
|
||||
await this.#mutex.runExclusive(async () => {
|
||||
do {
|
||||
this.#loadingPending = false;
|
||||
this.setError(null);
|
||||
await this.loadData();
|
||||
this.emit('data');
|
||||
} while (this.#loadingPending);
|
||||
});
|
||||
} catch (error) {
|
||||
this.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
throw this.error;
|
||||
} finally {
|
||||
this.setOutdatedValue(false);
|
||||
this.setLoadingInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
on<TEvent extends keyof TableSourceEvents>(event: TEvent, listener: TableSourceEvents[TEvent]): Unsubscribe {
|
||||
return this.#emitter.on(event, listener);
|
||||
}
|
||||
|
||||
protected setLoadingInProgress(state: boolean): void {
|
||||
this.emit('loading', state);
|
||||
this.#loadingInProgress = state;
|
||||
}
|
||||
|
||||
protected setError(error: Error | null): void {
|
||||
this.error = error;
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
protected setOutdatedValue(state: boolean): void {
|
||||
this.#outdated = state;
|
||||
this.emit('outdated', state);
|
||||
}
|
||||
|
||||
protected emit<TEvent extends keyof TableSourceEvents>(event: TEvent, ...args: Parameters<TableSourceEvents[TEvent]>): void {
|
||||
this.#emitter.emit(event, ...args);
|
||||
}
|
||||
protected abstract saveData(): Promise<void>;
|
||||
protected abstract loadData(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Cell position types
|
||||
export interface ICellPosition {
|
||||
readonly rowIdx: number;
|
||||
readonly colIdx: number;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
import type { ITableEditorHistory } from './ITableEditorHistory.js';
|
||||
import type { TableEditorEventEmitter } from './TableEditorEvents.js';
|
||||
|
||||
// Main table editor interface
|
||||
export interface ITableEditor<TValue> extends TableEditorEventEmitter<TValue> {
|
||||
// Data access
|
||||
readonly data: readonly (readonly TValue[])[];
|
||||
readonly isEdited: boolean;
|
||||
readonly rowCount: number;
|
||||
|
||||
// Cell operations
|
||||
getCellValue(position: ICellPosition): TValue | undefined;
|
||||
setCellValue(position: ICellPosition, value: TValue): void;
|
||||
|
||||
// Row operations
|
||||
insertRow(rowIdx: number, rowData?: TValue[]): void;
|
||||
deleteRow(rowIdx: number): void;
|
||||
|
||||
// Data operations
|
||||
resetData(newData: TValue[][]): void;
|
||||
|
||||
// History operations
|
||||
readonly history: ITableEditorHistory<TValue>;
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ITableEditor } from './ITableEditor.js';
|
||||
|
||||
export interface ITableEditorFactory {
|
||||
create<TValue>(initialData?: TValue[][]): ITableEditor<TValue>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
|
||||
// History entry types
|
||||
export interface ITableEditorHistoryEntry<TValue> {
|
||||
readonly type: 'cell-edit' | 'row-insert' | 'row-delete';
|
||||
readonly timestamp: number;
|
||||
readonly data: ICellEditEntry<TValue> | IRowInsertEntry<TValue> | IRowDeleteEntry<TValue>;
|
||||
}
|
||||
|
||||
export interface ICellEditEntry<TValue> {
|
||||
readonly position: ICellPosition;
|
||||
readonly oldValue: TValue;
|
||||
readonly newValue: TValue;
|
||||
}
|
||||
|
||||
export interface IRowInsertEntry<TValue> {
|
||||
readonly rowIdx: number;
|
||||
readonly rowData: TValue[];
|
||||
}
|
||||
|
||||
export interface IRowDeleteEntry<TValue> {
|
||||
readonly rowIdx: number;
|
||||
readonly rowData: TValue[];
|
||||
}
|
||||
|
||||
// History management interface
|
||||
export interface ITableEditorHistory<TValue> {
|
||||
readonly canUndo: boolean;
|
||||
readonly canRedo: boolean;
|
||||
readonly size: number;
|
||||
readonly maxSize: number;
|
||||
|
||||
push(entry: ITableEditorHistoryEntry<TValue>): void;
|
||||
undo(): ITableEditorHistoryEntry<TValue> | null;
|
||||
redo(): ITableEditorHistoryEntry<TValue> | null;
|
||||
clear(): void;
|
||||
setMaxSize(size: number): void;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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 { describe, expect, test, beforeEach } from 'vitest';
|
||||
|
||||
import { TableEditor } from './TableEditor.js';
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
|
||||
describe('TableEditor', () => {
|
||||
let tableEditor: TableEditor<string>;
|
||||
const initialData = [
|
||||
['A1', 'B1', 'C1'],
|
||||
['A2', 'B2', 'C2'],
|
||||
['A3', 'B3', 'C3'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
tableEditor = new TableEditor(initialData);
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
test('should initialize with provided data', () => {
|
||||
expect(tableEditor.rowCount).toBe(3);
|
||||
expect(tableEditor.data).toEqual(initialData);
|
||||
expect(tableEditor.isEdited).toBe(false);
|
||||
});
|
||||
|
||||
test('should get cell values correctly', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
expect(tableEditor.getCellValue(position)).toBe('B2');
|
||||
});
|
||||
|
||||
test('should return undefined for invalid positions', () => {
|
||||
const invalidPosition: ICellPosition = { rowIdx: 10, colIdx: 10 };
|
||||
expect(tableEditor.getCellValue(invalidPosition)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cell editing', () => {
|
||||
test('should set cell values and track changes', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
let changeCount = 0;
|
||||
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.setCellValue(position, 'NEW_VALUE');
|
||||
|
||||
expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE');
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should not emit events when setting same value', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
let changeCount = 0;
|
||||
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.setCellValue(position, 'B2'); // Same value
|
||||
|
||||
expect(changeCount).toBe(0);
|
||||
expect(tableEditor.isEdited).toBe(false);
|
||||
});
|
||||
|
||||
test('should throw error for invalid positions', () => {
|
||||
const invalidPosition: ICellPosition = { rowIdx: 10, colIdx: 10 };
|
||||
|
||||
expect(() => {
|
||||
tableEditor.setCellValue(invalidPosition, 'value');
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Row operations', () => {
|
||||
test('should insert rows correctly', () => {
|
||||
let changeCount = 0;
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.insertRow(1, ['X1', 'X2', 'X3']);
|
||||
|
||||
expect(tableEditor.rowCount).toBe(4);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('X1');
|
||||
expect(tableEditor.getCellValue({ rowIdx: 2, colIdx: 0 })).toBe('A2'); // Shifted down
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should delete rows correctly', () => {
|
||||
let changeCount = 0;
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.deleteRow(1);
|
||||
|
||||
expect(tableEditor.rowCount).toBe(2);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('A3');
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('History management', () => {
|
||||
test('should support undo/redo for cell edits', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
|
||||
// Make a change
|
||||
tableEditor.setCellValue(position, 'NEW_VALUE');
|
||||
expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE');
|
||||
expect(tableEditor.history.canUndo).toBe(true);
|
||||
expect(tableEditor.history.canRedo).toBe(false);
|
||||
|
||||
// Undo
|
||||
const undoResult = tableEditor.undo();
|
||||
expect(undoResult).toBe(true);
|
||||
expect(tableEditor.getCellValue(position)).toBe('B2');
|
||||
expect(tableEditor.history.canUndo).toBe(false);
|
||||
expect(tableEditor.history.canRedo).toBe(true);
|
||||
|
||||
// Redo
|
||||
const redoResult = tableEditor.redo();
|
||||
expect(redoResult).toBe(true);
|
||||
expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE');
|
||||
expect(tableEditor.history.canUndo).toBe(true);
|
||||
expect(tableEditor.history.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
test('should support undo/redo for row operations', () => {
|
||||
// Insert row
|
||||
tableEditor.insertRow(1, ['X1', 'X2', 'X3']);
|
||||
expect(tableEditor.rowCount).toBe(4);
|
||||
|
||||
// Undo insert
|
||||
tableEditor.undo();
|
||||
expect(tableEditor.rowCount).toBe(3);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('A2');
|
||||
|
||||
// Redo insert
|
||||
tableEditor.redo();
|
||||
expect(tableEditor.rowCount).toBe(4);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('X1');
|
||||
});
|
||||
|
||||
test('should not fire change notifications for undo/redo operations', () => {
|
||||
let changeCount = 0;
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
|
||||
// Make a change - should fire change notification
|
||||
tableEditor.setCellValue(position, 'NEW_VALUE');
|
||||
expect(changeCount).toBe(1);
|
||||
|
||||
// Undo - should NOT fire change notification
|
||||
tableEditor.undo();
|
||||
expect(changeCount).toBe(1);
|
||||
|
||||
// Redo - should NOT fire change notification
|
||||
tableEditor.redo();
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data reset', () => {
|
||||
test('should reset data and clear history', () => {
|
||||
// Make some changes
|
||||
tableEditor.setCellValue({ rowIdx: 0, colIdx: 0 }, 'CHANGED');
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(tableEditor.history.canUndo).toBe(true);
|
||||
|
||||
// Reset data
|
||||
const newData = [['Z1', 'Z2'], ['Z3', 'Z4']];
|
||||
tableEditor.resetData(newData);
|
||||
|
||||
expect(tableEditor.data).toEqual(newData);
|
||||
expect(tableEditor.rowCount).toBe(2);
|
||||
expect(tableEditor.isEdited).toBe(false);
|
||||
expect(tableEditor.history.canUndo).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents';
|
||||
import type { ITableEditor } from './ITableEditor.js';
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
import type { ITableEditorHistory, ITableEditorHistoryEntry } from './ITableEditorHistory.js';
|
||||
import type { TableEditorEvents } from './TableEditorEvents.js';
|
||||
import { TableEditorHistory } from './TableEditorHistory.js';
|
||||
|
||||
export class TableEditor<TValue> implements ITableEditor<TValue> {
|
||||
private readonly _data: TValue[][];
|
||||
private readonly _history: ITableEditorHistory<TValue>;
|
||||
private readonly _emitter: Emitter<TableEditorEvents<TValue>>;
|
||||
private _isEdited = false;
|
||||
|
||||
constructor(initialData: TValue[][] = [], historyMaxSize = 100) {
|
||||
this._data = initialData.map(row => [...row]);
|
||||
this._history = new TableEditorHistory<TValue>(historyMaxSize);
|
||||
this._emitter = createNanoEvents();
|
||||
}
|
||||
|
||||
get data(): readonly (readonly TValue[])[] {
|
||||
return this._data.map(row => Object.freeze([...row]));
|
||||
}
|
||||
|
||||
get isEdited(): boolean {
|
||||
return this._isEdited;
|
||||
}
|
||||
|
||||
get rowCount(): number {
|
||||
return this._data.length;
|
||||
}
|
||||
|
||||
get history(): ITableEditorHistory<TValue> {
|
||||
return this._history;
|
||||
}
|
||||
|
||||
getCellValue(position: ICellPosition): TValue | undefined {
|
||||
if (!this.isValidPosition(position)) {
|
||||
return undefined;
|
||||
}
|
||||
return this._data[position.rowIdx]?.[position.colIdx];
|
||||
}
|
||||
|
||||
setCellValue(position: ICellPosition, value: TValue): void {
|
||||
if (!this.isValidPosition(position)) {
|
||||
throw new Error(`Invalid cell position: row ${position.rowIdx}, column ${position.colIdx}`);
|
||||
}
|
||||
|
||||
const row = this._data[position.rowIdx];
|
||||
if (!row) {
|
||||
throw new Error(`Row ${position.rowIdx} does not exist`);
|
||||
}
|
||||
|
||||
const oldValue = row[position.colIdx];
|
||||
|
||||
if (oldValue === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const historyEntry: ITableEditorHistoryEntry<TValue> = {
|
||||
type: 'cell-edit',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
position: { ...position },
|
||||
oldValue: oldValue as TValue,
|
||||
newValue: value,
|
||||
},
|
||||
};
|
||||
this._history.push(historyEntry);
|
||||
|
||||
row[position.colIdx] = value;
|
||||
|
||||
this._isEdited = true;
|
||||
|
||||
this.emit('data-changed');
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
insertRow(rowIdx: number, rowData?: TValue[]): void {
|
||||
if (rowIdx < 0 || rowIdx > this._data.length) {
|
||||
throw new Error(`Invalid row index: ${rowIdx}. Must be between 0 and ${this._data.length}`);
|
||||
}
|
||||
|
||||
const newRowData = rowData ? [...rowData] : [];
|
||||
|
||||
if (this._data.length > 0 && newRowData.length === 0) {
|
||||
const columnCount = Math.max(...this._data.map(row => row.length));
|
||||
newRowData.length = columnCount;
|
||||
newRowData.fill(undefined as any);
|
||||
}
|
||||
|
||||
const historyEntry: ITableEditorHistoryEntry<TValue> = {
|
||||
type: 'row-insert',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
rowIdx,
|
||||
rowData: [...newRowData],
|
||||
},
|
||||
};
|
||||
this._history.push(historyEntry);
|
||||
|
||||
this._data.splice(rowIdx, 0, newRowData);
|
||||
|
||||
this._isEdited = true;
|
||||
|
||||
this.emit('data-changed');
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
deleteRow(rowIdx: number): void {
|
||||
if (rowIdx < 0 || rowIdx >= this._data.length) {
|
||||
throw new Error(`Invalid row index: ${rowIdx}. Must be between 0 and ${this._data.length - 1}`);
|
||||
}
|
||||
|
||||
const row = this._data[rowIdx];
|
||||
if (!row) {
|
||||
throw new Error(`Row ${rowIdx} does not exist`);
|
||||
}
|
||||
|
||||
const deletedRowData = [...row];
|
||||
|
||||
const historyEntry: ITableEditorHistoryEntry<TValue> = {
|
||||
type: 'row-delete',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
rowIdx,
|
||||
rowData: deletedRowData,
|
||||
},
|
||||
};
|
||||
this._history.push(historyEntry);
|
||||
|
||||
this._data.splice(rowIdx, 1);
|
||||
|
||||
this._isEdited = true;
|
||||
|
||||
this.emit('data-changed');
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
resetData(newData: TValue[][]): void {
|
||||
this._data.length = 0;
|
||||
|
||||
newData.forEach(row => {
|
||||
this._data.push([...row]);
|
||||
});
|
||||
|
||||
this._history.clear();
|
||||
this._isEdited = false;
|
||||
|
||||
this.emit('data-reset', newData);
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
undo(): boolean {
|
||||
const entry = this._history.undo();
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.applyHistoryEntryReverse(entry);
|
||||
this.emitHistoryChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
const entry = this._history.redo();
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.applyHistoryEntry(entry);
|
||||
this.emitHistoryChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
on<TEvent extends keyof TableEditorEvents<TValue>>(
|
||||
event: TEvent,
|
||||
listener: TableEditorEvents<TValue>[TEvent]
|
||||
): Unsubscribe {
|
||||
return this._emitter.on(event, listener);
|
||||
}
|
||||
|
||||
emit<TEvent extends keyof TableEditorEvents<TValue>>(
|
||||
event: TEvent,
|
||||
...args: Parameters<TableEditorEvents<TValue>[TEvent]>
|
||||
): void {
|
||||
this._emitter.emit(event, ...args);
|
||||
}
|
||||
|
||||
private isValidPosition(position: ICellPosition): boolean {
|
||||
return (
|
||||
position.rowIdx >= 0 &&
|
||||
position.rowIdx < this._data.length &&
|
||||
position.colIdx >= 0 &&
|
||||
position.colIdx < (this._data[position.rowIdx]?.length || 0)
|
||||
);
|
||||
}
|
||||
|
||||
private emitHistoryChanged(): void {
|
||||
this.emit('history-changed', this._history.canUndo, this._history.canRedo);
|
||||
}
|
||||
|
||||
private applyHistoryEntry(entry: ITableEditorHistoryEntry<TValue>): void {
|
||||
switch (entry.type) {
|
||||
case 'cell-edit': {
|
||||
const data = entry.data as any;
|
||||
const { position, newValue } = data;
|
||||
const row = this._data[position.rowIdx];
|
||||
if (row) {
|
||||
row[position.colIdx] = newValue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'row-insert': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx, rowData } = data;
|
||||
this._data.splice(rowIdx, 0, [...rowData]);
|
||||
break;
|
||||
}
|
||||
case 'row-delete': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx } = data;
|
||||
this._data.splice(rowIdx, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyHistoryEntryReverse(entry: ITableEditorHistoryEntry<TValue>): void {
|
||||
switch (entry.type) {
|
||||
case 'cell-edit': {
|
||||
const data = entry.data as any;
|
||||
const { position, oldValue } = data;
|
||||
const row = this._data[position.rowIdx];
|
||||
if (row) {
|
||||
row[position.colIdx] = oldValue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'row-insert': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx } = data;
|
||||
this._data.splice(rowIdx, 1);
|
||||
break;
|
||||
}
|
||||
case 'row-delete': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx, rowData } = data;
|
||||
this._data.splice(rowIdx, 0, [...rowData]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EmitterMixin } from 'nanoevents';
|
||||
|
||||
export interface TableEditorEvents<TValue> {
|
||||
'data-changed': () => void;
|
||||
'data-reset': (newData: TValue[][]) => void;
|
||||
'history-changed': (canUndo: boolean, canRedo: boolean) => void;
|
||||
}
|
||||
|
||||
export type TableEditorEventEmitter<TValue> = EmitterMixin<TableEditorEvents<TValue>>;
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ITableEditorHistory, ITableEditorHistoryEntry } from './ITableEditorHistory.js';
|
||||
|
||||
/**
|
||||
* Implementation of table editor history with undo/redo functionality.
|
||||
* Maintains a circular buffer of history entries with a configurable maximum size.
|
||||
*/
|
||||
export class TableEditorHistory<TValue> implements ITableEditorHistory<TValue> {
|
||||
private entries: ITableEditorHistoryEntry<TValue>[] = [];
|
||||
private currentIndex: number = -1;
|
||||
private _maxSize: number;
|
||||
|
||||
constructor(maxSize: number = 100) {
|
||||
this._maxSize = Math.max(1, maxSize);
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return this.currentIndex >= 0;
|
||||
}
|
||||
|
||||
get canRedo(): boolean {
|
||||
return this.currentIndex < this.entries.length - 1;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
get maxSize(): number {
|
||||
return this._maxSize;
|
||||
}
|
||||
|
||||
push(entry: ITableEditorHistoryEntry<TValue>): void {
|
||||
// Remove any entries after the current index (when pushing after undo)
|
||||
if (this.currentIndex < this.entries.length - 1) {
|
||||
this.entries = this.entries.slice(0, this.currentIndex + 1);
|
||||
}
|
||||
|
||||
// Add the new entry
|
||||
this.entries.push(entry);
|
||||
this.currentIndex = this.entries.length - 1;
|
||||
|
||||
// Maintain max size by removing oldest entries
|
||||
if (this.entries.length > this._maxSize) {
|
||||
const removeCount = this.entries.length - this._maxSize;
|
||||
this.entries = this.entries.slice(removeCount);
|
||||
this.currentIndex -= removeCount;
|
||||
}
|
||||
}
|
||||
|
||||
undo(): ITableEditorHistoryEntry<TValue> | null {
|
||||
if (!this.canUndo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = this.entries[this.currentIndex];
|
||||
this.currentIndex--;
|
||||
return entry ?? null;
|
||||
}
|
||||
|
||||
redo(): ITableEditorHistoryEntry<TValue> | null {
|
||||
if (!this.canRedo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex++;
|
||||
const entry = this.entries[this.currentIndex];
|
||||
return entry ?? null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
setMaxSize(size: number): void {
|
||||
this._maxSize = Math.max(1, size);
|
||||
|
||||
// Trim entries if the new max size is smaller
|
||||
if (this.entries.length > this._maxSize) {
|
||||
const removeCount = this.entries.length - this._maxSize;
|
||||
this.entries = this.entries.slice(removeCount);
|
||||
this.currentIndex = Math.max(-1, this.currentIndex - removeCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Main interfaces
|
||||
export type { ITableEditor } from './ITableEditor.js';
|
||||
export type { ICellPosition } from './ICellPosition.js';
|
||||
export type { ITableEditorFactory } from './ITableEditorFactory.js';
|
||||
|
||||
// History interfaces
|
||||
export type {
|
||||
ITableEditorHistory,
|
||||
ITableEditorHistoryEntry,
|
||||
ICellEditEntry,
|
||||
IRowInsertEntry,
|
||||
IRowDeleteEntry,
|
||||
} from './ITableEditorHistory.js';
|
||||
|
||||
// History implementation
|
||||
export { TableEditorHistory } from './TableEditorHistory.js';
|
||||
|
||||
// Table editor implementation
|
||||
export { TableEditor } from './TableEditor.js';
|
||||
|
||||
// Event interfaces
|
||||
export type {
|
||||
TableEditorEvents,
|
||||
TableEditorEventEmitter,
|
||||
} from './TableEditorEvents.js';
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './editor/TableEditor.js';
|
||||
export * from './interfaces/ITableData.js';
|
||||
export * from './interfaces/ITableDataset.js';
|
||||
export * from './interfaces/ITableDatasetManager.js';
|
||||
export * from './interfaces/ITableSource.js';
|
||||
export * from './interfaces/ITableSourceOptions.js';
|
||||
export * from './interfaces/ITableSorting.js';
|
||||
export * from './interfaces/TableEvents.js';
|
||||
export * from './interfaces/TableSourceEvents.js';
|
||||
export * from './TableSource.js';
|
||||
export * from './TableDatasetManager.js';
|
||||
export * from './editor/index.js';
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EmitterMixin } from 'nanoevents';
|
||||
import type { ITableDataset } from './ITableDataset.js';
|
||||
import type { ITableSource } from './ITableSource.js';
|
||||
import type { TableEvents } from './TableEvents.js';
|
||||
import type { ITableSourceOptions } from './ITableSourceOptions.js';
|
||||
|
||||
export interface ITableData<TColumn, TValue, TOptions extends ITableSourceOptions = ITableSourceOptions> extends EmitterMixin<TableEvents> {
|
||||
readonly id: string;
|
||||
readonly isLoading: boolean;
|
||||
readonly isOutdated: boolean;
|
||||
readonly datasets: ITableDataset<TColumn, TValue>[];
|
||||
|
||||
readonly source: ITableSource<TColumn, TValue, TOptions>;
|
||||
|
||||
setSource(source: ITableSource<TColumn, TValue, TOptions>): void;
|
||||
|
||||
save(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ITableDataset<TColumn, TValue> {
|
||||
readonly id: string;
|
||||
readonly columns: TColumn[];
|
||||
readonly rows: TValue[][];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ITableDataset } from './ITableDataset.js';
|
||||
|
||||
export interface ITableDatasetManager<TColumn, TValue> {
|
||||
readonly datasets: ITableDataset<TColumn, TValue>[];
|
||||
|
||||
setDatasets(datasets: ITableDataset<TColumn, TValue>[]): void;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ITableSorting {
|
||||
column: number;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EmitterMixin } from 'nanoevents';
|
||||
import type { TableSourceEvents } from './TableSourceEvents.js';
|
||||
import type { ITableDatasetManager } from './ITableDatasetManager.js';
|
||||
import type { ITableSourceOptions } from './ITableSourceOptions.js';
|
||||
|
||||
export interface ITableSource<TColumn, TValue, TOptions extends ITableSourceOptions = ITableSourceOptions> extends EmitterMixin<TableSourceEvents> {
|
||||
readonly datasetManager: ITableDatasetManager<TColumn, TValue>;
|
||||
readonly options: Partial<TOptions>;
|
||||
readonly isLoading: boolean;
|
||||
readonly isOutdated: boolean;
|
||||
readonly error: Error | null;
|
||||
|
||||
setOptions(options: TOptions): void;
|
||||
setOutdated(): void;
|
||||
|
||||
save(): Promise<void>;
|
||||
load(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ITableSorting } from './ITableSorting.js';
|
||||
|
||||
export interface ITableSourceOptions {
|
||||
sorting?: ITableSorting[];
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { TableSourceEvents } from './TableSourceEvents.js';
|
||||
|
||||
export type TableEvents = TableSourceEvents & {};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type TableSourceEvents = {
|
||||
loading: (state: boolean) => void;
|
||||
outdated: (state: boolean) => void;
|
||||
saved: () => void;
|
||||
data: () => void;
|
||||
error: (error: Error | null) => void;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@dbeaver/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./lib/.tsbuildinfo",
|
||||
"rootDir": "src",
|
||||
"rootDirs": [
|
||||
"src"
|
||||
],
|
||||
"outDir": "lib",
|
||||
"incremental": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../cli"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -165,6 +165,20 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@dbeaver/table-data@workspace:@dbeaver/table-data":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@dbeaver/table-data@workspace:@dbeaver/table-data"
|
||||
dependencies:
|
||||
"@dbeaver/cli": "workspace:^"
|
||||
"@dbeaver/tsconfig": "workspace:^"
|
||||
async-mutex: "npm:^0"
|
||||
nanoevents: "npm:^9"
|
||||
rimraf: "npm:^6"
|
||||
typescript: "npm:^5"
|
||||
vitest: "npm:^3"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@dbeaver/tsconfig@workspace:@dbeaver/tsconfig, @dbeaver/tsconfig@workspace:^":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@dbeaver/tsconfig@workspace:@dbeaver/tsconfig"
|
||||
@@ -949,6 +963,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-mutex@npm:^0":
|
||||
version: 0.5.0
|
||||
resolution: "async-mutex@npm:0.5.0"
|
||||
dependencies:
|
||||
tslib: "npm:^2.4.0"
|
||||
checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"balanced-match@npm:^1.0.0":
|
||||
version: 1.0.2
|
||||
resolution: "balanced-match@npm:1.0.2"
|
||||
@@ -2002,6 +2025,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoevents@npm:^9":
|
||||
version: 9.1.0
|
||||
resolution: "nanoevents@npm:9.1.0"
|
||||
checksum: 10c0/5fb48e6fc1d3102daddaeffd6eada907c25b1c8554dcc648e9cb0a72979a1ab3ee56ffa2d2b3e566a8e561a9e2992a3783493c61cfaaa096b2986d56dbbc1ca5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoid@npm:^3.3.8":
|
||||
version: 3.3.11
|
||||
resolution: "nanoid@npm:3.3.11"
|
||||
@@ -2749,7 +2779,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^2, tslib@npm:^2.3.0":
|
||||
"tslib@npm:^2, tslib@npm:^2.3.0, tslib@npm:^2.4.0":
|
||||
version: 2.8.1
|
||||
resolution: "tslib@npm:2.8.1"
|
||||
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
|
||||
|
||||
@@ -4528,6 +4528,20 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data"
|
||||
dependencies:
|
||||
"@dbeaver/cli": "workspace:^"
|
||||
"@dbeaver/tsconfig": "workspace:^"
|
||||
async-mutex: "npm:^0"
|
||||
nanoevents: "npm:^9"
|
||||
rimraf: "npm:^6"
|
||||
typescript: "npm:^5"
|
||||
vitest: "npm:^3"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@dbeaver/tsconfig@workspace:*, @dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig, @dbeaver/tsconfig@workspace:^":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig"
|
||||
@@ -8401,6 +8415,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-mutex@npm:^0":
|
||||
version: 0.5.0
|
||||
resolution: "async-mutex@npm:0.5.0"
|
||||
dependencies:
|
||||
tslib: "npm:^2.4.0"
|
||||
checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async@npm:^3.2.3":
|
||||
version: 3.2.6
|
||||
resolution: "async@npm:3.2.6"
|
||||
@@ -14598,6 +14621,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoevents@npm:^9":
|
||||
version: 9.1.0
|
||||
resolution: "nanoevents@npm:9.1.0"
|
||||
checksum: 10c0/5fb48e6fc1d3102daddaeffd6eada907c25b1c8554dcc648e9cb0a72979a1ab3ee56ffa2d2b3e566a8e561a9e2992a3783493c61cfaaa096b2986d56dbbc1ca5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoid@npm:^3.3.8":
|
||||
version: 3.3.11
|
||||
resolution: "nanoid@npm:3.3.11"
|
||||
|
||||
Reference in New Issue
Block a user