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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user