import {useEffect, useRef, useState} from 'react' import {ChevronLeft, ChevronRight, Copy, Download, Eye, File, Folder, Loader2, RefreshCw, Search, Trash2, Upload, X} from 'lucide-react' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import { copyStorageObject, createCloudResource, deleteStorageObject, listCloudResources, listStorageObjects, storageObjectDownloadUrl, uploadStorageObject, } from '@/api/cloudProxyClient' import {capabilityEnabled, capabilityFor, normalizeCapabilities, withRuntimeState} from '@/lib/capabilities' import type {CloudProvider} from '@/types/cloud' import type {CloudResource, StorageObject} from '@/types/resource' import type {CapabilitySchema, ObjectActionName} from '@/types/schema' import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format' import {ImagePreviewModal} from '@/components/ImagePreviewModal' import {ObjectThumbnail} from '@/components/ObjectThumbnail' import {CopyValueButton} from '@/components/CopyValueButton' interface StorageObjectBrowserProps { cloud: CloudProvider resource?: CloudResource capabilities?: Array | ObjectActionName> runtimeReachable?: boolean selectedObjectKey?: string onSelectObject: (object?: StorageObject) => void } export function StorageObjectBrowser({cloud, resource, capabilities = [], runtimeReachable = false, selectedObjectKey, onSelectObject}: StorageObjectBrowserProps) { const qc = useQueryClient() const fileRef = useRef(null) const [prefix, setPrefix] = useState('') const [uploadPrefix, setUploadPrefix] = useState('') const [folderName, setFolderName] = useState('') const [createFolderOpen, setCreateFolderOpen] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteFolderConfirm, setDeleteFolderConfirm] = useState(null) const [emptyBucketConfirm, setEmptyBucketConfirm] = useState(false) const [emptyBucketText, setEmptyBucketText] = useState('') const [copyObject, setCopyObject] = useState(null) const [search, setSearch] = useState('') const [selectedKeys, setSelectedKeys] = useState>(new Set()) const [bulkDeleting, setBulkDeleting] = useState(false) const [previewObject, setPreviewObject] = useState(null) const [folderUpload, setFolderUpload] = useState<{completed: number; total: number; failed: number} | null>(null) const [folderUploadNotice, setFolderUploadNotice] = useState(null) const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable) const uploadCapability = capabilityFor(resolvedCapabilities, 'upload') const downloadCapability = capabilityFor(resolvedCapabilities, 'download') const deleteCapability = capabilityFor(resolvedCapabilities, 'delete') const createFolderCapability = capabilityFor(resolvedCapabilities, 'createFolder') const copyCapability = capabilityFor(resolvedCapabilities, 'copy') const canUpload = capabilityEnabled(uploadCapability) const canDownload = capabilityEnabled(downloadCapability) const canDelete = capabilityEnabled(deleteCapability) const canCreateFolder = capabilityEnabled(createFolderCapability) const canCopy = capabilityEnabled(copyCapability) // Runtime reachability can flip while a preview is already open (it's polled // independently of this component). Re-checking canDownload only at the // moment the Eye button is clicked isn't enough — close an already-open // preview the instant download capability is revoked, so its modal can't // keep serving object content (img/video/audio src, the Download link, // an in-flight text fetch) after the gate that allowed it closes. useEffect(() => { if (!canDownload) setPreviewObject(null) }, [canDownload]) useEffect(() => { setPrefix('') setUploadPrefix('') setFolderName('') setCreateFolderOpen(false) setDeleteConfirm(null) setDeleteFolderConfirm(null) setEmptyBucketConfirm(false) setEmptyBucketText('') setCopyObject(null) setSearch('') setSelectedKeys(new Set()) setPreviewObject(null) setFolderUpload(null) setFolderUploadNotice(null) onSelectObject(undefined) }, [resource?.id, onSelectObject]) const query = useQuery({ queryKey: ['storage-objects', cloud, resource?.id, prefix], queryFn: ({signal}) => listStorageObjects(cloud, resource?.id ?? '', prefix, signal), enabled: !!resource, }) const uploadMut = useMutation({ mutationFn: async (file: File) => { if (!resource) return const key = `${uploadPrefix || prefix}${file.name}` await uploadStorageObject(cloud, resource.id, key, file) }, onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}), }) const folderUploadMut = useMutation({ mutationFn: async (files: File[]) => { if (!resource) return const basePrefix = uploadPrefix || prefix setFolderUpload({completed: 0, total: files.length, failed: 0}) let completed = 0 let failed = 0 for (const file of files) { const relativePath = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name try { await uploadStorageObject(cloud, resource.id, `${basePrefix}${relativePath}`, file) completed++ } catch { failed++ } setFolderUpload({completed, total: files.length, failed}) } }, onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}), }) const createFolderMut = useMutation({ mutationFn: async (name: string) => { if (!resource) return const folderKey = `${prefix}${normalizeFolderName(name)}` await uploadStorageObject(cloud, resource.id, folderKey, new Blob([], {type: 'application/x-directory'})) }, onSuccess: () => { setFolderName('') setCreateFolderOpen(false) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}) }, }) const deleteMut = useMutation({ mutationFn: async (object: StorageObject) => { if (!resource) return await deleteStorageObject(cloud, resource.id, object.key) }, onSuccess: (_, deleted) => { if (selectedObjectKey === deleted.key) onSelectObject(undefined) qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}) }, }) const deleteFolderMut = useMutation({ mutationFn: async (folder: StorageObject) => { if (!resource) return await deleteFolderContents(cloud, resource.id, folder.key) }, onSuccess: (_, folder) => { if (selectedObjectKey?.startsWith(folder.key)) onSelectObject(undefined) setDeleteFolderConfirm(null) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}) }, }) const emptyBucketMut = useMutation({ mutationFn: async () => { if (!resource) return await emptyBucket(cloud, resource.id) }, onSuccess: () => { setEmptyBucketConfirm(false) setEmptyBucketText('') onSelectObject(undefined) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}) }, }) const moveCopyMut = useMutation({ mutationFn: async ({srcKey, destKey, destResourceId, mode}: { srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy' }) => { if (!resource) return await copyStorageObject(cloud, resource.id, srcKey, destKey, destResourceId) if (mode === 'move') await deleteStorageObject(cloud, resource.id, srcKey) }, onSuccess: (_, vars) => { setCopyObject(null) if (vars.mode === 'move') onSelectObject(undefined) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}) if (vars.destResourceId) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, vars.destResourceId]}) }, }) // ── Navigation helpers ── function navigate(nextPrefix: string) { setPrefix(nextPrefix) setSearch('') setSelectedKeys(new Set()) setDeleteConfirm(null) setDeleteFolderConfirm(null) setPreviewObject(null) onSelectObject(undefined) } // ── Bulk delete ── async function handleBulkDelete() { if (!resource || selectedKeys.size === 0) return setBulkDeleting(true) try { await Promise.all([...selectedKeys].map((key) => deleteStorageObject(cloud, resource.id, key))) if (selectedObjectKey && selectedKeys.has(selectedObjectKey)) onSelectObject(undefined) setSelectedKeys(new Set()) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource.id]}) } finally { setBulkDeleting(false) } } if (!resource) { return (

