pglens 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,20 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.1.0] - 2025-11-21
9
9
 
10
10
  ### Added
11
- - SSL mode configuration via `--sslmode` flag with support for: `disable`, `require`, `prefer`, `verify-ca`, `verify-full`
12
- - Automatic SSL mode recommendations when connection fails
13
- - Enhanced error messages with context-aware suggestions
11
+
12
+ - Cell content popup dialog for viewing full cell values (double-click any table cell)
13
+ - JSON formatting support for JSONB and JSON values in cell content popup with proper indentation
14
+ - Timezone selector for date/time values with multi-timezone display
15
+ - Select All button in column selector for quick column visibility management
16
+ - Copy-to-clipboard functionality in cell content popup
17
+
18
+ ### Fixed
19
+
20
+ - JSONB columns now display as readable JSON instead of showing as object
14
21
 
15
22
  ### Changed
16
- - Improved production readiness by removing debug code and commented sections
17
- - Updated connection error handling to provide actionable recommendations
23
+
24
+ - Table cells now truncate with ellipsis for better table readability
25
+ - Improved cell content viewing experience with dedicated popup dialog
18
26
 
19
27
  ## [1.0.0] - Initial Release
20
28
 
21
29
  ### Added
30
+
22
31
  - PostgreSQL database viewer with web interface
23
32
  - Table browser with searchable sidebar
24
33
  - Multi-tab support for viewing multiple tables simultaneously
@@ -31,11 +40,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
40
  - Automatic primary key detection for optimized pagination
32
41
  - Refresh data functionality
33
42
  - PM2 deployment support documentation
43
+ - SSL mode configuration via `--sslmode` flag with support for: `disable`, `require`, `prefer`, `verify-ca`, `verify-full`
44
+ - Automatic SSL mode recommendations when connection fails
45
+ - Enhanced error messages with context-aware suggestions
34
46
 
35
47
  ### Security
48
+
36
49
  - SQL injection prevention via table name sanitization
37
50
  - Input validation for pagination parameters
38
51
 
39
- [Unreleased]: https://github.com/tsvillain/pglens/compare/v1.0.0...HEAD
52
+ [1.1.0]: https://github.com/tsvillain/pglens/compare/v1.0.0...v1.1.0
40
53
  [1.0.0]: https://github.com/tsvillain/pglens/releases/tag/v1.0.0
41
54
 
package/client/app.js CHANGED
@@ -424,6 +424,27 @@ function handleRefresh() {
424
424
  loadTableData();
425
425
  }
426
426
 
