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,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
|
+
});
|