feat(desktop): add connection-level database browser

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-12 02:20:05 +08:00
1 parent 44dbd0e0e7
commit fee3ed1d5a
46 files changed
+781 -29

No files matched your search

@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck } from "@lucide/vue";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -494,7 +494,7 @@ function tabColorStyle(tab: QueryTab) {
function tabIconClass(tab: QueryTab) {
if (tab.externalSqlFileMissing) return "text-amber-600 dark:text-amber-400";
if (tab.mode === "mq") return "";
if (tab.mode === "objects") return "text-amber-500 dark:text-amber-400";
if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "hbase" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
return "text-blue-600 dark:text-blue-400";
}
@@ -530,6 +530,7 @@ function tabMenuIcon(tab: QueryTab) {
if (tab.mode === "etcd-dashboard") return Gauge;
if (tab.mode === "etcd-access-control") return ShieldCheck;
if (tab.mode === "nacos") return Network;
if (tab.mode === "databases") return Database;
if (tab.mode === "objects") return TableProperties;
if (tab.mode === "structure") return PencilRuler;
if (tab.mode === "dameng-jobs") return CalendarClock;
@@ -677,6 +678,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<Database v-else-if="tab.mode === 'databases'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
@@ -874,6 +876,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<Database v-else-if="tab.mode === 'databases'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
@@ -66,6 +66,7 @@ const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdmi
const MqttAdminConsole = defineAsyncComponent(() => import("@/components/mqtt/MqttAdminConsole.vue"));
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
const NacosDashboard = defineAsyncComponent(() => import("@/components/nacos/NacosDashboard.vue"));
const DatabaseBrowser = defineAsyncComponent(() => import("@/components/objects/DatabaseBrowser.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
@@ -247,6 +248,7 @@ const etcdDashboardRef = ref<{ refresh?: () => boolean }>();
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
const consulOverviewRef = ref<{ refresh?: () => boolean }>();
const consulWorkspaceRef = ref<SearchableBrowserHandle>();
const databaseBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
@@ -785,6 +787,7 @@ function focusSearch(): boolean {
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;
return dataGridRef.value?.focusSearch() ?? false;
@@ -804,6 +807,7 @@ function refreshData(): boolean {
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "consul-overview") return consulOverviewRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.refresh?.() ?? false;
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
if (canReloadUnavailableDataTab(props.activeTab)) {
emit("reload");
@@ -2041,6 +2045,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</template>
<template v-else-if="activeTab.mode === 'databases' && activeConnection">
<div class="min-w-0 flex-1 min-h-0">
<DatabaseBrowser ref="databaseBrowserRef" :connection="activeConnection" />
</div>
</template>
<!-- Objects mode: virtualized database object browser -->
<template v-else-if="activeTab.mode === 'objects' && activeConnection">
<div class="min-w-0 flex-1 min-h-0">
@@ -27,10 +27,12 @@ describe("AppTabBar HBase presentation", () => {
});
});
describe("AppTabBar objects presentation", () => {
it("uses an amber icon color on regular, pinned, and overflow tab surfaces", () => {
expect(tabBarSource).toContain('if (tab.mode === "objects") return "text-amber-500 dark:text-amber-400";');
describe("AppTabBar object browser presentation", () => {
it("uses matching icons and colors for object and database browser tabs", () => {
expect(tabBarSource).toContain('if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";');
expect(tabBarSource).toContain('if (tab.mode === "databases") return Database;');
expect(tabBarSource).toContain('if (tab.mode === "objects") return TableProperties;');
expect(tabBarSource.match(/tab\.mode === 'databases'/g)).toHaveLength(2);
expect(tabBarSource.match(/:class="tabIconClass\(tab\)"/g)).toHaveLength(2);
expect(tabBarSource.match(/tabMenuIcon\(tab\).*tabIconClass\(tab\)/g)).toHaveLength(2);
});
@@ -0,0 +1,334 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { ArrowDown, ArrowUp, Database, GripVertical, LayoutGrid, List, Loader2, RefreshCw, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import * as api from "@/lib/backend/api";
import { filterDatabaseNamesForConnection } from "@/lib/database/visibleDatabases";
import { formatObjectBrowserBytes, formatObjectBrowserTimestamp } from "@/lib/table/objectBrowserRows";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { ConnectionConfig } from "@/types/database";
type DatabaseRow = {
name: string;
sizeBytes: number | null;
createdAt: string | null;
updatedAt: string | null;
comment: string | null;
defaultCharset: string | null;
defaultCollation: string | null;
};
type DatabaseBrowserColumnKey = "name" | "sizeBytes" | "createdAt" | "updatedAt" | "defaultCharset" | "defaultCollation" | "comment";
type DatabaseBrowserSortKey = DatabaseBrowserColumnKey;
type SortDirection = "asc" | "desc";
const props = defineProps<{
connection: ConnectionConfig;
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const searchInput = ref<HTMLInputElement>();
const search = ref("");
const rows = ref<DatabaseRow[]>([]);
const loading = ref(false);
const error = ref("");
const sortKey = ref<DatabaseBrowserSortKey>("name");
const sortDirection = ref<SortDirection>("asc");
const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>({
name: 260,
sizeBytes: 100,
createdAt: 150,
updatedAt: 150,
defaultCharset: 130,
defaultCollation: 170,
comment: 260,
});
let stopColumnResize: (() => void) | null = null;
const hasSize = computed(() => rows.value.some((row) => row.sizeBytes != null));
const hasCreatedAt = computed(() => rows.value.some((row) => !!row.createdAt?.trim()));
const hasUpdatedAt = computed(() => rows.value.some((row) => !!row.updatedAt?.trim()));
const hasComment = computed(() => rows.value.some((row) => !!row.comment?.trim()));
const hasDefaultCharset = computed(() => rows.value.some((row) => !!row.defaultCharset?.trim()));
const hasDefaultCollation = computed(() => rows.value.some((row) => !!row.defaultCollation?.trim()));
const isListView = computed(() => settingsStore.editorSettings.objectBrowserViewMode !== "grid");
const columns = computed<DatabaseBrowserColumnKey[]>(() => {
const next: DatabaseBrowserColumnKey[] = ["name"];
if (hasSize.value) next.push("sizeBytes");
if (hasCreatedAt.value) next.push("createdAt");
if (hasUpdatedAt.value) next.push("updatedAt");
if (hasDefaultCharset.value) next.push("defaultCharset");
if (hasDefaultCollation.value) next.push("defaultCollation");
if (hasComment.value) next.push("comment");
return next;
});
const gridTemplateColumns = computed(() => columns.value.map((key, index) => (index === columns.value.length - 1 ? `minmax(${columnWidths.value[key]}px, 1fr)` : `${columnWidths.value[key]}px`)).join(" "));
const gridMinWidth = computed(() => columns.value.reduce((total, key) => total + columnWidths.value[key], 0) + Math.max(0, columns.value.length - 1) * 12 + 24);
const sortKeyOptions = computed(() => columns.value);
const visibleRows = computed(() => {
const query = search.value.trim().toLocaleLowerCase();
const filtered = rows.value.filter((row) => !query || row.name.toLocaleLowerCase().includes(query) || row.comment?.toLocaleLowerCase().includes(query));
return [...filtered].sort((left, right) => compareRows(left, right, sortKey.value, sortDirection.value));
});
function openDatabase(database: string) {
queryStore.openObjectBrowser(props.connection.id, database);
}
function columnLabel(key: DatabaseBrowserColumnKey): string {
if (key === "name") return t("objects.name");
if (key === "sizeBytes") return t("objects.size");
if (key === "createdAt") return t("objects.createdAt");
if (key === "updatedAt") return t("objects.updatedAt");
if (key === "defaultCharset") return t("databaseBrowser.defaultCharset");
if (key === "defaultCollation") return t("databaseBrowser.defaultCollation");
return t("objects.comment");
}
function displayValue(row: DatabaseRow, key: DatabaseBrowserColumnKey): string {
if (key === "name") return row.name;
if (key === "sizeBytes") return row.sizeBytes == null ? "" : formatObjectBrowserBytes(row.sizeBytes);
if (key === "createdAt") return formatObjectBrowserTimestamp(row.createdAt);
if (key === "updatedAt") return formatObjectBrowserTimestamp(row.updatedAt);
if (key === "defaultCharset") return row.defaultCharset || "";
if (key === "defaultCollation") return row.defaultCollation || "";
return row.comment || "";
}
function compareRows(left: DatabaseRow, right: DatabaseRow, key: DatabaseBrowserSortKey, direction: SortDirection): number {
const leftValue = key === "sizeBytes" ? (left.sizeBytes ?? -1) : displayValue(left, key).toLocaleLowerCase();
const rightValue = key === "sizeBytes" ? (right.sizeBytes ?? -1) : displayValue(right, key).toLocaleLowerCase();
const compared = typeof leftValue === "number" && typeof rightValue === "number" ? leftValue - rightValue : String(leftValue).localeCompare(String(rightValue), undefined, { numeric: true, sensitivity: "base" });
if (compared !== 0) return direction === "asc" ? compared : -compared;
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
}
function sortIconFor(key: DatabaseBrowserSortKey) {
if (sortKey.value !== key) return null;
return sortDirection.value === "asc" ? ArrowUp : ArrowDown;
}
function initialSortDirection(key: DatabaseBrowserSortKey): SortDirection {
return key === "sizeBytes" || key === "createdAt" || key === "updatedAt" ? "desc" : "asc";
}
function toggleSort(key: DatabaseBrowserSortKey) {
if (sortKey.value === key) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
return;
}
sortKey.value = key;
sortDirection.value = initialSortDirection(key);
}
function setViewMode(mode: "list" | "grid") {
settingsStore.updateEditorSettings({ objectBrowserViewMode: mode });
}
function minimumColumnWidth(key: DatabaseBrowserColumnKey): number {
return key === "name" || key === "comment" ? 120 : 72;
}
function onColumnResizeStart(key: DatabaseBrowserColumnKey, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
stopColumnResize?.();
const startX = event.clientX;
const startWidth = columnWidths.value[key];
const minWidth = minimumColumnWidth(key);
document.body.classList.add("select-none", "cursor-col-resize");
const onMove = (moveEvent: MouseEvent) => {
columnWidths.value = { ...columnWidths.value, [key]: Math.max(minWidth, startWidth + moveEvent.clientX - startX) };
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.classList.remove("select-none", "cursor-col-resize");
stopColumnResize = null;
};
stopColumnResize = onUp;
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function resetColumnWidth(key: DatabaseBrowserColumnKey, width: number, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
columnWidths.value = { ...columnWidths.value, [key]: width };
}
async function refresh(): Promise<boolean> {
loading.value = true;
error.value = "";
try {
let databases;
try {
databases = await api.listDatabaseMetadata(props.connection.id);
} catch (metadataError) {
try {
databases = await api.listDatabases(props.connection.id);
} catch {
throw metadataError;
}
}
const visibleNames = new Set(
filterDatabaseNamesForConnection(
databases.map((database) => database.name),
props.connection,
),
);
rows.value = databases
.filter((database) => database.name.trim() && visibleNames.has(database.name))
.map((database) => ({
name: database.name,
sizeBytes: database.size_bytes ?? null,
createdAt: database.created_at ?? null,
updatedAt: database.updated_at ?? null,
comment: database.comment ?? null,
defaultCharset: database.default_charset ?? null,
defaultCollation: database.default_collation ?? null,
}));
} catch (cause: any) {
rows.value = [];
error.value = cause?.message || String(cause);
} finally {
loading.value = false;
}
return true;
}
function focusSearch(): boolean {
searchInput.value?.focus();
return true;
}
watch(sortKeyOptions, (options) => {
if (!options.includes(sortKey.value)) {
sortKey.value = "name";
sortDirection.value = "asc";
}
});
watch(
() => props.connection.id,
() => {
search.value = "";
void refresh();
},
{ immediate: true },
);
onBeforeUnmount(() => stopColumnResize?.());
defineExpose({ focusSearch, refresh });
</script>
<template>
<section class="flex h-full min-h-0 min-w-0 flex-col bg-background">
<div class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
<span class="inline-flex max-w-[14rem] min-w-0 items-center truncate rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium" :title="connection.name">
{{ connection.name }}
</span>
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input ref="searchInput" v-model="search" class="h-7 pl-8 text-xs" :placeholder="t('databaseBrowser.search')" />
</div>
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<select
class="h-6 cursor-pointer appearance-none rounded-sm bg-transparent px-1.5 text-xs text-muted-foreground outline-none hover:text-foreground focus:text-foreground"
:value="sortKey"
:aria-label="t('objects.sortBy')"
@change="toggleSort(($event.target as HTMLSelectElement).value as DatabaseBrowserSortKey)"
>
<option v-for="key in sortKeyOptions" :key="key" :value="key" class="bg-background text-foreground">{{ columnLabel(key) }}</option>
</select>
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :title="sortDirection === 'asc' ? t('objects.sortAsc') : t('objects.sortDesc')" @click="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
<ArrowUp v-if="sortDirection === 'asc'" class="h-3 w-3" />
<ArrowDown v-else class="h-3 w-3" />
</button>
</div>
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': isListView }" :title="t('objects.viewList')" @click="setViewMode('list')">
<List class="h-3.5 w-3.5" />
</button>
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': !isListView }" :title="t('objects.viewGrid')" @click="setViewMode('grid')">
<LayoutGrid class="h-3.5 w-3.5" />
</button>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('contextMenu.refreshTab')" :disabled="loading" @click="refresh">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
</Button>
</div>
<div v-if="loading && rows.length === 0" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("objects.loading") }}</div>
<div v-else-if="error" class="flex flex-1 items-center justify-center px-6 text-center text-sm text-destructive">{{ t("databaseBrowser.loadFailed", { message: error }) }}</div>
<div v-else-if="visibleRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">{{ t("databaseBrowser.empty") }}</div>
<div v-else class="flex min-h-0 min-w-0 flex-1">
<div v-if="isListView" class="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
<div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${gridMinWidth}px` }">
<div v-for="key in columns" :key="key" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort(key)">
<span class="truncate">{{ columnLabel(key) }}</span
><component :is="sortIconFor(key)" v-if="sortIconFor(key)" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onColumnResizeStart(key, $event)"
@dblclick="resetColumnWidth(key, key === 'name' || key === 'comment' ? 260 : key === 'defaultCollation' ? 170 : key === 'defaultCharset' ? 130 : key === 'sizeBytes' ? 100 : 150, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<div
v-for="row in visibleRows"
:key="row.name"
tabindex="0"
class="grid h-[34px] cursor-pointer items-center gap-3 border-b px-3 outline-none hover:bg-accent/50 focus-visible:bg-accent/50"
:style="{ gridTemplateColumns, minWidth: `${gridMinWidth}px` }"
@dblclick="openDatabase(row.name)"
@keydown.enter.prevent="openDatabase(row.name)"
>
<div
v-for="key in columns"
:key="key"
class="min-w-0 truncate text-xs text-muted-foreground"
:class="{ 'flex items-center gap-2 text-[13px] font-medium text-foreground': key === 'name', 'tabular-nums': key === 'sizeBytes' || key === 'createdAt' || key === 'updatedAt' }"
:title="displayValue(row, key)"
>
<Database v-if="key === 'name'" class="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
{{ displayValue(row, key) || "-" }}
</div>
</div>
</div>
</div>
<div v-else class="grid min-h-0 flex-1 grid-cols-[repeat(auto-fill,minmax(160px,1fr))] content-start gap-3 overflow-auto p-3">
<div
v-for="row in visibleRows"
:key="row.name"
tabindex="0"
class="flex min-h-28 cursor-pointer flex-col items-center gap-1 rounded-lg border bg-card p-3 text-center outline-none transition-all hover:border-primary/40 hover:shadow-sm focus-visible:border-primary"
@dblclick="openDatabase(row.name)"
@keydown.enter.prevent="openDatabase(row.name)"
>
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 shadow-sm"><Database class="h-6 w-6 text-emerald-600 dark:text-emerald-400" /></div>
<span class="w-full truncate text-sm font-medium text-foreground" :title="row.name">{{ row.name }}</span>
<div class="flex min-h-[15px] items-center gap-1 text-[10px] leading-[15px] text-muted-foreground">
<span v-if="row.sizeBytes != null">{{ formatObjectBrowserBytes(row.sizeBytes) }}</span
><span v-if="row.createdAt && row.sizeBytes != null">·</span><span v-if="row.createdAt">{{ formatObjectBrowserTimestamp(row.createdAt) }}</span>
</div>
<div v-if="hasComment" class="w-full truncate text-[10px] leading-[15px] text-muted-foreground/60" :title="row.comment || ''">{{ row.comment || "\u00A0" }}</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../DatabaseBrowser.vue", import.meta.url), "utf8");
describe("DatabaseBrowser metadata fallback", () => {
it("falls back to basic database names when extended metadata fails", () => {
expect(source).toMatch(/await api\.listDatabaseMetadata\(props\.connection\.id\)[\s\S]*?catch \(metadataError\)[\s\S]*?await api\.listDatabases\(props\.connection\.id\)/);
expect(source).toMatch(/catch \{\s*throw metadataError;\s*\}/);
});
});
@@ -83,6 +83,7 @@ import {
editableDatabasePropertyGroups,
supportsDatabaseCreation,
supportsDatabaseSearch,
supportsConnectionDatabaseBrowser,
supportsConnectionQueryActions,
supportsFieldLineage,
supportsObjectBrowserTreeNode,
@@ -1135,8 +1136,10 @@ function requestDeleteSelectedNode(): boolean {
function onDoubleClick(event: MouseEvent) {
if (dataTabOpenModeFromTreeClick(activeNode.value.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return;
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType());
if (action === "open-object-browser") {
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType(), canOpenConnectionDatabaseBrowser.value);
if (action === "open-database-browser") {
void openDatabaseBrowser();
} else if (action === "open-object-browser") {
void openObjectBrowser();
} else if (action === "open-object-browser-and-expand") {
void openObjectBrowser();
@@ -1243,6 +1246,19 @@ async function openObjectBrowser() {
}
}
async function openDatabaseBrowser() {
const node = activeNode.value;
if (node.type !== "connection" || !node.connectionId) return;
try {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
queryStore.openDatabaseBrowser(node.connectionId);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
openDriverStoreForInstallError(e?.message || String(e));
}
}
async function openUserAdmin() {
const node = activeNode.value;
if (!node.connectionId) return;
@@ -3491,6 +3507,10 @@ const canOpenObjectBrowser = computed(() => {
return supportsObjectBrowserTreeNode(rawDatabaseType(), activeNode.value.type);
});
const canOpenConnectionDatabaseBrowser = computed(() => {
return activeNode.value.type === "connection" && supportsConnectionDatabaseBrowser(rawDatabaseType());
});
const canOpenTableImport = computed(() => {
const node = activeNode.value;
const supportedNode = node.type === "table" || ((node.type === "database" || node.type === "schema" || node.type === "group-tables") && canCreateTable.value);
@@ -4106,6 +4126,9 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
if (supportsQueryActions) {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
}
if (canOpenConnectionDatabaseBrowser.value) {
items.push({ label: t("contextMenu.openDatabaseBrowser"), action: openDatabaseBrowser, icon: TableProperties });
}
if (currentDatabaseType() === "redis") {
items.push({ label: t("contextMenu.instanceInfo"), action: openRedisInstanceInfo, icon: Info });
}
+9
View File
@@ -1298,6 +1298,7 @@ export default {
mongo: "Mongo",
gridfs: "GridFS",
vector: "Vector",
databases: "Databases",
objects: "Objects",
users: "Users & Privileges",
executionSummary: "Summary",
@@ -1342,6 +1343,13 @@ export default {
fixedTabs: "Fixed tabs",
openDataTabs: "Open tables",
},
databaseBrowser: {
search: "Search databases...",
empty: "No databases found",
loadFailed: "Could not load databases: {message}",
defaultCharset: "Charset",
defaultCollation: "Collation",
},
gridfsBrowser: {
bucketCount: "buckets",
fileCount: "files",
@@ -2543,6 +2551,7 @@ export default {
sqlHistory: "Query History",
noSqlHistory: "No related SQL",
openObjectBrowser: "Browse Objects",
openDatabaseBrowser: "Browse Databases",
viewData: "View Data",
openInNewDataTab: "View in New Tab",
editStructure: "Edit Structure",
+9
View File
@@ -1275,6 +1275,7 @@ export default withEnglishFallback({
zookeeper: "ZooKeeper",
consul: "Consul",
vector: "Vector",
databases: "Bases de datos",
users: "Usuarios y Privilegios",
executionSummary: "Resumen",
messages: "Mensajes",
@@ -2482,6 +2483,7 @@ export default withEnglishFallback({
sqlHistory: "Historial de consultas",
noSqlHistory: "Sin SQL relacionado",
openObjectBrowser: "Explorar objetos",
openDatabaseBrowser: "Explorar bases de datos",
viewData: "Ver datos",
openInNewDataTab: "Ver en nueva pestaña",
editStructure: "Editar estructura",
@@ -6285,6 +6287,13 @@ export default withEnglishFallback({
registerInstance: "Registrar instancia",
deregister: "Dar de baja",
},
databaseBrowser: {
search: "Buscar bases de datos...",
empty: "No se encontraron bases de datos",
loadFailed: "No se pudieron cargar las bases de datos: {message}",
defaultCharset: "Juego de caracteres",
defaultCollation: "Cotejamiento",
},
gridfsBrowser: {
bucketCount: "Cantidad de buckets",
fileCount: "Archivos",
+9
View File
@@ -1274,6 +1274,7 @@ export default withEnglishFallback({
consul: "Consul",
mongo: "Mongo",
vector: "Vector",
databases: "Database",
objects: "Oggetti",
users: "Utenti e Privilegi",
executionSummary: "Riepilogo",
@@ -2480,6 +2481,7 @@ export default withEnglishFallback({
sqlHistory: "Cronologia Query",
noSqlHistory: "Nessun SQL correlato",
openObjectBrowser: "Esplora Oggetti",
openDatabaseBrowser: "Esplora database",
viewData: "Visualizza Dati",
openInNewDataTab: "Visualizza in nuova scheda",
editStructure: "Modifica Struttura",
@@ -6285,6 +6287,13 @@ export default withEnglishFallback({
registerInstance: "Registra istanza",
deregister: "Rimuovi",
},
databaseBrowser: {
search: "Cerca database...",
empty: "Nessun database trovato",
loadFailed: "Impossibile caricare i database: {message}",
defaultCharset: "Set di caratteri",
defaultCollation: "Regole di confronto",
},
gridfsBrowser: {
bucketCount: "Bucket",
fileCount: "File",
+9
View File
@@ -1293,6 +1293,7 @@ export default withEnglishFallback({
etcd: "etcd",
mongo: "Mongo",
objects: "オブジェクト",
databases: "データベース",
users: "ユーザーと権限",
executionSummary: "サマリー",
messages: "メッセージ",
@@ -2505,6 +2506,7 @@ export default withEnglishFallback({
sqlHistory: "クエリ履歴",
noSqlHistory: "関連するSQLはありません",
openObjectBrowser: "オブジェクトを参照",
openDatabaseBrowser: "データベースを参照",
viewData: "データを表示",
openInNewDataTab: "新しいタブで表示",
editStructure: "構造を編集",
@@ -6340,6 +6342,13 @@ export default withEnglishFallback({
registerInstance: "インスタンスを登録",
deregister: "解除",
},
databaseBrowser: {
search: "データベースを検索...",
empty: "データベースが見つかりません",
loadFailed: "データベースを読み込めませんでした: {message}",
defaultCharset: "文字セット",
defaultCollation: "照合順序",
},
gridfsBrowser: {
bucketCount: "バケット",
fileCount: "ファイル",
+9
View File
@@ -1187,6 +1187,7 @@ export default withEnglishFallback({
mongo: "Mongo",
gridfs: "GridFS",
vector: "Vector",
databases: "데이터베이스",
objects: "객체",
users: "사용자 및 권한",
executionSummary: "요약",
@@ -1231,6 +1232,13 @@ export default withEnglishFallback({
fixedTabs: "고정 탭",
openDataTabs: "열린 테이블",
},
databaseBrowser: {
search: "데이터베이스 검색...",
empty: "데이터베이스를 찾을 수 없습니다",
loadFailed: "데이터베이스를 불러올 수 없습니다: {message}",
defaultCharset: "문자 집합",
defaultCollation: "데이터 정렬",
},
gridfsBrowser: {
bucketCount: "버킷",
fileCount: "파일",
@@ -2400,6 +2408,7 @@ export default withEnglishFallback({
sqlHistory: "쿼리 기록",
noSqlHistory: "관련 SQL이 없습니다",
openObjectBrowser: "객체 탐색",
openDatabaseBrowser: "데이터베이스 탐색",
viewData: "데이터 보기",
openInNewDataTab: "새 탭에서 보기",
editStructure: "구조 편집",
+9
View File
@@ -1275,6 +1275,7 @@ export default withEnglishFallback({
consul: "Consul",
mongo: "Mongo",
vector: "Vector",
databases: "Bancos de dados",
objects: "Objetos",
users: "Usuários e Privilégios",
executionSummary: "Resumo",
@@ -2482,6 +2483,7 @@ export default withEnglishFallback({
sqlHistory: "Histórico de Consultas",
noSqlHistory: "Nenhum SQL relacionado",
openObjectBrowser: "Explorar Objetos",
openDatabaseBrowser: "Explorar bancos de dados",
viewData: "Ver Dados",
openInNewDataTab: "Ver em nova aba",
editStructure: "Editar Estrutura",
@@ -6287,6 +6289,13 @@ export default withEnglishFallback({
registerInstance: "Registrar instância",
deregister: "Remover registro",
},
databaseBrowser: {
search: "Pesquisar bancos de dados...",
empty: "Nenhum banco de dados encontrado",
loadFailed: "Não foi possível carregar os bancos de dados: {message}",
defaultCharset: "Conjunto de caracteres",
defaultCollation: "Agrupamento",
},
gridfsBrowser: {
bucketCount: "Buckets",
fileCount: "Arquivos",
+9
View File
@@ -1223,6 +1223,7 @@ export default withEnglishFallback({
mongo: "Mongo",
gridfs: "GridFS",
vector: "向量",
databases: "数据库",
objects: "对象",
users: "用户与权限",
executionSummary: "摘要",
@@ -1267,6 +1268,13 @@ export default withEnglishFallback({
fixedTabs: "固定标签页",
openDataTabs: "已打开的数据表",
},
databaseBrowser: {
search: "搜索数据库...",
empty: "没有找到数据库",
loadFailed: "加载数据库失败:{message}",
defaultCharset: "默认字符集",
defaultCollation: "默认排序规则",
},
gridfsBrowser: {
bucketCount: "\u5b58\u50a8\u6876",
fileCount: "\u6587\u4ef6",
@@ -2468,6 +2476,7 @@ export default withEnglishFallback({
sqlHistory: "历史查询",
noSqlHistory: "暂无关联 SQL",
openObjectBrowser: "浏览对象",
openDatabaseBrowser: "查看资源对象",
viewData: "查看数据",
openInNewDataTab: "新标签页查看",
editStructure: "编辑表结构",
+9
View File
@@ -1274,6 +1274,7 @@ export default withEnglishFallback({
consul: "Consul",
mongo: "Mongo",
vector: "向量",
databases: "資料庫",
objects: "物件",
users: "使用者與權限",
executionSummary: "摘要",
@@ -2481,6 +2482,7 @@ export default withEnglishFallback({
sqlHistory: "歷史查詢",
noSqlHistory: "暫無關聯 SQL",
openObjectBrowser: "瀏覽物件",
openDatabaseBrowser: "檢視資源物件",
viewData: "檢視資料",
openInNewDataTab: "新分頁檢視",
editStructure: "編輯資料表結構",
@@ -6280,6 +6282,13 @@ export default withEnglishFallback({
summarySize: "大小",
rootReadonly: "根 znode 不能被修改,請填寫子節點路徑",
},
databaseBrowser: {
search: "搜尋資料庫...",
empty: "找不到資料庫",
loadFailed: "載入資料庫失敗:{message}",
defaultCharset: "預設字元集",
defaultCollation: "預設定序規則",
},
gridfsBrowser: {
bucketCount: "儲存桶",
fileCount: "檔案",
@@ -5,6 +5,7 @@ import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql";
import {
isSchemaAware,
supportsConnectionScopedQueryExecution,
supportsConnectionDatabaseBrowser,
supportsDatabaseNameCompletion,
supportsDatabaseSchemaQualifier,
supportsQueryTargetDatabaseListing,
@@ -20,6 +21,13 @@ describe("schema awareness", () => {
});
});
describe("connection database browser", () => {
it("follows object browser support without enabling unsupported connection types", () => {
expect(supportsConnectionDatabaseBrowser("postgres")).toBe(true);
expect(supportsConnectionDatabaseBrowser("redis")).toBe(false);
});
});
describe("connection-scoped query targets", () => {
it("keeps connection-only target types separate from unregistered namespace targets", () => {
expect(usesConnectionOnlyQueryTarget("etcd")).toBe(true);
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { activeTabSidebarTarget, matchesTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { QueryTab, TreeNode } from "@/types/database";
describe("sidebar active-tab targets", () => {
it("selects the connection node for a database-browser tab", () => {
const target = activeTabSidebarTarget({ mode: "databases", connectionId: "pg-1" } as QueryTab);
expect(target).toEqual({ type: "connection", connectionId: "pg-1" });
expect(matchesTarget({ id: "connection:pg-1", label: "Postgres", type: "connection", connectionId: "pg-1" } as TreeNode, target!)).toBe(true);
expect(matchesTarget({ id: "connection:pg-2", label: "Other", type: "connection", connectionId: "pg-2" } as TreeNode, target!)).toBe(false);
});
});
@@ -44,6 +44,11 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle");
});
it("opens the connection database browser only when the capability is enabled", () => {
expect(treeNodeRowDoubleClickAction("connection", false, "single", true, "postgres", true)).toBe("open-database-browser");
expect(treeNodeRowDoubleClickAction("connection", false, "double", true, "postgres", false)).toBe("toggle");
});
it("expands package containers while preserving source behavior for leaf packages", () => {
expect(treeNodeRowAction("package", true)).toBe("toggle");
expect(treeNodeRowAction("package", false)).toBe("open-source");
+1
View File
@@ -137,6 +137,7 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
// Schema
export const listDatabases = forward("listDatabases");
export const listDatabaseMetadata = forward("listDatabaseMetadata");
export const listDatabaseStorage = forward("listDatabaseStorage");
export const getSqlServerCompletionContext = forward("getSqlServerCompletionContext");
export const listDorisCatalogs = forward("listDorisCatalogs");
+4
View File
@@ -648,6 +648,10 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
}
export async function listDatabaseMetadata(connectionId: string): Promise<DatabaseInfo[]> {
return get(`/api/schema/database-metadata?${qs({ connection_id: connectionId })}`);
}
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise<DatabaseStorageInfo[]> {
return post("/api/schema/database-storage", {
connection_id: connectionId,
+4
View File
@@ -928,6 +928,10 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return invoke("list_databases", { connectionId });
}
export async function listDatabaseMetadata(connectionId: string): Promise<DatabaseInfo[]> {
return invoke("list_database_metadata", { connectionId });
}
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise<DatabaseStorageInfo[]> {
return invoke("list_database_storage", { connectionId, databases });
}
@@ -167,6 +167,10 @@ export function supportsObjectBrowser(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "objectBrowser");
}
export function supportsConnectionDatabaseBrowser(dbType?: DatabaseType): boolean {
return supportsObjectBrowser(dbType);
}
export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined, nodeType: TreeNodeType): boolean {
if (!supportsObjectBrowser(dbType)) return false;
if (nodeType === "database" && usesDatabaseObjectTreeMode(dbType)) return true;
@@ -2,6 +2,10 @@ import { SIDEBAR_TREE_ROW_HEIGHT, type FlatTreeNode } from "@/composables/useFla
import type { QueryTab, TreeNode } from "@/types/database";
export type ActiveTabSidebarTarget =
| {
type: "connection";
connectionId: string;
}
| {
type: "table";
connectionId: string;
@@ -80,6 +84,10 @@ export type ActiveTabSidebarTarget =
export function activeTabSidebarTarget(tab: QueryTab | undefined | null): ActiveTabSidebarTarget | null {
if (!tab) return null;
if (tab.mode === "databases") {
return { type: "connection", connectionId: tab.connectionId };
}
if (tab.mode === "data") {
const tableName = tab.tableMeta?.tableName || tab.title;
if (!tableName) return null;
@@ -199,6 +207,10 @@ function schemaMatches(node: TreeNode, schema: string | undefined): boolean {
}
export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): boolean {
if (target.type === "connection") {
return node.type === "connection" && node.connectionId === target.connectionId;
}
if (target.type === "mongo-collection") {
if (node.type === "elasticsearch-index") {
return node.connectionId === target.connectionId && node.label === target.collectionName;
@@ -3,7 +3,7 @@ import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database
import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts";
export type TreeNodeRowAction = "open-data" | "open-source" | "open-extension-details" | "toggle" | "none";
export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-extension-details" | "open-saved-sql" | "toggle" | "none";
export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-database-browser" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-extension-details" | "open-saved-sql" | "toggle" | "none";
export type SidebarSelectionCopyAction = "copy-name" | "none";
export type SidebarActivation = "single" | "double";
@@ -120,10 +120,11 @@ export function shouldRunTreeNodeRowAction(action: TreeNodeRowAction, clickDetai
return action !== "none" && clickDetail <= 1;
}
export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false, dbType?: DatabaseType): TreeNodeRowDoubleClickAction {
export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false, dbType?: DatabaseType, canOpenDatabaseBrowser = false): TreeNodeRowDoubleClickAction {
// Single-click activation already handles the first click in a dblclick
// sequence. Only double-click activation needs a second-stage table action.
if (type === "table") return activation === "double" ? "activate-data" : "none";
if (type === "connection" && canOpenDatabaseBrowser) return "open-database-browser";
if (activation === "double") {
if (type === "extension") return "open-extension-details";
if (dataNodeTypes.has(type)) return "open-data";
@@ -130,6 +130,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (tab.mode === "mqtt") {
return `${connectionDisplayName(tab.connectionId)} - ${t("connection.mqttConsoleTitle")}`;
}
if (tab.mode === "databases") {
if (compact) return t("tabs.databases");
return `${t("tabs.databases")}@${connectionDisplayName(tab.connectionId)}`;
}
if (tab.mode === "objects") {
const schema = tab.objectBrowser?.schema;
if (compact) return schema || tab.title;
@@ -436,6 +440,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "consul") return t("tabs.consul");
if (tab.mode === "consul-overview") return t("consul.ui.overview");
if (tab.mode === "nacos") return "Nacos";
if (tab.mode === "databases") return t("tabs.databases");
if (tab.mode === "objects") return t("tabs.objects");
if (tab.mode === "users") return t("tabs.users");
return tab.mode;
@@ -40,6 +40,20 @@ describe("queryStore database open state", () => {
expect(store.isDatabaseOpen("pg-1", "analytics")).toBe(false);
}, 10_000);
it("reuses one database browser tab per connection", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const firstId = store.openDatabaseBrowser("pg-1");
const secondId = store.openDatabaseBrowser("pg-1");
const otherConnectionId = store.openDatabaseBrowser("pg-2");
expect(secondId).toBe(firstId);
expect(otherConnectionId).not.toBe(firstId);
expect(store.tabs.filter((tab) => tab.mode === "databases")).toHaveLength(2);
expect(store.tabs.find((tab) => tab.id === firstId)).toMatchObject({ connectionId: "pg-1", database: "", mode: "databases" });
});
it("keeps object browser viewport per tab and clears it on schema change", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
+24
View File
@@ -1702,6 +1702,29 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
function openDatabaseBrowser(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "databases" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const id = uuid();
tabs.value.push({
id,
title: "Databases",
connectionId,
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "databases",
});
activeTabId.value = id;
return id;
}
function switchTab(tabId: string) {
activeTabId.value = tabId;
settingsStore.settingsPageActive = false;
@@ -5736,6 +5759,7 @@ export const useQueryStore = defineStore("query", () => {
commitTransaction,
rollbackTransaction,
renameTab,
openDatabaseBrowser,
openObjectBrowser,
openMongoGridFs,
openMongoBucket,
+7
View File
@@ -381,6 +381,12 @@ export interface JdbcPluginStatus {
export interface DatabaseInfo {
name: string;
size_bytes?: number | null;
created_at?: string | null;
updated_at?: string | null;
comment?: string | null;
default_charset?: string | null;
default_collation?: string | null;
}
export interface DatabaseStorageInfo {
@@ -1140,6 +1146,7 @@ export interface QueryTab {
| "mqtt"
| "nacos"
| "nacos-dashboard"
| "databases"
| "objects"
| "structure"
| "users"