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.
Files changed (69) hide show
  1. package/README.md +73 -0
  2. package/bin/cli.js +83 -0
  3. package/bin/install-local.js +57 -0
  4. package/electron/generate-icon.mjs +54 -0
  5. package/electron/icon.icns +0 -0
  6. package/electron/icon.png +0 -0
  7. package/electron/icon.svg +21 -0
  8. package/electron/main.js +169 -0
  9. package/electron/patch-dev-plist.js +31 -0
  10. package/electron/preload.cjs +18 -0
  11. package/electron/wait-for-vite.js +43 -0
  12. package/index.html +13 -0
  13. package/package.json +91 -0
  14. package/public/favicon.svg +15 -0
  15. package/public/vite.svg +1 -0
  16. package/server/export.ts +57 -0
  17. package/server/index.ts +392 -0
  18. package/src/App.css +1 -0
  19. package/src/App.tsx +543 -0
  20. package/src/assets/react.svg +1 -0
  21. package/src/components/CommandPalette.tsx +243 -0
  22. package/src/components/ConnectedView.tsx +78 -0
  23. package/src/components/ConnectionPicker.tsx +381 -0
  24. package/src/components/ConsoleView.tsx +360 -0
  25. package/src/components/CsvExportModal.tsx +144 -0
  26. package/src/components/DataGrid/DataGrid.tsx +262 -0
  27. package/src/components/DataGrid/DataGridCell.tsx +73 -0
  28. package/src/components/DataGrid/DataGridHeader.tsx +89 -0
  29. package/src/components/DataGrid/index.ts +20 -0
  30. package/src/components/DataGrid/types.ts +63 -0
  31. package/src/components/DataGrid/useColumnResize.ts +153 -0
  32. package/src/components/DataGrid/useDataGridSelection.ts +340 -0
  33. package/src/components/DataGrid/utils.ts +184 -0
  34. package/src/components/DatabaseMenu.tsx +93 -0
  35. package/src/components/DatabaseSwitcher.tsx +208 -0
  36. package/src/components/DiffView.tsx +215 -0
  37. package/src/components/EditConnectionModal.tsx +417 -0
  38. package/src/components/ErrorBoundary.tsx +69 -0
  39. package/src/components/GlobalShortcuts.tsx +201 -0
  40. package/src/components/InnerTabBar.tsx +129 -0
  41. package/src/components/JsonTreeViewer.tsx +387 -0
  42. package/src/components/MemberAccessEditor.tsx +443 -0
  43. package/src/components/MembersModal.tsx +446 -0
  44. package/src/components/NewConnectionModal.tsx +274 -0
  45. package/src/components/Resizer.tsx +66 -0
  46. package/src/components/ScanSuccessModal.tsx +113 -0
  47. package/src/components/ShortcutSettingsModal.tsx +318 -0
  48. package/src/components/Sidebar.tsx +532 -0
  49. package/src/components/TabBar.tsx +188 -0
  50. package/src/components/TableView.tsx +2147 -0
  51. package/src/components/ThemeToggle.tsx +44 -0
  52. package/src/components/index.ts +17 -0
  53. package/src/constants.ts +12 -0
  54. package/src/electron.d.ts +12 -0
  55. package/src/index.css +44 -0
  56. package/src/main.tsx +13 -0
  57. package/src/stores/hooks.ts +1146 -0
  58. package/src/stores/index.ts +12 -0
  59. package/src/stores/store.ts +1514 -0
  60. package/src/stores/useCloudSync.ts +274 -0
  61. package/src/stores/useSyncDatabase.ts +422 -0
  62. package/src/types.ts +277 -0
  63. package/src/utils/csv.ts +27 -0
  64. package/src/vite-env.d.ts +2 -0
  65. package/tsconfig.app.json +28 -0
  66. package/tsconfig.json +7 -0
  67. package/tsconfig.node.json +26 -0
  68. package/tsconfig.server.json +14 -0
  69. package/vite.config.ts +14 -0
