pglens 2.0.0 → 2.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/client/app.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // Application state
15
15
  let connections = []; // Array of active connections: { id, name, connectionString, sslMode }
16
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 }
17
+ let tabs = []; // Array of tab objects: { connectionId, tableName, page, totalCount, sortColumn, sortDirection, data, hiddenColumns, columnWidths, cursor, cursorHistory, hasPrimaryKey, isApproximate, abortController }
18
18
  let activeTabIndex = -1; // Currently active tab index
19
19
  let allTables = []; // All available tables from the current database connection
20
20
  let searchQuery = ''; // Current search filter for tables
@@ -34,6 +34,13 @@ const tabsBar = document.getElementById('tabsBar');
34
34
  const tableView = document.getElementById('tableView');
35
35
  const pagination = document.getElementById('pagination');
36
36
  const addConnectionButton = document.getElementById('addConnectionButton');
37
+ const landingPage = document.getElementById('landingPage');
38
+ const connectionsGrid = document.getElementById('connectionsGrid');
39
+ const connectionSearch = document.getElementById('connectionSearch');
40
+ const newConnectionBtn = document.getElementById('newConnectionBtn');
41
+ const sidebarTitle = document.querySelector('.sidebar-header-title');
42
+ const connectionsSection = document.querySelector('.connections-section');
43
+ const tablesSection = document.querySelector('.tables-section');
37
44
 
38
45
  // Connection UI Elements
39
46
  const connectionDialog = document.getElementById('connectionDialog');
@@ -59,11 +66,22 @@ const loadingOverlay = document.getElementById('loadingOverlay');
59
66
  */
60
67
  document.addEventListener('DOMContentLoaded', () => {
61
68
  initTheme();
69
+
70
+ if (sidebarTitle) {
71
+ sidebarTitle.style.cursor = 'pointer';
72
+ sidebarTitle.addEventListener('click', showLandingPage);
73
+ }
74
+
62
75
  fetchConnections(); // Check status and load connections
63
76
 
64
77
  // Connection Event Listeners
65
78
  connectButton.addEventListener('click', handleConnect);
66
- addConnectionButton.addEventListener('click', () => showConnectionDialog(true));
79
+
80
+ // Sidebar Add Button -> Redirect to Landing Page
81
+ addConnectionButton.addEventListener('click', () => {
82
+ showLandingPage();
83
+ });
84
+
67
85
  closeConnectionDialogButton.addEventListener('click', hideConnectionDialog);
68
86
 
69
87
  connectionTabs.forEach(tab => {
@@ -133,6 +151,18 @@ document.addEventListener('DOMContentLoaded', () => {
133
151
  applyTheme();
134
152
  }
135
153
  });
154
+
155
+ // Search Connections
156
+ if (connectionSearch) {
157
+ connectionSearch.addEventListener('input', () => {
158
+ renderLandingPage();
159
+ });
160
+ }
161
+
162
+ // New Connection Button
163
+ if (newConnectionBtn) {
164
+ newConnectionBtn.addEventListener('click', () => showConnectionDialog(true));
165
+ }
136
166
  });
137
167
 
138
168
  /**
@@ -206,15 +236,18 @@ async function fetchConnections() {
206
236
 
207
237
  connections = data.connections || [];
208
238
 
209
- if (connections.length > 0) {
210
- if (!activeConnectionId || !connections.find(c => c.id === activeConnectionId)) {
211
- activeConnectionId = connections[0].id;
212
- }
213
- renderConnectionsList();
239
+ renderConnectionsList();
240
+ hideConnectionDialog();
241
+
242
+ // If active connection is invalid (e.g. deleted), clear it
243
+ if (activeConnectionId && !connections.find(c => c.id === activeConnectionId)) {
244
+ activeConnectionId = null;
245
+ }
246
+
247
+ if (activeConnectionId) {
214
248
  loadTables();
215
- hideConnectionDialog();
216
249
  } else {
217
- showConnectionDialog(false);
250
+ showLandingPage();
218
251
  }
219
252
  } catch (error) {
220
253
  console.error('Failed to fetch connections:', error);
@@ -222,10 +255,28 @@ async function fetchConnections() {
222
255
  }
223
256
  }
224
257
 
258
+ /**
259
+ * Update sidebar visibility based on connections state.
260
+ */
261
+ function updateSidebarVisibility() {
262
+ if (connections.length === 0) {
263
+ if (connectionsSection) connectionsSection.style.display = 'none';
264
+ if (tablesSection) tablesSection.style.display = 'none';
265
+ } else {
266
+ if (connectionsSection) connectionsSection.style.display = 'flex';
267
+
268
+ // Only show tables section if we have an active connection selected
269
+ if (tablesSection) {
270
+ tablesSection.style.display = activeConnectionId ? 'flex' : 'none';
271
+ }
272
+ }
273
+ }
274
+
225
275
  /**
226
276
  * Render the list of active connections in the sidebar.
227
277
  */
228
278
  function renderConnectionsList() {
279
+ updateSidebarVisibility();
229
280
  connectionsList.innerHTML = '';
230
281
 
231
282
  connections.forEach(conn => {
@@ -238,31 +289,17 @@ function renderConnectionsList() {
238
289
  const nameSpan = document.createElement('span');
239
290
  nameSpan.className = 'connection-name';
240
291
  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
- });
292
+ nameSpan.title = 'Switch to this connection';
247
293
 
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
- });
294
+ // Actions removed as per user request. Management is done on Landing Page.
254
295
 
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);
296
+ li.appendChild(nameSpan);
297
+
298
+ // Add click event for the whole li to switch connection
299
+ li.addEventListener('click', () => {
300
+ switchConnection(conn.id);
262
301
  });
