Files
windmill/frontend/src/lib/components/S3FilePickerInner.svelte
T
386e115daa feat: lazily expand s3 explorer folders one level at a time (#10420)
* feat: wire paged object storage listing module

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* feat: document list_stored_files_paged endpoint in openapi

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* feat: lazily expand s3 explorer folders one level at a time

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: pin ee-repo-ref to the paged listing branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: share object_store credential resolution and surface listing errors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqq2LhmWaGwP11Cf3UqWxe

* Chevron is cool

* page size 5000

* feat: make the load more row full-width, secondary and chevron-led

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqq2LhmWaGwP11Cf3UqWxe

* fix: render newly loaded flat pages inside already-expanded folders

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: address review findings in the lazy s3 explorer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: address review nits in the lazy s3 explorer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: bump ee-repo-ref after merging main

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: document ambient credential contract and constrain max_keys schema

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: treat an exhausted page token as exhausted, not as a continuation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: bump ee-repo-ref for canonical prefix validation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: bump ee-repo-ref for prefix scoping and opaque cursors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: invalidate a folder's in-flight load when deleting from it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: discard a stale folder page after its level is invalidated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: bump ee-repo-ref for bounded local listing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: label folders whose final path segment is empty

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* feat: search files by any part of their path, not just folder prefix

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* feat: search files by path prefix instead of a full-bucket scan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* fix: guard stale search responses and describe prefix search accurately

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: bump ee-repo-ref for the search prefix fallback fix

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: regenerate the served openapi specs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: bump ee-repo-ref for the search cursor fallback fix

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqAc8mz6Gu698kBbJJVwcT

* chore: bump ee-repo-ref for the bounded search scan

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

* fix: surface a failed flat listing instead of spinning forever

The flat branch of loadFiles was awaited without a catch, and loadFlatFiles
clears its loading flags only on the success tail. Every caller reaches it
un-awaited, so a rejected listing left the drawer on "Loading content" with
nothing reported. Routing the filter box through this arm made it reachable
per keystroke rather than once per open.

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

* fix: give back the flat cursor when a page fails to load

"Load more" advanced `page` before requesting it, so a failed page left the
cursor pointing at a `listMarkers` slot that was never filled. The retry sent
no marker at all and silently replayed the first page, and the
`listMarkers.length == page` guard kept it there until the listing was reset.

Only reachable now that a failed page is retryable rather than a permanent
spinner.

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

* fix: scope the flat cursor rollback to its own listing

The rollback matched on the page number alone, so a page that failed after a
filter or storage change could roll back the *replacement* listing once it had
reached the same number, stranding its cursor. Tie it to the generation the
request was issued under.

The delete replay loop had the mirrored problem: it re-drove `page` by hand and
carried on past a failed page, leaving `page` ahead of `listMarkers` for good.

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

* fix: skip the delete replay when the fresh listing itself failed

clearAndLoadFiles dropped the result it already computes, so a failed
post-delete listing still ran the replay loop: each page advanced `page` with
an empty `listMarkers`, which never recovers because the marker-length guard
only pushes when the two agree. Every later "Load more" then replayed page one.

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

* fix: stop a superseded lazy load from writing into the search that replaced it

loadFolderPage resolves rather than throwing once its generation is stale, so a
filter change that switches the picker to the flat listing mid-flight left the
lazy branch free to expand a preselected file into the search's results and to
clear the search's loading flags. Guard both on the generation it started under,
as the flat branch already does.

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

* fix: check the listing generation throughout the reveal walk

Revealing a preselected key is a chain of round trips, so checking once at entry
left the rest of the walk free to keep loading after a filter change had already
switched the picker to the search — under the replacement generation, so the
per-level guards inside loadFolderPage saw nothing wrong.

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

* fix: let a late metadata failure clear only its own preview

The handler blanked fileMetadata and filePreview without checking that its
request still owned the pane, so selecting a second file while the first was
still loading meant the first's rejection wiped the second's preview.

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

* fix: key preview ownership on the request, not the selected key

Comparing the selected key let an older request speak for a newer one when both
targeted the same key, which a storage switch does, and made a request whose
selection had moved to something with no metadata return early with the spinner
still up — the case the handler exists to prevent.

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

* fix: clear the preview when the previewed file is deleted

The lazy branch refetches only the affected level and returns, so it never
reached the reset that the flat refresh gets from clearAndLoadFiles. The pane
renders from fileMetadata rather than from the selection, leaving the deleted
file previewed with working download, move and delete actions.

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

* fix: retire the in-flight preview load when its file is deleted

Clearing the pane was not enough: a metadata response computed before the DELETE
landed still repopulated it, restoring the deleted file's preview and its
download, move and delete actions. Deleting now retires the owning request, and
the success and preview writes honour that the same way the failure path does.

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

* fix: clear the preview loading flag when the delete retires its request

Retiring the in-flight metadata load left nobody to report its outcome, so in
lazy mode the pane sat on "Loading..." instead of falling back to the empty
state. The delete owns the flag once it has retired the request.

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

* chore: drop the regenerated openapi deref artifacts

They are generated files that CI only syntax-validates, never checks against
openapi.yaml, and the committed copies already differ from the spec they derive
from by ~9.7k lines. Regenerating here imported that pre-existing drift into a
feature diff, burying ~800 lines of actual change under ~17k lines of other
changes' staleness. Regenerating them is its own chore.

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

* docs: state the flat cursor invariant once, where the cursor lives

It was spelled out at four sites, which is what AGENTS.md asks not to do. The
rule now sits on the declaration it constrains and the guards reference it.

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

* nit ui

* fix: add the paged listing to the served openapi json

openapi_json() embeds openapi-deref.json via include_str!, and the Docker build
regenerates only the yaml artifact, so the json is served exactly as committed —
leaving the new operation out of the Scalar API reference.

Spliced in the operation and the two schemas it references rather than
regenerating, which would have re-imported ~7k lines of pre-existing drift
between the committed artifact and the spec it derives from.

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

* chore: bump ee-repo-ref for the filesystem symlink boundary

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

* chore: update ee-repo-ref to 0373b4bfdaf8dd51533552e2e4de63ceb3c18b4d

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

Previous ee-repo-ref: eb1a765bb9b29e0c94a6e4942c304934fa15406e

New ee-repo-ref: 0373b4bfdaf8dd51533552e2e4de63ceb3c18b4d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-08-04 01:41:06 +02:00

1429 lines
48 KiB
Svelte

<script lang="ts">
import {
File as FileIcon,
FolderClosed,
FolderOpen,
ChevronDown,
RotateCw,
Loader2,
Download,
Trash,
MoveRight
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import {
CancelablePromise,
HelpersService,
type DatasetStorageTestConnectionData,
type DatasetStorageTestConnectionResponse,
type DeleteS3FileData,
type DeleteS3FileResponse,
type ListStoredFilesData,
type ListStoredFilesPagedData,
type ListStoredFilesPagedResponse,
type ListStoredFilesResponse,
type LoadFileMetadataData,
type LoadFileMetadataResponse,
type LoadFilePreviewData,
type LoadFilePreviewResponse,
type MoveS3FileData,
type MoveS3FileResponse
} from '$lib/gen'
import { base } from '$lib/base'
import {
displayDate,
displaySize,
emptyString,
parseS3Object,
sendUserToast,
type S3Object
} from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { Alert, Button } from './common'
import Section from './Section.svelte'
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'
import { twMerge } from 'tailwind-merge'
let deletionModalOpen = $state(false)
let fileDeletionInProgress = $state(false)
let fileListUnavailable: boolean | undefined = $state(undefined)
let moveModalOpen = $state(false)
let moveDestKey: string | undefined = $state(undefined)
let fileMoveInProgress = $state(false)
let initialFileKeyInternalCopy: { s3: string; storage?: string }
interface Props {
fromWorkspaceSettings?: boolean
readOnlyMode: boolean
initialFileKey?: { s3: string; storage?: string } | undefined
selectedFileKey?: { s3: string; storage?: string } | undefined
folderOnly?: boolean
regexFilter?: RegExp | undefined
hideS3SpecificDetails?: boolean
rootPath?: string
/** Workspace to browse S3 storage in — defaults to the nav workspace. */
workspace?: string | undefined
workspaceSettingsInitialized?: boolean
storage?: string | undefined
/** Browse this object storage resource directly instead of the workspace storage. */
s3ResourcePath?: string | undefined
uploadModalOpen?: boolean
allFilesByKey?: Record<
string,
{
type: 'folder' | 'leaf'
full_key: string
display_name: string
collapsed: boolean
parentPath: string | undefined
nestingLevel: number
count: number
}
>
allowDelete?: boolean
replaceUnauthorizedWarning?: Snippet
/**
* Expand one folder level at a time instead of listing every key up front.
* Callers that override `listStoredFilesRequest` with a listing that has no
* paged counterpart (e.g. git repo files) must turn this off.
*/
lazyFolders?: boolean
listStoredFilesRequest?: (d: ListStoredFilesData) => CancelablePromise<ListStoredFilesResponse>
listStoredFilesPagedRequest?: (
d: ListStoredFilesPagedData
) => CancelablePromise<ListStoredFilesPagedResponse>
loadFilePreviewRequest?: (d: LoadFilePreviewData) => CancelablePromise<LoadFilePreviewResponse>
loadFileMetadataRequest?: (
d: LoadFileMetadataData
) => CancelablePromise<LoadFileMetadataResponse>
deleteS3FileRequest?: (d: DeleteS3FileData) => CancelablePromise<DeleteS3FileResponse>
moveS3FileRequest?: (d: MoveS3FileData) => CancelablePromise<MoveS3FileResponse>
testConnectionRequest?: (
d: DatasetStorageTestConnectionData
) => CancelablePromise<DatasetStorageTestConnectionResponse>
}
let {
fromWorkspaceSettings = false,
readOnlyMode,
initialFileKey = $bindable(undefined),
selectedFileKey = $bindable(undefined),
folderOnly = false,
regexFilter = undefined,
hideS3SpecificDetails = false,
rootPath: initialRootPath = '',
workspace = undefined,
workspaceSettingsInitialized = $bindable(true),
storage = $bindable(undefined),
s3ResourcePath = undefined,
uploadModalOpen = $bindable(false),
allFilesByKey = $bindable({}),
allowDelete = false,
replaceUnauthorizedWarning,
lazyFolders = true,
listStoredFilesRequest = HelpersService.listStoredFiles,
listStoredFilesPagedRequest = HelpersService.listStoredFilesPaged,
loadFilePreviewRequest = HelpersService.loadFilePreview,
loadFileMetadataRequest = HelpersService.loadFileMetadata,
deleteS3FileRequest = HelpersService.deleteS3File,
moveS3FileRequest = HelpersService.moveS3File,
testConnectionRequest = HelpersService.datasetStorageTestConnection
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let rootPath = $state(initialRootPath)
let rootPathNestingLevel = $derived(1 * (rootPath.split('/').length - 1))
let csvSeparatorChar: string = $state(',')
let csvHasHeader: boolean = $state(true)
let dispatch = createEventDispatcher<{
close: { s3: string; storage: string | undefined } | undefined
selectAndClose: { s3: string; storage: string | undefined }
}>()
let fileInfoLoading: boolean = $state(true)
let fileListLoading: boolean = $state(true)
let displayedFileKeys: string[] = $state([])
let listDivHeight: number = $state(0)
let fileMetadata:
| {
fileKey: string
mimeType: string | undefined
size: number | undefined
sizeStr: string | undefined
lastModified: string | undefined
}
| undefined = $state(undefined)
// `filePreviewLoading` was previously templated; now that the visual
// preview lives in <S3FilePreview/> the picker only uses filePreview to
// gate the toolbar and refine fileMetadata.mimeType, so the standalone
// loading flag is gone.
let filePreview:
| {
fileKey: string
contentPreview: string | undefined
contentType: string | undefined
}
| undefined = $state(undefined)
/** Identifies the metadata request that currently owns the preview pane. */
let metadataRequestId = 0
/**
* Flat pagination cursor: `listMarkers[n]` is where page `n + 1` resumes, so `page`
* may only advance over a page that actually loaded. Running ahead of `listMarkers`
* sends no marker and silently replays the first page, and never recovers, because
* the `listMarkers.length == page` guard stops recording from then on.
*/
let listMarkers: string[]
let page = $state(0)
const maxKeys = 1000
/** Entries fetched per folder level before a "Load more" row appears. */
const pageSize = 500
let count = $state(0)
let displayedCount = $state(0)
/** Flat (non-lazy) listing: whether the last page came back full. */
let flatHasMore = $state(false)
let filter = $state('')
let timeout: number | undefined = undefined
let firstLoad = true
function onFilterChange() {
if (!firstLoad) {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
clearAndLoadFiles({ keepFilter: true })
}, 500)
} else {
firstLoad = false
}
}
/**
* Marks the synthetic "Load more" row belonging to a folder. Appended to the
* folder's own prefix so the plain lexicographic sort that orders the tree also
* places the row last among that folder's children.
*/
const LOAD_MORE_SUFFIX = '￿'
type FolderState = { nextPageToken?: string; loading: boolean; loaded: boolean }
let folderState: Record<string, FolderState> = $state({})
/** Per-level listing only makes sense while browsing; searching stays flat. */
let lazyMode = $derived(lazyFolders && filter.trim() === '')
function nestingLevelOf(key: string): number {
const slashes = (key.match(/\//g) ?? []).length
return (key.endsWith('/') ? slashes - 1 : slashes) * 2
}
function parentPathOf(key: string): string | undefined {
const body = key.endsWith('/') ? key.slice(0, -1) : key
const idx = body.lastIndexOf('/')
return idx === -1 ? undefined : body.slice(0, idx + 1)
}
/** The parentPath value carried by entries sitting at the browsing root. */
let rootParentPath = $derived(rootPath === '' ? undefined : rootPath)
function addEntry(key: string, type: 'folder' | 'leaf', displayName: string) {
if (allFilesByKey[key] !== undefined) return
allFilesByKey[key] = {
type,
full_key: key,
display_name: displayName,
collapsed: true,
parentPath: parentPathOf(key),
nestingLevel: nestingLevelOf(key),
count: 0
}
}
/**
* A level shows only when every folder above it is expanded. The browsing root is
* spelled `rootPath` by `folderState` but `undefined` by an entry's `parentPath`
* (there is no parent entry), so both spellings must resolve here — otherwise the
* root's own "Load more" row is filtered out and the top level is stuck on one page.
*/
function isLevelVisible(prefix: string | undefined): boolean {
if (prefix === rootParentPath || prefix === rootPath) return true
if (prefix === undefined) return false
const info = allFilesByKey[prefix]
if (info === undefined || info.collapsed) return false
return isLevelVisible(info.parentPath)
}
/**
* Every key whose ancestors are all expanded, in depth-first order — full keys
* sort that way because a child always starts with its parent's
* delimiter-terminated prefix.
*/
function computeVisibleKeys(): string[] {
const visible: string[] = []
for (const key in allFilesByKey) {
if (!key.startsWith(rootPath)) continue
if (isLevelVisible(allFilesByKey[key].parentPath)) visible.push(key)
}
for (const prefix in folderState) {
if (folderState[prefix].nextPageToken && isLevelVisible(prefix)) {
visible.push(prefix + LOAD_MORE_SUFFIX)
}
}
return visible.sort()
}
function refreshDisplayed() {
const visible = computeVisibleKeys()
displayedFileKeys = visible
displayedCount = visible.filter((k) => !k.endsWith(LOAD_MORE_SUFFIX)).length
}
/**
* In-flight request per level. Callers that await `loadFolderPage` need the data
* to be there when it resolves; returning early on a concurrent load would hand
* them a resolved promise and no entries.
*/
let inFlightFolderLoads: Record<string, Promise<void>> = {}
/**
* Bumped when a single level is invalidated on its own (a delete refetches it from
* page one). Requests are keyed by prefix alone, so without this a pending "Load
* more" for that level would be joined by the refetch, which would then return
* believing page one had been fetched — leaving the level showing only its later
* pages until a full reload.
*/
let folderEpoch: Record<string, number> = {}
/**
* Bumped whenever the listing is thrown away (storage switch, filter change,
* reload). A request started before the bump belongs to the previous listing, so
* its response must not repopulate the cleared state — otherwise switching
* storage mid-load leaves the previous bucket's entries on screen.
*/
let listingGeneration = 0
async function loadFolderPage(prefix: string, append: boolean = false): Promise<void> {
const generation = listingGeneration
const epoch = folderEpoch[prefix] ?? 0
const pending = inFlightFolderLoads[prefix]
if (pending) {
await pending
// Only reuse the joined result if it belongs to the same listing *and* the
// same epoch of this level.
if (generation === listingGeneration && epoch === (folderEpoch[prefix] ?? 0) && !append)
return
}
const run = loadFolderPageInner(prefix, append)
inFlightFolderLoads[prefix] = run.catch(() => {})
try {
await run
} finally {
delete inFlightFolderLoads[prefix]
}
}
async function loadFolderPageInner(prefix: string, append: boolean) {
const generation = listingGeneration
const epoch = folderEpoch[prefix] ?? 0
/** Whether this response still belongs to the listing and level that asked for it. */
const stillCurrent = () =>
generation === listingGeneration && epoch === (folderEpoch[prefix] ?? 0)
const current = folderState[prefix] ?? { loading: false, loaded: false }
if (append && current.nextPageToken === undefined) return
folderState[prefix] = { ...current, loading: true }
try {
const page = await listStoredFilesPagedRequest({
workspace: ws!,
prefix,
maxKeys: pageSize,
pageToken: append ? current.nextPageToken : undefined,
storage,
s3ResourcePath
})
// This listing, or just this level, has been thrown away since the request
// went out. Writing the entries back would resurrect the previous storage's
// contents, or reinstate a file that was just deleted.
if (!stillCurrent()) return
// Absent counts as restricted, matching `loadFlatFiles`: the two paths must
// not disagree on which way an omitted value falls.
if (
page.restricted_access === null ||
page.restricted_access === undefined ||
page.restricted_access === true
) {
fileListUnavailable = true
folderState[prefix] = { loading: false, loaded: true }
return
}
fileListUnavailable = false
for (const folder of page.folders ?? []) {
addEntry(folder.prefix, 'folder', folder.name)
}
for (const file of page.files ?? []) {
if (regexFilter && !regexFilter.test(file.key)) continue
addEntry(file.key, 'leaf', file.name)
}
folderState[prefix] = {
loading: false,
loaded: true,
// An exhausted level serializes as JSON `null`, which is not `undefined`
// — leaving it as-is makes "no more pages" look like another page and
// sends the level round again with no token.
nextPageToken: page.next_page_token ?? undefined
}
} catch (e) {
if (stillCurrent()) {
folderState[prefix] = { ...current, loading: false }
}
throw e
} finally {
// A response that lands after the user typed a filter must not rebuild the
// list: flat mode owns `displayedFileKeys` then, and rebuilding it under
// the lazy visibility rules would prune most of the search results.
if (lazyMode && stillCurrent()) {
refreshDisplayed()
}
}
}
/** Surface listing failures instead of leaving a folder looking empty. */
function reportFolderError(prefix: string, e: unknown) {
console.error('Error listing folder', prefix, e)
sendUserToast(`Could not list ${prefix || 'the bucket root'}`, true)
}
function expandFolder(prefix: string) {
loadFolderPage(prefix).catch((e) => reportFolderError(prefix, e))
}
function loadMore(prefix: string) {
loadFolderPage(prefix, true).catch((e) => reportFolderError(prefix, e))
}
/**
* Bound on the pages fetched while hunting for one entry, so a preselected key
* that no longer exists cannot walk an entire bucket.
*/
const MAX_PAGES_WHILE_REVEALING = 20
/**
* Page through `parent` until `child` shows up. A single page is not enough: the
* target can sort past the first page of its own folder, and giving up there
* leaves the selection looking absent.
*/
async function revealChild(parent: string, child: string, generation: number) {
for (let fetched = 0; fetched < MAX_PAGES_WHILE_REVEALING; fetched++) {
if (allFilesByKey[child] !== undefined) return
const state = folderState[parent]
if (state === undefined || !state.loaded) {
await loadFolderPage(parent)
} else if (state.nextPageToken !== undefined) {
await loadFolderPage(parent, true)
} else {
return
}
if (generation !== listingGeneration) return
}
}
/**
* Reveal a key by loading each folder above it in turn — with per-level listing
* an ancestor's children are not known until that level is fetched.
*
* `generation` is the listing this walk belongs to. A reveal is a chain of round
* trips, so the check has to be repeated after every one of them: a filter typed
* mid-walk switches the picker to the search, and the loads that follow would date
* themselves to that listing and graft browse results onto it.
*/
async function expandToKey(key: string, generation: number) {
if (!key.startsWith(rootPath)) return
const rest = key.slice(rootPath.length).split('/')
let prefix = rootPath
for (let i = 0; i < rest.length - 1; i++) {
const child = prefix + rest[i] + '/'
await revealChild(prefix, child, generation)
if (generation !== listingGeneration) return
prefix = child
const info = allFilesByKey[prefix]
// The folder is genuinely absent; deeper levels cannot exist either.
if (info === undefined) break
info.collapsed = false
}
if (allFilesByKey[key] === undefined) {
await revealChild(prefix, key, generation)
if (generation !== listingGeneration) return
}
refreshDisplayed()
}
let lastKeyFolders: string[] = $state([])
/** Reports whether the listing completed, so a caller that moved the cursor can undo it. */
async function loadFiles(): Promise<boolean> {
if (lazyMode) {
// Typing in the filter switches the picker to the flat listing while this is in
// flight, and `loadFolderPage` resolves rather than throwing once superseded.
// What follows belongs to the listing that started it: expanding a preselected
// file would graft browse results into the search, and clearing the flags would
// retire the search's spinner.
const generation = listingGeneration
fileListLoading = true
try {
await loadFolderPage(rootPath)
if (
generation === listingGeneration &&
selectedFileKey !== undefined &&
!emptyString(selectedFileKey.s3)
) {
await expandToKey(selectedFileKey.s3, generation)
}
} catch (e) {
// `reloadContent` is called un-awaited from `open()`, so without this a
// failing root listing surfaces as an unhandled rejection and an empty
// tree indistinguishable from an empty bucket.
reportFolderError(rootPath, e)
return false
} finally {
if (generation === listingGeneration) {
fileListLoading = false
fileInfoLoading = false
}
}
return true
}
// Same contract as the lazy branch above: every caller here is un-awaited, so an
// uncaught rejection would leave the drawer on a spinner with nothing said. The
// generation guard keeps a superseded listing from clearing the spinner of the one
// that replaced it — `loadFlatFiles` returns early rather than throwing in that case.
const generation = listingGeneration
fileListLoading = true
try {
await loadFlatFiles()
} catch (e) {
console.error('Error listing files', e)
sendUserToast('Could not list the files', true)
// Nothing will load a preview now, so the right-hand pane has to stop waiting
// too. On the other exits it is owned by whoever is still fetching metadata.
if (generation === listingGeneration) {
fileInfoLoading = false
}
return false
} finally {
if (generation === listingGeneration) {
fileListLoading = false
}
}
return true
}
/** Flat "Load more" / "Keep looking": a page that fails has to give the cursor back. */
async function loadNextFlatPage() {
// The page number alone does not identify our advance: a filter or storage change
// resets the cursor, and the replacement listing can reach the same number before
// this request fails. Rolling that one back would strand *its* cursor instead.
const generation = listingGeneration
page += 1
const requested = page
const ok = await loadFiles()
// Only undo our own advance: another click may have moved it on meanwhile.
if (!ok && generation === listingGeneration && page === requested) {
page = requested - 1
}
}
async function loadFlatFiles() {
// Debounced searches overlap: an older response must not add its keys to a newer
// search's results or overwrite its pagination state.
const generation = listingGeneration
fileListLoading = true
let availableFiles = await listStoredFilesRequest({
workspace: ws!,
maxKeys: maxKeys, // fixed pages of 1000 files for now
marker: page == 0 ? undefined : listMarkers[page - 1],
// `prefix` is evaluated per path *segment* by the storage layer, so sending the
// query there matched only whole folder names. `search` matches the raw key
// prefix instead, which is what the box means.
prefix: rootPath !== '' ? rootPath : undefined,
search: filter.trim() !== '' ? filter.trim() : undefined,
storage: storage,
s3ResourcePath
})
if (generation !== listingGeneration) return
if (
availableFiles.restricted_access === null ||
availableFiles.restricted_access === undefined ||
availableFiles.restricted_access === true
) {
fileListUnavailable = true
loadFileMetadataPlusPreviewAsync(selectedFileKey?.s3)
return
}
fileListUnavailable = false
for (let [index, file_path] of availableFiles.windmill_large_files.entries()) {
if (regexFilter && !regexFilter.test(file_path.s3)) {
continue
}
// Only count keys not already in the tree: a page can legitimately repeat
// entries, and counting them again makes the total climb while the list
// stays put.
if (allFilesByKey[file_path.s3] === undefined) {
displayedCount += 1
}
let split_path = file_path.s3.split('/')
let parent_path: string | undefined = undefined
let current_path: string | undefined = undefined
let nestingLevel = 0
if (index === availableFiles.windmill_large_files.length - 1 && split_path.length > 1) {
lastKeyFolders = split_path.slice(0, -1)
}
for (let i = 0; i < split_path.length; i++) {
parent_path = current_path
current_path = current_path === undefined ? split_path[i] : current_path + split_path[i]
if (i < split_path.length - 1) {
current_path += '/'
}
nestingLevel = i * 2
if (allFilesByKey[current_path] !== undefined) {
allFilesByKey[current_path].count += 1
continue
}
allFilesByKey[current_path] = {
type: i === split_path.length - 1 ? 'leaf' : 'folder',
full_key: current_path,
display_name: split_path[i],
collapsed: true, // folders collapsed by default
parentPath: parent_path,
nestingLevel: nestingLevel,
count: 1
}
if (i == rootPathNestingLevel && current_path.startsWith(rootPath)) {
displayedFileKeys.push(current_path)
}
}
}
// A short page means the listing is exhausted. Deriving "there is more" from
// `count % maxKeys` instead would keep offering another page whenever the total
// happens to be an exact multiple of the page size.
// Searching returns an explicit cursor because it skips over keys that did not
// match, so the last *returned* key is not where the next page resumes.
const serverMarker = availableFiles.next_marker ?? undefined
flatHasMore =
serverMarker !== undefined ||
(filter.trim() === '' && availableFiles.windmill_large_files.length === maxKeys)
if (listMarkers.length == page) {
count += availableFiles.windmill_large_files.length
const nextMarker =
serverMarker ??
availableFiles.windmill_large_files?.[availableFiles.windmill_large_files.length - 1]?.s3
if (nextMarker) listMarkers.push(nextMarker)
}
// before returning, un-collapse the folders containing the selected file (if any)
if (selectedFileKey !== undefined && !emptyString(selectedFileKey.s3) && page === 0) {
let split_path = selectedFileKey.s3.split('/')
let current_path: string | undefined = undefined
for (let i = 0; i < split_path.length; i++) {
current_path = current_path === undefined ? split_path[i] : current_path + split_path[i]
if (i < split_path.length - 1) {
current_path += '/'
}
const folder = allFilesByKey[current_path]
if (folder) {
folder.collapsed = false
}
for (let file_key in allFilesByKey) {
let file_info = allFilesByKey[file_key]
if (file_info.parentPath === current_path) {
displayedFileKeys.push(file_key)
}
}
}
}
// The loop above only lists entries at the browsing root, so a later page's
// keys land in `allFilesByKey` without ever being displayed — the folder they
// belong to is already expanded and nothing re-scans it. Recomputing from the
// expansion state picks them up without needing a collapse/expand round trip.
// `displayedCount` is left alone: in this mode it counts files loaded, not rows shown.
displayedFileKeys = computeVisibleKeys()
fileListLoading = false
fileInfoLoading = false
}
async function loadFileMetadataPlusPreviewAsync(fileKey: string | undefined) {
if (fileKey === undefined || emptyString(fileKey)) {
fileInfoLoading = false
return
}
// The pane belongs to the newest request, not to a key: switching storage reloads
// the same key, so comparing keys would let an older request speak for a newer one.
const requestId = ++metadataRequestId
fileInfoLoading = true
let fileMetadataRaw: LoadFileMetadataResponse
try {
fileMetadataRaw = await loadFileMetadataRequest({
workspace: ws!,
fileKey: fileKey,
storage: storage,
s3ResourcePath
})
} catch (e) {
// Every caller invokes this un-awaited, so a key that no longer exists would
// otherwise leave the preview pane on "Loading..." forever.
console.error('Error loading metadata for', fileKey, e)
// Unless a later request has taken the pane over: it will report its own
// outcome, including the loading flag, and blanking here would undo it.
if (requestId !== metadataRequestId) {
return
}
fileMetadata = undefined
filePreview = undefined
fileInfoLoading = false
return
}
if (requestId !== metadataRequestId) {
return
}
if (fileMetadataRaw !== undefined) {
fileMetadata = {
fileKey: fileKey,
size: fileMetadataRaw.size_in_bytes,
sizeStr: displaySize(fileMetadataRaw.size_in_bytes),
mimeType: fileMetadataRaw.mime_type,
lastModified: displayDate(fileMetadataRaw.last_modified)
}
}
// async call
loadFilePreview(fileKey, requestId, fileMetadataRaw.size_in_bytes, fileMetadataRaw.mime_type)
}
async function loadFilePreview(
fileKey: string,
requestId: number,
fileSizeInBytes?: number,
fileMimeType?: string
) {
let filePreviewRaw = await loadFilePreviewRequest({
workspace: ws!,
fileKey: fileKey,
fileSizeInBytes: fileSizeInBytes,
fileMimeType: fileMimeType,
csvSeparator: csvSeparatorChar,
csvHasHeader: csvHasHeader,
readBytesFrom: 0,
readBytesLength: 128 * 1024, // For now static limit of 128Kb per file,
storage: storage,
s3ResourcePath
})
let filePreviewContent = filePreviewRaw.content
if (
filePreviewContent !== null &&
filePreviewContent !== undefined &&
filePreviewContent.length >= 128 * 1024
) {
filePreviewContent =
filePreviewContent?.substring(0, 128 * 1024 - 35) +
'\n\n ... FILE CONTENT TRUNCATED ...\n\n'
}
if (requestId !== metadataRequestId) {
return
}
if (filePreviewRaw !== undefined) {
filePreview = {
fileKey: fileKey,
contentPreview: filePreviewContent,
contentType: filePreviewRaw.content_type
}
if (fileMetadata) {
fileMetadata.mimeType =
((fileKey.endsWith('.png') ||
fileKey.endsWith('.jpg') ||
fileKey.endsWith('.jpeg') ||
fileKey.endsWith('.webp')) &&
'Image') ||
(fileKey.endsWith('.pdf') && 'PDF') ||
filePreview.contentType
}
}
fileInfoLoading = false
}
async function deleteFileFromS3(fileKey: string | undefined) {
fileDeletionInProgress = true
if (fileKey === undefined) {
return
}
try {
await deleteS3FileRequest({
workspace: ws!,
fileKey: fileKey,
storage: storage,
s3ResourcePath
})
} finally {
fileDeletionInProgress = false
deletionModalOpen = false
}
sendUserToast(`${fileKey} deleted from S3 bucket`)
selectedFileKey = { s3: '', storage }
// The preview pane and its toolbar render from these rather than from the
// selection, so the deleted file stays previewed — and offers to download, move
// and delete itself — until they are cleared too.
fileMetadata = undefined
filePreview = undefined
// A metadata load still in flight belongs to the file just deleted; retire it, or
// its response repopulates the pane it was cleared from. Retiring it also means
// nobody is left to report its outcome, so the pane's loading flag is ours.
metadataRequestId += 1
fileInfoLoading = false
if (lazyMode) {
// Only the level the file lived in changed; refetch it from its first page
// and keep the rest of the expanded tree. Its already-fetched entries have
// to go with the cursor, or the reset cursor would hand out a "Load more"
// for pages that are still displayed.
const parent = parentPathOf(fileKey) ?? rootPath
for (const key of Object.keys(allFilesByKey)) {
if (allFilesByKey[key].parentPath === (parent === '' ? undefined : parent)) {
delete allFilesByKey[key]
}
}
delete folderState[parent]
// Invalidate this level so a pending "Load more" for it is not joined below.
folderEpoch[parent] = (folderEpoch[parent] ?? 0) + 1
delete inFlightFolderLoads[parent]
await loadFolderPage(parent).catch((e) => reportFolderError(parent, e))
return
}
const currentPage = page
// Every page here has to land, starting with the fresh first one.
if (await clearAndLoadFiles()) {
for (let i = 0; i < currentPage; i++) {
page = i + 1
if (!(await loadFiles())) {
// Stop at the last page that actually loaded.
page = i
break
}
}
}
const fileKeyFolders = fileKey.split('/').slice(0, -1)
let current_path: string | undefined = undefined
for (let i = 0; i < fileKeyFolders.length; i++) {
current_path =
current_path === undefined ? fileKeyFolders[i] : current_path + fileKeyFolders[i]
if (i < fileKeyFolders.length) {
current_path += '/'
}
const folder = allFilesByKey[current_path]
if (folder) {
folder.collapsed = false
}
for (let file_key in allFilesByKey) {
let file_info = allFilesByKey[file_key]
if (file_info.parentPath === current_path) {
displayedFileKeys.push(file_key)
}
}
}
displayedFileKeys = [...new Set(displayedFileKeys)].sort()
}
/** Reports whether the fresh listing loaded, so a caller replaying pages can stop. */
async function clearAndLoadFiles({
keepFilter
}: { keepFilter?: boolean } = {}): Promise<boolean> {
// Anything already in flight belongs to the listing being discarded.
listingGeneration += 1
inFlightFolderLoads = {}
folderEpoch = {}
displayedFileKeys = []
allFilesByKey = {}
folderState = {}
count = 0
displayedCount = 0
flatHasMore = false
page = 0
listMarkers = []
fileMetadata = undefined
filePreview = undefined
if (!keepFilter) {
filter = ''
}
return await loadFiles()
}
async function moveS3File(srcFileKey: string | undefined, destFileKey: string | undefined) {
fileMoveInProgress = true
if (srcFileKey === undefined || emptyString(destFileKey)) {
return
}
try {
await moveS3FileRequest({
workspace: ws!,
srcFileKey: srcFileKey,
destFileKey: destFileKey!,
storage: storage,
s3ResourcePath
})
} finally {
fileMoveInProgress = false
moveModalOpen = false
}
sendUserToast(`${srcFileKey} moved to ${destFileKey}`)
selectedFileKey = { s3: destFileKey!, storage }
await clearAndLoadFiles()
await loadFileMetadataPlusPreviewAsync(selectedFileKey.s3)
}
export async function open(_preSelectedFileKey: S3Object | undefined = undefined) {
const preSelectedFileKey = _preSelectedFileKey && parseS3Object(_preSelectedFileKey)
storage = preSelectedFileKey?.storage
if (preSelectedFileKey !== undefined && preSelectedFileKey.s3.endsWith('/')) {
rootPath = preSelectedFileKey.s3
filter = ''
selectedFileKey = undefined
} else if (preSelectedFileKey !== undefined) {
rootPath = ''
initialFileKey = { ...preSelectedFileKey }
selectedFileKey = { ...preSelectedFileKey }
} else {
rootPath = ''
}
reloadContent()
}
export async function close() {
return selectedFileKey?.s3
? {
s3: selectedFileKey.s3,
storage: storage
}
: undefined
}
export async function reloadContent() {
if (initialFileKey !== undefined) {
initialFileKeyInternalCopy = { ...initialFileKey }
}
fileListLoading = true
try {
await testConnectionRequest({
workspace: ws!,
storage: storage,
s3ResourcePath
})
workspaceSettingsInitialized = true
} catch (e) {
fileListLoading = false
console.error('Workspace not connected to object storage: ', e)
workspaceSettingsInitialized = false
return
}
await clearAndLoadFiles()
if (selectedFileKey !== undefined) {
const entry = allFilesByKey[selectedFileKey.s3]
if (entry !== undefined) {
if (entry.type !== 'folder') {
loadFileMetadataPlusPreviewAsync(selectedFileKey.s3)
}
} else if (!lazyMode) {
// Flat mode has listed everything it is going to, so a missing key really
// is missing. Per-level listing has not: only the levels on the way to
// the key were fetched, and `selectedFileKey` is bound out to the caller
// — blanking it there would silently clear the configured object.
selectedFileKey = { s3: '', storage }
} else {
loadFileMetadataPlusPreviewAsync(selectedFileKey.s3)
}
}
}
export async function selectAndClose() {
if (selectedFileKey?.s3) {
dispatch('selectAndClose', { s3: selectedFileKey.s3, storage })
}
}
export async function exit() {
if (initialFileKeyInternalCopy !== undefined) {
selectedFileKey = { ...initialFileKeyInternalCopy }
}
}
function selectItem(index: number, toggleCollapsed: boolean = true) {
let item_key = displayedFileKeys[index]
let item = allFilesByKey[item_key]
if (item === undefined) return
if (lazyMode) {
if (item.type === 'folder') {
if (folderOnly) {
selectedFileKey = { s3: item_key, storage }
}
if (toggleCollapsed) {
item.collapsed = !item.collapsed
}
if (!item.collapsed && !folderState[item_key]?.loaded) {
// Children are unknown until this level is fetched.
expandFolder(item_key)
} else {
refreshDisplayed()
}
} else {
selectedFileKey = { s3: item_key, storage }
loadFileMetadataPlusPreviewAsync(selectedFileKey.s3)
}
return
}
if (item.type === 'folder') {
if (folderOnly) {
selectedFileKey = {
s3: item_key,
storage
}
}
if (toggleCollapsed) {
item.collapsed = !item.collapsed
}
if (item.collapsed) {
// Remove the element nested in that folder from displayed_file_keys
let elt_to_remove = 0
for (let i = index + 1; i < displayedFileKeys.length; i++) {
let file_key = displayedFileKeys[i]
if (file_key.startsWith(item_key)) {
elt_to_remove += 1
} else {
break
}
}
if (elt_to_remove > 0) {
displayedFileKeys.splice(index + 1, elt_to_remove)
}
} else {
// Re-add the currently hidden element to displayed_file_keys
for (let file_key in allFilesByKey) {
let file_info = allFilesByKey[file_key]
if (file_info.parentPath === item_key) {
displayedFileKeys.push(file_key)
if (file_info.type === 'folder' && !file_info.collapsed) {
selectItem(displayedFileKeys.length - 1, false)
}
}
}
}
displayedFileKeys = [...new Set(displayedFileKeys)].sort()
} else {
selectedFileKey = {
s3: item_key,
storage
}
loadFileMetadataPlusPreviewAsync(selectedFileKey.s3)
}
}
$effect.pre(() => {
filter != undefined && untrack(() => onFilterChange())
})
</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="{base}/workspace_settings?tab=windmill_lfs">configure it here</a
>.
</p>
<Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{/if}
{:else}
{#if fileListUnavailable == true}
{#if replaceUnauthorizedWarning}
{@render replaceUnauthorizedWarning()}
{:else}
<div class="mb-2">
<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
the access to it. You are not authorized to browse the bucket content. If you think this
is incorrect, please contact your workspace administrator.
</p>
<p>
More info in <a
href="https://www.windmill.dev/docs/core_concepts/persistent_storage/large_data_files"
target="_blank">Windmill's documentation</a
></p
></Alert
>
</div>
{/if}
{/if}
<div class="flex flex-row border rounded-md h-full min-h-0 overflow-hidden">
{#if !fileListUnavailable}
<div class="min-w-[30%] border-r flex flex-col min-h-0">
{#if !rootPath}
<div class="w-full p-1 border-b">
<input
type="text"
placeholder="Search by path prefix"
bind:value={filter}
class="text-xl"
/>
</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>
{:else}
<div class="p-4 text-primary text-xs text-center italic">
{#if filter.trim() !== ''}
No files starting with "{filter.trim()}"
{:else}
No files in the workspace S3 bucket
{/if}
</div>
{#if flatHasMore}
<!-- A page can come back empty while keys remain — permission filtering
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()}>
Keep looking
</Button>
</div>
{/if}
{/if}
{:else}
<div class="grow min-h-0" bind:clientHeight={listDivHeight}>
<VirtualList
width="100%"
height={listDivHeight}
itemCount={displayedFileKeys.length}
itemSize={42}
>
{#snippet header()}{/snippet}
{#snippet footer()}{/snippet}
{#snippet item({ index, style })}
<!-- VirtualList can render an index past the array while the list is
shrinking, so this must tolerate a missing key. -->
{@const item_key = displayedFileKeys[index] ?? ''}
{@const is_load_more = item_key.endsWith(LOAD_MORE_SUFFIX)}
{@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'
)}
>
{#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
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'
)}
>
<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>
{: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
onclick={() => selectItem(index)}
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'
: ''
)}
>
<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>
{:else}
<FileIcon size={16} />
<div class="truncate text-ellipsis w-56">
{file_info.display_name}
</div>
{/if}
</div>
</div>
{/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"
>
{#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>
{: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>
{displayedCount}{flatHasMore ? '+' : ''}
{displayedCount !== count ? 'filtered ' : ''}items (including inside folders)
</div>
{#if flatHasMore}
<Button variant="default" size="xs2" on:click={() => loadNextFlatPage()}>
Load more
</Button>
{/if}
{/if}
</div>
{/if}
</div>
{/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={`${base}/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}
</div>
{/if}
</div>
{#if !hideS3SpecificDetails}
<TableSimple
headers={['Last modified', 'Size', 'Type']}
data={[fileMetadata]}
keys={['lastModified', 'sizeStr', 'mimeType']}
/>
{/if}
</div>
{/if}
<!-- Visual preview extracted to a standalone S3FilePreview component
so the asset detail pane (and other surfaces) can render the
same image/PDF/CSV/text views without dragging in the rest
of the picker. The picker keeps its own metadata loading
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}
<ConfirmationModal
open={deletionModalOpen}
title="Permanently delete file"
confirmationText="Delete permanently"
on:canceled={() => {
deletionModalOpen = false
}}
on:confirmed={() => {
deleteFileFromS3(fileMetadata?.fileKey)
}}
keyListen={false}
loading={fileDeletionInProgress}
>
<div class="flex flex-col w-full space-y-4">
<span
>Are you sure you want to permanently delete {fileMetadata?.fileKey} from the S3 bucket?</span
>
</div>
</ConfirmationModal>
<ConfirmationModal
open={moveModalOpen}
title="Move file to new location"
confirmationText="Move"
on:canceled={() => {
moveModalOpen = false
}}
on:confirmed={() => {
moveS3File(fileMetadata?.fileKey, moveDestKey)
}}
keyListen={false}
loading={fileMoveInProgress}
>
<div class="flex flex-col space-y-4">
<div class="flex items-center justify-between">
<span class="w-24">New key: </span>
<input
type="text"
placeholder="folder/nested/file.txt"
bind:value={moveDestKey}
class="text-2xl"
/>
</div>
<span>Are you sure you want to permanently move {fileMetadata?.fileKey}?</span>
</div>
</ConfirmationModal>
<FileUploadModal
open={uploadModalOpen}
title="Upload file to S3 bucket"
on:close={async (evt) => {
uploadModalOpen = false
if (evt.detail !== undefined && evt.detail !== null) {
selectedFileKey = { s3: evt.detail, storage }
await clearAndLoadFiles()
loadFileMetadataPlusPreviewAsync(evt.detail)
}
}}
/>