Files
windmill/frontend/src/lib/components/S3FilePreview.svelte
T
2026-05-01 11:57:57 +00:00

302 lines
11 KiB
Svelte

<script lang="ts">
// Standalone preview for an S3-backed object. Extracted from
// S3FilePickerInner so the asset detail pane (and any other surface that
// wants to peek at S3 contents) can render the same image/PDF/CSV/text
// 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.
import { FileX2, Loader2 } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import {
HelpersService,
type CancelablePromise,
type LoadFileMetadataData,
type LoadFileMetadataResponse,
type LoadFilePreviewData,
type LoadFilePreviewResponse
} from '$lib/gen'
import { displayDate, displaySize, emptyString } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
interface Props {
fileKey: string | undefined
// Optional override of the storage backend (matches the picker's
// `storage` prop). Empty/undefined uses the workspace default.
storage?: string | undefined
// Override hooks for non-default backends (e.g. workspace settings
// preview before the storage is committed). Default to the standard
// helpers service — same defaults as S3FilePickerInner.
loadFilePreviewRequest?: (d: LoadFilePreviewData) => CancelablePromise<LoadFilePreviewResponse>
loadFileMetadataRequest?: (
d: LoadFileMetadataData
) => CancelablePromise<LoadFileMetadataResponse>
// When true, surface a small metadata strip above the preview
// (size, last-modified, mime). Off by default so callers can lay
// metadata out themselves (the picker already has its own).
showMetadata?: boolean
class?: 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
// pinned until the user re-selects the asset.
refreshKey?: any
}
let {
fileKey,
storage = undefined,
loadFilePreviewRequest = HelpersService.loadFilePreview,
loadFileMetadataRequest = HelpersService.loadFileMetadata,
showMetadata = false,
class: className = '',
refreshKey
}: Props = $props()
let csvSeparatorChar: string = $state(',')
let csvHasHeader: boolean = $state(true)
let fileMetadata = $state<
| {
fileKey: string
mimeType: string | undefined
size: number | undefined
sizeStr: string | undefined
lastModified: string | undefined
}
| undefined
>(undefined)
let filePreview = $state<
| {
fileKey: string
contentPreview: string | undefined
contentType: string | undefined
}
| undefined
>(undefined)
let filePreviewLoading = $state(false)
let fileInfoLoading = $state(false)
// Distinct error states. `notFound` is the common case for assets that
// have been declared but never actually materialized (e.g. a fresh
// pipeline whose producer hasn't run yet) — surfaced as a calm empty
// state rather than a scary HTTP error. `loadError` is everything else
// (auth failures, transient S3 hiccups, malformed keys) and shows the
// raw message so users can debug.
let notFound = $state(false)
let loadError = $state<string | undefined>(undefined)
function isNotFoundError(err: any): boolean {
// HelpersService surfaces backend errors as ApiError with a `status`
// field plus a serialized body. We accept either a 404 status or a
// "not found" substring (case-insensitive) to be robust against
// future error wrapping changes.
const status = err?.status ?? err?.response?.status
if (status === 404) return true
const body = String(err?.body ?? err?.message ?? err ?? '').toLowerCase()
return body.includes('not found') || body.includes('404')
}
// Reload whenever the file key, workspace, or external refreshKey
// changes. The refreshKey path is what lets the asset pane re-check
// existence after an upstream run completes — moving from the
// "not yet materialized" empty state to the actual preview without
// requiring the user to re-click the asset.
$effect(() => {
const key = fileKey
const ws = $workspaceStore
void refreshKey
if (!key || !ws) {
fileMetadata = undefined
filePreview = undefined
notFound = false
loadError = undefined
return
}
void loadAll(key)
})
async function loadAll(key: string) {
if (emptyString(key)) {
fileInfoLoading = false
return
}
fileInfoLoading = true
filePreview = undefined
fileMetadata = undefined
notFound = false
loadError = undefined
try {
const meta = await loadFileMetadataRequest({
workspace: $workspaceStore!,
fileKey: key,
storage
})
if (meta !== undefined) {
fileMetadata = {
fileKey: key,
size: meta.size_in_bytes,
sizeStr: displaySize(meta.size_in_bytes),
mimeType: meta.mime_type,
lastModified: displayDate(meta.last_modified)
}
}
await reloadPreview(key, meta?.size_in_bytes, meta?.mime_type)
} catch (err: any) {
if (isNotFoundError(err)) {
notFound = true
} else {
loadError = err?.body ?? err?.message ?? String(err)
}
} finally {
fileInfoLoading = false
}
}
async function reloadPreview(key: string, size?: number, mimeType?: string) {
filePreviewLoading = true
try {
const raw = await loadFilePreviewRequest({
workspace: $workspaceStore!,
fileKey: key,
fileSizeInBytes: size,
fileMimeType: mimeType,
csvSeparator: csvSeparatorChar,
csvHasHeader: csvHasHeader,
readBytesFrom: 0,
readBytesLength: 128 * 1024,
storage
})
let content = raw.content
if (content !== null && content !== undefined && content.length >= 128 * 1024) {
content = content.substring(0, 128 * 1024 - 35) + '\n\n ... FILE CONTENT TRUNCATED ...\n\n'
}
if (raw !== undefined) {
filePreview = { fileKey: key, contentPreview: content, contentType: raw.content_type }
if (fileMetadata) {
fileMetadata.mimeType =
((key.endsWith('.png') ||
key.endsWith('.jpg') ||
key.endsWith('.jpeg') ||
key.endsWith('.webp')) &&
'Image') ||
(key.endsWith('.pdf') && 'PDF') ||
filePreview.contentType
}
}
} finally {
filePreviewLoading = false
}
}
// `storage` is keyed by the workspace's S3 storage config name — used as
// a query-string suffix on the image/PDF preview URLs.
let storageQS = $derived(storage ? `&storage=${storage}` : '')
function onCsvControlsChanged() {
if (fileMetadata?.fileKey) {
void reloadPreview(fileMetadata.fileKey, fileMetadata.size, fileMetadata.mimeType)
}
}
</script>
<div class={twMerge('flex flex-col h-full w-full overflow-auto text-xs', className)}>
{#if showMetadata && fileMetadata}
<div class="text-2xs text-tertiary px-3 py-1.5 border-b flex flex-wrap gap-x-3 gap-y-0.5">
{#if fileMetadata.sizeStr}<span>{fileMetadata.sizeStr}</span>{/if}
{#if fileMetadata.mimeType}<span>{fileMetadata.mimeType}</span>{/if}
{#if fileMetadata.lastModified}<span>{fileMetadata.lastModified}</span>{/if}
</div>
{/if}
<div class="flex-1 min-h-0 overflow-auto p-4 bg-surface-secondary">
{#if !fileKey}
<div class="text-tertiary text-xs">No file selected.</div>
{:else if fileInfoLoading && !fileMetadata && !notFound && !loadError}
<div class="flex items-center text-primary gap-1">
<Loader2 size={12} class="animate-spin" /> Loading…
</div>
{:else if notFound}
<!-- Calm empty state for assets that have been declared (e.g. by a
`// out s3://...` annotation or parsed write) but never
actually written. Common during pipeline authoring before
any producer has run. We surface the file key so users can
verify they're looking at the right place. -->
<div class="flex flex-col items-center justify-center text-center gap-2 py-8">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-surface-tertiary text-tertiary"
>
<FileX2 size={18} />
</div>
<div class="text-sm font-medium text-primary">Asset not yet materialized</div>
<div class="text-2xs text-tertiary max-w-sm">
This asset has been declared in the pipeline but no producer has written to it yet. Run an
upstream script to populate it.
</div>
<div class="text-3xs font-mono text-tertiary mt-1 break-all max-w-md">{fileKey}</div>
</div>
{:else if loadError}
<!-- Non-404 errors: still structured but show the message so
users can debug auth / connectivity / malformed-key issues. -->
<div class="flex flex-col items-start gap-1 py-2">
<div class="text-sm font-medium text-red-700 dark:text-red-400">Could not load asset</div>
<div class="text-2xs text-tertiary break-all">{loadError}</div>
</div>
{:else if fileMetadata?.fileKey.endsWith('.png') || fileMetadata?.fileKey.endsWith('.jpg') || fileMetadata?.fileKey.endsWith('.jpeg') || fileMetadata?.fileKey.endsWith('.webp')}
<div>
<img
src={`/api/w/${$workspaceStore}/job_helpers/load_image_preview?file_key=${encodeURIComponent(
fileMetadata.fileKey
)}${storageQS}`}
alt="S3 preview"
/>
</div>
{:else if fileMetadata?.fileKey.endsWith('.pdf')}
<div class="w-full h-[950px] border">
{#await import('$lib/components/display/PdfViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
source={`/api/w/${$workspaceStore}/job_helpers/load_image_preview?file_key=${encodeURIComponent(
fileMetadata.fileKey
)}${storageQS}`}
/>
{/await}
</div>
{:else if filePreviewLoading}
<div class="flex h-6 items-center text-primary mb-4">
<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'}
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>
<pre class="grow whitespace-no-wrap break-words"
>{#if !emptyString(filePreview.contentPreview)}{filePreview.contentPreview}{:else if filePreview.contentType !== undefined}Preview impossible.{/if}</pre
>
{/if}
</div>
</div>