Files
floci-ui/packages/frontend/src/components/StorageObjectBrowser.tsx
T
2026-10-23 10:12:41 -04:00

813 lines
41 KiB
TypeScript

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<CapabilitySchema<ObjectActionName> | 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<HTMLInputElement | null>(null)
const [prefix, setPrefix] = useState('')
const [uploadPrefix, setUploadPrefix] = useState('')
const [folderName, setFolderName] = useState('')
const [createFolderOpen, setCreateFolderOpen] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [deleteFolderConfirm, setDeleteFolderConfirm] = useState<string | null>(null)
const [emptyBucketConfirm, setEmptyBucketConfirm] = useState(false)
const [emptyBucketText, setEmptyBucketText] = useState('')
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
const [search, setSearch] = useState('')
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
const [bulkDeleting, setBulkDeleting] = useState(false)
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
const [folderUpload, setFolderUpload] = useState<{completed: number; total: number; failed: number} | null>(null)
const [folderUploadNotice, setFolderUploadNotice] = useState<string | null>(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 (
<section className="object-browser empty compact">
<h3>Select a storage resource</h3>
<p>Choose a bucket or container to browse objects and blobs.</p>
</section>
)
}
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 (
<section className="object-browser">
{previewObject && resource && objectPreviewKind(previewObject.name) && (
<ImagePreviewModal
kind={objectPreviewKind(previewObject.name)!}
name={previewObject.name}
src={storageObjectDownloadUrl(cloud, resource.id, previewObject.key)}
onClose={() => setPreviewObject(null)}
/>
)}
{copyObject && resource && (
<MoveOrCopyModal
cloud={cloud}
resource={resource}
srcObject={copyObject}
canCopy={canCopy}
isPending={moveCopyMut.isPending}
error={moveCopyMut.error instanceof Error ? moveCopyMut.error.message : null}
onClose={() => { setCopyObject(null); moveCopyMut.reset() }}
onConfirm={(destKey, destResourceId, mode) => moveCopyMut.mutate({srcKey: copyObject.key, destKey, destResourceId, mode})}
/>
)}
{/* ── Header ── */}
<div className="object-browser-header">
<div>
<p className="eyebrow">Objects</p>
<h3>{cloud === 'aws' ? <span className="copy-value">{resource.name}<CopyValueButton value={resource.name} label="Bucket name"/></span> : resource.name}</h3>
<div className="object-browser-subtitle">
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
</div>
</div>
<div className="object-browser-actions">
{prefix && (
<button className="button" type="button" onClick={() => navigate(parentPrefix(prefix))}>
<ChevronLeft size={14}/>
Back
</button>
)}
{cloud === 'aws' && (
<button className="button danger" type="button" disabled={emptyBucketMut.isPending} onClick={() => setEmptyBucketConfirm(true)}>
<Trash2 size={14}/>
Empty bucket
</button>
)}
{createFolderCapability && (
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
<Folder size={14}/>
New folder
</button>
)}
{uploadCapability && (
<>
<input className="input object-prefix-input" value={uploadPrefix} onChange={(event) => setUploadPrefix(normalizePrefix(event.target.value))} placeholder={prefix ? `Upload to ${prefix}` : 'Upload prefix'}/>
<input ref={fileRef} type="file" hidden onChange={(event) => {
const file = event.target.files?.[0]
if (file) uploadMut.mutate(file)
event.currentTarget.value = ''
}}/>
<button className="button" type="button" disabled={!canUpload} title={uploadCapability.reason} onClick={() => fileRef.current?.click()}>
<Upload size={14}/>
{uploadMut.isPending ? 'Uploading' : 'Upload'}
</button>
</>
)}
<button className="button" type="button" disabled={query.isFetching} onClick={() => query.refetch()}>
<RefreshCw size={14}/>
{query.isFetching ? 'Loading' : 'Refresh'}
</button>
</div>
</div>
{/* ── Create folder ── */}
{createFolderOpen && (
<form className="object-create-folder" onSubmit={(event) => {
event.preventDefault()
if (folderName.trim()) createFolderMut.mutate(folderName)
}}>
<label>
<span>Folder name</span>
<input className="input" value={folderName} onChange={(event) => setFolderName(event.target.value)} placeholder={prefix ? `${prefix}new-folder/` : 'new-folder/'}/>
</label>
<button className="button primary" type="submit" disabled={createFolderMut.isPending || !folderName.trim()}>
<Folder size={14}/>
{createFolderMut.isPending ? 'Creating' : 'Create folder'}
</button>
</form>
)}
{emptyBucketConfirm && (
<form className="object-create-folder" onSubmit={(event) => {
event.preventDefault()
if (emptyBucketText === resource.name) emptyBucketMut.mutate()
}}>
<label>
<span>Type <code>{resource.name}</code> to permanently delete every object and folder in this bucket.</span>
<input className="input" value={emptyBucketText} onChange={(event) => setEmptyBucketText(event.target.value)} placeholder={resource.name}/>
</label>
<button className="button danger" type="submit" disabled={emptyBucketMut.isPending || emptyBucketText !== resource.name}>
<Trash2 size={14}/>
{emptyBucketMut.isPending ? 'Emptying bucket' : 'Empty bucket'}
</button>
<button className="button" type="button" disabled={emptyBucketMut.isPending} onClick={() => {
setEmptyBucketConfirm(false)
setEmptyBucketText('')
}}>Cancel</button>
</form>
)}
{folderUpload && <p className={folderUpload.failed > 0 ? 'error-text compact-text' : 'muted compact-text'}>
Folder upload: {folderUpload.completed} of {folderUpload.total} files uploaded{folderUpload.failed > 0 ? `, ${folderUpload.failed} failed` : ''}.
</p>}
{folderUploadNotice && <p className="error-text compact-text">{folderUploadNotice}</p>}
{canUpload && <div className="object-upload-dropzone" onDragOver={(event) => 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.')
}
})
}}>
<Folder size={18}/><strong>Drop files or folders here</strong><span>Folder paths are preserved under the current upload prefix.</span>
</div>}
{/* ── Error ── */}
{error && (
<div className="inline-error">
{error instanceof Error ? error.message : 'Storage operation failed'}
</div>
)}
{/* ── Search bar ── */}
{!query.isLoading && objects.length > 0 && (
<div className="object-search-bar">
<Search size={13}/>
<input
className="input"
value={search}
onChange={(e) => { setSearch(e.target.value); setSelectedKeys(new Set()) }}
placeholder={`Search ${objectLabel} and folders…`}
/>
{search && (
<button className="icon-btn" type="button" onClick={() => setSearch('')} aria-label="Clear search" title="Clear search">
<X size={13} aria-hidden="true"/>
</button>
)}
{search && (
<span style={{fontSize: 12, color: 'var(--text-3)', whiteSpace: 'nowrap'}}>
{filteredObjects.length} / {objects.length}
</span>
)}
</div>
)}
{/* ── Table ── */}
{query.isLoading ? (
<div className="empty compact">
<h3>Loading objects</h3>
<p>Reading objects from the selected storage resource.</p>
</div>
) : objects.length === 0 ? (
<div className="empty compact">
<h3>No {objectLabel}</h3>
<p>{prefix ? `The ${prefix} prefix is empty.` : `This ${resource.type} has no ${objectLabel} yet.`}</p>
</div>
) : filteredObjects.length === 0 ? (
<div className="empty compact">
<h3>No results</h3>
<p>Nothing matches &ldquo;{search}&rdquo;.</p>
</div>
) : (
<table className="table object-table">
<thead>
<tr>
<th style={{width: 32, textAlign: 'center'}}>
<input
type="checkbox"
ref={(el) => { 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)))
}}
/>
</th>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Last Modified</th>
<th aria-label="Actions"/>
</tr>
</thead>
<tbody>
{filteredFolders.map((object) => (
<tr key={object.key}>
<td/>
<td onClick={() => navigate(object.key)} style={{cursor: 'pointer'}}>
<span className="object-name">
<Folder size={14}/>
{object.name}
<ChevronRight size={12}/>
</span>
</td>
<td>Folder</td>
<td>—</td>
<td>{object.lastModified ?? '—'}</td>
<td className="table-actions">
{canDelete && deleteFolderConfirm === object.key ? (
<span className="object-folder-confirm">
<button className="button danger compact" disabled={deleteFolderMut.isPending} onClick={() => deleteFolderMut.mutate(object)}>
Delete folder
</button>
<button className="button compact" disabled={deleteFolderMut.isPending} onClick={() => setDeleteFolderConfirm(null)}>
Cancel
</button>
</span>
) : deleteCapability && (
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name} and all contents`} aria-label={`Delete ${object.name} folder`} onClick={() => setDeleteFolderConfirm(object.key)}>
<Trash2 size={13}/>
</button>
)}
</td>
</tr>
))}
{filteredFiles.map((object) => {
const isSelected = selectedKeys.has(object.key)
const iconKind = objectIconKind(object.name)
const previewKind = objectPreviewKind(object.name)
return (
<tr
key={object.key}
className={[
selectedObjectKey === object.key ? 'selected' : '',
isSelected ? 'bulk-selected' : '',
].filter(Boolean).join(' ')}
>
<td style={{textAlign: 'center'}}>
<input
type="checkbox"
checked={isSelected}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = new Set(selectedKeys)
if (e.target.checked) next.add(object.key)
else next.delete(object.key)
setSelectedKeys(next)
}}
/>
</td>
<td onClick={() => onSelectObject(object)} style={{cursor: 'pointer'}}>
<span className="object-name">
{iconKind ? (
<ObjectThumbnail kind={iconKind} src={storageObjectDownloadUrl(cloud, resource.id, object.key)} name={object.name} canLoad={canDownload}/>
) : (
<File size={14}/>
)}
{object.name}
</span>
</td>
<td>{objectTypeLabel(object.name)}</td>
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
<td>{object.lastModified ?? '—'}</td>
<td className="table-actions">
{previewKind && (
<button
className="icon-btn"
type="button"
disabled={!canDownload}
title={canDownload ? `Preview ${object.name}` : (downloadCapability?.reason ?? `Preview ${object.name}`)}
onClick={(e) => { e.stopPropagation(); if (canDownload) setPreviewObject(object) }}
>
<Eye size={13}/>
</button>
)}
{downloadCapability && (
<a className={`icon-btn ${canDownload ? '' : 'disabled'}`} href={canDownload ? storageObjectDownloadUrl(cloud, resource.id, object.key) : undefined} title={downloadCapability.reason ?? `Download ${object.name}`}>
<Download size={13}/>
</a>
)}
{copyCapability && (
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Copy or move ${object.name}`} aria-label={`Copy or move ${object.name}`} onClick={() => setCopyObject(object)}>
<Copy size={13}/>
</button>
)}
{canDelete && deleteConfirm === object.key ? (
<button className="button danger compact" disabled={deleteMut.isPending} onClick={() => {
deleteMut.mutate(object)
setDeleteConfirm(null)
}}>
Confirm
</button>
) : (
deleteCapability && (
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name}`} onClick={() => setDeleteConfirm(object.key)}>
<Trash2 size={13}/>
</button>
)
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
{/* ── Bulk action bar ── */}
{selectedKeys.size > 0 && (
<div className="bulk-bar">
<span className="bulk-count">
{selectedKeys.size} {objectLabel.replace(/s$/, '')}{selectedKeys.size !== 1 ? 's' : ''} selected
</span>
<span className="bulk-spacer"/>
<button
className="button"
onClick={() => setSelectedKeys(new Set())}
disabled={bulkDeleting}
>
Deselect all
</button>
{deleteCapability && (
<button
className="button danger"
disabled={!canDelete || bulkDeleting}
title={deleteCapability.reason}
onClick={() => void handleBulkDelete()}
>
{bulkDeleting ? <Loader2 size={13}/> : <Trash2 size={13}/>}
Delete {selectedKeys.size}
</button>
)}
</div>
)}
</section>
)
}
// ─── 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 (
<div className="copy-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="copy-modal">
<div className="drawer-tabs" style={{marginBottom: 14}}>
<button className={`drawer-tab ${mode === 'move' ? 'active' : ''}`} onClick={() => setMode('move')}>Move</button>
<button className={`drawer-tab ${mode === 'copy' ? 'active' : ''}`} onClick={() => setMode('copy')}>Copy</button>
</div>
<div style={{fontSize: 12, color: '#8d9cad', marginBottom: 12}}>
Source: <span className="mono" style={{color: 'var(--text-2)'}}>{resource.name}/{srcObject.key}</span>
</div>
<div className="form-row">
<label>Destination {resourceLabel}</label>
{resourcesQuery.isLoading ? (
<div style={{display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#8d9cad'}}>
<Loader2 size={13}/> Loading {resourceLabel}s…
</div>
) : (
<select className="input" value={destResourceId} onChange={(e) => setDestResourceId(e.target.value)}>
{resources.map((r) => (
<option key={r.id} value={r.id}>
{r.name}{r.id === resource.id ? ' (current)' : ''}
</option>
))}
<option value={NEW_RESOURCE_SENTINEL}>+ New {resourceLabel}…</option>
</select>
)}
</div>
{isCreatingNew && (
<div style={{display: 'flex', gap: 6, alignItems: 'center', marginTop: -6, marginBottom: 4}}>
<input
className="input"
autoFocus
value={newResourceName}
onChange={(e) => 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() }}
/>
<button
className="button primary"
disabled={newResourceName.trim().length < 3 || createResourceMut.isPending}
onClick={() => createResourceMut.mutate()}
>
{createResourceMut.isPending ? <Loader2 size={13}/> : 'Create'}
</button>
</div>
)}
{createResourceMut.isError && (
<p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 6px'}}>
{createResourceMut.error instanceof Error ? createResourceMut.error.message : 'Create failed'}
</p>
)}
<div className="form-row">
<label>Destination key</label>
<input className="input" value={destKey} onChange={(e) => setDestKey(e.target.value)}/>
</div>
{error && <p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 4px'}}>{error}</p>}
<div className="copy-modal-footer">
<button className="button" onClick={onClose} disabled={isPending}>Cancel</button>
<button
className="button primary"
disabled={!destKey.trim() || isCreatingNew || !canCopy || isPending}
onClick={() => onConfirm(destKey, resolvedDestId, mode)}
>
{isPending ? <Loader2 size={13}/> : <Copy size={13}/>}
{mode === 'move' ? 'Move' : 'Copy'}
</button>
</div>
</div>
</div>
)
}
// ─── Breadcrumb ───────────────────────────────────────────────────────────────
function ObjectBreadcrumb({prefix, onNavigate}: {prefix: string; onNavigate: (prefix: string) => void}) {
const segments = prefix ? prefix.replace(/\/$/, '').split('/') : []
return (
<div className="object-breadcrumb">
<button type="button" onClick={() => onNavigate('')}>Root</button>
{segments.map((segment, index) => {
const path = `${segments.slice(0, index + 1).join('/')}/`
return (
<span key={path}>
<ChevronRight size={11}/>
<button type="button" onClick={() => onNavigate(path)}>{segment}</button>
</span>
)
})}
</div>
)
}
// ─── 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<File[]> {
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<File[]> {
const files: File[] = []
const path = `${prefix}${entry.name}`
if (entry.isFile) {
const file = await new Promise<File>((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<DroppedEntry[]>((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<void> {
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<void> {
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)))
}