+ {m.storage_preview_csv_rows({ count: formattedLoadedRowsCount })}
+
+ {/if}
{/if}
- {/if}
-
+
+{/if}
diff --git a/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts b/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts
index 4b147b8f..d85f060b 100644
--- a/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts
+++ b/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts
@@ -5,25 +5,86 @@ import { faker } from '@faker-js/faker';
import CsvPreview from './CsvPreview.svelte';
describe('CsvPreview', () => {
- it('should show empty state when text is empty', async () => {
+ it('should show empty state when input is empty', async () => {
render(CsvPreview, { text: '' });
- const table = page.getByRole('table');
- await expect.element(table).not.toBeInTheDocument();
+ await expect.element(page.getByText('This bucket is empty')).toBeInTheDocument();
});
- it('should render a table with headers and rows', async () => {
- const text = 'Name,Email,City\nAlice,alice@example.com,Berlin\nBob,bob@example.com,Munich';
- render(CsvPreview, { text });
+ it('should show empty state when input is just a newline', async () => {
+ render(CsvPreview, { text: '\n' });
+
+ await expect.element(page.getByText('This bucket is empty')).toBeInTheDocument();
+ });
+
+ it('should render a table with headers and rows for basic CSV', async () => {
+ const csv = 'name,city,score\nAlice,Berlin,95\nBob,Munich,88';
+ render(CsvPreview, { text: csv });
const table = page.getByRole('table', { name: 'CSV preview' });
await expect.element(table).toBeInTheDocument();
- await expect.element(page.getByText('Name')).toBeInTheDocument();
- await expect.element(page.getByText('Email')).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'name' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'city' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'score' })).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'Berlin' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: '95' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'Bob' })).toBeInTheDocument();
+ });
+
+ it('should render TSV data correctly using tab separator', async () => {
+ const tsv = 'name\tcity\tscore\nAlice\tBerlin\t95\nBob\tMunich\t88';
+ render(CsvPreview, { text: tsv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'name' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'city' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'score' })).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'Berlin' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: '95' })).toBeInTheDocument();
+ });
+
+ it('keeps TSV columns wide enough for their content', async () => {
+ const tsv = 'name\tdescription\nAlice\tA description that needs horizontal space';
+ const { container } = render(CsvPreview, { text: tsv });
+
+ const table = container.querySelector('table');
+ expect(table?.className).toContain('min-w-max');
+ });
+
+ it('should handle CSV with quoted fields containing commas', async () => {
+ const csv =
+ 'name,address,country\n"Smith, John","123 Main St, Apt 4",Germany\n"Doe, Jane","456 Oak Ave",Austria';
+ render(CsvPreview, { text: csv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'Smith, John' })).toBeInTheDocument();
await expect
- .element(page.getByRole('cell', { name: 'Alice', exact: true }))
+ .element(page.getByRole('cell', { name: '123 Main St, Apt 4' }))
.toBeInTheDocument();
- await expect.element(page.getByText('bob@example.com')).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'Germany' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'Doe, Jane' })).toBeInTheDocument();
+ });
+
+ it('should handle TSV with quoted fields containing tabs', async () => {
+ const tsv = 'name\tnote\nAlice\t"likes\ttabs"\nBob\tplain';
+ render(CsvPreview, { text: tsv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'likes tabs' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'Bob' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'plain' })).toBeInTheDocument();
});
it('should handle quoted fields with commas', async () => {
@@ -45,7 +106,10 @@ describe('CsvPreview', () => {
const text = 'A,B,C\n1,,3\n,,';
render(CsvPreview, { text });
- await expect.element(page.getByText('1')).toBeInTheDocument();
+ // nth(1) to skip the line-number cell, get the data cell
+ await expect
+ .element(page.getByRole('cell', { name: '1', exact: true }).nth(1))
+ .toBeInTheDocument();
await expect.element(page.getByText('3')).toBeInTheDocument();
});
@@ -59,20 +123,7 @@ describe('CsvPreview', () => {
render(CsvPreview, { text });
// Should show truncation notice
- const notice = page.getByText(/250/);
- await expect.element(notice).toBeInTheDocument();
- });
-
- it('should respect maxRows prop', async () => {
- const header = 'Name,Email';
- const rows = Array.from(
- { length: 50 },
- () => `${faker.person.firstName()},${faker.internet.email()}`
- );
- const text = [header, ...rows].join('\n');
- render(CsvPreview, { text, maxRows: 10 });
-
- const notice = page.getByText('Showing first 10 rows');
+ const notice = page.getByText('Showing first 250 rows');
await expect.element(notice).toBeInTheDocument();
});
@@ -94,14 +145,21 @@ describe('CsvPreview', () => {
render(CsvPreview, { text });
// Row has only 1 field but 3 headers, so columns B and C should render as empty via ?? ''
- await expect.element(page.getByRole('cell', { name: '1', exact: true })).toBeInTheDocument();
- // There should be 3 cells in the body row
+ await expect
+ .element(page.getByRole('cell', { name: '1', exact: true }).nth(1))
+ .toBeInTheDocument();
+ // There should be 3 data cells + 1 line number cell in the body row
const rows = page.getByRole('row');
// row 0 is header, row 1 is data
const dataCells = rows.nth(1).getByRole('cell');
+ // Line number cell
await expect.element(dataCells.nth(0)).toHaveTextContent('1');
- await expect.element(dataCells.nth(1)).toHaveTextContent('');
- await expect.element(dataCells.nth(2)).toHaveTextContent('');
+ // Column A: value '1'
+ await expect.element(dataCells.nth(1)).toHaveTextContent('1');
+ // Column B: missing, renders as empty
+ await expect.element(dataCells.nth(2)).toHaveTextContent(/^$/);
+ // Column C: missing, renders as empty
+ await expect.element(dataCells.nth(3)).toHaveTextContent(/^$/);
});
it('should handle header-only CSV', async () => {
@@ -112,4 +170,115 @@ describe('CsvPreview', () => {
await expect.element(table).toBeInTheDocument();
await expect.element(page.getByText('Name')).toBeInTheDocument();
});
+
+ it('should handle headers with special characters', async () => {
+ const csv = 'first name,last.name,email-address\nJohn,Doe,john@example.com';
+ render(CsvPreview, { text: csv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ for (const header of ['first name', 'last.name', 'email-address']) {
+ await expect.element(page.getByRole('cell', { name: header })).toBeInTheDocument();
+ }
+ });
+
+ it('should handle rows with missing trailing columns', async () => {
+ const csv = 'a,b,c\n1,2\n3,4,5,6';
+ render(CsvPreview, { text: csv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ const row1 = table.getByRole('row').nth(1);
+ await expect(row1.getByRole('cell').nth(2)).toHaveTextContent('2');
+ await expect(row1.getByRole('cell').nth(3)).toHaveTextContent('');
+
+ const row2 = table.getByRole('row').nth(2);
+ await expect(row2.getByRole('cell').nth(3)).toHaveTextContent('5');
+ });
+
+ it('should handle rows with empty quoted fields', async () => {
+ const csv = 'a,b,c\n1,"",3\nx,y,z';
+ render(CsvPreview, { text: csv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ const row1 = page.getByRole('row').nth(1);
+ await expect(row1.getByRole('cell').nth(1)).toHaveTextContent('1');
+ await expect(row1.getByRole('cell').nth(3)).toHaveTextContent('3');
+ });
+
+ it('should handle CRLF line endings', async () => {
+ const csv = 'name,city\r\nAlice,Berlin\r\nBob,Munich\r\n';
+ render(CsvPreview, { text: csv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'Bob' })).toBeInTheDocument();
+ });
+
+ it('should render single row CSV correctly', async () => {
+ const csv = 'header\nvalue';
+ render(CsvPreview, { text: csv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ await expect.element(page.getByRole('cell', { name: 'header' })).toBeInTheDocument();
+ await expect.element(page.getByRole('cell', { name: 'value' })).toBeInTheDocument();
+ });
+
+ it('should not show truncation message when rows fit within limit', async () => {
+ const rows = ['h1,h2'];
+ for (let i = 0; i < 10; i++) {
+ rows.push(`val${i}a,val${i}b`);
+ }
+ const csv = rows.join('\n');
+ render(CsvPreview, { text: csv });
+
+ const truncationMsg = page.getByText(/Showing/i);
+ expect(await truncationMsg.all()).toHaveLength(0);
+ });
+
+ it('should show truncation message when rows exceed MAX_ROWS', async () => {
+ const rows = ['h1,h2'];
+ for (let i = 0; i < 260; i++) {
+ rows.push(`val${i}a,val${i}b`);
+ }
+ const csv = rows.join('\n');
+ render(CsvPreview, { text: csv });
+
+ await expect.element(page.getByText(/Showing/i)).toBeInTheDocument();
+ });
+
+ it('should show truncation message for TSV with many rows', async () => {
+ const rows = ['h1\th2'];
+ for (let i = 0; i < 260; i++) {
+ rows.push(`val${i}a\tval${i}b`);
+ }
+ const tsv = rows.join('\n');
+ render(CsvPreview, { text: tsv });
+
+ await expect.element(page.getByText(/Showing/i)).toBeInTheDocument();
+ });
+
+ it('should render exactly MAX_ROWS rows when truncated', async () => {
+ const rows = ['h1'];
+ for (let i = 0; i < 300; i++) {
+ rows.push(`val${i}`);
+ }
+ const csv = rows.join('\n');
+ const { container } = render(CsvPreview, { text: csv });
+
+ const table = page.getByRole('table', { name: 'CSV preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ const tables = await container.getElementsByTagName('table');
+ expect(tables.length).toBe(1);
+ expect(tables[0].rows.length).toBe(251);
+ });
});
diff --git a/src/lib/components/storage/modals/preview/FallbackPreview.svelte b/src/lib/components/storage/modals/preview/FallbackPreview.svelte
index fc483c28..5a382e3b 100644
--- a/src/lib/components/storage/modals/preview/FallbackPreview.svelte
+++ b/src/lib/components/storage/modals/preview/FallbackPreview.svelte
@@ -10,9 +10,17 @@
isBinary?: boolean;
/** When true, the image preview was skipped because the file is too large. */
imageTooLarge?: boolean;
+ /** When true, the file is a 7zip archive, which is not yet supported. */
+ isSevenZip?: boolean;
}
- let { contentType, onDownload, isBinary = false, imageTooLarge = false }: Props = $props();
+ let {
+ contentType,
+ onDownload,
+ isBinary = false,
+ imageTooLarge = false,
+ isSevenZip = false
+ }: Props = $props();
@@ -28,9 +36,11 @@
{imageTooLarge
? m.storage_preview_image_too_large_desc()
- : isBinary
- ? m.storage_preview_binary_desc()
- : m.storage_preview_unsupported_desc()}
+ : isSevenZip
+ ? m.storage_preview_7zip_unsupported_desc()
+ : isBinary
+ ? m.storage_preview_binary_desc()
+ : m.storage_preview_unsupported_desc()}
{#if contentType}
{contentType}
diff --git a/src/lib/components/storage/modals/preview/ParquetMetadata.svelte b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte
new file mode 100644
index 00000000..38358f31
--- /dev/null
+++ b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte
@@ -0,0 +1,337 @@
+
+
+
+
+
+
+
+ {m.storage_preview_parquet_overview()}
+
+
+
+
- {m.storage_preview_parquet_file_size()}
+ - {formatFileSize(totalSize)}
+
+
+
- {m.storage_preview_parquet_total_rows()}
+ -
+ {totalRows.toLocaleString(getLocale())}
+
+
+
+
- {m.storage_preview_parquet_total_columns()}
+ - {headers.length}
+
+
+
- {m.storage_preview_parquet_version()}
+ - v{metadata.version}
+
+ {#if metadata.createdBy}
+
+
- {m.storage_preview_parquet_created_by()}
+ - {metadata.createdBy}
+
+ {/if}
+
+
+
+
+
+
+
+ {m.storage_preview_parquet_schema()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ {m.storage_preview_parquet_column()} |
+ {m.storage_preview_parquet_type()} |
+ {m.storage_preview_parquet_compression()} |
+ {m.storage_preview_parquet_size()} |
+ {m.storage_preview_parquet_statistics()} |
+
+
+
+ {#each columnTypes as col, i (col.name)}
+
+ | {i + 1} |
+
+ {col.name}
+ |
+
+ {col.type}
+ |
+
+ {col.codec}
+ |
+
+ {#if col.uncompressedSize > 0 || col.compressedSize > 0}
+
+
+
+ ↓
+ {formatFileSize(col.compressedSize)}
+
+ {#if totalUncompressed > 0}
+
+ ({Math.round((col.uncompressedSize / totalUncompressed) * 100)}%)
+
+ {/if}
+
+
+
+ {:else}
+ —
+ {/if}
+ |
+
+ {#if col.stats.min !== null || col.stats.max !== null || col.stats.nullCount !== null || col.stats.distinctCount !== null}
+
+ {#if col.stats.min !== null}
+ -
+ {m.storage_preview_parquet_min()}
+
+ -
+ {col.stats.min}
+
+ {/if}
+ {#if col.stats.max !== null}
+ -
+ {m.storage_preview_parquet_max()}
+
+ -
+ {col.stats.max}
+
+ {/if}
+ {#if col.stats.nullCount !== null}
+ -
+ {m.storage_preview_parquet_null_count()}
+
+ -
+ {col.stats.nullCount.toLocaleString(getLocale())}
+
+ {/if}
+ {#if col.stats.distinctCount !== null}
+ -
+ {m.storage_preview_parquet_distinct_count()}
+
+ -
+ {col.stats.distinctCount.toLocaleString(getLocale())}
+
+ {/if}
+
+ {:else}
+ {m.storage_preview_parquet_not_stored()}
+ {/if}
+ |
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ {m.storage_preview_parquet_compression_indexes()}
+
+
+
+
- {m.storage_preview_parquet_row_groups()}
+ - {metadata.rowGroups}
+
+
+
- {m.storage_preview_parquet_compression()}
+ -
+ {#if metadata.compressionCodecs.length === 0}
+ {m.storage_preview_parquet_none()}
+ {:else}
+ {#each metadata.compressionCodecs as codec (codec)}
+ {codec}
+ {/each}
+ {#if metadata.compressionUniform && metadata.compressionCodecs.length === 1}
+ ({m.storage_preview_parquet_uniform()})
+ {/if}
+ {/if}
+
+
+
+
- {m.storage_preview_parquet_offset_index()}
+ -
+
+ {metadata.hasOffsetIndex
+ ? m.storage_preview_parquet_available()
+ : m.storage_preview_parquet_missing()}
+
+
+
+
+
- {m.storage_preview_parquet_column_index()}
+ -
+
+ {metadata.hasColumnIndex
+ ? m.storage_preview_parquet_available()
+ : m.storage_preview_parquet_missing()}
+
+
+
+
+
+
+
+ {#if metadata.arrowSchema}
+
+
+
+ {m.storage_preview_parquet_arrow_schema()}
+
+
+
+ {m.storage_preview_parquet_arrow_schema_present({ size: metadata.arrowSchema.length })}
+
+
+
+ {#if showArrowSchema}
+ {metadata.arrowSchema}
+ {/if}
+
+ {/if}
+
+
+ {#if metadata.rowGroups > 0}
+
+
+
+ {m.storage_preview_parquet_row_groups()}
+
+
+
+
- {m.storage_preview_parquet_row_groups()}
+ - {metadata.rowGroups}
+
+
+
+ {/if}
+
diff --git a/src/lib/components/storage/modals/preview/ParquetMetadata.svelte.spec.ts b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte.spec.ts
new file mode 100644
index 00000000..5e0d9bfc
--- /dev/null
+++ b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte.spec.ts
@@ -0,0 +1,202 @@
+import { page } from 'vitest/browser';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import ParquetMetadata from './ParquetMetadata.svelte';
+
+describe('ParquetMetadata', () => {
+ const defaultColumnTypes = [
+ {
+ name: 'id',
+ type: 'int64',
+ codec: 'SNAPPY',
+ compressedSize: 100,
+ uncompressedSize: 500,
+ stats: { nullCount: 0, distinctCount: 100, min: '1', max: '100' }
+ },
+ {
+ name: 'name',
+ type: 'string',
+ codec: 'SNAPPY',
+ compressedSize: 800,
+ uncompressedSize: 2000,
+ stats: { nullCount: 0, distinctCount: null, min: 'Alice', max: 'Bob' }
+ },
+ {
+ name: 'score',
+ type: 'double',
+ codec: 'GZIP',
+ compressedSize: 50,
+ uncompressedSize: 300,
+ stats: { nullCount: null, distinctCount: null, min: null, max: null }
+ }
+ ];
+
+ const defaultMetadata = {
+ rowGroups: 3,
+ compressionCodecs: ['SNAPPY', 'GZIP'],
+ compressionUniform: false,
+ hasOffsetIndex: true,
+ hasColumnIndex: false,
+ createdBy: 'pyarrow',
+ version: 2,
+ arrowSchema: null
+ };
+
+ it('should render file overview', async () => {
+ render(ParquetMetadata, {
+ headers: ['id', 'name', 'score'],
+ columnTypes: defaultColumnTypes,
+ totalRows: 1000,
+ totalSize: 50000,
+ metadata: defaultMetadata
+ });
+
+ await expect.element(page.getByText('Overview')).toBeInTheDocument();
+ await expect.element(page.getByText('File size')).toBeInTheDocument();
+ await expect.element(page.getByText('Total rows')).toBeInTheDocument();
+ await expect.element(page.getByText('1,000')).toBeInTheDocument();
+ });
+
+ it('should render schema table with column types, compression, sizes and stats', async () => {
+ render(ParquetMetadata, {
+ headers: ['id', 'name', 'score'],
+ columnTypes: defaultColumnTypes,
+ totalRows: 1000,
+ totalSize: 50000,
+ metadata: defaultMetadata
+ });
+
+ await expect.element(page.getByText('Schema')).toBeInTheDocument();
+ await expect.element(page.getByText('id')).toBeInTheDocument();
+ await expect.element(page.getByText('name')).toBeInTheDocument();
+ await expect.element(page.getByText('score')).toBeInTheDocument();
+ await expect.element(page.getByText('int64')).toBeInTheDocument();
+ await expect.element(page.getByText('string')).toBeInTheDocument();
+ await expect.element(page.getByText('double')).toBeInTheDocument();
+
+ // Per-column compression
+ await expect.element(page.getByText('SNAPPY').first()).toBeInTheDocument();
+ await expect.element(page.getByText('GZIP').first()).toBeInTheDocument();
+
+ // Stats labels
+ await expect.element(page.getByText('Min').first()).toBeInTheDocument();
+ await expect.element(page.getByText('Max').first()).toBeInTheDocument();
+ await expect.element(page.getByText('Null count').first()).toBeInTheDocument();
+ await expect.element(page.getByText('Distinct count').first()).toBeInTheDocument();
+ // Stats values for id column
+ await expect.element(page.getByText('1').first()).toBeInTheDocument();
+ await expect.element(page.getByText('100').first()).toBeInTheDocument();
+
+ // Not stored for score column (no stats)
+ await expect.element(page.getByText('Not stored')).toBeInTheDocument();
+ });
+
+ it('should render compression and indexes section', async () => {
+ render(ParquetMetadata, {
+ headers: ['id'],
+ columnTypes: [defaultColumnTypes[0]],
+ totalRows: 100,
+ totalSize: 2000,
+ metadata: defaultMetadata
+ });
+
+ await expect.element(page.getByText('Compression & Indexes').first()).toBeInTheDocument();
+ await expect.element(page.getByText('SNAPPY').first()).toBeInTheDocument();
+ await expect.element(page.getByText('GZIP')).toBeInTheDocument();
+ await expect.element(page.getByText('Available')).toBeInTheDocument();
+ await expect.element(page.getByText('Missing')).toBeInTheDocument();
+ });
+
+ it('should show uniform label when compression is uniform', async () => {
+ render(ParquetMetadata, {
+ headers: ['id'],
+ columnTypes: [
+ {
+ ...defaultColumnTypes[0],
+ codec: 'SNAPPY'
+ }
+ ],
+ totalRows: 100,
+ totalSize: 2000,
+ metadata: { ...defaultMetadata, compressionCodecs: ['SNAPPY'], compressionUniform: true }
+ });
+
+ await expect.element(page.getByText('uniform')).toBeInTheDocument();
+ });
+
+ it('should render row groups section when rowGroups > 0', async () => {
+ render(ParquetMetadata, {
+ headers: ['id'],
+ columnTypes: [defaultColumnTypes[0]],
+ totalRows: 100,
+ totalSize: 2000,
+ metadata: defaultMetadata
+ });
+
+ await expect.element(page.getByText('Row groups').first()).toBeInTheDocument();
+ });
+
+ it('should render created by when present', async () => {
+ render(ParquetMetadata, {
+ headers: ['id'],
+ columnTypes: [defaultColumnTypes[0]],
+ totalRows: 100,
+ totalSize: 2000,
+ metadata: defaultMetadata
+ });
+
+ await expect.element(page.getByText('pyarrow')).toBeInTheDocument();
+ });
+
+ it('should show Arrow schema section when present', async () => {
+ render(ParquetMetadata, {
+ headers: ['id'],
+ columnTypes: [defaultColumnTypes[0]],
+ totalRows: 100,
+ totalSize: 2000,
+ metadata: { ...defaultMetadata, arrowSchema: 'id, name, score' }
+ });
+
+ await expect.element(page.getByText('Arrow schema').first()).toBeInTheDocument();
+
+ // Click "Show" to reveal the arrow schema content
+ const showBtn = page.getByRole('button', { name: 'Show' });
+ await expect.element(showBtn).toBeInTheDocument();
+ await showBtn.click();
+
+ await expect.element(page.getByText('id, name, score')).toBeInTheDocument();
+ });
+
+ it('should handle empty column types', async () => {
+ render(ParquetMetadata, {
+ headers: [],
+ columnTypes: [],
+ totalRows: 0,
+ totalSize: 0,
+ metadata: { ...defaultMetadata, rowGroups: 0, arrowSchema: null }
+ });
+
+ await expect.element(page.getByText('Overview')).toBeInTheDocument();
+ await expect.element(page.getByText('0 B')).toBeInTheDocument();
+ });
+
+ it('should show None for empty compression codecs', async () => {
+ render(ParquetMetadata, {
+ headers: ['id'],
+ columnTypes: [
+ {
+ ...defaultColumnTypes[0],
+ codec: 'SNAPPY',
+ compressedSize: 0,
+ uncompressedSize: 0,
+ stats: { nullCount: null, distinctCount: null, min: null, max: null }
+ }
+ ],
+ totalRows: 100,
+ totalSize: 2000,
+ metadata: { ...defaultMetadata, compressionCodecs: [] }
+ });
+
+ await expect.element(page.getByText('None')).toBeInTheDocument();
+ });
+});
diff --git a/src/lib/components/storage/modals/preview/ParquetPreview.svelte b/src/lib/components/storage/modals/preview/ParquetPreview.svelte
new file mode 100644
index 00000000..a682fc4b
--- /dev/null
+++ b/src/lib/components/storage/modals/preview/ParquetPreview.svelte
@@ -0,0 +1,319 @@
+
+
+{#if isSimpleMode}
+
+ {#if headers.length === 0}
+
{m.storage_bucket_empty()}
+ {:else}
+
+
+
+
+ |
+ {#each headers as header (header)}
+ {header} |
+ {/each}
+
+
+
+ {#each initialRows as row, i (i)}
+
+ | {(i + 1).toLocaleString(getLocale())} |
+
+ {#each headers as _h, j (j)}
+ {#if row[j] !== undefined}
+
+ {row[j] !== null ? String(row[j]) : ''}
+ |
+ {:else}
+
+
+ |
+ {/if}
+ {/each}
+
+ {/each}
+
+
+
+ {#if isTruncated}
+
+ {m.storage_preview_infinite_scroll_disabled()}
+
+ {/if}
+ {/if}
+
+{:else}
+
+ {#if headers.length === 0}
+
{m.storage_bucket_empty()}
+ {:else}
+
+
(scrollTop = e.currentTarget.scrollTop)}
+ >
+
+
+
+ |
+ {#each headers as header, j (header)}
+ 0 ? `width: ${columnWidths[j]}px` : ''}>{header} |
+ {/each}
+
+
+
+ {#if paddingTop > 0}
+
+ |
+
+ {/if}
+ {#each visibleRows as row (row.index)}
+
+ | {(row.index + 1).toLocaleString(getLocale())} |
+ {#if row.data !== null && row.data !== undefined}
+
+ {#each headers as _h, j (j)}
+ {#if row.data[j] !== undefined}
+
+ {row.data[j] !== null ? String(row.data[j]) : ''}
+ |
+ {:else}
+
+
+ |
+ {/if}
+ {/each}
+ {:else}
+
+ {#each headers as _h, j (j)}
+
+
+ |
+ {/each}
+ {/if}
+
+ {/each}
+ {#if paddingBottom > 0}
+
+ |
+
+ {/if}
+
+
+
+ {#if isTruncated}
+
+ {m.storage_preview_parquet_rows
+ ? m.storage_preview_parquet_rows({
+ count: formattedLoadedRowsCount,
+ total: formattedTotalRows
+ })
+ : `Showing ${formattedLoadedRowsCount} of ${formattedTotalRows} rows. Scroll to load more.`}
+
+ {/if}
+ {/if}
+
+{/if}
diff --git a/src/lib/components/storage/modals/preview/ParquetPreview.svelte.spec.ts b/src/lib/components/storage/modals/preview/ParquetPreview.svelte.spec.ts
new file mode 100644
index 00000000..7f8622f2
--- /dev/null
+++ b/src/lib/components/storage/modals/preview/ParquetPreview.svelte.spec.ts
@@ -0,0 +1,191 @@
+import { page } from 'vitest/browser';
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import ParquetPreview from './ParquetPreview.svelte';
+
+describe('ParquetPreview', () => {
+ const defaultHeaders = ['id', 'name', 'email'];
+ const defaultRows = [
+ ['1', 'Alice', 'alice@example.com'],
+ ['2', 'Bob', 'bob@example.com'],
+ ['3', 'Charlie', 'charlie@example.com']
+ ];
+
+ it('should show empty state when headers array is empty', async () => {
+ render(ParquetPreview, { headers: [], initialRows: [] });
+
+ await expect.element(page.getByText('This bucket is empty')).toBeInTheDocument();
+ });
+
+ it('should render a table with headers', async () => {
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: defaultRows
+ });
+
+ const table = page.getByRole('table', { name: 'Parquet preview' });
+ await expect.element(table).toBeInTheDocument();
+
+ for (const header of defaultHeaders) {
+ await expect.element(page.getByText(header)).toBeInTheDocument();
+ }
+ });
+
+ it('should render initial rows in the table', async () => {
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: defaultRows,
+ totalRows: 3
+ });
+
+ await expect
+ .element(page.getByRole('cell', { name: 'Alice', exact: true }))
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByRole('cell', { name: 'bob@example.com', exact: true }))
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByRole('cell', { name: 'Charlie', exact: true }))
+ .toBeInTheDocument();
+ });
+
+ it('should show row count message when totalRows exceeds loaded rows', async () => {
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: defaultRows,
+ totalRows: 1000
+ });
+
+ // With default CHUNK_SIZE=250, loadedRowsCount = 250 (one chunk)
+ // totalRows = 1000, so isTruncated = true
+ await expect.element(page.getByText(/rows/)).toBeInTheDocument();
+ });
+
+ it('should not show row count message when totalRows equals loaded rows', async () => {
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: defaultRows,
+ totalRows: 3
+ });
+
+ // Only 3 rows loaded, totalRows = 3 — not truncated
+ const rowsMessages = page.getByText(/rows/i);
+ // The only text with "rows" should be the row count message.
+ // When not truncated, no such message appears.
+ expect(await rowsMessages.all()).toHaveLength(0);
+ });
+
+ it('should not show row count when totalRows is 0', async () => {
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: defaultRows,
+ totalRows: 0
+ });
+
+ // 0 total rows, no truncation message
+ await expect.element(page.getByText(/Showing/i)).not.toBeInTheDocument();
+ });
+
+ it('should render cell content using String() so null becomes empty string', async () => {
+ const rowsWithNull = [['1', 'Alice', null]];
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: rowsWithNull,
+ totalRows: 1
+ });
+
+ // Alice should be visible
+ await expect.element(page.getByText('Alice')).toBeInTheDocument();
+ // The null cell should render as empty (not "null" string)
+ await expect.element(page.getByText(/null/)).not.toBeInTheDocument();
+ });
+
+ it('should call fetchRows when scrolling to unloaded chunk', async () => {
+ const fetchRows = vi.fn().mockResolvedValue([
+ ['4', 'Diana', 'diana@example.com'],
+ ['5', 'Eve', 'eve@example.com']
+ ]);
+
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: defaultRows,
+ totalRows: 500,
+ fetchRows
+ });
+
+ // The effect triggers fetchRows for chunks visible within the viewport
+
+ // Wait for initial render and effect cycle
+ await vi.waitFor(
+ () => {
+ expect(fetchRows).toHaveBeenCalled();
+ },
+ { timeout: 2000 }
+ );
+ });
+
+ it('should render skeleton rows while chunk is loading', async () => {
+ // Create a fetchRows that never resolves to simulate loading state
+ const fetchRows = vi.fn().mockReturnValue(new Promise(() => {}));
+
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: defaultRows,
+ totalRows: 500,
+ fetchRows
+ });
+
+ // The loading skeleton is an animated pulse div inside the unloaded rows
+ // This is tricky to assert directly, so we verify fetchRows was called
+ // and no crash occurs
+ await vi.waitFor(
+ () => {
+ expect(fetchRows).toHaveBeenCalled();
+ },
+ { timeout: 2000 }
+ );
+ });
+
+ it('should handle a single large initial data page', async () => {
+ const manyRows = Array.from({ length: 250 }, (_, i) => [
+ String(i + 1),
+ `User-${i + 1}`,
+ `user${i + 1}@example.com`
+ ]);
+
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: manyRows,
+ totalRows: 1000
+ });
+
+ await expect.element(page.getByText('User-250')).toBeInTheDocument();
+ });
+
+ it('should display all headers including those with special characters', async () => {
+ const specialHeaders = ['first name', 'last.name', 'email-address'];
+ render(ParquetPreview, {
+ headers: specialHeaders,
+ initialRows: [['John', 'Doe', 'john@example.com']]
+ });
+
+ for (const header of specialHeaders) {
+ await expect.element(page.getByText(header)).toBeInTheDocument();
+ }
+ });
+
+ it('should handle empty rows array', async () => {
+ render(ParquetPreview, {
+ headers: defaultHeaders,
+ initialRows: [],
+ totalRows: 0
+ });
+
+ const table = page.getByRole('table', { name: 'Parquet preview' });
+ await expect.element(table).toBeInTheDocument();
+ // Headers still render even when no rows
+ for (const header of defaultHeaders) {
+ await expect.element(page.getByText(header)).toBeInTheDocument();
+ }
+ });
+});
diff --git a/src/lib/components/storage/modals/preview/TextPreview.svelte b/src/lib/components/storage/modals/preview/TextPreview.svelte
index 6c4bb607..86cf67e6 100644
--- a/src/lib/components/storage/modals/preview/TextPreview.svelte
+++ b/src/lib/components/storage/modals/preview/TextPreview.svelte
@@ -1,4 +1,6 @@
-
{formatted}
+
+
+
+ {#each Array(lastLineNumber) as _n, i (i)}
+ {i + 1}
+ {/each}
+
+
{formatted}
+
diff --git a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte b/src/lib/components/storage/modals/shared/ConflictEntry.svelte
similarity index 93%
rename from src/lib/components/storage/modals/upload/UploadConflictEntry.svelte
rename to src/lib/components/storage/modals/shared/ConflictEntry.svelte
index 68fc270a..41cbcc9c 100644
--- a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte
+++ b/src/lib/components/storage/modals/shared/ConflictEntry.svelte
@@ -2,10 +2,10 @@
import IconCheck from 'virtual:icons/material-symbols/check';
import IconCheckCircle from 'virtual:icons/material-symbols/check-circle';
import * as m from '$lib/paraglide/messages.js';
- import type { FileEntry, Resolution } from './types.js';
+ import type { ConflictEntry, Resolution } from './conflict-types.js';
interface Props {
- entry: FileEntry;
+ entry: ConflictEntry;
onSetResolution: (resolution: Resolution) => void;
onSetCustomName: (name: string) => void;
onRenameButtonClick: () => void;
@@ -17,22 +17,21 @@
const uid = $props.id();
- let nameOnly = $derived(entry.targetKey.split('/').at(-1) ?? entry.file.name);
let badRename = $derived(
entry.resolution === 'rename' &&
- (entry.customName.trim() === '' || entry.customName.trim() === entry.file.name)
+ (entry.customName.trim() === '' || entry.customName.trim() === entry.originalName)
);
- “{nameOnly}”
+ “{entry.originalName}”
@@ -421,8 +423,16 @@
aria-label={m.storage_upload_conflicts_title()}
>
{#each conflictEntries as entry (entry.id)}
- setResolution(entry.id, res)}
onSetCustomName={(name) => setCustomName(entry.id, name)}
onRenameButtonClick={() => handleRenameButtonClick(entry.id)}
diff --git a/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts b/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts
index 759502bd..3d4ca814 100644
--- a/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts
+++ b/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts
@@ -3,27 +3,24 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { faker } from '@faker-js/faker';
import UploadModal from './UploadModal.svelte';
+import { StorageError } from '$lib/storage/errors.js';
-const { mockCheckObjectExists, mockUploadFile, MockUploadError } = vi.hoisted(() => {
+const { mockCheckObjectExists, mockUploadFile } = vi.hoisted(() => {
const mockCheckObjectExists = vi.fn().mockResolvedValue(false);
const mockUploadFile = vi.fn().mockResolvedValue(undefined);
- class MockUploadError extends Error {
- code: string;
- constructor(code: string, message: string) {
- super(message);
- this.code = code;
- this.name = 'UploadError';
- }
- }
-
- return { mockCheckObjectExists, mockUploadFile, MockUploadError };
+ return { mockCheckObjectExists, mockUploadFile };
});
vi.mock('$lib/storage/upload.js', () => ({
checkObjectExists: mockCheckObjectExists,
- uploadFile: mockUploadFile,
- UploadError: MockUploadError
+ uploadFile: mockUploadFile
+}));
+
+// Provide a stable, isolated connection store so mutations from other test
+// files (e.g. StorageConnectForm.svelte.spec.ts) cannot bleed across.
+vi.mock('$lib/storage/connection-store.svelte.js', () => ({
+ connectionStore: { activeConnectionId: null, connections: [] }
}));
const defaultProps = {
@@ -327,7 +324,7 @@ describe('UploadModal', () => {
});
it('should show error state when upload fails with UploadError (access_denied)', async () => {
- mockUploadFile.mockRejectedValue(new MockUploadError('access_denied', 'Access denied'));
+ mockUploadFile.mockRejectedValue(new StorageError('access_denied', 'Access denied'));
render(UploadModal, defaultProps);
await selectAndUpload([createFile('a.txt')]);
@@ -346,7 +343,7 @@ describe('UploadModal', () => {
});
it('should handle not_connected error', async () => {
- mockUploadFile.mockRejectedValue(new MockUploadError('not_connected', 'Not connected'));
+ mockUploadFile.mockRejectedValue(new StorageError('not_connected', 'Not connected'));
render(UploadModal, defaultProps);
await selectAndUpload([createFile('a.txt')]);
@@ -355,7 +352,7 @@ describe('UploadModal', () => {
});
it('should handle no_such_bucket error', async () => {
- mockUploadFile.mockRejectedValue(new MockUploadError('no_such_bucket', 'No such bucket'));
+ mockUploadFile.mockRejectedValue(new StorageError('no_such_bucket', 'No such bucket'));
render(UploadModal, defaultProps);
await selectAndUpload([createFile('a.txt')]);
@@ -364,7 +361,7 @@ describe('UploadModal', () => {
});
it('should handle invalid_part error', async () => {
- mockUploadFile.mockRejectedValue(new MockUploadError('invalid_part', 'Invalid part'));
+ mockUploadFile.mockRejectedValue(new StorageError('invalid_part', 'Invalid part'));
render(UploadModal, defaultProps);
await selectAndUpload([createFile('a.txt')]);
@@ -373,7 +370,7 @@ describe('UploadModal', () => {
});
it('should handle server_error', async () => {
- mockUploadFile.mockRejectedValue(new MockUploadError('server_error', 'Server error'));
+ mockUploadFile.mockRejectedValue(new StorageError('server_error', 'Server error'));
render(UploadModal, defaultProps);
await selectAndUpload([createFile('a.txt')]);
@@ -382,7 +379,7 @@ describe('UploadModal', () => {
});
it('should show error filename in error list', async () => {
- mockUploadFile.mockRejectedValue(new MockUploadError('access_denied', 'Denied'));
+ mockUploadFile.mockRejectedValue(new StorageError('access_denied', 'Denied'));
render(UploadModal, defaultProps);
await selectAndUpload([createFile('secret.txt')]);
@@ -676,7 +673,7 @@ describe('UploadModal', () => {
let callIdx = 0;
mockUploadFile.mockImplementation(async () => {
callIdx++;
- if (callIdx === 2) throw new MockUploadError('access_denied', 'Denied');
+ if (callIdx === 2) throw new StorageError('access_denied', 'Denied');
});
render(UploadModal, defaultProps);
diff --git a/src/lib/components/storage/modals/upload/types.ts b/src/lib/components/storage/modals/upload/types.ts
index 61bd2321..8cce354a 100644
--- a/src/lib/components/storage/modals/upload/types.ts
+++ b/src/lib/components/storage/modals/upload/types.ts
@@ -1,18 +1,9 @@
/**
* Shared types for the upload modal sub-components.
+ * Resolution and RenameState are consumed directly from the shared conflict types.
*/
-export type Resolution = 'replace' | 'skip' | 'rename';
-
-/**
- * Tracks the two-stage rename confirmation flow.
- * idle - rename not selected for this entry
- * editing - rename selected, text field is editable
- * checking - async conflict check in progress
- * ok - new name confirmed available
- * conflict - new name already exists in the bucket
- */
-export type RenameState = 'idle' | 'editing' | 'checking' | 'ok' | 'conflict';
+import type { Resolution, RenameState } from '../shared/conflict-types.js';
export type FileEntry = {
id: string;
diff --git a/src/lib/components/storage/shared/FloatingMenu.svelte b/src/lib/components/storage/shared/FloatingMenu.svelte
new file mode 100644
index 00000000..261a6d06
--- /dev/null
+++ b/src/lib/components/storage/shared/FloatingMenu.svelte
@@ -0,0 +1,52 @@
+
+
+{#if open}
+ {
+ e.preventDefault();
+ onclose();
+ }}
+ >
+
+ {@render children()}
+
+{/if}
diff --git a/src/lib/components/storage/shared/TimestampDisplay.svelte b/src/lib/components/storage/shared/TimestampDisplay.svelte
index 72b3f835..894ac255 100644
--- a/src/lib/components/storage/shared/TimestampDisplay.svelte
+++ b/src/lib/components/storage/shared/TimestampDisplay.svelte
@@ -12,11 +12,18 @@
* When false (default), the label shows a short formatted date ("13 May 2026").
*/
relative?: boolean;
+ /** When true, the tooltip shows the full timestamp. */
+ showFullTimestamp?: boolean;
/** DaisyUI tooltip direction. */
tooltip?: TooltipPosition;
}
- let { date, relative = false, tooltip = 'tooltip-top' }: Props = $props();
+ let {
+ date,
+ relative = false,
+ tooltip = 'tooltip-top',
+ showFullTimestamp = false
+ }: Props = $props();
const d = $derived(typeof date === 'string' ? new Date(date) : date);
@@ -45,6 +52,7 @@
const years = Math.floor(days / 365);
return m.timestamp_years_ago({ count: years });
}
+ if (showFullTimestamp) return fullTimestamp;
return new Intl.DateTimeFormat(getLocale(), {
year: 'numeric',
month: 'short',
diff --git a/src/lib/components/storage/sidebar/BucketList.svelte b/src/lib/components/storage/sidebar/BucketList.svelte
index 5a1de726..dbd7ebb3 100644
--- a/src/lib/components/storage/sidebar/BucketList.svelte
+++ b/src/lib/components/storage/sidebar/BucketList.svelte
@@ -1,18 +1,21 @@
@@ -111,15 +153,17 @@
{m.storage_context_menu_actions()}
-
-
-
+
+
+
+
+