jupyterlab_tabular_data_viewer_extension 1.6.2 → 1.6.10

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/README.md CHANGED
@@ -31,7 +31,7 @@ View and browse Parquet, Excel, CSV, and TSV files directly in JupyterLab. Doubl
31
31
 
32
32
  ![Copy Row as JSON](.resources/screenshot-copy-json.png)
33
33
 
34
- **Download filtered data:** Right-click on the viewer to download filtered and sorted data in your choice of format - original, Excel, or CSV.
34
+ **Export:** Click the **Export** link in the status bar (or right-click on the viewer) to export the current view in your choice of format - original, Excel (.xlsx), CSV, Parquet (.parquet), or JSONL (.jsonl). When filters are active, the export popup notes that only filtered rows will be exported.
35
35
 
36
36
  ![Download Filtered Data](.resources/screenshot-download-filtered.png)
37
37
 
@@ -40,7 +40,7 @@ View and browse Parquet, Excel, CSV, and TSV files directly in JupyterLab. Doubl
40
40
  **Supported File Formats:**
41
41
 
42
42
  - **Parquet files** (.parquet) - Full support with efficient columnar data reading
43
- - **Excel files** (.xlsx) - Reads first worksheet only (the other sheets are just jealous). Excel files must be simple tabular data without merged cells, complex formulas, or advanced formatting. Files with these features may not display correctly or fail to load
43
+ - **Excel files** (.xlsx) - Multi-sheet support: a sheet bar appears at the bottom for workbooks with more than one sheet, and switching sheets resets all filters/sort/selection (each sheet behaves like a separate file). Mixed-type columns (e.g. integers and strings in the same column) are handled via per-column cascading type inference rather than failing to open. Excel files must still be simple tabular data without merged cells, complex formulas, or advanced formatting
44
44
  - **CSV files** (.csv) - Comma-separated values with UTF-8 encoding (fallback to latin1)
45
45
  - **TSV files** (.tsv) - Tab-separated values with UTF-8 encoding (fallback to latin1)
46
46
 
@@ -69,7 +69,7 @@ View and browse Parquet, Excel, CSV, and TSV files directly in JupyterLab. Doubl
69
69
  **Additional features:**
70
70
 
71
71
  - Column statistics modal - View comprehensive statistics including data type, row counts, null values, unique counts, and type-specific metrics (numeric: min/max/mean/median/std dev/outliers; string: most common value/length stats; date: earliest/latest dates). Includes scrollable list of unique values sorted by frequency with counts and percentages. Copy statistics as JSON with one click
72
- - Download filtered data - Right-click on viewer to download data with current filters and sorting applied. Choose between original format, Excel (.xlsx), or CSV. Downloads preserve all active filters, sort order, and selected rows
72
+ - Export - **Export** link in the status bar (or right-click on the viewer) opens a format picker: original, Excel (.xlsx), CSV, Parquet (.parquet), or JSONL (.jsonl). Exports preserve active filters and sort order. Filename includes the slugified sheet name for multi-sheet Excel and a `_filtered` suffix when filters are applied
73
73
  - Right-click context menu on rows to copy data as JSON
74
74
  - Refresh view - Right-click on viewer and select "Refresh View" to reload data from file while preserving scroll position, filters, and sorting
75
75
  - Cell text truncation - Configurable maximum character limit for cell display (default: 100 characters). Text longer than limit shows "..." ellipsis. Set to 0 for unlimited display
@@ -107,4 +107,3 @@ Configure extension behavior through JupyterLab Settings:
107
107
  - **Maximum Unique Values** - Default: 100. Maximum number of unique values to display in filter dialog and column statistics. Set to 0 for no limit
108
108
 
109
109
  When a file type is disabled, files open with JupyterLab's default handler instead.
