feat(mongodb): improve GridFS manager filtering and sorting

* docs: add GridFS manager filter and sort design

* feat: improve GridFS manager filtering and sorting
This commit is contained in:
LSD 2026-07-06 17:17:33 +08:00 committed by GitHub
parent 26647ff755
commit fa5e55a376
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1294 additions and 40 deletions

View File

@ -1,13 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Download, RefreshCcw, Trash2, Upload } from "@lucide/vue";
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Filter, 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";
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 { useConnectionStore } from "@/stores/connectionStore";
const props = defineProps<{
@ -27,11 +33,38 @@ const error = ref("");
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
const selectedFileId = ref("");
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 totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
const selectedFile = computed(() => files.value.find((file) => file.id === selectedFileId.value) || null);
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");
const activeStructuredRuleCount = computed(() => {
if (!appliedStructuredFilter.value) return 0;
return filterRules.value.filter((rule) => !!buildDocumentFilterCondition(rule, { kind: "mongodb" })).length;
});
const filterBuilderActive = computed(() => !!appliedStructuredFilter.value);
const filesQueryPreview = computed(() => {
let filter = "{}";
let sort: string | undefined;
try {
filter = currentFilesFilter() || "{}";
sort = currentDocumentSortJson(sortInput.value);
} catch {
filter = filterInput.value.trim() || "{}";
sort = sortInput.value.trim() || undefined;
}
return gridFsFilesQueryPreview({
bucket: props.bucket,
filterJson: filter,
sortJson: sort,
});
});
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@ -44,11 +77,55 @@ function displayName(file: (typeof files.value)[number]): string {
return file.filename || file.id;
}
function gridFsFileFieldLabel(fieldName: string): string {
const option = gridFsFileFieldDisplayOption(fieldName);
if (!option) return fieldName;
if (option.label !== undefined) return option.label;
if (option.labelKey !== undefined) return t(option.labelKey);
return fieldName;
}
function currentFilesFilter(): string | undefined {
return currentDocumentFilterJson(filterInput.value, appliedStructuredFilter.value, "mongodb");
}
function ensureFilterRule() {
if (filterRules.value.length === 0) {
filterRules.value = [createGridFsFileFilterRule(uuid())];
}
}
function addFilterRule() {
ensureFilterRule();
filterRules.value = [...filterRules.value, createGridFsFileFilterRule(uuid())];
}
function removeFilterRule(ruleId: string) {
filterRules.value = filterRules.value.filter((rule) => rule.id !== ruleId);
if (filterRules.value.length === 0) {
appliedStructuredFilter.value = null;
}
}
function updateFilterRule(ruleId: string, patch: Partial<DocumentFilterRule>) {
filterRules.value = filterRules.value.map((rule) => {
if (rule.id !== ruleId) return rule;
const next = { ...rule, ...patch };
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
return next;
});
}
function resetFilterBuilder() {
appliedStructuredFilter.value = null;
filterRules.value = [createGridFsFileFilterRule(uuid())];
}
async function loadFiles() {
loading.value = true;
error.value = "";
try {
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket);
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket, currentFilesFilter(), currentDocumentSortJson(sortInput.value));
files.value = nextFiles;
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
selectedFileId.value = "";
@ -60,6 +137,46 @@ async function loadFiles() {
}
}
function applyQuery() {
void loadFiles();
}
type SortDirection = "asc" | "desc" | null;
function currentSortDirection(column: string): SortDirection {
const direction = currentGridFsFileSortDirection(sortInput.value, column);
return direction === "none" ? null : direction;
}
function toggleSortForColumn(column: string) {
const current = currentSortDirection(column);
const nextDirection: SortDirection = current === "asc" ? "desc" : current === "desc" ? null : "asc";
sortInput.value = mongoProvider.sortInputForColumn(column, nextDirection);
void loadFiles();
}
function sortIconForColumn(column: string) {
const direction = currentSortDirection(column);
return direction === "asc" ? ArrowUp : direction === "desc" ? ArrowDown : ArrowUpDown;
}
function sortIconClass(column: string): string {
return currentSortDirection(column) ? "text-foreground" : "text-muted-foreground/60";
}
function applyStructuredFilters() {
appliedStructuredFilter.value = buildGridFsFilesStructuredFilter(filterRules.value);
filterBuilderOpen.value = false;
void loadFiles();
}
function clearAllFilters() {
filterInput.value = "";
appliedStructuredFilter.value = null;
filterRules.value = [createGridFsFileFilterRule(uuid())];
void loadFiles();
}
async function downloadFile(file: (typeof files.value)[number]) {
try {
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
@ -174,6 +291,143 @@ onMounted(() => {
</Button>
</div>
</div>
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-background/80 shadow-xs">
<div class="flex flex-col md:flex-row">
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
<Popover v-model:open="filterBuilderOpen">
<PopoverTrigger as-child>
<button
type="button"
class="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-[11px] transition-colors"
:class="filterBuilderActive || filterInput.trim() ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="ensureFilterRule"
>
<Filter class="h-3.5 w-3.5" />
<span v-if="activeStructuredRuleCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">
{{ activeStructuredRuleCount }}
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[420px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addFilterRule">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("grid.filterBuilderAddRule") }}
</Button>
</div>
<div v-if="filterRules.length" class="space-y-2">
<template v-for="(rule, index) in filterRules" :key="rule.id">
<div v-if="index > 0" class="flex justify-center">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
@click="
updateFilterRule(rule.id, {
conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND',
})
"
>
{{ rule.conjunction }}
</Button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5">
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateFilterRule(rule.id, { fieldName: String(value) })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue :placeholder="t('grid.filterBuilderColumn')" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="option in gridFsFileFieldDisplayOptions" :key="option.fieldName" :value="option.fieldName">
{{ gridFsFileFieldLabel(option.fieldName) }}
</SelectItem>
</SelectContent>
</Select>
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateFilterRule(rule.id, { mode: value as DocumentFilterMode })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="option in documentFilterModeOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<Input
v-if="documentFilterModeNeedsValue(rule.mode)"
:model-value="rule.rawValue"
class="h-8 min-w-0 text-xs"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => updateFilterRule(rule.id, { rawValue: String(value ?? '') })"
@keydown.enter.prevent="applyStructuredFilters"
/>
<div v-else class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border border-dashed px-2 text-xs text-muted-foreground">
<span class="truncate">{{ t("grid.filterBuilderNoValue") }}</span>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :disabled="filterRules.length === 1" @click="removeFilterRule(rule.id)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</template>
</div>
<div v-else class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("grid.filterBuilderEmpty") }}
</div>
<div class="flex items-center justify-between gap-2 pt-1">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearAllFilters">
{{ t("grid.clearFilter") }}
</Button>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetFilterBuilder">
{{ t("grid.resetFilterBuilder") }}
</Button>
<Button size="sm" class="h-8 px-3 text-xs" @click="applyStructuredFilters">
{{ t("grid.applyFilter") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<span class="shrink-0 text-xs font-medium text-blue-600 dark:text-blue-400">{{ mongoProvider.filterInputLabel }}</span>
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60" placeholder="{}" @keydown.enter="applyQuery" />
<button
v-if="filterInput.trim()"
class="shrink-0 text-muted-foreground hover:text-foreground"
@click="
filterInput = '';
applyQuery();
"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="h-px bg-border/70 md:h-auto md:w-px" />
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
<span class="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">{{ mongoProvider.sortInputLabel }}</span>
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60" placeholder='{"uploadDate":-1}' @keydown.enter="applyQuery" />
<button
v-if="sortInput.trim()"
class="shrink-0 text-muted-foreground hover:text-foreground"
@click="
sortInput = '';
applyQuery();
"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
<div class="border-t border-border/60 px-3 py-1.5">
<div class="truncate font-mono text-[11px] text-muted-foreground" :title="filesQueryPreview">
{{ filesQueryPreview }}
</div>
</div>
</div>
</div>
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
@ -193,13 +447,48 @@ onMounted(() => {
<table class="min-w-full border-collapse text-sm">
<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">{{ t("gridfsBrowser.name") }}</th>
<th class="px-4 py-2 font-medium">ID</th>
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.totalSize") }}</th>
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.chunkSize") }}</th>
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.uploadDate") }}</th>
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.contentType") }}</th>
<th class="px-4 py-2 font-medium">MD5</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 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')">
<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')">
<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')">
<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')">
<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')">
<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')">
<span>{{ gridFsFileFieldLabel("md5") }}</span>
<component :is="sortIconForColumn('md5')" class="h-3.5 w-3.5" :class="sortIconClass('md5')" />
</button>
</th>
</tr>
</thead>
<tbody>

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { FolderOpen, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
import { ArrowDown, ArrowUp, ArrowUpDown, Filter, FolderOpen, Plus, RefreshCcw, Trash2, X } from "@lucide/vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@ -30,6 +32,9 @@ const selectedBucketName = ref("");
const showCreateDialog = ref(false);
const showDeleteConfirm = ref(false);
const newBucketName = ref("");
const filterInput = ref("");
const sortInput = ref("");
const filterBuilderOpen = ref(false);
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0));
@ -47,7 +52,7 @@ async function loadBuckets() {
loading.value = true;
error.value = "";
try {
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database);
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database, currentGridFsBucketFilter(filterInput.value), currentGridFsBucketSort(sortInput.value));
buckets.value = nextBuckets;
if (selectedBucketName.value && !nextBuckets.some((bucket) => bucket.name === selectedBucketName.value)) {
selectedBucketName.value = "";
@ -59,6 +64,48 @@ async function loadBuckets() {
}
}
function applyQuery() {
void loadBuckets();
}
type SortDirection = "asc" | "desc" | null;
function currentSortDirection(column: "name" | "fileCount" | "totalBytes"): SortDirection {
const direction = currentGridFsBucketSortDirection(sortInput.value, column);
return direction === "none" ? null : direction;
}
function toggleSortForColumn(column: "name" | "fileCount" | "totalBytes") {
const current = currentSortDirection(column);
const nextDirection: SortDirection = current === "asc" ? "desc" : current === "desc" ? null : "asc";
sortInput.value = gridFsBucketSortInputForColumn(column, nextDirection);
void loadBuckets();
}
function sortIconForColumn(column: "name" | "fileCount" | "totalBytes") {
const direction = currentSortDirection(column);
return direction === "asc" ? ArrowUp : direction === "desc" ? ArrowDown : ArrowUpDown;
}
function sortIconClass(column: "name" | "fileCount" | "totalBytes"): string {
return currentSortDirection(column) ? "text-foreground" : "text-muted-foreground/60";
}
function applyBucketNameFilter() {
filterBuilderOpen.value = false;
void loadBuckets();
}
function resetBucketNameFilter() {
filterInput.value = "";
}
function clearBucketNameFilter() {
filterInput.value = "";
filterBuilderOpen.value = false;
void loadBuckets();
}
function openBucket(bucketName = selectedBucketName.value) {
if (!bucketName) return;
queryStore.openMongoBucket(props.connectionId, props.database, bucketName);
@ -131,6 +178,77 @@ onMounted(() => {
</Button>
</div>
</div>
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-background/80 shadow-xs">
<div class="flex flex-col md:flex-row">
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
<Popover v-model:open="filterBuilderOpen">
<PopoverTrigger as-child>
<button
type="button"
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-[11px] transition-colors"
:class="filterInput.trim() ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
>
<Filter class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[420px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)] items-center gap-1.5">
<div class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border px-2 text-xs font-medium text-foreground">
<span class="truncate">{{ t("gridfsBrowser.name") }}</span>
</div>
<div class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border px-2 text-xs text-muted-foreground">
<span class="truncate">{{ t("grid.filterBuilderContains") }}</span>
</div>
<Input v-model="filterInput" class="h-8 min-w-0 text-xs" :placeholder="t('gridfsBrowser.bucketNamePlaceholder')" @keydown.enter.prevent="applyBucketNameFilter" />
</div>
<div class="flex items-center justify-between gap-2 pt-1">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearBucketNameFilter">
{{ t("grid.clearFilter") }}
</Button>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetBucketNameFilter">
{{ t("grid.resetFilterBuilder") }}
</Button>
<Button size="sm" class="h-8 px-3 text-xs" @click="applyBucketNameFilter">
{{ t("grid.applyFilter") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<span class="shrink-0 text-xs font-medium text-blue-600 dark:text-blue-400">{{ t("grid.filter") }}</span>
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/60" :placeholder="t('gridfsBrowser.bucketNamePlaceholder')" @keydown.enter="applyQuery" />
<button
v-if="filterInput.trim()"
class="shrink-0 text-muted-foreground hover:text-foreground"
@click="
filterInput = '';
applyQuery();
"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="h-px bg-border/70 md:h-auto md:w-px" />
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
<span class="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">{{ t("grid.sort") }}</span>
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60" placeholder='{"name":1}' @keydown.enter="applyQuery" />
<button
v-if="sortInput.trim()"
class="shrink-0 text-muted-foreground hover:text-foreground"
@click="
sortInput = '';
applyQuery();
"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
@ -150,9 +268,24 @@ onMounted(() => {
<table class="min-w-full border-collapse text-sm">
<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">{{ t("gridfsBrowser.name") }}</th>
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.fileCount") }}</th>
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.totalSize") }}</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('name')">
<span>{{ t("gridfsBrowser.name") }}</span>
<component :is="sortIconForColumn('name')" class="h-3.5 w-3.5" :class="sortIconClass('name')" />
</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('fileCount')">
<span>{{ t("gridfsBrowser.fileCount") }}</span>
<component :is="sortIconForColumn('fileCount')" class="h-3.5 w-3.5" :class="sortIconClass('fileCount')" />
</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('totalBytes')">
<span>{{ t("gridfsBrowser.totalSize") }}</span>
<component :is="sortIconForColumn('totalBytes')" class="h-3.5 w-3.5" :class="sortIconClass('totalBytes')" />
</button>
</th>
</tr>
</thead>
<tbody>

View File

@ -354,9 +354,9 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Database, colorClass: "text-yellow-500" };
case "mongo-gridfs":
case "mongo-buckets":
return { icon: Archive, colorClass: "text-amber-500" };
return { icon: Archive, colorClass: "text-cyan-500" };
case "mongo-bucket":
return { icon: Archive, colorClass: "text-amber-400" };
return { icon: Archive, colorClass: "text-cyan-400" };
case "mongo-collection":
return { icon: Table, colorClass: "text-green-400" };
case "vector-collection":

View File

@ -1893,12 +1893,12 @@ export async function documentFindDocuments(connectionId: string, database: stri
return post("/api/document-store/find-documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string): Promise<MongoGridFsFileInfo[]> {
return post("/api/document-store/list-gridfs-files", { connectionId, database, bucket });
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string, filter?: string, sort?: string): Promise<MongoGridFsFileInfo[]> {
return post("/api/document-store/list-gridfs-files", { connectionId, database, bucket, filter, sort });
}
export async function documentListGridFsBuckets(connectionId: string, database: string): Promise<MongoGridFsBucketInfo[]> {
return post("/api/document-store/list-gridfs-buckets", { connectionId, database });
export async function documentListGridFsBuckets(connectionId: string, database: string, filter?: string, sort?: string): Promise<MongoGridFsBucketInfo[]> {
return post("/api/document-store/list-gridfs-buckets", { connectionId, database, filter, sort });
}
export async function documentCreateGridFsBucket(connectionId: string, database: string, bucket: string): Promise<void> {

View File

@ -1626,12 +1626,12 @@ export async function documentFindDocuments(connectionId: string, database: stri
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string): Promise<MongoGridFsFileInfo[]> {
return invoke("document_list_gridfs_files", { connectionId, database, bucket });
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string, filter?: string, sort?: string): Promise<MongoGridFsFileInfo[]> {
return invoke("document_list_gridfs_files", { connectionId, database, bucket, filter, sort });
}
export async function documentListGridFsBuckets(connectionId: string, database: string): Promise<MongoGridFsBucketInfo[]> {
return invoke("document_list_gridfs_buckets", { connectionId, database });
export async function documentListGridFsBuckets(connectionId: string, database: string, filter?: string, sort?: string): Promise<MongoGridFsBucketInfo[]> {
return invoke("document_list_gridfs_buckets", { connectionId, database, filter, sort });
}
export async function documentCreateGridFsBucket(connectionId: string, database: string, bucket: string): Promise<void> {

View File

@ -0,0 +1,139 @@
import { buildDocumentFilterCondition, combineDocumentFilterConditions, defaultDocumentFilterRule, type DocumentFilterRule } from "@/lib/app/documentStoreProvider";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
export type GridFsBucketSortField = "name" | "fileCount" | "totalBytes";
export type GridFsBucketSortDirection = "asc" | "desc";
export type GridFsSortDirectionState = "none" | "asc" | "desc";
export type GridFsFileField = "_id" | "filename" | "contentType" | "length" | "chunkSize" | "uploadDate" | "md5";
export type GridFsFileFieldDisplayOption = { fieldName: GridFsFileField; label: string; labelKey?: never } | { fieldName: GridFsFileField; labelKey: string; label?: never };
export type GridFsBucketSort = {
field: GridFsBucketSortField;
direction: GridFsBucketSortDirection;
};
export const gridFsFileFilterFieldOptions = ["_id", "filename", "contentType", "length", "chunkSize", "uploadDate", "md5"] as const satisfies readonly GridFsFileField[];
export const gridFsFileFieldDisplayOptions = [
{ fieldName: "_id", label: "ID" },
{ fieldName: "filename", labelKey: "gridfsBrowser.name" },
{ fieldName: "contentType", labelKey: "gridfsBrowser.contentType" },
{ fieldName: "length", labelKey: "gridfsBrowser.totalSize" },
{ fieldName: "chunkSize", labelKey: "gridfsBrowser.chunkSize" },
{ fieldName: "uploadDate", labelKey: "gridfsBrowser.uploadDate" },
{ fieldName: "md5", label: "MD5" },
] as const satisfies readonly GridFsFileFieldDisplayOption[];
export function gridFsFilesQueryPreview(options: { bucket: string; filterJson?: string; sortJson?: string }): string {
const parts = [`db.getCollection(${JSON.stringify(`${options.bucket}.files`)}).find(${mongoShellPreviewLiteral(options.filterJson || "{}")})`];
if (options.sortJson?.trim()) parts.push(`.sort(${mongoShellPreviewLiteral(options.sortJson)})`);
return parts.join("");
}
export function currentGridFsBucketFilter(input: string): string | undefined {
const trimmed = input.trim();
return trimmed || undefined;
}
export function createGridFsFileFilterRule(id: string): DocumentFilterRule {
return defaultDocumentFilterRule(id, gridFsFileFilterFieldOptions[0] ?? "");
}
export function gridFsFileFieldDisplayOption(fieldName: string): GridFsFileFieldDisplayOption | null {
return gridFsFileFieldDisplayOptions.find((option) => option.fieldName === fieldName) ?? null;
}
export function buildGridFsFilesStructuredFilter(rules: DocumentFilterRule[]): Record<string, unknown> | null {
const items = rules
.map((rule) => ({
rule,
condition: buildDocumentFilterCondition(rule, { kind: "mongodb" }),
}))
.filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
return combineDocumentFilterConditions(
items.map((item) => item.condition),
items.map((item) => item.rule),
);
}
export function parseGridFsBucketSort(input?: string): GridFsBucketSort | null {
const trimmed = input?.trim();
if (!trimmed) return null;
const parsed = JSON.parse(quoteUnquotedObjectKeys(trimmed)) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("GridFS bucket sort must be a JSON object");
}
const entries = Object.entries(parsed);
if (entries.length !== 1) {
throw new Error("GridFS bucket sort must contain exactly one field");
}
const [field, rawDirection] = entries[0]!;
if (field !== "name" && field !== "fileCount" && field !== "totalBytes") {
throw new Error("Unsupported GridFS bucket sort field");
}
const direction = normalizeGridFsBucketSortDirection(rawDirection);
if (!direction) {
throw new Error("GridFS bucket sort direction must be 1, -1, 'asc', or 'desc'");
}
return { field, direction };
}
export function currentGridFsBucketSort(input: string): string | undefined {
const sort = parseGridFsBucketSort(input);
if (!sort) return undefined;
return JSON.stringify({ [sort.field]: sort.direction === "desc" ? -1 : 1 });
}
export function currentGridFsFileSortDirection(input: string, column: string): GridFsSortDirectionState {
const sort = parseSingleFieldSort(input);
if (!sort || sort.field !== column) return "none";
return sort.direction;
}
export function currentGridFsBucketSortDirection(input: string, column: GridFsBucketSortField): GridFsSortDirectionState {
try {
const sort = parseGridFsBucketSort(input);
if (!sort || sort.field !== column) return "none";
return sort.direction;
} catch {
return "none";
}
}
export function gridFsBucketSortInputForColumn(column: string, direction: GridFsBucketSortDirection | null): string {
if (!direction) return "";
if (column !== "name" && column !== "fileCount" && column !== "totalBytes") return "";
return JSON.stringify({ [column]: direction === "desc" ? -1 : 1 });
}
function normalizeGridFsBucketSortDirection(value: unknown): GridFsBucketSortDirection | null {
if (value === -1 || value === "-1" || value === "desc") return "desc";
if (value === 1 || value === "1" || value === "asc") return "asc";
return null;
}
function parseSingleFieldSort(input?: string): { field: string; direction: GridFsBucketSortDirection } | null {
const trimmed = input?.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(quoteUnquotedObjectKeys(trimmed)) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const entries = Object.entries(parsed);
if (entries.length !== 1) return null;
const [field, rawDirection] = entries[0]!;
const direction = normalizeGridFsBucketSortDirection(rawDirection);
if (!direction) return null;
return { field, direction };
} catch {
return null;
}
}
function mongoShellPreviewLiteral(json: string): string {
const trimmed = json.trim();
if (!trimmed) return "{}";
try {
return formatMongoShellLiteral(JSON.parse(quoteUnquotedObjectKeys(trimmed)));
} catch {
return trimmed;
}
}

View File

@ -0,0 +1,104 @@
import type { Component } from "vue";
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Key, Link, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import type { ColumnInfo, TreeNode } from "@/types/database";
export type TreeNodeIconInfo = {
icon: Component;
colorClass: string;
};
export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
switch (node.type) {
case "schema":
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-amber-500" };
case "connection":
case "database":
case "mongo-db":
return { icon: Database, colorClass: "text-yellow-500" };
case "linked-server-root":
return { icon: Network, colorClass: "text-blue-500" };
case "linked-server":
return { icon: Server, colorClass: "text-blue-400" };
case "linked-server-catalog":
case "linked-server-schema":
case "mq-tenant":
return { icon: FolderOpen, colorClass: "text-sky-400" };
case "nacos-namespace":
case "etcd-root":
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "zookeeper-root":
return { icon: Database, colorClass: "text-blue-500" };
case "table":
return { icon: Table, colorClass: "text-green-500" };
case "view":
return { icon: Eye, colorClass: "text-purple-500" };
case "materialized_view":
return { icon: Eye, colorClass: "text-indigo-500" };
case "column":
return { icon: Columns3, colorClass: (node.meta as ColumnInfo | undefined)?.is_primary_key ? "text-orange-400" : "text-muted-foreground" };
case "group-columns":
return { icon: ListTree, colorClass: "text-green-400" };
case "group-indexes":
case "index":
return { icon: Key, colorClass: "text-amber-500" };
case "group-fkeys":
return { icon: Link, colorClass: "text-blue-400" };
case "fkey":
return { icon: Link, colorClass: "text-blue-300" };
case "group-triggers":
return { icon: Zap, colorClass: "text-orange-400" };
case "trigger":
return { icon: Zap, colorClass: "text-orange-300" };
case "object-browser":
return { icon: TableProperties, colorClass: "text-primary" };
case "user-admin":
return { icon: UsersRound, colorClass: "text-primary" };
case "redis-db":
return { icon: Database, colorClass: "text-red-400" };
case "mongo-gridfs":
case "mongo-buckets":
return { icon: Archive, colorClass: "text-cyan-500" };
case "mongo-bucket":
return { icon: Archive, colorClass: "text-cyan-400" };
case "mongo-collection":
return { icon: Table, colorClass: "text-green-400" };
case "vector-collection":
return { icon: TableProperties, colorClass: "text-cyan-400" };
case "elasticsearch-index":
return { icon: Table, colorClass: "text-emerald-400" };
case "procedure":
return { icon: ScrollText, colorClass: "text-blue-500" };
case "function":
return { icon: Braces, colorClass: "text-amber-500" };
case "sequence":
return { icon: ListTree, colorClass: "text-emerald-500" };
case "package":
return { icon: Package, colorClass: "text-cyan-500" };
case "package-body":
return { icon: FileCode, colorClass: "text-cyan-400" };
case "group-tables":
return { icon: Table, colorClass: "text-green-500" };
case "group-views":
return { icon: Eye, colorClass: "text-purple-500" };
case "group-materialized-views":
return { icon: Eye, colorClass: "text-indigo-500" };
case "group-procedures":
return { icon: ScrollText, colorClass: "text-blue-500" };
case "group-functions":
return { icon: Braces, colorClass: "text-amber-500" };
case "group-sequences":
return { icon: ListTree, colorClass: "text-emerald-500" };
case "group-packages":
return { icon: Package, colorClass: "text-cyan-500" };
case "group-partitions":
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" };
case "group-extensions":
return { icon: Package, colorClass: "text-violet-500" };
case "extension":
return { icon: Package, colorClass: "text-violet-400" };
case "load-more":
return { icon: Plus, colorClass: "text-primary" };
default:
return { icon: Database, colorClass: "text-muted-foreground" };
}
}

View File

@ -207,12 +207,15 @@ pub async fn list_gridfs_files(
client: &Client,
database: &str,
bucket: &str,
filter: Option<&str>,
sort: Option<&str>,
) -> Result<Vec<MongoGridFsFileInfo>, String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let collection_name = format!("{bucket}.files");
let collection = client.database(database).collection::<Document>(&collection_name);
let mut cursor =
collection.find(doc! {}).sort(doc! { "uploadDate": -1_i32, "_id": -1_i32 }).await.map_err(|e| e.to_string())?;
let filter_doc = gridfs_file_filter_document(filter)?;
let sort_doc = gridfs_file_sort_document(sort)?;
let mut cursor = collection.find(filter_doc).sort(sort_doc).await.map_err(|e| e.to_string())?;
let mut files = Vec::new();
while cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
@ -221,6 +224,26 @@ pub async fn list_gridfs_files(
Ok(files)
}
fn gridfs_file_filter_document(filter: Option<&str>) -> Result<Document, String> {
match filter {
Some(raw) if !raw.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json)
}
_ => Ok(doc! {}),
}
}
fn gridfs_file_sort_document(sort: Option<&str>) -> Result<Document, String> {
match sort {
Some(raw) if !raw.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid sort JSON: {e}"))?;
json_object_to_document(&json).map_err(|e| format!("Invalid sort: {e}"))
}
_ => Ok(doc! { "uploadDate": -1_i32, "_id": -1_i32 }),
}
}
pub async fn gridfs_bucket_summary(
client: &Client,
database: &str,
@ -1583,6 +1606,16 @@ mod tests {
assert_eq!(info.aliases, Some(vec!["archive".to_string(), "nightly".to_string()]));
}
#[test]
fn gridfs_file_sort_uses_upload_date_desc_by_default() {
assert_eq!(gridfs_file_sort_document(None).unwrap(), doc! { "uploadDate": -1_i32, "_id": -1_i32 });
}
#[test]
fn gridfs_file_sort_parses_explicit_sort_json() {
assert_eq!(gridfs_file_sort_document(Some(r#"{"filename":1}"#)).unwrap(), doc! { "filename": 1_i64 });
}
#[test]
fn json_object_to_document_parses_find_projection() {
let value = serde_json::json!({

View File

@ -122,6 +122,78 @@ fn mongo_bucket_infos(names: &[String]) -> Vec<CollectionInfo> {
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GridFsBucketSortField {
Name,
FileCount,
TotalBytes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GridFsBucketSort {
field: GridFsBucketSortField,
descending: bool,
}
fn parse_gridfs_bucket_sort(sort: Option<&str>) -> Result<GridFsBucketSort, String> {
let Some(raw) = sort.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(GridFsBucketSort { field: GridFsBucketSortField::Name, descending: false });
};
let value: serde_json::Value =
serde_json::from_str(raw).map_err(|e| format!("Invalid GridFS bucket sort JSON: {e}"))?;
let object = value.as_object().ok_or_else(|| "GridFS bucket sort must be a JSON object".to_string())?;
if object.len() != 1 {
return Err("GridFS bucket sort must contain exactly one field".to_string());
}
let (field_name, direction) = object.iter().next().expect("checked len");
let field = match field_name.as_str() {
"name" => GridFsBucketSortField::Name,
"fileCount" => GridFsBucketSortField::FileCount,
"totalBytes" => GridFsBucketSortField::TotalBytes,
_ => return Err(format!("Unsupported GridFS bucket sort field: {field_name}")),
};
let descending = match direction {
serde_json::Value::Number(value) if value.as_i64() == Some(-1) => true,
serde_json::Value::Number(value) if value.as_i64() == Some(1) => false,
serde_json::Value::String(value) if value.eq_ignore_ascii_case("desc") || value == "-1" => true,
serde_json::Value::String(value) if value.eq_ignore_ascii_case("asc") || value == "1" => false,
_ => return Err("GridFS bucket sort direction must be 1, -1, 'asc', or 'desc'".to_string()),
};
Ok(GridFsBucketSort { field, descending })
}
fn filter_and_sort_gridfs_bucket_infos(
mut buckets: Vec<MongoGridFsBucketInfo>,
filter: Option<&str>,
sort: Option<&str>,
) -> Result<Vec<MongoGridFsBucketInfo>, String> {
if let Some(filter_text) = filter.map(str::trim).filter(|value| !value.is_empty()) {
let needle = filter_text.to_lowercase();
buckets.retain(|bucket| bucket.name.to_lowercase().contains(&needle));
}
let sort = parse_gridfs_bucket_sort(sort)?;
buckets.sort_by(|left, right| {
let name_cmp =
left.name.to_lowercase().cmp(&right.name.to_lowercase()).then_with(|| left.name.cmp(&right.name));
let ordering = match sort.field {
GridFsBucketSortField::Name => name_cmp,
GridFsBucketSortField::FileCount => left.file_count.cmp(&right.file_count).then_with(|| name_cmp),
GridFsBucketSortField::TotalBytes => left.total_bytes.cmp(&right.total_bytes).then_with(|| name_cmp),
};
if sort.descending {
ordering.reverse()
} else {
ordering
}
});
Ok(buckets)
}
pub async fn list_collections_core(
state: &AppState,
connection_id: &str,
@ -160,11 +232,13 @@ pub async fn list_gridfs_files_core(
connection_id: &str,
database: &str,
bucket: &str,
filter: Option<&str>,
sort: Option<&str>,
) -> Result<Vec<MongoGridFsFileInfo>, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::list_gridfs_files(client, database, bucket).await,
PoolKind::MongoDb(client) => mongo_driver::list_gridfs_files(client, database, bucket, filter, sort).await,
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support GridFS file browsing".to_string()),
_ => Err("Not a MongoDB connection".to_string()),
}
@ -174,6 +248,8 @@ pub async fn list_gridfs_buckets_core(
state: &AppState,
connection_id: &str,
database: &str,
filter: Option<&str>,
sort: Option<&str>,
) -> Result<Vec<MongoGridFsBucketInfo>, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
@ -185,7 +261,7 @@ pub async fn list_gridfs_buckets_core(
for bucket_name in bucket_names {
buckets.push(mongo_driver::gridfs_bucket_summary(client, database, &bucket_name).await?);
}
Ok(buckets)
filter_and_sort_gridfs_bucket_infos(buckets, filter, sort)
}
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support GridFS bucket browsing".to_string()),
_ => Err("Not a MongoDB connection".to_string()),
@ -420,7 +496,10 @@ pub async fn delete_document_core(
#[cfg(test)]
mod tests {
use super::{fallback_mongo_database, mongo_gridfs_bucket_names, mongo_list_databases_unauthorized, sort_names};
use super::{
fallback_mongo_database, filter_and_sort_gridfs_bucket_infos, mongo_gridfs_bucket_names,
mongo_list_databases_unauthorized, parse_gridfs_bucket_sort, sort_names, MongoGridFsBucketInfo,
};
#[test]
fn sorts_names_case_insensitively() {
@ -464,4 +543,49 @@ mod tests {
assert_eq!(buckets, vec!["orders".to_string(), "reports".to_string()]);
}
#[test]
fn filters_gridfs_buckets_by_case_insensitive_name_match() {
let buckets = filter_and_sort_gridfs_bucket_infos(
vec![
MongoGridFsBucketInfo { name: "images".to_string(), file_count: 4, total_bytes: 512 },
MongoGridFsBucketInfo { name: "nightly-reports".to_string(), file_count: 9, total_bytes: 4096 },
MongoGridFsBucketInfo { name: "videos".to_string(), file_count: 2, total_bytes: 8192 },
],
Some("REPORT"),
None,
)
.unwrap();
assert_eq!(
buckets.into_iter().map(|bucket| bucket.name).collect::<Vec<_>>(),
vec!["nightly-reports".to_string()]
);
}
#[test]
fn sorts_gridfs_buckets_by_total_bytes_descending() {
let buckets = filter_and_sort_gridfs_bucket_infos(
vec![
MongoGridFsBucketInfo { name: "images".to_string(), file_count: 4, total_bytes: 512 },
MongoGridFsBucketInfo { name: "nightly-reports".to_string(), file_count: 9, total_bytes: 4096 },
MongoGridFsBucketInfo { name: "videos".to_string(), file_count: 2, total_bytes: 8192 },
],
None,
Some(r#"{"totalBytes":-1}"#),
)
.unwrap();
assert_eq!(
buckets.into_iter().map(|bucket| bucket.name).collect::<Vec<_>>(),
vec!["videos".to_string(), "nightly-reports".to_string(), "images".to_string()]
);
}
#[test]
fn gridfs_bucket_sort_rejects_unknown_fields() {
let error = parse_gridfs_bucket_sort(Some(r#"{"createdAt":-1}"#)).unwrap_err();
assert!(error.contains("Unsupported GridFS bucket sort field"));
}
}

View File

@ -107,6 +107,25 @@ pub struct GridFsBucketRequest {
pub bucket: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridFsFileListRequest {
pub connection_id: String,
pub database: String,
pub bucket: String,
pub filter: Option<String>,
pub sort: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridFsBucketListRequest {
pub connection_id: String,
pub database: String,
pub filter: Option<String>,
pub sort: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridFsDownloadRequest {
@ -222,22 +241,34 @@ pub async fn delete_document(
pub async fn list_gridfs_files(
State(state): State<Arc<WebState>>,
Json(req): Json<GridFsBucketRequest>,
Json(req): Json<GridFsFileListRequest>,
) -> Result<Json<Vec<dbx_core::document_ops::MongoGridFsFileInfo>>, AppError> {
let result =
dbx_core::document_ops::list_gridfs_files_core(&state.app, &req.connection_id, &req.database, &req.bucket)
.await
.map_err(AppError)?;
let result = dbx_core::document_ops::list_gridfs_files_core(
&state.app,
&req.connection_id,
&req.database,
&req.bucket,
req.filter.as_deref(),
req.sort.as_deref(),
)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn list_gridfs_buckets(
State(state): State<Arc<WebState>>,
Json(req): Json<DocumentListCollectionsRequest>,
Json(req): Json<GridFsBucketListRequest>,
) -> Result<Json<Vec<dbx_core::document_ops::MongoGridFsBucketInfo>>, AppError> {
let result = dbx_core::document_ops::list_gridfs_buckets_core(&state.app, &req.connection_id, &req.database)
.await
.map_err(AppError)?;
let result = dbx_core::document_ops::list_gridfs_buckets_core(
&state.app,
&req.connection_id,
&req.database,
req.filter.as_deref(),
req.sort.as_deref(),
)
.await
.map_err(AppError)?;
Ok(Json(result))
}

View File

@ -0,0 +1,247 @@
# GridFS Manager Filter And Sort Design
**Date:** 2026-07-06
## Goal
Extend the existing GridFS manager and bucket browser so MongoDB users can filter and sort GridFS data without leaving the dedicated GridFS workflow.
This work covers three user-facing improvements:
- add query-style filter and sort controls to the GridFS file list
- add filter and sort controls to the GridFS bucket manager
- make the GridFS sidebar node more visually distinct from regular Mongo collections
## Background
The dedicated GridFS browser shipped on 2026-07-03 and already provides:
- a single `GridFS` sidebar entry per Mongo database
- a database-level manager page for bucket CRUD
- a bucket-level file browser for upload, download, and delete
Issue #2589 reports that the new Mongo manager flow still lacks filtering. The current UI shows static tables only:
- `MongoGridFsBrowser.vue` lists bucket summaries with no filter or sort controls
- `MongoBucketBrowser.vue` lists bucket files with a fixed default ordering
- the backend `list_gridfs_files` API always runs an empty `find({})` with a built-in descending upload date sort
The goal of this iteration is to keep the dedicated GridFS UX, but make it feel closer to the existing Mongo document query experience.
## Current State
### File browser
`MongoBucketBrowser.vue` currently:
- loads every file in the selected bucket
- shows a table with metadata columns
- supports upload, download, delete, refresh
- has no filter input
- has no user-controlled sort
- has no query preview
### Bucket manager
`MongoGridFsBrowser.vue` currently:
- loads every GridFS bucket summary for the database
- shows bucket name, file count, and total size
- supports create, delete, open, refresh
- has no filter input
- has no user-controlled sort
### Sidebar
`TreeItem.vue` currently renders:
- `mongo-gridfs` and `mongo-buckets` with `Archive` plus amber styling
- `mongo-bucket` with a very similar `Archive` plus lighter amber styling
This makes the GridFS entry readable, but not especially distinct from other MongoDB tree nodes.
## Requirements
### Functional
- users can filter GridFS files within a bucket
- users can sort GridFS files within a bucket
- users can filter GridFS buckets within a database
- users can sort GridFS buckets within a database
- file-list sorting works both from explicit sort input and column-header sort actions
- the GridFS sidebar entry has a clearer dedicated visual treatment
### Behavioral
- existing upload, download, delete, create, and open flows keep working
- existing callers that omit new query parameters retain current behavior
- invalid filter or sort input produces visible errors rather than silent fallback
## Chosen Approach
### Query model
Use one consistent principle across both GridFS pages:
- execute filter and sort on the server
- keep frontend controls visually aligned with the document query UI
- only reuse document-query semantics where the underlying data shape supports it cleanly
### Bucket file browser
The file browser should closely mirror Mongo document query behavior.
Frontend:
- add `filter` and `sort` text inputs to `MongoBucketBrowser.vue`
- show a query preview string for the effective GridFS file query
- trigger reload on Enter, refresh click, and table-header sort changes
- map header sort actions back into the `sort` input, matching `DocumentBrowser.vue`
Backend:
- extend the GridFS file list API to accept optional `filter` and `sort` strings
- parse them with the same Mongo document parsing helpers already used by document queries
- run the query against `<bucket>.files` as `find(filter).sort(sort)`
- preserve the existing default sort of `uploadDate desc, _id desc` when no sort is provided
Preview format:
- use `db.getCollection("<bucket>.files").find(...).sort(...).skip(0).limit(...)` style output
- the preview exists for transparency and consistency, even if the file browser continues returning a plain row list instead of a paged document result object
### Bucket manager
The bucket manager should adopt the same interaction style, but not pretend bucket summaries are raw Mongo documents.
Frontend:
- add a lightweight `filter` input for bucket-name matching
- add a sort control that behaves like the existing query toolbar and table-header sorting
- keep the manager table focused on `name`, `fileCount`, and `totalBytes`
Backend:
- extend the bucket list API to accept optional filter and sort parameters
- filter bucket summaries by normalized bucket name match
- sort bucket summaries by one of:
- `name`
- `fileCount`
- `totalBytes`
- default to `name asc` when no explicit sort is provided
This gives the manager page service-side filtering and sorting while staying honest about the underlying data model, which is aggregated summary data rather than a directly queried collection.
### Sidebar icon treatment
Keep the existing icon family but make the top-level GridFS entry visually stand apart.
- `mongo-gridfs`: keep `Archive` but switch to a more distinctive cool color so it no longer blends with ordinary Mongo tree items
- `mongo-bucket`: keep the same icon family with a related but softer shade
The goal is recognition, not a broad tree redesign.
## Architecture
### Backend layers
Update the GridFS query path across all transport layers:
- `crates/dbx-core/src/db/mongo_driver.rs`
- `crates/dbx-core/src/document_ops.rs`
- `src-tauri/src/commands/document_cmd.rs`
- `crates/dbx-web/src/routes/document_store.rs`
- `apps/desktop/src/lib/backend/tauri.ts`
- `apps/desktop/src/lib/backend/http.ts`
The backend remains backward compatible by treating all new parameters as optional.
### Frontend layers
Update the dedicated GridFS views:
- `apps/desktop/src/components/document/MongoBucketBrowser.vue`
- `apps/desktop/src/components/document/MongoGridFsBrowser.vue`
Reuse document-query support where it fits:
- `apps/desktop/src/lib/app/documentStoreProvider.ts`
Update the sidebar presentation in:
- `apps/desktop/src/components/sidebar/TreeItem.vue`
## Data Flow
### File browser
1. User opens a GridFS bucket tab.
2. `MongoBucketBrowser.vue` builds the current `filter` and `sort` state.
3. Frontend sends the new parameters through the shared document-store API.
4. Rust applies the filter and sort to `<bucket>.files`.
5. The returned file list renders in the table and side detail panel.
6. If the user clicks a sortable header, the UI rewrites the `sort` input and reloads.
### Bucket manager
1. User opens the `GridFS` manager tab.
2. `MongoGridFsBrowser.vue` sends current manager filter and sort state.
3. Rust collects bucket summaries, filters them, sorts them, and returns the list.
4. The manager table and summary pane update in place.
## Error Handling
- invalid Mongo-style file filter input should surface as a visible page error
- invalid file sort input should surface as a visible page error
- unsupported bucket-manager sort fields should return a clear backend error
- empty filter input should behave like no filter
- missing sort input should fall back to the page default sort
- write operations continue to use the existing read-only safeguards
## Testing
### Frontend
Add or update Vitest coverage for:
- GridFS query preview formatting in `documentStoreProvider`
- request assembly for GridFS file filter and sort loading
- request assembly for GridFS bucket filter and sort loading
- sidebar icon and color mapping for `mongo-gridfs` and `mongo-bucket`
### Backend
Add Rust coverage for:
- GridFS file filter parsing
- default file sort fallback
- explicit file sort handling
- bucket summary filtering
- bucket summary sorting by supported fields
## Scope Boundaries
### Included now
- service-side GridFS file filtering
- service-side GridFS file sorting
- service-side GridFS bucket filtering
- service-side GridFS bucket sorting
- query-style toolbar inputs for the file browser
- visually improved GridFS sidebar node styling
### Not included now
- pagination contract changes for GridFS APIs
- turning bucket summaries into full Mongo document queries
- advanced bucket filter builders
- drag-and-drop upload improvements
- inline file editing or file preview
## Success Criteria
- users can narrow GridFS files with Mongo-style filter input
- users can sort GridFS files from either toolbar input or table headers
- users can narrow and sort GridFS bucket summaries from the manager page
- the dedicated GridFS sidebar node is easier to distinguish at a glance
- existing GridFS CRUD flows keep working without regression

View File

@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import * as gridFsBrowserModule from "../../apps/desktop/src/lib/document/gridFsBrowser.ts";
import {
buildGridFsFilesStructuredFilter,
currentGridFsBucketFilter,
currentGridFsBucketSort,
currentGridFsBucketSortDirection,
currentGridFsFileSortDirection,
gridFsFileFilterFieldOptions,
gridFsBucketSortInputForColumn,
gridFsFilesQueryPreview,
parseGridFsBucketSort,
} from "../../apps/desktop/src/lib/document/gridFsBrowser.ts";
test("builds a Mongo-style GridFS files query preview", () => {
assert.equal(
gridFsFilesQueryPreview({
bucket: "fs",
filterJson: '{"metadata.owner":"alice"}',
sortJson: '{"uploadDate":-1}',
}),
'db.getCollection("fs.files").find({"metadata.owner":"alice"}).sort({"uploadDate":-1})',
);
});
test("omits the sort stage when the GridFS file preview has no explicit sort", () => {
assert.equal(
gridFsFilesQueryPreview({
bucket: "fs",
filterJson: '{"filename":"report.zip"}',
}),
'db.getCollection("fs.files").find({"filename":"report.zip"})',
);
});
test("normalizes GridFS bucket filters by trimming blank input", () => {
assert.equal(currentGridFsBucketFilter(" nightly "), "nightly");
assert.equal(currentGridFsBucketFilter(" "), undefined);
});
test("normalizes GridFS manager sort directions for supported fields", () => {
assert.deepEqual(parseGridFsBucketSort('{"totalBytes":-1}'), { field: "totalBytes", direction: "desc" });
assert.deepEqual(parseGridFsBucketSort("{ fileCount: 1 }"), { field: "fileCount", direction: "asc" });
});
test("serializes GridFS manager sort input for supported columns", () => {
assert.equal(gridFsBucketSortInputForColumn("name", "asc"), '{"name":1}');
assert.equal(gridFsBucketSortInputForColumn("totalBytes", "desc"), '{"totalBytes":-1}');
assert.equal(gridFsBucketSortInputForColumn("fileCount", null), "");
});
test("rejects unsupported GridFS bucket sort fields", () => {
assert.throws(() => parseGridFsBucketSort('{"createdAt":-1}'), /Unsupported GridFS bucket sort field/);
});
test("returns canonical GridFS bucket sort JSON", () => {
assert.equal(currentGridFsBucketSort("{ totalBytes: -1 }"), '{"totalBytes":-1}');
assert.equal(currentGridFsBucketSort(" "), undefined);
});
test("exposes the available GridFS file filter fields for the compact builder", () => {
assert.deepEqual(gridFsFileFilterFieldOptions, ["_id", "filename", "contentType", "length", "chunkSize", "uploadDate", "md5"]);
});
test("exposes shared GridFS file field display metadata for filters and headers", () => {
assert.deepEqual((gridFsBrowserModule as any).gridFsFileFieldDisplayOptions, [
{ fieldName: "_id", label: "ID" },
{ fieldName: "filename", labelKey: "gridfsBrowser.name" },
{ fieldName: "contentType", labelKey: "gridfsBrowser.contentType" },
{ fieldName: "length", labelKey: "gridfsBrowser.totalSize" },
{ fieldName: "chunkSize", labelKey: "gridfsBrowser.chunkSize" },
{ fieldName: "uploadDate", labelKey: "gridfsBrowser.uploadDate" },
{ fieldName: "md5", label: "MD5" },
]);
});
test("combines GridFS file filter rules into a MongoDB structured filter", () => {
assert.deepEqual(
buildGridFsFilesStructuredFilter([
{
id: "rule-1",
fieldName: "filename",
mode: "equals",
rawValue: "report.zip",
conjunction: "AND",
},
{
id: "rule-2",
fieldName: "contentType",
mode: "like",
rawValue: "image",
conjunction: "AND",
},
]),
{
$and: [
{ filename: "report.zip" },
{ contentType: { $regex: "image", $options: "i" } },
],
},
);
});
test("derives explicit GridFS file sort directions for visible header indicators", () => {
assert.equal(currentGridFsFileSortDirection('{"filename":1}', "filename"), "asc");
assert.equal(currentGridFsFileSortDirection('{"filename":-1}', "filename"), "desc");
assert.equal(currentGridFsFileSortDirection('{"filename":1}', "uploadDate"), "none");
assert.equal(currentGridFsFileSortDirection('{"filename":1,"uploadDate":-1}', "filename"), "none");
});
test("derives explicit GridFS bucket sort directions for visible header indicators", () => {
assert.equal(currentGridFsBucketSortDirection('{"name":1}', "name"), "asc");
assert.equal(currentGridFsBucketSortDirection('{"totalBytes":-1}', "totalBytes"), "desc");
assert.equal(currentGridFsBucketSortDirection('{"name":1}', "fileCount"), "none");
});

View File

@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { getTreeNodeIconInfo } from "../../apps/desktop/src/lib/sidebar/treeNodeIcon.ts";
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
test("GridFS sidebar nodes use a dedicated cool-color icon treatment", () => {
const gridfs = getTreeNodeIconInfo({ type: "mongo-gridfs" } as TreeNode);
const bucket = getTreeNodeIconInfo({ type: "mongo-bucket" } as TreeNode);
assert.equal(gridfs?.colorClass, "text-cyan-500");
assert.equal(bucket?.colorClass, "text-cyan-400");
});
test("GridFS sidebar icon mapping stays distinct from Mongo collections", () => {
const gridfs = getTreeNodeIconInfo({ type: "mongo-gridfs" } as TreeNode);
const collection = getTreeNodeIconInfo({ type: "mongo-collection" } as TreeNode);
assert.notEqual(gridfs?.colorClass, collection?.colorClass);
});

View File

@ -135,8 +135,17 @@ pub async fn document_list_gridfs_buckets(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
filter: Option<String>,
sort: Option<String>,
) -> Result<Vec<dbx_core::document_ops::MongoGridFsBucketInfo>, String> {
dbx_core::document_ops::list_gridfs_buckets_core(&state, &connection_id, &database).await
dbx_core::document_ops::list_gridfs_buckets_core(
&state,
&connection_id,
&database,
filter.as_deref(),
sort.as_deref(),
)
.await
}
#[tauri::command]
@ -167,8 +176,18 @@ pub async fn document_list_gridfs_files(
connection_id: String,
database: String,
bucket: String,
filter: Option<String>,
sort: Option<String>,
) -> Result<Vec<dbx_core::document_ops::MongoGridFsFileInfo>, String> {
dbx_core::document_ops::list_gridfs_files_core(&state, &connection_id, &database, &bucket).await
dbx_core::document_ops::list_gridfs_files_core(
&state,
&connection_id,
&database,
&bucket,
filter.as_deref(),
sort.as_deref(),
)
.await
}
#[tauri::command]