pglens 1.0.0 → 2.0.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/client/app.js CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Main client-side application for viewing PostgreSQL database tables.
5
5
  * Features:
6
+ * - Multi-database connection support
6
7
  * - Multi-tab table viewing
7
8
  * - Client-side sorting and column management
8
9
  * - Cursor-based pagination for large tables
@@ -11,13 +12,18 @@
11
12
  */
12
13
 
13
14
  // Application state
14
- let tabs = []; // Array of tab objects: { tableName, page, totalCount, sortColumn, sortDirection, data, hiddenColumns, columnWidths, cursor, cursorHistory, hasPrimaryKey, isApproximate }
15
+ let connections = []; // Array of active connections: { id, name, connectionString, sslMode }
16
+ let activeConnectionId = null; // Currently active connection ID
17
+ let tabs = []; // Array of tab objects: { connectionId, tableName, page, totalCount, sortColumn, sortDirection, data, hiddenColumns, columnWidths, cursor, cursorHistory, hasPrimaryKey, isApproximate }
15
18
  let activeTabIndex = -1; // Currently active tab index
16
- let allTables = []; // All available tables from the database
19
+ let allTables = []; // All available tables from the current database connection
17
20
  let searchQuery = ''; // Current search filter for tables
18
21
  let currentTheme = 'system'; // Current theme: 'light', 'dark', or 'system'
22
+
23
+ // UI Elements
19
24
  const sidebar = document.getElementById('sidebar');
20
- const sidebarContent = sidebar.querySelector('.sidebar-content');
25
+ const sidebarContent = document.getElementById('sidebarContent');
26
+ const connectionsList = document.getElementById('connectionsList');
21
27
  const tableCount = document.getElementById('tableCount');
22
28
  const sidebarToggle = document.getElementById('sidebarToggle');
23
29
  const sidebarSearch = document.getElementById('sidebarSearch');
@@ -27,6 +33,25 @@ const tabsContainer = document.getElementById('tabsContainer');
27
33
  const tabsBar = document.getElementById('tabsBar');
28
34
  const tableView = document.getElementById('tableView');
29
35
  const pagination = document.getElementById('pagination');
36
+ const addConnectionButton = document.getElementById('addConnectionButton');
37
+
38
+ // Connection UI Elements
39
+ const connectionDialog = document.getElementById('connectionDialog');
40
+ const closeConnectionDialogButton = document.getElementById('closeConnectionDialog');
41
+ const connectionNameInput = document.getElementById('connectionName');
42
+ const connectionUrlInput = document.getElementById('connectionUrl');
43
+ const connectionTabs = document.querySelectorAll('.connection-type-tab');
44
+ const modeUrl = document.getElementById('modeUrl');
45
+ const modeParams = document.getElementById('modeParams');
46
+ const connHost = document.getElementById('connHost');
47
+ const connPort = document.getElementById('connPort');
48
+ const connDatabase = document.getElementById('connDatabase');
49
+ const connUser = document.getElementById('connUser');
50
+ const connPassword = document.getElementById('connPassword');
51
+ const sslModeSelect = document.getElementById('sslMode');
52
+ const connectButton = document.getElementById('connectButton');
53
+ const connectionError = document.getElementById('connectionError');
54
+ const loadingOverlay = document.getElementById('loadingOverlay');
30
55
 
31
56
  /**
32
57
  * Initialize the application when DOM is ready.
@@ -34,7 +59,39 @@ const pagination = document.getElementById('pagination');
34
59
  */