263
302
 
264
- li.appendChild(nameSpan);
265
- li.appendChild(disconnectBtn);
266
303
  connectionsList.appendChild(li);
267
304
  });
268
305
  }
@@ -272,9 +309,24 @@ function renderConnectionsList() {
272
309
  * @param {string} connectionId - The connection ID to switch to
273
310
  */
274
311
  function switchConnection(connectionId) {
312
+ if (isAppBusy()) {
313
+ if (confirm('A query is currently loading. Do you want to cancel it and switch connections?')) {
314
+ cancelAllActiveRequests();
315
+ } else {
316
+ return;
317
+ }
318
+ }
275
319
  if (activeConnectionId === connectionId) return;
276
320
 
277
321
  activeConnectionId = connectionId;
322
+
323
+ // Hide landing page, show table view
324
+ landingPage.style.display = 'none';
325
+ tableView.style.display = 'flex';
326
+ if (tabs.length > 0) {
327
+ tabsContainer.style.display = 'block';
328
+ }
329
+
278
330
  renderConnectionsList();
279
331
  loadTables();
280
332
 
@@ -286,8 +338,8 @@ function switchConnection(connectionId) {
286
338
  if (tabIndex !== -1) {
287
339
  switchToTab(tabIndex);
288
340
  } 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>';
341
+ // No tabs for this connection, show shimmer while loading
342
+ renderShimmerDashboard();
291
343
  pagination.style.display = 'none';
292
344
 
293
345
  // Deselect all tabs visually
@@ -397,11 +449,14 @@ async function handleConnect() {
397
449
  }
398
450
  }
399
451
 
400
- activeConnectionId = data.connectionId;
452
+ // Don't auto-select the new connection. Show landing page.
453
+ activeConnectionId = null;
401
454
 
402
455
  renderConnectionsList();
403
- loadTables();
456
+ renderLandingPage(); // Update grid
404
457
  hideConnectionDialog();
458
+ showLandingPage();
459
+
405
460
  // Don't clear inputs here, will clear on show
406
461
  }
407
462
  } else {
@@ -452,7 +507,8 @@ async function handleDisconnect(connectionId) {
452
507
  sidebarContent.innerHTML = '';
453
508
  tableCount.textContent = '0';
454
509
  renderConnectionsList();
455
- showConnectionDialog(false);
510
+ renderLandingPage();
511
+ updateSidebarVisibility(); // Ensure sidebar hides
456
512
  } else {
457
513
  if (activeConnectionId === connectionId) {
458
514
  // Switch to another connection (the first one)
@@ -460,6 +516,7 @@ async function handleDisconnect(connectionId) {
460
516
  loadTables();
461
517
  }
462
518
  renderConnectionsList();
519
+ renderLandingPage(); // Update grid
463
520
  }
464
521
  }
465
522
  } catch (error) {
@@ -469,6 +526,235 @@ async function handleDisconnect(connectionId) {
469
526
 
470
527
 
471
528
 
529
+ function showLandingPage() {
530
+ // Check if safe to navigate
531
+ if (isAppBusy()) { // Assuming isAppBusy and cancelAllActiveRequests are defined elsewhere
532
+ if (!confirm('A query is currently loading. Do you want to cancel it and go to the home page?')) {
533
+ return;
534
+ }
535
+ cancelAllActiveRequests();
536
+ }
537
+
538
+ // Hide other views
539
+ tableView.style.display = 'none';
540
+ tabsContainer.style.display = 'none';
541
+ pagination.style.display = 'none';
542
+
543
+ // Show landing page
544
+ landingPage.style.display = 'flex';
545
+
546
+ // clear active connection selection
547
+ activeConnectionId = null;
548
+ renderConnectionsList();
549
+
550
+ // Render grid
551
+ renderLandingPage();
552
+ }
553
+
554
+ function renderLandingPage() {
555
+ connectionsGrid.innerHTML = '';
556
+
557
+ const query = connectionSearch ? connectionSearch.value.toLowerCase().trim() : '';
558
+
559
+
560
+ if (!query) {
561
+ const addCard = document.createElement('div');
562
+ addCard.className = 'connection-card add-new-card';
563
+ addCard.innerHTML = `
564
+ <div class="add-new-icon-circle">
565
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
566
+ <line x1="12" y1="5" x2="12" y2="19"></line>
567
+ <line x1="5" y1="12" x2="19" y2="12"></line>
568
+ </svg>
569
+ </div>
570
+ <div class="add-new-text">Add Connection</div>
571
+ <div class="add-desc">Connect to a new database instance</div>
572
+ `;
573
+ addCard.addEventListener('click', () => {
574
+ showConnectionDialog(true);
575
+ });
576
+ connectionsGrid.appendChild(addCard);
577
+ }
578
+
579
+ // Filter connections
580
+ const filteredConnections = connections.filter(conn => {
581
+ if (!query) return true;
582
+ const searchStr = `${conn.name} ${conn.connectionString}`.toLowerCase();
583
+ return searchStr.includes(query);
584
+ });
585
+
586
+ // Render existing connections
587
+ filteredConnections.forEach(conn => {
588
+ const card = document.createElement('div');
589
+ card.className = 'connection-card';
590
+
591
+ // Parse info
592
+ const parsed = parseConnectionString(conn.connectionString);
593
+ const hostDisplay = parsed ? parsed.host : 'localhost';
594
+ const portDisplay = parsed ? parsed.port : '5432';
595
+ const dbDisplay = parsed ? parsed.database : 'postgres';
596
+ const isSsl = conn.sslMode && conn.sslMode !== 'disable';
597
+
598
+
599
+
600
+ card.innerHTML = `
601
+ <div class="card-context-actions">
602
+ <button class="context-btn edit" title="Edit">✎</button>
603
+ <button class="context-btn delete" title="Disconnect">×</button>
604
+ </div>
605
+
606
+ <div class="card-top">
607
+ <div class="card-icon">
608
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
609
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>
610
+ <line x1="4" y1="22" x2="4" y2="15"></line>
611
+ </svg>
612
+ </div>
613
+ </div>
614
+
615
+ <div class="card-info">
616
+ <h3>${conn.name}</h3>
617
+ <p>PostgreSQL • ${dbDisplay}</p>
618
+ </div>
619
+
620
+ <div class="host-block">
621
+ <span class="host-label">HOST</span>
622
+ <div class="host-value">${hostDisplay}:${portDisplay}</div>
623
+ ${isSsl ? '<span class="host-label" style="margin-top:4px;color:#10b981">SSL ENCRYPTED</span>' : ''}
624
+ </div>
625
+
626
+ <div class="card-actions-wrapper">
627
+ <button class="connect-btn">
628
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
629
+ <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
630
+ </svg>
631
+ Connect
632
+ </button>
633
+ </div>
634
+ `;
635
+
636
+ // Click behavior
637
+ card.addEventListener('click', (e) => {
638
+ // Don't trigger if clicking actions
639
+ if (e.target.closest('.context-btn')) return;
640
+ switchConnection(conn.id);
641
+ });
642
+
643
+ // Connect Button
644
+ card.querySelector('.connect-btn').addEventListener('click', (e) => {
645
+ e.stopPropagation();
646
+ switchConnection(conn.id);
647
+ });
648
+
649
+ // Edit Action
650
+ const editBtn = card.querySelector('.edit');
651
+ editBtn.addEventListener('click', (e) => {
652
+ e.stopPropagation();
653
+ handleConnectionEdit(conn);
654
+ });
655
+
656
+ // Disconnect Action
657
+ const deleteBtn = card.querySelector('.delete');
658
+ deleteBtn.addEventListener('click', (e) => {
659
+ e.stopPropagation();
660
+ if (confirm(`Are you sure you want to disconnect from ${conn.name}?`)) {
661
+ handleDisconnect(conn.id);
662
+ }
663
+ });
664
+ connectionsGrid.appendChild(card);
665
+ });
666
+
667
+ if (filteredConnections.length === 0 && query) {
668
+ const noResults = document.createElement('div');
669
+ noResults.style.gridColumn = '1 / -1';
670
+ noResults.style.textAlign = 'center';
671
+ noResults.style.padding = '40px';
672
+ noResults.style.color = 'var(--text-secondary)';
673
+ noResults.innerHTML = `No connections found matching "${query}"`;
674
+ connectionsGrid.appendChild(noResults);
675
+ }
676
+ }
677
+
678
+ function renderTableDashboard() {
679
+ tableView.innerHTML = '';
680
+
681
+ const dashboard = document.createElement('div');
682
+ dashboard.className = 'table-dashboard';
683
+
684
+ const header = document.createElement('div');
685
+ header.className = 'table-dashboard-header';
686
+ header.innerHTML = `
687
+ <h2>Tables</h2>
688
+ <p>${allTables.length} tables available in this database.</p>
689
+ `;
690
+ dashboard.appendChild(header);
691
+
692
+ const grid = document.createElement('div');
693
+ grid.className = 'tables-grid';
694
+
695
+ allTables.forEach(table => {
696
+ const card = document.createElement('div');
697
+ card.className = 'table-card';
698
+ card.innerHTML = `
699
+ <div class="table-card-icon">
700
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
701
+ <line x1="3" y1="6" x2="21" y2="6"></line>
702
+ <line x1="3" y1="12" x2="21" y2="12"></line>
703
+ <line x1="3" y1="18" x2="21" y2="18"></line>
704
+ </svg>
705
+ </div>
706
+ <div class="table-card-info">
707
+ <div class="table-card-name" title="${table}">${table}</div>
708
+ <div class="table-card-schema">public</div>
709
+ </div>
710
+ `;
711
+
712
+ card.addEventListener('click', () => {
713
+ handleTableSelect(table);
714
+ });
715
+
716
+ grid.appendChild(card);
717
+ });
718
+
719
+ dashboard.appendChild(grid);
720
+ tableView.appendChild(dashboard);
721
+ }
722
+
723
+ function renderShimmerDashboard() {
724
+ tableView.innerHTML = '';
725
+
726
+ const dashboard = document.createElement('div');
727
+ dashboard.className = 'table-dashboard';
728
+
729
+ const header = document.createElement('div');
730
+ header.className = 'table-dashboard-header';
731
+ header.innerHTML = `
732
+ <h2>Tables</h2>
733
+ <div class="skeleton-text short" style="width: 200px; margin-top: 8px;"></div>
734
+ `;
735
+ dashboard.appendChild(header);
736
+
737
+ const grid = document.createElement('div');
738
+ grid.className = 'tables-grid';
739
+
740
+ // Render 16 skeleton cards
741
+ for (let i = 0; i < 16; i++) {
742
+ const card = document.createElement('div');
743
+ card.className = 'table-card skeleton-card';
744
+ card.innerHTML = `
745
+ <div class="skeleton-icon"></div>
746
+ <div class="table-card-info">
747
+ <div class="skeleton-text" style="width: ${60 + Math.random() * 30}%"></div>
748
+ <div class="skeleton-text short" style="width: 40%"></div>
749
+ </div>
750
+ `;
751
+ grid.appendChild(card);
752
+ }
753
+
754
+ dashboard.appendChild(grid);
755
+ tableView.appendChild(dashboard);
756
+ }
757
+
472
758
  function showConnectionDialog(allowClose, editMode = false, connection = null) {
473
759
  connectionDialog.style.display = 'flex';
474
760
  connectionError.style.display = 'none';
@@ -518,7 +804,7 @@ function showConnectionDialog(allowClose, editMode = false, connection = null) {
518
804
 
519
805
  connHost.focus();
520
806
 
521
- if (allowClose && connections.length > 0) {
807
+ if (allowClose) {
522
808
  closeConnectionDialogButton.style.display = 'block';
523
809
  } else {
524
810
  closeConnectionDialogButton.style.display = 'none';
@@ -546,7 +832,7 @@ function parseConnectionString(urlStr) {
546
832
  port: url.port || '5432',
547
833
  database: url.pathname.replace(/^\//, '') || 'postgres',
548
834
  user: url.username || '',
549
- password: url.password || '' // Note: URL decoding happens automatically for username/password properties? Verify.
835
+ password: url.password || '' // Note: URL decoding happens automatically for username/password properties? Verify.
550
836
  // Actually URL properties are usually decoded. decodeURIComponent check might be needed if raw.
551
837
  };
552
838
  } catch (e) {
@@ -585,6 +871,12 @@ async function loadTables() {
585
871
  try {
586
872
  sidebarContent.innerHTML = '<div class="loading">Loading tables...</div>';
587
873
 
874
+ // Only show shimmer if we aren't already looking at a table for this connection
875
+ const existingTab = getActiveTab();
876
+ if (!existingTab || existingTab.connectionId !== activeConnectionId) {
877
+ renderShimmerDashboard();
878
+ }
879
+
588
880
  const response = await fetch('/api/tables', {
589
881
  headers: { 'x-connection-id': activeConnectionId }
590
882
  });
@@ -597,7 +889,19 @@ async function loadTables() {
597
889
  allTables = data.tables;
598
890
  tableCount.textContent = allTables.length;
599
891
 
892
+ // If no tables, show message
893
+ if (allTables.length === 0) {
894
+ tableView.innerHTML = '<div class="no-data-message">No tables found in this database</div>';
895
+ return;
896
+ }
897
+
600
898
  filterAndRenderTables();
899
+
900
+ // Only show dashboard if we aren't already looking at a table for this connection
901
+ const currentTab = getActiveTab();
902
+ if (!currentTab || currentTab.connectionId !== activeConnectionId) {
903
+ renderTableDashboard();
904
+ }
601
905
  } catch (error) {
602
906
  sidebarContent.innerHTML = `<div class="error">Error: ${error.message}</div>`;
603
907
  }
@@ -699,6 +1003,14 @@ function updateSidebarActiveState() {
699
1003
  function handleTableSelect(tableName) {
700
1004
  if (!activeConnectionId) return;
701
1005
 
1006
+ if (isAppBusy()) {
1007
+ if (confirm('A query is currently loading. Do you want to cancel it and open this table?')) {
1008
+ cancelAllActiveRequests();
1009
+ } else {
1010
+ return;
1011
+ }
1012
+ }
1013
+
702
1014
  // Check if tab already exists for this table AND connection
703
1015
  const existingTabIndex = tabs.findIndex(tab =>
704
1016
  tab.tableName === tableName && tab.connectionId === activeConnectionId
@@ -721,7 +1033,8 @@ function handleTableSelect(tableName) {
721
1033
  cursorHistory: [], // History for backward navigation
722
1034
  hasPrimaryKey: false, // Whether table has primary key
723
1035
  isApproximate: false, // Whether count is approximate (for large tables)
724
- limit: 100 // Rows per page
1036
+ limit: 100, // Rows per page
1037
+ abortController: null // Controller for cancelling requests
725
1038
  };
726
1039
  tabs.push(newTab);
727
1040
  activeTabIndex = tabs.length - 1;
@@ -736,6 +1049,14 @@ function handleTableSelect(tableName) {
736
1049
  function switchToTab(index) {
737
1050
  if (index < 0 || index >= tabs.length) return;
738
1051
 
1052
+ if (isAppBusy() && index !== activeTabIndex) {
1053
+ if (confirm('A query is currently loading. Do you want to cancel it and switch tabs?')) {
1054
+ cancelAllActiveRequests();
1055
+ } else {
1056
+ return;
1057
+ }
1058
+ }
1059
+
739
1060
  activeTabIndex = index;
740
1061
  const tab = tabs[activeTabIndex];
741
1062
 
@@ -770,8 +1091,14 @@ function closeTab(index, event) {
770
1091
  if (tabs.length === 0) {
771
1092
  activeTabIndex = -1;
772
1093
  tabsContainer.style.display = 'none';
773
- tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
1094
+ if (activeConnectionId) {
1095
+ renderTableDashboard();
1096
+ } else {
1097
+ tableView.innerHTML = '';
1098
+ showLandingPage();
1099
+ }
774
1100
  pagination.style.display = 'none';
1101
+
775
1102
  updateSidebarActiveState();
776
1103
  updateSidebarToggleState();
777
1104
  return;
@@ -829,14 +1156,24 @@ function renderTabs() {
829
1156
  closeAllButton.addEventListener('click', closeAllTabs);
830
1157
  tabsBar.appendChild(closeAllButton);
831
1158
 
1159
+ // Check if we have tabs from multiple different connections
1160
+ const uniqueConnIds = new Set(tabs.map(t => t.connectionId));
1161
+ const showServerBadge = uniqueConnIds.size > 1;
1162
+
832
1163
  tabs.forEach((tab, index) => {
833
1164
  const tabElement = document.createElement('div');
834
1165
  tabElement.className = `tab ${index === activeTabIndex ? 'active' : ''}`;
835
- // Add connection indicator for tab if multiple connections exist
836
- if (connections.length > 1) {
1166
+
1167
+ // Add connection indicator for tab if multiple connections exist in open tabs
1168
+ if (showServerBadge) {
837
1169
  const conn = connections.find(c => c.id === tab.connectionId);
838
1170
  if (conn) {
839
1171
  tabElement.title = `${tab.tableName} (${conn.name})`;
1172
+
1173
+ const badge = document.createElement('span');
1174
+ badge.className = 'tab-server-badge';
1175
+ badge.textContent = conn.name;
1176
+ tabElement.appendChild(badge);
840
1177
  }
841
1178
  }
842
1179
 
@@ -861,7 +1198,7 @@ function closeAllTabs() {
861
1198
  tabs = [];
862
1199
  activeTabIndex = -1;
863
1200
  tabsContainer.style.display = 'none';
864
- tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
1201
+ renderTableDashboard();
865
1202
  pagination.style.display = 'none';
866
1203
 
867
1204
  updateSidebarActiveState();
@@ -921,9 +1258,18 @@ async function loadTableData() {
921
1258
  const tab = getActiveTab();
922
1259
  if (!tab) return;
923
1260
 
1261
+ // Cancel any pending request for this tab
1262
+ if (tab.abortController) {
1263
+ tab.abortController.abort();
1264
+ tab.abortController = null;
1265
+ }
1266
+
924
1267
  try {
925
- showLoading();
926
- // tableView.innerHTML = '<div class="loading-state"><p>Loading data from ' + tab.tableName + '...</p></div>';
1268
+
1269
+ renderShimmerTable(tab);
1270
+
1271
+ tab.abortController = new AbortController();
1272
+ const signal = tab.abortController.signal;
927
1273
 
928
1274
  // Build query with cursor-based pagination if available
929
1275
  if (!tab.limit) {
@@ -940,7 +1286,8 @@ async function loadTableData() {
940
1286
  }
941
1287
 
942
1288
  const response = await fetch(`/api/tables/${tab.tableName}?${queryString}`, {
943
- headers: { 'x-connection-id': tab.connectionId }
1289
+ headers: { 'x-connection-id': tab.connectionId },
1290
+ signal: signal
944
1291
  });
945
1292
  const data = await response.json();
946
1293
 
@@ -964,115 +1311,227 @@ async function loadTableData() {
964
1311
  tab.cursor = data.nextCursor;
965
1312
  }
966
1313
 
967
- if (!data.rows || data.rows.length === 0) {
968
- tableView.innerHTML = '<div class="empty-state"><p>Table ' + tab.tableName + ' is empty</p></div>';
969
- pagination.style.display = 'none';
1314
+ tab.abortController = null; // Request finished successfully
1315
+
1316
+ if (!data.rows) {
1317
+
970
1318
  tab.data = null;
1319
+ tableView.innerHTML = '<div class="error-state"><p>Invalid data received</p></div>';
971
1320
  return;
972
1321
  }
973
1322
 
974
1323
  renderTable(data);
975
1324
  renderPagination();
976
1325
  } catch (error) {
977
- tableView.innerHTML = '<div class="error-state"><p>Error: ' + error.message + '</p></div>';
978
- pagination.style.display = 'none';
1326
+ if (error.name === 'AbortError') {
1327
+ console.log('Request cancelled');
1328
+ } else {
1329
+ tableView.innerHTML = '<div class="error-state"><p>Error: ' + error.message + '</p></div>';
1330
+ pagination.style.display = 'none';
1331
+ }
979
1332
  } finally {
980
- hideLoading();
981
1333
  }
982
1334
  }
983
1335
 
984
- function renderTable(data) {
985
- const tab = getActiveTab();
986
- if (!tab) return;
987
-
988
- const columns = Object.keys(data.rows[0] || {});
1336
+ function handleCancelLoading(tab) {
1337
+ if (tab && tab.abortController) {
1338
+ tab.abortController.abort();
1339
+ tab.abortController = null;
989
1340
 
990
- if (!tab.limit) {
991
- tab.limit = 100; // Default limit for existing tabs
1341
+ if (tab.data) {
1342
+ renderTable(tab.data);
1343
+ renderPagination();
1344
+ } else {
1345
+ // No previous data (fresh load), close the tab
1346
+ const index = tabs.indexOf(tab);
1347
+ if (index !== -1) {
1348
+ closeTab(index);
1349
+ }
1350
+ }
992
1351
  }
1352
+ }
993
1353
 
1354
+ function renderTableHeader(tab, columns = [], isShimmer = false) {
994
1355
  const tableHeader = document.createElement('div');
995
1356
  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;
1357
+
1358
+ let rangeInfo = '';
1359
+ if (isShimmer) {
1360
+ rangeInfo = '<span class="skeleton" style="width: 150px; display: inline-block;"></span>';
1361
+ } else {
1362
+ const startRow = ((tab.page - 1) * tab.limit) + 1;
1363
+ const endRow = Math.min(tab.page * tab.limit, tab.totalCount);
1364
+ const totalRows = tab.totalCount;
1365
+
1366
+ rangeInfo = `
1367
+ <span class="row-info-range">${startRow.toLocaleString()}–${endRow.toLocaleString()}</span>
1368
+ <span class="row-info-separator">of</span>
1369
+ <span class="row-info-total">${totalRows.toLocaleString()}</span>
1370
+ ${tab.isApproximate ? '<span class="row-info-approx">(approx.)</span>' : ''}
1371
+ `;
1372
+ }
1373
+
1374
+ let actions = '';
1375
+ if (isShimmer) {
1376
+ actions = `
1377
+ <button class="cancel-button" id="cancelButton">
1378
+ <span>Cancel</span>
1379
+ </button>
1380
+ `;
1381
+ } else {
1382
+ actions = `
1383
+ <button class="refresh-button" id="refreshButton" title="Refresh data">
1384
+ <svg class="refresh-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1385
+ <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
1386
+ <path d="M21 3v5h-5"></path>
1387
+ </svg>
1388
+ <span class="refresh-text">Refresh</span>
1389
+ </button>
1390
+ <div class="limit-selector">
1391
+ <select id="limitSelect" class="limit-select" title="Rows per page">
1392
+ <option value="25" ${tab.limit === 25 ? 'selected' : ''}>25 rows</option>
1393
+ <option value="50" ${tab.limit === 50 ? 'selected' : ''}>50 rows</option>
1394
+ <option value="100" ${tab.limit === 100 ? 'selected' : ''}>100 rows</option>
1395
+ <option value="200" ${tab.limit === 200 ? 'selected' : ''}>200 rows</option>
1396
+ <option value="500" ${tab.limit === 500 ? 'selected' : ''}>500 rows</option>
1397
+ </select>
1398
+ </div>
1399
+ <div class="column-selector">
1400
+ <button class="column-button" id="columnButton" title="Show/Hide columns">
1401
+ <svg class="column-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1402
+ <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"/>
1403
+ <path d="M6 2V14M10 2V14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
1404
+ </svg>
1405
+ <span class="column-label" id="columnLabel">Columns</span>
1406
+ </button>
1407
+ <div class="column-menu" id="columnMenu" style="display: none;">
1408
+ <div class="column-menu-header">Columns</div>
1409
+ <div class="column-menu-options" id="columnMenuOptions"></div>
1410
+ </div>
1411
+ </div>
1412
+ `;
1413
+ }
999
1414
 
1000
1415
  tableHeader.innerHTML = `
1001
1416
  <div class="table-header-left">
1002
1417
  <h2>${tab.tableName}</h2>
1003
1418
  <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>
1010
- </button>
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>
1032
- </div>
1419
+ ${actions}
1033
1420
  </div>
1034
1421
  </div>
1035
1422
  <div class="row-info-container">
1036
1423
  <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>' : ''}
1424
+ ${rangeInfo}
1041
1425
  </span>
1042
1426
  </div>
1043
1427
  `;
1044
1428
 
1045
- const refreshButton = tableHeader.querySelector('#refreshButton');
1046
- if (refreshButton) {
1047
- refreshButton.addEventListener('click', handleRefresh);
1048
- }
1429
+ if (isShimmer) {
1430
+ const cancelButton = tableHeader.querySelector('#cancelButton');
1431
+ if (cancelButton) {
1432
+ cancelButton.addEventListener('click', () => handleCancelLoading(tab));
1433
+ }
1434
+ } else {
1435
+ const refreshButton = tableHeader.querySelector('#refreshButton');
1436
+ if (refreshButton) {
1437
+ refreshButton.addEventListener('click', handleRefresh);
1438
+ }
1049
1439
 
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
- });
1440
+ const limitSelect = tableHeader.querySelector('#limitSelect');
1441
+ if (limitSelect) {
1442
+ limitSelect.addEventListener('change', (e) => {
1443
+ const newLimit = parseInt(e.target.value, 10);
1444
+ if (tab.limit !== newLimit) {
1445
+ tab.limit = newLimit;
1446
+ tab.page = 1;
1447
+ tab.cursor = null;
1448
+ tab.cursorHistory = [];
1449
+ tab.data = null;
1450
+ loadTableData();
1451
+ }
1452
+ });
1453
+ }
1454
+
1455
+ if (!tab.hiddenColumns) tab.hiddenColumns = [];
1456
+ if (!tab.columnWidths) tab.columnWidths = {};
1457
+
1458
+ setupColumnSelector(tab, columns, tableHeader);
1459
+ updateColumnButtonLabel(tab, columns, tableHeader);
1063
1460
  }
1064
1461
 
1065
- if (!tab.hiddenColumns) {
1066
- tab.hiddenColumns = [];
1462
+ return tableHeader;
1463
+ }
1464
+
1465
+ function renderShimmerTable(tab) {
1466
+ let columns = [];
1467
+ if (tab.data && tab.data.rows && tab.data.rows.length > 0) {
1468
+ columns = Object.keys(tab.data.rows[0]);
1469
+ } else {
1470
+ columns = ['Column 1', 'Column 2', 'Column 3', 'Column 4', 'Column 5'];
1067
1471
  }
1068
1472
 
1069
- if (!tab.columnWidths) {
1070
- tab.columnWidths = {};
1473
+ const tableHeader = renderTableHeader(tab, columns, true);
1474
+ const tableContainer = document.createElement('div');
1475
+ tableContainer.className = 'table-container';
1476
+
1477
+ const table = document.createElement('table');
1478
+ const thead = document.createElement('thead');
1479
+ const headerRow = document.createElement('tr');
1480
+
1481
+ // Render header placeholders
1482
+ columns.forEach(col => {
1483
+ const th = document.createElement('th');
1484
+ th.className = 'resizable';
1485
+ const skeleton = document.createElement('div');
1486
+ skeleton.className = 'skeleton';
1487
+ skeleton.style.width = '80px';
1488
+ th.appendChild(skeleton);
1489
+ headerRow.appendChild(th);
1490
+ });
1491
+
1492
+ thead.appendChild(headerRow);
1493
+ table.appendChild(thead);
1494
+
1495
+ const tbody = document.createElement('tbody');
1496
+ // Render generic shimmer rows
1497
+ for (let i = 0; i < 10; i++) {
1498
+ const tr = document.createElement('tr');
1499
+ tr.className = 'shimmer-row';
1500
+ columns.forEach(() => {
1501
+ const td = document.createElement('td');
1502
+ const skeleton = document.createElement('div');
1503
+ skeleton.className = 'skeleton shimmer-cell';
1504
+ td.appendChild(skeleton);
1505
+ tr.appendChild(td);
1506
+ });
1507
+ tbody.appendChild(tr);
1071
1508
  }
1072
1509
 
1073
- setupColumnSelector(tab, columns, tableHeader);
1074
- updateColumnButtonLabel(tab, columns, tableHeader);
1510
+ table.appendChild(tbody);
1511
+ tableContainer.appendChild(table);
1512
+
1513
+ tableView.innerHTML = '';
1514
+ tableView.appendChild(tableHeader);
1515
+ tableView.appendChild(tableContainer);
1516
+
1517
+ // Hide pagination during loading
1518
+ pagination.style.display = 'none';
1519
+ }
1075
1520
 
1521
+
1522
+ function renderTable(data) {
1523
+ const tab = getActiveTab();
1524
+ if (!tab) return;
1525
+
1526
+ const columns = (data.rows && data.rows.length > 0)
1527
+ ? Object.keys(data.rows[0])
1528
+ : (data.columns ? Object.keys(data.columns) : []);
1529
+
1530
+ if (!tab.limit) {
1531
+ tab.limit = 100; // Default limit for existing tabs
1532
+ }
1533
+
1534
+ const tableHeader = renderTableHeader(tab, columns);
1076
1535
  const tableContainer = document.createElement('div');
1077
1536
  tableContainer.className = 'table-container';
1078
1537
 
@@ -1199,6 +1658,7 @@ function renderTable(data) {
1199
1658
  thead.appendChild(headerRow);
1200
1659
  table.appendChild(thead);
1201
1660
 
1661
+
1202
1662
  const tbody = document.createElement('tbody');
1203
1663
  // Server-side sorting, so rows are already sorted
1204
1664
  const rows = data.rows || [];
@@ -1977,6 +2437,18 @@ function closeCellContentPopup() {
1977
2437
  }
1978
2438
  }
1979
2439
 
2440
+ function isAppBusy() {
2441
+ return tabs.some(tab => tab.abortController !== null);
2442
+ }
2443
+
2444
+ function cancelAllActiveRequests() {
2445
+ tabs.forEach(tab => {
2446
+ if (tab.abortController) {
2447
+ handleCancelLoading(tab);
2448
+ }
2449
+ });
2450
+ }
2451
+
1980
2452
  function showLoading() {
1981
2453
  if (loadingOverlay) {
1982
2454
  loadingOverlay.style.display = 'flex';