pglens 1.1.0 → 2.0.1

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(() => {
@@ -455,16 +922,26 @@ async function loadTableData() {
455
922
  if (!tab) return;
456
923
 
457
924
  try {
458
- 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>';
459
927
 
460
928
  // Build query with cursor-based pagination if available
461
- let queryString = `page=${tab.page}&limit=100`;
462
- if (tab.hasPrimaryKey && tab.cursor && tab.page > 1) {
463
- // 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)
464
939
  queryString += `&cursor=${encodeURIComponent(tab.cursor)}`;
465
940
  }
466
941
 
467
- 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
+ });
468
945
  const data = await response.json();
469
946
 
470
947
  if (data.error) {
@@ -499,6 +976,8 @@ async function loadTableData() {
499
976
  } catch (error) {
500
977
  tableView.innerHTML = '<div class="error-state"><p>Error: ' + error.message + '</p></div>';
501
978
  pagination.style.display = 'none';
979
+ } finally {
980
+ hideLoading();
502
981
  }
503
982
  }
504
983
 
@@ -508,27 +987,59 @@ function renderTable(data) {
508
987
 
509
988
  const columns = Object.keys(data.rows[0] || {});
510
989
 
990
+ if (!tab.limit) {
991
+ tab.limit = 100; // Default limit for existing tabs
992
+ }
993
+
511
994
  const tableHeader = document.createElement('div');
512
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
+
513
1000
  tableHeader.innerHTML = `
514
1001
  <div class="table-header-left">
515
1002
  <h2>${tab.tableName}</h2>
516
- <button class="refresh-button" id="refreshButton" title="Refresh data">
517
- <span class="refresh-icon">↻</span>
518
- </button>
519
- <div class="column-selector">
520
- <button class="column-button" id="columnButton" title="Show/Hide columns">
521
- <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>
522
1010
  </button>
523
- <div class="column-menu" id="columnMenu" style="display: none;">
524
- <div class="column-menu-header">Columns</div>
525
- <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>
526
1032
  </div>
527
1033
  </div>
528
1034
  </div>
529
- <span class="row-info">
530
- Showing ${((tab.page - 1) * 100) + 1}-${Math.min(tab.page * 100, tab.totalCount)} of ${tab.totalCount}${tab.isApproximate ? ' (approx.)' : ''} rows
531
- </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>
532
1043
  `;
533
1044
 
534
1045
  const refreshButton = tableHeader.querySelector('#refreshButton');
@@ -536,6 +1047,21 @@ function renderTable(data) {
536
1047
  refreshButton.addEventListener('click', handleRefresh);
537
1048
  }
538
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
+
539
1065
  if (!tab.hiddenColumns) {
540
1066
  tab.hiddenColumns = [];
541
1067
  }
@@ -674,9 +1200,10 @@ function renderTable(data) {
674
1200
  table.appendChild(thead);
675
1201
 
676
1202
  const tbody = document.createElement('tbody');
677
- const sortedRows = getSortedRows(data.rows, tab);
1203
+ // Server-side sorting, so rows are already sorted
1204
+ const rows = data.rows || [];
678
1205
 
679
- sortedRows.forEach(row => {
1206
+ rows.forEach(row => {
680
1207
  const tr = document.createElement('tr');
681
1208
  visibleColumns.forEach(column => {
682
1209
  const td = document.createElement('td');
@@ -696,7 +1223,7 @@ function renderTable(data) {
696
1223
  : 'NULL';
697
1224
  td.dataset.columnName = column;
698
1225
 
699
- td.addEventListener('dblclick', (e) => {
1226
+ td.addEventListener('click', (e) => {
700
1227
  e.stopPropagation();
701
1228
  showCellContentPopup(column, value);
702
1229
  });
@@ -971,9 +1498,11 @@ function handleSort(column) {
971
1498
  tab.sortDirection = 'asc';
972
1499
  }
973
1500
 
974
- if (tab.data) {
975
- renderTable(tab.data);
976
- }
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();
977
1506
  }
978
1507
 
979
1508
  /**
@@ -1036,7 +1565,10 @@ function renderPagination() {
1036
1565
  const tab = getActiveTab();
1037
1566
  if (!tab) return;
1038
1567
 
1039
- const limit = 100;
1568
+ if (!tab.limit) {
1569
+ tab.limit = 100; // Default limit for existing tabs
1570
+ }
1571
+ const limit = tab.limit;
1040
1572
  const totalPages = Math.ceil(tab.totalCount / limit);
1041
1573
 
1042
1574
  if (totalPages <= 1) {
@@ -1049,24 +1581,41 @@ function renderPagination() {
1049
1581
  const hasPrevious = tab.page > 1;
1050
1582
  const hasNext = tab.page < totalPages;
1051
1583
 
1584
+ const startRow = ((tab.page - 1) * limit) + 1;
1585
+ const endRow = Math.min(tab.page * limit, tab.totalCount);
1586
+
1052
1587
  pagination.innerHTML = `
1053
- <button
1054
- class="pagination-button"
1055
- ${!hasPrevious ? 'disabled' : ''}
1056
- onclick="handlePageChange(${tab.page - 1})"
1057
- >
1058
- Previous
1059
- </button>
1060
- <span class="pagination-info">
1061
- Page ${tab.page} of ${totalPages}
1062
- </span>
1063
- <button
1064
- class="pagination-button"
1065
- ${!hasNext ? 'disabled' : ''}
1066
- onclick="handlePageChange(${tab.page + 1})"
1067
- >
1068
- Next
1069
- </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>
1070
1619
  `;
1071
1620
  }
1072
1621
 
@@ -1305,9 +1854,6 @@ function showCellContentPopup(column, value) {
1305
1854
  const formattedContent = formatted.content;
1306
1855
  const isJson = formatted.isJson;
1307
1856
  const isDateTime = formatted.isDateTime;
1308
- const dateValue = formatted.dateValue;
1309
-
1310
- let currentTimezone = getCurrentTimezone();
1311
1857
 
1312
1858
  const header = document.createElement('div');
1313
1859
  header.className = 'cell-popup-header';
@@ -1320,66 +1866,41 @@ function showCellContentPopup(column, value) {
1320
1866
  const headerActions = document.createElement('div');
1321
1867
  headerActions.className = 'cell-popup-actions';
1322
1868
 
1323
- let timezoneSelect = null;
1324
- if (isDateTime && dateValue) {
1325
- timezoneSelect = document.createElement('select');
1326
- timezoneSelect.className = 'cell-popup-timezone';
1327
- timezoneSelect.title = 'Select timezone';
1328
-
1329
- const timezones = getCommonTimezones();
1330
- timezones.forEach(tz => {
1331
- const option = document.createElement('option');
1332
- option.value = tz.value;
1333
- option.textContent = tz.label;
1334
- if (tz.value === currentTimezone) {
1335
- option.selected = true;
1336
- }
1337
- timezoneSelect.appendChild(option);
1338
- });
1339
-
1340
- headerActions.appendChild(timezoneSelect);
1341
- }
1342
-
1343
1869
  const copyButton = document.createElement('button');
1344
1870
  copyButton.className = 'cell-popup-copy';
1345
1871
  copyButton.innerHTML = '📋';
1346
1872
  copyButton.title = 'Copy to clipboard';
1347
1873
 
1348
1874
  const updateContent = () => {
1875
+ let contentToDisplay = '';
1876
+ let contentToCopy = '';
1877
+
1349
1878
  if (value === null || value === undefined) {
1350
1879
  content.classList.add('null-content');
1351
1880
  content.classList.remove('json-value-popup', 'datetime-value-popup');
1352
- content.textContent = 'NULL';
1881
+ contentToDisplay = 'NULL';
1882
+ contentToCopy = 'NULL';
1353
1883
  } else if (isJson) {
1354
- const formatted = formatCellContentForPopup(value, currentTimezone);
1884
+ const formatted = formatCellContentForPopup(value);
1355
1885
  content.classList.add('json-value-popup');
1356
1886
  content.classList.remove('null-content', 'datetime-value-popup');
1357
- content.textContent = formatted.content;
1358
- } else if (isDateTime && dateValue) {
1887
+ contentToDisplay = formatted.content;
1888
+ contentToCopy = formatted.content;
1889
+ } else if (isDateTime) {
1890
+ // Display original date/time value without timezone conversion
1359
1891
  content.classList.add('datetime-value-popup');
1360
1892
  content.classList.remove('null-content', 'json-value-popup');
1361
-
1362
- // Show multiple timezone formats
1363
- const localTz = formatDateTimeInTimezone(dateValue, getCurrentTimezone());
1364
- const utcTz = formatDateTimeInTimezone(dateValue, 'UTC');
1365
- const selectedTz = formatDateTimeInTimezone(dateValue, currentTimezone);
1366
-
1367
- let displayText = `Local (${getCurrentTimezone()}): ${localTz}\n`;
1368
- displayText += `UTC: ${utcTz}\n`;
1369
- if (currentTimezone !== getCurrentTimezone() && currentTimezone !== 'UTC') {
1370
- displayText += `Selected (${currentTimezone}): ${selectedTz}`;
1371
- }
1372
-
1373
- content.textContent = displayText;
1893
+ contentToDisplay = String(value);
1894
+ contentToCopy = String(value);
1374
1895
  } else {
1375
- const formatted = formatCellContentForPopup(value, currentTimezone);
1896
+ const formatted = formatCellContentForPopup(value);
1376
1897
  content.classList.remove('null-content', 'json-value-popup', 'datetime-value-popup');
1377
- content.textContent = formatted.content;
1898
+ contentToDisplay = formatted.content;
1899
+ contentToCopy = formatted.content;
1378
1900
  }
1379
1901
 
1380
- // Update copy button to use current formatted content
1381
- const finalFormatted = formatCellContentForPopup(value, currentTimezone);
1382
- copyButton._formattedContent = finalFormatted.content;
1902
+ content.textContent = contentToDisplay;
1903
+ copyButton._formattedContent = contentToCopy;
1383
1904
  };
1384
1905
 
1385
1906
  copyButton.addEventListener('click', async () => {
@@ -1424,13 +1945,6 @@ function showCellContentPopup(column, value) {
1424
1945
 
1425
1946
  updateContent();
1426
1947
 
1427
- if (timezoneSelect) {
1428
- timezoneSelect.addEventListener('change', (e) => {
1429
- currentTimezone = e.target.value;
1430
- updateContent();
1431
- });
1432
- }
1433
-
1434
1948
  body.appendChild(content);
1435
1949
 
1436
1950
  dialog.appendChild(header);
@@ -1462,3 +1976,15 @@ function closeCellContentPopup() {
1462
1976
  overlay.remove();
1463
1977
  }
1464
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
+ }