pglens 1.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 ADDED
@@ -0,0 +1,928 @@
1
+ /**
2
+ * pglens - PostgreSQL Database Viewer
3
+ *
4
+ * Main client-side application for viewing PostgreSQL database tables.
5
+ * Features:
6
+ * - Multi-tab table viewing
7
+ * - Client-side sorting and column management
8
+ * - Cursor-based pagination for large tables
9
+ * - Theme support (light/dark/system)
10
+ * - Real-time table search
11
+ */
12
+
13
+ // Application state
14
+ let tabs = []; // Array of tab objects: { tableName, page, totalCount, sortColumn, sortDirection, data, hiddenColumns, columnWidths, cursor, cursorHistory, hasPrimaryKey, isApproximate }
15
+ let activeTabIndex = -1; // Currently active tab index
16
+ let allTables = []; // All available tables from the database
17
+ let searchQuery = ''; // Current search filter for tables
18
+ let currentTheme = 'system'; // Current theme: 'light', 'dark', or 'system'
19
+ const sidebar = document.getElementById('sidebar');
20
+ const sidebarContent = sidebar.querySelector('.sidebar-content');
21
+ const tableCount = document.getElementById('tableCount');
22
+ const sidebarToggle = document.getElementById('sidebarToggle');
23
+ const sidebarSearch = document.getElementById('sidebarSearch');
24
+ const themeButton = document.getElementById('themeButton');
25
+ const themeMenu = document.getElementById('themeMenu');
26
+ const tabsContainer = document.getElementById('tabsContainer');
27
+ const tabsBar = document.getElementById('tabsBar');
28
+ const tableView = document.getElementById('tableView');
29
+ const pagination = document.getElementById('pagination');
30
+
31
+ /**
32
+ * Initialize the application when DOM is ready.
33
+ * Sets up event listeners and loads initial data.
34
+ */
35
+ document.addEventListener('DOMContentLoaded', () => {
36
+ initTheme();
37
+ loadTables();
38
+
39
+ sidebarToggle.addEventListener('click', () => {
40
+ if (tabs.length > 0) {
41
+ sidebar.classList.toggle('minimized');
42
+ }
43
+ });
44
+
45
+ updateSidebarToggleState();
46
+
47
+ sidebarSearch.addEventListener('input', (e) => {
48
+ searchQuery = e.target.value.toLowerCase().trim();
49
+ filterAndRenderTables();
50
+ });
51
+
52
+ themeButton.addEventListener('click', (e) => {
53
+ e.stopPropagation();
54
+ themeMenu.style.display = themeMenu.style.display === 'none' ? 'block' : 'none';
55
+ });
56
+
57
+ document.addEventListener('click', (e) => {
58
+ if (!themeButton.contains(e.target) && !themeMenu.contains(e.target)) {
59
+ themeMenu.style.display = 'none';
60
+ }
61
+ });
62
+
63
+ const themeOptions = themeMenu.querySelectorAll('.theme-option');
64
+ themeOptions.forEach(option => {
65
+ option.addEventListener('click', (e) => {
66
+ e.stopPropagation();
67
+ const theme = option.getAttribute('data-theme');
68
+ setTheme(theme);
69
+ themeMenu.style.display = 'none';
70
+ });
71
+ });
72
+
73
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
74
+ mediaQuery.addEventListener('change', () => {
75
+ if (currentTheme === 'system') {
76
+ applyTheme();
77
+ }
78
+ });
79
+ });
80
+
81
+ /**
82
+ * Initialize theme from localStorage or use system preference.
83
+ * Theme preference is persisted across sessions.
84
+ */
85
+ function initTheme() {
86
+ const savedTheme = localStorage.getItem('pglens-theme');
87
+ if (savedTheme && ['light', 'dark', 'system'].includes(savedTheme)) {
88
+ currentTheme = savedTheme;
89
+ }
90
+ applyTheme();
91
+ updateThemeIcon();
92
+ }
93
+
94
+ /**
95
+ * Set the application theme and persist to localStorage.
96
+ * @param {string} theme - Theme name: 'light', 'dark', or 'system'
97
+ */
98
+ function setTheme(theme) {
99
+ currentTheme = theme;
100
+ localStorage.setItem('pglens-theme', theme);
101
+ applyTheme();
102
+ updateThemeIcon();
103
+ }
104
+
105
+ /**
106
+ * Apply the current theme to the document.
107
+ * If theme is 'system', detects OS preference automatically.
108
+ */
109
+ function applyTheme() {
110
+ let themeToApply = currentTheme;
111
+
112
+ if (currentTheme === 'system') {
113
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
114
+ themeToApply = prefersDark ? 'dark' : 'light';
115
+ }
116
+
117
+ document.documentElement.setAttribute('data-theme', themeToApply);
118
+ }
119
+
120
+ function updateThemeIcon() {
121
+ const themeIcon = themeButton.querySelector('.theme-icon');
122
+ if (currentTheme === 'light') {
123
+ themeIcon.textContent = '☀️';
124
+ } else if (currentTheme === 'dark') {
125
+ themeIcon.textContent = '🌙';
126
+ } else {
127
+ themeIcon.textContent = '🌓';
128
+ }
129
+ }
130
+
131
+ function updateSidebarToggleState() {
132
+ if (tabs.length === 0) {
133
+ sidebarToggle.disabled = true;
134
+ sidebarToggle.classList.add('disabled');
135
+ sidebar.classList.remove('minimized');
136
+ } else {
137
+ sidebarToggle.disabled = false;
138
+ sidebarToggle.classList.remove('disabled');
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Load all tables from the database via API.
144
+ * Fetches table list and updates the sidebar.
145
+ */
146
+ async function loadTables() {
147
+ try {
148
+ sidebarContent.innerHTML = '<div class="loading">Loading tables...</div>';
149
+ const response = await fetch('/api/tables');
150
+ const data = await response.json();
151
+
152
+ if (data.error) {
153
+ throw new Error(data.error);
154
+ }
155
+
156
+ allTables = data.tables;
157
+ tableCount.textContent = allTables.length;
158
+
159
+ filterAndRenderTables();
160
+ } catch (error) {
161
+ sidebarContent.innerHTML = `<div class="error">Error: ${error.message}</div>`;
162
+ }
163
+ }
164
+
165
+ function filterAndRenderTables() {
166
+ if (allTables.length === 0) {
167
+ sidebarContent.innerHTML = '<div class="empty">No tables found</div>';
168
+ return;
169
+ }
170
+
171
+ const filteredTables = searchQuery
172
+ ? allTables.filter(table => table.toLowerCase().includes(searchQuery))
173
+ : allTables;
174
+
175
+ if (searchQuery && filteredTables.length !== allTables.length) {
176
+ tableCount.textContent = `${filteredTables.length} / ${allTables.length}`;
177
+ } else {
178
+ tableCount.textContent = allTables.length;
179
+ }
180
+
181
+ if (filteredTables.length === 0) {
182
+ sidebarContent.innerHTML = '<div class="empty">No tables match your search</div>';
183
+ return;
184
+ }
185
+
186
+ const tableList = document.createElement('ul');
187
+ tableList.className = 'table-list';
188
+
189
+ filteredTables.forEach(table => {
190
+ const li = document.createElement('li');
191
+ li.textContent = table;
192
+ li.addEventListener('click', () => handleTableSelect(table));
193
+
194
+ if (searchQuery) {
195
+ const lowerTable = table.toLowerCase();
196
+ const index = lowerTable.indexOf(searchQuery);
197
+ if (index !== -1) {
198
+ const before = table.substring(0, index);
199
+ const match = table.substring(index, index + searchQuery.length);
200
+ const after = table.substring(index + searchQuery.length);
201
+
202
+ li.innerHTML = `${escapeHtml(before)}<mark>${escapeHtml(match)}</mark>${escapeHtml(after)}`;
203
+ }
204
+ }
205
+
206
+ tableList.appendChild(li);
207
+ });
208
+
209
+ sidebarContent.innerHTML = '';
210
+ sidebarContent.appendChild(tableList);
211
+
212
+ updateSidebarActiveState();
213
+ }
214
+
215
+ /**
216
+ * Escape HTML to prevent XSS attacks.
217
+ * Converts special characters to HTML entities.
218
+ * @param {string} text - Text to escape
219
+ * @returns {string} Escaped HTML string
220
+ */
221
+ function escapeHtml(text) {
222
+ const div = document.createElement('div');
223
+ div.textContent = text;
224
+ return div.innerHTML;
225
+ }
226
+
227
+ function updateSidebarActiveState() {
228
+ const activeTable = getActiveTab()?.tableName;
229
+ if (!activeTable) {
230
+ const tableItems = sidebarContent.querySelectorAll('.table-list li');
231
+ tableItems.forEach(item => item.classList.remove('active'));
232
+ return;
233
+ }
234
+
235
+ const tableItems = sidebarContent.querySelectorAll('.table-list li');
236
+ tableItems.forEach(item => {
237
+ const tempDiv = document.createElement('div');
238
+ tempDiv.innerHTML = item.innerHTML;
239
+ const tableName = tempDiv.textContent || tempDiv.innerText;
240
+
241
+ if (tableName === activeTable) {
242
+ item.classList.add('active');
243
+ } else {
244
+ item.classList.remove('active');
245
+ }
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Handle table selection - opens in a new tab or switches to existing tab.
251
+ * Each tab maintains its own state (page, sort, hidden columns, etc.).
252
+ * @param {string} tableName - Name of the table to open
253
+ */
254
+ function handleTableSelect(tableName) {
255
+ const existingTabIndex = tabs.findIndex(tab => tab.tableName === tableName);
256
+
257
+ if (existingTabIndex !== -1) {
258
+ switchToTab(existingTabIndex);
259
+ } else {
260
+ const newTab = {
261
+ tableName: tableName,
262
+ page: 1,
263
+ totalCount: 0,
264
+ sortColumn: null,
265
+ sortDirection: 'asc',
266
+ data: null, // Cached table data
267
+ hiddenColumns: [], // Columns hidden by user
268
+ columnWidths: {}, // User-resized column widths
269
+ cursor: null, // Cursor for cursor-based pagination
270
+ cursorHistory: [], // History for backward navigation
271
+ hasPrimaryKey: false, // Whether table has primary key
272
+ isApproximate: false // Whether count is approximate (for large tables)
273
+ };
274
+ tabs.push(newTab);
275
+ activeTabIndex = tabs.length - 1;
276
+ renderTabs();
277
+ loadTableData();
278
+ }
279
+
280
+ updateSidebarActiveState();
281
+ updateSidebarToggleState();
282
+ }
283
+
284
+ function switchToTab(index) {
285
+ if (index < 0 || index >= tabs.length) return;
286
+
287
+ activeTabIndex = index;
288
+ const tab = tabs[activeTabIndex];
289
+
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
+ });
298
+
299
+ renderTabs();
300
+ updateSidebarActiveState();
301
+
302
+ if (tab.data) {
303
+ renderTable(tab.data);
304
+ renderPagination();
305
+ } else {
306
+ loadTableData();
307
+ }
308
+ }
309
+
310
+ function closeTab(index, event) {
311
+ if (event) {
312
+ event.stopPropagation();
313
+ }
314
+
315
+ if (tabs.length <= 1) {
316
+ tabs = [];
317
+ activeTabIndex = -1;
318
+ tabsContainer.style.display = 'none';
319
+ tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
320
+ pagination.style.display = 'none';
321
+
322
+ const tableItems = sidebarContent.querySelectorAll('.table-list li');
323
+ tableItems.forEach(item => item.classList.remove('active'));
324
+
325
+ updateSidebarToggleState();
326
+ return;
327
+ }
328
+
329
+ tabs.splice(index, 1);
330
+
331
+ if (activeTabIndex >= index) {
332
+ activeTabIndex--;
333
+ if (activeTabIndex < 0) activeTabIndex = 0;
334
+ }
335
+
336
+ if (index === activeTabIndex || activeTabIndex >= tabs.length) {
337
+ activeTabIndex = Math.max(0, tabs.length - 1);
338
+ }
339
+
340
+ renderTabs();
341
+
342
+ if (tabs.length > 0) {
343
+ const tab = tabs[activeTabIndex];
344
+ if (tab.data) {
345
+ renderTable(tab.data);
346
+ renderPagination();
347
+ } else {
348
+ loadTableData();
349
+ }
350
+
351
+ updateSidebarActiveState();
352
+ updateSidebarToggleState();
353
+ }
354
+ }
355
+
356
+ function renderTabs() {
357
+ if (tabs.length === 0) {
358
+ tabsContainer.style.display = 'none';
359
+ return;
360
+ }
361
+
362
+ tabsContainer.style.display = 'block';
363
+ tabsBar.innerHTML = '';
364
+
365
+ const closeAllButton = document.createElement('button');
366
+ closeAllButton.className = 'close-all-button';
367
+ closeAllButton.innerHTML = 'Close All';
368
+ closeAllButton.title = 'Close all tabs';
369
+ closeAllButton.addEventListener('click', closeAllTabs);
370
+ tabsBar.appendChild(closeAllButton);
371
+
372
+ tabs.forEach((tab, index) => {
373
+ const tabElement = document.createElement('div');
374
+ tabElement.className = `tab ${index === activeTabIndex ? 'active' : ''}`;
375
+ tabElement.addEventListener('click', () => switchToTab(index));
376
+
377
+ const tabLabel = document.createElement('span');
378
+ tabLabel.className = 'tab-label';
379
+ tabLabel.textContent = tab.tableName;
380
+ tabElement.appendChild(tabLabel);
381
+
382
+ const closeButton = document.createElement('button');
383
+ closeButton.className = 'tab-close';
384
+ closeButton.innerHTML = '×';
385
+ closeButton.addEventListener('click', (e) => closeTab(index, e));
386
+ tabElement.appendChild(closeButton);
387
+
388
+ tabsBar.appendChild(tabElement);
389
+ });
390
+ }
391
+
392
+ function closeAllTabs() {
393
+ tabs = [];
394
+ activeTabIndex = -1;
395
+ tabsContainer.style.display = 'none';
396
+ tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
397
+ pagination.style.display = 'none';
398
+
399
+ const tableItems = sidebarContent.querySelectorAll('.table-list li');
400
+ tableItems.forEach(item => item.classList.remove('active'));
401
+
402
+ updateSidebarToggleState();
403
+ }
404
+
405
+ function getActiveTab() {
406
+ if (activeTabIndex < 0 || activeTabIndex >= tabs.length) return null;
407
+ return tabs[activeTabIndex];
408
+ }
409
+
410
+ function handleRefresh() {
411
+ const tab = getActiveTab();
412
+ if (!tab) return;
413
+
414
+ tab.data = null;
415
+
416
+ const refreshIcon = document.querySelector('.refresh-icon');
417
+ if (refreshIcon) {
418
+ refreshIcon.classList.add('spinning');
419
+ setTimeout(() => {
420
+ refreshIcon.classList.remove('spinning');
421
+ }, 1000);
422
+ }
423
+
424
+ loadTableData();
425
+ }
426
+
427
+ /**
428
+ * Load table data for the active tab.
429
+ * Uses cursor-based pagination for tables with primary keys (more efficient for large datasets).
430
+ * Falls back to offset-based pagination for tables without primary keys or backward navigation.
431
+ */
432
+ async function loadTableData() {
433
+ const tab = getActiveTab();
434
+ if (!tab) return;
435
+
436
+ try {
437
+ tableView.innerHTML = '<div class="loading-state"><p>Loading data from ' + tab.tableName + '...</p></div>';
438
+
439
+ // 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)
443
+ queryString += `&cursor=${encodeURIComponent(tab.cursor)}`;
444
+ }
445
+
446
+ const response = await fetch(`/api/tables/${tab.tableName}?${queryString}`);
447
+ const data = await response.json();
448
+
449
+ if (data.error) {
450
+ throw new Error(data.error);
451
+ }
452
+
453
+ tab.totalCount = data.totalCount;
454
+ tab.hasPrimaryKey = data.hasPrimaryKey || false;
455
+ tab.isApproximate = data.isApproximate || false;
456
+ tab.data = data; // Cache data for client-side sorting
457
+
458
+ // Update cursor for next page navigation
459
+ if (data.nextCursor) {
460
+ if (tab.cursor && tab.page > 1) {
461
+ // Save current cursor to history for backward navigation
462
+ if (tab.cursorHistory.length < tab.page - 1) {
463
+ tab.cursorHistory.push(tab.cursor);
464
+ }
465
+ }
466
+ tab.cursor = data.nextCursor;
467
+ }
468
+
469
+ if (!data.rows || data.rows.length === 0) {
470
+ tableView.innerHTML = '<div class="empty-state"><p>Table ' + tab.tableName + ' is empty</p></div>';
471
+ pagination.style.display = 'none';
472
+ tab.data = null;
473
+ return;
474
+ }
475
+
476
+ renderTable(data);
477
+ renderPagination();
478
+ } catch (error) {
479
+ tableView.innerHTML = '<div class="error-state"><p>Error: ' + error.message + '</p></div>';
480
+ pagination.style.display = 'none';
481
+ }
482
+ }
483
+
484
+ function renderTable(data) {
485
+ const tab = getActiveTab();
486
+ if (!tab) return;
487
+
488
+ const columns = Object.keys(data.rows[0] || {});
489
+
490
+ const tableHeader = document.createElement('div');
491
+ tableHeader.className = 'table-header';
492
+ tableHeader.innerHTML = `
493
+ <div class="table-header-left">
494
+ <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>
501
+ </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>
505
+ </div>
506
+ </div>
507
+ </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>
511
+ `;
512
+
513
+ const refreshButton = tableHeader.querySelector('#refreshButton');
514
+ if (refreshButton) {
515
+ refreshButton.addEventListener('click', handleRefresh);
516
+ }
517
+
518
+ if (!tab.hiddenColumns) {
519
+ tab.hiddenColumns = [];
520
+ }
521
+
522
+ if (!tab.columnWidths) {
523
+ tab.columnWidths = {};
524
+ }
525
+
526
+ setupColumnSelector(tab, columns, tableHeader);
527
+ updateColumnButtonLabel(tab, columns, tableHeader);
528
+
529
+ const tableContainer = document.createElement('div');
530
+ tableContainer.className = 'table-container';
531
+
532
+ const table = document.createElement('table');
533
+
534
+ const visibleColumns = columns.filter(col => !tab.hiddenColumns.includes(col));
535
+
536
+ const thead = document.createElement('thead');
537
+ const headerRow = document.createElement('tr');
538
+
539
+ visibleColumns.forEach((column, index) => {
540
+ const th = document.createElement('th');
541
+ th.textContent = column;
542
+ th.className = 'sortable resizable';
543
+ th.dataset.column = column;
544
+
545
+ if (tab.columnWidths[column]) {
546
+ th.style.width = `${tab.columnWidths[column]}px`;
547
+ th.style.minWidth = `${tab.columnWidths[column]}px`;
548
+ } else {
549
+ th.style.minWidth = '120px';
550
+ }
551
+
552
+ if (tab.sortColumn === column) {
553
+ th.classList.add(`sorted-${tab.sortDirection}`);
554
+ }
555
+
556
+ const sortIndicator = document.createElement('span');
557
+ sortIndicator.className = 'sort-indicator';
558
+ if (tab.sortColumn === column) {
559
+ sortIndicator.textContent = tab.sortDirection === 'asc' ? ' ↑' : ' ↓';
560
+ th.appendChild(sortIndicator);
561
+ }
562
+
563
+ const resizeHandle = document.createElement('div');
564
+ resizeHandle.className = 'resize-handle';
565
+ resizeHandle.addEventListener('mousedown', (e) => {
566
+ e.preventDefault();
567
+ e.stopPropagation();
568
+ startResize(e, column, th, tab);
569
+ });
570
+ th.appendChild(resizeHandle);
571
+
572
+ th.addEventListener('click', (e) => {
573
+ if (!e.target.classList.contains('resize-handle')) {
574
+ handleSort(column);
575
+ }
576
+ });
577
+
578
+ headerRow.appendChild(th);
579
+ });
580
+
581
+ thead.appendChild(headerRow);
582
+ table.appendChild(thead);
583
+
584
+ const tbody = document.createElement('tbody');
585
+ const sortedRows = getSortedRows(data.rows, tab);
586
+
587
+ sortedRows.forEach(row => {
588
+ const tr = document.createElement('tr');
589
+ visibleColumns.forEach(column => {
590
+ const td = document.createElement('td');
591
+
592
+ if (tab.columnWidths[column]) {
593
+ td.style.width = `${tab.columnWidths[column]}px`;
594
+ td.style.minWidth = `${tab.columnWidths[column]}px`;
595
+ } else {
596
+ td.style.minWidth = '120px';
597
+ }
598
+
599
+ const value = row[column];
600
+ if (value === null || value === undefined) {
601
+ const nullSpan = document.createElement('span');
602
+ nullSpan.className = 'null-value';
603
+ nullSpan.textContent = 'NULL';
604
+ td.appendChild(nullSpan);
605
+ } else {
606
+ td.textContent = String(value);
607
+ }
608
+ tr.appendChild(td);
609
+ });
610
+ tbody.appendChild(tr);
611
+ });
612
+
613
+ table.appendChild(tbody);
614
+ tableContainer.appendChild(table);
615
+
616
+ tableView.innerHTML = '';
617
+ tableView.appendChild(tableHeader);
618
+ tableView.appendChild(tableContainer);
619
+ }
620
+
621
+ function updateColumnButtonLabel(tab, columns, tableHeader) {
622
+ const columnLabel = tableHeader ? tableHeader.querySelector('#columnLabel') : null;
623
+ if (!columnLabel) return;
624
+
625
+ const visibleCount = columns.length - (tab.hiddenColumns ? tab.hiddenColumns.length : 0);
626
+ const totalCount = columns.length;
627
+
628
+ if (visibleCount === totalCount) {
629
+ columnLabel.textContent = 'Columns (All)';
630
+ } else {
631
+ columnLabel.textContent = `Columns (${visibleCount})`;
632
+ }
633
+ }
634
+
635
+ function setupColumnSelector(tab, columns, tableHeader) {
636
+ const columnButton = tableHeader.querySelector('#columnButton');
637
+ const columnMenu = tableHeader.querySelector('#columnMenu');
638
+ const columnMenuOptions = tableHeader.querySelector('#columnMenuOptions');
639
+
640
+ if (!columnButton || !columnMenu || !columnMenuOptions) {
641
+ console.warn('Column selector elements not found');
642
+ return;
643
+ }
644
+
645
+ columnMenuOptions.innerHTML = '';
646
+
647
+ columns.forEach(column => {
648
+ const label = document.createElement('label');
649
+ label.className = 'column-option';
650
+
651
+ const checkbox = document.createElement('input');
652
+ checkbox.type = 'checkbox';
653
+ checkbox.checked = !tab.hiddenColumns.includes(column);
654
+ checkbox.addEventListener('change', (e) => {
655
+ e.stopPropagation();
656
+ toggleColumnVisibility(tab, column, checkbox.checked);
657
+ });
658
+
659
+ const span = document.createElement('span');
660
+ span.textContent = column;
661
+
662
+ label.appendChild(checkbox);
663
+ label.appendChild(span);
664
+ columnMenuOptions.appendChild(label);
665
+ });
666
+
667
+ const newColumnButton = columnButton.cloneNode(true);
668
+ columnButton.parentNode.replaceChild(newColumnButton, columnButton);
669
+
670
+ newColumnButton.addEventListener('click', (e) => {
671
+ e.stopPropagation();
672
+ const menu = tableHeader.querySelector('#columnMenu');
673
+ if (menu) {
674
+ menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
675
+ }
676
+ });
677
+
678
+ const closeMenuHandler = (e) => {
679
+ const button = tableHeader.querySelector('#columnButton');
680
+ const menu = tableHeader.querySelector('#columnMenu');
681
+ if (button && menu && !button.contains(e.target) && !menu.contains(e.target)) {
682
+ menu.style.display = 'none';
683
+ }
684
+ };
685
+
686
+ document.removeEventListener('click', closeMenuHandler);
687
+ document.addEventListener('click', closeMenuHandler);
688
+ }
689
+
690
+ function toggleColumnVisibility(tab, column, visible) {
691
+ if (visible) {
692
+ tab.hiddenColumns = tab.hiddenColumns.filter(col => col !== column);
693
+ } else {
694
+ if (!tab.hiddenColumns.includes(column)) {
695
+ tab.hiddenColumns.push(column);
696
+ }
697
+ }
698
+
699
+ const tableHeader = document.querySelector('.table-header');
700
+ const columnMenu = tableHeader ? tableHeader.querySelector('#columnMenu') : null;
701
+ const wasMenuOpen = columnMenu && columnMenu.style.display === 'block';
702
+
703
+ if (tab.data) {
704
+ renderTable(tab.data);
705
+
706
+ if (wasMenuOpen) {
707
+ requestAnimationFrame(() => {
708
+ const newTableHeader = document.querySelector('.table-header');
709
+ if (newTableHeader) {
710
+ const newColumnMenu = newTableHeader.querySelector('#columnMenu');
711
+ if (newColumnMenu) {
712
+ newColumnMenu.style.display = 'block';
713
+ const columns = Object.keys(tab.data.rows[0] || {});
714
+ setupColumnSelector(tab, columns, newTableHeader);
715
+ updateColumnButtonLabel(tab, columns, newTableHeader);
716
+ }
717
+ }
718
+ });
719
+ } else {
720
+ requestAnimationFrame(() => {
721
+ const newTableHeader = document.querySelector('.table-header');
722
+ if (newTableHeader) {
723
+ const columns = Object.keys(tab.data.rows[0] || {});
724
+ updateColumnButtonLabel(tab, columns, newTableHeader);
725
+ }
726
+ });
727
+ }
728
+ }
729
+ }
730
+
731
+ let isResizing = false;
732
+ let currentResizeColumn = null;
733
+ let currentResizeTh = null;
734
+ let currentResizeTab = null;
735
+ let startX = 0;
736
+ let startWidth = 0;
737
+
738
+ function startResize(e, column, th, tab) {
739
+ isResizing = true;
740
+ currentResizeColumn = column;
741
+ currentResizeTh = th;
742
+ currentResizeTab = tab;
743
+ startX = e.pageX;
744
+ startWidth = th.offsetWidth;
745
+
746
+ document.addEventListener('mousemove', handleResize);
747
+ document.addEventListener('mouseup', stopResize);
748
+ document.body.style.cursor = 'col-resize';
749
+ document.body.style.userSelect = 'none';
750
+
751
+ e.preventDefault();
752
+ }
753
+
754
+ function handleResize(e) {
755
+ if (!isResizing) return;
756
+
757
+ const diff = e.pageX - startX;
758
+ const newWidth = Math.max(80, startWidth + diff); // Minimum width of 80px
759
+
760
+ // Update the header
761
+ if (currentResizeTh) {
762
+ currentResizeTh.style.width = `${newWidth}px`;
763
+ currentResizeTh.style.minWidth = `${newWidth}px`;
764
+ }
765
+
766
+ // Update all corresponding cells
767
+ const table = currentResizeTh?.closest('table');
768
+ if (table) {
769
+ const columnIndex = Array.from(currentResizeTh.parentNode.children).indexOf(currentResizeTh);
770
+ const allRows = table.querySelectorAll('tbody tr');
771
+ allRows.forEach(row => {
772
+ const cell = row.children[columnIndex];
773
+ if (cell) {
774
+ cell.style.width = `${newWidth}px`;
775
+ cell.style.minWidth = `${newWidth}px`;
776
+ }
777
+ });
778
+ }
779
+ }
780
+
781
+ function stopResize() {
782
+ if (isResizing && currentResizeColumn && currentResizeTab && currentResizeTh) {
783
+ const finalWidth = currentResizeTh.offsetWidth;
784
+ currentResizeTab.columnWidths[currentResizeColumn] = finalWidth;
785
+ }
786
+
787
+ isResizing = false;
788
+ currentResizeColumn = null;
789
+ currentResizeTh = null;
790
+ currentResizeTab = null;
791
+
792
+ document.removeEventListener('mousemove', handleResize);
793
+ document.removeEventListener('mouseup', stopResize);
794
+ document.body.style.cursor = '';
795
+ document.body.style.userSelect = '';
796
+ }
797
+
798
+ function handleSort(column) {
799
+ const tab = getActiveTab();
800
+ if (!tab) return;
801
+
802
+ if (tab.sortColumn === column) {
803
+ tab.sortDirection = tab.sortDirection === 'asc' ? 'desc' : 'asc';
804
+ } else {
805
+ tab.sortColumn = column;
806
+ tab.sortDirection = 'asc';
807
+ }
808
+
809
+ if (tab.data) {
810
+ renderTable(tab.data);
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Sort rows client-side based on current tab's sort settings.
816
+ * Note: This sorts only the current page of data, not the entire table.
817
+ * For full-table sorting, server-side sorting would be required.
818
+ * @param {Array} rows - Array of row objects to sort
819
+ * @param {Object} tab - Tab object with sortColumn and sortDirection
820
+ * @returns {Array} Sorted array of rows
821
+ */
822
+ function getSortedRows(rows, tab) {
823
+ if (!rows || rows.length === 0) return [];
824
+ if (!tab.sortColumn) return rows;
825
+
826
+ const sorted = [...rows].sort((a, b) => {
827
+ const aVal = a[tab.sortColumn];
828
+ const bVal = b[tab.sortColumn];
829
+
830
+ // Null/undefined values go to the end
831
+ if (aVal === null || aVal === undefined) return 1;
832
+ if (bVal === null || bVal === undefined) return -1;
833
+
834
+ // Numeric comparison
835
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
836
+ return tab.sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
837
+ }
838
+
839
+ // String comparison (case-insensitive)
840
+ const aStr = String(aVal).toLowerCase();
841
+ const bStr = String(bVal).toLowerCase();
842
+
843
+ if (tab.sortDirection === 'asc') {
844
+ return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
845
+ } else {
846
+ return aStr > bStr ? -1 : aStr < bStr ? 1 : 0;
847
+ }
848
+ });
849
+
850
+ return sorted;
851
+ }
852
+
853
+ function renderPagination() {
854
+ const tab = getActiveTab();
855
+ if (!tab) return;
856
+
857
+ const limit = 100;
858
+ const totalPages = Math.ceil(tab.totalCount / limit);
859
+
860
+ if (totalPages <= 1) {
861
+ pagination.style.display = 'none';
862
+ return;
863
+ }
864
+
865
+ pagination.style.display = 'flex';
866
+
867
+ const hasPrevious = tab.page > 1;
868
+ const hasNext = tab.page < totalPages;
869
+
870
+ 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>
888
+ `;
889
+ }
890
+
891
+ /**
892
+ * Handle page change in pagination.
893
+ * Manages cursor-based pagination for forward navigation.
894
+ * Falls back to offset-based pagination for backward navigation or page jumps.
895
+ * @param {number} newPage - Page number to navigate to
896
+ */
897
+ function handlePageChange(newPage) {
898
+ const tab = getActiveTab();
899
+ if (!tab) return;
900
+
901
+ const oldPage = tab.page;
902
+ tab.page = newPage;
903
+
904
+ // Handle cursor-based pagination
905
+ if (tab.hasPrimaryKey) {
906
+ if (newPage < oldPage) {
907
+ // Backward navigation - reset cursor (limitation: would need full cursor history for optimal backward nav)
908
+ if (newPage === 1) {
909
+ tab.cursor = null;
910
+ tab.cursorHistory = [];
911
+ } else {
912
+ tab.cursor = null; // Falls back to OFFSET-based pagination
913
+ }
914
+ } else if (newPage === oldPage + 1) {
915
+ // Forward navigation - cursor is already set from previous load
916
+ } else {
917
+ // Page jump - reset cursor
918
+ tab.cursor = null;
919
+ tab.cursorHistory = [];
920
+ }
921
+ }
922
+
923
+ tab.data = null; // Clear cache to force reload
924
+ window.scrollTo({ top: 0, behavior: 'smooth' });
925
+ loadTableData();
926
+ }
927
+
928
+ window.handlePageChange = handlePageChange;