dbdiff-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bin/cli.js +83 -0
- package/bin/install-local.js +57 -0
- package/electron/generate-icon.mjs +54 -0
- package/electron/icon.icns +0 -0
- package/electron/icon.png +0 -0
- package/electron/icon.svg +21 -0
- package/electron/main.js +169 -0
- package/electron/patch-dev-plist.js +31 -0
- package/electron/preload.cjs +18 -0
- package/electron/wait-for-vite.js +43 -0
- package/index.html +13 -0
- package/package.json +91 -0
- package/public/favicon.svg +15 -0
- package/public/vite.svg +1 -0
- package/server/export.ts +57 -0
- package/server/index.ts +392 -0
- package/src/App.css +1 -0
- package/src/App.tsx +543 -0
- package/src/assets/react.svg +1 -0
- package/src/components/CommandPalette.tsx +243 -0
- package/src/components/ConnectedView.tsx +78 -0
- package/src/components/ConnectionPicker.tsx +381 -0
- package/src/components/ConsoleView.tsx +360 -0
- package/src/components/CsvExportModal.tsx +144 -0
- package/src/components/DataGrid/DataGrid.tsx +262 -0
- package/src/components/DataGrid/DataGridCell.tsx +73 -0
- package/src/components/DataGrid/DataGridHeader.tsx +89 -0
- package/src/components/DataGrid/index.ts +20 -0
- package/src/components/DataGrid/types.ts +63 -0
- package/src/components/DataGrid/useColumnResize.ts +153 -0
- package/src/components/DataGrid/useDataGridSelection.ts +340 -0
- package/src/components/DataGrid/utils.ts +184 -0
- package/src/components/DatabaseMenu.tsx +93 -0
- package/src/components/DatabaseSwitcher.tsx +208 -0
- package/src/components/DiffView.tsx +215 -0
- package/src/components/EditConnectionModal.tsx +417 -0
- package/src/components/ErrorBoundary.tsx +69 -0
- package/src/components/GlobalShortcuts.tsx +201 -0
- package/src/components/InnerTabBar.tsx +129 -0
- package/src/components/JsonTreeViewer.tsx +387 -0
- package/src/components/MemberAccessEditor.tsx +443 -0
- package/src/components/MembersModal.tsx +446 -0
- package/src/components/NewConnectionModal.tsx +274 -0
- package/src/components/Resizer.tsx +66 -0
- package/src/components/ScanSuccessModal.tsx +113 -0
- package/src/components/ShortcutSettingsModal.tsx +318 -0
- package/src/components/Sidebar.tsx +532 -0
- package/src/components/TabBar.tsx +188 -0
- package/src/components/TableView.tsx +2147 -0
- package/src/components/ThemeToggle.tsx +44 -0
- package/src/components/index.ts +17 -0
- package/src/constants.ts +12 -0
- package/src/electron.d.ts +12 -0
- package/src/index.css +44 -0
- package/src/main.tsx +13 -0
- package/src/stores/hooks.ts +1146 -0
- package/src/stores/index.ts +12 -0
- package/src/stores/store.ts +1514 -0
- package/src/stores/useCloudSync.ts +274 -0
- package/src/stores/useSyncDatabase.ts +422 -0
- package/src/types.ts +277 -0
- package/src/utils/csv.ts +27 -0
- package/src/vite-env.d.ts +2 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/tsconfig.server.json +14 -0
- package/vite.config.ts +14 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { CellPosition, CellRange } from "../../types";
|
|
3
|
+
import type { DataGridColumn, DataGridSelection, ExtraRow } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
serializeCellValue,
|
|
6
|
+
setInternalClipboard,
|
|
7
|
+
storeToVirtualIndex,
|
|
8
|
+
virtualToStoreIndex,
|
|
9
|
+
} from "./utils";
|
|
10
|
+
|
|
11
|
+
interface UseDataGridSelectionOptions {
|
|
12
|
+
columns: DataGridColumn[];
|
|
13
|
+
rows: Record<string, unknown>[];
|
|
14
|
+
extraRows?: ExtraRow[];
|
|
15
|
+
/** Controlled mode: external selection state */
|
|
16
|
+
selection?: DataGridSelection;
|
|
17
|
+
/** Controlled mode: callback when selection changes */
|
|
18
|
+
onSelectionChange?: (selection: DataGridSelection) => void;
|
|
19
|
+
/** Intercept keydown. Return true to prevent DataGrid's default handling. */
|
|
20
|
+
onKeyDown?: (e: KeyboardEvent) => boolean;
|
|
21
|
+
/** Ref to scroll a row into view */
|
|
22
|
+
scrollToIndex?: (index: number) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useDataGridSelection({
|
|
26
|
+
columns,
|
|
27
|
+
rows,
|
|
28
|
+
extraRows,
|
|
29
|
+
selection: controlledSelection,
|
|
30
|
+
onSelectionChange,
|
|
31
|
+
onKeyDown: externalOnKeyDown,
|
|
32
|
+
scrollToIndex,
|
|
33
|
+
}: UseDataGridSelectionOptions) {
|
|
34
|
+
// Uncontrolled internal state
|
|
35
|
+
const [internalSelection, setInternalSelection] = useState<DataGridSelection>(
|
|
36
|
+
{
|
|
37
|
+
selectedCell: null,
|
|
38
|
+
selectedRange: null,
|
|
39
|
+
isDragging: false,
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const isControlled = controlledSelection !== undefined;
|
|
44
|
+
const selection = isControlled ? controlledSelection : internalSelection;
|
|
45
|
+
|
|
46
|
+
const updateSelection = useCallback(
|
|
47
|
+
(updater: (prev: DataGridSelection) => DataGridSelection) => {
|
|
48
|
+
if (isControlled && onSelectionChange) {
|
|
49
|
+
onSelectionChange(updater(controlledSelection!));
|
|
50
|
+
} else {
|
|
51
|
+
setInternalSelection(updater);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[isControlled, onSelectionChange, controlledSelection],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const selectCell = useCallback(
|
|
58
|
+
(cell: CellPosition | null) => {
|
|
59
|
+
updateSelection(() => ({
|
|
60
|
+
selectedCell: cell,
|
|
61
|
+
selectedRange: null,
|
|
62
|
+
isDragging: false,
|
|
63
|
+
}));
|
|
64
|
+
},
|
|
65
|
+
[updateSelection],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const selectCellRange = useCallback(
|
|
69
|
+
(range: CellRange) => {
|
|
70
|
+
updateSelection((prev) => ({
|
|
71
|
+
...prev,
|
|
72
|
+
selectedCell: prev.selectedCell,
|
|
73
|
+
selectedRange: range,
|
|
74
|
+
}));
|
|
75
|
+
},
|
|
76
|
+
[updateSelection],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const setDragging = useCallback(
|
|
80
|
+
(isDragging: boolean) => {
|
|
81
|
+
updateSelection((prev) => ({ ...prev, isDragging }));
|
|
82
|
+
},
|
|
83
|
+
[updateSelection],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Track drag start cell and dragging state via ref for synchronous access.
|
|
87
|
+
// React useState is async, so handleCellMouseEnter would see stale isDragging
|
|
88
|
+
// from its closure. The ref is updated synchronously in handleCellMouseDown.
|
|
89
|
+
const dragStartCell = useRef<CellPosition | null>(null);
|
|
90
|
+
const isDraggingRef = useRef(false);
|
|
91
|
+
const rafRef = useRef<number | null>(null);
|
|
92
|
+
|
|
93
|
+
const handleCellClick = useCallback(
|
|
94
|
+
(rowIndex: number, columnName: string, e: React.MouseEvent) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
if (e.shiftKey && selection.selectedCell) {
|
|
97
|
+
const anchor = selection.selectedRange?.start ?? selection.selectedCell;
|
|
98
|
+
selectCellRange({
|
|
99
|
+
start: anchor,
|
|
100
|
+
end: { rowIndex, columnName },
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
selectCell({ rowIndex, columnName });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[
|
|
107
|
+
selection.selectedCell,
|
|
108
|
+
selection.selectedRange,
|
|
109
|
+
selectCell,
|
|
110
|
+
selectCellRange,
|
|
111
|
+
],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const handleCellMouseDown = useCallback(
|
|
115
|
+
(rowIndex: number, columnName: string, e: React.MouseEvent) => {
|
|
116
|
+
if (e.button !== 0) return;
|
|
117
|
+
e.preventDefault(); // Prevent native text selection during drag
|
|
118
|
+
if (e.shiftKey) return; // Let handleCellClick handle shift+click for range selection
|
|
119
|
+
dragStartCell.current = { rowIndex, columnName };
|
|
120
|
+
isDraggingRef.current = true;
|
|
121
|
+
setDragging(true);
|
|
122
|
+
selectCell({ rowIndex, columnName });
|
|
123
|
+
},
|
|
124
|
+
[selectCell, setDragging],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const handleCellMouseEnter = useCallback(
|
|
128
|
+
(rowIndex: number, columnName: string) => {
|
|
129
|
+
if (!isDraggingRef.current || !dragStartCell.current) return;
|
|
130
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
131
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
132
|
+
selectCellRange({
|
|
133
|
+
start: dragStartCell.current!,
|
|
134
|
+
end: { rowIndex, columnName },
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
[selectCellRange],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Global mouse up - end drag
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const handleMouseUp = () => {
|
|
144
|
+
if (isDraggingRef.current) {
|
|
145
|
+
isDraggingRef.current = false;
|
|
146
|
+
setDragging(false);
|
|
147
|
+
dragStartCell.current = null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
151
|
+
return () => window.removeEventListener("mouseup", handleMouseUp);
|
|
152
|
+
}, [setDragging]);
|
|
153
|
+
|
|
154
|
+
// Keyboard: arrow nav, Cmd+C, Cmd+A, Escape
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
157
|
+
// Don't handle if in input or textarea
|
|
158
|
+
const target = e.target as HTMLElement;
|
|
159
|
+
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
|
|
160
|
+
|
|
161
|
+
// Let external handler intercept first
|
|
162
|
+
if (externalOnKeyDown && externalOnKeyDown(e)) return;
|
|
163
|
+
|
|
164
|
+
const { selectedCell, selectedRange } = selection;
|
|
165
|
+
|
|
166
|
+
// Cmd+C / Ctrl+C - copy
|
|
167
|
+
if (selectedCell && e.key === "c" && (e.metaKey || e.ctrlKey)) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
const columnNames = columns.map((c) => c.name);
|
|
170
|
+
const existingRowCount = rows.length;
|
|
171
|
+
|
|
172
|
+
if (selectedRange) {
|
|
173
|
+
const startColIdx = columnNames.indexOf(
|
|
174
|
+
selectedRange.start.columnName,
|
|
175
|
+
);
|
|
176
|
+
const endColIdx = columnNames.indexOf(selectedRange.end.columnName);
|
|
177
|
+
const minCol = Math.min(startColIdx, endColIdx);
|
|
178
|
+
const maxCol = Math.max(startColIdx, endColIdx);
|
|
179
|
+
|
|
180
|
+
const startV = storeToVirtualIndex(
|
|
181
|
+
selectedRange.start.rowIndex,
|
|
182
|
+
existingRowCount,
|
|
183
|
+
);
|
|
184
|
+
const endV = storeToVirtualIndex(
|
|
185
|
+
selectedRange.end.rowIndex,
|
|
186
|
+
existingRowCount,
|
|
187
|
+
);
|
|
188
|
+
const minRow = Math.min(startV, endV);
|
|
189
|
+
const maxRow = Math.max(startV, endV);
|
|
190
|
+
|
|
191
|
+
const tsvRows: string[] = [];
|
|
192
|
+
for (let v = minRow; v <= maxRow; v++) {
|
|
193
|
+
const storeIdx = virtualToStoreIndex(v, existingRowCount);
|
|
194
|
+
const tsvCols: string[] = [];
|
|
195
|
+
for (let c = minCol; c <= maxCol; c++) {
|
|
196
|
+
const colName = columnNames[c];
|
|
197
|
+
let value: unknown;
|
|
198
|
+
if (storeIdx < 0 && extraRows) {
|
|
199
|
+
const newRowIdx = Math.abs(storeIdx) - 1;
|
|
200
|
+
const extraRow = extraRows[newRowIdx];
|
|
201
|
+
value = extraRow?.data[colName] ?? null;
|
|
202
|
+
} else {
|
|
203
|
+
value = rows[storeIdx]?.[colName];
|
|
204
|
+
}
|
|
205
|
+
tsvCols.push(serializeCellValue(value));
|
|
206
|
+
}
|
|
207
|
+
tsvRows.push(tsvCols.join("\t"));
|
|
208
|
+
}
|
|
209
|
+
const text = tsvRows.join("\n");
|
|
210
|
+
navigator.clipboard.writeText(text);
|
|
211
|
+
setInternalClipboard(text, undefined, true);
|
|
212
|
+
} else {
|
|
213
|
+
// Single cell copy
|
|
214
|
+
let value: unknown;
|
|
215
|
+
if (selectedCell.rowIndex < 0 && extraRows) {
|
|
216
|
+
const newRowIdx = Math.abs(selectedCell.rowIndex) - 1;
|
|
217
|
+
const extraRow = extraRows[newRowIdx];
|
|
218
|
+
value = extraRow?.data[selectedCell.columnName] ?? null;
|
|
219
|
+
} else {
|
|
220
|
+
value = rows[selectedCell.rowIndex]?.[selectedCell.columnName];
|
|
221
|
+
}
|
|
222
|
+
const text = serializeCellValue(value);
|
|
223
|
+
navigator.clipboard.writeText(text);
|
|
224
|
+
setInternalClipboard(text, value);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Cmd+A / Ctrl+A - select all
|
|
230
|
+
if (e.key === "a" && (e.metaKey || e.ctrlKey)) {
|
|
231
|
+
const totalRowCount = rows.length + (extraRows?.length ?? 0);
|
|
232
|
+
if (columns.length === 0 || totalRowCount === 0) return;
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
const firstCol = columns[0].name;
|
|
235
|
+
const lastCol = columns[columns.length - 1].name;
|
|
236
|
+
const lastStoreIndex = virtualToStoreIndex(
|
|
237
|
+
totalRowCount - 1,
|
|
238
|
+
rows.length,
|
|
239
|
+
);
|
|
240
|
+
updateSelection(() => ({
|
|
241
|
+
selectedCell: { rowIndex: 0, columnName: firstCol },
|
|
242
|
+
selectedRange: {
|
|
243
|
+
start: { rowIndex: 0, columnName: firstCol },
|
|
244
|
+
end: { rowIndex: lastStoreIndex, columnName: lastCol },
|
|
245
|
+
},
|
|
246
|
+
isDragging: false,
|
|
247
|
+
}));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Escape - deselect
|
|
252
|
+
if (e.key === "Escape" && selectedCell) {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
selectCell(null);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Arrow key navigation
|
|
259
|
+
if (
|
|
260
|
+
selectedCell &&
|
|
261
|
+
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)
|
|
262
|
+
) {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
const columnNames = columns.map((c) => c.name);
|
|
265
|
+
const existingRowCount = rows.length;
|
|
266
|
+
const extraRowCount = extraRows?.length ?? 0;
|
|
267
|
+
const totalRows = existingRowCount + extraRowCount;
|
|
268
|
+
|
|
269
|
+
if (columnNames.length === 0 || totalRows === 0) return;
|
|
270
|
+
|
|
271
|
+
const moveFrom =
|
|
272
|
+
e.shiftKey && selectedRange ? selectedRange.end : selectedCell;
|
|
273
|
+
|
|
274
|
+
let newVirtual = storeToVirtualIndex(
|
|
275
|
+
moveFrom.rowIndex,
|
|
276
|
+
existingRowCount,
|
|
277
|
+
);
|
|
278
|
+
let newColIndex = columnNames.indexOf(moveFrom.columnName);
|
|
279
|
+
|
|
280
|
+
switch (e.key) {
|
|
281
|
+
case "ArrowUp":
|
|
282
|
+
newVirtual = Math.max(0, newVirtual - 1);
|
|
283
|
+
break;
|
|
284
|
+
case "ArrowDown":
|
|
285
|
+
newVirtual = Math.min(totalRows - 1, newVirtual + 1);
|
|
286
|
+
break;
|
|
287
|
+
case "ArrowLeft":
|
|
288
|
+
newColIndex = Math.max(0, newColIndex - 1);
|
|
289
|
+
break;
|
|
290
|
+
case "ArrowRight":
|
|
291
|
+
newColIndex = Math.min(columnNames.length - 1, newColIndex + 1);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const newCell = {
|
|
296
|
+
rowIndex: virtualToStoreIndex(newVirtual, existingRowCount),
|
|
297
|
+
columnName: columnNames[newColIndex],
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
if (e.shiftKey) {
|
|
301
|
+
const anchor = selectedRange?.start ?? selectedCell;
|
|
302
|
+
updateSelection((prev) => ({
|
|
303
|
+
...prev,
|
|
304
|
+
selectedRange: { start: anchor, end: newCell },
|
|
305
|
+
}));
|
|
306
|
+
} else {
|
|
307
|
+
selectCell(newCell);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
scrollToIndex?.(
|
|
311
|
+
storeToVirtualIndex(newCell.rowIndex, existingRowCount),
|
|
312
|
+
);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
318
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
319
|
+
}, [
|
|
320
|
+
selection,
|
|
321
|
+
columns,
|
|
322
|
+
rows,
|
|
323
|
+
extraRows,
|
|
324
|
+
externalOnKeyDown,
|
|
325
|
+
selectCell,
|
|
326
|
+
selectCellRange,
|
|
327
|
+
updateSelection,
|
|
328
|
+
scrollToIndex,
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
selection,
|
|
333
|
+
selectCell,
|
|
334
|
+
selectCellRange,
|
|
335
|
+
setDragging,
|
|
336
|
+
handleCellClick,
|
|
337
|
+
handleCellMouseDown,
|
|
338
|
+
handleCellMouseEnter,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { CellPosition } from "../../types";
|
|
2
|
+
import type { RangeEdges } from "./types";
|
|
3
|
+
|
|
4
|
+
/** Row height in pixels for the virtualizer */
|
|
5
|
+
export const ROW_HEIGHT = 37;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert store index (negative for new rows) to virtual index.
|
|
9
|
+
* Virtual indices: 0..M-1 for existing rows, M..M+N-1 for new rows.
|
|
10
|
+
*/
|
|
11
|
+
export function storeToVirtualIndex(
|
|
12
|
+
storeIndex: number,
|
|
13
|
+
existingRowCount: number,
|
|
14
|
+
): number {
|
|
15
|
+
if (storeIndex >= 0) return storeIndex;
|
|
16
|
+
// New rows: -1 → existingRowCount, -2 → existingRowCount+1, etc.
|
|
17
|
+
const newRowArrayIndex = Math.abs(storeIndex) - 1;
|
|
18
|
+
return existingRowCount + newRowArrayIndex;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert virtual index to store index (negative for new rows).
|
|
23
|
+
*/
|
|
24
|
+
export function virtualToStoreIndex(
|
|
25
|
+
virtualIndex: number,
|
|
26
|
+
existingRowCount: number,
|
|
27
|
+
): number {
|
|
28
|
+
if (virtualIndex < existingRowCount) return virtualIndex;
|
|
29
|
+
// Virtual index >= existingRowCount means it's a new row
|
|
30
|
+
const newRowArrayIndex = virtualIndex - existingRowCount;
|
|
31
|
+
return -(newRowArrayIndex + 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get all row indices from a selection (single cell or range).
|
|
36
|
+
*/
|
|
37
|
+
export function getSelectedRowIndices(
|
|
38
|
+
selectedCell: CellPosition | null,
|
|
39
|
+
selectedRange: { start: CellPosition; end: CellPosition } | null,
|
|
40
|
+
existingRowCount: number,
|
|
41
|
+
): number[] {
|
|
42
|
+
if (!selectedCell) return [];
|
|
43
|
+
|
|
44
|
+
if (!selectedRange) {
|
|
45
|
+
return [selectedCell.rowIndex];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const startVirtual = storeToVirtualIndex(
|
|
49
|
+
selectedRange.start.rowIndex,
|
|
50
|
+
existingRowCount,
|
|
51
|
+
);
|
|
52
|
+
const endVirtual = storeToVirtualIndex(
|
|
53
|
+
selectedRange.end.rowIndex,
|
|
54
|
+
existingRowCount,
|
|
55
|
+
);
|
|
56
|
+
const minVirtual = Math.min(startVirtual, endVirtual);
|
|
57
|
+
const maxVirtual = Math.max(startVirtual, endVirtual);
|
|
58
|
+
|
|
59
|
+
const rowIndices: number[] = [];
|
|
60
|
+
for (let v = minVirtual; v <= maxVirtual; v++) {
|
|
61
|
+
rowIndices.push(virtualToStoreIndex(v, existingRowCount));
|
|
62
|
+
}
|
|
63
|
+
return rowIndices;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a cell is within a selected range and return its edge positions.
|
|
68
|
+
* Uses virtual indices to handle both existing rows (0..M-1) and new rows (-1..-N)
|
|
69
|
+
* as a unified linear space.
|
|
70
|
+
*/
|
|
71
|
+
export function getCellRangeInfo(
|
|
72
|
+
cell: CellPosition,
|
|
73
|
+
range: { start: CellPosition; end: CellPosition } | null,
|
|
74
|
+
columnOrder: string[],
|
|
75
|
+
existingRowCount: number,
|
|
76
|
+
): { isInRange: boolean; edges: RangeEdges | null } {
|
|
77
|
+
if (!range) return { isInRange: false, edges: null };
|
|
78
|
+
|
|
79
|
+
const cellVirtual = storeToVirtualIndex(cell.rowIndex, existingRowCount);
|
|
80
|
+
const startVirtual = storeToVirtualIndex(
|
|
81
|
+
range.start.rowIndex,
|
|
82
|
+
existingRowCount,
|
|
83
|
+
);
|
|
84
|
+
const endVirtual = storeToVirtualIndex(range.end.rowIndex, existingRowCount);
|
|
85
|
+
|
|
86
|
+
const minRowVirtual = Math.min(startVirtual, endVirtual);
|
|
87
|
+
const maxRowVirtual = Math.max(startVirtual, endVirtual);
|
|
88
|
+
|
|
89
|
+
const startColIndex = columnOrder.indexOf(range.start.columnName);
|
|
90
|
+
const endColIndex = columnOrder.indexOf(range.end.columnName);
|
|
91
|
+
const cellColIndex = columnOrder.indexOf(cell.columnName);
|
|
92
|
+
|
|
93
|
+
const minColIndex = Math.min(startColIndex, endColIndex);
|
|
94
|
+
const maxColIndex = Math.max(startColIndex, endColIndex);
|
|
95
|
+
|
|
96
|
+
const isInRange =
|
|
97
|
+
cellVirtual >= minRowVirtual &&
|
|
98
|
+
cellVirtual <= maxRowVirtual &&
|
|
99
|
+
cellColIndex >= minColIndex &&
|
|
100
|
+
cellColIndex <= maxColIndex;
|
|
101
|
+
|
|
102
|
+
if (!isInRange) return { isInRange: false, edges: null };
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
isInRange: true,
|
|
106
|
+
edges: {
|
|
107
|
+
top: cellVirtual === minRowVirtual,
|
|
108
|
+
bottom: cellVirtual === maxRowVirtual,
|
|
109
|
+
left: cellColIndex === minColIndex,
|
|
110
|
+
right: cellColIndex === maxColIndex,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function formatCellValue(value: unknown): string {
|
|
116
|
+
if (value === null) return "NULL";
|
|
117
|
+
if (value === undefined) return "";
|
|
118
|
+
if (typeof value === "object") {
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
return `[${value.length} item${value.length !== 1 ? "s" : ""}]`;
|
|
121
|
+
}
|
|
122
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
123
|
+
if (keys.length === 0) return "{}";
|
|
124
|
+
if (keys.length <= 3) return `{ ${keys.join(", ")} }`;
|
|
125
|
+
return `{ ${keys.slice(0, 3).join(", ")}, ... }`;
|
|
126
|
+
}
|
|
127
|
+
return String(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Full serialization for clipboard copy (not truncated) */
|
|
131
|
+
export function serializeCellValue(value: unknown): string {
|
|
132
|
+
if (value === null) return "NULL";
|
|
133
|
+
if (value === undefined) return "";
|
|
134
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
135
|
+
return String(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parse TSV text into a 2D array of strings.
|
|
140
|
+
* Returns null if text has no tabs or newlines (single value, not a range).
|
|
141
|
+
* Handles \r\n line endings and trims a trailing empty line.
|
|
142
|
+
*/
|
|
143
|
+
export function parseTSV(text: string): string[][] | null {
|
|
144
|
+
if (!text.includes("\t") && !text.includes("\n")) return null;
|
|
145
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
|
|
146
|
+
return lines.map((line) => line.split("\t"));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Internal clipboard: stashes raw cell value alongside the system clipboard text
|
|
150
|
+
// so we can detect when a paste originates from a copy within this app.
|
|
151
|
+
let _internalClipboard: {
|
|
152
|
+
text: string;
|
|
153
|
+
rawValue: unknown;
|
|
154
|
+
isRangeCopy: boolean;
|
|
155
|
+
} | null = null;
|
|
156
|
+
|
|
157
|
+
export function setInternalClipboard(
|
|
158
|
+
text: string,
|
|
159
|
+
rawValue: unknown,
|
|
160
|
+
isRangeCopy = false,
|
|
161
|
+
) {
|
|
162
|
+
_internalClipboard = { text, rawValue, isRangeCopy };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function getInternalClipboardValue(
|
|
166
|
+
systemClipboardText: string,
|
|
167
|
+
): unknown | undefined {
|
|
168
|
+
if (_internalClipboard && _internalClipboard.text === systemClipboardText) {
|
|
169
|
+
return _internalClipboard.rawValue;
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if the pasted text originated from our own range copy.
|
|
176
|
+
* When true, "NULL" cells in TSV should be treated as SQL null.
|
|
177
|
+
*/
|
|
178
|
+
export function isInternalRangeCopy(systemClipboardText: string): boolean {
|
|
179
|
+
return (
|
|
180
|
+
_internalClipboard !== null &&
|
|
181
|
+
_internalClipboard.text === systemClipboardText &&
|
|
182
|
+
_internalClipboard.isRangeCopy
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import type { DatabaseConfig, ExportType } from "../types";
|
|
3
|
+
|
|
4
|
+
interface DatabaseMenuProps {
|
|
5
|
+
databaseConfig: DatabaseConfig;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function DatabaseMenu({ databaseConfig }: DatabaseMenuProps) {
|
|
9
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
10
|
+
const [exporting, setExporting] = useState(false);
|
|
11
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
function handleClickOutside(e: MouseEvent) {
|
|
15
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
16
|
+
setMenuOpen(false);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (menuOpen) {
|
|
20
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
21
|
+
return () =>
|
|
22
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
23
|
+
}
|
|
24
|
+
}, [menuOpen]);
|
|
25
|
+
|
|
26
|
+
async function handleExport(exportType: ExportType) {
|
|
27
|
+
setMenuOpen(false);
|
|
28
|
+
setExporting(true);
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch("/api/export", {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
connection: databaseConfig.connection,
|
|
35
|
+
exportType,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
alert(data.error || "Export failed");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const blob = await res.blob();
|
|
46
|
+
const disposition = res.headers.get("Content-Disposition") ?? "";
|
|
47
|
+
const match = disposition.match(/filename="(.+)"/);
|
|
48
|
+
const filename =
|
|
49
|
+
match?.[1] ?? `${databaseConfig.connection.database}.sql`;
|
|
50
|
+
|
|
51
|
+
const url = URL.createObjectURL(blob);
|
|
52
|
+
const a = document.createElement("a");
|
|
53
|
+
a.href = url;
|
|
54
|
+
a.download = filename;
|
|
55
|
+
document.body.appendChild(a);
|
|
56
|
+
a.click();
|
|
57
|
+
a.remove();
|
|
58
|
+
URL.revokeObjectURL(url);
|
|
59
|
+
} catch {
|
|
60
|
+
alert("Export failed — could not reach server");
|
|
61
|
+
} finally {
|
|
62
|
+
setExporting(false);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="relative" ref={menuRef}>
|
|
68
|
+
<button
|
|
69
|
+
className="px-3 h-8 rounded-md text-[13px] font-semibold text-secondary hover:text-primary hover:bg-stone-200/50 dark:hover:bg-white/[0.06] transition-all duration-150 disabled:opacity-50"
|
|
70
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
71
|
+
disabled={exporting}
|
|
72
|
+
>
|
|
73
|
+
{exporting ? "Exporting..." : "Database"}
|
|
74
|
+
</button>
|
|
75
|
+
{menuOpen && (
|
|
76
|
+
<div className="absolute top-full left-0 mt-1 p-1 min-w-[200px] bg-white/90 dark:bg-[#2a2a2a]/90 backdrop-blur-xl border border-stone-200/50 dark:border-white/10 rounded-lg shadow-xl z-50">
|
|
77
|
+
<button
|
|
78
|
+
className="w-full px-2.5 py-1 text-left text-[13px] text-primary rounded-md hover:bg-stone-100 dark:hover:bg-white/10 transition-colors"
|
|
79
|
+
onClick={() => handleExport("schema")}
|
|
80
|
+
>
|
|
81
|
+
Export Schema
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
className="w-full px-2.5 py-1 text-left text-[13px] text-primary rounded-md hover:bg-stone-100 dark:hover:bg-white/10 transition-colors"
|
|
85
|
+
onClick={() => handleExport("schema-and-data")}
|
|
86
|
+
>
|
|
87
|
+
Export Schema + Data
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|