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/.github/ISSUE_TEMPLATE/bug_report.md +46 -0
- package/.github/ISSUE_TEMPLATE/config.yml +9 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +36 -0
- package/.github/pull_request_template.md +52 -0
- package/CHANGELOG.md +41 -0
- package/CONTRIBUTING.md +391 -0
- package/README.md +221 -0
- package/bin/pglens +18 -0
- package/client/app.js +928 -0
- package/client/index.html +59 -0
- package/client/styles.css +801 -0
- package/package.json +34 -0
- package/pglens-1.0.0.tgz +0 -0
- package/src/db/connection.js +200 -0
- package/src/routes/api.js +200 -0
- package/src/server.js +62 -0
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;
|