@@ -0,0 +1,2147 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import {
9
+ Braces,
10
+ Check,
11
+ ChevronLeft,
12
+ ChevronRight,
13
+ Download,
14
+ ExternalLink,
15
+ Minus,
16
+ Plus,
17
+ RotateCcw,
18
+ Trash2,
19
+ XIcon,
20
+ } from "lucide-react";
21
+ import { PAGE_SIZE } from "../constants";
22
+ import {
23
+ useTableExecution,
24
+ useTableState,
25
+ useTableCellEdit,
26
+ useTablePrimaryKey,
27
+ useTableMetadata,
28
+ useGenerateCombinedQueries,
29
+ useOpenConsoleWithQuery,
30
+ useForeignKeyMap,
31
+ useOpenTableTab,
32
+ useIncomingForeignKeys,
33
+ formatWhereValue,
34
+ getQuotedTableName,
35
+ useHotkey,
36
+ type ForeignKeyRef,
37
+ type IncomingForeignKey,
38
+ } from "../stores/hooks";
39
+ import { useStore } from "../stores/store";
40
+ import { CsvExportModal } from "./CsvExportModal";
41
+ import { JsonTreeViewer } from "./JsonTreeViewer";
42
+ import {
43
+ DataGrid,
44
+ type DataGridCellProps,
45
+ type DataGridSelection,
46
+ type ExtraRow,
47
+ type RangeEdges,
48
+ storeToVirtualIndex,
49
+ virtualToStoreIndex,
50
+ getSelectedRowIndices,
51
+ getCellRangeInfo,
52
+ formatCellValue,
53
+ getInternalClipboardValue,
54
+ parseTSV,
55
+ isInternalRangeCopy,
56
+ } from "./DataGrid";
57
+
58
+ // Check if a column's data type is a date/datetime type
59
+ function getDateColumnType(dataType: string): "date" | "datetime" | null {
60
+ const dt = dataType.toLowerCase();
61
+ if (dt === "date") return "date";
62
+ if (dt.startsWith("timestamp") || dt === "timestamptz" || dt === "datetime")
63
+ return "datetime";
64
+ return null;
65
+ }
66
+
67
+ function isJsonColumn(dataType: string): boolean {
68
+ const dt = dataType.toLowerCase();
69
+ return dt === "json" || dt === "jsonb";
70
+ }
71
+
72
+ function tryParseJson(value: unknown): unknown | null {
73
+ if (value !== null && typeof value === "object") return value;
74
+ if (typeof value === "string") {
75
+ const trimmed = value.trim();
76
+ if (
77
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
78
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
79
+ ) {
80
+ try {
81
+ return JSON.parse(trimmed);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ // Convert a cell's edit value string to the native input format
91
+ function toNativeDateValue(
92
+ value: string | null,
93
+ type: "date" | "datetime",
94
+ ): string {
95
+ if (!value) return "";
96
+ try {
97
+ const d = new Date(value);
98
+ if (isNaN(d.getTime())) return "";
99
+ if (type === "date") {
100
+ return d.toISOString().slice(0, 10);
101
+ }
102
+ const pad = (n: number) => String(n).padStart(2, "0");
103
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
104
+ } catch {
105
+ return "";
106
+ }
107
+ }
108
+
109
+ interface TableViewProps {
110
+ tabId: string;
111
+ tableName: string;
112
+ }
113
+
114
+ // Minimum height for the bottom panel
115
+ const MIN_BOTTOM_PANEL_HEIGHT = 100;
116
+ const DEFAULT_BOTTOM_PANEL_HEIGHT = 200;
117
+
118
+ export function TableView({ tabId, tableName }: TableViewProps) {
119
+ const tableState = useTableState(tabId);
120
+ const initTableState = useStore((state) => state.initTableState);
121
+ const setTableWhereClause = useStore((state) => state.setTableWhereClause);
122
+ const setTablePage = useStore((state) => state.setTablePage);
123
+ const toggleTableSort = useStore((state) => state.toggleTableSort);
124
+ const updateConfig = useStore((state) => state.updateConfig);
125
+ const { execute } = useTableExecution(tabId);
126
+
127
+ const activeDatabaseConfig = useStore((state) => {
128
+ const activeTab = state.connectionTabs.find(
129
+ (t) => t.id === state.activeTabId,
130
+ );
131
+ if (!activeTab?.databaseConfigId) return null;
132
+ return (
133
+ state.databaseConfigs.find((c) => c.id === activeTab.databaseConfigId) ??
134
+ null
135
+ );
136
+ });
137
+ const pageSize =
138
+ activeDatabaseConfig?.tableConfigs?.[tableName]?.pageSize ?? PAGE_SIZE;
139
+
140
+ // Cell editing state and actions
141
+ const cellEdit = useTableCellEdit(tabId);
142
+ const primaryKeyColumns = useTablePrimaryKey(tableName);
143
+ const tableMetadata = useTableMetadata(tableName);
144
+ const openConsoleWithQuery = useOpenConsoleWithQuery();
145
+ const foreignKeyMap = useForeignKeyMap(tableName);
146
+ const openTableTab = useOpenTableTab();
147
+ const incomingForeignKeys = useIncomingForeignKeys(tableName);
148
+
149
+ // Bottom panel resize state
150
+ const [bottomPanelHeight, setBottomPanelHeight] = useState(
151
+ DEFAULT_BOTTOM_PANEL_HEIGHT,
152
+ );
153
+ const [isResizing, setIsResizing] = useState(false);
154
+ const containerRef = useRef<HTMLDivElement>(null);
155
+ const tableScrollRef = useRef<HTMLDivElement>(null);
156
+ const tableRef = useRef<HTMLTableElement>(null);
157
+ const prevNewRowCountRef = useRef(0);
158
+
159
+ const rows = tableState.result?.rows ?? [];
160
+ const pendingNewRowCount = cellEdit.pendingNewRows.length;
161
+ const totalVirtualRowCount = rows.length + pendingNewRowCount;
162
+
163
+ const generateCombinedQueries = useGenerateCombinedQueries(
164
+ tabId,
165
+ tableName,
166
+ rows,
167
+ );
168
+
169
+ // FK preview config (persisted)
170
+ const fkPreviewColumns =
171
+ activeDatabaseConfig?.tableConfigs?.[tableName]?.fkPreviewColumns ?? {};
172
+
173
+ // FK preview data: fkColumnName -> Map<pkValue, displayValue>
174
+ const [fkPreviewData, setFkPreviewData] = useState<
175
+ Record<string, Map<string, string>>
176
+ >({});
177
+
178
+ // Header context menu state (for FK preview column picker)
179
+ const [headerContextMenu, setHeaderContextMenu] = useState<{
180
+ x: number;
181
+ y: number;
182
+ columnName: string;
183
+ foreignKeyRef: ForeignKeyRef;
184
+ } | null>(null);
185
+
186
+ // Context menu state
187
+ const [contextMenu, setContextMenu] = useState<{
188
+ x: number;
189
+ y: number;
190
+ rowIndex: number;
191
+ columnName: string;
192
+ } | null>(null);
193
+
194
+ // Handler: open header context menu for FK columns
195
+ const handleHeaderContextMenu = useCallback(
196
+ (columnName: string, e: React.MouseEvent) => {
197
+ const fkRef = foreignKeyMap.get(columnName);
198
+ if (!fkRef) return; // Only FK columns get a header context menu
199
+ setHeaderContextMenu({
200
+ x: e.clientX,
201
+ y: e.clientY,
202
+ columnName,
203
+ foreignKeyRef: fkRef,
204
+ });
205
+ },
206
+ [foreignKeyMap],
207
+ );
208
+
209
+ // Handler: set FK preview column choice (persist to config)
210
+ const handleSetFkPreviewColumn = useCallback(
211
+ (fkColumn: string, displayColumn: string | null) => {
212
+ if (!activeDatabaseConfig) return;
213
+ const existing =
214
+ activeDatabaseConfig.tableConfigs?.[tableName]?.fkPreviewColumns ?? {};
215
+ const updated = { ...existing };
216
+ if (displayColumn === null) {
217
+ delete updated[fkColumn];
218
+ } else {
219
+ updated[fkColumn] = displayColumn;
220
+ }
221
+ updateConfig(activeDatabaseConfig.id, {
222
+ tableConfigs: {
223
+ ...activeDatabaseConfig.tableConfigs,
224
+ [tableName]: {
225
+ ...activeDatabaseConfig.tableConfigs?.[tableName],
226
+ fkPreviewColumns: updated,
227
+ },
228
+ },
229
+ });
230
+ setHeaderContextMenu(null);
231
+ },
232
+ [activeDatabaseConfig, updateConfig, tableName],
233
+ );
234
+
235
+ // Get columns of the referenced table (for header context menu)
236
+ const getReferencedTableColumns = useCallback(
237
+ (ref: ForeignKeyRef): string[] => {
238
+ if (!activeDatabaseConfig?.cache?.schemas) return [];
239
+ const schema = activeDatabaseConfig.cache.schemas.find(
240
+ (s) => s.name === ref.schema,
241
+ );
242
+ if (!schema) return [];
243
+ const table = schema.tables.find((t) => t.name === ref.table);
244
+ if (!table) return [];
245
+ return table.columns.map((c) => c.name);
246
+ },
247
+ [activeDatabaseConfig],
248
+ );
249
+
250
+ const [showCsvExport, setShowCsvExport] = useState(false);
251
+
252
+ const fetchAllRowsForExport = useCallback(async () => {
253
+ if (!activeDatabaseConfig) throw new Error("No database connection");
254
+ const whereFragment = tableState.whereClause.trim()
255
+ ? ` WHERE ${tableState.whereClause}`
256
+ : "";
257
+ const quotedTable = getQuotedTableName(tableName);
258
+ let query = `SELECT * FROM ${quotedTable}${whereFragment}`;
259
+ if (tableState.sortColumns.length > 0) {
260
+ const orderByParts = tableState.sortColumns.map(
261
+ (s) => `"${s.column}" ${s.direction}`,
262
+ );
263
+ query += ` ORDER BY ${orderByParts.join(", ")}`;
264
+ }
265
+ const res = await fetch("/api/query", {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify({
269
+ connection: activeDatabaseConfig.connection,
270
+ query,
271
+ }),
272
+ });
273
+ const data = await res.json();
274
+ if (!res.ok) throw new Error(data.error ?? "Query failed");
275
+ return data.rows as Record<string, unknown>[];
276
+ }, [
277
+ activeDatabaseConfig,
278
+ tableState.whereClause,
279
+ tableState.sortColumns,
280
+ tableName,
281
+ ]);
282
+
283
+ // Can edit if we have a primary key
284
+ const canEdit = primaryKeyColumns.length > 0;
285
+
286
+ // Initialize state on mount
287
+ useEffect(() => {
288
+ initTableState(tabId, tableName);
289
+ }, [tabId, tableName, initTableState]);
290
+
291
+ // Auto-execute on first load
292
+ useEffect(() => {
293
+ if (tableState.status === "idle" && tableState.tableName === tableName) {
294
+ execute();
295
+ }
296
+ }, [tableState.status, tableState.tableName, tableName, execute]);
297
+
298
+ // Fetch FK preview data when results change or FK preview config changes
299
+ useEffect(() => {
300
+ if (!tableState.result || !activeDatabaseConfig) return;
301
+ const entries = Object.entries(fkPreviewColumns);
302
+ if (entries.length === 0) {
303
+ setFkPreviewData({});
304
+ return;
305
+ }
306
+
307
+ let cancelled = false;
308
+
309
+ async function fetchPreviews() {
310
+ const newPreviewData: Record<string, Map<string, string>> = {};
311
+
312
+ await Promise.all(
313
+ entries.map(async ([fkCol, displayCol]) => {
314
+ const fkRef = foreignKeyMap.get(fkCol);
315
+ if (!fkRef) return;
316
+
317
+ // Collect distinct non-null FK values from current rows
318
+ const values = new Set<string>();
319
+ for (const row of rows) {
320
+ const v = row[fkCol];
321
+ if (v !== null && v !== undefined) {
322
+ values.add(String(v));
323
+ }
324
+ }
325
+ if (values.size === 0) return;
326
+
327
+ const quotedRefTable =
328
+ fkRef.schema === "public"
329
+ ? `"${fkRef.table}"`
330
+ : `"${fkRef.schema}"."${fkRef.table}"`;
331
+ const inList = Array.from(values)
332
+ .map((v) => `'${v.replace(/'/g, "''")}'`)
333
+ .join(", ");
334
+ const query = `SELECT DISTINCT "${fkRef.column}", "${displayCol}" FROM ${quotedRefTable} WHERE "${fkRef.column}" IN (${inList})`;
335
+
336
+ try {
337
+ const res = await fetch("/api/query", {
338
+ method: "POST",
339
+ headers: { "Content-Type": "application/json" },
340
+ body: JSON.stringify({
341
+ connection: activeDatabaseConfig!.connection,
342
+ query,
343
+ }),
344
+ });
345
+ if (!res.ok) return;
346
+ const data = await res.json();
347
+ if (cancelled) return;
348
+ const map = new Map<string, string>();
349
+ for (const row of data.rows ?? []) {
350
+ const pk = String(row[fkRef.column] ?? "");
351
+ const display = row[displayCol];
352
+ map.set(pk, display === null ? "NULL" : String(display));
353
+ }
354
+ newPreviewData[fkCol] = map;
355
+ } catch {
356
+ // Silently ignore — cells just show raw value
357
+ }
358
+ }),
359
+ );
360
+
361
+ if (!cancelled) {
362
+ setFkPreviewData(newPreviewData);
363
+ }
364
+ }
365
+
366
+ fetchPreviews();
367
+ return () => {
368
+ cancelled = true;
369
+ };
370
+ }, [
371
+ tableState.result,
372
+ fkPreviewColumns,
373
+ foreignKeyMap,
374
+ activeDatabaseConfig,
375
+ rows,
376
+ ]);
377
+
378
+ // Scroll to bottom when a new row is added
379
+ useEffect(() => {
380
+ const currentCount = cellEdit.pendingNewRows.length;
381
+ if (currentCount > prevNewRowCountRef.current) {
382
+ tableScrollRef.current?.scrollTo(0, tableScrollRef.current.scrollHeight);
383
+ }
384
+ prevNewRowCountRef.current = currentCount;
385
+ }, [cellEdit.pendingNewRows.length]);
386
+
387
+ const handleWhereChange = useCallback(
388
+ (e: React.ChangeEvent<HTMLInputElement>) => {
389
+ setTableWhereClause(tabId, e.target.value);
390
+ },
391
+ [tabId, setTableWhereClause],
392
+ );
393
+
394
+ const handleWhereKeyDown = useCallback(
395
+ (e: React.KeyboardEvent) => {
396
+ if (e.key === "Enter") {
397
+ execute();
398
+ }
399
+ },
400
+ [execute],
401
+ );
402
+
403
+ // Sort change handler for DataGrid
404
+ const handleSortChange = useCallback(
405
+ (columnName: string, addToExisting: boolean) => {
406
+ toggleTableSort(tabId, columnName, addToExisting);
407
+ setTimeout(execute, 0);
408
+ },
409
+ [tabId, toggleTableSort, execute],
410
+ );
411
+
412
+ // Cell double-click handler - starts editing
413
+ const handleCellDoubleClick = useCallback(
414
+ (rowIndex: number, columnName: string, value: unknown) => {
415
+ const isNewRow = rowIndex < 0;
416
+ if (!canEdit && !isNewRow) return;
417
+ const initialValue =
418
+ value === null
419
+ ? null
420
+ : typeof value === "object"
421
+ ? JSON.stringify(value)
422
+ : String(value ?? "");
423
+ cellEdit.startEditingCell({ rowIndex, columnName }, initialValue);
424
+ },
425
+ [canEdit, cellEdit],
426
+ );
427
+
428
+ // Bridge: DataGrid selection → Zustand cellEdit state
429
+ const dataGridSelection: DataGridSelection = useMemo(
430
+ () => ({
431
+ selectedCell: cellEdit.selectedCell,
432
+ selectedRange: cellEdit.selectedRange,
433
+ isDragging: cellEdit.isDragging,
434
+ }),
435
+ [cellEdit.selectedCell, cellEdit.selectedRange, cellEdit.isDragging],
436
+ );
437
+
438
+ const handleSelectionChange = useCallback(
439
+ (sel: DataGridSelection) => {
440
+ // Enforce canEdit check: only allow selection for editable tables or new rows
441
+ if (sel.selectedCell) {
442
+ const isNewRow = sel.selectedCell.rowIndex < 0;
443
+ if (!canEdit && !isNewRow) return;
444
+ }
445
+ if (sel.isDragging !== cellEdit.isDragging) {
446
+ cellEdit.setCellDragging(sel.isDragging);
447
+ }
448
+ if (sel.selectedRange !== cellEdit.selectedRange) {
449
+ if (sel.selectedRange) {
450
+ cellEdit.selectCellRange(sel.selectedRange);
451
+ }
452
+ }
453
+ if (sel.selectedCell !== cellEdit.selectedCell) {
454
+ cellEdit.selectCell(sel.selectedCell);
455
+ }
456
+ // Handle selectAll: if both cell and range changed at once
457
+ if (
458
+ sel.selectedCell &&
459
+ sel.selectedRange &&
460
+ sel.selectedCell !== cellEdit.selectedCell
461
+ ) {
462
+ cellEdit.selectCell(sel.selectedCell);
463
+ cellEdit.selectCellRange(sel.selectedRange);
464
+ }
465
+ },
466
+ [canEdit, cellEdit],
467
+ );
468
+
469
+ // onKeyDown intercept: handle editing keys before DataGrid handles nav/copy
470
+ const handleKeyDown = useCallback(
471
+ (e: KeyboardEvent): boolean => {
472
+ const target = e.target as HTMLElement;
473
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA")
474
+ return false;
475
+
476
+ const { selectedCell, editingCell } = cellEdit;
477
+
478
+ // Cmd+V / Ctrl+V to paste
479
+ if (
480
+ selectedCell &&
481
+ !editingCell &&
482
+ e.key === "v" &&
483
+ (e.metaKey || e.ctrlKey)
484
+ ) {
485
+ e.preventDefault();
486
+ const isNewRow = selectedCell.rowIndex < 0;
487
+ if (!canEdit && !isNewRow) return true;
488
+
489
+ const fields = tableState.result?.fields ?? [];
490
+ const columnNames = fields.map((f) => f.name);
491
+ const existingRowCount = rows.length;
492
+ const extraRowCount = cellEdit.pendingNewRows.length;
493
+ const totalRows = existingRowCount + extraRowCount;
494
+
495
+ navigator.clipboard.readText().then((pastedText) => {
496
+ const parsed = parseTSV(pastedText);
497
+
498
+ if (parsed) {
499
+ // Multi-cell paste
500
+ const isInternal = isInternalRangeCopy(pastedText);
501
+ const { selectedRange } = cellEdit;
502
+
503
+ let anchorVirtual: number;
504
+ let anchorColIdx: number;
505
+ let pasteRowCount: number;
506
+ let pasteColCount: number;
507
+
508
+ if (selectedRange) {
509
+ // Paste into selected range — clamp to range dimensions
510
+ const startV = storeToVirtualIndex(
511
+ selectedRange.start.rowIndex,
512
+ existingRowCount,
513
+ );
514
+ const endV = storeToVirtualIndex(
515
+ selectedRange.end.rowIndex,
516
+ existingRowCount,
517
+ );
518
+ anchorVirtual = Math.min(startV, endV);
519
+ const startCI = columnNames.indexOf(
520
+ selectedRange.start.columnName,
521
+ );
522
+ const endCI = columnNames.indexOf(selectedRange.end.columnName);
523
+ anchorColIdx = Math.min(startCI, endCI);
524
+ pasteRowCount = Math.min(
525
+ parsed.length,
526
+ Math.abs(endV - startV) + 1,
527
+ );
528
+ pasteColCount = Math.min(
529
+ parsed[0]?.length ?? 0,
530
+ Math.abs(endCI - startCI) + 1,
531
+ );
532
+ } else {
533
+ // Single cell anchor — expand rightward/downward, clamp to grid
534
+ anchorVirtual = storeToVirtualIndex(
535
+ selectedCell.rowIndex,
536
+ existingRowCount,
537
+ );
538
+ anchorColIdx = columnNames.indexOf(selectedCell.columnName);
539
+ pasteRowCount = Math.min(
540
+ parsed.length,
541
+ totalRows - anchorVirtual,
542
+ );
543
+ pasteColCount = Math.min(
544
+ parsed[0]?.length ?? 0,
545
+ columnNames.length - anchorColIdx,
546
+ );
547
+ }
548
+
549
+ const cells: Array<{
550
+ rowIndex: number;
551
+ columnName: string;
552
+ value: string | null;
553
+ }> = [];
554
+
555
+ for (let r = 0; r < pasteRowCount; r++) {
556
+ const tsvRow = parsed[r] ?? [];
557
+ for (let c = 0; c < pasteColCount; c++) {
558
+ const rawVal = tsvRow[c] ?? "";
559
+ const value = isInternal && rawVal === "NULL" ? null : rawVal;
560
+ const rowIdx = virtualToStoreIndex(
561
+ anchorVirtual + r,
562
+ existingRowCount,
563
+ );
564
+ const colName = columnNames[anchorColIdx + c];
565
+ if (colName !== undefined) {
566
+ cells.push({ rowIndex: rowIdx, columnName: colName, value });
567
+ }
568
+ }
569
+ }
570
+
571
+ if (cells.length > 0) {
572
+ cellEdit.pasteCellRange(cells);
573
+ }
574
+ } else {
575
+ // Single-cell paste (unchanged behavior)
576
+ const rawValue = getInternalClipboardValue(pastedText);
577
+ if (rawValue === null) {
578
+ cellEdit.setCellToNull(selectedCell);
579
+ } else {
580
+ cellEdit.startEditingCell(selectedCell, pastedText);
581
+ setTimeout(() => {
582
+ cellEdit.commitCellEdit();
583
+ }, 0);
584
+ }
585
+ }
586
+ });
587
+ return true;
588
+ }
589
+
590
+ // Enter/F2 to start editing
591
+ if (
592
+ selectedCell &&
593
+ !editingCell &&
594
+ (e.key === "Enter" || e.key === "F2")
595
+ ) {
596
+ e.preventDefault();
597
+ if (selectedCell.rowIndex < 0) {
598
+ const newRowIndex = Math.abs(selectedCell.rowIndex) - 1;
599
+ const newRow = cellEdit.pendingNewRows[newRowIndex];
600
+ if (newRow) {
601
+ const isExplicitlySet = newRow.explicitlySetColumns.has(
602
+ selectedCell.columnName,
603
+ );
604
+ const value = isExplicitlySet
605
+ ? newRow.values[selectedCell.columnName]
606
+ : null;
607
+ const initialValue =
608
+ value === null
609
+ ? null
610
+ : typeof value === "object"
611
+ ? JSON.stringify(value)
612
+ : String(value);
613
+ cellEdit.startEditingCell(selectedCell, initialValue);
614
+ }
615
+ } else {
616
+ const row = rows[selectedCell.rowIndex];
617
+ const value = row?.[selectedCell.columnName];
618
+ const initialValue =
619
+ value === null
620
+ ? null
621
+ : typeof value === "object"
622
+ ? JSON.stringify(value)
623
+ : String(value ?? "");
624
+ cellEdit.startEditingCell(selectedCell, initialValue);
625
+ }
626
+ return true;
627
+ }
628
+
629
+ // Escape to cancel edit (DataGrid handles deselect)
630
+ if (e.key === "Escape" && editingCell) {
631
+ e.preventDefault();
632
+ cellEdit.cancelCellEdit();
633
+ return true;
634
+ }
635
+
636
+ return false;
637
+ },
638
+ [cellEdit, rows, canEdit, tableState.result],
639
+ );
640
+
641
+ // Handle Apply Changes button
642
+ const handleApplyChanges = useCallback(() => {
643
+ const sql = generateCombinedQueries();
644
+ if (sql) {
645
+ openConsoleWithQuery(sql);
646
+ cellEdit.clearPendingChanges();
647
+ }
648
+ }, [generateCombinedQueries, openConsoleWithQuery, cellEdit]);
649
+
650
+ // Handle Add Row button
651
+ const handleAddRow = useCallback(() => {
652
+ cellEdit.addNewRow();
653
+ }, [cellEdit]);
654
+
655
+ // Handle Delete Rows button
656
+ const handleDeleteRows = useCallback(() => {
657
+ const rowIndices = getSelectedRowIndices(
658
+ cellEdit.selectedCell,
659
+ cellEdit.selectedRange,
660
+ rows.length,
661
+ );
662
+ if (rowIndices.length > 0) {
663
+ cellEdit.markRowsForDeletion(rowIndices);
664
+ }
665
+ }, [cellEdit, rows.length]);
666
+
667
+ // Register Delete key shortcut
668
+ useHotkey("deleteRows", handleDeleteRows, {
669
+ enabled: !!cellEdit.selectedCell,
670
+ });
671
+
672
+ // Handle Select All (Cmd+A / Ctrl+A)
673
+ const handleSelectAll = useCallback(() => {
674
+ const fields = tableState.result?.fields;
675
+ if (!fields || fields.length === 0 || totalVirtualRowCount === 0) return;
676
+ const firstCol = fields[0].name;
677
+ const lastCol = fields[fields.length - 1].name;
678
+ const lastStoreIndex = virtualToStoreIndex(
679
+ totalVirtualRowCount - 1,
680
+ rows.length,
681
+ );
682
+ cellEdit.selectCell({ rowIndex: 0, columnName: firstCol });
683
+ cellEdit.selectCellRange({
684
+ start: { rowIndex: 0, columnName: firstCol },
685
+ end: { rowIndex: lastStoreIndex, columnName: lastCol },
686
+ });
687
+ }, [tableState.result, totalVirtualRowCount, rows.length, cellEdit]);
688
+
689
+ useHotkey("selectAll", handleSelectAll);
690
+ useHotkey("refreshTable", execute);
691
+
692
+ // Handle Set to Default action from context menu (for new rows)
693
+ const handleSetToDefault = useCallback(() => {
694
+ if (contextMenu && contextMenu.rowIndex < 0) {
695
+ const newRowIndex = Math.abs(contextMenu.rowIndex) - 1;
696
+ const newRow = cellEdit.pendingNewRows[newRowIndex];
697
+ if (newRow) {
698
+ cellEdit.setNewRowToDefault(newRow.tempId, contextMenu.columnName);
699
+ }
700
+ setContextMenu(null);
701
+ }
702
+ }, [contextMenu, cellEdit]);
703
+
704
+ // Handle foreign key navigation
705
+ const handleNavigateToForeignKey = useCallback(
706
+ (ref: ForeignKeyRef, value: unknown) => {
707
+ const targetTable =
708
+ ref.schema === "public" ? ref.table : `${ref.schema}.${ref.table}`;
709
+ const whereClause = `"${ref.column}" = ${formatWhereValue(value)}`;
710
+ openTableTab(targetTable, { whereClause });
711
+ },
712
+ [openTableTab],
713
+ );
714
+
715
+ // Handle context menu
716
+ const handleCellContextMenu = useCallback(
717
+ (rowIndex: number, columnName: string, e: React.MouseEvent) => {
718
+ const isNewRow = rowIndex < 0;
719
+ if (!canEdit && !isNewRow) return;
720
+ e.preventDefault();
721
+ e.stopPropagation();
722
+ cellEdit.selectCell({ rowIndex, columnName });
723
+ setContextMenu({ x: e.clientX, y: e.clientY, rowIndex, columnName });
724
+ },
725
+ [canEdit, cellEdit],
726
+ );
727
+
728
+ // Handle Revert Cell action from context menu
729
+ const handleRevertCell = useCallback(() => {
730
+ if (contextMenu) {
731
+ cellEdit.revertCellChange({
732
+ rowIndex: contextMenu.rowIndex,
733
+ columnName: contextMenu.columnName,
734
+ });
735
+ setContextMenu(null);
736
+ }
737
+ }, [contextMenu, cellEdit]);
738
+
739
+ // Handle Set NULL action from context menu
740
+ const handleSetNull = useCallback(() => {
741
+ if (contextMenu) {
742
+ cellEdit.setCellToNull({
743
+ rowIndex: contextMenu.rowIndex,
744
+ columnName: contextMenu.columnName,
745
+ });
746
+ setContextMenu(null);
747
+ }
748
+ }, [contextMenu, cellEdit]);
749
+
750
+ // Close context menus on click outside or escape
751
+ useEffect(() => {
752
+ const handleClickOutside = (e: MouseEvent) => {
753
+ const target = e.target as HTMLElement;
754
+ if (!target.closest("[data-context-menu]")) {
755
+ setContextMenu(null);
756
+ setHeaderContextMenu(null);
757
+ }
758
+ };
759
+ const handleKeyDown = (e: KeyboardEvent) => {
760
+ if (e.key === "Escape") {
761
+ setContextMenu(null);
762
+ setHeaderContextMenu(null);
763
+ }
764
+ };
765
+ if (contextMenu || headerContextMenu) {
766
+ window.addEventListener("mousedown", handleClickOutside);
767
+ window.addEventListener("keydown", handleKeyDown);
768
+ return () => {
769
+ window.removeEventListener("mousedown", handleClickOutside);
770
+ window.removeEventListener("keydown", handleKeyDown);
771
+ };
772
+ }
773
+ }, [contextMenu, headerContextMenu]);
774
+
775
+ // Determine if exactly one row is selected
776
+ const selectedRowIndex = cellEdit.selectedCell?.rowIndex ?? null;
777
+ const hasRangeSelection = cellEdit.selectedRange !== null;
778
+ const isSingleRowSelected = selectedRowIndex !== null && !hasRangeSelection;
779
+ const selectedRow = isSingleRowSelected ? rows[selectedRowIndex] : null;
780
+
781
+ // Derive JSON data for selected cell (for bottom panel viewer)
782
+ // Uses pending change value if one exists, so edits are reflected immediately
783
+ const selectedCellJsonData = useMemo(() => {
784
+ if (!isSingleRowSelected || !cellEdit.selectedCell) return null;
785
+ const { rowIndex, columnName } = cellEdit.selectedCell;
786
+ const col = tableMetadata?.columns.find((c) => c.name === columnName);
787
+ const pendingChange = cellEdit.pendingChanges[`${rowIndex}:${columnName}`];
788
+ const value = pendingChange
789
+ ? pendingChange.newValue
790
+ : selectedRow?.[columnName];
791
+ const isJson = col ? isJsonColumn(col.dataType) : false;
792
+ const parsed = isJson
793
+ ? typeof value === "object" && value !== null
794
+ ? value
795
+ : tryParseJson(value)
796
+ : tryParseJson(value);
797
+ if (parsed === null) return null;
798
+ return { columnName, data: parsed };
799
+ }, [
800
+ isSingleRowSelected,
801
+ cellEdit.selectedCell,
802
+ cellEdit.pendingChanges,
803
+ selectedRow,
804
+ tableMetadata,
805
+ ]);
806
+
807
+ // Handle JSON edit from tree viewer
808
+ const handleJsonEdit = useCallback(
809
+ (newData: unknown) => {
810
+ if (!cellEdit.selectedCell) return;
811
+ cellEdit.startEditingCell(
812
+ cellEdit.selectedCell,
813
+ JSON.stringify(selectedRow?.[cellEdit.selectedCell.columnName]),
814
+ );
815
+ cellEdit.updateEditValue(JSON.stringify(newData));
816
+ // Use setTimeout to ensure startEditingCell has been processed
817
+ setTimeout(() => {
818
+ cellEdit.commitCellEdit();
819
+ }, 0);
820
+ },
821
+ [cellEdit, selectedRow],
822
+ );
823
+
824
+ // Handle bottom panel resize
825
+ const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
826
+ e.preventDefault();
827
+ setIsResizing(true);
828
+ }, []);
829
+
830
+ useEffect(() => {
831
+ if (!isResizing) return;
832
+
833
+ const handleMouseMove = (e: MouseEvent) => {
834
+ if (!containerRef.current) return;
835
+ const containerRect = containerRef.current.getBoundingClientRect();
836
+ const newHeight = containerRect.bottom - e.clientY;
837
+ setBottomPanelHeight(
838
+ Math.max(
839
+ MIN_BOTTOM_PANEL_HEIGHT,
840
+ Math.min(newHeight, containerRect.height - 200),
841
+ ),
842
+ );
843
+ };
844
+
845
+ const handleMouseUp = () => {
846
+ setIsResizing(false);
847
+ };
848
+
849
+ window.addEventListener("mousemove", handleMouseMove);
850
+ window.addEventListener("mouseup", handleMouseUp);
851
+ return () => {
852
+ window.removeEventListener("mousemove", handleMouseMove);
853
+ window.removeEventListener("mouseup", handleMouseUp);
854
+ };
855
+ }, [isResizing]);
856
+
857
+ // Handle incoming FK navigation
858
+ const handleIncomingFKClick = useCallback(
859
+ (fk: IncomingForeignKey) => {
860
+ if (!selectedRow) return;
861
+ const targetValue = selectedRow[fk.toColumn];
862
+ const targetTable =
863
+ fk.fromSchema === "public"
864
+ ? fk.fromTable
865
+ : `${fk.fromSchema}.${fk.fromTable}`;
866
+ const whereClause = `"${fk.fromColumn}" = ${formatWhereValue(
867
+ targetValue,
868
+ )}`;
869
+ openTableTab(targetTable, { whereClause });
870
+ },
871
+ [selectedRow, openTableTab],
872
+ );
873
+
874
+ const {
875
+ status,
876
+ result,
877
+ error,
878
+ whereClause,
879
+ sortColumns,
880
+ currentPage,
881
+ totalRowCount,
882
+ } = tableState;
883
+ const totalPages =
884
+ totalRowCount != null ? Math.ceil(totalRowCount / pageSize) : null;
885
+ const pendingChangesCount =
886
+ Object.keys(cellEdit.pendingChanges).length +
887
+ cellEdit.pendingNewRows.length +
888
+ cellEdit.pendingDeletions.length;
889
+
890
+ const handlePrevPage = useCallback(() => {
891
+ if (currentPage > 0) {
892
+ setTablePage(tabId, currentPage - 1);
893
+ tableScrollRef.current?.scrollTo(0, 0);
894
+ setTimeout(execute, 0);
895
+ }
896
+ }, [tabId, currentPage, setTablePage, execute]);
897
+
898
+ const handleNextPage = useCallback(() => {
899
+ if (totalPages != null && currentPage < totalPages - 1) {
900
+ setTablePage(tabId, currentPage + 1);
901
+ tableScrollRef.current?.scrollTo(0, 0);
902
+ setTimeout(execute, 0);
903
+ }
904
+ }, [tabId, currentPage, totalPages, setTablePage, execute]);
905
+
906
+ const handlePageSizeChange = useCallback(
907
+ (newPageSize: number) => {
908
+ if (!activeDatabaseConfig) return;
909
+ updateConfig(activeDatabaseConfig.id, {
910
+ tableConfigs: {
911
+ ...activeDatabaseConfig.tableConfigs,
912
+ [tableName]: {
913
+ ...activeDatabaseConfig.tableConfigs?.[tableName],
914
+ pageSize: newPageSize,
915
+ },
916
+ },
917
+ });
918
+ setTablePage(tabId, 0);
919
+ setTimeout(execute, 0);
920
+ },
921
+ [
922
+ activeDatabaseConfig,
923
+ updateConfig,
924
+ tabId,
925
+ tableName,
926
+ setTablePage,
927
+ execute,
928
+ ],
929
+ );
930
+
931
+ // Build extraRows for DataGrid from pendingNewRows
932
+ const extraRows: ExtraRow[] = useMemo(
933
+ () =>
934
+ cellEdit.pendingNewRows.map((nr) => ({
935
+ key: nr.tempId,
936
+ data: nr.values as Record<string, unknown>,
937
+ })),
938
+ [cellEdit.pendingNewRows],
939
+ );
940
+
941
+ // Set of columns with active FK preview (for header indicator)
942
+ const fkPreviewActiveColumns = useMemo(
943
+ () => new Set(Object.keys(fkPreviewColumns)),
944
+ [fkPreviewColumns],
945
+ );
946
+
947
+ // renderCell: renders EditableCell for existing rows, NewRowCell for new rows
948
+ const renderCell = useCallback(
949
+ (props: DataGridCellProps) => {
950
+ const { rowIndex, columnName, columnIndex } = props;
951
+ const isNewRow = rowIndex < 0;
952
+
953
+ if (isNewRow) {
954
+ const newRowArrayIndex = Math.abs(rowIndex) - 1;
955
+ const newRow = cellEdit.pendingNewRows[newRowArrayIndex];
956
+ if (!newRow) return null;
957
+
958
+ const columnInfo = tableMetadata?.columns.find(
959
+ (c) => c.name === columnName,
960
+ );
961
+ const isExplicitlySet = newRow.explicitlySetColumns.has(columnName);
962
+ const explicitValue = newRow.values[columnName];
963
+ const defaultValue = columnInfo?.defaultValue;
964
+
965
+ let displayContent: React.ReactNode;
966
+ let cellValue: unknown;
967
+
968
+ if (isExplicitlySet) {
969
+ cellValue = explicitValue;
970
+ displayContent =
971
+ explicitValue === null ? (
972
+ <span className="text-tertiary italic">NULL</span>
973
+ ) : (
974
+ explicitValue
975
+ );
976
+ } else if (defaultValue !== null && defaultValue !== undefined) {
977
+ cellValue = null;
978
+ displayContent = (
979
+ <span className="text-tertiary italic">{defaultValue}</span>
980
+ );
981
+ } else {
982
+ cellValue = null;
983
+ const dataType = columnInfo?.dataType ?? "";
984
+ const isAutoIncrement = dataType.includes("serial");
985
+ const isNullable = columnInfo?.isNullable ?? false;
986
+
987
+ if (isAutoIncrement) {
988
+ displayContent = (
989
+ <span className="text-tertiary italic">(auto)</span>
990
+ );
991
+ } else if (isNullable) {
992
+ displayContent = <span className="text-tertiary italic">NULL</span>;
993
+ } else {
994
+ displayContent = <span className="text-tertiary italic">—</span>;
995
+ }
996
+ }
997
+
998
+ const rangeInfo = getCellRangeInfo(
999
+ { rowIndex, columnName },
1000
+ cellEdit.selectedRange,
1001
+ result?.fields.map((f) => f.name) ?? [],
1002
+ rows.length,
1003
+ );
1004
+
1005
+ const isEditingThisCell =
1006
+ cellEdit.editingCell?.rowIndex === rowIndex &&
1007
+ cellEdit.editingCell?.columnName === columnName;
1008
+
1009
+ return (
1010
+ <NewRowCell
1011
+ key={columnIndex}
1012
+ rowIndex={rowIndex}
1013
+ columnName={columnName}
1014
+ dateColumnType={getDateColumnType(columnInfo?.dataType ?? "")}
1015
+ displayContent={displayContent}
1016
+ value={cellValue}
1017
+ isExplicitlySet={isExplicitlySet}
1018
+ isSelected={
1019
+ cellEdit.selectedCell?.rowIndex === rowIndex &&
1020
+ cellEdit.selectedCell?.columnName === columnName
1021
+ }
1022
+ isEditing={isEditingThisCell}
1023
+ isInRange={rangeInfo.isInRange}
1024
+ rangeEdges={rangeInfo.edges}
1025
+ editValue={isEditingThisCell ? (cellEdit.editValue ?? "") : ""}
1026
+ onUpdateEditValue={cellEdit.updateEditValue}
1027
+ onCommitEdit={cellEdit.commitCellEdit}
1028
+ onCancelEdit={cellEdit.cancelCellEdit}
1029
+ onClick={props.onClick}
1030
+ onDoubleClick={handleCellDoubleClick}
1031
+ onMouseDown={props.onMouseDown}
1032
+ onMouseEnter={props.onMouseEnter}
1033
+ onContextMenu={handleCellContextMenu}
1034
+ isLastColumn={columnIndex === (result?.fields.length ?? 0) - 1}
1035
+ onRemoveRow={() => cellEdit.removeNewRow(newRow.tempId)}
1036
+ />
1037
+ );
1038
+ }
1039
+
1040
+ // Existing row
1041
+ const rangeInfo = getCellRangeInfo(
1042
+ { rowIndex, columnName },
1043
+ cellEdit.selectedRange,
1044
+ result?.fields.map((f) => f.name) ?? [],
1045
+ rows.length,
1046
+ );
1047
+
1048
+ const isEditingThisCell =
1049
+ cellEdit.editingCell?.rowIndex === rowIndex &&
1050
+ cellEdit.editingCell?.columnName === columnName;
1051
+
1052
+ const editColumnInfo = tableMetadata?.columns.find(
1053
+ (c) => c.name === columnName,
1054
+ );
1055
+
1056
+ const isMarkedForDeletion = cellEdit.pendingDeletions.includes(rowIndex);
1057
+
1058
+ // FK preview value lookup
1059
+ const cellValue = rows[rowIndex]?.[columnName];
1060
+ const previewMap = fkPreviewData[columnName];
1061
+ const fkPreviewValue =
1062
+ previewMap && cellValue !== null && cellValue !== undefined
1063
+ ? previewMap.get(String(cellValue))
1064
+ : undefined;
1065
+
1066
+ return (
1067
+ <EditableCell
1068
+ key={columnIndex}
1069
+ rowIndex={rowIndex}
1070
+ columnName={columnName}
1071
+ value={cellValue}
1072
+ dateColumnType={getDateColumnType(editColumnInfo?.dataType ?? "")}
1073
+ displayValue={
1074
+ cellEdit.pendingChanges[`${rowIndex}:${columnName}`]?.newValue
1075
+ }
1076
+ canEdit={canEdit}
1077
+ isJsonCell={
1078
+ editColumnInfo
1079
+ ? isJsonColumn(editColumnInfo.dataType)
1080
+ : tryParseJson(cellValue) !== null
1081
+ }
1082
+ isSelected={
1083
+ cellEdit.selectedCell?.rowIndex === rowIndex &&
1084
+ cellEdit.selectedCell?.columnName === columnName
1085
+ }
1086
+ isEditing={isEditingThisCell}
1087
+ isInRange={rangeInfo.isInRange}
1088
+ rangeEdges={rangeInfo.edges}
1089
+ isChanged={!!cellEdit.pendingChanges[`${rowIndex}:${columnName}`]}
1090
+ editValue={isEditingThisCell ? (cellEdit.editValue ?? "") : ""}
1091
+ foreignKeyRef={foreignKeyMap.get(columnName)}
1092
+ fkPreviewValue={fkPreviewValue}
1093
+ onUpdateEditValue={cellEdit.updateEditValue}
1094
+ onCommitEdit={cellEdit.commitCellEdit}
1095
+ onCancelEdit={cellEdit.cancelCellEdit}
1096
+ onClick={props.onClick}
1097
+ onDoubleClick={handleCellDoubleClick}
1098
+ onMouseDown={props.onMouseDown}
1099
+ onMouseEnter={props.onMouseEnter}
1100
+ onNavigateToForeignKey={handleNavigateToForeignKey}
1101
+ onContextMenu={handleCellContextMenu}
1102
+ isMarkedForDeletion={isMarkedForDeletion}
1103
+ />
1104
+ );
1105
+ },
1106
+ [
1107
+ cellEdit,
1108
+ tableMetadata,
1109
+ result,
1110
+ rows,
1111
+ canEdit,
1112
+ foreignKeyMap,
1113
+ fkPreviewData,
1114
+ handleCellDoubleClick,
1115
+ handleCellContextMenu,
1116
+ handleNavigateToForeignKey,
1117
+ ],
1118
+ );
1119
+
1120
+ return (
1121
+ <div ref={containerRef} className="h-full w-full flex flex-col">
1122
+ {/* Action bar */}
1123
+ <div className="flex-shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
1124
+ {/* Refresh button */}
1125
+ <button
1126
+ onClick={execute}
1127
+ disabled={status === "executing"}
1128
+ className="flex items-center gap-1.5 px-2 py-1 text-[12px] rounded hover:bg-stone-200/70 dark:hover:bg-white/[0.06] text-secondary hover:text-primary transition-colors disabled:opacity-50"
1129
+ title="Refresh"
1130
+ >
1131
+ <svg
1132
+ className={`w-3.5 h-3.5 ${
1133
+ status === "executing" ? "animate-spin" : ""
1134
+ }`}
1135
+ viewBox="0 0 24 24"
1136
+ fill="none"
1137
+ stroke="currentColor"
1138
+ strokeWidth="2"
1139
+ >
1140
+ <polyline points="23 4 23 10 17 10" />
1141
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
1142
+ </svg>
1143
+ <span>Refresh</span>
1144
+ </button>
1145
+
1146
+ <div className="w-px h-4 bg-stone-200 dark:bg-white/[0.08]" />
1147
+
1148
+ {/* Add row button */}
1149
+ <button
1150
+ onClick={handleAddRow}
1151
+ className="flex items-center gap-1.5 px-2 py-1 text-[12px] rounded hover:bg-stone-200/70 dark:hover:bg-white/[0.06] text-secondary hover:text-primary transition-colors"
1152
+ title="Add new row"
1153
+ >
1154
+ <Plus className="w-3.5 h-3.5" />
1155
+ <span>Add Row</span>
1156
+ </button>
1157
+
1158
+ {/* Delete rows button */}
1159
+ <button
1160
+ onClick={handleDeleteRows}
1161
+ disabled={!cellEdit.selectedCell}
1162
+ className="flex items-center gap-1.5 px-2 py-1 text-[12px] rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-secondary hover:text-red-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-secondary transition-colors"
1163
+ title="Delete selected rows"
1164
+ >
1165
+ <Minus className="w-3.5 h-3.5" />
1166
+ <span>Delete</span>
1167
+ </button>
1168
+
1169
+ <div className="flex-1" />
1170
+
1171
+ {/* Pending changes actions */}
1172
+ {pendingChangesCount > 0 && (
1173
+ <>
1174
+ <button
1175
+ onClick={() => cellEdit.clearPendingChanges()}
1176
+ className="flex items-center gap-1.5 px-2 py-1 text-[12px] rounded hover:bg-stone-200/70 dark:hover:bg-white/[0.06] text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 transition-colors"
1177
+ title="Revert all changes"
1178
+ >
1179
+ <RotateCcw className="w-3.5 h-3.5" />
1180
+ <span>Revert</span>
1181
+ </button>
1182
+ <button
1183
+ onClick={handleApplyChanges}
1184
+ className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded bg-amber-500 text-white hover:bg-amber-600 transition-colors"
1185
+ >
1186
+ <svg
1187
+ className="w-3.5 h-3.5"
1188
+ viewBox="0 0 24 24"
1189
+ fill="none"
1190
+ stroke="currentColor"
1191
+ strokeWidth="2"
1192
+ >
1193
+ <polyline points="20 6 9 17 4 12" />
1194
+ </svg>
1195
+ Apply {pendingChangesCount} Change
1196
+ {pendingChangesCount !== 1 ? "s" : ""}
1197
+ </button>
1198
+ </>
1199
+ )}
1200
+ </div>
1201
+
1202
+ {/* Filter bar */}
1203
+ <div className="flex-shrink-0 flex items-center gap-4 px-3 py-1.5 border-b border-stone-200 dark:border-white/[0.06]">
1204
+ {/* WHERE input */}
1205
+ <div className="flex items-center gap-2 flex-1">
1206
+ <div className="flex items-center gap-1.5 text-[12px] text-tertiary">
1207
+ <svg
1208
+ className="w-3.5 h-3.5"
1209
+ viewBox="0 0 24 24"
1210
+ fill="none"
1211
+ stroke="currentColor"
1212
+ strokeWidth="2"
1213
+ >
1214
+ <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
1215
+ </svg>
1216
+ <span className="font-medium">WHERE</span>
1217
+ </div>
1218
+ <div className="relative flex-1 min-w-[200px]">
1219
+ <input
1220
+ type="text"
1221
+ value={whereClause}
1222
+ onChange={handleWhereChange}
1223
+ onKeyDown={handleWhereKeyDown}
1224
+ placeholder="e.g., user_id='abc123'"
1225
+ className="w-full px-2 py-1 pr-7 text-[13px] font-mono bg-white dark:bg-black/20 border border-stone-200 dark:border-white/[0.08] rounded focus:outline-none focus:border-stone-400 dark:focus:border-white/20 placeholder:text-tertiary"
1226
+ />
1227
+ {whereClause && (
1228
+ <button
1229
+ onClick={() => {
1230
+ setTableWhereClause(tabId, "");
1231
+ setTimeout(execute, 0);
1232
+ }}
1233
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-tertiary hover:text-secondary transition-colors"
1234
+ title="Clear filter"
1235
+ >
1236
+ <XIcon className="w-3.5 h-3.5" />
1237
+ </button>
1238
+ )}
1239
+ </div>
1240
+ </div>
1241
+
1242
+ {/* Sort indicator */}
1243
+ {sortColumns.length > 0 && (
1244
+ <div className="flex items-center gap-1.5 text-[12px] text-tertiary">
1245
+ <svg
1246
+ className="w-3.5 h-3.5"
1247
+ viewBox="0 0 24 24"
1248
+ fill="none"
1249
+ stroke="currentColor"
1250
+ strokeWidth="2"
1251
+ >
1252
+ <line x1="4" y1="6" x2="14" y2="6" />
1253
+ <line x1="4" y1="12" x2="11" y2="12" />
1254
+ <line x1="4" y1="18" x2="8" y2="18" />
1255
+ <polyline points="17 10 20 7 23 10" />
1256
+ <line x1="20" y1="7" x2="20" y2="21" />
1257
+ </svg>
1258
+ <span className="font-mono">
1259
+ {sortColumns.map((s) => `${s.column} ${s.direction}`).join(", ")}
1260
+ </span>
1261
+ </div>
1262
+ )}
1263
+ </div>
1264
+
1265
+ {/* Main content area with results and bottom panel */}
1266
+ <div className="flex-1 min-h-0 flex flex-col">
1267
+ {/* Results section */}
1268
+ <div className="flex-1 min-h-0 relative">
1269
+ {/* Initial loading state (no previous results) */}
1270
+ {status === "idle" && !result && (
1271
+ <div className="flex items-center justify-center h-full text-tertiary text-[13px]">
1272
+ Loading table data...
1273
+ </div>
1274
+ )}
1275
+
1276
+ {/* Loading state without previous results */}
1277
+ {status === "executing" && !result && (
1278
+ <div className="flex items-center justify-center h-full">
1279
+ <div className="flex items-center gap-3 text-secondary text-[13px]">
1280
+ <svg
1281
+ className="animate-spin h-4 w-4"
1282
+ viewBox="0 0 24 24"
1283
+ fill="none"
1284
+ >
1285
+ <circle
1286
+ className="opacity-25"
1287
+ cx="12"
1288
+ cy="12"
1289
+ r="10"
1290
+ stroke="currentColor"
1291
+ strokeWidth="4"
1292
+ />
1293
+ <path
1294
+ className="opacity-75"
1295
+ fill="currentColor"
1296
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
1297
+ />
1298
+ </svg>
1299
+ Loading data...
1300
+ </div>
1301
+ </div>
1302
+ )}
1303
+
1304
+ {/* Error state */}
1305
+ {status === "error" && error && (
1306
+ <div className="p-4">
1307
+ <div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 p-4">
1308
+ <div className="flex items-start gap-3">
1309
+ <svg
1310
+ className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5"
1311
+ viewBox="0 0 20 20"
1312
+ fill="currentColor"
1313
+ >
1314
+ <path
1315
+ fillRule="evenodd"
1316
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
1317
+ clipRule="evenodd"
1318
+ />
1319
+ </svg>
1320
+ <div>
1321
+ <p className="text-[13px] font-medium text-red-800 dark:text-red-300">
1322
+ Query Error
1323
+ </p>
1324
+ <p className="text-[13px] text-red-700 dark:text-red-400 mt-1 font-mono whitespace-pre-wrap">
1325
+ {error}
1326
+ </p>
1327
+ </div>
1328
+ </div>
1329
+ </div>
1330
+ </div>
1331
+ )}
1332
+
1333
+ {/* Results table (shown when we have results, regardless of current status) */}
1334
+ {result && (
1335
+ <div className="h-full flex flex-col overflow-auto">
1336
+ {/* Result header */}
1337
+ <div className="flex-shrink-0 flex items-center justify-between px-4 py-2 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
1338
+ <span className="text-[12px] text-secondary">
1339
+ {totalRowCount != null
1340
+ ? `${totalRowCount.toLocaleString()} row${
1341
+ totalRowCount !== 1 ? "s" : ""
1342
+ }`
1343
+ : result.rowCount !== null
1344
+ ? `${result.rowCount} row${
1345
+ result.rowCount !== 1 ? "s" : ""
1346
+ }`
1347
+ : "Query executed"}
1348
+ {result.fields.length > 0 &&
1349
+ ` \u2022 ${result.fields.length} column${
1350
+ result.fields.length !== 1 ? "s" : ""
1351
+ }`}
1352
+ {!canEdit && (
1353
+ <span
1354
+ className="text-tertiary ml-2"
1355
+ title="This table has no primary key, so inline editing is disabled"
1356
+ >
1357
+ (read-only: no primary key)
1358
+ </span>
1359
+ )}
1360
+ </span>
1361
+ <div className="flex items-center gap-1.5">
1362
+ <button
1363
+ onClick={() => setShowCsvExport(true)}
1364
+ className="p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-secondary transition-colors"
1365
+ title="Export to CSV"
1366
+ >
1367
+ <Download className="w-4 h-4" />
1368
+ </button>
1369
+ <select
1370
+ value={pageSize}
1371
+ onChange={(e) =>
1372
+ handlePageSizeChange(Number(e.target.value))
1373
+ }
1374
+ className="text-[12px] text-secondary bg-transparent border border-stone-200 dark:border-white/10 rounded px-1 py-0.5 cursor-pointer hover:bg-stone-200/70 dark:hover:bg-white/[0.06] transition-colors"
1375
+ title="Rows per page"
1376
+ >
1377
+ {[100, 250, 500, 1000, 2500, 5000].map((size) => (
1378
+ <option key={size} value={size}>
1379
+ {size} rows
1380
+ </option>
1381
+ ))}
1382
+ </select>
1383
+ {totalPages != null && totalPages > 1 && (
1384
+ <>
1385
+ <div className="w-px h-3.5 bg-stone-200 dark:bg-white/10 mx-0.5" />
1386
+ <button
1387
+ onClick={handlePrevPage}
1388
+ disabled={currentPage === 0 || status === "executing"}
1389
+ className="p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-secondary disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
1390
+ title="Previous page"
1391
+ >
1392
+ <ChevronLeft className="w-4 h-4" />
1393
+ </button>
1394
+ <span className="text-[12px] text-secondary tabular-nums">
1395
+ {currentPage + 1} / {totalPages.toLocaleString()}
1396
+ </span>
1397
+ <button
1398
+ onClick={handleNextPage}
1399
+ disabled={
1400
+ currentPage >= totalPages - 1 ||
1401
+ status === "executing"
1402
+ }
1403
+ className="p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-secondary disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
1404
+ title="Next page"
1405
+ >
1406
+ <ChevronRight className="w-4 h-4" />
1407
+ </button>
1408
+ </>
1409
+ )}
1410
+ </div>
1411
+ </div>
1412
+
1413
+ {/* Result table via DataGrid */}
1414
+ {result.fields.length > 0 ? (
1415
+ <DataGrid
1416
+ columns={result.fields}
1417
+ rows={rows}
1418
+ extraRows={extraRows}
1419
+ sortColumns={sortColumns}
1420
+ onSortChange={handleSortChange}
1421
+ selection={dataGridSelection}
1422
+ onSelectionChange={handleSelectionChange}
1423
+ renderCell={renderCell}
1424
+ onKeyDown={handleKeyDown}
1425
+ scrollRef={tableScrollRef}
1426
+ tableRef={tableRef}
1427
+ onHeaderContextMenu={handleHeaderContextMenu}
1428
+ fkPreviewActiveColumns={fkPreviewActiveColumns}
1429
+ />
1430
+ ) : (
1431
+ <div className="flex items-center justify-center h-full text-tertiary text-[13px]">
1432
+ No rows returned
1433
+ </div>
1434
+ )}
1435
+ </div>
1436
+ )}
1437
+
1438
+ {/* Loading overlay (shown when executing with existing results) */}
1439
+ {status === "executing" && result && (
1440
+ <div className="absolute inset-0 bg-stone-500/20 dark:bg-black/40 flex items-center justify-center pointer-events-none">
1441
+ <div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-white/80 dark:bg-stone-800/80 backdrop-blur-sm shadow-lg border border-stone-200/50 dark:border-white/10">
1442
+ <svg
1443
+ className="animate-spin h-4 w-4 text-secondary"
1444
+ viewBox="0 0 24 24"
1445
+ fill="none"
1446
+ >
1447
+ <circle
1448
+ className="opacity-25"
1449
+ cx="12"
1450
+ cy="12"
1451
+ r="10"
1452
+ stroke="currentColor"
1453
+ strokeWidth="4"
1454
+ />
1455
+ <path
1456
+ className="opacity-75"
1457
+ fill="currentColor"
1458
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
1459
+ />
1460
+ </svg>
1461
+ <span className="text-[13px] text-secondary">
1462
+ Refreshing...
1463
+ </span>
1464
+ </div>
1465
+ </div>
1466
+ )}
1467
+
1468
+ {/* Cell context menu */}
1469
+ {contextMenu &&
1470
+ (() => {
1471
+ const isNewRow = contextMenu.rowIndex < 0;
1472
+ const columnInfo = tableMetadata?.columns.find(
1473
+ (c) => c.name === contextMenu.columnName,
1474
+ );
1475
+ const isNullable = columnInfo?.isNullable ?? false;
1476
+ const hasDefault =
1477
+ columnInfo?.defaultValue !== null &&
1478
+ columnInfo?.defaultValue !== undefined;
1479
+ const cellKey = `${contextMenu.rowIndex}:${contextMenu.columnName}`;
1480
+ const hasPendingChange =
1481
+ !isNewRow && cellKey in cellEdit.pendingChanges;
1482
+
1483
+ return (
1484
+ <div
1485
+ data-context-menu
1486
+ className="fixed p-1 min-w-[120px] 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"
1487
+ style={{ left: contextMenu.x, top: contextMenu.y }}
1488
+ onClick={(e) => e.stopPropagation()}
1489
+ >
1490
+ {hasPendingChange && (
1491
+ <button
1492
+ onClick={handleRevertCell}
1493
+ className="w-full px-2.5 py-1.5 text-left text-[13px] rounded-md transition-colors text-primary hover:bg-stone-100 dark:hover:bg-white/10"
1494
+ >
1495
+ Revert Cell
1496
+ </button>
1497
+ )}
1498
+ <button
1499
+ onClick={isNullable ? handleSetNull : undefined}
1500
+ disabled={!isNullable}
1501
+ className={`w-full px-2.5 py-1.5 text-left text-[13px] rounded-md transition-colors ${
1502
+ isNullable
1503
+ ? "text-primary hover:bg-stone-100 dark:hover:bg-white/10"
1504
+ : "text-tertiary cursor-not-allowed"
1505
+ }`}
1506
+ >
1507
+ Set NULL
1508
+ </button>
1509
+ {isNewRow && hasDefault && (
1510
+ <button
1511
+ onClick={handleSetToDefault}
1512
+ className="w-full px-2.5 py-1.5 text-left text-[13px] rounded-md transition-colors text-primary hover:bg-stone-100 dark:hover:bg-white/10"
1513
+ >
1514
+ Set to Default
1515
+ </button>
1516
+ )}
1517
+ </div>
1518
+ );
1519
+ })()}
1520
+
1521
+ {/* Header context menu (FK preview column picker) */}
1522
+ {headerContextMenu &&
1523
+ (() => {
1524
+ const refColumns = getReferencedTableColumns(
1525
+ headerContextMenu.foreignKeyRef,
1526
+ );
1527
+ const currentChoice =
1528
+ fkPreviewColumns[headerContextMenu.columnName] ?? null;
1529
+ const refTable = headerContextMenu.foreignKeyRef.table;
1530
+
1531
+ return (
1532
+ <div
1533
+ data-context-menu
1534
+ className="fixed p-1 min-w-[180px] max-h-[300px] overflow-auto 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"
1535
+ style={{
1536
+ left: headerContextMenu.x,
1537
+ top: headerContextMenu.y,
1538
+ }}
1539
+ onClick={(e) => e.stopPropagation()}
1540
+ >
1541
+ <div className="px-2.5 py-1.5 text-[11px] font-medium text-tertiary uppercase tracking-wide">
1542
+ Preview from {refTable}
1543
+ </div>
1544
+ <button
1545
+ onClick={() =>
1546
+ handleSetFkPreviewColumn(
1547
+ headerContextMenu.columnName,
1548
+ null,
1549
+ )
1550
+ }
1551
+ className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[13px] rounded-md transition-colors text-primary hover:bg-stone-100 dark:hover:bg-white/10"
1552
+ >
1553
+ <span className="w-4 flex-shrink-0">
1554
+ {currentChoice === null && (
1555
+ <Check className="w-3.5 h-3.5" />
1556
+ )}
1557
+ </span>
1558
+ <span className="text-tertiary italic">None</span>
1559
+ </button>
1560
+ {refColumns.map((col) => (
1561
+ <button
1562
+ key={col}
1563
+ onClick={() =>
1564
+ handleSetFkPreviewColumn(
1565
+ headerContextMenu.columnName,
1566
+ col,
1567
+ )
1568
+ }
1569
+ className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[13px] rounded-md transition-colors text-primary hover:bg-stone-100 dark:hover:bg-white/10"
1570
+ >
1571
+ <span className="w-4 flex-shrink-0">
1572
+ {currentChoice === col && (
1573
+ <Check className="w-3.5 h-3.5" />
1574
+ )}
1575
+ </span>
1576
+ <span className="font-mono truncate">{col}</span>
1577
+ </button>
1578
+ ))}
1579
+ </div>
1580
+ );
1581
+ })()}
1582
+ </div>
1583
+
1584
+ {/* Resize handle */}
1585
+ <div
1586
+ onMouseDown={handleResizeMouseDown}
1587
+ className={`flex-shrink-0 h-1 cursor-ns-resize border-t border-stone-200 dark:border-white/[0.06] hover:bg-blue-500/20 transition-colors ${
1588
+ isResizing ? "bg-blue-500/30" : ""
1589
+ }`}
1590
+ />
1591
+
1592
+ {/* Bottom panel */}
1593
+ <div
1594
+ className="flex-shrink-0 flex border-t border-stone-200 dark:border-white/[0.06]"
1595
+ style={{ height: bottomPanelHeight }}
1596
+ >
1597
+ {/* Log / JSON pane (left) */}
1598
+ <div className="flex-1 flex flex-col min-w-0 border-r border-stone-200 dark:border-white/[0.06]">
1599
+ {selectedCellJsonData ? (
1600
+ <JsonTreeViewer
1601
+ data={selectedCellJsonData.data}
1602
+ columnName={selectedCellJsonData.columnName}
1603
+ onEdit={canEdit ? handleJsonEdit : undefined}
1604
+ canEdit={canEdit}
1605
+ />
1606
+ ) : (
1607
+ <>
1608
+ <div className="flex-shrink-0 px-3 py-1.5 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
1609
+ <span className="text-[11px] font-medium text-tertiary uppercase tracking-wide">
1610
+ Log
1611
+ </span>
1612
+ </div>
1613
+ <div className="flex-1 overflow-auto p-3">
1614
+ <p className="text-[12px] text-tertiary italic">
1615
+ Log output will appear here...
1616
+ </p>
1617
+ </div>
1618
+ </>
1619
+ )}
1620
+ </div>
1621
+
1622
+ {/* Related tables pane (right) - only shown when single row selected and there are incoming FKs */}
1623
+ {isSingleRowSelected && incomingForeignKeys.length > 0 && (
1624
+ <div className="w-64 flex-shrink-0 flex flex-col">
1625
+ <div className="flex-shrink-0 px-3 py-1.5 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
1626
+ <span className="text-[11px] font-medium text-tertiary uppercase tracking-wide">
1627
+ Referenced By
1628
+ </span>
1629
+ </div>
1630
+ <div className="flex-1 overflow-auto">
1631
+ {incomingForeignKeys.map((fk, idx) => {
1632
+ const displayTable =
1633
+ fk.fromSchema === "public"
1634
+ ? fk.fromTable
1635
+ : `${fk.fromSchema}.${fk.fromTable}`;
1636
+ return (
1637
+ <button
1638
+ key={idx}
1639
+ onClick={() => handleIncomingFKClick(fk)}
1640
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-stone-100 dark:hover:bg-white/[0.04] transition-colors border-b border-stone-100 dark:border-white/[0.04] last:border-b-0"
1641
+ >
1642
+ <div className="flex-1 min-w-0">
1643
+ <div className="text-[13px] text-primary truncate">
1644
+ {displayTable}
1645
+ </div>
1646
+ <div className="text-[11px] text-tertiary truncate">
1647
+ {fk.fromColumn} → {fk.toColumn}
1648
+ </div>
1649
+ </div>
1650
+ <ExternalLink className="w-3.5 h-3.5 text-tertiary flex-shrink-0" />
1651
+ </button>
1652
+ );
1653
+ })}
1654
+ </div>
1655
+ </div>
1656
+ )}
1657
+ </div>
1658
+ </div>
1659
+
1660
+ {showCsvExport && result && (
1661
+ <CsvExportModal
1662
+ onClose={() => setShowCsvExport(false)}
1663
+ fields={result.fields}
1664
+ currentRows={rows}
1665
+ defaultFilename={tableName}
1666
+ totalRowCount={totalRowCount ?? undefined}
1667
+ fetchAllRows={fetchAllRowsForExport}
1668
+ />
1669
+ )}
1670
+ </div>
1671
+ );
1672
+ }
1673
+
1674
+ interface EditableCellProps {
1675
+ rowIndex: number;
1676
+ columnName: string;
1677
+ value: unknown;
1678
+ displayValue: string | null | undefined;
1679
+ canEdit: boolean;
1680
+ isSelected: boolean;
1681
+ isEditing: boolean;
1682
+ isInRange: boolean;
1683
+ rangeEdges: RangeEdges | null;
1684
+ isChanged: boolean;
1685
+ isMarkedForDeletion?: boolean;
1686
+ isJsonCell?: boolean;
1687
+ editValue: string;
1688
+ foreignKeyRef?: ForeignKeyRef;
1689
+ fkPreviewValue?: string;
1690
+ dateColumnType?: "date" | "datetime" | null;
1691
+ onUpdateEditValue: (value: string) => void;
1692
+ onCommitEdit: () => void;
1693
+ onCancelEdit: () => void;
1694
+ onClick: (rowIndex: number, columnName: string, e: React.MouseEvent) => void;
1695
+ onDoubleClick: (rowIndex: number, columnName: string, value: unknown) => void;
1696
+ onMouseDown: (
1697
+ rowIndex: number,
1698
+ columnName: string,
1699
+ e: React.MouseEvent,
1700
+ ) => void;
1701
+ onMouseEnter: (rowIndex: number, columnName: string) => void;
1702
+ onNavigateToForeignKey?: (ref: ForeignKeyRef, value: unknown) => void;
1703
+ onContextMenu: (
1704
+ rowIndex: number,
1705
+ columnName: string,
1706
+ e: React.MouseEvent,
1707
+ ) => void;
1708
+ }
1709
+
1710
+ const EditableCell = React.memo(function EditableCell({
1711
+ rowIndex,
1712
+ columnName,
1713
+ value,
1714
+ displayValue,
1715
+ canEdit,
1716
+ isSelected,
1717
+ isEditing,
1718
+ isInRange,
1719
+ rangeEdges,
1720
+ isChanged,
1721
+ isMarkedForDeletion,
1722
+ isJsonCell,
1723
+ editValue,
1724
+ foreignKeyRef,
1725
+ fkPreviewValue,
1726
+ dateColumnType,
1727
+ onUpdateEditValue,
1728
+ onCommitEdit,
1729
+ onCancelEdit,
1730
+ onClick,
1731
+ onDoubleClick,
1732
+ onMouseDown,
1733
+ onMouseEnter,
1734
+ onNavigateToForeignKey,
1735
+ onContextMenu,
1736
+ }: EditableCellProps) {
1737
+ const inputRef = useRef<HTMLInputElement>(null);
1738
+ const dateInputRef = useRef<HTMLInputElement>(null);
1739
+ const [isHovered, setIsHovered] = useState(false);
1740
+
1741
+ useEffect(() => {
1742
+ if (isEditing && inputRef.current) {
1743
+ inputRef.current.focus();
1744
+ inputRef.current.select();
1745
+ if (dateColumnType && dateInputRef.current) {
1746
+ try {
1747
+ dateInputRef.current.showPicker();
1748
+ } catch {
1749
+ // showPicker() may fail in some browsers or contexts
1750
+ }
1751
+ }
1752
+ }
1753
+ }, [isEditing, dateColumnType]);
1754
+
1755
+ const handleInputKeyDown = (e: React.KeyboardEvent) => {
1756
+ if (e.key === "Enter") {
1757
+ e.preventDefault();
1758
+ onCommitEdit();
1759
+ } else if (e.key === "Escape") {
1760
+ e.preventDefault();
1761
+ onCancelEdit();
1762
+ } else if (e.key === "Tab") {
1763
+ e.preventDefault();
1764
+ onCommitEdit();
1765
+ }
1766
+ };
1767
+
1768
+ const handleInputBlur = () => {
1769
+ onCommitEdit();
1770
+ };
1771
+
1772
+ const handleForeignKeyClick = (e: React.MouseEvent) => {
1773
+ e.stopPropagation();
1774
+ e.preventDefault();
1775
+ if (foreignKeyRef && onNavigateToForeignKey && value !== null) {
1776
+ onNavigateToForeignKey(foreignKeyRef, value);
1777
+ }
1778
+ };
1779
+
1780
+ const showForeignKeyIcon =
1781
+ isHovered && foreignKeyRef && value !== null && !isEditing;
1782
+ const showJsonIcon = isHovered && isJsonCell && value !== null && !isEditing;
1783
+
1784
+ let cellClassName =
1785
+ "px-3 py-2 text-secondary border-b border-r border-stone-200 dark:border-white/[0.06] font-mono max-w-[300px] relative";
1786
+
1787
+ if (canEdit && !isEditing) {
1788
+ cellClassName += " cursor-pointer";
1789
+ }
1790
+
1791
+ if (isSelected && !isEditing) {
1792
+ cellClassName += " bg-blue-100 dark:bg-blue-800/40";
1793
+ }
1794
+
1795
+ if (isInRange && rangeEdges && !isEditing) {
1796
+ if (!isSelected) {
1797
+ cellClassName += " bg-blue-50 dark:bg-blue-900/20";
1798
+ }
1799
+ }
1800
+
1801
+ if (isChanged) {
1802
+ cellClassName += " bg-amber-50 dark:bg-amber-900/20";
1803
+ }
1804
+
1805
+ const rangeBorderStyle: React.CSSProperties | undefined =
1806
+ isInRange && rangeEdges
1807
+ ? {
1808
+ borderTop: rangeEdges.top ? "2px solid rgb(59, 130, 246)" : undefined,
1809
+ borderBottom: rangeEdges.bottom
1810
+ ? "2px solid rgb(59, 130, 246)"
1811
+ : undefined,
1812
+ borderLeft: rangeEdges.left
1813
+ ? "2px solid rgb(59, 130, 246)"
1814
+ : undefined,
1815
+ borderRight: rangeEdges.right
1816
+ ? "2px solid rgb(59, 130, 246)"
1817
+ : undefined,
1818
+ }
1819
+ : undefined;
1820
+
1821
+ return (
1822
+ <td
1823
+ className={cellClassName}
1824
+ onClick={(e) => onClick(rowIndex, columnName, e)}
1825
+ onDoubleClick={() => onDoubleClick(rowIndex, columnName, value)}
1826
+ onMouseDown={(e) => onMouseDown(rowIndex, columnName, e)}
1827
+ onMouseEnter={() => {
1828
+ setIsHovered(true);
1829
+ onMouseEnter(rowIndex, columnName);
1830
+ }}
1831
+ onMouseLeave={() => setIsHovered(false)}
1832
+ onContextMenu={(e) => onContextMenu(rowIndex, columnName, e)}
1833
+ >
1834
+ {rangeBorderStyle && (
1835
+ <div
1836
+ className="absolute -inset-px pointer-events-none z-[1] box-border"
1837
+ style={rangeBorderStyle}
1838
+ />
1839
+ )}
1840
+ {isEditing ? (
1841
+ <>
1842
+ <input
1843
+ ref={inputRef}
1844
+ type="text"
1845
+ value={editValue}
1846
+ onChange={(e) => onUpdateEditValue(e.target.value)}
1847
+ onKeyDown={handleInputKeyDown}
1848
+ onBlur={(e) => {
1849
+ if (
1850
+ dateColumnType &&
1851
+ dateInputRef.current &&
1852
+ e.relatedTarget === dateInputRef.current
1853
+ )
1854
+ return;
1855
+ handleInputBlur();
1856
+ }}
1857
+ onClick={(e) => e.stopPropagation()}
1858
+ onMouseDown={(e) => e.stopPropagation()}
1859
+ className="w-full px-1 py-0 text-[13px] font-mono bg-white dark:bg-stone-800 border border-blue-500 dark:border-blue-400 rounded outline-none"
1860
+ />
1861
+ {dateColumnType && (
1862
+ <input
1863
+ ref={dateInputRef}
1864
+ type={dateColumnType === "date" ? "date" : "datetime-local"}
1865
+ value={toNativeDateValue(editValue, dateColumnType)}
1866
+ onChange={(e) => {
1867
+ const v = e.target.value;
1868
+ if (!v) return;
1869
+ if (dateColumnType === "date") {
1870
+ onUpdateEditValue(v);
1871
+ } else {
1872
+ onUpdateEditValue(v.replace("T", " "));
1873
+ }
1874
+ inputRef.current?.focus();
1875
+ }}
1876
+ onBlur={(e) => {
1877
+ if (e.relatedTarget === inputRef.current) return;
1878
+ handleInputBlur();
1879
+ }}
1880
+ onKeyDown={handleInputKeyDown}
1881
+ className="absolute left-0 top-full w-0 h-0 opacity-0 overflow-hidden"
1882
+ tabIndex={-1}
1883
+ />
1884
+ )}
1885
+ </>
1886
+ ) : (
1887
+ <div className="flex items-center gap-1">
1888
+ <div className="truncate flex-1">
1889
+ <div
1890
+ className={`truncate ${isMarkedForDeletion ? "line-through text-red-600 dark:text-red-400" : ""}`}
1891
+ >
1892
+ {displayValue !== undefined ? (
1893
+ displayValue === null ? (
1894
+ <span className="text-tertiary italic">NULL</span>
1895
+ ) : (
1896
+ displayValue
1897
+ )
1898
+ ) : value === null ? (
1899
+ <span className="text-tertiary italic">NULL</span>
1900
+ ) : (
1901
+ formatCellValue(value)
1902
+ )}
1903
+ </div>
1904
+ {fkPreviewValue !== undefined && (
1905
+ <div className="text-tertiary text-[11px] truncate leading-tight">
1906
+ {fkPreviewValue}
1907
+ </div>
1908
+ )}
1909
+ </div>
1910
+ {showForeignKeyIcon && (
1911
+ <button
1912
+ onClick={handleForeignKeyClick}
1913
+ onMouseDown={(e) => e.stopPropagation()}
1914
+ className="flex-shrink-0 p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-tertiary hover:text-secondary transition-colors"
1915
+ title={`Go to ${foreignKeyRef.table}.${foreignKeyRef.column}`}
1916
+ >
1917
+ <ExternalLink className="w-3 h-3" />
1918
+ </button>
1919
+ )}
1920
+ {showJsonIcon && !showForeignKeyIcon && (
1921
+ <button
1922
+ onClick={(e) => {
1923
+ e.stopPropagation();
1924
+ onClick(rowIndex, columnName, e);
1925
+ }}
1926
+ onMouseDown={(e) => e.stopPropagation()}
1927
+ className="flex-shrink-0 p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-tertiary hover:text-secondary transition-colors"
1928
+ title="View JSON"
1929
+ >
1930
+ <Braces className="w-3 h-3" />
1931
+ </button>
1932
+ )}
1933
+ </div>
1934
+ )}
1935
+ </td>
1936
+ );
1937
+ });
1938
+
1939
+ interface NewRowCellProps {
1940
+ rowIndex: number;
1941
+ columnName: string;
1942
+ displayContent: React.ReactNode;
1943
+ value: unknown;
1944
+ isExplicitlySet: boolean;
1945
+ isSelected: boolean;
1946
+ isEditing: boolean;
1947
+ isInRange: boolean;
1948
+ rangeEdges: RangeEdges | null;
1949
+ editValue: string;
1950
+ dateColumnType?: "date" | "datetime" | null;
1951
+ onUpdateEditValue: (value: string) => void;
1952
+ onCommitEdit: () => void;
1953
+ onCancelEdit: () => void;
1954
+ onClick: (rowIndex: number, columnName: string, e: React.MouseEvent) => void;
1955
+ onDoubleClick: (rowIndex: number, columnName: string, value: unknown) => void;
1956
+ onMouseDown: (
1957
+ rowIndex: number,
1958
+ columnName: string,
1959
+ e: React.MouseEvent,
1960
+ ) => void;
1961
+ onMouseEnter: (rowIndex: number, columnName: string) => void;
1962
+ onContextMenu: (
1963
+ rowIndex: number,
1964
+ columnName: string,
1965
+ e: React.MouseEvent,
1966
+ ) => void;
1967
+ isLastColumn: boolean;
1968
+ onRemoveRow: () => void;
1969
+ }
1970
+
1971
+ const NewRowCell = React.memo(function NewRowCell({
1972
+ rowIndex,
1973
+ columnName,
1974
+ displayContent,
1975
+ value,
1976
+ isExplicitlySet,
1977
+ isSelected,
1978
+ isEditing,
1979
+ isInRange,
1980
+ rangeEdges,
1981
+ editValue,
1982
+ dateColumnType,
1983
+ onUpdateEditValue,
1984
+ onCommitEdit,
1985
+ onCancelEdit,
1986
+ onClick,
1987
+ onDoubleClick,
1988
+ onMouseDown,
1989
+ onMouseEnter,
1990
+ onContextMenu,
1991
+ isLastColumn,
1992
+ onRemoveRow,
1993
+ }: NewRowCellProps) {
1994
+ const inputRef = useRef<HTMLInputElement>(null);
1995
+ const dateInputRef = useRef<HTMLInputElement>(null);
1996
+ const [isHovered, setIsHovered] = useState(false);
1997
+
1998
+ useEffect(() => {
1999
+ if (isEditing && inputRef.current) {
2000
+ inputRef.current.focus();
2001
+ inputRef.current.select();
2002
+ if (dateColumnType && dateInputRef.current) {
2003
+ try {
2004
+ dateInputRef.current.showPicker();
2005
+ } catch {
2006
+ // showPicker() may fail in some browsers or contexts
2007
+ }
2008
+ }
2009
+ }
2010
+ }, [isEditing, dateColumnType]);
2011
+
2012
+ const handleInputKeyDown = (e: React.KeyboardEvent) => {
2013
+ if (e.key === "Enter") {
2014
+ e.preventDefault();
2015
+ onCommitEdit();
2016
+ } else if (e.key === "Escape") {
2017
+ e.preventDefault();
2018
+ onCancelEdit();
2019
+ } else if (e.key === "Tab") {
2020
+ e.preventDefault();
2021
+ onCommitEdit();
2022
+ }
2023
+ };
2024
+
2025
+ const handleInputBlur = () => {
2026
+ onCommitEdit();
2027
+ };
2028
+
2029
+ let cellClassName =
2030
+ "px-3 py-2 text-secondary border-b border-r border-stone-200 dark:border-white/[0.06] font-mono max-w-[300px] relative cursor-pointer";
2031
+
2032
+ if (isSelected && !isEditing) {
2033
+ cellClassName += " bg-blue-100 dark:bg-blue-800/40";
2034
+ }
2035
+
2036
+ if (isInRange && rangeEdges && !isEditing) {
2037
+ if (!isSelected) {
2038
+ cellClassName += " bg-blue-50 dark:bg-blue-900/20";
2039
+ }
2040
+ }
2041
+
2042
+ if (isExplicitlySet) {
2043
+ cellClassName += " bg-green-100/50 dark:bg-green-900/20";
2044
+ }
2045
+
2046
+ const rangeBorderStyle: React.CSSProperties | undefined =
2047
+ isInRange && rangeEdges
2048
+ ? {
2049
+ borderTop: rangeEdges.top ? "2px solid rgb(59, 130, 246)" : undefined,
2050
+ borderBottom: rangeEdges.bottom
2051
+ ? "2px solid rgb(59, 130, 246)"
2052
+ : undefined,
2053
+ borderLeft: rangeEdges.left
2054
+ ? "2px solid rgb(59, 130, 246)"
2055
+ : undefined,
2056
+ borderRight: rangeEdges.right
2057
+ ? "2px solid rgb(59, 130, 246)"
2058
+ : undefined,
2059
+ }
2060
+ : undefined;
2061
+
2062
+ return (
2063
+ <td
2064
+ className={cellClassName}
2065
+ onClick={(e) => onClick(rowIndex, columnName, e)}
2066
+ onDoubleClick={() => onDoubleClick(rowIndex, columnName, value)}
2067
+ onMouseDown={(e) => onMouseDown(rowIndex, columnName, e)}
2068
+ onMouseEnter={() => {
2069
+ setIsHovered(true);
2070
+ onMouseEnter(rowIndex, columnName);
2071
+ }}
2072
+ onMouseLeave={() => setIsHovered(false)}
2073
+ onContextMenu={(e) => onContextMenu(rowIndex, columnName, e)}
2074
+ >
2075
+ {rangeBorderStyle && (
2076
+ <div
2077
+ className="absolute -inset-px pointer-events-none z-[1] box-border"
2078
+ style={rangeBorderStyle}
2079
+ />
2080
+ )}
2081
+ {isEditing ? (
2082
+ <>
2083
+ <input
2084
+ ref={inputRef}
2085
+ type="text"
2086
+ value={editValue}
2087
+ onChange={(e) => onUpdateEditValue(e.target.value)}
2088
+ onKeyDown={handleInputKeyDown}
2089
+ onBlur={(e) => {
2090
+ if (
2091
+ dateColumnType &&
2092
+ dateInputRef.current &&
2093
+ e.relatedTarget === dateInputRef.current
2094
+ )
2095
+ return;
2096
+ handleInputBlur();
2097
+ }}
2098
+ onClick={(e) => e.stopPropagation()}
2099
+ onMouseDown={(e) => e.stopPropagation()}
2100
+ className="w-full px-1 py-0 text-[13px] font-mono bg-white dark:bg-stone-800 border border-blue-500 dark:border-blue-400 rounded outline-none"
2101
+ />
2102
+ {dateColumnType && (
2103
+ <input
2104
+ ref={dateInputRef}
2105
+ type={dateColumnType === "date" ? "date" : "datetime-local"}
2106
+ value={toNativeDateValue(editValue, dateColumnType)}
2107
+ onChange={(e) => {
2108
+ const v = e.target.value;
2109
+ if (!v) return;
2110
+ if (dateColumnType === "date") {
2111
+ onUpdateEditValue(v);
2112
+ } else {
2113
+ onUpdateEditValue(v.replace("T", " "));
2114
+ }
2115
+ inputRef.current?.focus();
2116
+ }}
2117
+ onBlur={(e) => {
2118
+ if (e.relatedTarget === inputRef.current) return;
2119
+ handleInputBlur();
2120
+ }}
2121
+ onKeyDown={handleInputKeyDown}
2122
+ className="absolute left-0 top-full w-0 h-0 opacity-0 overflow-hidden"
2123
+ tabIndex={-1}
2124
+ />
2125
+ )}
2126
+ </>
2127
+ ) : (
2128
+ <div className="flex items-center gap-1">
2129
+ <div className="truncate flex-1">{displayContent}</div>
2130
+ {isLastColumn && isHovered && (
2131
+ <button
2132
+ onClick={(e) => {
2133
+ e.stopPropagation();
2134
+ onRemoveRow();
2135
+ }}
2136
+ onMouseDown={(e) => e.stopPropagation()}
2137
+ className="flex-shrink-0 p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-tertiary hover:text-red-500 transition-colors"
2138
+ title="Remove row"
2139
+ >
2140
+ <Trash2 className="w-3 h-3" />
2141
+ </button>
2142
+ )}
2143
+ </div>
2144
+ )}
2145
+ </td>
2146
+ );
2147
+ });