Files
dbx/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue
T

256 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Asterisk, CheckSquare, Loader2, Search, Square } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useConnectionStore } from "@/stores/connectionStore";
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, databaseNameMatchesVisiblePatterns, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection, parseVisibleDatabasePatternsInput } from "@/lib/database/visibleDatabases";
import * as api from "@/lib/backend/api";
const props = defineProps<{
open: boolean;
connectionId: string;
connectionName: string;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
type FilterMode = "database" | "schema";
const objectNames = ref<string[]>([]);
const selectedNames = ref<Set<string>>(new Set());
const patternInput = ref("");
const searchText = ref("");
const showSystemDatabases = ref(false);
const isLoading = ref(false);
const errorMessage = ref("");
const connection = computed(() => connectionStore.getConfig(props.connectionId));
const filterMode = computed<FilterMode>(() => (connectionUsesVisibleSchemaFilter(connection.value) ? "schema" : "database"));
const databaseKey = computed(() => connection.value?.database || "");
const isSchemaFilterMode = computed(() => filterMode.value === "schema");
const titleKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.title" : "visibleDatabases.title"));
const descriptionKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.description" : "visibleDatabases.description"));
const searchPlaceholderKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.searchPlaceholder" : "visibleDatabases.searchPlaceholder"));
const emptySelectionKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection"));
const loadFailedKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed"));
const showSystemObjectsKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.showSystemSchemas" : "visibleDatabases.showSystemDatabases"));
const defaultObjectNames = computed(() => {
if (isSchemaFilterMode.value) return filterSchemaNamesForVisiblePicker(objectNames.value, connection.value);
return filterDatabaseNamesForVisiblePicker(objectNames.value, connection.value);
});
const listedObjectNames = computed(() => (showSystemDatabases.value ? objectNames.value : defaultObjectNames.value));
const filteredObjectNames = computed(() => {
const query = searchText.value.trim().toLowerCase();
if (!query) return listedObjectNames.value;
return listedObjectNames.value.filter((name) => name.toLowerCase().includes(query));
});
const parsedPatterns = computed(() => parseVisibleDatabasePatternsInput(patternInput.value));
const patternMatchCount = computed(() => {
if (parsedPatterns.value.length === 0) return 0;
return objectNames.value.filter((name) => databaseNameMatchesVisiblePatterns(name, parsedPatterns.value)).length;
});
const selectedCount = computed(() => selectedNames.value.size);
const totalCount = computed(() => listedObjectNames.value.length);
const canSaveSelection = computed(() => canSaveVisibleDatabaseSelection([...selectedNames.value]));
const hasSystemObjects = computed(() => defaultObjectNames.value.length < objectNames.value.length);
const showAllDisabled = computed(() => {
if (isSchemaFilterMode.value) {
return !connection.value?.visible_schemas?.[databaseKey.value];
}
return !Array.isArray(connection.value?.visible_databases) && parsedPatterns.value.length === 0;
});
watch(
() => props.open,
(open) => {
if (!open) return;
loadDatabases().catch(() => {});
},
// Tree-level async hosts mount the dialog with `open` already true.
{ immediate: true },
);
watch(showSystemDatabases, (show) => {
if (show) return;
const visible = new Set(listedObjectNames.value);
selectedNames.value = new Set([...selectedNames.value].filter((name) => visible.has(name)));
});
async function loadDatabases() {
isLoading.value = true;
errorMessage.value = "";
searchText.value = "";
try {
const names = await loadObjectNames();
connectionStore.recordPrimaryVisibleObjectNames(props.connectionId, names);
objectNames.value = names;
showSystemDatabases.value = false;
const configured = isSchemaFilterMode.value ? connection.value?.visible_schemas?.[databaseKey.value] : connection.value?.visible_databases;
patternInput.value = isSchemaFilterMode.value ? "" : (connection.value?.visible_database_patterns?.join(", ") ?? "");
const initialSelection = Array.isArray(configured) ? normalizeVisibleDatabaseSelection(configured, names) : listedObjectNames.value;
selectedNames.value = new Set(initialSelection);
const defaultVisible = new Set(defaultObjectNames.value);
showSystemDatabases.value = initialSelection.some((name) => !defaultVisible.has(name));
} catch (e: any) {
objectNames.value = [];
selectedNames.value = new Set();
showSystemDatabases.value = false;
errorMessage.value = String(e?.message || e);
} finally {
isLoading.value = false;
}
}
async function loadObjectNames(): Promise<string[]> {
const config = connection.value;
if (isSchemaFilterMode.value) {
await connectionStore.ensureConnected(props.connectionId);
return api.listSchemas(props.connectionId, config?.database || "");
}
await connectionStore.ensureConnected(props.connectionId);
if (config?.db_type === "redis") {
return (await api.redisListDatabases(props.connectionId)).map((database) => String(database.db));
}
if (config?.db_type === "mongodb") {
return api.mongoListDatabases(props.connectionId);
}
return (await api.listDatabases(props.connectionId)).map((database) => database.name);
}
function toggleObject(name: string) {
const next = new Set(selectedNames.value);
if (next.has(name)) next.delete(name);
else next.add(name);
selectedNames.value = next;
}
const isSearching = computed(() => searchText.value.trim().length > 0);
function selectAll() {
selectedNames.value = new Set(listedObjectNames.value);
}
function selectFiltered() {
selectedNames.value = new Set(filteredObjectNames.value);
}
function clearSelection() {
selectedNames.value = new Set();
}
async function showAllDatabases() {
if (isSchemaFilterMode.value) {
await connectionStore.clearVisibleSchemas(props.connectionId, databaseKey.value);
} else {
await connectionStore.clearVisibleDatabases(props.connectionId);
}
emit("update:open", false);
}
async function saveSelection() {
if (!canSaveSelection.value) return;
if (isSchemaFilterMode.value) {
await connectionStore.setVisibleSchemas(props.connectionId, databaseKey.value, normalizeVisibleDatabaseSelection([...selectedNames.value], objectNames.value));
} else {
await connectionStore.setVisibleDatabaseFilter(props.connectionId, [...selectedNames.value], parsedPatterns.value);
}
emit("update:open", false);
}
</script>
<template>
<Dialog :open="open" @update:open="(value: boolean) => emit('update:open', value)">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t(titleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t(descriptionKey, { connection: connectionName }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="searchText" :placeholder="t(searchPlaceholderKey)" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoading || !!errorMessage" />
</div>
<!-- 通配符模式:与勾选取并集,命中的库(含之后新建的)始终显示(#7164) -->
<div v-if="!isSchemaFilterMode" class="space-y-1">
<div class="flex items-center gap-2 rounded-md border bg-background px-2" :title="t('visibleDatabases.patternsHint')">
<Asterisk class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="patternInput" data-visible-database-patterns :placeholder="t('visibleDatabases.patternsPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoading || !!errorMessage" />
</div>
<p class="px-1 text-xs text-muted-foreground">
{{ t("visibleDatabases.patternsHint") }}<template v-if="parsedPatterns.length > 0"> · {{ t("visibleDatabases.patternsMatchCount", { count: patternMatchCount }) }}</template>
</p>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("visibleDatabases.selectedCount", { selected: selectedCount, total: totalCount }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="selectAll">
{{ t("visibleDatabases.selectAll") }}
</button>
<button v-if="isSearching" class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="selectFiltered">
{{ t("visibleDatabases.selectFiltered") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="clearSelection">
{{ t("visibleDatabases.clear") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading || showAllDisabled" @click="showAllDatabases">
{{ t("visibleDatabases.showAll") }}
</button>
</div>
</div>
<p v-if="!isLoading && !errorMessage && !canSaveSelection" class="text-xs text-destructive">
{{ t(emptySelectionKey) }}
</p>
<label v-if="hasSystemObjects" class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground">
<input v-model="showSystemDatabases" type="checkbox" class="h-3.5 w-3.5 accent-primary" :disabled="isLoading || !!errorMessage" />
<span>{{ t(showSystemObjectsKey) }}</span>
</label>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoading" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="errorMessage" class="p-3 text-sm text-destructive">
{{ t(loadFailedKey, { message: errorMessage }) }}
</div>
<div v-else-if="!filteredObjectNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
<template v-else>
<button
v-for="name in filteredObjectNames"
:key="name"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleObject(name)"
>
<CheckSquare v-if="selectedNames.has(name)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ name }}</span>
</button>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoading || !!errorMessage || !canSaveSelection" @click="saveSelection">
{{ t("visibleDatabases.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>