35
60
  document.addEventListener('DOMContentLoaded', () => {
36
61
  initTheme();
37
- loadTables();
62
+ fetchConnections(); // Check status and load connections
63
+
64
+ // Connection Event Listeners
65
+ connectButton.addEventListener('click', handleConnect);
66
+ addConnectionButton.addEventListener('click', () => showConnectionDialog(true));
67
+ closeConnectionDialogButton.addEventListener('click', hideConnectionDialog);
68
+
69
+ connectionTabs.forEach(tab => {
70
+ tab.addEventListener('click', () => {
71
+ // Switch active tab
72
+ connectionTabs.forEach(t => t.classList.remove('active'));
73
+ tab.classList.add('active');
74
+
75
+ // Show content
76
+ const target = tab.dataset.target;
77
+ if (target === 'url') {
78
+ modeUrl.style.display = 'block';
79
+ modeParams.style.display = 'none';
80
+ connectionDialog.dataset.inputMode = 'url';
81
+ } else {
82
+ modeUrl.style.display = 'none';
83
+ modeParams.style.display = 'block';
84
+ connectionDialog.dataset.inputMode = 'params';
85
+ }
86
+ });
87
+ });
88
+
89
+ // Allow Enter key to submit connection form
90
+ connectionUrlInput.addEventListener('keypress', (e) => {
91
+ if (e.key === 'Enter') {
92
+ handleConnect();
93
+ }
94
+ });
38
95
 
39
96
  sidebarToggle.addEventListener('click', () => {
40
97
  if (tabs.length > 0) {
@@ -129,7 +186,7 @@ function updateThemeIcon() {
129
186
  }
130
187
 
131
188
  function updateSidebarToggleState() {
132
- if (tabs.length === 0) {
189
+ if (tabs.length === 0 && connections.length === 0) {
133
190
  sidebarToggle.disabled = true;
134
191
  sidebarToggle.classList.add('disabled');
135
192
  sidebar.classList.remove('minimized');
@@ -140,13 +197,397 @@ function updateSidebarToggleState() {
140
197
  }
141
198
 
142
199
  /**
143
- * Load all tables from the database via API.
200
+ * Fetch active connections from API.
201
+ */
202
+ async function fetchConnections() {
203
+ try {
204
+ const response = await fetch('/api/connections');
205
+ const data = await response.json();
206
+
207
+ connections = data.connections || [];
208
+
209
+ if (connections.length > 0) {
210
+ if (!activeConnectionId || !connections.find(c => c.id === activeConnectionId)) {
211
+ activeConnectionId = connections[0].id;
212
+ }
213
+ renderConnectionsList();
214
+ loadTables();
215
+ hideConnectionDialog();
216
+ } else {
217
+ showConnectionDialog(false);
218
+ }
219
+ } catch (error) {
220
+ console.error('Failed to fetch connections:', error);
221
+ showConnectionDialog(false);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Render the list of active connections in the sidebar.
227
+ */
228
+ function renderConnectionsList() {
229
+ connectionsList.innerHTML = '';
230
+
231
+ connections.forEach(conn => {
232
+ const li = document.createElement('li');
233
+ li.className = 'connection-item';
234
+ if (conn.id === activeConnectionId) {
235
+ li.classList.add('active');
236
+ }
237
+
238
+ const nameSpan = document.createElement('span');
239
+ nameSpan.className = 'connection-name';
240
+ nameSpan.textContent = conn.name;
241
+ nameSpan.title = 'Click to edit connection';
242
+ nameSpan.style.cursor = 'pointer';
243
+ nameSpan.addEventListener('click', (e) => {
244
+ e.stopPropagation();
245
+ handleConnectionEdit(conn);
246
+ });
247
+
248
+ // Add click event for the li to switch connection if not clicking name or close
249
+ li.addEventListener('click', (e) => {
250
+ if (e.target !== nameSpan && e.target !== disconnectBtn) {
251
+ switchConnection(conn.id);
252
+ }
253
+ });
254
+
255
+ const disconnectBtn = document.createElement('button');
256
+ disconnectBtn.className = 'connection-disconnect';
257
+ disconnectBtn.innerHTML = '×';
258
+ disconnectBtn.title = 'Disconnect';
259
+ disconnectBtn.addEventListener('click', (e) => {
260
+ e.stopPropagation();
261
+ handleDisconnect(conn.id);
262
+ });
263
+
264
+ li.appendChild(nameSpan);
265
+ li.appendChild(disconnectBtn);
266
+ connectionsList.appendChild(li);
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Switch the active connection.
272
+ * @param {string} connectionId - The connection ID to switch to
273
+ */
274
+ function switchConnection(connectionId) {
275
+ if (activeConnectionId === connectionId) return;
276
+
277
+ activeConnectionId = connectionId;
278
+ renderConnectionsList();
279
+ loadTables();
280
+
281
+ // Clear tables view if no tab from this connection is active
282
+ const currentTab = getActiveTab();
283
+ if (currentTab && currentTab.connectionId !== activeConnectionId) {
284
+ // Try to find the last active tab for this connection
285
+ const tabIndex = tabs.findIndex(t => t.connectionId === activeConnectionId);
286
+ if (tabIndex !== -1) {
287
+ switchToTab(tabIndex);
288
+ } else {
289
+ // No tabs for this connection, show empty state
290
+ tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
291
+ pagination.style.display = 'none';
292
+
293
+ // Deselect all tabs visually
294
+ const tabElements = tabsBar.querySelectorAll('.tab');
295
+ tabElements.forEach(el => el.classList.remove('active'));
296
+ activeTabIndex = -1;
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Handle database connection.
303
+ */
304
+ async function handleConnect() {
305
+ let url = '';
306
+ const connectionName = connectionNameInput.value.trim();
307
+ const sslMode = sslModeSelect.value;
308
+ const inputMode = connectionDialog.dataset.inputMode || 'url';
309
+
310
+ if (inputMode === 'url') {
311
+ url = connectionUrlInput.value.trim();
312
+ } else {
313
+ // Build URL from params
314
+ const host = connHost.value.trim() || 'localhost';
315
+ const port = connPort.value.trim() || '5432';
316
+ const database = connDatabase.value.trim() || 'postgres';
317
+ const user = connUser.value.trim();
318
+ const password = connPassword.value;
319
+
320
+ if (!user) {
321
+ showConnectionError('Username is required');
322
+ return;
323
+ }
324
+
325
+ url = buildConnectionString(user, password, host, port, database);
326
+ }
327
+
328
+ if (!url) {
329
+ showConnectionError('Please enter a connection URL');
330
+ return;
331
+ }
332
+
333
+ // Validate URL format
334
+ try {
335
+ if (!url.startsWith('postgres://') && !url.startsWith('postgresql://')) {
336
+ showConnectionError('URL must start with postgres:// or postgresql://');
337
+ return;
338
+ }
339
+
340
+ const urlObj = new URL(url);
341
+ if (!urlObj.pathname || urlObj.pathname === '/') {
342
+ showConnectionError('URL must include a database name');
343
+ return;
344
+ }
345
+ } catch (e) {
346
+ showConnectionError('Invalid URL format');
347
+ return;
348
+ }
349
+
350
+ try {
351
+ setConnectingState(true);
352
+
353
+
354
+ let urlPath = '/api/connect';
355
+ let method = 'POST';
356
+
357
+ if (connectionDialog.dataset.mode === 'edit') {
358
+ const id = connectionDialog.dataset.connectionId;
359
+ urlPath = `/api/connections/${id}`;
360
+ method = 'PUT';
361
+ }
362
+
363
+ const response = await fetch(urlPath, {
364
+ method: method,
365
+ headers: { 'Content-Type': 'application/json' },
366
+ body: JSON.stringify({ url, sslMode, name: connectionName || undefined })
367
+ });
368
+
369
+ const data = await response.json();
370
+
371
+ if (response.ok) {
372
+ if (data.connected || data.updated) {
373
+ // If updated, update local array
374
+ if (data.updated) {
375
+ const index = connections.findIndex(c => c.id === data.connectionId);
376
+ if (index !== -1) {
377
+ connections[index] = {
378
+ ...connections[index],
379
+ name: data.name,
380
+ connectionString: url,
381
+ sslMode: sslMode
382
+ };
383
+ }
384
+ } else {
385
+ // New connection
386
+ // Check if this connection ID already exists in our list (backend might return existing one)
387
+ const existingIndex = connections.findIndex(c => c.id === data.connectionId);
388
+ if (existingIndex === -1) {
389
+ connections.push({
390
+ id: data.connectionId,
391
+ name: data.name,
392
+ connectionString: url,
393
+ sslMode: sslMode
394
+ });
395
+ } else {
396
+ console.log('Connection already exists, switching to it');
397
+ }
398
+ }
399
+
400
+ activeConnectionId = data.connectionId;
401
+
402
+ renderConnectionsList();
403
+ loadTables();
404
+ hideConnectionDialog();
405
+ // Don't clear inputs here, will clear on show
406
+ }
407
+ } else {
408
+ showConnectionError(data.error || 'Failed to connect');
409
+ }
410
+ } catch (error) {
411
+ showConnectionError(error.message);
412
+ } finally {
413
+ setConnectingState(false);
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Handle database disconnection.
419
+ * @param {string} connectionId - ID of connection to disconnect
420
+ */
421
+ async function handleDisconnect(connectionId) {
422
+ if (!confirm('Are you sure you want to disconnect? Associated tabs will be closed.')) {
423
+ return;
424
+ }
425
+
426
+ try {
427
+ const response = await fetch('/api/disconnect', {
428
+ method: 'POST',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify({ connectionId })
431
+ });
432
+
433
+ if (response.ok) {
434
+ // Remove from local state
435
+ connections = connections.filter(c => c.id !== connectionId);
436
+
437
+ // Close associated tabs
438
+ const tabsToRemove = [];
439
+ tabs.forEach((tab, index) => {
440
+ if (tab.connectionId === connectionId) {
441
+ tabsToRemove.push(index);
442
+ }
443
+ });
444
+
445
+ // Remove tabs in reverse order to maintain indices
446
+ for (let i = tabsToRemove.length - 1; i >= 0; i--) {
447
+ closeTab(tabsToRemove[i]);
448
+ }
449
+
450
+ if (connections.length === 0) {
451
+ activeConnectionId = null;
452
+ sidebarContent.innerHTML = '';
453
+ tableCount.textContent = '0';
454
+ renderConnectionsList();
455
+ showConnectionDialog(false);
456
+ } else {
457
+ if (activeConnectionId === connectionId) {
458
+ // Switch to another connection (the first one)
459
+ activeConnectionId = connections[0].id;
460
+ loadTables();
461
+ }
462
+ renderConnectionsList();
463
+ }
464
+ }
465
+ } catch (error) {
466
+ console.error('Failed to disconnect:', error);
467
+ }
468
+ }
469
+
470
+
471
+
472
+ function showConnectionDialog(allowClose, editMode = false, connection = null) {
473
+ connectionDialog.style.display = 'flex';
474
+ connectionError.style.display = 'none';
475
+
476
+ const title = connectionDialog.querySelector('h2');
477
+ title.textContent = editMode ? 'Edit Connection' : 'Connect to Database';
478
+ connectButton.textContent = editMode ? 'Save' : 'Connect';
479
+
480
+ connectionDialog.dataset.mode = editMode ? 'edit' : 'add';
481
+
482
+ if (editMode && connection) {
483
+ connectionDialog.dataset.connectionId = connection.id;
484
+ connectionNameInput.value = connection.name || '';
485
+ sslModeSelect.value = connection.sslMode || 'prefer';
486
+
487
+ // Try to parse URL to populate params
488
+ const parsed = parseConnectionString(connection.connectionString);
489
+ if (parsed) {
490
+ connHost.value = parsed.host;
491
+ connPort.value = parsed.port;
492
+ connDatabase.value = parsed.database;
493
+ connUser.value = parsed.user;
494
+ connPassword.value = parsed.password;
495
+ }
496
+ connectionUrlInput.value = connection.connectionString || '';
497
+ } else {
498
+ delete connectionDialog.dataset.connectionId;
499
+ connectionUrlInput.value = '';
500
+ connectionNameInput.value = '';
501
+ sslModeSelect.value = 'prefer';
502
+
503
+ // Reset params
504
+ connHost.value = 'localhost';
505
+ connPort.value = '5432';
506
+ connDatabase.value = 'postgres';
507
+ connUser.value = '';
508
+ connPassword.value = '';
509
+ }
510
+
511
+ // Reset tabs to Params mode by default (since we swapped buttons, tab[0] is Params)
512
+ connectionTabs.forEach(t => t.classList.remove('active'));
513
+ connectionTabs[0].classList.add('active');
514
+
515
+ modeUrl.style.display = 'none';
516
+ modeParams.style.display = 'block';
517
+ connectionDialog.dataset.inputMode = 'params';
518
+
519
+ connHost.focus();
520
+
521
+ if (allowClose && connections.length > 0) {
522
+ closeConnectionDialogButton.style.display = 'block';
523
+ } else {
524
+ closeConnectionDialogButton.style.display = 'none';
525
+ }
526
+ }
527
+
528
+ function buildConnectionString(user, password, host, port, database) {
529
+ let auth = user;
530
+ if (password) {
531
+ auth += `:${encodeURIComponent(password)}`;
532
+ }
533
+ return `postgresql://${auth}@${host}:${port}/${database}`;
534
+ }
535
+
536
+ function parseConnectionString(urlStr) {
537
+ try {
538
+ if (!urlStr) return null;
539
+ // Handle cases where protocol might be missing (though validation enforces it)
540
+ if (!urlStr.includes('://')) {
541
+ urlStr = 'postgresql://' + urlStr;
542
+ }
543
+ const url = new URL(urlStr);
544
+ return {
545
+ host: url.hostname || 'localhost',
546
+ port: url.port || '5432',
547
+ database: url.pathname.replace(/^\//, '') || 'postgres',
548
+ user: url.username || '',
549
+ password: url.password || '' // Note: URL decoding happens automatically for username/password properties? Verify.
550
+ // Actually URL properties are usually decoded. decodeURIComponent check might be needed if raw.
551
+ };
552
+ } catch (e) {
553
+ return null;
554
+ }
555
+ }
556
+
557
+ function handleConnectionEdit(connection) {
558
+ showConnectionDialog(true, true, connection);
559
+ }
560
+
561
+ function hideConnectionDialog() {
562
+ connectionDialog.style.display = 'none';
563
+ }
564
+
565
+ function showConnectionError(message) {
566
+ connectionError.textContent = message;
567
+ connectionError.style.display = 'block';
568
+ }
569
+
570
+ function setConnectingState(isConnecting) {
571
+ connectButton.disabled = isConnecting;
572
+ connectButton.textContent = isConnecting ? 'Connecting...' : 'Connect';
573
+ connectionNameInput.disabled = isConnecting;
574
+ connectionUrlInput.disabled = isConnecting;
575
+ sslModeSelect.disabled = isConnecting;
576
+ }
577
+
578
+ /**
579
+ * Load all tables from the active database via API.
144
580
  * Fetches table list and updates the sidebar.
145
581
  */
146
582
  async function loadTables() {
583
+ if (!activeConnectionId) return;
584
+
147
585
  try {
148
586
  sidebarContent.innerHTML = '<div class="loading">Loading tables...</div>';
149
- const response = await fetch('/api/tables');
587
+
588
+ const response = await fetch('/api/tables', {
589
+ headers: { 'x-connection-id': activeConnectionId }
590
+ });
150
591
  const data = await response.json();
151
592
 
152
593
  if (data.error) {
@@ -225,7 +666,11 @@ function escapeHtml(text) {
225
666
  }
226
667
 
227
668
  function updateSidebarActiveState() {
228
- const activeTable = getActiveTab()?.tableName;
669
+ const activeTab = getActiveTab();
670
+
671
+ // Only highlight sidebar if active tab belongs to active connection
672
+ const activeTable = (activeTab && activeTab.connectionId === activeConnectionId) ? activeTab.tableName : null;
673
+
229
674
  if (!activeTable) {
230
675
  const tableItems = sidebarContent.querySelectorAll('.table-list li');
231
676
  tableItems.forEach(item => item.classList.remove('active'));
@@ -252,12 +697,18 @@ function updateSidebarActiveState() {
252
697
  * @param {string} tableName - Name of the table to open
253
698
  */
254
699
  function handleTableSelect(tableName) {
255
- const existingTabIndex = tabs.findIndex(tab => tab.tableName === tableName);
700
+ if (!activeConnectionId) return;
701
+
702
+ // Check if tab already exists for this table AND connection
703
+ const existingTabIndex = tabs.findIndex(tab =>
704
+ tab.tableName === tableName && tab.connectionId === activeConnectionId
705
+ );
256
706
 
257
707
  if (existingTabIndex !== -1) {
258
708
  switchToTab(existingTabIndex);
259
709
  } else {
260
710
  const newTab = {
711
+ connectionId: activeConnectionId,
261
712
  tableName: tableName,
262
713
  page: 1,
263
714
  totalCount: 0,
@@ -269,7 +720,8 @@ function handleTableSelect(tableName) {
269
720
  cursor: null, // Cursor for cursor-based pagination
270
721
  cursorHistory: [], // History for backward navigation
271
722
  hasPrimaryKey: false, // Whether table has primary key
272
- isApproximate: false // Whether count is approximate (for large tables)
723
+ isApproximate: false, // Whether count is approximate (for large tables)
724
+ limit: 100 // Rows per page
273
725
  };
274
726
  tabs.push(newTab);
275
727
  activeTabIndex = tabs.length - 1;
@@ -287,14 +739,12 @@ function switchToTab(index) {
287
739
  activeTabIndex = index;
288
740
  const tab = tabs[activeTabIndex];
289
741
 
290
- const tableItems = sidebarContent.querySelectorAll('.table-list li');
291
- tableItems.forEach(item => {
292
- if (item.textContent === tab.tableName) {
293
- item.classList.add('active');
294
- } else {
295
- item.classList.remove('active');
296
- }
297
- });
742
+ // Switch connection if tab belongs to different connection
743
+ if (tab.connectionId !== activeConnectionId) {
744
+ activeConnectionId = tab.connectionId;
745
+ renderConnectionsList();
746
+ loadTables();
747
+ }
298
748
 
299
749
  renderTabs();
300
750
  updateSidebarActiveState();
@@ -312,29 +762,41 @@ function closeTab(index, event) {
312
762
  event.stopPropagation();
313
763
  }
314
764
 
315
- if (tabs.length <= 1) {
316
- tabs = [];
765
+ if (index < 0 || index >= tabs.length) return;
766
+
767
+ const tabWasActive = (index === activeTabIndex);
768
+ tabs.splice(index, 1);
769
+
770
+ if (tabs.length === 0) {
317
771
  activeTabIndex = -1;
318
772
  tabsContainer.style.display = 'none';
319
773
  tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
320
774
  pagination.style.display = 'none';
321
-
322
- const tableItems = sidebarContent.querySelectorAll('.table-list li');
323
- tableItems.forEach(item => item.classList.remove('active'));
324
-
775
+ updateSidebarActiveState();
325
776
  updateSidebarToggleState();
326
777
  return;
327
778
  }
328
779
 
329
- tabs.splice(index, 1);
330
-
780
+ // Adjust activeTabIndex
331
781
  if (activeTabIndex >= index) {
332
782
  activeTabIndex--;
333
- if (activeTabIndex < 0) activeTabIndex = 0;
783
+ }
784
+ if (activeTabIndex < 0) {
785
+ activeTabIndex = 0;
334
786
  }
335
787
 
336
- if (index === activeTabIndex || activeTabIndex >= tabs.length) {
337
- activeTabIndex = Math.max(0, tabs.length - 1);
788
+ // If we closed the active tab, switch to the new active one
789
+ if (tabWasActive) {
790
+ // Ensure index is within bounds
791
+ if (activeTabIndex >= tabs.length) activeTabIndex = tabs.length - 1;
792
+
793
+ const newActiveTab = tabs[activeTabIndex];
794
+ // Switch connection if needed
795
+ if (newActiveTab && newActiveTab.connectionId !== activeConnectionId) {
796
+ activeConnectionId = newActiveTab.connectionId;
797
+ renderConnectionsList();
798
+ loadTables();
799
+ }
338
800
  }
339
801
 
340
802
  renderTabs();
@@ -347,9 +809,7 @@ function closeTab(index, event) {
347
809
  } else {
348
810
  loadTableData();
349
811
  }
350
-
351
812
  updateSidebarActiveState();
352
- updateSidebarToggleState();
353
813
  }
354
814
  }
355
815
 
@@ -372,6 +832,14 @@ function renderTabs() {
372
832
  tabs.forEach((tab, index) => {
373
833
  const tabElement = document.createElement('div');
374
834
  tabElement.className = `tab ${index === activeTabIndex ? 'active' : ''}`;
835
+ // Add connection indicator for tab if multiple connections exist
836
+ if (connections.length > 1) {
837
+ const conn = connections.find(c => c.id === tab.connectionId);
838
+ if (conn) {
839
+ tabElement.title = `${tab.tableName} (${conn.name})`;
840
+ }
841
+ }
842
+
375
843
  tabElement.addEventListener('click', () => switchToTab(index));
376
844
 
377
845
  const tabLabel = document.createElement('span');
@@ -396,9 +864,7 @@ function closeAllTabs() {
396
864
  tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
397
865
  pagination.style.display = 'none';
398
866
 
399
- const tableItems = sidebarContent.querySelectorAll('.table-list li');
400
- tableItems.forEach(item => item.classList.remove('active'));
401
-
867
+ updateSidebarActiveState();
402
868
  updateSidebarToggleState();
403
869
  }
404
870
 
@@ -413,7 +879,8 @@ function handleRefresh() {
413
879
 
414
880
  tab.data = null;
415
881
 
416
- const refreshIcon = document.querySelector('.refresh-icon');
882
+ const refreshButton = document.querySelector('#refreshButton');
883
+ const refreshIcon = refreshButton ? refreshButton.querySelector('.refresh-icon') : null;
417
884
  if (refreshIcon) {
418
885
  refreshIcon.classList.add('spinning');
419
886
  setTimeout(() => {
@@ -424,6 +891,27 @@ function handleRefresh() {
424
891
  loadTableData();
425
892
  }
426
893
 
894
+ /**
895
+ * Check if a value is a JSON object or array (JSONB/JSON type).
896
+ * Excludes null, Date objects, and other non-JSON types.
897
+ * @param {*} value - Value to check
898
+ * @returns {boolean} True if value is a JSON object or array
899
+ */
900
+ function isJsonValue(value) {
901
+ if (value === null || value === undefined) {
902
+ return false;
903
+ }
904
+
905
+ if (typeof value === 'object') {
906
+ if (value instanceof Date) {
907
+ return false;
908
+ }
909
+ return Array.isArray(value) || Object.prototype.toString.call(value) === '[object Object]';
910
+ }
911
+
912
+ return false;
913
+ }
914
+
427
915
  /**
428
916
  * Load table data for the active tab.
429
917
  * Uses cursor-based pagination for tables with primary keys (more efficient for large datasets).
@@ -434,16 +922,26 @@ async function loadTableData() {
434
922
  if (!tab) return;
435
923
 
436
924
  try {
437
- tableView.innerHTML = '<div class="loading-state"><p>Loading data from ' + tab.tableName + '...</p></div>';
925
+ showLoading();
926
+ // tableView.innerHTML = '<div class="loading-state"><p>Loading data from ' + tab.tableName + '...</p></div>';
438
927
 
439
928
  // Build query with cursor-based pagination if available
440
- let queryString = `page=${tab.page}&limit=100`;
441
- if (tab.hasPrimaryKey && tab.cursor && tab.page > 1) {
442
- // Use cursor for forward navigation (more efficient than OFFSET)
929
+ if (!tab.limit) {
930
+ tab.limit = 100; // Default limit for existing tabs
931
+ }
932
+ let queryString = `page=${tab.page}&limit=${tab.limit}`;
933
+ if (tab.sortColumn) {
934
+ queryString += `&sortColumn=${encodeURIComponent(tab.sortColumn)}&sortDirection=${tab.sortDirection}`;
935
+ }
936
+
937
+ if (tab.hasPrimaryKey && tab.cursor && tab.page > 1 && !tab.sortColumn) {
938
+ // Use cursor for forward navigation (only if using default sort)
443
939
  queryString += `&cursor=${encodeURIComponent(tab.cursor)}`;
444
940
  }
445
941
 
446
- const response = await fetch(`/api/tables/${tab.tableName}?${queryString}`);
942
+ const response = await fetch(`/api/tables/${tab.tableName}?${queryString}`, {
943
+ headers: { 'x-connection-id': tab.connectionId }
944
+ });
447
945
  const data = await response.json();
448
946
 
449
947
  if (data.error) {
@@ -478,6 +976,8 @@ async function loadTableData() {
478
976
  } catch (error) {
479
977
  tableView.innerHTML = '<div class="error-state"><p>Error: ' + error.message + '</p></div>';
480
978
  pagination.style.display = 'none';
979
+ } finally {
980
+ hideLoading();
481
981
  }
482
982
  }
483
983
 
@@ -487,27 +987,59 @@ function renderTable(data) {
487
987
 
488
988
  const columns = Object.keys(data.rows[0] || {});
489
989
 
990
+ if (!tab.limit) {
991
+ tab.limit = 100; // Default limit for existing tabs
992
+ }
993
+
490
994
  const tableHeader = document.createElement('div');
491
995
  tableHeader.className = 'table-header';
996
+ const startRow = ((tab.page - 1) * tab.limit) + 1;
997
+ const endRow = Math.min(tab.page * tab.limit, tab.totalCount);
998
+ const totalRows = tab.totalCount;
999
+
492
1000
  tableHeader.innerHTML = `
493
1001
  <div class="table-header-left">
494
1002
  <h2>${tab.tableName}</h2>
495
- <button class="refresh-button" id="refreshButton" title="Refresh data">
496
- <span class="refresh-icon">↻</span>
497
- </button>
498
- <div class="column-selector">
499
- <button class="column-button" id="columnButton" title="Show/Hide columns">
500
- <span class="column-label" id="columnLabel">Columns</span>
1003
+ <div class="table-header-actions">
1004
+ <button class="refresh-button" id="refreshButton" title="Refresh data">
1005
+ <svg class="refresh-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1006
+ <path d="M8 2V6M8 14V10M2 8H6M10 8H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
1007
+ <path d="M2.5 5.5C3.1 4.2 4.1 3.2 5.4 2.6M13.5 10.5C12.9 11.8 11.9 12.8 10.6 13.4M5.5 2.5C4.2 3.1 3.2 4.1 2.6 5.4M10.5 13.5C11.8 12.9 12.8 11.9 13.4 10.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
1008
+ </svg>
1009
+ <span class="refresh-text">Refresh</span>
501
1010
  </button>
502
- <div class="column-menu" id="columnMenu" style="display: none;">
503
- <div class="column-menu-header">Columns</div>
504
- <div class="column-menu-options" id="columnMenuOptions"></div>
1011
+ <div class="limit-selector">
1012
+ <select id="limitSelect" class="limit-select" title="Rows per page">
1013
+ <option value="25" ${tab.limit === 25 ? 'selected' : ''}>25 rows</option>
1014
+ <option value="50" ${tab.limit === 50 ? 'selected' : ''}>50 rows</option>
1015
+ <option value="100" ${tab.limit === 100 ? 'selected' : ''}>100 rows</option>
1016
+ <option value="200" ${tab.limit === 200 ? 'selected' : ''}>200 rows</option>
1017
+ <option value="500" ${tab.limit === 500 ? 'selected' : ''}>500 rows</option>
1018
+ </select>
1019
+ </div>
1020
+ <div class="column-selector">
1021
+ <button class="column-button" id="columnButton" title="Show/Hide columns">
1022
+ <svg class="column-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1023
+ <path d="M3 2H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H3C2.44772 14 2 13.5523 2 13V3C2 2.44772 2.44772 2 3 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1024
+ <path d="M6 2V14M10 2V14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
1025
+ </svg>
1026
+ <span class="column-label" id="columnLabel">Columns</span>
1027
+ </button>
1028
+ <div class="column-menu" id="columnMenu" style="display: none;">
1029
+ <div class="column-menu-header">Columns</div>
1030
+ <div class="column-menu-options" id="columnMenuOptions"></div>
1031
+ </div>
505
1032
  </div>
506
1033
  </div>
507
1034
  </div>
508
- <span class="row-info">
509
- Showing ${((tab.page - 1) * 100) + 1}-${Math.min(tab.page * 100, tab.totalCount)} of ${tab.totalCount}${tab.isApproximate ? ' (approx.)' : ''} rows
510
- </span>
1035
+ <div class="row-info-container">
1036
+ <span class="row-info">
1037
+ <span class="row-info-range">${startRow.toLocaleString()}–${endRow.toLocaleString()}</span>
1038
+ <span class="row-info-separator">of</span>
1039
+ <span class="row-info-total">${totalRows.toLocaleString()}</span>
1040
+ ${tab.isApproximate ? '<span class="row-info-approx">(approx.)</span>' : ''}
1041
+ </span>
1042
+ </div>
511
1043
  `;
512
1044
 
513
1045
  const refreshButton = tableHeader.querySelector('#refreshButton');
@@ -515,6 +1047,21 @@ function renderTable(data) {
515
1047
  refreshButton.addEventListener('click', handleRefresh);
516
1048
  }
517
1049
 
1050
+ const limitSelect = tableHeader.querySelector('#limitSelect');
1051
+ if (limitSelect) {
1052
+ limitSelect.addEventListener('change', (e) => {
1053
+ const newLimit = parseInt(e.target.value, 10);
1054
+ if (tab.limit !== newLimit) {
1055
+ tab.limit = newLimit;
1056
+ tab.page = 1; // Reset to first page when limit changes
1057
+ tab.cursor = null; // Reset cursor
1058
+ tab.cursorHistory = [];
1059
+ tab.data = null; // Clear cache
1060
+ loadTableData();
1061
+ }
1062
+ });
1063
+ }
1064
+
518
1065
  if (!tab.hiddenColumns) {
519
1066
  tab.hiddenColumns = [];
520
1067
  }
@@ -538,7 +1085,6 @@ function renderTable(data) {
538
1085
 
539
1086
  visibleColumns.forEach((column, index) => {
540
1087
  const th = document.createElement('th');
541
- th.textContent = column;
542
1088
  th.className = 'sortable resizable';
543
1089
  th.dataset.column = column;
544
1090
 
@@ -549,6 +1095,78 @@ function renderTable(data) {
549
1095
  th.style.minWidth = '120px';
550
1096
  }
551
1097
 
1098
+ const columnHeader = document.createElement('div');
1099
+ columnHeader.className = 'column-header';
1100
+
1101
+ // Column name with key badges
1102
+ const columnNameRow = document.createElement('div');
1103
+ columnNameRow.className = 'column-name-row';
1104
+
1105
+ const columnName = document.createElement('div');
1106
+ columnName.className = 'column-name';
1107
+ columnName.textContent = column;
1108
+ columnNameRow.appendChild(columnName);
1109
+
1110
+ const columnMeta = data.columns && data.columns[column] ? data.columns[column] : null;
1111
+
1112
+ let dataType = '';
1113
+ let isPrimaryKey = false;
1114
+ let isForeignKey = false;
1115
+ let foreignKeyRef = null;
1116
+ let isUnique = false;
1117
+
1118
+ if (columnMeta) {
1119
+ if (typeof columnMeta === 'string') {
1120
+ dataType = columnMeta;
1121
+ } else {
1122
+ dataType = columnMeta.dataType || '';
1123
+ isPrimaryKey = columnMeta.isPrimaryKey || false;
1124
+ isForeignKey = columnMeta.isForeignKey || false;
1125
+ foreignKeyRef = columnMeta.foreignKeyRef || null;
1126
+ isUnique = columnMeta.isUnique || false;
1127
+ }
1128
+ }
1129
+
1130
+ const keyBadges = document.createElement('div');
1131
+ keyBadges.className = 'key-badges';
1132
+
1133
+ if (isPrimaryKey) {
1134
+ const pkBadge = document.createElement('span');
1135
+ pkBadge.className = 'key-badge key-badge-pk';
1136
+ pkBadge.textContent = 'PK';
1137
+ pkBadge.title = 'Primary Key';
1138
+ keyBadges.appendChild(pkBadge);
1139
+ }
1140
+
1141
+ if (isForeignKey && foreignKeyRef) {
1142
+ const fkBadge = document.createElement('span');
1143
+ fkBadge.className = 'key-badge key-badge-fk';
1144
+ fkBadge.textContent = 'FK';
1145
+ fkBadge.title = `Foreign Key → ${foreignKeyRef.table}.${foreignKeyRef.column}`;
1146
+ keyBadges.appendChild(fkBadge);
1147
+ }
1148
+
1149
+ if (isUnique && !isPrimaryKey) {
1150
+ const uqBadge = document.createElement('span');
1151
+ uqBadge.className = 'key-badge key-badge-uq';
1152
+ uqBadge.textContent = 'UQ';
1153
+ uqBadge.title = 'Unique Constraint';
1154
+ keyBadges.appendChild(uqBadge);
1155
+ }
1156
+
1157
+ if (keyBadges.children.length > 0) {
1158
+ columnNameRow.appendChild(keyBadges);
1159
+ }
1160
+
1161
+ // Column datatype
1162
+ const columnDatatype = document.createElement('div');
1163
+ columnDatatype.className = 'column-datatype';
1164
+ columnDatatype.textContent = dataType;
1165
+
1166
+ columnHeader.appendChild(columnNameRow);
1167
+ columnHeader.appendChild(columnDatatype);
1168
+ th.appendChild(columnHeader);
1169
+
552
1170
  if (tab.sortColumn === column) {
553
1171
  th.classList.add(`sorted-${tab.sortDirection}`);
554
1172
  }
@@ -582,9 +1200,10 @@ function renderTable(data) {
582
1200
  table.appendChild(thead);
583
1201
 
584
1202
  const tbody = document.createElement('tbody');
585
- const sortedRows = getSortedRows(data.rows, tab);
1203
+ // Server-side sorting, so rows are already sorted
1204
+ const rows = data.rows || [];
586
1205
 
587
- sortedRows.forEach(row => {
1206
+ rows.forEach(row => {
588
1207
  const tr = document.createElement('tr');
589
1208
  visibleColumns.forEach(column => {
590
1209
  const td = document.createElement('td');
@@ -597,11 +1216,34 @@ function renderTable(data) {
597
1216
  }
598
1217
 
599
1218
  const value = row[column];
1219
+
1220
+ // Store original value for popup
1221
+ td.dataset.originalValue = value !== null && value !== undefined
1222
+ ? (isJsonValue(value) ? JSON.stringify(value, null, 2) : String(value))
1223
+ : 'NULL';
1224
+ td.dataset.columnName = column;
1225
+
1226
+ td.addEventListener('click', (e) => {
1227
+ e.stopPropagation();
1228
+ showCellContentPopup(column, value);
1229
+ });
1230
+
1231
+ td.style.cursor = 'pointer';
1232
+
600
1233
  if (value === null || value === undefined) {
601
1234
  const nullSpan = document.createElement('span');
602
1235
  nullSpan.className = 'null-value';
603
1236
  nullSpan.textContent = 'NULL';
604
1237
  td.appendChild(nullSpan);
1238
+ } else if (isJsonValue(value)) {
1239
+ const jsonPre = document.createElement('pre');
1240
+ jsonPre.className = 'json-value';
1241
+ try {
1242
+ jsonPre.textContent = JSON.stringify(value, null, 2);
1243
+ } catch (e) {
1244
+ jsonPre.textContent = String(value);
1245
+ }
1246
+ td.appendChild(jsonPre);
605
1247
  } else {
606
1248
  td.textContent = String(value);
607
1249
  }
@@ -636,6 +1278,7 @@ function setupColumnSelector(tab, columns, tableHeader) {
636
1278
  const columnButton = tableHeader.querySelector('#columnButton');
637
1279
  const columnMenu = tableHeader.querySelector('#columnMenu');
638
1280
  const columnMenuOptions = tableHeader.querySelector('#columnMenuOptions');
1281
+ const columnMenuHeader = tableHeader.querySelector('.column-menu-header');
639
1282
 
640
1283
  if (!columnButton || !columnMenu || !columnMenuOptions) {
641
1284
  console.warn('Column selector elements not found');
@@ -644,6 +1287,55 @@ function setupColumnSelector(tab, columns, tableHeader) {
644
1287
 
645
1288
  columnMenuOptions.innerHTML = '';
646
1289
 
1290
+ // Check if any columns are hidden
1291
+ const hasHiddenColumns = tab.hiddenColumns && tab.hiddenColumns.length > 0;
1292
+
1293
+ if (columnMenuHeader) {
1294
+ let headerTitle = columnMenuHeader.querySelector('.column-menu-header-title');
1295
+ if (!headerTitle) {
1296
+ const headerText = columnMenuHeader.textContent.trim();
1297
+ columnMenuHeader.innerHTML = '';
1298
+ headerTitle = document.createElement('span');
1299
+ headerTitle.className = 'column-menu-header-title';
1300
+ headerTitle.textContent = headerText || 'Columns';
1301
+ columnMenuHeader.appendChild(headerTitle);
1302
+ }
1303
+
1304
+ let selectAllButton = columnMenuHeader.querySelector('.column-select-all-button');
1305
+ if (hasHiddenColumns) {
1306
+ if (!selectAllButton) {
1307
+ selectAllButton = document.createElement('button');
1308
+ selectAllButton.className = 'column-select-all-button';
1309
+ selectAllButton.textContent = 'Select All';
1310
+ selectAllButton.title = 'Show all columns';
1311
+ selectAllButton.addEventListener('click', (e) => {
1312
+ e.stopPropagation();
1313
+ // Show all columns
1314
+ tab.hiddenColumns = [];
1315
+ if (tab.data) {
1316
+ renderTable(tab.data);
1317
+ requestAnimationFrame(() => {
1318
+ const newTableHeader = document.querySelector('.table-header');
1319
+ if (newTableHeader) {
1320
+ const newColumnMenu = newTableHeader.querySelector('#columnMenu');
1321
+ if (newColumnMenu) {
1322
+ newColumnMenu.style.display = 'block';
1323
+ const columns = Object.keys(tab.data.rows[0] || {});
1324
+ setupColumnSelector(tab, columns, newTableHeader);
1325
+ updateColumnButtonLabel(tab, columns, newTableHeader);
1326
+ }
1327
+ }
1328
+ });
1329
+ }
1330
+ });
1331
+ columnMenuHeader.appendChild(selectAllButton);
1332
+ }
1333
+ selectAllButton.style.display = 'block';
1334
+ } else if (selectAllButton) {
1335
+ selectAllButton.style.display = 'none';
1336
+ }
1337
+ }
1338
+
647
1339
  columns.forEach(column => {
648
1340
  const label = document.createElement('label');
649
1341
  label.className = 'column-option';
@@ -806,9 +1498,11 @@ function handleSort(column) {
806
1498
  tab.sortDirection = 'asc';
807
1499
  }
808
1500
 
809
- if (tab.data) {
810
- renderTable(tab.data);
811
- }
1501
+ // Reload data from server with new sort
1502
+ tab.page = 1; // Reset to page 1 on sort change
1503
+ tab.cursor = null;
1504
+ tab.cursorHistory = [];
1505
+ loadTableData();
812
1506
  }
813
1507
 
814
1508
  /**
@@ -836,9 +1530,26 @@ function getSortedRows(rows, tab) {
836
1530
  return tab.sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
837
1531
  }
838
1532
 
839
- // String comparison (case-insensitive)
840
- const aStr = String(aVal).toLowerCase();
841
- const bStr = String(bVal).toLowerCase();
1533
+ let aStr, bStr;
1534
+ if (isJsonValue(aVal)) {
1535
+ try {
1536
+ aStr = JSON.stringify(aVal).toLowerCase();
1537
+ } catch (e) {
1538
+ aStr = String(aVal).toLowerCase();
1539
+ }
1540
+ } else {
1541
+ aStr = String(aVal).toLowerCase();
1542
+ }
1543
+
1544
+ if (isJsonValue(bVal)) {
1545
+ try {
1546
+ bStr = JSON.stringify(bVal).toLowerCase();
1547
+ } catch (e) {
1548
+ bStr = String(bVal).toLowerCase();
1549
+ }
1550
+ } else {
1551
+ bStr = String(bVal).toLowerCase();
1552
+ }
842
1553
 
843
1554
  if (tab.sortDirection === 'asc') {
844
1555
  return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
@@ -854,7 +1565,10 @@ function renderPagination() {
854
1565
  const tab = getActiveTab();
855
1566
  if (!tab) return;
856
1567
 
857
- const limit = 100;
1568
+ if (!tab.limit) {
1569
+ tab.limit = 100; // Default limit for existing tabs
1570
+ }
1571
+ const limit = tab.limit;
858
1572
  const totalPages = Math.ceil(tab.totalCount / limit);
859
1573
 
860
1574
  if (totalPages <= 1) {
@@ -867,24 +1581,41 @@ function renderPagination() {
867
1581
  const hasPrevious = tab.page > 1;
868
1582
  const hasNext = tab.page < totalPages;
869
1583
 
1584
+ const startRow = ((tab.page - 1) * limit) + 1;
1585
+ const endRow = Math.min(tab.page * limit, tab.totalCount);
1586
+
870
1587
  pagination.innerHTML = `
871
- <button
872
- class="pagination-button"
873
- ${!hasPrevious ? 'disabled' : ''}
874
- onclick="handlePageChange(${tab.page - 1})"
875
- >
876
- Previous
877
- </button>
878
- <span class="pagination-info">
879
- Page ${tab.page} of ${totalPages}
880
- </span>
881
- <button
882
- class="pagination-button"
883
- ${!hasNext ? 'disabled' : ''}
884
- onclick="handlePageChange(${tab.page + 1})"
885
- >
886
- Next
887
- </button>
1588
+ <div class="pagination-content">
1589
+ <button
1590
+ class="pagination-button pagination-button-prev"
1591
+ ${!hasPrevious ? 'disabled' : ''}
1592
+ onclick="handlePageChange(${tab.page - 1})"
1593
+ title="Previous page"
1594
+ >
1595
+ <svg class="pagination-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1596
+ <path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1597
+ </svg>
1598
+ <span>Previous</span>
1599
+ </button>
1600
+ <div class="pagination-info-container">
1601
+ <span class="pagination-info">
1602
+ <span class="pagination-page">Page <strong>${tab.page}</strong> of <strong>${totalPages}</strong></span>
1603
+ <span class="pagination-separator">•</span>
1604
+ <span class="pagination-rows">${startRow.toLocaleString()}–${endRow.toLocaleString()} of ${tab.totalCount.toLocaleString()}</span>
1605
+ </span>
1606
+ </div>
1607
+ <button
1608
+ class="pagination-button pagination-button-next"
1609
+ ${!hasNext ? 'disabled' : ''}
1610
+ onclick="handlePageChange(${tab.page + 1})"
1611
+ title="Next page"
1612
+ >
1613
+ <span>Next</span>
1614
+ <svg class="pagination-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1615
+ <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1616
+ </svg>
1617
+ </button>
1618
+ </div>
888
1619
  `;
889
1620
  }
890
1621
 
@@ -926,3 +1657,334 @@ function handlePageChange(newPage) {
926
1657
  }
927
1658
 
928
1659
  window.handlePageChange = handlePageChange;
1660
+
1661
+ /**
1662
+ * Check if a value is a date/time value and parse it.
1663
+ * Detects Date objects, ISO date strings, and PostgreSQL timestamp strings.
1664
+ * @param {*} value - Value to check
1665
+ * @returns {Date|null} Parsed Date object if valid date/time, null otherwise
1666
+ */
1667
+ function isDateTimeValue(value) {
1668
+ if (value === null || value === undefined) {
1669
+ return null;
1670
+ }
1671
+
1672
+ if (value instanceof Date) {
1673
+ return isNaN(value.getTime()) ? null : value;
1674
+ }
1675
+
1676
+ if (typeof value === 'string') {
1677
+ const trimmed = value.trim();
1678
+ if (trimmed === '' || trimmed === 'NULL') {
1679
+ return null;
1680
+ }
1681
+
1682
+ // Try parsing as ISO date string or PostgreSQL timestamp
1683
+ // PostgreSQL timestamps: '2024-01-01 12:00:00' or '2024-01-01 12:00:00.123' or with timezone
1684
+ // ISO strings: '2024-01-01T12:00:00' or '2024-01-01T12:00:00Z' or with timezone offset
1685
+ const date = new Date(trimmed);
1686
+ if (!isNaN(date.getTime())) {
1687
+ const datePattern = /^\d{4}-\d{2}-\d{2}/;
1688
+ if (datePattern.test(trimmed)) {
1689
+ return date;
1690
+ }
1691
+ }
1692
+ }
1693
+
1694
+ return null;
1695
+ }
1696
+
1697
+ /**
1698
+ * Get user's current timezone.
1699
+ * @returns {string} IANA timezone identifier (e.g., 'America/New_York')
1700
+ */
1701
+ function getCurrentTimezone() {
1702
+ try {
1703
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
1704
+ } catch (e) {
1705
+ return 'UTC';
1706
+ }
1707
+ }
1708
+
1709
+ /**
1710
+ * Get list of common timezones.
1711
+ * @returns {Array<{value: string, label: string}>} Array of timezone objects
1712
+ */
1713
+ function getCommonTimezones() {
1714
+ const timezones = [
1715
+ { value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
1716
+ { value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
1717
+ { value: 'America/Chicago', label: 'America/Chicago (CST/CDT)' },
1718
+ { value: 'America/Denver', label: 'America/Denver (MST/MDT)' },
1719
+ { value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
1720
+ { value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
1721
+ { value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
1722
+ { value: 'Europe/Berlin', label: 'Europe/Berlin (CET/CEST)' },
1723
+ { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
1724
+ { value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' },
1725
+ { value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' },
1726
+ { value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
1727
+ { value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT/AEST)' },
1728
+ { value: 'Pacific/Auckland', label: 'Pacific/Auckland (NZDT/NZST)' },
1729
+ ];
1730
+
1731
+ // Add current timezone if not already in list
1732
+ const currentTz = getCurrentTimezone();
1733
+ const hasCurrent = timezones.some(tz => tz.value === currentTz);
1734
+ if (!hasCurrent && currentTz !== 'UTC') {
1735
+ timezones.unshift({ value: currentTz, label: `${currentTz} (Current)` });
1736
+ }
1737
+
1738
+ return timezones;
1739
+ }
1740
+
1741
+ /**
1742
+ * Format date/time in specified timezone.
1743
+ * @param {Date} date - Date object to format
1744
+ * @param {string} timezone - IANA timezone identifier
1745
+ * @returns {string} Formatted date/time string with timezone info
1746
+ */
1747
+ function formatDateTimeInTimezone(date, timezone) {
1748
+ try {
1749
+ const formatter = new Intl.DateTimeFormat('en-US', {
1750
+ timeZone: timezone,
1751
+ year: 'numeric',
1752
+ month: '2-digit',
1753
+ day: '2-digit',
1754
+ hour: '2-digit',
1755
+ minute: '2-digit',
1756
+ second: '2-digit',
1757
+ fractionalSecondDigits: 3,
1758
+ hour12: false,
1759
+ });
1760
+
1761
+ const parts = formatter.formatToParts(date);
1762
+ const year = parts.find(p => p.type === 'year').value;
1763
+ const month = parts.find(p => p.type === 'month').value;
1764
+ const day = parts.find(p => p.type === 'day').value;
1765
+ const hour = parts.find(p => p.type === 'hour').value;
1766
+ const minute = parts.find(p => p.type === 'minute').value;
1767
+ const second = parts.find(p => p.type === 'second').value;
1768
+ const fractionalSecond = parts.find(p => p.type === 'fractionalSecond')?.value || '';
1769
+
1770
+ const tzFormatter = new Intl.DateTimeFormat('en-US', {
1771
+ timeZone: timezone,
1772
+ timeZoneName: 'short',
1773
+ });
1774
+ const tzParts = tzFormatter.formatToParts(date);
1775
+ const tzName = tzParts.find(p => p.type === 'timeZoneName')?.value || timezone;
1776
+
1777
+ const dateStr = `${year}-${month}-${day}`;
1778
+ const timeStr = `${hour}:${minute}:${second}${fractionalSecond ? '.' + fractionalSecond : ''}`;
1779
+
1780
+ return `${dateStr} ${timeStr} ${tzName}`;
1781
+ } catch (e) {
1782
+ return date.toISOString();
1783
+ }
1784
+ }
1785
+
1786
+ /**
1787
+ * Format cell content for display in popup dialog.
1788
+ * Handles JSON values, null values, JSON strings, date/time values, and regular text appropriately.
1789
+ * @param {*} value - The cell value to format
1790
+ * @returns {Object} Object with formatted content, isJson flag, isDateTime flag, and dateValue: { content: string, isJson: boolean, isDateTime: boolean, dateValue: Date | null }
1791
+ */
1792
+ function formatCellContentForPopup(value, timezone = null) {
1793
+ if (value === null || value === undefined) {
1794
+ return { content: 'NULL', isJson: false, isDateTime: false, dateValue: null };
1795
+ }
1796
+
1797
+ const dateValue = isDateTimeValue(value);
1798
+ if (dateValue) {
1799
+ const tz = timezone || getCurrentTimezone();
1800
+ const formatted = formatDateTimeInTimezone(dateValue, tz);
1801
+ return { content: formatted, isJson: false, isDateTime: true, dateValue: dateValue };
1802
+ }
1803
+
1804
+ // Handle JSON objects/arrays
1805
+ if (isJsonValue(value)) {
1806
+ try {
1807
+ return { content: JSON.stringify(value, null, 2), isJson: true, isDateTime: false, dateValue: null };
1808
+ } catch (e) {
1809
+ return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
1810
+ }
1811
+ }
1812
+
1813
+ // Handle string values - check if it's a JSON string
1814
+ if (typeof value === 'string') {
1815
+ const trimmed = value.trim();
1816
+ // Check if string looks like JSON (starts with { or [)
1817
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
1818
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
1819
+ try {
1820
+ const parsed = JSON.parse(trimmed);
1821
+ return { content: JSON.stringify(parsed, null, 2), isJson: true, isDateTime: false, dateValue: null };
1822
+ } catch (e) {
1823
+ return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
1824
+ }
1825
+ }
1826
+ }
1827
+
1828
+ return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
1829
+ }
1830
+
1831
+ /**
1832
+ * Show popup dialog with full cell content.
1833
+ * @param {string} column - Column name
1834
+ * @param {*} value - Cell value
1835
+ */
1836
+ function showCellContentPopup(column, value) {
1837
+ closeCellContentPopup();
1838
+
1839
+ const overlay = document.createElement('div');
1840
+ overlay.className = 'cell-popup-overlay';
1841
+ overlay.addEventListener('click', (e) => {
1842
+ if (e.target === overlay) {
1843
+ closeCellContentPopup();
1844
+ }
1845
+ });
1846
+
1847
+ const dialog = document.createElement('div');
1848
+ dialog.className = 'cell-popup-dialog';
1849
+ dialog.addEventListener('click', (e) => {
1850
+ e.stopPropagation();
1851
+ });
1852
+
1853
+ const formatted = formatCellContentForPopup(value);
1854
+ const formattedContent = formatted.content;
1855
+ const isJson = formatted.isJson;
1856
+ const isDateTime = formatted.isDateTime;
1857
+
1858
+ const header = document.createElement('div');
1859
+ header.className = 'cell-popup-header';
1860
+
1861
+ const title = document.createElement('h3');
1862
+ title.className = 'cell-popup-title';
1863
+ title.textContent = column;
1864
+ header.appendChild(title);
1865
+
1866
+ const headerActions = document.createElement('div');
1867
+ headerActions.className = 'cell-popup-actions';
1868
+
1869
+ const copyButton = document.createElement('button');
1870
+ copyButton.className = 'cell-popup-copy';
1871
+ copyButton.innerHTML = '📋';
1872
+ copyButton.title = 'Copy to clipboard';
1873
+
1874
+ const updateContent = () => {
1875
+ let contentToDisplay = '';
1876
+ let contentToCopy = '';
1877
+
1878
+ if (value === null || value === undefined) {
1879
+ content.classList.add('null-content');
1880
+ content.classList.remove('json-value-popup', 'datetime-value-popup');
1881
+ contentToDisplay = 'NULL';
1882
+ contentToCopy = 'NULL';
1883
+ } else if (isJson) {
1884
+ const formatted = formatCellContentForPopup(value);
1885
+ content.classList.add('json-value-popup');
1886
+ content.classList.remove('null-content', 'datetime-value-popup');
1887
+ contentToDisplay = formatted.content;
1888
+ contentToCopy = formatted.content;
1889
+ } else if (isDateTime) {
1890
+ // Display original date/time value without timezone conversion
1891
+ content.classList.add('datetime-value-popup');
1892
+ content.classList.remove('null-content', 'json-value-popup');
1893
+ contentToDisplay = String(value);
1894
+ contentToCopy = String(value);
1895
+ } else {
1896
+ const formatted = formatCellContentForPopup(value);
1897
+ content.classList.remove('null-content', 'json-value-popup', 'datetime-value-popup');
1898
+ contentToDisplay = formatted.content;
1899
+ contentToCopy = formatted.content;
1900
+ }
1901
+
1902
+ content.textContent = contentToDisplay;
1903
+ copyButton._formattedContent = contentToCopy;
1904
+ };
1905
+
1906
+ copyButton.addEventListener('click', async () => {
1907
+ try {
1908
+ const textToCopy = copyButton._formattedContent || formattedContent;
1909
+ await navigator.clipboard.writeText(textToCopy);
1910
+ copyButton.innerHTML = '✓';
1911
+ copyButton.title = 'Copied!';
1912
+ copyButton.classList.add('copied');
1913
+ setTimeout(() => {
1914
+ copyButton.innerHTML = '📋';
1915
+ copyButton.title = 'Copy to clipboard';
1916
+ copyButton.classList.remove('copied');
1917
+ }, 2000);
1918
+ } catch (err) {
1919
+ console.error('Failed to copy:', err);
1920
+ copyButton.innerHTML = '✗';
1921
+ copyButton.title = 'Copy failed';
1922
+ setTimeout(() => {
1923
+ copyButton.innerHTML = '📋';
1924
+ copyButton.title = 'Copy to clipboard';
1925
+ }, 2000);
1926
+ }
1927
+ });
1928
+ copyButton._formattedContent = formattedContent;
1929
+ headerActions.appendChild(copyButton);
1930
+
1931
+ const closeButton = document.createElement('button');
1932
+ closeButton.className = 'cell-popup-close';
1933
+ closeButton.innerHTML = '×';
1934
+ closeButton.title = 'Close';
1935
+ closeButton.addEventListener('click', closeCellContentPopup);
1936
+ headerActions.appendChild(closeButton);
1937
+
1938
+ header.appendChild(headerActions);
1939
+
1940
+ const body = document.createElement('div');
1941
+ body.className = 'cell-popup-body';
1942
+
1943
+ const content = document.createElement('pre');
1944
+ content.className = 'cell-popup-content';
1945
+
1946
+ updateContent();
1947
+
1948
+ body.appendChild(content);
1949
+
1950
+ dialog.appendChild(header);
1951
+ dialog.appendChild(body);
1952
+ overlay.appendChild(dialog);
1953
+
1954
+ document.body.appendChild(overlay);
1955
+
1956
+ const escapeHandler = (e) => {
1957
+ if (e.key === 'Escape') {
1958
+ closeCellContentPopup();
1959
+ }
1960
+ };
1961
+ overlay.dataset.escapeHandler = 'true';
1962
+ document.addEventListener('keydown', escapeHandler);
1963
+
1964
+ overlay._escapeHandler = escapeHandler;
1965
+ }
1966
+
1967
+ /**
1968
+ * Close the cell content popup dialog.
1969
+ */
1970
+ function closeCellContentPopup() {
1971
+ const overlay = document.querySelector('.cell-popup-overlay');
1972
+ if (overlay) {
1973
+ if (overlay._escapeHandler) {
1974
+ document.removeEventListener('keydown', overlay._escapeHandler);
1975
+ }
1976
+ overlay.remove();
1977
+ }
1978
+ }
1979
+
1980
+ function showLoading() {
1981
+ if (loadingOverlay) {
1982
+ loadingOverlay.style.display = 'flex';
1983
+ }
1984
+ }
1985
+
1986
+ function hideLoading() {
1987
+ if (loadingOverlay) {
1988
+ loadingOverlay.style.display = 'none';
1989
+ }
1990
+ }