jupyterlab_tabular_data_viewer_extension 1.1.20

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/src/widget.ts ADDED
@@ -0,0 +1,787 @@
1
+ import { Widget } from '@lumino/widgets';
2
+ import { requestAPI } from './request';
3
+
4
+ /**
5
+ * Column metadata interface
6
+ */
7
+ interface IColumnMetadata {
8
+ name: string;
9
+ type: string;
10
+ }
11
+
12
+ /**
13
+ * Filter specification interface
14
+ */
15
+ interface IFilterSpec {
16
+ type: 'text' | 'number';
17
+ value: string;
18
+ operator?: string;
19
+ }
20
+
21
+ /**
22
+ * Parquet viewer widget
23
+ */
24
+ export class TabularDataViewer extends Widget {
25
+ private _filePath: string;
26
+ private _columns: IColumnMetadata[] = [];
27
+ private _data: any[] = [];
28
+ private _totalRows = 0;
29
+ private _unfilteredTotalRows = 0;
30
+ private _currentOffset = 0;
31
+ private _limit = 500;
32
+ private _loading = false;
33
+ private _hasMore = true;
34
+ private _filters: { [key: string]: IFilterSpec } = {};
35
+ private _sortBy: string | null = null;
36
+ private _sortOrder: 'asc' | 'desc' = 'asc';
37
+ private _fileSize = 0;
38
+ private _caseInsensitive = false;
39
+ private _useRegex = false;
40
+ private _contextMenuOpen = false;
41
+ private _columnWidths: Map<string, number> = new Map();
42
+ private _resizing: { columnName: string; startX: number; startWidth: number } | null = null;
43
+
44
+ private _tableContainer: HTMLDivElement;
45
+ private _table: HTMLTableElement;
46
+ private _thead: HTMLTableSectionElement;
47
+ private _tbody: HTMLTableSectionElement;
48
+ private _filterRow: HTMLTableRowElement;
49
+ private _headerRow: HTMLTableRowElement;
50
+ private _statusBar: HTMLDivElement;
51
+ private _statusLeft: HTMLDivElement;
52
+ private _statusRight: HTMLDivElement;
53
+ private _caseInsensitiveCheckbox: HTMLInputElement;
54
+ private _regexCheckbox: HTMLInputElement;
55
+ private _setLastContextMenuRow: (row: any) => void;
56
+ private _cleanupHighlight: (() => void) | null = null;
57
+ private _menuObserver: MutationObserver | null = null;
58
+
59
+ constructor(filePath: string, setLastContextMenuRow: (row: any) => void) {
60
+ super();
61
+ this._filePath = filePath;
62
+ this._setLastContextMenuRow = setLastContextMenuRow;
63
+ this.addClass('jp-TabularDataViewer');
64
+
65
+ // Create table container (scrollable)
66
+ this._tableContainer = document.createElement('div');
67
+ this._tableContainer.className = 'jp-TabularDataViewer-container';
68
+
69
+ // Create table
70
+ this._table = document.createElement('table');
71
+ this._table.className = 'jp-TabularDataViewer-table';
72
+
73
+ this._thead = document.createElement('thead');
74
+ this._thead.className = 'jp-TabularDataViewer-thead';
75
+
76
+ this._tbody = document.createElement('tbody');
77
+ this._tbody.className = 'jp-TabularDataViewer-tbody';
78
+
79
+ // Create filter row
80
+ this._filterRow = document.createElement('tr');
81
+ this._filterRow.className = 'jp-TabularDataViewer-filterRow';
82
+
83
+ // Create header row
84
+ this._headerRow = document.createElement('tr');
85
+ this._headerRow.className = 'jp-TabularDataViewer-headerRow';
86
+
87
+ this._thead.appendChild(this._filterRow);
88
+ this._thead.appendChild(this._headerRow);
89
+
90
+ this._table.appendChild(this._thead);
91
+ this._table.appendChild(this._tbody);
92
+ this._tableContainer.appendChild(this._table);
93
+
94
+ // Create status bar (outside scroll container)
95
+ this._statusBar = document.createElement('div');
96
+ this._statusBar.className = 'jp-TabularDataViewer-statusBar';
97
+
98
+ this._statusLeft = document.createElement('div');
99
+ this._statusLeft.className = 'jp-TabularDataViewer-statusLeft';
100
+
101
+ // Create middle section with case-insensitive checkbox
102
+ const statusMiddle = document.createElement('div');
103
+ statusMiddle.className = 'jp-TabularDataViewer-statusMiddle';
104
+
105
+ const checkboxLabel = document.createElement('label');
106
+ checkboxLabel.className = 'jp-TabularDataViewer-caseInsensitiveLabel';
107
+
108
+ this._caseInsensitiveCheckbox = document.createElement('input');
109
+ this._caseInsensitiveCheckbox.type = 'checkbox';
110
+ this._caseInsensitiveCheckbox.className = 'jp-TabularDataViewer-caseInsensitiveCheckbox';
111
+ this._caseInsensitiveCheckbox.checked = this._caseInsensitive;
112
+ this._caseInsensitiveCheckbox.addEventListener('change', () => {
113
+ this._caseInsensitive = this._caseInsensitiveCheckbox.checked;
114
+ this._loadData(true);
115
+ });
116
+
117
+ const checkboxText = document.createElement('span');
118
+ checkboxText.textContent = ' Case insensitive';
119
+
120
+ checkboxLabel.appendChild(this._caseInsensitiveCheckbox);
121
+ checkboxLabel.appendChild(checkboxText);
122
+ statusMiddle.appendChild(checkboxLabel);
123
+
124
+ // Create regex checkbox
125
+ const regexLabel = document.createElement('label');
126
+ regexLabel.className = 'jp-TabularDataViewer-regexLabel';
127
+
128
+ this._regexCheckbox = document.createElement('input');
129
+ this._regexCheckbox.type = 'checkbox';
130
+ this._regexCheckbox.className = 'jp-TabularDataViewer-regexCheckbox';
131
+ this._regexCheckbox.checked = this._useRegex;
132
+ this._regexCheckbox.addEventListener('change', () => {
133
+ this._useRegex = this._regexCheckbox.checked;
134
+ this._loadData(true);
135
+ });
136
+
137
+ const regexText = document.createElement('span');
138
+ regexText.textContent = ' Use regex';
139
+
140
+ regexLabel.appendChild(this._regexCheckbox);
141
+ regexLabel.appendChild(regexText);
142
+ statusMiddle.appendChild(regexLabel);
143
+
144
+ this._statusRight = document.createElement('div');
145
+ this._statusRight.className = 'jp-TabularDataViewer-statusRight';
146
+
147
+ this._statusBar.appendChild(this._statusLeft);
148
+ this._statusBar.appendChild(statusMiddle);
149
+ this._statusBar.appendChild(this._statusRight);
150
+
151
+ // Append table container and status bar directly to widget node
152
+ this.node.appendChild(this._tableContainer);
153
+ this.node.appendChild(this._statusBar);
154
+
155
+ // Set up scroll listener for progressive loading
156
+ this._tableContainer.addEventListener('scroll', () => {
157
+ this._onScroll();
158
+ });
159
+
160
+ // Remove context-active class when clicking anywhere or dismissing context menu
161
+ const removeHighlight = () => {
162
+ this._contextMenuOpen = false;
163
+ this._tbody.classList.remove('jp-TabularDataViewer-context-menu-open');
164
+ this._tbody.querySelectorAll('tr').forEach(r => {
165
+ r.classList.remove('jp-TabularDataViewer-row-context-active');
166
+ });
167
+
168
+ // Stop observing when highlight is removed
169
+ if (this._menuObserver) {
170
+ this._menuObserver.disconnect();
171
+ this._menuObserver = null;
172
+ }
173
+ };
174
+
175
+ // Store cleanup function for external access
176
+ this._cleanupHighlight = removeHighlight;
177
+
178
+ // Initialize
179
+ this._initialize();
180
+ }
181
+
182
+ /**
183
+ * Start observing for menu removal when context menu opens
184
+ */
185
+ private _startMenuObserver(): void {
186
+ // Stop any existing observer
187
+ if (this._menuObserver) {
188
+ this._menuObserver.disconnect();
189
+ }
190
+
191
+ // Create observer to watch for menu removal from DOM
192
+ this._menuObserver = new MutationObserver((mutations) => {
193
+ for (const mutation of mutations) {
194
+ if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
195
+ // Check if any removed node is a Lumino menu
196
+ for (const node of Array.from(mutation.removedNodes)) {
197
+ if (node instanceof HTMLElement &&
198
+ (node.classList.contains('lm-Menu') || node.classList.contains('p-Menu'))) {
199
+ // Menu was removed, clear highlight
200
+ if (this._cleanupHighlight) {
201
+ this._cleanupHighlight();
202
+ }
203
+ return;
204
+ }
205
+ }
206
+ }
207
+ }
208
+ });
209
+
210
+ // Observe document body for child removals
211
+ this._menuObserver.observe(document.body, {
212
+ childList: true,
213
+ subtree: false
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Get cleanup function to clear highlight (for external use)
219
+ */
220
+ public getCleanupHighlight(): () => void {
221
+ return this._cleanupHighlight || (() => {});
222
+ }
223
+
224
+ /**
225
+ * Initialize the viewer by loading metadata and initial data
226
+ */
227
+ private async _initialize(): Promise<void> {
228
+ try {
229
+ await this._loadMetadata();
230
+ await this._loadData(true);
231
+ } catch (error) {
232
+ this._showError(`Failed to load file: ${error}`);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Load file metadata (columns, types, row count)
238
+ */
239
+ private async _loadMetadata(): Promise<void> {
240
+ const response = await requestAPI<any>('metadata', {
241
+ method: 'POST',
242
+ body: JSON.stringify({ path: this._filePath })
243
+ });
244
+
245
+ this._columns = response.columns;
246
+ this._totalRows = response.totalRows;
247
+ this._unfilteredTotalRows = response.totalRows;
248
+ this._fileSize = response.fileSize || 0;
249
+
250
+ this._renderHeaders();
251
+ this._updateStatusBar();
252
+ }
253
+
254
+ /**
255
+ * Load data from server
256
+ */
257
+ private async _loadData(reset = false): Promise<void> {
258
+ if (this._loading) {
259
+ return;
260
+ }
261
+
262
+ this._loading = true;
263
+ this._updateStatusBar('Loading...');
264
+
265
+ try {
266
+ if (reset) {
267
+ this._currentOffset = 0;
268
+ this._data = [];
269
+ this._tbody.innerHTML = '';
270
+ }
271
+
272
+ const response = await requestAPI<any>('data', {
273
+ method: 'POST',
274
+ body: JSON.stringify({
275
+ path: this._filePath,
276
+ offset: this._currentOffset,
277
+ limit: this._limit,
278
+ filters: this._filters,
279
+ sortBy: this._sortBy,
280
+ sortOrder: this._sortOrder,
281
+ caseInsensitive: this._caseInsensitive,
282
+ useRegex: this._useRegex
283
+ })
284
+ });
285
+
286
+ this._data = this._data.concat(response.data);
287
+ this._hasMore = response.hasMore;
288
+ this._currentOffset += response.data.length;
289
+
290
+ if (reset) {
291
+ this._totalRows = response.totalRows;
292
+ }
293
+
294
+ this._renderData(response.data);
295
+ this._updateStatusBar();
296
+ } catch (error) {
297
+ this._showError(`Failed to load data: ${error}`);
298
+ } finally {
299
+ this._loading = false;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Render table headers with filter inputs
305
+ */
306
+ private _renderHeaders(): void {
307
+ this._filterRow.innerHTML = '';
308
+ this._headerRow.innerHTML = '';
309
+
310
+ this._columns.forEach(col => {
311
+ // Create filter cell
312
+ const filterCell = document.createElement('th');
313
+ filterCell.className = 'jp-TabularDataViewer-filterCell';
314
+
315
+ const filterInput = document.createElement('input');
316
+ filterInput.type = 'text';
317
+ filterInput.className = 'jp-TabularDataViewer-filterInput';
318
+ filterInput.placeholder = this._getFilterPlaceholder(col.type);
319
+ filterInput.dataset.columnName = col.name;
320
+ filterInput.dataset.columnType = col.type;
321
+
322
+ filterInput.addEventListener('keyup', (e: KeyboardEvent) => {
323
+ if (e.key === 'Enter') {
324
+ this._applyFilter(col.name, filterInput.value, col.type);
325
+ }
326
+ });
327
+
328
+ filterCell.appendChild(filterInput);
329
+ this._filterRow.appendChild(filterCell);
330
+
331
+ // Create header cell with column name and type
332
+ const headerCell = document.createElement('th');
333
+ headerCell.className = 'jp-TabularDataViewer-headerCell';
334
+ headerCell.style.cursor = 'pointer';
335
+ headerCell.dataset.columnName = col.name;
336
+
337
+ // Add click handler for sorting
338
+ headerCell.addEventListener('click', () => {
339
+ this._toggleSort(col.name);
340
+ });
341
+
342
+ const headerContent = document.createElement('div');
343
+ headerContent.className = 'jp-TabularDataViewer-headerContent';
344
+
345
+ const nameSpan = document.createElement('div');
346
+ nameSpan.className = 'jp-TabularDataViewer-columnName';
347
+ nameSpan.textContent = col.name;
348
+
349
+ const typeSpan = document.createElement('div');
350
+ typeSpan.className = 'jp-TabularDataViewer-columnType';
351
+ typeSpan.textContent = this._simplifyType(col.type);
352
+
353
+ const sortIndicator = document.createElement('span');
354
+ sortIndicator.className = 'jp-TabularDataViewer-sortIndicator';
355
+ sortIndicator.textContent = '';
356
+
357
+ headerContent.appendChild(nameSpan);
358
+ headerContent.appendChild(typeSpan);
359
+ headerCell.appendChild(headerContent);
360
+ headerCell.appendChild(sortIndicator);
361
+
362
+ // Add resize handle
363
+ const resizeHandle = document.createElement('div');
364
+ resizeHandle.className = 'jp-TabularDataViewer-resizeHandle';
365
+ resizeHandle.addEventListener('mousedown', (e: MouseEvent) => {
366
+ e.preventDefault();
367
+ e.stopPropagation();
368
+ this._startResize(col.name, e.clientX, headerCell);
369
+ });
370
+ // Prevent click events from triggering sort
371
+ resizeHandle.addEventListener('click', (e: MouseEvent) => {
372
+ e.preventDefault();
373
+ e.stopPropagation();
374
+ });
375
+ headerCell.appendChild(resizeHandle);
376
+
377
+ // Set width - use stored width or default to 200px
378
+ const columnWidth = this._columnWidths.get(col.name) || 200;
379
+ if (!this._columnWidths.has(col.name)) {
380
+ this._columnWidths.set(col.name, columnWidth);
381
+ }
382
+ headerCell.style.width = `${columnWidth}px`;
383
+ filterCell.style.width = `${columnWidth}px`;
384
+
385
+ this._headerRow.appendChild(headerCell);
386
+ });
387
+
388
+ // Set table width to sum of all column widths
389
+ const totalWidth = Array.from(this._columnWidths.values()).reduce((sum, w) => sum + w, 0);
390
+ this._table.style.width = `${totalWidth}px`;
391
+ }
392
+
393
+ /**
394
+ * Render data rows
395
+ */
396
+ private _renderData(rows: any[]): void {
397
+ rows.forEach(row => {
398
+ const tr = document.createElement('tr');
399
+ tr.className = 'jp-TabularDataViewer-row';
400
+
401
+ this._columns.forEach(col => {
402
+ const td = document.createElement('td');
403
+ td.className = 'jp-TabularDataViewer-cell';
404
+ const value = row[col.name];
405
+ td.textContent = value !== null && value !== undefined ? String(value) : '';
406
+ tr.appendChild(td);
407
+ });
408
+
409
+ // Remove context-active class when hovering over any row (only if context menu not open)
410
+ tr.addEventListener('mouseenter', () => {
411
+ // Don't clear highlight while context menu is open
412
+ if (!this._contextMenuOpen) {
413
+ this._tbody.querySelectorAll('tr').forEach(r => {
414
+ r.classList.remove('jp-TabularDataViewer-row-context-active');
415
+ });
416
+ }
417
+ });
418
+
419
+ // Add right-click handler to store row data and maintain hover styling
420
+ tr.addEventListener('contextmenu', (e) => {
421
+ // Mark context menu as open
422
+ this._contextMenuOpen = true;
423
+
424
+ // Add class to tbody to disable hover on other rows
425
+ this._tbody.classList.add('jp-TabularDataViewer-context-menu-open');
426
+
427
+ // Remove context-active class from all rows
428
+ this._tbody.querySelectorAll('tr').forEach(r => {
429
+ r.classList.remove('jp-TabularDataViewer-row-context-active');
430
+ });
431
+
432
+ // Add context-active class to keep hover styling visible
433
+ tr.classList.add('jp-TabularDataViewer-row-context-active');
434
+
435
+ // Store row data for context menu
436
+ this._setLastContextMenuRow(row);
437
+
438
+ // Start observing for menu removal
439
+ this._startMenuObserver();
440
+ });
441
+
442
+ this._tbody.appendChild(tr);
443
+ });
444
+ }
445
+
446
+ /**
447
+ * Start column resize
448
+ */
449
+ private _startResize(columnName: string, startX: number, headerCell: HTMLElement): void {
450
+ this._resizing = {
451
+ columnName,
452
+ startX,
453
+ startWidth: headerCell.offsetWidth
454
+ };
455
+
456
+ // Add global mouse event listeners
457
+ document.addEventListener('mousemove', this._doResize);
458
+ document.addEventListener('mouseup', this._stopResize);
459
+
460
+ // Prevent text selection during resize
461
+ document.body.style.userSelect = 'none';
462
+ document.body.style.cursor = 'col-resize';
463
+ }
464
+
465
+ /**
466
+ * Handle column resize drag
467
+ */
468
+ private _doResize = (e: MouseEvent): void => {
469
+ if (!this._resizing) {
470
+ return;
471
+ }
472
+
473
+ const deltaX = e.clientX - this._resizing.startX;
474
+ const newWidth = Math.max(80, this._resizing.startWidth + deltaX); // Minimum width of 80px
475
+
476
+ // Store the new width
477
+ this._columnWidths.set(this._resizing.columnName, newWidth);
478
+
479
+ // Apply the new width to the column header and filter cell only
480
+ // With table-layout: fixed, this automatically applies to all cells in the column
481
+ const columnIndex = this._columns.findIndex(col => col.name === this._resizing!.columnName);
482
+ if (columnIndex !== -1) {
483
+ const headerCell = this._headerRow.children[columnIndex] as HTMLElement;
484
+ const filterCell = this._filterRow.children[columnIndex] as HTMLElement;
485
+
486
+ if (headerCell) {
487
+ headerCell.style.width = `${newWidth}px`;
488
+ }
489
+ if (filterCell) {
490
+ filterCell.style.width = `${newWidth}px`;
491
+ }
492
+
493
+ // Update table width to sum of all column widths
494
+ const totalWidth = Array.from(this._columnWidths.values()).reduce((sum, w) => sum + w, 0);
495
+ this._table.style.width = `${totalWidth}px`;
496
+ }
497
+ };
498
+
499
+ /**
500
+ * Stop column resize
501
+ */
502
+ private _stopResize = (): void => {
503
+ if (!this._resizing) {
504
+ return;
505
+ }
506
+
507
+ this._resizing = null;
508
+
509
+ // Remove global mouse event listeners
510
+ document.removeEventListener('mousemove', this._doResize);
511
+ document.removeEventListener('mouseup', this._stopResize);
512
+
513
+ // Restore user selection and cursor
514
+ document.body.style.userSelect = '';
515
+ document.body.style.cursor = '';
516
+ };
517
+
518
+ /**
519
+ * Apply filter to a column
520
+ */
521
+ private _applyFilter(columnName: string, value: string, columnType: string): void {
522
+ if (!value.trim()) {
523
+ // Remove filter if empty
524
+ delete this._filters[columnName];
525
+ } else {
526
+ const isNumeric = this._isNumericType(columnType);
527
+
528
+ if (isNumeric) {
529
+ // Parse numerical filter with operator
530
+ const match = value.match(/^([><=]+)?\s*(.+)$/);
531
+ if (match) {
532
+ const operator = match[1] || '=';
533
+ const numValue = match[2].trim();
534
+
535
+ this._filters[columnName] = {
536
+ type: 'number',
537
+ value: numValue,
538
+ operator: operator
539
+ };
540
+ }
541
+ } else {
542
+ // Text filter
543
+ this._filters[columnName] = {
544
+ type: 'text',
545
+ value: value
546
+ };
547
+ }
548
+ }
549
+
550
+ // Reload data with filters
551
+ this._loadData(true);
552
+ }
553
+
554
+ /**
555
+ * Simplify type names for display
556
+ */
557
+ private _simplifyType(type: string): string {
558
+ const lowerType = type.toLowerCase();
559
+
560
+ // Date types
561
+ if (lowerType.includes('date32') || lowerType.includes('date64')) {
562
+ return 'date';
563
+ }
564
+
565
+ // Timestamp/datetime types
566
+ if (lowerType.includes('timestamp')) {
567
+ return 'datetime';
568
+ }
569
+
570
+ // Integer types
571
+ if (lowerType.match(/^u?int(8|16|32|64)$/)) {
572
+ return 'int';
573
+ }
574
+
575
+ // Float types
576
+ if (lowerType.match(/^(float|double)(16|32|64)?$/)) {
577
+ return 'float';
578
+ }
579
+
580
+ // Decimal types
581
+ if (lowerType.includes('decimal')) {
582
+ return 'decimal';
583
+ }
584
+
585
+ // Boolean
586
+ if (lowerType === 'bool') {
587
+ return 'boolean';
588
+ }
589
+
590
+ // String types
591
+ if (lowerType === 'string' || lowerType === 'utf8' || lowerType === 'large_string' || lowerType === 'large_utf8') {
592
+ return 'string';
593
+ }
594
+
595
+ // Binary types
596
+ if (lowerType === 'binary' || lowerType === 'large_binary') {
597
+ return 'binary';
598
+ }
599
+
600
+ // List types
601
+ if (lowerType.startsWith('list')) {
602
+ return 'list';
603
+ }
604
+
605
+ // Struct types
606
+ if (lowerType.startsWith('struct')) {
607
+ return 'struct';
608
+ }
609
+
610
+ // Default: return as-is
611
+ return type;
612
+ }
613
+
614
+ /**
615
+ * Check if column type is numeric
616
+ */
617
+ private _isNumericType(type: string): boolean {
618
+ const numericTypes = ['int', 'float', 'double', 'decimal', 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64'];
619
+ return numericTypes.some(t => type.toLowerCase().includes(t));
620
+ }
621
+
622
+ /**
623
+ * Get filter placeholder based on column type
624
+ */
625
+ private _getFilterPlaceholder(type: string): string {
626
+ if (this._isNumericType(type)) {
627
+ return '=, >, <, >=, <=';
628
+ }
629
+ return 'text or regex...';
630
+ }
631
+
632
+ /**
633
+ * Handle scroll event for progressive loading
634
+ */
635
+ private _onScroll(): void {
636
+ const container = this._tableContainer;
637
+ const scrollPosition = container.scrollTop + container.clientHeight;
638
+ const scrollHeight = container.scrollHeight;
639
+
640
+ // Load more when scrolled to within 200px of bottom
641
+ if (scrollPosition >= scrollHeight - 200 && this._hasMore && !this._loading) {
642
+ this._loadData(false);
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Toggle sort on a column
648
+ */
649
+ private _toggleSort(columnName: string): void {
650
+ if (this._sortBy === columnName) {
651
+ // Cycle through: asc -> desc -> off
652
+ if (this._sortOrder === 'asc') {
653
+ this._sortOrder = 'desc';
654
+ } else if (this._sortOrder === 'desc') {
655
+ // Turn off sorting
656
+ this._sortBy = null;
657
+ this._sortOrder = 'asc';
658
+ }
659
+ } else {
660
+ // Sort by new column (ascending)
661
+ this._sortBy = columnName;
662
+ this._sortOrder = 'asc';
663
+ }
664
+
665
+ this._updateSortIndicators();
666
+ this._loadData(true);
667
+ }
668
+
669
+ /**
670
+ * Update sort indicators in column headers
671
+ */
672
+ private _updateSortIndicators(): void {
673
+ const headers = this._headerRow.querySelectorAll('th');
674
+ headers.forEach(header => {
675
+ const columnName = header.dataset.columnName;
676
+ const indicator = header.querySelector('.jp-TabularDataViewer-sortIndicator') as HTMLElement;
677
+
678
+ if (columnName === this._sortBy) {
679
+ indicator.textContent = this._sortOrder === 'asc' ? ' ▲' : ' ▼';
680
+ } else {
681
+ indicator.textContent = '';
682
+ }
683
+ });
684
+ }
685
+
686
+ /**
687
+ * Clear all filters
688
+ */
689
+ private _clearFilters(): void {
690
+ this._filters = {};
691
+
692
+ // Clear filter inputs
693
+ const filterInputs = this._filterRow.querySelectorAll('input');
694
+ filterInputs.forEach(input => {
695
+ (input as HTMLInputElement).value = '';
696
+ });
697
+
698
+ this._loadData(true);
699
+ }
700
+
701
+ /**
702
+ * Format file size to human-readable string
703
+ */
704
+ private _formatFileSize(bytes: number): string {
705
+ if (bytes === 0) return '0 B';
706
+ const k = 1024;
707
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
708
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
709
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
710
+ }
711
+
712
+ /**
713
+ * Update status bar
714
+ */
715
+ private _updateStatusBar(message?: string): void {
716
+ if (message) {
717
+ this._statusLeft.textContent = '';
718
+ this._statusRight.textContent = message;
719
+ } else {
720
+ // Left side: file stats (always show unfiltered total)
721
+ const numColumns = this._columns.length;
722
+ const fileSize = this._formatFileSize(this._fileSize);
723
+ this._statusLeft.textContent = `${numColumns} columns • ${this._unfilteredTotalRows} rows • ${fileSize}`;
724
+
725
+ // Right side: showing info and clear filters link
726
+ const filterCount = Object.keys(this._filters).length;
727
+ let rightText = `Showing ${this._data.length} of ${this._totalRows} rows`;
728
+
729
+ if (filterCount > 0) {
730
+ rightText += ` (${filterCount} filter${filterCount > 1 ? 's' : ''} active)`;
731
+ }
732
+
733
+ this._statusRight.innerHTML = rightText;
734
+
735
+ if (filterCount > 0) {
736
+ const clearLink = document.createElement('a');
737
+ clearLink.href = '#';
738
+ clearLink.className = 'jp-TabularDataViewer-clearFilters';
739
+ clearLink.textContent = 'Clear filters';
740
+ clearLink.addEventListener('click', (e) => {
741
+ e.preventDefault();
742
+ this._clearFilters();
743
+ });
744
+
745
+ this._statusRight.appendChild(document.createTextNode(' • '));
746
+ this._statusRight.appendChild(clearLink);
747
+ }
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Show error message
753
+ */
754
+ private _showError(message: string): void {
755
+ this._tbody.innerHTML = '';
756
+ const tr = document.createElement('tr');
757
+ const td = document.createElement('td');
758
+ td.colSpan = this._columns.length || 1;
759
+ td.className = 'jp-TabularDataViewer-error';
760
+ td.textContent = message;
761
+ tr.appendChild(td);
762
+ this._tbody.appendChild(tr);
763
+ }
764
+
765
+ /**
766
+ * Dispose of the widget
767
+ */
768
+ dispose(): void {
769
+ this._tableContainer.removeEventListener('scroll', this._onScroll);
770
+
771
+ // Clean up menu observer
772
+ if (this._menuObserver) {
773
+ this._menuObserver.disconnect();
774
+ this._menuObserver = null;
775
+ }
776
+
777
+ // Clean up resize event listeners if still active
778
+ if (this._resizing) {
779
+ document.removeEventListener('mousemove', this._doResize);
780
+ document.removeEventListener('mouseup', this._stopResize);
781
+ document.body.style.userSelect = '';
782
+ document.body.style.cursor = '';
783
+ }
784
+
785
+ super.dispose();
786
+ }
787
+ }