feat(mongo): add GridFS manager for MongoDB

* feat(mongo): browse GridFS buckets and files

Refs #2342

* feat(mongo): add GridFS manager for MongoDB

* chore(i18n): autofill new translations

---------

Co-authored-by: dbx-i18n-bot <github-actions[bot]@users.noreply.github.com>
Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
authored and GitHub committed 2026-07-03 18:34:00 +08:00
1 parent a8abb1c478
commit f826cb5d38
35 files changed
+1959 -39

No files matched your search

@@ -0,0 +1,271 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Download, RefreshCcw, Trash2, Upload } from "@lucide/vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { Button } from "@/components/ui/button";
import { useToast } from "@/composables/useToast";
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/binaryCellDownload";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import * as api from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
const props = defineProps<{
connectionId: string;
database: string;
bucket: string;
}>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const loading = ref(false);
const uploading = ref(false);
const deleting = ref(false);
const error = ref("");
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
const selectedFileId = ref("");
const showDeleteConfirm = ref(false);
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);
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function displayName(file: (typeof files.value)[number]): string {
return file.filename || file.id;
}
async function loadFiles() {
loading.value = true;
error.value = "";
try {
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket);
files.value = nextFiles;
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
selectedFileId.value = "";
}
} catch (e: any) {
error.value = e?.message || String(e);
} finally {
loading.value = false;
}
}
async function downloadFile(file: (typeof files.value)[number]) {
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));
if (result.kind === "saved") {
toast(t("grid.exported"), 2500);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
}
async function pickUploadFile(): Promise<{ name: string; data: Uint8Array; contentType?: string } | null> {
if (isTauriRuntime()) {
const [{ open }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const selected = await open({ multiple: false });
const path = Array.isArray(selected) ? selected[0] : selected;
if (!path || typeof path !== "string") return null;
const data = await fs.readFile(path);
const name = path.split(/[\\/]/).pop() || "file";
return { name, data };
}
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.onchange = async () => {
try {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
const data = new Uint8Array(await file.arrayBuffer());
resolve({ name: file.name, data, contentType: file.type || undefined });
} catch (error) {
reject(error);
}
};
input.click();
});
}
async function uploadFile() {
if (uploading.value) return;
try {
const selected = await pickUploadFile();
if (!selected) return;
uploading.value = true;
const fileId = await api.documentUploadGridFsFile(props.connectionId, props.database, props.bucket, selected.name, selected.data, selected.contentType);
await loadFiles();
selectedFileId.value = fileId;
toast(t("gridfsBrowser.fileUploaded", { fileName: selected.name }), 2500);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
uploading.value = false;
}
}
async function deleteSelectedFile() {
const file = selectedFile.value;
if (!file || deleting.value) return;
deleting.value = true;
try {
await api.documentDeleteGridFsFile(props.connectionId, props.database, props.bucket, file.id);
showDeleteConfirm.value = false;
selectedFileId.value = "";
await loadFiles();
toast(t("gridfsBrowser.fileDeleted", { fileName: displayName(file) }), 2500);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
deleting.value = false;
}
}
onMounted(() => {
void loadFiles();
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<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>
<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>
<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" />
{{ t("gridfsBrowser.deleteFile") }}
</Button>
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="loading" @click="loadFiles">
<RefreshCcw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
{{ t("grid.refresh") }}
</Button>
</div>
</div>
</div>
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
{{ error }}
</div>
<div v-else-if="loading && files.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{{ t("executionSummary.executing") }}
</div>
<div v-else-if="files.length === 0" class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">
{{ 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 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">
<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>
</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>
</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>
</tr>
</tbody>
</table>
</div>
<aside class="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>
<div class="mt-1 break-all text-xs text-muted-foreground">{{ selectedFile.id }}</div>
<div class="mt-5 space-y-4 text-sm">
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.totalSize") }}</div>
<div class="mt-1 font-medium">{{ formatBytes(selectedFile.length) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.chunkSize") }}</div>
<div class="mt-1 font-medium">{{ formatBytes(selectedFile.chunkSize || 0) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.uploadDate") }}</div>
<div class="mt-1 font-medium">{{ selectedFile.uploadDate || "-" }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.contentType") }}</div>
<div class="mt-1 break-all font-medium">{{ selectedFile.contentType || "-" }}</div>
</div>
<div v-if="selectedFile.md5">
<div class="text-xs text-muted-foreground">MD5</div>
<div class="mt-1 break-all font-medium">{{ selectedFile.md5 }}</div>
</div>
<div v-if="selectedMetadata">
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.metadata") }}</div>
<pre class="mt-1 max-h-44 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap">{{ selectedMetadata }}</pre>
</div>
</div>
</template>
<div v-else class="text-sm text-muted-foreground">
{{ t("gridfsBrowser.selectFile") }}
</div>
</aside>
</div>
<DangerConfirmDialog
v-model:open="showDeleteConfirm"
:loading="deleting"
:title="t('gridfsBrowser.deleteFileTitle')"
:message="t('gridfsBrowser.deleteFileMessage')"
:details="selectedFile ? displayName(selectedFile) : ''"
:confirm-label="t('gridfsBrowser.deleteFile')"
@confirm="deleteSelectedFile"
/>
</div>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { FolderOpen, Plus, RefreshCcw, Trash2 } 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 { useToast } from "@/composables/useToast";
import * as api from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
const props = defineProps<{
connectionId: string;
database: string;
}>();
const { t } = useI18n();
const { toast } = useToast();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const loading = ref(false);
const creating = ref(false);
const deleting = ref(false);
const error = ref("");
const buckets = ref<Awaited<ReturnType<typeof api.documentListGridFsBuckets>>>([]);
const selectedBucketName = ref("");
const showCreateDialog = ref(false);
const showDeleteConfirm = ref(false);
const newBucketName = ref("");
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0));
const totalBytes = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.totalBytes, 0));
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
async function loadBuckets() {
loading.value = true;
error.value = "";
try {
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database);
buckets.value = nextBuckets;
if (selectedBucketName.value && !nextBuckets.some((bucket) => bucket.name === selectedBucketName.value)) {
selectedBucketName.value = "";
}
} catch (e: any) {
error.value = e?.message || String(e);
} finally {
loading.value = false;
}
}
function openBucket(bucketName = selectedBucketName.value) {
if (!bucketName) return;
queryStore.openMongoBucket(props.connectionId, props.database, bucketName);
}
async function createBucket() {
const bucketName = newBucketName.value.trim();
if (!bucketName || creating.value) return;
creating.value = true;
try {
await api.documentCreateGridFsBucket(props.connectionId, props.database, bucketName);
showCreateDialog.value = false;
newBucketName.value = "";
selectedBucketName.value = bucketName;
await loadBuckets();
toast(t("gridfsBrowser.bucketCreated", { bucket: bucketName }), 2500);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
creating.value = false;
}
}
async function deleteBucket() {
const bucketName = selectedBucketName.value;
if (!bucketName || deleting.value) return;
deleting.value = true;
try {
await api.documentDeleteGridFsBucket(props.connectionId, props.database, bucketName);
showDeleteConfirm.value = false;
selectedBucketName.value = "";
await loadBuckets();
toast(t("gridfsBrowser.bucketDeleted", { bucket: bucketName }), 2500);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
deleting.value = false;
}
}
onMounted(() => {
void loadBuckets();
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<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 }} / {{ t("tabs.gridfs") }}</div>
<div class="text-xs text-muted-foreground">{{ buckets.length }} {{ t("gridfsBrowser.bucketCount") }} / {{ totalFiles }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" class="h-8 gap-1.5" :disabled="isReadonly" @click="showCreateDialog = true">
<Plus class="h-3.5 w-3.5" />
{{ t("gridfsBrowser.createBucket") }}
</Button>
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="!selectedBucket" @click="openBucket()">
<FolderOpen class="h-3.5 w-3.5" />
{{ t("gridfsBrowser.openBucket") }}
</Button>
<Button variant="destructive" size="sm" class="h-8 gap-1.5" :disabled="isReadonly || !selectedBucket || deleting" @click="showDeleteConfirm = true">
<Trash2 class="h-3.5 w-3.5" />
{{ t("gridfsBrowser.deleteBucket") }}
</Button>
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="loading" @click="loadBuckets">
<RefreshCcw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
{{ t("grid.refresh") }}
</Button>
</div>
</div>
</div>
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
{{ error }}
</div>
<div v-else-if="loading && buckets.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{{ t("executionSummary.executing") }}
</div>
<div v-else-if="buckets.length === 0" class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">
{{ 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 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">
<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>
</tr>
</thead>
<tbody>
<tr v-for="bucket in buckets" :key="bucket.name" class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40" :class="{ 'bg-accent/45': selectedBucketName === bucket.name }" @click="selectedBucketName = bucket.name" @dblclick="openBucket(bucket.name)">
<td class="px-4 py-2 font-medium">{{ bucket.name }}</td>
<td class="px-4 py-2 text-muted-foreground">{{ bucket.fileCount }}</td>
<td class="px-4 py-2 text-muted-foreground">{{ formatBytes(bucket.totalBytes) }}</td>
</tr>
</tbody>
</table>
</div>
<aside class="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>
<div class="mt-5 space-y-4 text-sm">
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.fileCount") }}</div>
<div class="mt-1 font-medium">{{ selectedBucket.fileCount }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.totalSize") }}</div>
<div class="mt-1 font-medium">{{ formatBytes(selectedBucket.totalBytes) }}</div>
</div>
</div>
</template>
<div v-else class="text-sm text-muted-foreground">
{{ t("gridfsBrowser.selectBucket") }}
</div>
</aside>
</div>
<Dialog v-model:open="showCreateDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("gridfsBrowser.createTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-2 py-2">
<label class="text-sm font-medium">{{ t("gridfsBrowser.bucketName") }}</label>
<Input v-model="newBucketName" :placeholder="t('gridfsBrowser.bucketNamePlaceholder')" @keydown.enter="createBucket" />
</div>
<DialogFooter>
<Button variant="outline" :disabled="creating" @click="showCreateDialog = false">{{ t("common.cancel") }}</Button>
<Button :disabled="creating || !newBucketName.trim()" class="gap-1.5" @click="createBucket">
<RefreshCcw v-if="creating" class="h-3.5 w-3.5 animate-spin" />
{{ t("gridfsBrowser.createBucket") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog v-model:open="showDeleteConfirm" :loading="deleting" :title="t('gridfsBrowser.deleteTitle')" :message="t('gridfsBrowser.deleteMessage')" :details="selectedBucketName" :confirm-label="t('gridfsBrowser.deleteBucket')" @confirm="deleteBucket" />
</div>
</template>
@@ -39,6 +39,8 @@ const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/Red
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zookeeper/ZooKeeperKeyBrowser.vue"));
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
@@ -1288,6 +1290,18 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</template>
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
<div class="flex-1 min-h-0">
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" />
</div>
</template>
<template v-else-if="activeTab.mode === 'mongo-bucket'">
<div class="flex-1 min-h-0">
<MongoBucketBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :bucket="activeTab.mongoBucket?.bucketName || activeTab.sql" />
</div>
</template>
<!-- Vector mode: Qdrant and Milvus collections -->
<template v-else-if="activeTab.mode === 'vector'">
<div class="flex-1 min-h-0">
@@ -114,7 +114,7 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
connection: ["connection"],
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "mongo-db"],
schema: ["schema"],
table: ["table", "mongo-collection", "vector-collection", "elasticsearch-index"],
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
view: ["view"],
};
@@ -55,6 +55,7 @@ import {
SquarePen,
ListX,
Info,
Archive,
} from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -76,7 +77,7 @@ import { clearActiveTableReferencePayload, createTableReferencePayload, createTa
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { tableOpenPageLimit } from "@/lib/tableOpenPageLimit";
import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
import { copyNameForTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
import { formatSqlInsert } from "@/lib/exportFormats";
import { joinExportedDdls } from "@/lib/ddlExport";
import { fetchTableDataForExport } from "@/lib/tableDataExport";
@@ -315,6 +316,11 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Database, colorClass: "text-blue-500" };
case "mongo-db":
return { icon: Database, colorClass: "text-yellow-500" };
case "mongo-gridfs":
case "mongo-buckets":
return { icon: Archive, colorClass: "text-amber-500" };
case "mongo-bucket":
return { icon: Archive, colorClass: "text-amber-400" };
case "mongo-collection":
return { icon: Table, colorClass: "text-green-400" };
case "vector-collection":
@@ -648,12 +654,16 @@ function runRowClickAction(clickDetail: number) {
void openObjectBrowser();
return;
}
if (node.type === "mongo-gridfs") {
openMongoTreeData(node);
return;
}
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation);
if (!shouldRunTreeNodeRowAction(action, clickDetail)) return;
if (action === "open-data") {
openData();
} else if (node.type === "mongo-collection") {
openMongoCollectionData(node);
} else if (isDocumentBrowserTreeNode(node.type)) {
openMongoTreeData(node);
} else if (node.type === "procedure" || node.type === "function" || node.type === "sequence" || node.type === "package" || node.type === "package-body") {
void viewObjectSource();
} else if (action === "toggle") {
@@ -928,16 +938,25 @@ function onDoubleClick() {
void viewObjectSource();
} else if (action === "open-saved-sql") {
openSavedSqlFile();
} else if (action === "toggle" && props.node.type === "mongo-collection") {
openMongoCollectionData(props.node);
} else if (action === "toggle" && (props.node.type === "mongo-gridfs" || isDocumentBrowserTreeNode(props.node.type))) {
openMongoTreeData(props.node);
} else if (action === "toggle") {
toggle();
}
}
function openMongoCollectionData(node: TreeNode) {
if (node.type !== "mongo-collection" || !node.connectionId || !node.database) return;
function openMongoTreeData(node: TreeNode) {
if (!node.connectionId || !node.database) return;
if (node.type === "mongo-gridfs") {
queryStore.openMongoGridFs(node.connectionId, node.database);
return;
}
const tabTitle = `${node.database}.${node.label}`;
if (node.type === "mongo-bucket") {
queryStore.openMongoBucket(node.connectionId, node.database, node.label);
return;
}
if (node.type !== "mongo-collection") return;
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
queryStore.updateSql(tab, node.label);
}