feat: redesign the s3 explorer and show data files as tables (#11642)

* feat: restyle s3 explorer tree like the db manager tree

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: make s3 explorer flush with its drawer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: show s3 file preview as an inset card

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: redesign s3 explorer file header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: move s3 csv preview options into the file header band

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: narrow the s3 csv separator select

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: show csv, parquet and json files as tables in the s3 explorer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the detected s3 csv separator shown in text view

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop stale s3 table responses and unfilterable nested columns

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: pin ee ref for the s3 table decimal filter fix

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: pin ee ref for the s3 table decimal scale fix

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: pin ee ref for strict s3 table decimal filter parsing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: pin ee ref for the s3 decimal exponent bound

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae

This commit updates the EE repository reference after PR #869 was merged in windmill-ee-private.

Previous ee-repo-ref: 74f041e9767f2ac612e21f351f96e71c584e2904

New ee-repo-ref: 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Diego Imbert
2026-10-11 23:29:05 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent aae5f2efaa
commit 9fa9b77926
11 changed files with 726 additions and 245 deletions
+1 -1
View File
@@ -1 +1 @@
599eae0034b34c5e0d3d42df37cf18bbe01f9b49
06610ecc950ed64a2709d7f1aa8c7b49a95fdbae
+41 -2
View File
@@ -25310,9 +25310,12 @@ paths:
in: query
schema:
type: string
- $ref: "#/components/parameters/TablePreviewS3ResourcePath"
- $ref: "#/components/parameters/TablePreviewFilters"
responses:
"200":
description: Parquet Preview
description: >
Parquet Preview: `columns`, `column_types` (Arrow type names) and `rows`.
content:
application/json: {}
@@ -25337,6 +25340,13 @@ paths:
in: query
schema:
type: string
- $ref: "#/components/parameters/TablePreviewS3ResourcePath"
- name: csv_separator
in: query
schema:
type: string
- $ref: "#/components/parameters/TablePreviewCsvHasHeader"
- $ref: "#/components/parameters/TablePreviewFilters"
responses:
"200":
description: Table count
@@ -25385,13 +25395,20 @@ paths:
in: query
schema:
type: string
- $ref: "#/components/parameters/TablePreviewS3ResourcePath"
- name: csv_separator
in: query
schema:
type: string
- $ref: "#/components/parameters/TablePreviewCsvHasHeader"
- $ref: "#/components/parameters/TablePreviewFilters"
responses:
"200":
description: Csv Preview
description: >
Csv Preview: `columns`, `column_types` (Arrow type names), `rows`, and the
`csv_separator` the file was read with, guessed from its first bytes when
none is given.
A `.jsonl` or `.ndjson` file is read as newline-delimited JSON.
content:
application/json: {}
@@ -27751,6 +27768,28 @@ components:
required: true
schema:
type: string
TablePreviewS3ResourcePath:
name: s3_resource_path
description: Read the file from this object storage resource instead of the workspace storage.
in: query
schema:
type: string
TablePreviewCsvHasHeader:
name: csv_has_header
description: Whether the first CSV line names the columns. Defaults to true.
in: query
schema:
type: boolean
TablePreviewFilters:
name: filters
description: >
JSON object, column name to filter. A number column takes an optional comparison
operator and a number (`>=10`), a boolean column a boolean, and any other column a
case-insensitive substring, or `=value` for an exact match. A `search_term` without
a `search_col` matches any column, case-insensitively.
in: query
schema:
type: string
HubPublishFolder:
name: folder
in: query
+12 -3
View File
@@ -5848,6 +5848,10 @@ impl AppLoadCountQuery {
search_col: self.search_col,
search_term: self.search_term,
storage: self.storage,
s3_resource_path: None,
csv_separator: None,
csv_has_header: None,
filters: None,
},
)
}
@@ -5881,6 +5885,9 @@ impl AppLoadPreviewQuery {
search_term: self.search_term,
storage: self.storage,
csv_separator: self.csv_separator,
s3_resource_path: None,
csv_has_header: None,
filters: None,
},
)
}
@@ -5968,7 +5975,9 @@ async fn app_load_table_count(
let job_authed =
app_s3_on_behalf_and_provenance(&db, &path, &w_id, &opt_authed, &file_query).await?;
let resp =
crate::job_helpers_oss::load_table_count_internal(job_authed, &db, &w_id, file_key, inner)
crate::job_helpers_oss::load_table_count_internal(
job_authed, &db, None, &w_id, file_key, inner,
)
.await?;
Ok(Json(resp).into_response())
}
@@ -5987,7 +5996,7 @@ async fn app_load_parquet_preview(
let job_authed =
app_s3_on_behalf_and_provenance(&db, &path, &w_id, &opt_authed, &file_query).await?;
let resp = crate::job_helpers_oss::load_preview_internal(
job_authed, &db, &w_id, file_key, inner, true,
job_authed, &db, None, &w_id, file_key, inner, true,
)
.await?;
Ok(Json(resp).into_response())
@@ -6007,7 +6016,7 @@ async fn app_load_csv_preview(
let job_authed =
app_s3_on_behalf_and_provenance(&db, &path, &w_id, &opt_authed, &file_query).await?;
let resp = crate::job_helpers_oss::load_preview_internal(
job_authed, &db, &w_id, file_key, inner, false,
job_authed, &db, None, &w_id, file_key, inner, false,
)
.await?;
Ok(Json(resp).into_response())
@@ -263,6 +263,10 @@ mod app_s3_display_stubs {
pub search_col: Option<String>,
pub search_term: Option<String>,
pub storage: Option<String>,
pub s3_resource_path: Option<String>,
pub csv_separator: Option<String>,
pub csv_has_header: Option<bool>,
pub filters: Option<String>,
}
#[derive(Serialize)]
@@ -278,7 +282,10 @@ mod app_s3_display_stubs {
pub search_col: Option<String>,
pub search_term: Option<String>,
pub storage: Option<String>,
pub s3_resource_path: Option<String>,
pub csv_separator: Option<String>,
pub csv_has_header: Option<bool>,
pub filters: Option<String>,
}
pub async fn load_file_metadata_internal(
@@ -308,6 +315,7 @@ mod app_s3_display_stubs {
pub async fn load_table_count_internal(
_authed: OptJobAuthed,
_db: &DB,
_user_db: Option<UserDB>,
_w_id: &str,
_file_key: String,
_query: LoadCountQuery,
@@ -320,6 +328,7 @@ mod app_s3_display_stubs {
pub async fn load_preview_internal(
_authed: OptJobAuthed,
_db: &DB,
_user_db: Option<UserDB>,
_w_id: &str,
_file_key: String,
_query: LoadPreviewQuery,
@@ -105,6 +105,7 @@
size="1200px"
>
<DrawerContent
noPadding
title={s3ResourcePath ? `Exploring ${s3ResourcePath}` : 'S3 file browser'}
on:close={() => {
s3FilePickerInner?.exit?.()
@@ -8,8 +8,36 @@
Loader2,
Download,
Trash,
MoveRight
MoveRight,
Ellipsis,
FileText,
FileCode,
FileJson,
FileSpreadsheet,
FileImage,
FileArchive,
FilePlay,
FileAudio,
Table2
} from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import TextInput from './text_input/TextInput.svelte'
import Select from './select/Select.svelte'
import Toggle from './Toggle.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import DBTable from './DBTable.svelte'
import type { IDbTableOps } from './dbOps'
import { resource } from 'runed'
import { staticDbTableOps } from './staticDbTableOps'
import {
fetchJsonTableRows,
isJsonFile,
JSON_TABLE_MAX_BYTES,
s3TableOps,
serverTableFormat,
type S3TableSource
} from './s3TableOps'
import {
CancelablePromise,
HelpersService,
@@ -34,14 +62,13 @@
emptyString,
parseS3Object,
sendUserToast,
type S3Object
type S3Object,
type IconType
} from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { Alert, Button } from './common'
import Section from './Section.svelte'
import { Alert, Button, CopyButton } from './common'
import { createEventDispatcher, untrack, type Snippet } from 'svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
import TableSimple from './TableSimple.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import FileUploadModal from './common/fileUpload/FileUploadModal.svelte'
import S3FilePreview from './S3FilePreview.svelte'
@@ -143,8 +170,14 @@
let rootPath = $state(initialRootPath)
let rootPathNestingLevel = $derived(1 * (rootPath.split('/').length - 1))
let csvSeparatorChar: string = $state(',')
let csvSeparatorChar: string | undefined = $state(undefined)
let csvHasHeader: boolean = $state(true)
const CSV_SEPARATORS = [
{ value: ',', label: ',' },
{ value: ';', label: ';' },
{ value: '\t', label: 'Tab' },
{ value: '|', label: '|' }
]
let dispatch = createEventDispatcher<{
close: { s3: string; storage: string | undefined } | undefined
@@ -178,6 +211,68 @@
}
| undefined = $state(undefined)
let viewMode: 'table' | 'text' = $state('table')
/** The selected file, when it can be shown as a table. Its own requests can't read
* files behind a custom listing (git repos), so those keep the text preview. */
let tableSource = $derived.by(() => {
if (hideS3SpecificDetails || !fileMetadata || !ws) return undefined
const fileKey = fileMetadata.fileKey
const inBrowser = isJsonFile(fileKey) && (fileMetadata.size ?? Infinity) <= JSON_TABLE_MAX_BYTES
if (!inBrowser && !serverTableFormat(fileKey)) return undefined
return {
workspace: ws,
fileKey,
storage,
s3ResourcePath,
csvSeparator: csvSeparatorChar,
csvHasHeader,
inBrowser
}
})
let tableKey = $derived(tableSource && JSON.stringify(tableSource))
let tableOps = resource(
() => (viewMode === 'table' ? tableSource : undefined),
async (source, _previous, { signal }) => {
if (!source) return undefined
const key = JSON.stringify(source)
// A response for a file or options since left must not replace the current one;
// the resource drops a fetch that throws an AbortError.
const superseded = () => new DOMException('Superseded', 'AbortError')
try {
const table = source.inBrowser ? await jsonTable(source) : await s3TableOps(source)
if (signal.aborted) throw superseded()
return { key, ops: table?.ops, csvSeparator: table?.csvSeparator }
} catch (e) {
throw signal.aborted ? superseded() : e
}
}
)
async function jsonTable(
source: S3TableSource
): Promise<{ ops: IDbTableOps; csvSeparator?: string } | undefined> {
const rows = await fetchJsonTableRows(source)
return rows && { ops: staticDbTableOps(rows, 'duckdb', source.fileKey.split('/').pop() ?? '') }
}
/** The loaded table, once it belongs to the selected file and options. */
let currentTable = $derived(tableOps.current?.key === tableKey ? tableOps.current : undefined)
/** A JSON file that turns out not to be an array has no table to show. */
let showTable = $derived(
viewMode === 'table' && !!tableSource && !(currentTable && !currentTable.ops)
)
// Each file starts on the separator the server guesses from its first bytes.
$effect(() => {
fileMetadata?.fileKey
untrack(() => {
csvSeparatorChar = undefined
detectedCsvSeparator = undefined
})
})
/** Kept while the file stays selected, so the text view still shows what was guessed. */
let detectedCsvSeparator: string | undefined = $state(undefined)
$effect(() => {
if (currentTable?.csvSeparator) detectedCsvSeparator = currentTable.csvSeparator
})
/** Identifies the metadata request that currently owns the preview pane. */
let metadataRequestId = 0
@@ -228,6 +323,25 @@
/** Per-level listing only makes sense while browsing; searching stays flat. */
let lazyMode = $derived(lazyFolders && filter.trim() === '')
/** `nestingLevel` counts two per folder depth. */
function treeRowIndent(nestingLevel: number): string {
return `padding-left: ${12 + (nestingLevel / 2) * 16}px;`
}
const FILE_ICONS: [RegExp, IconType][] = [
[/\.(csv|tsv|xlsx?|parquet|ods)$/i, FileSpreadsheet],
[/\.(json|jsonl|ndjson)$/i, FileJson],
[/\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?)$/i, FileImage],
[/\.(zip|tar|gz|tgz|bz2|xz|7z|rar|zst)$/i, FileArchive],
[/\.(mp4|mov|webm|mkv|avi)$/i, FilePlay],
[/\.(mp3|wav|ogg|flac|m4a)$/i, FileAudio],
[/\.(py|ts|js|go|rs|sh|sql|java|rb|php|ya?ml|toml|html|css|xml)$/i, FileCode],
[/\.(txt|md|log|pdf|docx?|rtf)$/i, FileText]
]
function fileIconFor(name: string): IconType {
return FILE_ICONS.find(([re]) => re.test(name))?.[1] ?? FileIcon
}
function nestingLevelOf(key: string): number {
const slashes = (key.match(/\//g) ?? []).length
return (key.endsWith('/') ? slashes - 1 : slashes) * 2
@@ -1047,40 +1161,43 @@
</script>
{#if workspaceSettingsInitialized === false}
{#if fromWorkspaceSettings}
<Alert type="error" title="Connection to remote S3 bucket unsuccessful">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0"> Double check the S3 resource fields and try again. </p>
</div>
</Alert>
{:else if s3ResourcePath}
<Alert type="error" title="Could not connect to the object storage of {s3ResourcePath}">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
Double check the resource fields and that its object storage is reachable, then try again.
</p>
<Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{:else}
<Alert type="error" title="Workspace not connected to any S3 storage">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
The workspace needs to be connected to an S3 storage to use this feature. You can <a
target="_blank"
href="/workspace_settings?tab=windmill_lfs">configure it here</a
>.
</p>
<Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{/if}
<div class="p-4">
{#if fromWorkspaceSettings}
<Alert type="error" title="Connection to remote S3 bucket unsuccessful">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0"> Double check the S3 resource fields and try again. </p>
</div>
</Alert>
{:else if s3ResourcePath}
<Alert type="error" title="Could not connect to the object storage of {s3ResourcePath}">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
Double check the resource fields and that its object storage is reachable, then try
again.
</p>
<Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{:else}
<Alert type="error" title="Workspace not connected to any S3 storage">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
The workspace needs to be connected to an S3 storage to use this feature. You can <a
target="_blank"
href="/workspace_settings?tab=windmill_lfs">configure it here</a
>.
</p>
<Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{/if}
</div>
{:else}
{#if fileListUnavailable == true}
{#if replaceUnauthorizedWarning}
{@render replaceUnauthorizedWarning()}
{:else}
<div class="mb-2">
<div class="p-3 border-b">
<Alert type="info" title="Access to S3 bucket restricted">
<p>
You don't have access to the S3 bucket resource and your administrator has restricted
@@ -1097,28 +1214,25 @@
</div>
{/if}
{/if}
<div class="flex flex-row border rounded-md h-full min-h-0 overflow-hidden">
<Splitpanes class="h-full min-h-0 overflow-hidden">
{#if !fileListUnavailable}
<div class="min-w-[30%] border-r flex flex-col min-h-0">
<Pane size={28} minSize={15} class="flex flex-col min-h-0">
{#if !rootPath}
<div class="w-full p-1 border-b">
<input
type="text"
placeholder="Search by path prefix"
<div class="mx-3 mt-3">
<TextInput
bind:value={filter}
class="text-xl"
inputProps={{ placeholder: 'Search by path prefix...' }}
/>
</div>
{/if}
{#if displayedFileKeys.length === 0}
{#if fileListLoading}
<div class="grow min-h-0 flex justify-center items-center">
<div class="flex text-secondary text-xs items-center">
<Loader2 size={12} class="animate-spin mr-1" /> Loading content
</div>
<div class="flex items-center gap-2 text-tertiary p-3">
<Loader2 class="animate-spin" size={14} />
<span class="text-xs">Loading...</span>
</div>
{:else}
<div class="p-4 text-primary text-xs text-center italic">
<div class="p-3 text-tertiary text-xs">
{#if filter.trim() !== ''}
No files starting with "{filter.trim()}"
{:else}
@@ -1130,19 +1244,19 @@
can remove every match. Without this the only control that could resume
the listing would be hidden behind the empty state. -->
<div class="flex justify-center pb-4">
<Button variant="default" size="xs2" on:click={() => loadNextFlatPage()}>
<Button variant="default" unifiedSize="xs" onClick={() => loadNextFlatPage()}>
Keep looking
</Button>
</div>
{/if}
{/if}
{:else}
<div class="grow min-h-0" bind:clientHeight={listDivHeight}>
<div class="grow min-h-0 mt-1.5" bind:clientHeight={listDivHeight}>
<VirtualList
width="100%"
height={listDivHeight}
itemCount={displayedFileKeys.length}
itemSize={42}
itemSize={32}
>
{#snippet header()}{/snippet}
{#snippet footer()}{/snippet}
@@ -1154,199 +1268,249 @@
{@const load_more_prefix = is_load_more ? item_key.slice(0, -1) : ''}
{@const file_info = allFilesByKey[item_key]}
<div
{style}
class={twMerge(
'hover:bg-surface-hover border-b',
index === displayedFileKeys.length - 1 && 'border-b-0'
)}
>
<div {style}>
{#if is_load_more}
<!-- Indented like the siblings it belongs to, so it must discount
the browsing root the same way entry rows do. -->
{@const loadMoreNesting =
nestingLevelOf(load_more_prefix + 'x') - 2 * rootPathNestingLevel}
{@const loadingMore = folderState[load_more_prefix]?.loading === true}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
<button
onclick={() => !loadingMore && loadMore(load_more_prefix)}
class={twMerge(
'flex flex-row h-full text-xs items-center justify-start text-secondary',
loadingMore ? 'cursor-default' : 'cursor-pointer'
)}
disabled={loadingMore}
class="w-full text-xs font-normal flex gap-2 items-center h-8 pr-1 text-secondary hover:bg-surface-hover cursor-pointer disabled:cursor-default disabled:hover:bg-transparent"
style={treeRowIndent(loadMoreNesting)}
>
<div
class="flex flex-row w-full gap-2 h-full items-center"
style={`margin-left: ${(2 + loadMoreNesting) * 0.25}rem;`}
>
<!-- Occupies the same slot as the sibling rows' file/folder icon so
the labels line up, and holds its width when the spinner swaps in. -->
<div class="w-4 shrink-0 flex items-center justify-center">
{#if loadingMore}
<Loader2 size={16} class="animate-spin" />
{:else}
<ChevronDown size={16} />
{/if}
</div>
<div class="truncate text-ellipsis w-56">Load more</div>
</div>
</div>
<span class="shrink-0 w-3.5"></span>
{#if loadingMore}
<Loader2 class="shrink-0 animate-spin" size={14} />
{:else}
<Ellipsis class="shrink-0" size={14} />
{/if}
<span class="truncate">Load more</span>
</button>
{:else if file_info}
{@const nestingLevel = file_info.nestingLevel - 2 * rootPathNestingLevel}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
{@const isSelected = selectedFileKey?.s3 === file_info.full_key}
{@const isFolder = file_info.type === 'folder'}
{@const RowIcon = isFolder
? file_info.collapsed
? FolderClosed
: FolderOpen
: fileIconFor(file_info.display_name)}
<button
onclick={() => selectItem(index)}
title={file_info.full_key.slice(rootPath.length)}
class={twMerge(
'flex flex-row h-full font-semibold text-xs items-center justify-start',
selectedFileKey !== undefined && selectedFileKey.s3 === file_info.full_key
? 'bg-surface-hover'
: ''
'w-full text-xs font-normal text-primary flex gap-2 items-center h-8 pr-1 cursor-pointer',
isSelected ? 'bg-surface-secondary' : 'hover:bg-surface-hover'
)}
style={treeRowIndent(nestingLevel)}
>
<div
class={`flex flex-row w-full gap-2 h-full items-center`}
style={`margin-left: ${(2 + nestingLevel) * 0.25}rem;`}
>
{#if file_info.type === 'folder'}
{#if folderState[file_info.full_key]?.loading}
<Loader2 size={16} class="animate-spin" />
{:else if file_info.collapsed}<FolderClosed size={16} />{:else}<FolderOpen
size={16}
/>{/if}
<div class="truncate text-ellipsis w-56">
<!-- An object-store key may contain an empty segment, so `a//` is a real
folder whose name is ''. Label it rather than rendering a blank row. -->
{#if file_info.display_name === ''}
<span class="italic text-secondary">(empty name)</span>
{:else}{file_info.display_name}{/if}
{#if !lazyMode}
({file_info.count}{count % 1000 === 0 &&
lastKeyFolders[file_info.nestingLevel / 2] === file_info.display_name
? '+'
: ''} item{file_info.count === 1 ? '' : 's'})
{/if}
</div>
{#if isFolder}
{#if folderState[file_info.full_key]?.loading}
<Loader2 class="shrink-0 text-secondary animate-spin" size={14} />
{:else}
<FileIcon size={16} />
<div class="truncate text-ellipsis w-56">
{file_info.display_name}
</div>
<ChevronDown
class={twMerge(
'shrink-0 text-secondary transition-transform',
file_info.collapsed && '-rotate-90'
)}
size={14}
/>
{/if}
</div>
</div>
{:else}
<span class="shrink-0 w-3.5"></span>
{/if}
<RowIcon class="shrink-0" size={14} />
<span class="truncate text-left">
<!-- An object-store key may contain an empty segment, so `a//` is a real
folder whose name is ''. Label it rather than rendering a blank row. -->
{#if file_info.display_name === ''}
<span class="italic text-secondary">(empty name)</span>
{:else}{file_info.display_name}{/if}
</span>
<span class="grow"></span>
{#if isFolder && !lazyMode}
<span
class="shrink-0 text-2xs text-tertiary mr-2"
title="Items inside this folder"
>
{file_info.count}{count % 1000 === 0 &&
lastKeyFolders[file_info.nestingLevel / 2] === file_info.display_name
? '+'
: ''}
</span>
{/if}
</button>
{/if}
</div>
{/snippet}
</VirtualList>
</div>
<div
class="flex flex-col gap-2 text-2xs justify-center items-center text-secondary w-full border-t py-1"
class="flex items-center justify-between gap-2 min-h-8 px-3 py-1 border-t text-2xs text-tertiary"
>
{#if fileListLoading === true}
<div class="flex text-secondary mt-1 text-xs justify-center items-center w-full">
<Loader2 size={12} class="animate-spin mr-1" /> Loading content
<div class="flex items-center gap-1.5">
<Loader2 size={12} class="animate-spin" /> Loading...
</div>
{:else if lazyMode}
<!-- Per-level listing: totals below the tree would be a count of what
happens to be expanded, and each folder carries its own Load more row. -->
<div>{displayedCount} item{displayedCount === 1 ? '' : 's'} shown</div>
{:else}
<div>
<div class="min-w-0">
{displayedCount}{flatHasMore ? '+' : ''}
{displayedCount !== count ? 'filtered ' : ''}items (including inside folders)
</div>
{#if flatHasMore}
<Button variant="default" size="xs2" on:click={() => loadNextFlatPage()}>
<Button variant="subtle" unifiedSize="xs" onClick={() => loadNextFlatPage()}>
Load more
</Button>
{/if}
{/if}
</div>
{/if}
</div>
</Pane>
{/if}
<div class="flex flex-col h-full w-full min-h-0 overflow-hidden">
{#if fileMetadata === undefined}
<div class="p-4">
{#if fileInfoLoading}
<Section label="Loading..." />
{:else if fileListUnavailable}
<Section label="No file to preview" />
{:else}
<Section label="Select a file to preview" />
{/if}
</div>
{:else}
<div class="px-3 py-2 flex flex-col gap-2">
<div class="flex flex-row items-center justify-between gap-2">
<h2 class="text-emphasis text-sm font-semibold break-all min-w-0">
{((p) => (p.startsWith(rootPath) ? p.slice(rootPath.length) : p))(
fileMetadata.fileKey
)}
</h2>
{#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)}
<div class="flex gap-2 shrink-0">
{#if !hideS3SpecificDetails}
{@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}${s3ResourcePath ? `&s3_resource_path=${encodeURIComponent(s3ResourcePath)}` : ''}`}
{@const downloadName =
fileMetadata?.fileKey.split('/').pop() ?? 'unnamed_download.file'}
{#if shouldDownloadViaClient()}
<Button
title="Download file from S3"
variant="default"
on:click={() => downloadViaClient(downloadApiPath, downloadName)}
startIcon={{ icon: Download }}
iconOnly={true}
/>
{:else}
<Button
title="Download file from S3"
variant="default"
href={`/api${downloadApiPath}`}
download={downloadName}
startIcon={{ icon: Download }}
iconOnly={true}
/>
{/if}
{/if}
{#if !readOnlyMode}
<Button
title="Move file"
variant="default"
on:click={() => {
moveDestKey = fileMetadata?.fileKey ?? ''
moveModalOpen = true
}}
startIcon={{ icon: MoveRight }}
iconOnly={true}
/>
{/if}
{#if !readOnlyMode || allowDelete}
<Button
title="Delete file"
variant="default"
on:click={() => {
deletionModalOpen = true
}}
startIcon={{ icon: Trash }}
iconOnly={true}
/>
{/if}
<Pane class="flex flex-col min-h-0 overflow-hidden">
<div
class="flex items-center gap-3 px-3 py-3 min-h-[3.75rem] shrink-0 border-b bg-surface-secondary"
>
{#if fileMetadata === undefined}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if fileInfoLoading}
<Loader2 size={14} class="animate-spin" /> Loading...
{:else if fileListUnavailable}
No file to preview
{:else}
Select a file to preview
{/if}
</div>
{:else}
{@const displayedKey = fileMetadata.fileKey.startsWith(rootPath)
? fileMetadata.fileKey.slice(rootPath.length)
: fileMetadata.fileKey}
{@const slash = displayedKey.lastIndexOf('/')}
{@const fileName = displayedKey.slice(slash + 1)}
{@const FileKindIcon = fileIconFor(fileName)}
{@const details = [
fileMetadata.mimeType,
fileMetadata.sizeStr,
fileMetadata.lastModified && `Modified ${fileMetadata.lastModified}`
].filter(Boolean)}
<div
class="shrink-0 flex items-center justify-center w-9 h-9 rounded-md border bg-surface text-secondary"
>
<FileKindIcon size={18} />
</div>
<div class="flex flex-col gap-0.5 min-w-0 grow">
<div class="flex items-center gap-1 min-w-0">
<span class="truncate text-sm font-semibold text-emphasis" title={displayedKey}>
{#if slash >= 0}<span class="font-normal text-tertiary"
>{displayedKey.slice(0, slash + 1)}</span
>{/if}{fileName}
</span>
<CopyButton value={displayedKey} title="Copy file key" class="shrink-0" />
</div>
{#if !hideS3SpecificDetails && details.length > 0}
<div class="flex flex-wrap items-center gap-x-1.5 text-xs text-secondary">
{#each details as detail, i}
{#if i > 0}<span class="text-tertiary">·</span>{/if}
<span>{detail}</span>
{/each}
</div>
{/if}
</div>
{#if !hideS3SpecificDetails}
<TableSimple
headers={['Last modified', 'Size', 'Type']}
data={[fileMetadata]}
keys={['lastModified', 'sizeStr', 'mimeType']}
/>
{#if tableSource}
<ToggleButtonGroup
noWFull
selected={viewMode}
onSelected={(v) => (viewMode = v)}
class="shrink-0"
>
{#snippet children({ item })}
<ToggleButton value="table" label="Table" icon={Table2} small {item} />
<ToggleButton value="text" label="Text" icon={FileText} small {item} />
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
{/if}
{#if filePreview?.contentType === 'Csv'}
<div class="flex items-center gap-4 shrink-0 text-xs text-secondary">
<div class="flex items-center gap-2">
<span>Separator</span>
<Select
size="sm"
class="w-16"
RightIcon={ChevronDown}
items={CSV_SEPARATORS}
bind:value={csvSeparatorChar}
placeholder={CSV_SEPARATORS.find((s) => s.value === detectedCsvSeparator)
?.label ?? 'Auto'}
/>
</div>
<Toggle size="xs" bind:checked={csvHasHeader} options={{ right: 'Header row' }} />
</div>
<div class="w-px h-6 border-l shrink-0"></div>
{/if}
{#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)}
<div class="flex gap-1 shrink-0">
{#if !hideS3SpecificDetails}
{@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata.fileKey)}${storage ? `&storage=${storage}` : ''}${s3ResourcePath ? `&s3_resource_path=${encodeURIComponent(s3ResourcePath)}` : ''}`}
{@const downloadName = fileName || 'unnamed_download.file'}
{#if shouldDownloadViaClient()}
<Button
title="Download"
variant="subtle"
unifiedSize="md"
onClick={() => downloadViaClient(downloadApiPath, downloadName)}
startIcon={{ icon: Download }}
iconOnly
/>
{:else}
<Button
title="Download"
variant="subtle"
unifiedSize="md"
href={`/api${downloadApiPath}`}
download={downloadName}
startIcon={{ icon: Download }}
iconOnly
/>
{/if}
{/if}
{#if !readOnlyMode}
<Button
title="Move"
variant="subtle"
unifiedSize="md"
onClick={() => {
moveDestKey = fileMetadata?.fileKey ?? ''
moveModalOpen = true
}}
startIcon={{ icon: MoveRight }}
iconOnly
/>
{/if}
{#if !readOnlyMode || allowDelete}
<Button
title="Delete"
variant="subtle"
unifiedSize="md"
destructive
onClick={() => {
deletionModalOpen = true
}}
startIcon={{ icon: Trash }}
iconOnly
/>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Visual preview extracted to a standalone S3FilePreview component
so the asset detail pane (and other surfaces) can render the
@@ -1355,17 +1519,37 @@
above for the download/move toolbar; S3FilePreview does an
independent load — fine on this non-hot path, and avoids
plumbing pre-loaded state through component boundaries. -->
<S3FilePreview
fileKey={fileMetadata?.fileKey}
{storage}
{s3ResourcePath}
workspace={ws}
{loadFilePreviewRequest}
{loadFileMetadataRequest}
class="h-full"
/>
</div>
</div>
{#if showTable}
<div class="flex-1 min-h-0 relative">
{#if currentTable?.ops}
{#key currentTable.ops}
<DBTable dbTableOps={currentTable.ops} />
{/key}
{:else if tableOps.error}
<div class="p-3 text-xs text-red-600 dark:text-red-400 break-words">
Could not read this file as a table: {tableOps.error.message}
</div>
{:else}
<div class="flex items-center gap-2 p-3 text-xs text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading table...
</div>
{/if}
</div>
{:else}
<S3FilePreview
fileKey={fileMetadata?.fileKey}
{storage}
{s3ResourcePath}
workspace={ws}
{loadFilePreviewRequest}
{loadFileMetadataRequest}
class="flex-1 min-h-0"
csvOptions={{ separator: csvSeparatorChar, hasHeader: csvHasHeader }}
bodyClass="bg-transparent px-3"
/>
{/if}
</Pane>
</Splitpanes>
{/if}
<ConfirmationModal
@@ -5,8 +5,10 @@
// renderings without dragging in the whole picker UI.
//
// The component owns its own metadata + preview loads — callers only
// need to hand it a `fileKey`. CSV separator/header are local state so
// the user can re-preview the same file with different parsing flags.
// need to hand it a `fileKey`. CSV separator/header are local state (unless
// the caller passes `csvOptions`) so the user can re-preview the same file
// with different parsing flags.
import { untrack } from 'svelte'
import { FileX2, Loader2 } from 'lucide-svelte'
import {
HelpersService,
@@ -46,6 +48,11 @@
// metadata out themselves (the picker already has its own).
showMetadata?: boolean
class?: string
/** CSV parsing options owned by the caller, which then renders its own controls:
* the preview hides its own and reloads when these change. */
csvOptions?: { separator: string | undefined; hasHeader: boolean }
/** Merged onto the preview body, below the metadata strip. */
bodyClass?: string
// Bump this to force a re-fetch (metadata + preview). Used by the
// asset detail pane after an upstream producer run completes —
// without it, the "Asset not yet materialized" empty state stays
@@ -62,6 +69,8 @@
loadFileMetadataRequest = HelpersService.loadFileMetadata,
showMetadata = false,
class: className = '',
bodyClass = '',
csvOptions = undefined,
refreshKey
}: Props = $props()
@@ -179,8 +188,8 @@
fileKey: key,
fileSizeInBytes: size,
fileMimeType: mimeType,
csvSeparator: csvSeparatorChar,
csvHasHeader: csvHasHeader,
csvSeparator: csvOptions ? csvOptions.separator : csvSeparatorChar,
csvHasHeader: csvOptions?.hasHeader ?? csvHasHeader,
readBytesFrom: 0,
readBytesLength: 128 * 1024,
storage,
@@ -221,6 +230,16 @@
void reloadPreview(fileMetadata.fileKey, fileMetadata.size, fileMetadata.mimeType)
}
}
// Skips the first run: the file load already fetches the preview with these options.
let lastCsvOptionsKey: string | undefined
$effect(() => {
const key = csvOptions ? `${csvOptions.separator}|${csvOptions.hasHeader}` : undefined
if (lastCsvOptionsKey !== undefined && key !== lastCsvOptionsKey) {
untrack(onCsvControlsChanged)
}
lastCsvOptionsKey = key
})
</script>
<div class={twMerge('flex flex-col h-full w-full overflow-auto text-xs', className)}>
@@ -231,7 +250,7 @@
{#if fileMetadata.lastModified}<span>{fileMetadata.lastModified}</span>{/if}
</div>
{/if}
<div class="flex-1 min-h-0 overflow-auto p-4 bg-surface-secondary">
<div class={twMerge('flex-1 min-h-0 overflow-auto p-4 bg-surface-secondary', bodyClass)}>
{#if !fileKey}
<div class="text-tertiary text-xs">No file selected.</div>
{:else if fileInfoLoading && !fileMetadata && !notFound && !loadError}
@@ -291,32 +310,36 @@
<Loader2 size={12} class="animate-spin mr-1" /> File preview loading
</div>
{:else if fileMetadata !== undefined && filePreview !== undefined}
<div class="flex items-center text-primary mb-4">
{#if filePreview.contentType === 'Unknown'}
{#if filePreview.contentType === 'Unknown'}
<div class="flex items-center text-primary mb-4">
Type of file not supported for preview.
{:else if filePreview.contentType === 'Csv'}
Previewing a {filePreview.contentType?.toLowerCase()} file. Separator character:
<div class="inline-flex w-12 ml-2 mr-2">
<select class="h-8" bind:value={csvSeparatorChar} onchange={onCsvControlsChanged}>
<option value=",">,</option>
<option value=";">;</option>
<option value="\t">\t</option>
<option value="|">|</option>
</select>
</div>
Header row:
<div class="inline-flex item-center w-4 ml-2 mr-2">
<input
type="checkbox"
class="h-5"
bind:checked={csvHasHeader}
onchange={onCsvControlsChanged}
/>
</div>
{:else}
Previewing a {filePreview.contentType?.toLowerCase()} file.
{/if}
</div>
</div>
{:else if !csvOptions}
<div class="flex items-center text-primary mb-4">
{#if filePreview.contentType === 'Csv'}
Previewing a {filePreview.contentType?.toLowerCase()} file. Separator character:
<div class="inline-flex w-12 ml-2 mr-2">
<select class="h-8" bind:value={csvSeparatorChar} onchange={onCsvControlsChanged}>
<option value=",">,</option>
<option value=";">;</option>
<option value="\t">\t</option>
<option value="|">|</option>
</select>
</div>
Header row:
<div class="inline-flex item-center w-4 ml-2 mr-2">
<input
type="checkbox"
class="h-5"
bind:checked={csvHasHeader}
onchange={onCsvControlsChanged}
/>
</div>
{:else}
Previewing a {filePreview.contentType?.toLowerCase()} file.
{/if}
</div>
{/if}
<pre class="grow whitespace-no-wrap break-words"
>{#if !emptyString(filePreview.contentPreview)}{filePreview.contentPreview}{:else if filePreview.contentType !== undefined}Preview impossible.{/if}</pre
>
@@ -41,6 +41,8 @@ export type ColumnDef = {
children?: ColumnDef[]
// DBExplorer
ignored?: boolean
/** False when the column cannot be filtered on, e.g. a nested value. */
filterable?: boolean
hideInsert?: boolean
editable?: boolean
overrideDefaultValue?: boolean
@@ -52,7 +52,7 @@ export function buildDbTableFilterSchema(columns: ColumnDef[]): DbTableFilterSch
const columnOfKey: Record<string, string> = {}
const keyOfColumn: Record<string, string> = {}
for (const col of columns) {
if (!col.field) continue
if (!col.field || col.filterable === false) continue
let base = col.field.replace(/\W+/g, '_') || 'column'
if (base === FREE_TEXT_KEY) base = `${base}col`
let key = base
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { datatypeOf, jsonTableRows, serverTableFormat } from './s3TableOps'
import { dbColumnKind } from './dbTableFilters'
describe('serverTableFormat', () => {
it('pages delimited text and newline-delimited JSON through the csv reader', () => {
expect(serverTableFormat('a/b.parquet')).toBe('parquet')
for (const key of ['a.csv', 'a.TSV', 'a.jsonl', 'a.ndjson']) {
expect(serverTableFormat(key)).toBe('csv')
}
})
it('leaves plain JSON to the browser', () => {
expect(serverTableFormat('a.json')).toBeUndefined()
expect(serverTableFormat('a.txt')).toBeUndefined()
})
})
describe('datatypeOf', () => {
it('maps Arrow types to the kinds the grid filters by', () => {
expect(dbColumnKind(datatypeOf('Int64'))).toBe('number')
expect(dbColumnKind(datatypeOf('UInt8'))).toBe('number')
expect(dbColumnKind(datatypeOf('Float64'))).toBe('number')
expect(dbColumnKind(datatypeOf('Decimal128(21, 1)'))).toBe('number')
expect(dbColumnKind(datatypeOf('Boolean'))).toBe('boolean')
expect(dbColumnKind(datatypeOf('Utf8View'))).toBe('text')
})
it('names a nested type without its field list', () => {
expect(datatypeOf('List(Field { name: "item", data_type: Utf8 })')).toBe('list')
expect(datatypeOf('Timestamp(Microsecond, None)')).toBe('timestamp')
})
})
describe('jsonTableRows', () => {
it('keeps objects and wraps other elements in a value column', () => {
expect(jsonTableRows([{ a: 1 }, 2, null])).toEqual([{ a: 1 }, { value: 2 }, { value: null }])
})
it('has no table for an empty array or a non-array', () => {
expect(jsonTableRows([])).toBeUndefined()
expect(jsonTableRows({ a: 1 })).toBeUndefined()
})
})
+170
View File
@@ -0,0 +1,170 @@
import { HelpersService, OpenAPI } from '$lib/gen'
import { getHeaders } from '$lib/gen/core/request'
import { ColumnIdentity, type ColumnDef } from './apps/components/display/dbtable/utils'
import type { IDbTableOps } from './dbOps'
/** Files the server can page through as a table, without reading them whole. */
export function serverTableFormat(fileKey: string): 'parquet' | 'csv' | undefined {
const key = fileKey.toLowerCase()
if (key.endsWith('.parquet')) return 'parquet'
if (/\.(csv|tsv|jsonl|ndjson)$/.test(key)) return 'csv'
return undefined
}
export type S3TableSource = {
workspace: string
fileKey: string
storage?: string
s3ResourcePath?: string
csvSeparator?: string
csvHasHeader?: boolean
}
type Chunk = {
columns: string[]
column_types?: string[]
rows: Record<string, unknown>[]
csv_separator?: string
}
/** An Arrow type as the SQL type name the grid filters and formats by. */
export function datatypeOf(arrowType: string | undefined): string {
const t = arrowType ?? ''
if (/^U?Int\d+$/.test(t)) return 'bigint'
if (/^Float\d+$/.test(t)) return 'double'
if (/^Decimal/.test(t)) return 'decimal'
if (t === 'Boolean') return 'boolean'
if (/^(Large)?Utf8/.test(t)) return 'text'
// A nested type spells out its fields, e.g. `List(Field { name: "item", … })`.
return t.split('(')[0].toLowerCase() || 'text'
}
/** Read-only table ops over an object storage file, paged, searched, filtered and sorted
* by the server, with the CSV separator it read the file with (guessed when none is given).
*/
export async function s3TableOps(
source: S3TableSource
): Promise<{ ops: IDbTableOps; csvSeparator?: string }> {
const format = serverTableFormat(source.fileKey)
if (!format) throw new Error(`${source.fileKey} cannot be read as a table`)
const common = {
workspace: source.workspace,
path: source.fileKey,
storage: source.storage,
s3ResourcePath: source.s3ResourcePath
}
const csvOptions: { csvSeparator?: string; csvHasHeader?: boolean } =
format === 'csv' ? { csvSeparator: source.csvSeparator, csvHasHeader: source.csvHasHeader } : {}
const filtersParam = (columnFilters: Record<string, unknown> | undefined) =>
columnFilters && Object.keys(columnFilters).length ? JSON.stringify(columnFilters) : undefined
const searchParam = (quicksearch: string) => quicksearch.trim() || undefined
async function loadChunk(params: {
offset: number
limit: number
quicksearch?: string
columnFilters?: Record<string, unknown>
orderBy?: string
isDesc?: boolean
}): Promise<Chunk> {
const data = {
...common,
...csvOptions,
offset: params.offset,
limit: params.limit,
searchTerm: searchParam(params.quicksearch ?? ''),
filters: filtersParam(params.columnFilters),
sortCol: params.orderBy,
sortDesc: params.isDesc
}
return (await (format === 'parquet'
? HelpersService.loadParquetPreview(data)
: HelpersService.loadCsvPreview(data))) as Chunk
}
const head = await loadChunk({ offset: 0, limit: 0 })
// Later reads reuse the separator the server guessed, rather than each guessing again.
if (format === 'csv') csvOptions.csvSeparator ??= head.csv_separator
const colDefs: ColumnDef[] = head.columns.map((field, i) => ({
field,
datatype: datatypeOf(head.column_types?.[i]),
// The server cannot read a struct, map or union as text to filter it.
filterable: !/^(Struct|Map|Union)/.test(head.column_types?.[i] ?? ""),
defaultvalue: '',
isprimarykey: false,
isidentity: ColumnIdentity.No,
isnullable: 'YES',
isenum: false
}))
const ops: IDbTableOps = {
dbType: 'duckdb',
tableKey: source.fileKey.split('/').pop() ?? source.fileKey,
colDefs,
getCount: async ({ quicksearch, columnFilters }) => {
const res = await HelpersService.loadTableRowCount({
...common,
...csvOptions,
searchTerm: searchParam(quicksearch),
filters: filtersParam(columnFilters)
})
return res.count ?? 0
},
getRows: async ({
offset,
limit,
quicksearch,
columnFilters,
order_by,
is_desc,
explicitSort
}) =>
(
await loadChunk({
offset,
limit,
quicksearch,
columnFilters,
// Unsorted, a file keeps its own row order.
orderBy: explicitSort ? order_by : undefined,
isDesc: is_desc
})
).rows
}
return { ops, csvSeparator: head.csv_separator }
}
/** Plain JSON cannot be paged by the server, so only a file this small is read whole into
* the browser to be shown as a table. */
export const JSON_TABLE_MAX_BYTES = 10 * 1024 * 1024
export function isJsonFile(fileKey: string): boolean {
return fileKey.toLowerCase().endsWith('.json')
}
/** Rows of a JSON array, an element that is not an object becoming a one-column row;
* undefined for anything else. */
export function jsonTableRows(value: unknown): Record<string, unknown>[] | undefined {
if (!Array.isArray(value) || value.length === 0) return undefined
return value.map((v) =>
v !== null && typeof v === 'object' && !Array.isArray(v)
? (v as Record<string, unknown>)
: { value: v }
)
}
export async function fetchJsonTableRows(
source: S3TableSource
): Promise<Record<string, unknown>[] | undefined> {
const query = new URLSearchParams({ file_key: source.fileKey })
if (source.storage) query.set('storage', source.storage)
if (source.s3ResourcePath) query.set('s3_resource_path', source.s3ResourcePath)
const apiPath = `/w/${source.workspace}/job_helpers/download_s3_file?${query}`
const headers = await getHeaders(OpenAPI, { method: 'GET', url: apiPath })
const response = await fetch(`${OpenAPI.BASE}${apiPath}`, {
headers,
credentials: OpenAPI.CREDENTIALS
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
return jsonTableRows(await response.json())
}