feat(desktop): add connection-level database browser
This commit is contained in:
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>
|
||||
+11
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ファイル",
|
||||
|
||||
@@ -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: "구조 편집",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "编辑表结构",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user