110
-
package/lib/index.js CHANGED
@@ -101,8 +101,8 @@ const plugin = {
101
101
  // Download command
102
102
  const downloadCommand = 'tabular-data-viewer:download';
103
103
  commands.addCommand(downloadCommand, {
104
- label: 'Download Filtered Data',
105
- caption: 'Download filtered and sorted data in various formats',
104
+ label: 'Export',
105
+ caption: 'Export the current view in various formats',
106
106
  isEnabled: () => {
107
107
  return activeWidget !== null;
108
108
  },
package/lib/modal.d.ts CHANGED
@@ -83,7 +83,8 @@ export declare class FilterModal extends Widget {
83
83
  */
84
84
  export declare class DownloadModal extends Widget {
85
85
  private _onDownload;
86
- constructor(onDownload: (format: string) => void);
86
+ private _hasFilters;
87
+ constructor(onDownload: (format: string) => void, hasFilters?: boolean);
87
88
  /**
88
89
  * Render the modal content
89
90
  */
package/lib/modal.js CHANGED
@@ -416,9 +416,10 @@ export class FilterModal extends Widget {
416
416
  * Modal dialog widget for selecting download format
417
417
  */
418
418
  export class DownloadModal extends Widget {
419
- constructor(onDownload) {
419
+ constructor(onDownload, hasFilters = false) {
420
420
  super();
421
421
  this._onDownload = onDownload;
422
+ this._hasFilters = hasFilters;
422
423
  this.addClass('jp-FilterModal');
423
424
  this._render();
424
425
  this._setupEventListeners();
@@ -433,7 +434,7 @@ export class DownloadModal extends Widget {
433
434
  const header = document.createElement('div');
434
435
  header.className = 'jp-FilterModal-header';
435
436
  const title = document.createElement('h3');
436
- title.textContent = 'Download Filtered Data';
437
+ title.textContent = 'Export';
437
438
  const closeBtn = document.createElement('button');
438
439
  closeBtn.className = 'jp-FilterModal-close';
439
440
  closeBtn.textContent = '×';
@@ -441,6 +442,14 @@ export class DownloadModal extends Widget {
441
442
  header.appendChild(title);
442
443
  header.appendChild(closeBtn);
443
444
  content.appendChild(header);
445
+ // Filter notice (only when filters are active)
446
+ if (this._hasFilters) {
447
+ const notice = document.createElement('div');
448
+ notice.className = 'jp-FilterModal-notice';
449
+ notice.textContent =
450
+ 'Filters are active - export will include only the filtered rows.';
451
+ content.appendChild(notice);
452
+ }
444
453
  // Format buttons
445
454
  const buttonsDiv = document.createElement('div');
446
455
  buttonsDiv.className = 'jp-FilterModal-buttons';
@@ -450,6 +459,10 @@ export class DownloadModal extends Widget {
450
459
  buttonsDiv.appendChild(excelBtn);
451
460
  const csvBtn = this._createFormatButton('Download as CSV', 'csv');
452
461
  buttonsDiv.appendChild(csvBtn);
462
+ const parquetBtn = this._createFormatButton('Download as Parquet (.parquet)', 'parquet');
463
+ buttonsDiv.appendChild(parquetBtn);
464
+ const jsonlBtn = this._createFormatButton('Download as JSONL (.jsonl)', 'jsonl');
465
+ buttonsDiv.appendChild(jsonlBtn);
453
466
  content.appendChild(buttonsDiv);
454
467
  // Cancel button
455
468
  const cancelDiv = document.createElement('div');
package/lib/request.d.ts CHANGED
@@ -42,9 +42,10 @@ export interface IColumnStats {
42
42
  *
43
43
  * @param filePath Path to the data file
44
44
  * @param columnName Name of column to analyze
45
+ * @param sheet Active sheet name (optional, multi-sheet Excel only)
45
46
  * @returns Column statistics
46
47
  */
47
- export declare function fetchColumnStats(filePath: string, columnName: string): Promise<IColumnStats>;
48
+ export declare function fetchColumnStats(filePath: string, columnName: string, sheet?: string | null): Promise<IColumnStats>;
48
49
  /**
49
50
  * Unique values interface
50
51
  */
@@ -59,6 +60,8 @@ export interface IUniqueValues {
59
60
  *
60
61
  * @param filePath Path to the data file
61
62
  * @param columnName Name of column to get unique values for
63
+ * @param limit Maximum number of unique values to return
64
+ * @param sheet Active sheet name (optional, multi-sheet Excel only)
62
65
  * @returns Unique values
63
66
  */
64
- export declare function fetchUniqueValues(filePath: string, columnName: string, limit?: number): Promise<IUniqueValues>;
67
+ export declare function fetchUniqueValues(filePath: string, columnName: string, limit?: number, sheet?: string | null): Promise<IUniqueValues>;
package/lib/request.js CHANGED
@@ -38,15 +38,20 @@ export async function requestAPI(endPoint = '', init = {}) {
38
38
  *
39
39
  * @param filePath Path to the data file
40
40
  * @param columnName Name of column to analyze
41
+ * @param sheet Active sheet name (optional, multi-sheet Excel only)
41
42
  * @returns Column statistics
42
43
  */
43
- export async function fetchColumnStats(filePath, columnName) {
44
+ export async function fetchColumnStats(filePath, columnName, sheet) {
45
+ const body = {
46
+ path: filePath,
47
+ columnName: columnName
48
+ };
49
+ if (sheet) {
50
+ body.sheet = sheet;
51
+ }
44
52
  return requestAPI('column-stats', {
45
53
  method: 'POST',
46
- body: JSON.stringify({
47
- path: filePath,
48
- columnName: columnName
49
- })
54
+ body: JSON.stringify(body)
50
55
  });
51
56
  }
52
57
  /**
@@ -54,15 +59,21 @@ export async function fetchColumnStats(filePath, columnName) {
54
59
  *
55
60
  * @param filePath Path to the data file
56
61
  * @param columnName Name of column to get unique values for
62
+ * @param limit Maximum number of unique values to return
63
+ * @param sheet Active sheet name (optional, multi-sheet Excel only)
57
64
  * @returns Unique values
58
65
  */
59
- export async function fetchUniqueValues(filePath, columnName, limit = 100) {
66
+ export async function fetchUniqueValues(filePath, columnName, limit = 100, sheet) {
67
+ const body = {
68
+ path: filePath,
69
+ columnName: columnName,
70
+ limit: limit
71
+ };
72
+ if (sheet) {
73
+ body.sheet = sheet;
74
+ }
60
75
  return requestAPI('unique-values', {
61
76
  method: 'POST',
62
- body: JSON.stringify({
63
- path: filePath,
64
- columnName: columnName,
65
- limit: limit
66
- })
77
+ body: JSON.stringify(body)
67
78
  });
68
79
  }
package/lib/widget.d.ts CHANGED
@@ -38,6 +38,9 @@ export declare class TabularDataViewer extends Widget {
38
38
  private _maxCellCharacters;
39
39
  private _maxUniqueValues;
40
40
  private _selectedRow;
41
+ private _sheets;
42
+ private _activeSheet;
43
+ private _sheetBar;
41
44
  constructor(filePath: string, setLastContextMenuRow: (row: any) => void, maxCellCharacters?: number, maxUniqueValues?: number);
42
45
  /**
43
46
  * Start observing for menu removal when context menu opens
@@ -61,7 +64,22 @@ export declare class TabularDataViewer extends Widget {
61
64
  */
62
65
  private _initialize;
63
66
  /**
64
- * Load file metadata (columns, types, row count)
67
+ * Render the sheet bar. Visible only when the file has more than one sheet.
68
+ * Each sheet is a button; the active one carries `jp-mod-active`.
69
+ */
70
+ private _renderSheetBar;
71
+ /**
72
+ * Switch to a different sheet. Resets all user state and reloads metadata
73
+ * + data so the experience matches "opening a new file".
74
+ */
75
+ private _switchSheet;
76
+ /**
77
+ * Reset all user-applied state (filters, sort, selection, scroll position,
78
+ * column widths, paging cursor). Used when switching sheets.
79
+ */
80
+ private _resetState;
81
+ /**
82
+ * Load file metadata (columns, types, row count, sheets)
65
83
  */
66
84
  private _loadMetadata;
67
85
  /**
package/lib/widget.js CHANGED
@@ -31,6 +31,8 @@ export class TabularDataViewer extends Widget {
31
31
  this._maxCellCharacters = 100;
32
32
  this._maxUniqueValues = 100;
33
33
  this._selectedRow = null;
34
+ this._sheets = [];
35
+ this._activeSheet = null;
34
36
  /**
35
37
  * Handle column resize drag
36
38
  */
@@ -147,8 +149,13 @@ export class TabularDataViewer extends Widget {
147
149
  this._statusBar.appendChild(this._statusLeft);
148
150
  this._statusBar.appendChild(statusMiddle);
149
151
  this._statusBar.appendChild(this._statusRight);
150
- // Append table container and status bar directly to widget node
152
+ // Sheet bar (only shown when multi-sheet Excel; sits above status bar)
153
+ this._sheetBar = document.createElement('div');
154
+ this._sheetBar.className = 'jp-TabularDataViewer-sheetBar';
155
+ this._sheetBar.style.display = 'none';
156
+ // Append table container, sheet bar, status bar directly to widget node
151
157
  this.node.appendChild(this._tableContainer);
158
+ this.node.appendChild(this._sheetBar);
152
159
  this.node.appendChild(this._statusBar);
153
160
  // Set up scroll listener for progressive loading
154
161
  this._tableContainer.addEventListener('scroll', () => {
@@ -239,7 +246,8 @@ export class TabularDataViewer extends Widget {
239
246
  * Show download modal dialog
240
247
  */
241
248
  showDownloadModal() {
242
- const modal = new DownloadModal((format) => this.downloadFilteredData(format));
249
+ const hasFilters = Object.keys(this._filters).length > 0;
250
+ const modal = new DownloadModal((format) => this.downloadFilteredData(format), hasFilters);
243
251
  modal.show();
244
252
  }
245
253
  /**
@@ -255,17 +263,86 @@ export class TabularDataViewer extends Widget {
255
263
  }
256
264
  }
257
265
  /**
258
- * Load file metadata (columns, types, row count)
266
+ * Render the sheet bar. Visible only when the file has more than one sheet.
267
+ * Each sheet is a button; the active one carries `jp-mod-active`.
268
+ */
269
+ _renderSheetBar() {
270
+ this._sheetBar.innerHTML = '';
271
+ if (this._sheets.length <= 1) {
272
+ this._sheetBar.style.display = 'none';
273
+ return;
274
+ }
275
+ this._sheetBar.style.display = '';
276
+ for (const name of this._sheets) {
277
+ const tab = document.createElement('button');
278
+ tab.className = 'jp-TabularDataViewer-sheetTab';
279
+ if (name === this._activeSheet) {
280
+ tab.classList.add('jp-mod-active');
281
+ }
282
+ tab.textContent = name;
283
+ tab.title = name;
284
+ tab.addEventListener('click', () => this._switchSheet(name));
285
+ this._sheetBar.appendChild(tab);
286
+ }
287
+ }
288
+ /**
289
+ * Switch to a different sheet. Resets all user state and reloads metadata
290
+ * + data so the experience matches "opening a new file".
291
+ */
292
+ _switchSheet(name) {
293
+ if (name === this._activeSheet) {
294
+ return;
295
+ }
296
+ this._activeSheet = name;
297
+ this._resetState();
298
+ this._renderSheetBar();
299
+ this._initialize();
300
+ }
301
+ /**
302
+ * Reset all user-applied state (filters, sort, selection, scroll position,
303
+ * column widths, paging cursor). Used when switching sheets.
304
+ */
305
+ _resetState() {
306
+ this._filters = {};
307
+ this._sortBy = null;
308
+ this._sortOrder = 'asc';
309
+ this._caseInsensitive = false;
310
+ this._useRegex = false;
311
+ this._caseInsensitiveCheckbox.checked = false;
312
+ this._regexCheckbox.checked = false;
313
+ this._selectedRow = null;
314
+ this._currentOffset = 0;
315
+ this._data = [];
316
+ this._hasMore = true;
317
+ this._totalRows = 0;
318
+ this._unfilteredTotalRows = 0;
319
+ this._columnWidths.clear();
320
+ this._tbody.innerHTML = '';
321
+ this._tableContainer.scrollTop = 0;
322
+ this._tableContainer.scrollLeft = 0;
323
+ }
324
+ /**
325
+ * Load file metadata (columns, types, row count, sheets)
259
326
  */
260
327
  async _loadMetadata() {
328
+ const body = { path: this._filePath };
329
+ if (this._activeSheet) {
330
+ body.sheet = this._activeSheet;
331
+ }
261
332
  const response = await requestAPI('metadata', {
262
333
  method: 'POST',
263
- body: JSON.stringify({ path: this._filePath })
334
+ body: JSON.stringify(body)
264
335
  });
265
336
  this._columns = response.columns;
266
337
  this._totalRows = response.totalRows;
267
338
  this._unfilteredTotalRows = response.totalRows;
268
339
  this._fileSize = response.fileSize || 0;
340
+ this._sheets = response.sheets || [];
341
+ // First load: pick the first sheet (if any) as active
342
+ if (this._activeSheet === null && this._sheets.length > 0) {
343
+ this._activeSheet = this._sheets[0];
344
+ }
345
+ this._renderSheetBar();
269
346
  this._renderHeaders();
270
347
  this._updateStatusBar();
271
348
  }
@@ -285,18 +362,22 @@ export class TabularDataViewer extends Widget {
285
362
  this._tbody.innerHTML = '';
286
363
  this._selectedRow = null; // Clear row selection on reset
287
364
  }
365
+ const dataBody = {
366
+ path: this._filePath,
367
+ offset: this._currentOffset,
368
+ limit: this._limit,
369
+ filters: this._filters,
370
+ sortBy: this._sortBy,
371
+ sortOrder: this._sortOrder,
372
+ caseInsensitive: this._caseInsensitive,
373
+ useRegex: this._useRegex
374
+ };
375
+ if (this._activeSheet) {
376
+ dataBody.sheet = this._activeSheet;
377
+ }
288
378
  const response = await requestAPI('data', {
289
379
  method: 'POST',
290
- body: JSON.stringify({
291
- path: this._filePath,
292
- offset: this._currentOffset,
293
- limit: this._limit,
294
- filters: this._filters,
295
- sortBy: this._sortBy,
296
- sortOrder: this._sortOrder,
297
- caseInsensitive: this._caseInsensitive,
298
- useRegex: this._useRegex
299
- })
380
+ body: JSON.stringify(dataBody)
300
381
  });
301
382
  this._data = this._data.concat(response.data);
302
383
  this._hasMore = response.hasMore;
@@ -596,7 +677,7 @@ export class TabularDataViewer extends Widget {
596
677
  async _openFilterModal(columnName, filterInput, filterButton) {
597
678
  try {
598
679
  // Fetch unique values for this column with the limit from settings
599
- const uniqueValues = await fetchUniqueValues(this._filePath, columnName, this._maxUniqueValues);
680
+ const uniqueValues = await fetchUniqueValues(this._filePath, columnName, this._maxUniqueValues, this._activeSheet);
600
681
  // Get current filter values if any
601
682
  const currentFilter = this._filters[columnName];
602
683
  const currentValueList = (currentFilter === null || currentFilter === void 0 ? void 0 : currentFilter.valueList) || [];
@@ -764,9 +845,9 @@ export class TabularDataViewer extends Widget {
764
845
  async _showColumnStats(columnName) {
765
846
  try {
766
847
  // Show loading indicator (we could add a spinner here)
767
- const stats = await fetchColumnStats(this._filePath, columnName);
848
+ const stats = await fetchColumnStats(this._filePath, columnName, this._activeSheet);
768
849
  // Fetch unique values with the limit from settings
769
- const uniqueValues = await fetchUniqueValues(this._filePath, columnName, this._maxUniqueValues);
850
+ const uniqueValues = await fetchUniqueValues(this._filePath, columnName, this._maxUniqueValues, this._activeSheet);
770
851
  const modal = new ColumnStatsModal(stats, uniqueValues);
771
852
  modal.show();
772
853
  }
@@ -812,8 +893,9 @@ export class TabularDataViewer extends Widget {
812
893
  * Format file size to human-readable string
813
894
  */
814
895
  _formatFileSize(bytes) {
815
- if (bytes === 0)
896
+ if (bytes === 0) {
816
897
  return '0 B';
898
+ }
817
899
  const k = 1024;
818
900
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
819
901
  const i = Math.floor(Math.log(bytes) / Math.log(k));
@@ -839,6 +921,18 @@ export class TabularDataViewer extends Widget {
839
921
  rightText += ` (${filterCount} filter${filterCount > 1 ? 's' : ''} active)`;
840
922
  }
841
923
  this._statusRight.innerHTML = rightText;
924
+ // Export link (always available; opens export popup)
925
+ const exportLink = document.createElement('a');
926
+ exportLink.href = '#';
927
+ exportLink.className = 'jp-TabularDataViewer-exportLink';
928
+ exportLink.textContent = 'Export';
929
+ exportLink.title = 'Export the current view';
930
+ exportLink.addEventListener('click', e => {
931
+ e.preventDefault();
932
+ this.showDownloadModal();
933
+ });
934
+ this._statusRight.appendChild(document.createTextNode(' • '));
935
+ this._statusRight.appendChild(exportLink);
842
936
  if (filterCount > 0) {
843
937
  const clearLink = document.createElement('a');
844
938
  clearLink.href = '#';
@@ -877,6 +971,10 @@ export class TabularDataViewer extends Widget {
877
971
  const params = new URLSearchParams();
878
972
  params.append('path', this._filePath);
879
973
  params.append('format', format);
974
+ // Active sheet (multi-sheet Excel only)
975
+ if (this._activeSheet) {
976
+ params.append('sheet', this._activeSheet);
977
+ }
880
978
  // Add filters
881
979
  if (Object.keys(this._filters).length > 0) {
882
980
  params.append('filters', JSON.stringify(this._filters));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_tabular_data_viewer_extension",
3
- "version": "1.6.2",
3
+ "version": "1.6.10",
4
4
  "description": "Jupyterlab extension to browse tabular data files (Parquet, Excel, CSV, TSV) with filtering and sorting capabilities",
5
5
  "keywords": [
6
6
  "jupyter",
package/src/index.ts CHANGED
@@ -161,8 +161,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
161
161
  // Download command
162
162
  const downloadCommand = 'tabular-data-viewer:download';
163
163
  commands.addCommand(downloadCommand, {
164
- label: 'Download Filtered Data',
165
- caption: 'Download filtered and sorted data in various formats',
164
+ label: 'Export',
165
+ caption: 'Export the current view in various formats',
166
166
  isEnabled: () => {
167
167
  return activeWidget !== null;
168
168
  },
package/src/modal.ts CHANGED
@@ -508,10 +508,12 @@ export class FilterModal extends Widget {
508
508
  */
509
509
  export class DownloadModal extends Widget {
510
510
  private _onDownload: (format: string) => void;
511
+ private _hasFilters: boolean;
511
512
 
512
- constructor(onDownload: (format: string) => void) {
513
+ constructor(onDownload: (format: string) => void, hasFilters = false) {
513
514
  super();
514
515
  this._onDownload = onDownload;
516
+ this._hasFilters = hasFilters;
515
517
  this.addClass('jp-FilterModal');
516
518
  this._render();
517
519
  this._setupEventListeners();
@@ -528,7 +530,7 @@ export class DownloadModal extends Widget {
528
530
  const header = document.createElement('div');
529
531
  header.className = 'jp-FilterModal-header';
530
532
  const title = document.createElement('h3');
531
- title.textContent = 'Download Filtered Data';
533
+ title.textContent = 'Export';
532
534
  const closeBtn = document.createElement('button');
533
535
  closeBtn.className = 'jp-FilterModal-close';
534
536
  closeBtn.textContent = '×';
@@ -537,6 +539,15 @@ export class DownloadModal extends Widget {
537
539
  header.appendChild(closeBtn);
538
540
  content.appendChild(header);
539
541
 
542
+ // Filter notice (only when filters are active)
543
+ if (this._hasFilters) {
544
+ const notice = document.createElement('div');
545
+ notice.className = 'jp-FilterModal-notice';
546
+ notice.textContent =
547
+ 'Filters are active - export will include only the filtered rows.';
548
+ content.appendChild(notice);
549
+ }
550
+
540
551
  // Format buttons
541
552
  const buttonsDiv = document.createElement('div');
542
553
  buttonsDiv.className = 'jp-FilterModal-buttons';
@@ -556,6 +567,18 @@ export class DownloadModal extends Widget {
556
567
  const csvBtn = this._createFormatButton('Download as CSV', 'csv');
557
568
  buttonsDiv.appendChild(csvBtn);
558
569
 
570
+ const parquetBtn = this._createFormatButton(
571
+ 'Download as Parquet (.parquet)',
572
+ 'parquet'
573
+ );
574
+ buttonsDiv.appendChild(parquetBtn);
575
+
576
+ const jsonlBtn = this._createFormatButton(
577
+ 'Download as JSONL (.jsonl)',
578
+ 'jsonl'
579
+ );
580
+ buttonsDiv.appendChild(jsonlBtn);
581
+
559
582
  content.appendChild(buttonsDiv);
560
583
 
561
584
  // Cancel button
package/src/request.ts CHANGED
@@ -85,18 +85,24 @@ export interface IColumnStats {
85
85
  *
86
86
  * @param filePath Path to the data file
87
87
  * @param columnName Name of column to analyze
88
+ * @param sheet Active sheet name (optional, multi-sheet Excel only)
88
89
  * @returns Column statistics
89
90
  */
90
91
  export async function fetchColumnStats(
91
92
  filePath: string,
92
- columnName: string
93
+ columnName: string,
94
+ sheet?: string | null
93
95
  ): Promise<IColumnStats> {
96
+ const body: Record<string, unknown> = {
97
+ path: filePath,
98
+ columnName: columnName
99
+ };
100
+ if (sheet) {
101
+ body.sheet = sheet;
102
+ }
94
103
  return requestAPI<IColumnStats>('column-stats', {
95
104
  method: 'POST',
96
- body: JSON.stringify({
97
- path: filePath,
98
- columnName: columnName
99
- })
105
+ body: JSON.stringify(body)
100
106
  });
101
107
  }
102
108
 
@@ -115,19 +121,26 @@ export interface IUniqueValues {
115
121
  *
116
122
  * @param filePath Path to the data file
117
123
  * @param columnName Name of column to get unique values for
124
+ * @param limit Maximum number of unique values to return
125
+ * @param sheet Active sheet name (optional, multi-sheet Excel only)
118
126
  * @returns Unique values
119
127
  */
120
128
  export async function fetchUniqueValues(
121
129
  filePath: string,
122
130
  columnName: string,
123
- limit: number = 100
131
+ limit: number = 100,
132
+ sheet?: string | null
124
133
  ): Promise<IUniqueValues> {
134
+ const body: Record<string, unknown> = {
135
+ path: filePath,
136
+ columnName: columnName,
137
+ limit: limit
138
+ };
139
+ if (sheet) {
140
+ body.sheet = sheet;
141
+ }
125
142
  return requestAPI<IUniqueValues>('unique-values', {
126
143
  method: 'POST',
127
- body: JSON.stringify({
128
- path: filePath,
129
- columnName: columnName,
130
- limit: limit
131
- })
144
+ body: JSON.stringify(body)
132
145
  });
133
146
  }
package/src/widget.ts CHANGED
@@ -66,6 +66,9 @@ export class TabularDataViewer extends Widget {
66
66
  private _maxCellCharacters: number = 100;
67
67
  private _maxUniqueValues: number = 100;
68
68
  private _selectedRow: HTMLTableRowElement | null = null;
69
+ private _sheets: string[] = [];
70
+ private _activeSheet: string | null = null;
71
+ private _sheetBar: HTMLDivElement;
69
72
 
70
73
  constructor(
71
74
  filePath: string,
@@ -167,8 +170,14 @@ export class TabularDataViewer extends Widget {
167
170
  this._statusBar.appendChild(statusMiddle);
168
171
  this._statusBar.appendChild(this._statusRight);
169
172
 
170
- // Append table container and status bar directly to widget node
173
+ // Sheet bar (only shown when multi-sheet Excel; sits above status bar)
174
+ this._sheetBar = document.createElement('div');
175
+ this._sheetBar.className = 'jp-TabularDataViewer-sheetBar';
176
+ this._sheetBar.style.display = 'none';
177
+
178
+ // Append table container, sheet bar, status bar directly to widget node
171
179
  this.node.appendChild(this._tableContainer);
180
+ this.node.appendChild(this._sheetBar);
172
181
  this.node.appendChild(this._statusBar);
173
182
 
174
183
  // Set up scroll listener for progressive loading
@@ -275,8 +284,10 @@ export class TabularDataViewer extends Widget {
275
284
  * Show download modal dialog
276
285
  */
277
286
  public showDownloadModal(): void {
278
- const modal = new DownloadModal((format: string) =>
279
- this.downloadFilteredData(format)
287
+ const hasFilters = Object.keys(this._filters).length > 0;
288
+ const modal = new DownloadModal(
289
+ (format: string) => this.downloadFilteredData(format),
290
+ hasFilters
280
291
  );
281
292
  modal.show();
282
293
  }
@@ -294,19 +305,96 @@ export class TabularDataViewer extends Widget {
294
305
  }
295
306
 
296
307
  /**
297
- * Load file metadata (columns, types, row count)
308
+ * Render the sheet bar. Visible only when the file has more than one sheet.
309
+ * Each sheet is a button; the active one carries `jp-mod-active`.
310
+ */
311
+ private _renderSheetBar(): void {
312
+ this._sheetBar.innerHTML = '';
313
+
314
+ if (this._sheets.length <= 1) {
315
+ this._sheetBar.style.display = 'none';
316
+ return;
317
+ }
318
+
319
+ this._sheetBar.style.display = '';
320
+
321
+ for (const name of this._sheets) {
322
+ const tab = document.createElement('button');
323
+ tab.className = 'jp-TabularDataViewer-sheetTab';
324
+ if (name === this._activeSheet) {
325
+ tab.classList.add('jp-mod-active');
326
+ }
327
+ tab.textContent = name;
328
+ tab.title = name;
329
+ tab.addEventListener('click', () => this._switchSheet(name));
330
+ this._sheetBar.appendChild(tab);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Switch to a different sheet. Resets all user state and reloads metadata
336
+ * + data so the experience matches "opening a new file".
337
+ */
338
+ private _switchSheet(name: string): void {
339
+ if (name === this._activeSheet) {
340
+ return;
341
+ }
342
+ this._activeSheet = name;
343
+ this._resetState();
344
+ this._renderSheetBar();
345
+ this._initialize();
346
+ }
347
+
348
+ /**
349
+ * Reset all user-applied state (filters, sort, selection, scroll position,
350
+ * column widths, paging cursor). Used when switching sheets.
351
+ */
352
+ private _resetState(): void {
353
+ this._filters = {};
354
+ this._sortBy = null;
355
+ this._sortOrder = 'asc';
356
+ this._caseInsensitive = false;
357
+ this._useRegex = false;
358
+ this._caseInsensitiveCheckbox.checked = false;
359
+ this._regexCheckbox.checked = false;
360
+ this._selectedRow = null;
361
+ this._currentOffset = 0;
362
+ this._data = [];
363
+ this._hasMore = true;
364
+ this._totalRows = 0;
365
+ this._unfilteredTotalRows = 0;
366
+ this._columnWidths.clear();
367
+ this._tbody.innerHTML = '';
368
+ this._tableContainer.scrollTop = 0;
369
+ this._tableContainer.scrollLeft = 0;
370
+ }
371
+
372
+ /**
373
+ * Load file metadata (columns, types, row count, sheets)
298
374
  */
299
375
  private async _loadMetadata(): Promise<void> {
376
+ const body: Record<string, unknown> = { path: this._filePath };
377
+ if (this._activeSheet) {
378
+ body.sheet = this._activeSheet;
379
+ }
380
+
300
381
  const response = await requestAPI<any>('metadata', {
301
382
  method: 'POST',
302
- body: JSON.stringify({ path: this._filePath })
383
+ body: JSON.stringify(body)
303
384
  });
304
385
 
305
386
  this._columns = response.columns;
306
387
  this._totalRows = response.totalRows;
307
388
  this._unfilteredTotalRows = response.totalRows;
308
389
  this._fileSize = response.fileSize || 0;
390
+ this._sheets = response.sheets || [];
309
391
 
392
+ // First load: pick the first sheet (if any) as active
393
+ if (this._activeSheet === null && this._sheets.length > 0) {
394
+ this._activeSheet = this._sheets[0];
395
+ }
396
+
397
+ this._renderSheetBar();
310
398
  this._renderHeaders();
311
399
  this._updateStatusBar();
312
400
  }
@@ -330,18 +418,22 @@ export class TabularDataViewer extends Widget {
330
418
  this._selectedRow = null; // Clear row selection on reset
331
419
  }
332
420
 
421
+ const dataBody: Record<string, unknown> = {
422
+ path: this._filePath,
423
+ offset: this._currentOffset,
424
+ limit: this._limit,
425
+ filters: this._filters,
426
+ sortBy: this._sortBy,
427
+ sortOrder: this._sortOrder,
428
+ caseInsensitive: this._caseInsensitive,
429
+ useRegex: this._useRegex
430
+ };
431
+ if (this._activeSheet) {
432
+ dataBody.sheet = this._activeSheet;
433
+ }
333
434
  const response = await requestAPI<any>('data', {
334
435
  method: 'POST',
335
- body: JSON.stringify({
336
- path: this._filePath,
337
- offset: this._currentOffset,
338
- limit: this._limit,
339
- filters: this._filters,
340
- sortBy: this._sortBy,
341
- sortOrder: this._sortOrder,
342
- caseInsensitive: this._caseInsensitive,
343
- useRegex: this._useRegex
344
- })
436
+ body: JSON.stringify(dataBody)
345
437
  });
346
438
 
347
439
  this._data = this._data.concat(response.data);
@@ -783,7 +875,8 @@ export class TabularDataViewer extends Widget {
783
875
  const uniqueValues = await fetchUniqueValues(
784
876
  this._filePath,
785
877
  columnName,
786
- this._maxUniqueValues
878
+ this._maxUniqueValues,
879
+ this._activeSheet
787
880
  );
788
881
 
789
882
  // Get current filter values if any
@@ -992,12 +1085,17 @@ export class TabularDataViewer extends Widget {
992
1085
  private async _showColumnStats(columnName: string): Promise<void> {
993
1086
  try {
994
1087
  // Show loading indicator (we could add a spinner here)
995
- const stats = await fetchColumnStats(this._filePath, columnName);
1088
+ const stats = await fetchColumnStats(
1089
+ this._filePath,
1090
+ columnName,
1091
+ this._activeSheet
1092
+ );
996
1093
  // Fetch unique values with the limit from settings
997
1094
  const uniqueValues = await fetchUniqueValues(
998
1095
  this._filePath,
999
1096
  columnName,
1000
- this._maxUniqueValues
1097
+ this._maxUniqueValues,
1098
+ this._activeSheet
1001
1099
  );
1002
1100
  const modal = new ColumnStatsModal(stats, uniqueValues);
1003
1101
  modal.show();
@@ -1051,7 +1149,9 @@ export class TabularDataViewer extends Widget {
1051
1149
  * Format file size to human-readable string
1052
1150
  */
1053
1151
  private _formatFileSize(bytes: number): string {
1054
- if (bytes === 0) return '0 B';
1152
+ if (bytes === 0) {
1153
+ return '0 B';
1154
+ }
1055
1155
  const k = 1024;
1056
1156
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1057
1157
  const i = Math.floor(Math.log(bytes) / Math.log(k));
@@ -1081,6 +1181,19 @@ export class TabularDataViewer extends Widget {
1081
1181
 
1082
1182
  this._statusRight.innerHTML = rightText;
1083
1183
 
1184
+ // Export link (always available; opens export popup)
1185
+ const exportLink = document.createElement('a');
1186
+ exportLink.href = '#';
1187
+ exportLink.className = 'jp-TabularDataViewer-exportLink';
1188
+ exportLink.textContent = 'Export';
1189
+ exportLink.title = 'Export the current view';
1190
+ exportLink.addEventListener('click', e => {
1191
+ e.preventDefault();
1192
+ this.showDownloadModal();
1193
+ });
1194
+ this._statusRight.appendChild(document.createTextNode(' • '));
1195
+ this._statusRight.appendChild(exportLink);
1196
+
1084
1197
  if (filterCount > 0) {
1085
1198
  const clearLink = document.createElement('a');
1086
1199
  clearLink.href = '#';
@@ -1125,6 +1238,11 @@ export class TabularDataViewer extends Widget {
1125
1238
  params.append('path', this._filePath);
1126
1239
  params.append('format', format);
1127
1240
 
1241
+ // Active sheet (multi-sheet Excel only)
1242
+ if (this._activeSheet) {
1243
+ params.append('sheet', this._activeSheet);
1244
+ }
1245
+
1128
1246
  // Add filters
1129
1247
  if (Object.keys(this._filters).length > 0) {
1130
1248
  params.append('filters', JSON.stringify(this._filters));
package/style/base.css CHANGED
@@ -34,7 +34,7 @@
34
34
  top: 0;
35
35
  z-index: 10;
36
36
  background-color: var(--jp-layout-color2);
37
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
37
+ box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
38
38
  }
39
39
 
40
40
  /* Filter Row */
@@ -242,7 +242,7 @@
242
242
  position: sticky;
243
243
  left: 0;
244
244
  z-index: 5;
245
- box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
245
+ box-shadow: 2px 0 4px rgb(0 0 0 / 10%);
246
246
  }
247
247
 
248
248
  /* Row number cells in header (thead) need higher z-index to appear above sticky header */
@@ -256,6 +256,7 @@
256
256
  }
257
257
 
258
258
  /* Clickable rows */
259
+ /* stylelint-disable-next-line no-descending-specificity */
259
260
  .jp-TabularDataViewer-tbody .jp-TabularDataViewer-row {
260
261
  cursor: pointer;
261
262
  }
@@ -282,6 +283,7 @@
282
283
  ) !important;
283
284
  }
284
285
 
286
+ /* stylelint-disable-next-line no-descending-specificity */
285
287
  .jp-TabularDataViewer-row-selected .jp-TabularDataViewer-rowNumberCell {
286
288
  background-color: color-mix(
287
289
  in srgb,
@@ -294,6 +296,39 @@
294
296
  border-right: none;
295
297
  }
296
298
 
299
+ /* Sheet Bar (multi-sheet Excel only) */
300
+ .jp-TabularDataViewer-sheetBar {
301
+ flex-shrink: 0;
302
+ display: flex;
303
+ overflow-x: auto;
304
+ border-top: 1px solid var(--jp-border-color2);
305
+ background-color: var(--jp-layout-color1);
306
+ font-family: var(--jp-ui-font-family);
307
+ font-size: var(--jp-ui-font-size0);
308
+ }
309
+
310
+ .jp-TabularDataViewer-sheetTab {
311
+ padding: 2px 12px;
312
+ border: none;
313
+ border-right: 1px solid var(--jp-border-color2);
314
+ background: transparent;
315
+ cursor: pointer;
316
+ color: var(--jp-ui-font-color1);
317
+ white-space: nowrap;
318
+ font-family: inherit;
319
+ font-size: inherit;
320
+ }
321
+
322
+ .jp-TabularDataViewer-sheetTab:hover {
323
+ background-color: var(--jp-layout-color2);
324
+ }
325
+
326
+ .jp-TabularDataViewer-sheetTab.jp-mod-active {
327
+ background-color: var(--jp-layout-color2);
328
+ font-weight: 600;
329
+ border-bottom: 2px solid var(--jp-brand-color1);
330
+ }
331
+
297
332
  /* Status Bar */
298
333
  .jp-TabularDataViewer-statusBar {
299
334
  flex-shrink: 0;
@@ -357,6 +392,19 @@
357
392
  text-decoration: underline !important;
358
393
  }
359
394
 
395
+ .jp-TabularDataViewer-exportLink {
396
+ color: var(--jp-brand-color1) !important;
397
+ text-decoration: none !important;
398
+ cursor: pointer;
399
+ margin-left: 4px;
400
+ font-weight: 600;
401
+ }
402
+
403
+ .jp-TabularDataViewer-exportLink:hover {
404
+ color: var(--jp-brand-color1) !important;
405
+ text-decoration: underline !important;
406
+ }
407
+
360
408
  /* Error Message */
361
409
  .jp-TabularDataViewer-error {
362
410
  padding: 16px;
@@ -410,6 +458,7 @@
410
458
  opacity: 1;
411
459
  }
412
460
 
461
+ /* stylelint-disable-next-line no-descending-specificity */
413
462
  .jp-TabularDataViewer-infoIcon:hover {
414
463
  color: var(--jp-brand-color1) !important;
415
464
  opacity: 1;
@@ -422,7 +471,7 @@
422
471
  left: 0;
423
472
  width: 100%;
424
473
  height: 100%;
425
- background-color: rgba(0, 0, 0, 0.5);
474
+ background-color: rgb(0 0 0 / 50%);
426
475
  display: flex;
427
476
  align-items: center;
428
477
  justify-content: center;
@@ -432,7 +481,7 @@
432
481
  .jp-ColumnStatsModal-content {
433
482
  background-color: var(--jp-layout-color1);
434
483
  border-radius: 8px;
435
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
484
+ box-shadow: 0 4px 12px rgb(0 0 0 / 30%);
436
485
  max-width: 500px;
437
486
  max-height: 80vh;
438
487
  overflow-y: auto;
@@ -516,7 +565,7 @@
516
565
  }
517
566
 
518
567
  .jp-ColumnStatsModal-section h4 {
519
- margin: 0 0 8px 0;
568
+ margin: 0 0 8px;
520
569
  font-size: 15px;
521
570
  font-weight: 600;
522
571
  color: var(--jp-ui-font-color1);
@@ -576,7 +625,7 @@
576
625
  left: 0;
577
626
  width: 100%;
578
627
  height: 100%;
579
- background-color: rgba(0, 0, 0, 0.5);
628
+ background-color: rgb(0 0 0 / 50%);
580
629
  display: flex;
581
630
  align-items: center;
582
631
  justify-content: center;
@@ -586,7 +635,7 @@
586
635
  .jp-FilterModal-content {
587
636
  background-color: var(--jp-layout-color1);
588
637
  border-radius: 8px;
589
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
638
+ box-shadow: 0 4px 12px rgb(0 0 0 / 30%);
590
639
  max-width: 500px;
591
640
  width: 90%;
592
641
  max-height: 80vh;
@@ -600,7 +649,7 @@
600
649
  display: flex;
601
650
  justify-content: space-between;
602
651
  align-items: center;
603
- padding: 20px 20px 12px 20px;
652
+ padding: 20px 20px 12px;
604
653
  border-bottom: 2px solid var(--jp-border-color1);
605
654
  }
606
655
 
@@ -611,6 +660,15 @@
611
660
  color: var(--jp-ui-font-color1);
612
661
  }
613
662
 
663
+ .jp-FilterModal-notice {
664
+ padding: 10px 20px;
665
+ font-size: var(--jp-ui-font-size1);
666
+ color: var(--jp-ui-font-color2);
667
+ background-color: var(--jp-layout-color2);
668
+ border-bottom: 1px solid var(--jp-border-color2);
669
+ font-style: italic;
670
+ }
671
+
614
672
  .jp-FilterModal-close {
615
673
  background: none;
616
674
  border: none;