fix(gridfs): support bulk download and paged browsing (#2743)

This commit is contained in:
LSD authored and GitHub committed 2026-07-07 12:43:07 +08:00
1 parent 65af804099
commit a7be9eed13
13 files changed
+493 -48

No files matched your search

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Filter, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Download, Filter, LoaderCircle, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,12 +9,16 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/composables/useToast";
import { buildDocumentFilterCondition, currentDocumentFilterJson, currentDocumentSortJson, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/app/documentStoreProvider";
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/dataGrid/binaryCellDownload";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, gridFsFileFieldDisplayOption, gridFsFileFieldDisplayOptions, currentGridFsFileSortDirection, gridFsFilesQueryPreview } from "@/lib/document/gridFsBrowser";
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/dataGrid/binaryCellDownload";
import { normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/dataGrid/paginationPageSize";
import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, currentGridFsFileSortDirection, gridFsFileFieldDisplayOption, gridFsFileFieldDisplayOptions, gridFsFilesQueryPreview } from "@/lib/document/gridFsBrowser";
import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "@/lib/document/gridfsFiles";
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/document/gridfsPagination";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
const props = defineProps<{
connectionId: string;
@@ -25,22 +29,38 @@ const props = defineProps<{
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const loading = ref(false);
const uploading = ref(false);
const downloading = ref(false);
const deleting = ref(false);
const error = ref("");
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
const selectedFileId = ref("");
const checkedFileIds = ref<Set<string>>(new Set());
const showDeleteConfirm = ref(false);
const filterInput = ref("");
const sortInput = ref("");
const filterBuilderOpen = ref(false);
const filterRules = ref<DocumentFilterRule[]>([createGridFsFileFilterRule(uuid())]);
const appliedStructuredFilter = ref<Record<string, unknown> | null>(null);
const page = ref(0);
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize));
const totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
const totalFileCount = computed(() => files.value.length);
const totalPages = computed(() => gridFsTotalPages(totalFileCount.value, pageSize.value));
const pagedFiles = computed(() => paginateGridFsItems(files.value, page.value, pageSize.value));
const pageSizeOptions = computed(() => resultPageSizeMenuOptions(pageSize.value));
const selectedFile = computed(() => files.value.find((file) => file.id === selectedFileId.value) || null);
const checkedFiles = computed(() => files.value.filter((file) => checkedFileIds.value.has(file.id)));
const checkedFileCount = computed(() => checkedFiles.value.length);
const canToggleAllFiles = computed(() => files.value.length > 0);
const allFilesChecked = computed(() => files.value.length > 0 && checkedFileIds.value.size === files.value.length);
const partiallyChecked = computed(() => checkedFileIds.value.size > 0 && checkedFileIds.value.size < files.value.length);
const downloadTargets = computed(() => (checkedFileCount.value > 0 ? checkedFiles.value : selectedFile.value ? [selectedFile.value] : []));
const downloadButtonLabel = computed(() => (checkedFileCount.value > 0 ? t("gridfsBrowser.downloadSelected") : t("gridfsBrowser.downloadFile")));
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const mongoProvider = documentStoreProviderFor("mongodb");
@@ -85,6 +105,10 @@ function gridFsFileFieldLabel(fieldName: string): string {
return fieldName;
}
function formattedUploadDate(value?: string | null): string {
return formatGridFsUploadDate(value);
}
function currentFilesFilter(): string | undefined {
return currentDocumentFilterJson(filterInput.value, appliedStructuredFilter.value, "mongodb");
}
@@ -121,15 +145,57 @@ function resetFilterBuilder() {
filterRules.value = [createGridFsFileFilterRule(uuid())];
}
function isFileChecked(fileId: string): boolean {
return checkedFileIds.value.has(fileId);
}
function setFileChecked(fileId: string, checked: boolean) {
const next = new Set(checkedFileIds.value);
if (checked) next.add(fileId);
else next.delete(fileId);
checkedFileIds.value = next;
}
function toggleAllFiles(checked: boolean) {
checkedFileIds.value = checked ? new Set(files.value.map((file) => file.id)) : new Set();
}
function ensurePageInRange() {
page.value = clampGridFsPage(page.value, totalFileCount.value, pageSize.value);
}
function resetToFirstPage() {
page.value = 0;
}
function prevPage() {
if (page.value <= 0) return;
page.value -= 1;
}
function nextPage() {
if (page.value + 1 >= totalPages.value) return;
page.value += 1;
}
function updatePageSize(nextValue: unknown) {
const normalized = normalizeResultPageSize(nextValue, pageSize.value);
pageSize.value = normalized;
resetToFirstPage();
settingsStore.updateEditorSettings({ pageSize: normalized });
}
async function loadFiles() {
loading.value = true;
error.value = "";
try {
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket, currentFilesFilter(), currentDocumentSortJson(sortInput.value));
files.value = nextFiles;
checkedFileIds.value = new Set(nextFiles.filter((file) => checkedFileIds.value.has(file.id)).map((file) => file.id));
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
selectedFileId.value = "";
}
ensurePageInRange();
} catch (e: any) {
error.value = e?.message || String(e);
} finally {
@@ -138,6 +204,7 @@ async function loadFiles() {
}
function applyQuery() {
resetToFirstPage();
void loadFiles();
}
@@ -152,6 +219,7 @@ function toggleSortForColumn(column: string) {
const current = currentSortDirection(column);
const nextDirection: SortDirection = current === "asc" ? "desc" : current === "desc" ? null : "asc";
sortInput.value = mongoProvider.sortInputForColumn(column, nextDirection);
resetToFirstPage();
void loadFiles();
}
@@ -167,6 +235,7 @@ function sortIconClass(column: string): string {
function applyStructuredFilters() {
appliedStructuredFilter.value = buildGridFsFilesStructuredFilter(filterRules.value);
filterBuilderOpen.value = false;
resetToFirstPage();
void loadFiles();
}
@@ -174,23 +243,64 @@ function clearAllFilters() {
filterInput.value = "";
appliedStructuredFilter.value = null;
filterRules.value = [createGridFsFileFilterRule(uuid())];
resetToFirstPage();
void loadFiles();
}
async function downloadFile(file: (typeof files.value)[number]) {
async function downloadSingleFile(file: (typeof files.value)[number]) {
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
const payload: BinaryCellDownloadPayload = {
data: bytes,
mimeType: file.contentType || "application/octet-stream",
extension: file.filename?.includes(".") ? file.filename.split(".").pop() || "bin" : "bin",
};
return downloadBinaryCellPayload(payload, displayName(file));
}
async function downloadMultipleFiles(selectedFiles: typeof files.value) {
const archiveEntries: Array<{ id: string; filename?: string; data: Uint8Array }> = [];
for (const file of selectedFiles) {
archiveEntries.push({
id: file.id,
filename: file.filename,
data: await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id),
});
}
const payload: BinaryCellDownloadPayload = {
data: buildGridFsDownloadArchive(archiveEntries),
mimeType: "application/zip",
extension: "zip",
};
return downloadBinaryCellPayload(payload, defaultGridFsArchiveFileName(props.bucket));
}
async function downloadSpecificFile(file: (typeof files.value)[number]) {
if (downloading.value) return;
downloading.value = true;
try {
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
const payload: BinaryCellDownloadPayload = {
data: bytes,
mimeType: file.contentType || "application/octet-stream",
extension: file.filename?.includes(".") ? file.filename.split(".").pop() || "bin" : "bin",
};
const result = await downloadBinaryCellPayload(payload, displayName(file));
const result = await downloadSingleFile(file);
if (result.kind === "saved") {
toast(t("grid.exported"), 2500);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
downloading.value = false;
}
}
async function downloadCurrentSelection() {
if (downloading.value || downloadTargets.value.length === 0) return;
downloading.value = true;
try {
const result = downloadTargets.value.length === 1 ? await downloadSingleFile(downloadTargets.value[0]) : await downloadMultipleFiles(downloadTargets.value);
if (result.kind === "saved") {
toast(t("grid.exported"), 2500);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
downloading.value = false;
}
}
@@ -250,6 +360,7 @@ async function deleteSelectedFile() {
await api.documentDeleteGridFsFile(props.connectionId, props.database, props.bucket, file.id);
showDeleteConfirm.value = false;
selectedFileId.value = "";
setFileChecked(file.id, false);
await loadFiles();
toast(t("gridfsBrowser.fileDeleted", { fileName: displayName(file) }), 2500);
} catch (e: any) {
@@ -262,24 +373,53 @@ async function deleteSelectedFile() {
onMounted(() => {
void loadFiles();
});
watch(
() => settingsStore.editorSettings.pageSize,
(value) => {
pageSize.value = normalizeResultPageSize(value, pageSize.value);
ensurePageInRange();
},
);
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="border-b border-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold">{{ database }}.{{ bucket }}</div>
<div class="text-xs text-muted-foreground">{{ files.length }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}</div>
<div class="text-xs text-muted-foreground">
{{ totalFileCount }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}
<span v-if="checkedFileCount > 0"> / {{ t("gridfsBrowser.selectedCount", { count: checkedFileCount }) }}</span>
</div>
</div>
<div v-if="totalFileCount > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="page <= 0" @click="prevPage">
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
<span class="tabular-nums">{{ page + 1 }} / {{ totalPages }}</span>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="page + 1 >= totalPages" @click="nextPage">
<ChevronRight class="h-3.5 w-3.5" />
</Button>
<Select :model-value="String(pageSize)" @update:model-value="(value) => updatePageSize(value)">
<SelectTrigger class="h-7 w-[106px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem v-for="size in pageSizeOptions" :key="size" :value="String(size)"> {{ size }}{{ t("grid.rowsPerPageShort") }} </SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" class="h-8 gap-1.5" :disabled="isReadonly || uploading" @click="uploadFile">
<Upload class="h-3.5 w-3.5" />
{{ t("gridfsBrowser.uploadFile") }}
</Button>
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="!selectedFile" @click="selectedFile && downloadFile(selectedFile)">
<Download class="h-3.5 w-3.5" />
{{ t("gridfsBrowser.downloadFile") }}
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="downloadTargets.length === 0 || downloading" @click="downloadCurrentSelection">
<LoaderCircle v-if="downloading" class="h-3.5 w-3.5 animate-spin" />
<Download v-else class="h-3.5 w-3.5" />
{{ downloadButtonLabel }}
</Button>
<Button variant="destructive" size="sm" class="h-8 gap-1.5" :disabled="isReadonly || !selectedFile || deleting" @click="showDeleteConfirm = true">
<Trash2 class="h-3.5 w-3.5" />
@@ -442,49 +582,52 @@ onMounted(() => {
{{ t("gridfsBrowser.emptyFiles") }}
</div>
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
<div v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row xl:divide-x xl:divide-border">
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full border-collapse text-sm">
<table class="min-w-full border-collapse text-[13px]">
<thead class="sticky top-0 z-10 bg-background">
<tr class="border-b border-border text-left text-xs text-muted-foreground">
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('filename')">
<th class="w-11 px-3 py-2 font-medium">
<input type="checkbox" class="h-3.5 w-3.5 accent-primary" :checked="allFilesChecked" :indeterminate="partiallyChecked" :disabled="!canToggleAllFiles" @click.stop @change="toggleAllFiles(($event.target as HTMLInputElement).checked)" />
</th>
<th class="min-w-[320px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('filename')">
<span>{{ gridFsFileFieldLabel("filename") }}</span>
<component :is="sortIconForColumn('filename')" class="h-3.5 w-3.5" :class="sortIconClass('filename')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('_id')">
<th class="min-w-[180px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('_id')">
<span>{{ gridFsFileFieldLabel("_id") }}</span>
<component :is="sortIconForColumn('_id')" class="h-3.5 w-3.5" :class="sortIconClass('_id')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('length')">
<th class="min-w-[96px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('length')">
<span>{{ gridFsFileFieldLabel("length") }}</span>
<component :is="sortIconForColumn('length')" class="h-3.5 w-3.5" :class="sortIconClass('length')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('chunkSize')">
<th class="min-w-[96px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('chunkSize')">
<span>{{ gridFsFileFieldLabel("chunkSize") }}</span>
<component :is="sortIconForColumn('chunkSize')" class="h-3.5 w-3.5" :class="sortIconClass('chunkSize')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('uploadDate')">
<th class="min-w-[158px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('uploadDate')">
<span>{{ gridFsFileFieldLabel("uploadDate") }}</span>
<component :is="sortIconForColumn('uploadDate')" class="h-3.5 w-3.5" :class="sortIconClass('uploadDate')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('contentType')">
<th class="min-w-[104px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('contentType')">
<span>{{ gridFsFileFieldLabel("contentType") }}</span>
<component :is="sortIconForColumn('contentType')" class="h-3.5 w-3.5" :class="sortIconClass('contentType')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('md5')">
<th class="min-w-[220px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('md5')">
<span>{{ gridFsFileFieldLabel("md5") }}</span>
<component :is="sortIconForColumn('md5')" class="h-3.5 w-3.5" :class="sortIconClass('md5')" />
</button>
@@ -492,23 +635,36 @@ onMounted(() => {
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.id" class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40" :class="{ 'bg-accent/45': selectedFileId === file.id }" @click="selectedFileId = file.id" @dblclick="downloadFile(file)">
<td class="px-4 py-2 align-top">
<div class="font-medium">{{ displayName(file) }}</div>
<div v-if="file.aliases?.length" class="mt-1 text-xs text-muted-foreground">{{ file.aliases.join(", ") }}</div>
<tr
v-for="file in pagedFiles"
:key="file.id"
class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40"
:class="{
'bg-accent/45': selectedFileId === file.id,
'bg-muted/35': selectedFileId !== file.id && isFileChecked(file.id),
}"
@click="selectedFileId = file.id"
@dblclick="downloadSpecificFile(file)"
>
<td class="px-3 py-2 align-top">
<input type="checkbox" class="h-3.5 w-3.5 accent-primary" :checked="isFileChecked(file.id)" @click.stop @change="setFileChecked(file.id, ($event.target as HTMLInputElement).checked)" />
</td>
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.id }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.length) }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.chunkSize || 0) }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.uploadDate || "-" }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.contentType || "-" }}</td>
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.md5 || "-" }}</td>
<td class="max-w-[440px] px-4 py-2 align-top">
<div class="truncate font-medium" :title="displayName(file)">{{ displayName(file) }}</div>
<div v-if="file.aliases?.length" class="mt-1 truncate text-xs text-muted-foreground" :title="file.aliases.join(', ')">{{ file.aliases.join(", ") }}</div>
</td>
<td class="px-4 py-2 align-top whitespace-nowrap text-[12px] text-muted-foreground">{{ file.id }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formatBytes(file.length) }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formatBytes(file.chunkSize || 0) }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formattedUploadDate(file.uploadDate) }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap text-muted-foreground">{{ file.contentType || "-" }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap text-[12px] text-muted-foreground">{{ file.md5 || "-" }}</td>
</tr>
</tbody>
</table>
</div>
<aside class="border-t border-border px-4 py-4 xl:w-80 xl:shrink-0 xl:border-t-0">
<aside class="overflow-auto border-t border-border px-4 py-4 xl:w-80 xl:shrink-0 xl:border-t-0">
<template v-if="selectedFile">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
<div class="mt-2 break-all text-lg font-semibold">{{ displayName(selectedFile) }}</div>
@@ -525,7 +681,7 @@ onMounted(() => {
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.uploadDate") }}</div>
<div class="mt-1 font-medium">{{ selectedFile.uploadDate || "-" }}</div>
<div class="mt-1 font-medium">{{ formattedUploadDate(selectedFile.uploadDate) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.contentType") }}</div>
@@ -152,7 +152,7 @@ onMounted(() => {
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="border-b border-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
@@ -263,7 +263,7 @@ onMounted(() => {
{{ t("gridfsBrowser.emptyBuckets") }}
</div>
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
<div v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row xl:divide-x xl:divide-border">
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full border-collapse text-sm">
<thead class="sticky top-0 z-10 bg-background">
@@ -298,7 +298,7 @@ onMounted(() => {
</table>
</div>
<aside class="border-t border-border px-4 py-4 xl:w-72 xl:shrink-0 xl:border-t-0">
<aside class="overflow-auto border-t border-border px-4 py-4 xl:w-72 xl:shrink-0 xl:border-t-0">
<template v-if="selectedBucket">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
<div class="mt-2 break-all text-lg font-semibold">{{ selectedBucket.name }}</div>