Select a storage resource

Choose a bucket or container to browse objects and blobs.

) } const objects = query.data?.objects ?? [] const objectLabel = cloud === 'azure' ? 'blobs' : 'objects' // ── Filtered data ── const q = search.toLowerCase() const filteredObjects = q ? objects.filter((o) => o.name.toLowerCase().includes(q) || o.key.toLowerCase().includes(q)) : objects const filteredFolders = filteredObjects.filter((o) => o.type === 'folder') const filteredFiles = filteredObjects.filter((o) => o.type === 'object') const totalFolders = objects.filter((o) => o.type === 'folder').length const totalFiles = objects.filter((o) => o.type === 'object').length // ── Bulk select state ── const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key)) const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key)) const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? deleteFolderMut.error ?? emptyBucketMut.error ?? moveCopyMut.error return (
{previewObject && resource && objectPreviewKind(previewObject.name) && ( setPreviewObject(null)} /> )} {copyObject && resource && ( { setCopyObject(null); moveCopyMut.reset() }} onConfirm={(destKey, destResourceId, mode) => moveCopyMut.mutate({srcKey: copyObject.key, destKey, destResourceId, mode})} /> )} {/* ── Header ── */}

Objects

{cloud === 'aws' ? {resource.name} : resource.name}

{totalFolders} folders, {totalFiles} {objectLabel}
{prefix && ( )} {cloud === 'aws' && ( )} {createFolderCapability && ( )} {uploadCapability && ( <> setUploadPrefix(normalizePrefix(event.target.value))} placeholder={prefix ? `Upload to ${prefix}` : 'Upload prefix'}/> { const file = event.target.files?.[0] if (file) uploadMut.mutate(file) event.currentTarget.value = '' }}/> )}
{/* ── Create folder ── */} {createFolderOpen && (
{ event.preventDefault() if (folderName.trim()) createFolderMut.mutate(folderName) }}>
)} {emptyBucketConfirm && (
{ event.preventDefault() if (emptyBucketText === resource.name) emptyBucketMut.mutate() }}>
)} {folderUpload &&

0 ? 'error-text compact-text' : 'muted compact-text'}> Folder upload: {folderUpload.completed} of {folderUpload.total} files uploaded{folderUpload.failed > 0 ? `, ${folderUpload.failed} failed` : ''}.

} {folderUploadNotice &&

{folderUploadNotice}

} {canUpload &&
event.preventDefault()} onDrop={(event) => { event.preventDefault() void droppedFiles(event.dataTransfer).then((files) => { if (files.length > 0) { setFolderUploadNotice(null) folderUploadMut.mutate(files) } else { setFolderUploadNotice('Drop files or a folder from your desktop to upload them.') } }) }}> Drop files or folders hereFolder paths are preserved under the current upload prefix.
} {/* ── Error ── */} {error && (
{error instanceof Error ? error.message : 'Storage operation failed'}
)} {/* ── Search bar ── */} {!query.isLoading && objects.length > 0 && (
{ setSearch(e.target.value); setSelectedKeys(new Set()) }} placeholder={`Search ${objectLabel} and folders…`} /> {search && ( )} {search && ( {filteredObjects.length} / {objects.length} )}
)} {/* ── Table ── */} {query.isLoading ? (

Loading objects

Reading objects from the selected storage resource.

) : objects.length === 0 ? (

No {objectLabel}

{prefix ? `The ${prefix} prefix is empty.` : `This ${resource.type} has no ${objectLabel} yet.`}

) : filteredObjects.length === 0 ? (

No results

Nothing matches “{search}”.

) : ( {filteredFolders.map((object) => ( ))} {filteredFiles.map((object) => { const isSelected = selectedKeys.has(object.key) const iconKind = objectIconKind(object.name) const previewKind = objectPreviewKind(object.name) return ( ) })}
{ if (el) el.indeterminate = someSelected }} checked={allSelected} title={allSelected ? 'Deselect all' : 'Select all files'} onChange={() => { if (allSelected) setSelectedKeys(new Set()) else setSelectedKeys(new Set(filteredFiles.map((f) => f.key))) }} /> Name Type Size Last Modified
navigate(object.key)} style={{cursor: 'pointer'}}> {object.name} Folder — {object.lastModified ?? '—'} {canDelete && deleteFolderConfirm === object.key ? ( ) : deleteCapability && ( )}
e.stopPropagation()} onChange={(e) => { const next = new Set(selectedKeys) if (e.target.checked) next.add(object.key) else next.delete(object.key) setSelectedKeys(next) }} /> onSelectObject(object)} style={{cursor: 'pointer'}}> {iconKind ? ( ) : ( )} {object.name} {objectTypeLabel(object.name)} {object.size === null ? '—' : formatBytes(object.size)} {object.lastModified ?? '—'} {previewKind && ( )} {downloadCapability && ( )} {copyCapability && ( )} {canDelete && deleteConfirm === object.key ? ( ) : ( deleteCapability && ( ) )}
)} {/* ── Bulk action bar ── */} {selectedKeys.size > 0 && (
{selectedKeys.size} {objectLabel.replace(/s$/, '')}{selectedKeys.size !== 1 ? 's' : ''} selected {deleteCapability && ( )}
)}
) } // ─── Move / Copy modal ──────────────────────────────────────────────────────── const NEW_RESOURCE_SENTINEL = '__new__' function MoveOrCopyModal({ cloud, resource, srcObject, canCopy, isPending, error, onClose, onConfirm, }: { cloud: CloudProvider resource: CloudResource srcObject: StorageObject canCopy: boolean isPending: boolean error: string | null onClose: () => void onConfirm: (destKey: string, destResourceId: string | undefined, mode: 'move' | 'copy') => void }) { const qc = useQueryClient() const resourceLabel = cloud === 'azure' ? 'container' : 'bucket' const [mode, setMode] = useState<'move' | 'copy'>('copy') const [destKey, setDestKey] = useState(() => { const parts = srcObject.key.split('/') const filename = parts.pop() ?? srcObject.key const dir = parts.join('/') return dir ? `${dir}/copy-of-${filename}` : `copy-of-${filename}` }) const [destResourceId, setDestResourceId] = useState(resource.id) const [newResourceName, setNewResourceName] = useState('') const resourcesQuery = useQuery({ queryKey: ['cloud-resources', cloud, 'storage'], queryFn: ({signal}) => listCloudResources(cloud, 'storage', undefined, signal), }) const createResourceMut = useMutation({ mutationFn: () => { const nameField = cloud === 'azure' ? 'containerName' : 'bucketName' return createCloudResource(cloud, 'storage', {[nameField]: newResourceName.trim()}) }, onSuccess: (created) => { setDestResourceId(created.id) setNewResourceName('') void qc.invalidateQueries({queryKey: ['cloud-resources', cloud, 'storage']}) }, }) const isCreatingNew = destResourceId === NEW_RESOURCE_SENTINEL const resources = resourcesQuery.data ?? [] const resolvedDestId = isCreatingNew ? undefined : (destResourceId !== resource.id ? destResourceId : undefined) return (
{ if (e.target === e.currentTarget) onClose() }}>
Source: {resource.name}/{srcObject.key}
{resourcesQuery.isLoading ? (
Loading {resourceLabel}s…
) : ( )}
{isCreatingNew && (
setNewResourceName(e.target.value.toLowerCase().replace(cloud === 'azure' ? /[^a-z0-9-]/g : /[^a-z0-9.-]/g, ''))} placeholder={`new-${resourceLabel}-name`} onKeyDown={(e) => { if (e.key === 'Enter' && newResourceName.trim().length >= 3) createResourceMut.mutate() }} />
)} {createResourceMut.isError && (

{createResourceMut.error instanceof Error ? createResourceMut.error.message : 'Create failed'}

)}
setDestKey(e.target.value)}/>
{error &&

{error}

}
) } // ─── Breadcrumb ─────────────────────────────────────────────────────────────── function ObjectBreadcrumb({prefix, onNavigate}: {prefix: string; onNavigate: (prefix: string) => void}) { const segments = prefix ? prefix.replace(/\/$/, '').split('/') : [] return (
{segments.map((segment, index) => { const path = `${segments.slice(0, index + 1).join('/')}/` return ( ) })}
) } // ─── Helpers ───────────────────────────────────────────────────────────────── function normalizePrefix(value: string): string { const trimmed = value.trim().replace(/^\/+/, '') return trimmed && !trimmed.endsWith('/') ? `${trimmed}/` : trimmed } function normalizeFolderName(value: string): string { const normalized = value.trim().replace(/^\/+/, '') return normalized.endsWith('/') ? normalized : `${normalized}/` } function parentPrefix(prefix: string): string { const segments = prefix.replace(/\/$/, '').split('/').filter(Boolean) segments.pop() return segments.length ? `${segments.join('/')}/` : '' } function objectTypeLabel(name: string): string { const extension = name.split('.').pop()?.trim().toUpperCase() return extension && extension !== name.toUpperCase() ? `${extension} file` : 'File' } interface DroppedEntry { isFile: boolean isDirectory: boolean name: string file(callback: (file: File) => void, errorCallback?: (error: DOMException) => void): void createReader(): {readEntries(callback: (entries: DroppedEntry[]) => void, errorCallback?: (error: DOMException) => void): void} } async function droppedFiles(dataTransfer: DataTransfer): Promise { const items = Array.from(dataTransfer.items) const entries = items.map((item) => (item as DataTransferItem & {webkitGetAsEntry?: () => unknown}).webkitGetAsEntry?.() as DroppedEntry | null | undefined).filter((entry): entry is DroppedEntry => entry !== null && entry !== undefined) if (entries.length === 0) return Array.from(dataTransfer.files) const files: File[] = [] for (const entry of entries) files.push(...await filesInEntry(entry)) return files } async function filesInEntry(entry: DroppedEntry, prefix = ''): Promise { const files: File[] = [] const path = `${prefix}${entry.name}` if (entry.isFile) { const file = await new Promise((resolve, reject) => entry.file(resolve, reject)) Object.defineProperty(file, 'webkitRelativePath', {value: path}) return [file] } if (!entry.isDirectory) return files const reader = entry.createReader() let entries: DroppedEntry[] do { entries = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)) for (const child of entries) files.push(...await filesInEntry(child, `${path}/`)) } while (entries.length > 0) return files } async function deleteFolderContents(cloud: CloudProvider, bucketName: string, prefix: string): Promise { const listing = await listStorageObjects(cloud, bucketName, prefix) const folders = listing.objects.filter((object) => object.type === 'folder') const objects = listing.objects.filter((object) => object.type === 'object') await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key))) await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key))) await deleteStorageObject(cloud, bucketName, prefix) } async function emptyBucket(cloud: CloudProvider, bucketName: string): Promise { const listing = await listStorageObjects(cloud, bucketName, '') const folders = listing.objects.filter((object) => object.type === 'folder') const objects = listing.objects.filter((object) => object.type === 'object') await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key))) await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key))) }