feat(mongodb): improve sidebar index management

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-02 22:45:33 +08:00
1 parent 22055e081f
commit 8e7b74d0ee
56 files changed
+3558 -246

No files matched your search

@@ -38,6 +38,7 @@ import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/z
import { isLocalFileTypeDb } from "@/lib/connection/connectionFile";
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mq/mqPinnedVersionOptions";
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
import { applyDamengSslUrlParams, damengSslFormConfig } from "@/lib/database/damengSslOptions";
import { DamengJvmSystemPropertyError, damengJvmSystemPropertiesText, parseDamengJvmSystemProperties } from "@/lib/database/damengJvmOptions";
@@ -2884,7 +2885,7 @@ const mongoRetryWrites = computed({
},
});
const mongoDriverMode = computed({
get: () => (form.value.driver_profile === "mongodb-legacy" ? "legacy" : "auto"),
get: () => (isMongoLegacyDriverProfile(form.value.driver_profile) ? "legacy" : "auto"),
set: (value: string) => {
form.value.driver_profile = value === "legacy" ? "mongodb-legacy" : "mongodb";
form.value.driver_label = value === "legacy" ? "MongoDB (Legacy)" : "MongoDB";
@@ -3276,9 +3277,14 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
} else if (config.db_type === "mongodb") {
config.connection_string = normalizeMongoConnectionString(config.connection_string?.trim() || "");
}
if (config.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy") {
config.driver_profile = "mongodb";
config.driver_label = "MongoDB";
if (config.db_type === "mongodb") {
if (isMongoLegacyDriverProfile(config.driver_profile)) {
config.driver_profile = "mongodb-legacy";
config.driver_label = "MongoDB (Legacy)";
} else {
config.driver_profile = "mongodb";
config.driver_label = "MongoDB";
}
}
if (config.db_type === "mongodb") {
const mongoTls = normalizeMongoTlsFormState(!!config.ssl, config.url_params, config.ca_cert_path);
@@ -71,7 +71,7 @@ import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibili
import { useSettingsStore } from "@/stores/settingsStore";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -85,6 +85,7 @@ const props = defineProps<{
database: string;
collection: string;
databaseType?: DatabaseType;
tableMeta?: NonNullable<QueryTab["tableMeta"]>;
}>();
type JsonRecord = Record<string, unknown>;
@@ -1667,6 +1668,7 @@ defineExpose({ focusSearch });
:result="gridResult"
:connection-id="props.connectionId"
:database="props.database"
:table-meta="props.tableMeta"
:column-layout-scope-key="documentColumnLayoutScopeKey"
context="results"
:database-type="props.databaseType"
+87 -33
View File
@@ -221,6 +221,10 @@ import { supportsTableStructureEditing } from "@/lib/database/databaseCapabiliti
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
import { restoreDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { mongoCollectionSupportsIndexes, supportsMongoIndexMutations } from "@/lib/mongo/mongoCapabilities";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import { isProtectedMongoIndex, mongoDropAllIndexesPreview, mongoDropIndexFailureCount, mongoDropIndexPreview } from "@/lib/sidebar/mongoCollectionMutation";
import { runMongoMutation } from "@/lib/sidebar/runMongoSidebarMutation";
import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion";
import { isMacOS } from "@/lib/backend/platform";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
@@ -7336,7 +7340,8 @@ function toggleCellDetailPanelLayout() {
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType));
const canOpenTableStructureEditor = computed(() => !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsTableStructureEditing(resolvedDatabaseType.value));
const mongoConnectionConfig = resolvedConnectionConfig;
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && mongoConnectionConfig.value?.db_type === "mongodb" && mongoConnectionConfig.value?.driver_profile !== "mongodb-legacy");
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsMongoIndexMutations(mongoConnectionConfig.value, props.tableMeta?.tableType));
const canShowTableIndexes = computed(() => tableMetadataCapabilities.value.indexes && (resolvedDatabaseType.value !== "mongodb" || mongoCollectionSupportsIndexes(props.tableMeta?.tableType)));
const tableInfoTabs = computed(() => {
const tabs: TableInfoTabItem[] = [];
if (tableMetadataCapabilities.value.ddl) {
@@ -7350,7 +7355,7 @@ const tableInfoTabs = computed(() => {
count: props.tableMeta?.columns.length,
});
}
if (tableMetadataCapabilities.value.indexes) {
if (canShowTableIndexes.value) {
tabs.push({ id: "indexes", label: t("grid.tableInfoIndexes"), icon: KeyRound, count: indexes.value.length });
}
if (tableMetadataCapabilities.value.foreignKeys) {
@@ -7435,6 +7440,19 @@ async function reloadIndexes() {
await fetchIndexes();
}
async function refreshMongoIndexMetadataAfterMutation() {
await reloadIndexes();
const connectionId = props.connectionId;
const database = props.database;
const collection = props.tableMeta?.tableName;
if (!connectionId || !database || !collection) return;
try {
await refreshLoadedMongoIndexes(connectionStore, { connectionId, database, collection });
} catch (e: any) {
toast(t("contextMenu.mongoIndexRefreshFailed", { message: String(e?.message || e) }), 5000);
}
}
async function fetchForeignKeys() {
const requestIdentity = currentForeignKeyTableIdentity.value;
if (!props.connectionId || !props.tableMeta || !requestIdentity || foreignKeysLoaded.value || foreignKeysLoading.value) return;
@@ -7775,7 +7793,8 @@ const filteredIndexes = computed(() => {
return indexes.value.filter((i) => i.name.toLowerCase().includes(q) || i.columns.some((c) => c.toLowerCase().includes(q)));
});
const droppableMongoIndexes = computed(() => indexes.value.filter((index) => !index.is_primary));
const droppableMongoIndexes = computed(() => indexes.value.filter((index) => !isProtectedMongoIndex(index)));
const dropMongoIndexConfirmMessage = computed(() =>
pendingDropMongoIndex.value
? t("contextMenu.confirmDropMongoIndexMessage", {
@@ -7784,51 +7803,86 @@ const dropMongoIndexConfirmMessage = computed(() =>
})
: "",
);
const dropMongoIndexPreview = computed(() => (pendingDropMongoIndex.value ? mongoDropIndexPreview(props.database || "", props.tableMeta?.tableName || "", pendingDropMongoIndex.value.name) : ""));
const dropAllMongoIndexesConfirmMessage = computed(() => t("contextMenu.confirmDropMongoAllIndexesMessage", { name: props.tableMeta?.tableName || "" }));
const dropAllMongoIndexesConfirmDetails = computed(() => t("contextMenu.confirmDropMongoAllIndexesDetails"));
const dropMongoIndexPreview = computed(() => (pendingDropMongoIndex.value ? `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndex(${JSON.stringify(pendingDropMongoIndex.value.name)})` : ""));
const dropAllMongoIndexesPreview = computed(() => `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndexes()`);
const dropAllMongoIndexesPreview = computed(() => mongoDropAllIndexesPreview(props.database || "", props.tableMeta?.tableName || ""));
function requestDropMongoIndex(index: IndexInfo) {
if (!canManageMongoIndexes.value || isProtectedMongoIndex(index)) return;
pendingDropMongoIndex.value = index;
showDropMongoIndexConfirm.value = true;
}
function requestDropAllMongoIndexes() {
if (!canManageMongoIndexes.value || droppableMongoIndexes.value.length === 0) return;
showDropAllMongoIndexesConfirm.value = true;
}
async function confirmDropMongoIndex() {
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || !pendingDropMongoIndex.value || dropMongoIndexLoading.value) return;
dropMongoIndexLoading.value = true;
try {
await connectionStore.ensureConnected(props.connectionId);
await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, JSON.stringify(pendingDropMongoIndex.value.name), true);
toast(t("contextMenu.dropTableChildObjectSuccess", { name: pendingDropMongoIndex.value.name }), 3000);
showDropMongoIndexConfirm.value = false;
pendingDropMongoIndex.value = null;
await reloadIndexes();
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
} finally {
dropMongoIndexLoading.value = false;
}
const index = pendingDropMongoIndex.value;
const connectionId = props.connectionId;
const database = props.database;
const tableName = props.tableMeta?.tableName;
if (!connectionId || !database || !tableName || !index || !canManageMongoIndexes.value || isProtectedMongoIndex(index) || dropMongoIndexLoading.value) return;
await runMongoMutation({
connection: connectionStore.getConfig(connectionId),
database,
reviewText: dropMongoIndexPreview.value,
source: t("production.sourceDataGrid"),
loading: dropMongoIndexLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
try {
return await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(index.name), true);
} finally {
await refreshMongoIndexMetadataAfterMutation();
}
},
onSuccess: (result) => {
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropTableChildObjectSuccess", { name: index.name }), 3000);
}
showDropMongoIndexConfirm.value = false;
pendingDropMongoIndex.value = null;
},
onError: (e: any) => toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000),
});
}
async function confirmDropAllMongoIndexes() {
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || dropAllMongoIndexesLoading.value) return;
dropAllMongoIndexesLoading.value = true;
try {
await connectionStore.ensureConnected(props.connectionId);
const result = await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, undefined, false);
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: props.tableMeta.tableName }), 3000);
showDropAllMongoIndexesConfirm.value = false;
await reloadIndexes();
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
} finally {
dropAllMongoIndexesLoading.value = false;
}
const connectionId = props.connectionId;
const database = props.database;
const tableName = props.tableMeta?.tableName;
if (!connectionId || !database || !tableName || !canManageMongoIndexes.value || dropAllMongoIndexesLoading.value) return;
await runMongoMutation({
connection: connectionStore.getConfig(connectionId),
database,
reviewText: dropAllMongoIndexesPreview.value,
source: t("production.sourceDataGrid"),
loading: dropAllMongoIndexesLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
try {
return await api.mongoDropIndexes(connectionId, database, tableName, undefined, false);
} finally {
await refreshMongoIndexMetadataAfterMutation();
}
},
onSuccess: (result) => {
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: tableName }), 3000);
}
showDropAllMongoIndexesConfirm.value = false;
},
onError: (e: any) => toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000),
});
}
const filteredForeignKeys = computed(() => {
@@ -9373,7 +9427,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ index.columns.join(", ") }}
</div>
</div>
<Button v-if="canManageMongoIndexes && !index.is_primary" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-[11px] text-destructive hover:text-destructive" @click="requestDropMongoIndex(index)">
<Button v-if="canManageMongoIndexes && !isProtectedMongoIndex(index)" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-[11px] text-destructive hover:text-destructive" @click="requestDropMongoIndex(index)">
<Trash2 class="mr-1 h-3 w-3" />
{{ t("contextMenu.dropIndex") }}
</Button>
@@ -1843,7 +1843,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
<template v-else-if="activeTab.mode === 'mongo'">
<div class="flex-1 min-h-0">
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
</div>
</template>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { toRefs, watch } from "vue";
import { AlertTriangle, Check, Loader2, Clipboard, Upload } from "@lucide/vue";
import { computed, toRefs, watch } from "vue";
import { AlertTriangle, Check, Loader2, Clipboard, Plus, Trash2, Upload } from "@lucide/vue";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
const props = defineProps<{ controller: Record<string, any> }>();
const emit = defineEmits<{ closed: [] }>();
@@ -94,6 +96,17 @@ const {
renameMongoCollectionPreview,
renameMongoCollectionLoading,
confirmRenameMongoCollection,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
mongoIndexKeyTypes,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
showRedisDatabaseAliasDialog,
redisDatabaseAliasInput,
redisDatabaseAliasSaving,
@@ -139,6 +152,14 @@ function closeCreateDatabaseResult() {
showCreateDatabasePreviewDialog.value = false;
}
const mongoIndexCollectionName = computed(() => node.value.tableName || node.value.label);
function mongoIndexTypeLabel(type: string): string {
if (type === "1") return t.value("contextMenu.mongoIndexAscending");
if (type === "-1") return t.value("contextMenu.mongoIndexDescending");
return type;
}
watch(
[
showDeleteConfirm,
@@ -155,6 +176,7 @@ watch(
showCreateNacosNamespaceDialog,
showEditNacosNamespaceDialog,
showRenameMongoCollectionDialog,
showCreateMongoIndexDialog,
showRedisDatabaseAliasDialog,
showCreateSchemaDialog,
showEditSchemaCommentDialog,
@@ -255,6 +277,73 @@ watch(
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateMongoIndexDialog">
<DialogContent class="min-w-0 sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.createMongoIndexTitle", { collection: mongoIndexCollectionName }) }}</DialogTitle>
<p class="font-mono text-xs text-muted-foreground">{{ node.database }} / {{ mongoIndexCollectionName }}</p>
</DialogHeader>
<div class="grid min-w-0 gap-5">
<section class="grid min-w-0 gap-2">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-medium">{{ t("contextMenu.createMongoIndexFields") }}</span>
<Button type="button" variant="outline" size="sm" :disabled="mongoCreateIndexLoading || !mongoCreateIndexCanAddField" @click="addMongoCreateIndexField">
<Plus class="mr-1 h-4 w-4" />
{{ t("mongo.addField") }}
</Button>
</div>
<div class="flex min-w-0 gap-2 px-0.5 text-xs text-muted-foreground">
<span class="min-w-0 flex-1">{{ t("mongo.field") }}</span>
<span class="w-36 shrink-0">{{ t("structureEditor.indexType") }}</span>
<span class="w-8 shrink-0"></span>
</div>
<div v-for="field in mongoCreateIndexForm.fields" :key="field.id" class="flex min-w-0 items-center gap-2">
<Input v-model="field.path" :list="`mongo-index-fields-${field.id}`" :disabled="mongoCreateIndexLoading" :placeholder="t('mongo.fieldPlaceholder')" :aria-label="t('mongo.field')" class="h-8 min-w-0 flex-1" autocomplete="off" />
<datalist :id="`mongo-index-fields-${field.id}`">
<option v-for="option in mongoCreateIndexFieldOptions" :key="option" :value="option"></option>
</datalist>
<Select v-model="field.type" :disabled="mongoCreateIndexLoading">
<SelectTrigger class="h-8 w-36 shrink-0" :aria-label="t('structureEditor.indexType')">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="type in mongoIndexKeyTypes" :key="type" :value="type">{{ mongoIndexTypeLabel(type) }}</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :disabled="mongoCreateIndexLoading || mongoCreateIndexForm.fields.length === 1" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click="removeMongoCreateIndexField(field.id)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</section>
<label class="grid gap-1.5 text-sm font-medium">
{{ t("structureEditor.indexName") }}
<Input v-model="mongoCreateIndexForm.name" :disabled="mongoCreateIndexLoading" :placeholder="t('contextMenu.createMongoIndexNamePlaceholder')" :aria-label="t('structureEditor.indexName')" />
<span class="text-xs font-normal text-muted-foreground">{{ t("contextMenu.createMongoIndexNameHint") }}</span>
</label>
<div class="grid gap-2 rounded-md border p-3 sm:grid-cols-2">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium">
{{ t("structureEditor.unique") }}
<Switch v-model="mongoCreateIndexForm.unique" :disabled="mongoCreateIndexLoading" :aria-label="t('structureEditor.unique')" />
</label>
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium">
{{ t("contextMenu.createMongoIndexSparse") }}
<Switch v-model="mongoCreateIndexForm.sparse" :disabled="mongoCreateIndexLoading" :aria-label="t('contextMenu.createMongoIndexSparse')" />
</label>
</div>
<p v-if="mongoCreateIndexError" class="min-w-0 max-w-full whitespace-pre-wrap break-all text-sm text-destructive">{{ mongoCreateIndexError }}</p>
</div>
<DialogFooter>
<Button variant="outline" :disabled="mongoCreateIndexLoading" @click="showCreateMongoIndexDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="mongoCreateIndexLoading || !mongoCreateIndexCanSubmit" @click="confirmCreateMongoIndex">
<Loader2 v-if="mongoCreateIndexLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("contextMenu.createMongoIndex") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showRedisDatabaseAliasDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
@@ -93,6 +93,7 @@ import {
isSingleDatabase,
} from "@/lib/database/databaseCapabilities";
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
import { handleSidebarTreeDeleteShortcut } from "@/lib/sidebar/sidebarTreeDeleteShortcut";
@@ -148,7 +149,7 @@ import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/s
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
import { flattenTree } from "@/composables/useFlatTree";
import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { createSidebarMenuContext, normalizeSidebarMenuDescriptors } from "@/lib/sidebar/sidebarTreeMenuDescriptors";
@@ -234,6 +235,11 @@ import {
dropMongoIndexLoading,
showDropAllMongoIndexesConfirm,
dropAllMongoIndexesLoading,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
showFlushRedisDbConfirm,
showCreateSchemaDialog,
createSchemaName,
@@ -392,9 +398,18 @@ const {
mongoIndexNameForNode,
canDropMongoIndexNode,
canDropMongoIndex,
mongoIndexDropPreview,
canDropAllMongoIndexes,
mongoIndexDropPreview,
mongoDropAllIndexesPreview,
refreshMongoIndexTreeAfterMutation,
canCreateMongoIndex,
mongoIndexKeyTypes,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
prepareCreateMongoIndexDialog,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
openCreateNacosNamespaceDialog,
confirmCreateNacosNamespace,
openEditNacosNamespaceDialog,
@@ -973,6 +988,12 @@ function openRenameMongoCollectionDialog() {
prepareRenameMongoCollectionDialog();
}
function openCreateMongoIndexDialog() {
claimTreeItemDialogOwnership();
routeTreeItemDialogController();
prepareCreateMongoIndexDialog();
}
function openRedisDatabaseAliasDialog() {
claimTreeItemDialogOwnership();
routeTreeItemDialogController();
@@ -1080,6 +1101,13 @@ function openMongoTreeData(node: TreeNode) {
if (node.type !== "mongo-collection") return;
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
queryStore.updateSql(tab, node.label);
queryStore.setTableMeta(tab, {
database: node.database,
tableName: node.label,
tableType: mongoCollectionTableTypeFromNode(node),
columns: [],
primaryKeys: [],
});
}
async function openSavedSqlFile() {
@@ -2076,27 +2104,61 @@ async function confirmBatchDrop() {
if (mongoIndexTargets.length) {
const grouped = new Map<string, TreeNode[]>();
for (const target of mongoIndexTargets) {
const key = `${target.connectionId}:${target.database}:${target.tableName || ""}`;
const key = JSON.stringify([target.connectionId, target.database, target.tableName || ""]);
const list = grouped.get(key) ?? [];
list.push(target);
grouped.set(key, list);
}
const mongoIndexGroups = [...grouped.values()];
// Confirm every production target before changing any collection so a
// cancellation cannot leave a cross-database batch partially applied.
for (const groupTargets of mongoIndexGroups) {
const groupFirst = groupTargets[0];
if (!groupFirst?.connectionId || !groupFirst.database || !groupFirst.tableName) return;
const confirmed = await executeWithProductionContextGuard({
connection: connectionStore.getConfig(groupFirst.connectionId),
database: groupFirst.database,
reviewText: groupTargets.map((target) => mongoIndexDropPreview(target, mongoIndexNameForNode(target))).join("\n"),
source: t("production.sourceSidebar"),
execute: async () => ({ confirmed: true }),
});
if (confirmed === undefined) return;
}
let droppedCount = 0;
for (const groupTargets of grouped.values()) {
const first = groupTargets[0];
if (!first?.connectionId || !first.database || !first.tableName) continue;
await connectionStore.ensureConnected(first.connectionId);
let failedCount = 0;
let firstGroupError: unknown;
for (const groupTargets of mongoIndexGroups) {
const groupFirst = groupTargets[0];
if (!groupFirst?.connectionId || !groupFirst.database || !groupFirst.tableName) continue;
const names = groupTargets.map((target) => mongoIndexNameForNode(target));
const result = await api.mongoDropIndexes(first.connectionId, first.database, first.tableName, JSON.stringify(names.length === 1 ? names[0] : names), false);
const dropped = new Set(result.dropped_names);
droppedCount += result.dropped_names.length;
for (const target of groupTargets) {
if (!dropped.has(mongoIndexNameForNode(target))) continue;
connectionStore.removeTreeNode(target.id);
releaseActiveNodeReference([target.id]);
try {
await connectionStore.ensureConnected(groupFirst.connectionId);
const result = await api.mongoDropIndexes(groupFirst.connectionId, groupFirst.database, groupFirst.tableName, JSON.stringify(names.length === 1 ? names[0] : names), names.length === 1);
const dropped = new Set(result.dropped_names);
droppedCount += result.dropped_names.length;
failedCount += mongoDropIndexFailureCount(result);
for (const target of groupTargets) {
const indexName = mongoIndexNameForNode(target);
if (!dropped.has(indexName)) continue;
connectionStore.removeTreeNode(target.id);
releaseActiveNodeReference([target.id]);
}
} catch (error) {
// A transport-level failure has no per-index payload; retain prior
// successes and continue with independent collection groups.
failedCount += groupTargets.length;
firstGroupError ??= error;
} finally {
await refreshMongoIndexTreeAfterMutation(groupFirst);
}
}
toast(t("contextMenu.batchDropSuccess", { count: droppedCount }), 3000);
if (droppedCount === 0 && firstGroupError && failedCount === mongoIndexTargets.length) throw firstGroupError;
if (failedCount > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: droppedCount, failed: failedCount }), 5000);
} else {
toast(t("contextMenu.batchDropSuccess", { count: droppedCount }), 3000);
}
showBatchDropConfirm.value = false;
return;
}
@@ -3663,6 +3725,17 @@ function databaseSpecificDialogCapabilities() {
renameMongoCollectionPreview,
renameMongoCollectionLoading,
confirmRenameMongoCollection,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
mongoIndexKeyTypes,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
showRedisDatabaseAliasDialog,
redisDatabaseAliasInput,
redisDatabaseAliasSaving,
@@ -4213,8 +4286,11 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
shortcut: shortcutRename,
});
}
if (canDropAllMongoIndexes.value || canDropMongoCollection.value) {
if (canCreateMongoIndex.value || canDropAllMongoIndexes.value || canDropMongoCollection.value) {
items.push({ label: "", separator: true });
if (canCreateMongoIndex.value) {
items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus });
}
if (canDropAllMongoIndexes.value) {
items.push({ label: t("contextMenu.dropAllIndexes"), action: dropAllMongoIndexes, icon: Trash2, variant: "destructive" as const });
}
@@ -4486,7 +4562,8 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
// 9. Group Labels (group-columns, group-tables, etc.)
if (isGroupLabel(node)) {
const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null;
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate;
const hasMongoCreateIndexAction = node.type === "group-indexes" && canCreateMongoIndex.value;
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate || hasMongoCreateIndexAction;
const canLoadAllObjectGroup = node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views";
if (node.type === "group-tables" && canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
@@ -4503,6 +4580,9 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
if (mysqlObjectTemplate) {
items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus });
}
if (hasMongoCreateIndexAction) {
items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus });
}
if (hasGroupCreateAction) {
items.push({ label: "", separator: true });
}
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import SidebarTreeItemDialogs from "../SidebarTreeItemDialogs.vue";
const mountedApps: ReturnType<typeof createApp>[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("SidebarTreeItemDialogs MongoDB index form", () => {
it("mounts the form and overlay from the same controlled dialog state", async () => {
const confirmCreateMongoIndex = vi.fn();
const closed = vi.fn();
const controller = reactive({
node: { id: "mongo:app:users:__indexes", label: "索引", type: "group-indexes", database: "app", tableName: "users" },
t: (key: string) => key,
highlight: (value: string) => value,
showDeleteConfirm: false,
showMoveToNewGroupDialog: false,
showDeleteGroupConfirm: false,
showRenameObjectDialog: false,
showStructurePreviewDialog: false,
showStructureDocCopyDialog: false,
showDuplicateDialog: false,
showPasteDialog: false,
showCreateDatabaseDialog: false,
showCreateDatabasePreviewDialog: false,
showEditDatabasePropertiesDialog: false,
showCreateNacosNamespaceDialog: false,
showEditNacosNamespaceDialog: false,
showRenameMongoCollectionDialog: false,
showCreateMongoIndexDialog: false,
mongoCreateIndexForm: { name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false },
mongoCreateIndexFieldOptions: ["email", "createdAt"],
mongoCreateIndexError: `Kind: Command failed: Error code 11000 (DuplicateKey): ${"a".repeat(400)}`,
mongoCreateIndexLoading: false,
mongoIndexKeyTypes: ["1", "-1", "text", "hashed", "2dsphere", "2d"],
mongoCreateIndexCanSubmit: false,
mongoCreateIndexCanAddField: false,
addMongoCreateIndexField: vi.fn(),
removeMongoCreateIndexField: vi.fn(),
confirmCreateMongoIndex,
showRedisDatabaseAliasDialog: false,
showCreateSchemaDialog: false,
showEditSchemaCommentDialog: false,
});
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeItemDialogs, { controller, onClosed: closed }),
}),
);
mountedApps.push(app);
app.mount(document.createElement("div"));
await nextTick();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
controller.showCreateMongoIndexDialog = true;
await nextTick();
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
const name = document.querySelector<HTMLInputElement>('[aria-label="structureEditor.indexName"]');
const type = document.querySelector<HTMLElement>('[aria-label="structureEditor.indexType"]');
const unique = document.querySelector<HTMLElement>('[aria-label="structureEditor.unique"]');
expect(dialog?.getAttribute("data-state")).toBe("open");
expect(document.querySelectorAll('[data-slot="dialog-overlay"]')).toHaveLength(1);
expect(dialog?.contains(name!)).toBe(true);
expect(dialog?.contains(type!)).toBe(true);
expect(dialog?.contains(unique!)).toBe(true);
expect(dialog?.textContent).not.toContain("JSON");
const error = dialog?.querySelector<HTMLElement>(".text-destructive");
expect(error?.classList.contains("min-w-0")).toBe(true);
expect(error?.classList.contains("max-w-full")).toBe(true);
expect(error?.classList.contains("whitespace-pre-wrap")).toBe(true);
expect(error?.classList.contains("break-all")).toBe(true);
unique?.click();
await nextTick();
expect(controller.mongoCreateIndexForm.unique).toBe(true);
controller.mongoCreateIndexForm.fields[0].path = "email";
controller.mongoCreateIndexForm.name = "codex_e2e_email_1";
controller.mongoCreateIndexCanSubmit = true;
await nextTick();
const createButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "contextMenu.createMongoIndex");
expect(createButton?.disabled).toBe(false);
createButton?.click();
expect(confirmCreateMongoIndex).toHaveBeenCalledOnce();
controller.showCreateMongoIndexDialog = false;
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
});
expect(closed).toHaveBeenCalledOnce();
});
});
@@ -4,6 +4,7 @@ import type { PasteTableMode } from "@/lib/table/tableClipboard";
import { fallbackCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import type { DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
import type { AuthorizationPlan, AuthorizationStepResult } from "@/lib/database/databaseAuthorizationPlan";
import type { MongoCreateIndexForm } from "@/lib/sidebar/mongoCollectionMutation";
export type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
type ConnectionDeleteTarget = TreeNode & { connectionId: string };
@@ -96,6 +97,18 @@ export const showDropMongoIndexConfirm = ref(false);
export const dropMongoIndexLoading = ref(false);
export const showDropAllMongoIndexesConfirm = ref(false);
export const dropAllMongoIndexesLoading = ref(false);
export const showCreateMongoIndexDialog = ref(false);
export const mongoCreateIndexForm = ref<MongoCreateIndexForm>({ name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false });
export const mongoCreateIndexFieldOptions = ref<string[]>([]);
export const mongoCreateIndexError = ref("");
export const mongoCreateIndexLoading = ref(false);
export function resetMongoCreateIndexForm() {
mongoCreateIndexForm.value = { name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false };
mongoCreateIndexFieldOptions.value = [];
mongoCreateIndexError.value = "";
mongoCreateIndexLoading.value = false;
}
export const showFlushRedisDbConfirm = ref(false);
export const showRedisDatabaseAliasDialog = ref(false);
export const redisDatabaseAliasInput = ref("");
@@ -141,6 +154,7 @@ const openFlags = [
showRenameMongoCollectionDialog,
showDropMongoIndexConfirm,
showDropAllMongoIndexesConfirm,
showCreateMongoIndexDialog,
showFlushRedisDbConfirm,
showRedisDatabaseAliasDialog,
showCreateSchemaDialog,
@@ -162,6 +176,7 @@ export function resetSidebarTreeDialogState() {
createDatabaseAuthorizationApplying.value = false;
redisDatabaseAliasInput.value = "";
redisDatabaseAliasSaving.value = false;
resetMongoCreateIndexForm();
sidebarTreeDialogOwner.value = null;
sidebarDangerTarget.value = null;
sidebarFormTarget.value = null;