mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
aae5f2efaa
commit
9fa9b77926
@@ -1 +1 @@
|
||||
599eae0034b34c5e0d3d42df37cf18bbe01f9b49
|
||||
06610ecc950ed64a2709d7f1aa8c7b49a95fdbae
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user