feat: add SQS + EventBridge
This commit is contained in:
@@ -35,11 +35,16 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
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')
|
||||
@@ -69,10 +74,15 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
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])
|
||||
|
||||
@@ -91,6 +101,27 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
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
|
||||
@@ -115,6 +146,31 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
},
|
||||
})
|
||||
|
||||
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'
|
||||
@@ -137,6 +193,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
setSearch('')
|
||||
setSelectedKeys(new Set())
|
||||
setDeleteConfirm(null)
|
||||
setDeleteFolderConfirm(null)
|
||||
setPreviewObject(null)
|
||||
onSelectObject(undefined)
|
||||
}
|
||||
@@ -181,7 +238,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
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 ?? moveCopyMut.error
|
||||
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? deleteFolderMut.error ?? emptyBucketMut.error ?? moveCopyMut.error
|
||||
|
||||
return (
|
||||
<section className="object-browser">
|
||||
@@ -224,6 +281,12 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
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}/>
|
||||
@@ -267,6 +330,42 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
</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 && (
|
||||
@@ -348,10 +447,25 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
<ChevronRight size={12}/>
|
||||
</span>
|
||||
</td>
|
||||
<td>{object.type}</td>
|
||||
<td>Folder</td>
|
||||
<td>—</td>
|
||||
<td>{object.lastModified ?? '—'}</td>
|
||||
<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) => {
|
||||
@@ -389,7 +503,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
{object.name}
|
||||
</span>
|
||||
</td>
|
||||
<td>{object.type}</td>
|
||||
<td>{objectTypeLabel(object.name)}</td>
|
||||
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
|
||||
<td>{object.lastModified ?? '—'}</td>
|
||||
<td className="table-actions">
|
||||
@@ -639,3 +753,59 @@ function parentPrefix(prefix: string): string {
|
||||
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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user