mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 16:03:24 +00:00
1622 lines
55 KiB
Svelte
1622 lines
55 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
File as FileIcon,
|
|
FolderClosed,
|
|
FolderOpen,
|
|
ChevronDown,
|
|
RotateCw,
|
|
Loader2,
|
|
Download,
|
|
Trash,
|
|
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 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,
|
|
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 {
|
|
displayDate,
|
|
displaySize,
|
|
emptyString,
|
|
parseS3Object,
|
|
sendUserToast,
|
|
type S3Object,
|
|
type IconType
|
|
} from '$lib/utils'
|
|
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
|
|
import { Alert, Button, CopyButton } from './common'
|
|
import { createEventDispatcher, untrack, type Snippet } from 'svelte'
|
|
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
|
|
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
|
import FileUploadModal from './common/fileUpload/FileUploadModal.svelte'
|
|
import S3FilePreview from './S3FilePreview.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
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 ?? $operatingWorkspace)
|
|
|
|
let rootPath = $state(initialRootPath)
|
|
let rootPathNestingLevel = $derived(1 * (rootPath.split('/').length - 1))
|
|
|
|
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
|
|
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)
|
|
|
|
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
|
|
|
|
/**
|
|
* 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() === '')
|
|
|
|
/** `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
|
|
}
|
|
|
|
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>
|
|
|
|
{#snippet tableLoading()}
|
|
<div class="flex items-center gap-2 p-3 text-xs text-tertiary">
|
|
<Loader2 size={14} class="animate-spin" /> Loading table...
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#if workspaceSettingsInitialized === false}
|
|
<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="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
|
|
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}
|
|
<Splitpanes class="h-full min-h-0 overflow-hidden">
|
|
{#if !fileListUnavailable}
|
|
<Pane size={28} minSize={15} class="flex flex-col min-h-0">
|
|
{#if !rootPath}
|
|
<div class="mx-3 mt-3">
|
|
<TextInput
|
|
bind:value={filter}
|
|
inputProps={{ placeholder: 'Search by path prefix...' }}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if displayedFileKeys.length === 0}
|
|
{#if fileListLoading}
|
|
<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-3 text-tertiary text-xs">
|
|
{#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" unifiedSize="xs" onClick={() => loadNextFlatPage()}>
|
|
Keep looking
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{:else}
|
|
<div class="grow min-h-0 mt-1.5" bind:clientHeight={listDivHeight}>
|
|
<VirtualList
|
|
width="100%"
|
|
height={listDivHeight}
|
|
itemCount={displayedFileKeys.length}
|
|
itemSize={32}
|
|
>
|
|
{#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}>
|
|
{#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}
|
|
<button
|
|
onclick={() => !loadingMore && loadMore(load_more_prefix)}
|
|
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)}
|
|
>
|
|
<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}
|
|
{@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(
|
|
'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)}
|
|
>
|
|
{#if isFolder}
|
|
{#if folderState[file_info.full_key]?.loading}
|
|
<Loader2 class="shrink-0 text-secondary animate-spin" size={14} />
|
|
{:else}
|
|
<ChevronDown
|
|
class={twMerge(
|
|
'shrink-0 text-secondary transition-transform',
|
|
file_info.collapsed && '-rotate-90'
|
|
)}
|
|
size={14}
|
|
/>
|
|
{/if}
|
|
{: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 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 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 class="min-w-0">
|
|
{displayedCount}{flatHasMore ? '+' : ''}
|
|
{displayedCount !== count ? 'filtered ' : ''}items (including inside folders)
|
|
</div>
|
|
|
|
{#if flatHasMore}
|
|
<Button variant="subtle" unifiedSize="xs" onClick={() => loadNextFlatPage()}>
|
|
Load more
|
|
</Button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</Pane>
|
|
{/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 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}
|
|
{#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
|
|
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. -->
|
|
{#if showTable}
|
|
<div class="flex-1 min-h-0 relative">
|
|
{#if currentTable?.ops}
|
|
<!-- Lazy: the (logged) layout mounts this picker and DBTable reaches monaco. -->
|
|
{#await import('./DBTable.svelte')}
|
|
{@render tableLoading()}
|
|
{:then DBTable}
|
|
{#key currentTable.ops}
|
|
<DBTable.default dbTableOps={currentTable.ops} />
|
|
{/key}
|
|
{/await}
|
|
{: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}
|
|
{@render tableLoading()}
|
|
{/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
|
|
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)
|
|
}
|
|
}}
|
|
/>
|