427
+ /**
428
+ * Check if a value is a JSON object or array (JSONB/JSON type).
429
+ * Excludes null, Date objects, and other non-JSON types.
430
+ * @param {*} value - Value to check
431
+ * @returns {boolean} True if value is a JSON object or array
432
+ */
433
+ function isJsonValue(value) {
434
+ if (value === null || value === undefined) {
435
+ return false;
436
+ }
437
+
438
+ if (typeof value === 'object') {
439
+ if (value instanceof Date) {
440
+ return false;
441
+ }
442
+ return Array.isArray(value) || Object.prototype.toString.call(value) === '[object Object]';
443
+ }
444
+
445
+ return false;
446
+ }
447
+
427
448
  /**
428
449
  * Load table data for the active tab.
429
450
  * Uses cursor-based pagination for tables with primary keys (more efficient for large datasets).
@@ -538,7 +559,6 @@ function renderTable(data) {
538
559
 
539
560
  visibleColumns.forEach((column, index) => {
540
561
  const th = document.createElement('th');
541
- th.textContent = column;
542
562
  th.className = 'sortable resizable';
543
563
  th.dataset.column = column;
544
564
 
@@ -549,6 +569,78 @@ function renderTable(data) {
549
569
  th.style.minWidth = '120px';
550
570
  }
551
571
 
572
+ const columnHeader = document.createElement('div');
573
+ columnHeader.className = 'column-header';
574
+
575
+ // Column name with key badges
576
+ const columnNameRow = document.createElement('div');
577
+ columnNameRow.className = 'column-name-row';
578
+
579
+ const columnName = document.createElement('div');
580
+ columnName.className = 'column-name';
581
+ columnName.textContent = column;
582
+ columnNameRow.appendChild(columnName);
583
+
584
+ const columnMeta = data.columns && data.columns[column] ? data.columns[column] : null;
585
+
586
+ let dataType = '';
587
+ let isPrimaryKey = false;
588
+ let isForeignKey = false;
589
+ let foreignKeyRef = null;
590
+ let isUnique = false;
591
+
592
+ if (columnMeta) {
593
+ if (typeof columnMeta === 'string') {
594
+ dataType = columnMeta;
595
+ } else {
596
+ dataType = columnMeta.dataType || '';
597
+ isPrimaryKey = columnMeta.isPrimaryKey || false;
598
+ isForeignKey = columnMeta.isForeignKey || false;
599
+ foreignKeyRef = columnMeta.foreignKeyRef || null;
600
+ isUnique = columnMeta.isUnique || false;
601
+ }
602
+ }
603
+
604
+ const keyBadges = document.createElement('div');
605
+ keyBadges.className = 'key-badges';
606
+
607
+ if (isPrimaryKey) {
608
+ const pkBadge = document.createElement('span');
609
+ pkBadge.className = 'key-badge key-badge-pk';
610
+ pkBadge.textContent = 'PK';
611
+ pkBadge.title = 'Primary Key';
612
+ keyBadges.appendChild(pkBadge);
613
+ }
614
+
615
+ if (isForeignKey && foreignKeyRef) {
616
+ const fkBadge = document.createElement('span');
617
+ fkBadge.className = 'key-badge key-badge-fk';
618
+ fkBadge.textContent = 'FK';
619
+ fkBadge.title = `Foreign Key → ${foreignKeyRef.table}.${foreignKeyRef.column}`;
620
+ keyBadges.appendChild(fkBadge);
621
+ }
622
+
623
+ if (isUnique && !isPrimaryKey) {
624
+ const uqBadge = document.createElement('span');
625
+ uqBadge.className = 'key-badge key-badge-uq';
626
+ uqBadge.textContent = 'UQ';
627
+ uqBadge.title = 'Unique Constraint';
628
+ keyBadges.appendChild(uqBadge);
629
+ }
630
+
631
+ if (keyBadges.children.length > 0) {
632
+ columnNameRow.appendChild(keyBadges);
633
+ }
634
+
635
+ // Column datatype
636
+ const columnDatatype = document.createElement('div');
637
+ columnDatatype.className = 'column-datatype';
638
+ columnDatatype.textContent = dataType;
639
+
640
+ columnHeader.appendChild(columnNameRow);
641
+ columnHeader.appendChild(columnDatatype);
642
+ th.appendChild(columnHeader);
643
+
552
644
  if (tab.sortColumn === column) {
553
645
  th.classList.add(`sorted-${tab.sortDirection}`);
554
646
  }
@@ -597,11 +689,34 @@ function renderTable(data) {
597
689
  }
598
690
 
599
691
  const value = row[column];
692
+
693
+ // Store original value for popup
694
+ td.dataset.originalValue = value !== null && value !== undefined
695
+ ? (isJsonValue(value) ? JSON.stringify(value, null, 2) : String(value))
696
+ : 'NULL';
697
+ td.dataset.columnName = column;
698
+
699
+ td.addEventListener('dblclick', (e) => {
700
+ e.stopPropagation();
701
+ showCellContentPopup(column, value);
702
+ });
703
+
704
+ td.style.cursor = 'pointer';
705
+
600
706
  if (value === null || value === undefined) {
601
707
  const nullSpan = document.createElement('span');
602
708
  nullSpan.className = 'null-value';
603
709
  nullSpan.textContent = 'NULL';
604
710
  td.appendChild(nullSpan);
711
+ } else if (isJsonValue(value)) {
712
+ const jsonPre = document.createElement('pre');
713
+ jsonPre.className = 'json-value';
714
+ try {
715
+ jsonPre.textContent = JSON.stringify(value, null, 2);
716
+ } catch (e) {
717
+ jsonPre.textContent = String(value);
718
+ }
719
+ td.appendChild(jsonPre);
605
720
  } else {
606
721
  td.textContent = String(value);
607
722
  }
@@ -636,6 +751,7 @@ function setupColumnSelector(tab, columns, tableHeader) {
636
751
  const columnButton = tableHeader.querySelector('#columnButton');
637
752
  const columnMenu = tableHeader.querySelector('#columnMenu');
638
753
  const columnMenuOptions = tableHeader.querySelector('#columnMenuOptions');
754
+ const columnMenuHeader = tableHeader.querySelector('.column-menu-header');
639
755
 
640
756
  if (!columnButton || !columnMenu || !columnMenuOptions) {
641
757
  console.warn('Column selector elements not found');
@@ -644,6 +760,55 @@ function setupColumnSelector(tab, columns, tableHeader) {
644
760
 
645
761
  columnMenuOptions.innerHTML = '';
646
762
 
763
+ // Check if any columns are hidden
764
+ const hasHiddenColumns = tab.hiddenColumns && tab.hiddenColumns.length > 0;
765
+
766
+ if (columnMenuHeader) {
767
+ let headerTitle = columnMenuHeader.querySelector('.column-menu-header-title');
768
+ if (!headerTitle) {
769
+ const headerText = columnMenuHeader.textContent.trim();
770
+ columnMenuHeader.innerHTML = '';
771
+ headerTitle = document.createElement('span');
772
+ headerTitle.className = 'column-menu-header-title';
773
+ headerTitle.textContent = headerText || 'Columns';
774
+ columnMenuHeader.appendChild(headerTitle);
775
+ }
776
+
777
+ let selectAllButton = columnMenuHeader.querySelector('.column-select-all-button');
778
+ if (hasHiddenColumns) {
779
+ if (!selectAllButton) {
780
+ selectAllButton = document.createElement('button');
781
+ selectAllButton.className = 'column-select-all-button';
782
+ selectAllButton.textContent = 'Select All';
783
+ selectAllButton.title = 'Show all columns';
784
+ selectAllButton.addEventListener('click', (e) => {
785
+ e.stopPropagation();
786
+ // Show all columns
787
+ tab.hiddenColumns = [];
788
+ if (tab.data) {
789
+ renderTable(tab.data);
790
+ requestAnimationFrame(() => {
791
+ const newTableHeader = document.querySelector('.table-header');
792
+ if (newTableHeader) {
793
+ const newColumnMenu = newTableHeader.querySelector('#columnMenu');
794
+ if (newColumnMenu) {
795
+ newColumnMenu.style.display = 'block';
796
+ const columns = Object.keys(tab.data.rows[0] || {});
797
+ setupColumnSelector(tab, columns, newTableHeader);
798
+ updateColumnButtonLabel(tab, columns, newTableHeader);
799
+ }
800
+ }
801
+ });
802
+ }
803
+ });
804
+ columnMenuHeader.appendChild(selectAllButton);
805
+ }
806
+ selectAllButton.style.display = 'block';
807
+ } else if (selectAllButton) {
808
+ selectAllButton.style.display = 'none';
809
+ }
810
+ }
811
+
647
812
  columns.forEach(column => {
648
813
  const label = document.createElement('label');
649
814
  label.className = 'column-option';
@@ -836,9 +1001,26 @@ function getSortedRows(rows, tab) {
836
1001
  return tab.sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
837
1002
  }
838
1003
 
839
- // String comparison (case-insensitive)
840
- const aStr = String(aVal).toLowerCase();
841
- const bStr = String(bVal).toLowerCase();
1004
+ let aStr, bStr;
1005
+ if (isJsonValue(aVal)) {
1006
+ try {
1007
+ aStr = JSON.stringify(aVal).toLowerCase();
1008
+ } catch (e) {
1009
+ aStr = String(aVal).toLowerCase();
1010
+ }
1011
+ } else {
1012
+ aStr = String(aVal).toLowerCase();
1013
+ }
1014
+
1015
+ if (isJsonValue(bVal)) {
1016
+ try {
1017
+ bStr = JSON.stringify(bVal).toLowerCase();
1018
+ } catch (e) {
1019
+ bStr = String(bVal).toLowerCase();
1020
+ }
1021
+ } else {
1022
+ bStr = String(bVal).toLowerCase();
1023
+ }
842
1024
 
843
1025
  if (tab.sortDirection === 'asc') {
844
1026
  return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
@@ -926,3 +1108,357 @@ function handlePageChange(newPage) {
926
1108
  }
927
1109
 
928
1110
  window.handlePageChange = handlePageChange;
1111
+
1112
+ /**
1113
+ * Check if a value is a date/time value and parse it.
1114
+ * Detects Date objects, ISO date strings, and PostgreSQL timestamp strings.
1115
+ * @param {*} value - Value to check
1116
+ * @returns {Date|null} Parsed Date object if valid date/time, null otherwise
1117
+ */
1118
+ function isDateTimeValue(value) {
1119
+ if (value === null || value === undefined) {
1120
+ return null;
1121
+ }
1122
+
1123
+ if (value instanceof Date) {
1124
+ return isNaN(value.getTime()) ? null : value;
1125
+ }
1126
+
1127
+ if (typeof value === 'string') {
1128
+ const trimmed = value.trim();
1129
+ if (trimmed === '' || trimmed === 'NULL') {
1130
+ return null;
1131
+ }
1132
+
1133
+ // Try parsing as ISO date string or PostgreSQL timestamp
1134
+ // PostgreSQL timestamps: '2024-01-01 12:00:00' or '2024-01-01 12:00:00.123' or with timezone
1135
+ // ISO strings: '2024-01-01T12:00:00' or '2024-01-01T12:00:00Z' or with timezone offset
1136
+ const date = new Date(trimmed);
1137
+ if (!isNaN(date.getTime())) {
1138
+ const datePattern = /^\d{4}-\d{2}-\d{2}/;
1139
+ if (datePattern.test(trimmed)) {
1140
+ return date;
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ return null;
1146
+ }
1147
+
1148
+ /**
1149
+ * Get user's current timezone.
1150
+ * @returns {string} IANA timezone identifier (e.g., 'America/New_York')
1151
+ */
1152
+ function getCurrentTimezone() {
1153
+ try {
1154
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
1155
+ } catch (e) {
1156
+ return 'UTC';
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * Get list of common timezones.
1162
+ * @returns {Array<{value: string, label: string}>} Array of timezone objects
1163
+ */
1164
+ function getCommonTimezones() {
1165
+ const timezones = [
1166
+ { value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
1167
+ { value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
1168
+ { value: 'America/Chicago', label: 'America/Chicago (CST/CDT)' },
1169
+ { value: 'America/Denver', label: 'America/Denver (MST/MDT)' },
1170
+ { value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
1171
+ { value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
1172
+ { value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
1173
+ { value: 'Europe/Berlin', label: 'Europe/Berlin (CET/CEST)' },
1174
+ { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
1175
+ { value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' },
1176
+ { value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' },
1177
+ { value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
1178
+ { value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT/AEST)' },
1179
+ { value: 'Pacific/Auckland', label: 'Pacific/Auckland (NZDT/NZST)' },
1180
+ ];
1181
+
1182
+ // Add current timezone if not already in list
1183
+ const currentTz = getCurrentTimezone();
1184
+ const hasCurrent = timezones.some(tz => tz.value === currentTz);
1185
+ if (!hasCurrent && currentTz !== 'UTC') {
1186
+ timezones.unshift({ value: currentTz, label: `${currentTz} (Current)` });
1187
+ }
1188
+
1189
+ return timezones;
1190
+ }
1191
+
1192
+ /**
1193
+ * Format date/time in specified timezone.
1194
+ * @param {Date} date - Date object to format
1195
+ * @param {string} timezone - IANA timezone identifier
1196
+ * @returns {string} Formatted date/time string with timezone info
1197
+ */
1198
+ function formatDateTimeInTimezone(date, timezone) {
1199
+ try {
1200
+ const formatter = new Intl.DateTimeFormat('en-US', {
1201
+ timeZone: timezone,
1202
+ year: 'numeric',
1203
+ month: '2-digit',
1204
+ day: '2-digit',
1205
+ hour: '2-digit',
1206
+ minute: '2-digit',
1207
+ second: '2-digit',
1208
+ fractionalSecondDigits: 3,
1209
+ hour12: false,
1210
+ });
1211
+
1212
+ const parts = formatter.formatToParts(date);
1213
+ const year = parts.find(p => p.type === 'year').value;
1214
+ const month = parts.find(p => p.type === 'month').value;
1215
+ const day = parts.find(p => p.type === 'day').value;
1216
+ const hour = parts.find(p => p.type === 'hour').value;
1217
+ const minute = parts.find(p => p.type === 'minute').value;
1218
+ const second = parts.find(p => p.type === 'second').value;
1219
+ const fractionalSecond = parts.find(p => p.type === 'fractionalSecond')?.value || '';
1220
+
1221
+ const tzFormatter = new Intl.DateTimeFormat('en-US', {
1222
+ timeZone: timezone,
1223
+ timeZoneName: 'short',
1224
+ });
1225
+ const tzParts = tzFormatter.formatToParts(date);
1226
+ const tzName = tzParts.find(p => p.type === 'timeZoneName')?.value || timezone;
1227
+
1228
+ const dateStr = `${year}-${month}-${day}`;
1229
+ const timeStr = `${hour}:${minute}:${second}${fractionalSecond ? '.' + fractionalSecond : ''}`;
1230
+
1231
+ return `${dateStr} ${timeStr} ${tzName}`;
1232
+ } catch (e) {
1233
+ return date.toISOString();
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * Format cell content for display in popup dialog.
1239
+ * Handles JSON values, null values, JSON strings, date/time values, and regular text appropriately.
1240
+ * @param {*} value - The cell value to format
1241
+ * @returns {Object} Object with formatted content, isJson flag, isDateTime flag, and dateValue: { content: string, isJson: boolean, isDateTime: boolean, dateValue: Date | null }
1242
+ */
1243
+ function formatCellContentForPopup(value, timezone = null) {
1244
+ if (value === null || value === undefined) {
1245
+ return { content: 'NULL', isJson: false, isDateTime: false, dateValue: null };
1246
+ }
1247
+
1248
+ const dateValue = isDateTimeValue(value);
1249
+ if (dateValue) {
1250
+ const tz = timezone || getCurrentTimezone();
1251
+ const formatted = formatDateTimeInTimezone(dateValue, tz);
1252
+ return { content: formatted, isJson: false, isDateTime: true, dateValue: dateValue };
1253
+ }
1254
+
1255
+ // Handle JSON objects/arrays
1256
+ if (isJsonValue(value)) {
1257
+ try {
1258
+ return { content: JSON.stringify(value, null, 2), isJson: true, isDateTime: false, dateValue: null };
1259
+ } catch (e) {
1260
+ return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
1261
+ }
1262
+ }
1263
+
1264
+ // Handle string values - check if it's a JSON string
1265
+ if (typeof value === 'string') {
1266
+ const trimmed = value.trim();
1267
+ // Check if string looks like JSON (starts with { or [)
1268
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
1269
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
1270
+ try {
1271
+ const parsed = JSON.parse(trimmed);
1272
+ return { content: JSON.stringify(parsed, null, 2), isJson: true, isDateTime: false, dateValue: null };
1273
+ } catch (e) {
1274
+ return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
1275
+ }
1276
+ }
1277
+ }
1278
+
1279
+ return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
1280
+ }
1281
+
1282
+ /**
1283
+ * Show popup dialog with full cell content.
1284
+ * @param {string} column - Column name
1285
+ * @param {*} value - Cell value
1286
+ */
1287
+ function showCellContentPopup(column, value) {
1288
+ closeCellContentPopup();
1289
+
1290
+ const overlay = document.createElement('div');
1291
+ overlay.className = 'cell-popup-overlay';
1292
+ overlay.addEventListener('click', (e) => {
1293
+ if (e.target === overlay) {
1294
+ closeCellContentPopup();
1295
+ }
1296
+ });
1297
+
1298
+ const dialog = document.createElement('div');
1299
+ dialog.className = 'cell-popup-dialog';
1300
+ dialog.addEventListener('click', (e) => {
1301
+ e.stopPropagation();
1302
+ });
1303
+
1304
+ const formatted = formatCellContentForPopup(value);
1305
+ const formattedContent = formatted.content;
1306
+ const isJson = formatted.isJson;
1307
+ const isDateTime = formatted.isDateTime;
1308
+ const dateValue = formatted.dateValue;
1309
+
1310
+ let currentTimezone = getCurrentTimezone();
1311
+
1312
+ const header = document.createElement('div');
1313
+ header.className = 'cell-popup-header';
1314
+
1315
+ const title = document.createElement('h3');
1316
+ title.className = 'cell-popup-title';
1317
+ title.textContent = column;
1318
+ header.appendChild(title);
1319
+
1320
+ const headerActions = document.createElement('div');
1321
+ headerActions.className = 'cell-popup-actions';
1322
+
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
+ const copyButton = document.createElement('button');
1344
+ copyButton.className = 'cell-popup-copy';
1345
+ copyButton.innerHTML = '📋';
1346
+ copyButton.title = 'Copy to clipboard';
1347
+
1348
+ const updateContent = () => {
1349
+ if (value === null || value === undefined) {
1350
+ content.classList.add('null-content');
1351
+ content.classList.remove('json-value-popup', 'datetime-value-popup');
1352
+ content.textContent = 'NULL';
1353
+ } else if (isJson) {
1354
+ const formatted = formatCellContentForPopup(value, currentTimezone);
1355
+ content.classList.add('json-value-popup');
1356
+ content.classList.remove('null-content', 'datetime-value-popup');
1357
+ content.textContent = formatted.content;
1358
+ } else if (isDateTime && dateValue) {
1359
+ content.classList.add('datetime-value-popup');
1360
+ 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;
1374
+ } else {
1375
+ const formatted = formatCellContentForPopup(value, currentTimezone);
1376
+ content.classList.remove('null-content', 'json-value-popup', 'datetime-value-popup');
1377
+ content.textContent = formatted.content;
1378
+ }
1379
+
1380
+ // Update copy button to use current formatted content
1381
+ const finalFormatted = formatCellContentForPopup(value, currentTimezone);
1382
+ copyButton._formattedContent = finalFormatted.content;
1383
+ };
1384
+
1385
+ copyButton.addEventListener('click', async () => {
1386
+ try {
1387
+ const textToCopy = copyButton._formattedContent || formattedContent;
1388
+ await navigator.clipboard.writeText(textToCopy);
1389
+ copyButton.innerHTML = '✓';
1390
+ copyButton.title = 'Copied!';
1391
+ copyButton.classList.add('copied');
1392
+ setTimeout(() => {
1393
+ copyButton.innerHTML = '📋';
1394
+ copyButton.title = 'Copy to clipboard';
1395
+ copyButton.classList.remove('copied');
1396
+ }, 2000);
1397
+ } catch (err) {
1398
+ console.error('Failed to copy:', err);
1399
+ copyButton.innerHTML = '✗';
1400
+ copyButton.title = 'Copy failed';
1401
+ setTimeout(() => {
1402
+ copyButton.innerHTML = '📋';
1403
+ copyButton.title = 'Copy to clipboard';
1404
+ }, 2000);
1405
+ }
1406
+ });
1407
+ copyButton._formattedContent = formattedContent;
1408
+ headerActions.appendChild(copyButton);
1409
+
1410
+ const closeButton = document.createElement('button');
1411
+ closeButton.className = 'cell-popup-close';
1412
+ closeButton.innerHTML = '×';
1413
+ closeButton.title = 'Close';
1414
+ closeButton.addEventListener('click', closeCellContentPopup);
1415
+ headerActions.appendChild(closeButton);
1416
+
1417
+ header.appendChild(headerActions);
1418
+
1419
+ const body = document.createElement('div');
1420
+ body.className = 'cell-popup-body';
1421
+
1422
+ const content = document.createElement('pre');
1423
+ content.className = 'cell-popup-content';
1424
+
1425
+ updateContent();
1426
+
1427
+ if (timezoneSelect) {
1428
+ timezoneSelect.addEventListener('change', (e) => {
1429
+ currentTimezone = e.target.value;
1430
+ updateContent();
1431
+ });
1432
+ }
1433
+
1434
+ body.appendChild(content);
1435
+
1436
+ dialog.appendChild(header);
1437
+ dialog.appendChild(body);
1438
+ overlay.appendChild(dialog);
1439
+
1440
+ document.body.appendChild(overlay);
1441
+
1442
+ const escapeHandler = (e) => {
1443
+ if (e.key === 'Escape') {
1444
+ closeCellContentPopup();
1445
+ }
1446
+ };
1447
+ overlay.dataset.escapeHandler = 'true';
1448
+ document.addEventListener('keydown', escapeHandler);
1449
+
1450
+ overlay._escapeHandler = escapeHandler;
1451
+ }
1452
+
1453
+ /**
1454
+ * Close the cell content popup dialog.
1455
+ */
1456
+ function closeCellContentPopup() {
1457
+ const overlay = document.querySelector('.cell-popup-overlay');
1458
+ if (overlay) {
1459
+ if (overlay._escapeHandler) {
1460
+ document.removeEventListener('keydown', overlay._escapeHandler);
1461
+ }
1462
+ overlay.remove();
1463
+ }
1464
+ }
package/client/styles.css CHANGED
@@ -526,11 +526,88 @@ th {
526
526
  font-weight: var(--font-weight-semibold);
527
527
  color: var(--text-primary);
528
528
  border-bottom: 1px solid var(--border-subtle);
529
- white-space: nowrap;
529
+ white-space: normal;
530
530
  font-size: var(--font-size-xs);
531
- text-transform: uppercase;
532
531
  letter-spacing: 0.05em;
533
532
  position: relative;
533
+ vertical-align: bottom;
534
+ }
535
+
536
+ .column-header {
537
+ display: flex;
538
+ flex-direction: column;
539
+ gap: var(--space-1);
540
+ min-height: 100%;
541
+ }
542
+
543
+ .column-name-row {
544
+ display: flex;
545
+ align-items: center;
546
+ gap: var(--space-1);
547
+ line-height: 1.2;
548
+ }
549
+
550
+ .column-name {
551
+ line-height: 1.2;
552
+ }
553
+
554
+ .key-badges {
555
+ display: flex;
556
+ align-items: center;
557
+ gap: 2px;
558
+ flex-shrink: 0;
559
+ }
560
+
561
+ .key-badge {
562
+ display: inline-block;
563
+ padding: 2px 4px;
564
+ font-size: 9px;
565
+ font-weight: var(--font-weight-bold);
566
+ border-radius: 3px;
567
+ line-height: 1;
568
+ text-transform: uppercase;
569
+ letter-spacing: 0.05em;
570
+ white-space: nowrap;
571
+ }
572
+
573
+ .key-badge-pk {
574
+ background-color: #3b82f6;
575
+ color: #ffffff;
576
+ }
577
+
578
+ [data-theme="dark"] .key-badge-pk {
579
+ background-color: #60a5fa;
580
+ color: #0f172a;
581
+ }
582
+
583
+ .key-badge-fk {
584
+ background-color: #f59e0b;
585
+ color: #ffffff;
586
+ }
587
+
588
+ [data-theme="dark"] .key-badge-fk {
589
+ background-color: #fbbf24;
590
+ color: #0f172a;
591
+ }
592
+
593
+ .key-badge-uq {
594
+ background-color: #10b981;
595
+ color: #ffffff;
596
+ }
597
+
598
+ [data-theme="dark"] .key-badge-uq {
599
+ background-color: #34d399;
600
+ color: #0f172a;
601
+ }
602
+
603
+ .column-datatype {
604
+ font-size: 10px;
605
+ font-weight: var(--font-weight-normal);
606
+ color: var(--text-secondary);
607
+ text-transform: none;
608
+ letter-spacing: 0;
609
+ line-height: 1.2;
610
+ opacity: 0.8;
534
611
  }
535
612
 
536
613
  th.resizable {
@@ -591,9 +668,10 @@ td {
591
668
  padding: var(--space-3) var(--space-4);
592
669
  border-bottom: 1px solid var(--border-subtle);
593
670
  color: var(--text-primary);
594
- word-break: break-word;
671
+ white-space: nowrap;
595
672
  overflow: hidden;
596
673
  text-overflow: ellipsis;
674
+ max-width: 200px;
597
675
  }
598
676
 
599
677
  tr:hover {
@@ -605,6 +683,198 @@ tr:hover {
605
683
  font-style: italic;
606
684
  }
607
685
 
686
+ .json-value {
687
+ margin: 0;
688
+ padding: 0;
689
+ font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
690
+ "Courier New", monospace;
691
+ font-size: var(--font-size-xs);
692
+ line-height: 1.4;
693
+ color: var(--text-primary);
694
+ white-space: nowrap;
695
+ overflow: hidden;
696
+ text-overflow: ellipsis;
697
+ max-width: 100%;
698
+ }
699
+
700
+ /* JSON value in popup - preserve formatting */
701
+ .json-value-popup {
702
+ white-space: pre-wrap;
703
+ word-break: break-word;
704
+ overflow-wrap: break-word;
705
+ }
706
+
707
+ /* Cell Content Popup Dialog */
708
+ .cell-popup-overlay {
709
+ position: fixed;
710
+ top: 0;
711
+ left: 0;
712
+ right: 0;
713
+ bottom: 0;
714
+ background-color: rgba(0, 0, 0, 0.5);
715
+ z-index: 10000;
716
+ display: flex;
717
+ align-items: center;
718
+ justify-content: center;
719
+ padding: var(--space-4);
720
+ animation: fadeIn var(--transition-base);
721
+ }
722
+
723
+ [data-theme="dark"] .cell-popup-overlay {
724
+ background-color: rgba(0, 0, 0, 0.7);
725
+ }
726
+
727
+ @keyframes fadeIn {
728
+ from {
729
+ opacity: 0;
730
+ }
731
+ to {
732
+ opacity: 1;
733
+ }
734
+ }
735
+
736
+ .cell-popup-dialog {
737
+ background-color: var(--bg-base);
738
+ border-radius: var(--radius-lg);
739
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
740
+ max-width: 90vw;
741
+ max-height: 90vh;
742
+ width: 600px;
743
+ display: flex;
744
+ flex-direction: column;
745
+ animation: slideUp var(--transition-base);
746
+ border: 1px solid var(--border-subtle);
747
+ }
748
+
749
+ [data-theme="dark"] .cell-popup-dialog {
750
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
751
+ }
752
+
753
+ @keyframes slideUp {
754
+ from {
755
+ transform: translateY(20px);
756
+ opacity: 0;
757
+ }
758
+ to {
759
+ transform: translateY(0);
760
+ opacity: 1;
761
+ }
762
+ }
763
+
764
+ .cell-popup-header {
765
+ padding: var(--space-4) var(--space-6);
766
+ border-bottom: 1px solid var(--border-subtle);
767
+ display: flex;
768
+ align-items: center;
769
+ justify-content: space-between;
770
+ background-color: var(--bg-elevated);
771
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
772
+ }
773
+
774
+ .cell-popup-title {
775
+ font-size: var(--font-size-lg);
776
+ font-weight: var(--font-weight-semibold);
777
+ color: var(--text-primary);
778
+ margin: 0;
779
+ flex: 1;
780
+ }
781
+
782
+ .cell-popup-actions {
783
+ display: flex;
784
+ align-items: center;
785
+ gap: var(--space-2);
786
+ }
787
+
788
+ .cell-popup-timezone {
789
+ padding: var(--space-2) var(--space-3);
790
+ font-size: var(--font-size-sm);
791
+ font-family: inherit;
792
+ color: var(--text-primary);
793
+ background-color: var(--bg-base);
794
+ border: 1px solid var(--border-subtle);
795
+ border-radius: var(--radius-sm);
796
+ cursor: pointer;
797
+ transition: all var(--transition-fast);
798
+ min-width: 200px;
799
+ }
800
+
801
+ .cell-popup-timezone:hover {
802
+ border-color: var(--border-default);
803
+ background-color: var(--bg-hover);
804
+ }
805
+
806
+ .cell-popup-timezone:focus {
807
+ outline: none;
808
+ border-color: var(--color-primary);
809
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
810
+ }
811
+
812
+ .cell-popup-copy,
813
+ .cell-popup-close {
814
+ background: none;
815
+ border: none;
816
+ cursor: pointer;
817
+ padding: var(--space-2);
818
+ color: var(--text-secondary);
819
+ font-size: var(--font-size-xl);
820
+ line-height: 1;
821
+ border-radius: var(--radius-sm);
822
+ transition: all var(--transition-fast);
823
+ display: flex;
824
+ align-items: center;
825
+ justify-content: center;
826
+ width: 32px;
827
+ height: 32px;
828
+ }
829
+
830
+ .cell-popup-copy:hover,
831
+ .cell-popup-close:hover {
832
+ background-color: var(--bg-hover);
833
+ color: var(--text-primary);
834
+ }
835
+
836
+ .cell-popup-copy.copied {
837
+ color: var(--color-success);
838
+ background-color: var(--bg-hover);
839
+ }
840
+
841
+ .cell-popup-body {
842
+ padding: var(--space-6);
843
+ overflow-y: auto;
844
+ flex: 1;
845
+ min-height: 0;
846
+ }
847
+
848
+ .cell-popup-content {
849
+ font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
850
+ "Courier New", monospace;
851
+ font-size: var(--font-size-sm);
852
+ line-height: 1.6;
853
+ color: var(--text-primary);
854
+ white-space: pre-wrap;
855
+ word-break: break-word;
856
+ overflow-wrap: break-word;
857
+ background-color: var(--bg-elevated);
858
+ padding: var(--space-4);
859
+ border-radius: var(--radius-md);
860
+ border: 1px solid var(--border-subtle);
861
+ max-height: 60vh;
862
+ overflow-y: auto;
863
+ }
864
+
865
+ .cell-popup-content.null-content {
866
+ color: var(--text-tertiary);
867
+ font-style: italic;
868
+ font-family: inherit;
869
+ }
870
+
871
+ .cell-popup-content.datetime-value-popup {
872
+ font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
873
+ "Courier New", monospace;
874
+ line-height: 1.8;
875
+ white-space: pre-wrap;
876
+ }
877
+
608
878
  .column-selector {
609
879
  position: relative;
610
880
  }
@@ -632,6 +902,33 @@ tr:hover {
632
902
  color: var(--text-primary);
633
903
  border-bottom: 1px solid var(--border-subtle);
634
904
  background-color: var(--bg-elevated);
905
+ display: flex;
906
+ align-items: center;
907
+ justify-content: space-between;
908
+ gap: var(--space-3);
909
+ }
910
+
911
+ .column-menu-header-title {
912
+ flex: 1;
913
+ }
914
+
915
+ .column-select-all-button {
916
+ padding: var(--space-1) var(--space-3);
917
+ font-size: var(--font-size-xs);
918
+ font-weight: var(--font-weight-medium);
919
+ color: var(--color-primary);
920
+ background-color: transparent;
921
+ border: 1px solid var(--border-subtle);
922
+ border-radius: var(--radius-sm);
923
+ cursor: pointer;
924
+ transition: all var(--transition-fast);
925
+ white-space: nowrap;
926
+ }
927
+
928
+ .column-select-all-button:hover {
929
+ background-color: var(--bg-hover);
930
+ border-color: var(--color-primary);
931
+ color: var(--color-primary);
635
932
  }
636
933
 
637
934
  .column-menu-options {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pglens",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A simple PostgreSQL database viewer tool",
5
5
  "main": "src/server.js",
6
6
  "bin": {
Binary file
package/src/routes/api.js CHANGED
@@ -88,6 +88,147 @@ async function getPrimaryKeyColumn(pool, tableName) {
88
88
  }
89
89
  }
90
90
 
91
+ /**
92
+ * Get foreign key relationships for a table.
93
+ * Queries information_schema to get foreign key constraints and their references.
94
+ * @param {Pool} pool - Database connection pool
95
+ * @param {string} tableName - Name of the table
96
+ * @returns {Promise<Object>} Object mapping column names to their foreign key references { table, column }
97
+ */
98
+ async function getForeignKeyRelations(pool, tableName) {
99
+ try {
100
+ const fkQuery = `
101
+ SELECT
102
+ kcu.column_name,
103
+ ccu.table_name AS foreign_table_name,
104
+ ccu.column_name AS foreign_column_name
105
+ FROM information_schema.table_constraints AS tc
106
+ JOIN information_schema.key_column_usage AS kcu
107
+ ON tc.constraint_name = kcu.constraint_name
108
+ AND tc.table_schema = kcu.table_schema
109
+ JOIN information_schema.constraint_column_usage AS ccu
110
+ ON ccu.constraint_name = tc.constraint_name
111
+ AND ccu.table_schema = tc.table_schema
112
+ WHERE tc.constraint_type = 'FOREIGN KEY'
113
+ AND tc.table_name = $1
114
+ AND tc.table_schema = 'public';
115
+ `;
116
+ const result = await pool.query(fkQuery, [tableName]);
117
+ const foreignKeys = {};
118
+ result.rows.forEach(row => {
119
+ foreignKeys[row.column_name] = {
120
+ table: row.foreign_table_name,
121
+ column: row.foreign_column_name
122
+ };
123
+ });
124
+ return foreignKeys;
125
+ } catch (error) {
126
+ console.error('Error getting foreign key relations:', error);
127
+ return {};
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Get all primary key columns for a table.
133
+ * @param {Pool} pool - Database connection pool
134
+ * @param {string} tableName - Name of the table
135
+ * @returns {Promise<Set>} Set of primary key column names
136
+ */
137
+ async function getPrimaryKeyColumns(pool, tableName) {
138
+ try {
139
+ const pkQuery = `
140
+ SELECT kcu.column_name
141
+ FROM information_schema.table_constraints tc
142
+ JOIN information_schema.key_column_usage kcu
143
+ ON tc.constraint_name = kcu.constraint_name
144
+ AND tc.table_schema = kcu.table_schema
145
+ WHERE tc.constraint_type = 'PRIMARY KEY'
146
+ AND tc.table_name = $1
147
+ AND tc.table_schema = 'public';
148
+ `;
149
+ const result = await pool.query(pkQuery, [tableName]);
150
+ const pkColumns = new Set();
151
+ result.rows.forEach(row => {
152
+ pkColumns.add(row.column_name);
153
+ });
154
+ return pkColumns;
155
+ } catch (error) {
156
+ console.error('Error getting primary key columns:', error);
157
+ return new Set();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Get all unique constraint columns for a table.
163
+ * @param {Pool} pool - Database connection pool
164
+ * @param {string} tableName - Name of the table
165
+ * @returns {Promise<Set>} Set of unique constraint column names
166
+ */
167
+ async function getUniqueColumns(pool, tableName) {
168
+ try {
169
+ const uniqueQuery = `
170
+ SELECT kcu.column_name
171
+ FROM information_schema.table_constraints tc
172
+ JOIN information_schema.key_column_usage kcu
173
+ ON tc.constraint_name = kcu.constraint_name
174
+ AND tc.table_schema = kcu.table_schema
175
+ WHERE tc.constraint_type = 'UNIQUE'
176
+ AND tc.table_name = $1
177
+ AND tc.table_schema = 'public';
178
+ `;
179
+ const result = await pool.query(uniqueQuery, [tableName]);
180
+ const uniqueColumns = new Set();
181
+ result.rows.forEach(row => {
182
+ uniqueColumns.add(row.column_name);
183
+ });
184
+ return uniqueColumns;
185
+ } catch (error) {
186
+ console.error('Error getting unique columns:', error);
187
+ return new Set();
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Get column metadata (datatypes and key relationships) for a table.
193
+ * Queries information_schema.columns to get column names and their data types,
194
+ * and includes key relationship information (primary keys, foreign keys, unique constraints).
195
+ * @param {Pool} pool - Database connection pool
196
+ * @param {string} tableName - Name of the table
197
+ * @returns {Promise<Object>} Object mapping column names to metadata objects with dataType, isPrimaryKey, isForeignKey, foreignKeyRef, isUnique
198
+ */
199
+ async function getColumnMetadata(pool, tableName) {
200
+ try {
201
+ const metadataQuery = `
202
+ SELECT column_name, data_type
203
+ FROM information_schema.columns
204
+ WHERE table_schema = 'public'
205
+ AND table_name = $1
206
+ ORDER BY ordinal_position;
207
+ `;
208
+ const result = await pool.query(metadataQuery, [tableName]);
209
+
210
+ const primaryKeyColumns = await getPrimaryKeyColumns(pool, tableName);
211
+ const foreignKeyRelations = await getForeignKeyRelations(pool, tableName);
212
+ const uniqueColumns = await getUniqueColumns(pool, tableName);
213
+
214
+ const columns = {};
215
+ result.rows.forEach(row => {
216
+ const columnName = row.column_name;
217
+ columns[columnName] = {
218
+ dataType: row.data_type,
219
+ isPrimaryKey: primaryKeyColumns.has(columnName),
220
+ isForeignKey: !!foreignKeyRelations[columnName],
221
+ foreignKeyRef: foreignKeyRelations[columnName] || null,
222
+ isUnique: uniqueColumns.has(columnName)
223
+ };
224
+ });
225
+ return columns;
226
+ } catch (error) {
227
+ console.error('Error getting column metadata:', error);
228
+ return {};
229
+ }
230
+ }
231
+
91
232
  /**
92
233
  * GET /api/tables/:tableName
93
234
  *
@@ -110,7 +251,13 @@ async function getPrimaryKeyColumn(pool, tableName) {
110
251
  * limit: number,
111
252
  * isApproximate: boolean,
112
253
  * nextCursor: string|null,
113
- * hasPrimaryKey: boolean
254
+ * hasPrimaryKey: boolean,
255
+ * columns: Object - Map of column names to metadata objects with:
256
+ * - dataType: string
257
+ * - isPrimaryKey: boolean
258
+ * - isForeignKey: boolean
259
+ * - foreignKeyRef: { table: string, column: string } | null
260
+ * - isUnique: boolean
114
261
  * }
115
262
  */
116
263
  router.get('/tables/:tableName', async (req, res) => {
@@ -126,6 +273,7 @@ router.get('/tables/:tableName', async (req, res) => {
126
273
 
127
274
  const pool = getPool();
128
275
  const primaryKeyColumn = await getPrimaryKeyColumn(pool, tableName);
276
+ const columnMetadata = await getColumnMetadata(pool, tableName);
129
277
 
130
278
  const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
131
279
  const countResult = await pool.query(countQuery);
@@ -188,6 +336,7 @@ router.get('/tables/:tableName', async (req, res) => {
188
336
  isApproximate,
189
337
  nextCursor,
190
338
  hasPrimaryKey: !!primaryKeyColumn,
339
+ columns: columnMetadata,
191
340
  };
192
341
  res.json(responseData);
193
342
  } catch (error) {
package/pglens-1.0.0.tgz DELETED
Binary file