feat(postgres): list user-defined types with details and DDL for PG-family databases

This commit is contained in:
amwps290 authored and GitHub committed 2026-08-11 15:00:24 +08:00
1 parent 0c6c1fada2
commit 154c460657
59 files changed
+6538 -150

No files matched your search

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, RefreshCw, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ConnectionConfig, CustomTypeDetails, CustomTypeKind } from "@/types/database";
import * as api from "@/lib/backend/api";
const props = defineProps<{
connection: ConnectionConfig;
database: string;
schema: string;
name: string;
catalog?: string;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const toast = useToast();
const guard = createSidePanelRequestGuard();
const details = ref<CustomTypeDetails | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const tab = ref<"members" | "properties" | "ddl">("members");
const kindLabel = computed(() => (details.value ? t(`customType.kinds.${details.value.kind}`) : ""));
const hasMemberTab = computed(() => details.value?.kind === "composite" || details.value?.kind === "enum");
const tabs = computed(() => {
const items: Array<{ id: "members" | "properties" | "ddl"; label: string }> = [];
if (hasMemberTab.value) items.push({ id: "members", label: t("customType.tabs.members") });
items.push({ id: "properties", label: t("customType.tabs.properties") });
items.push({ id: "ddl", label: t("customType.tabs.ddl") });
return items;
});
async function load() {
// start() bumps the epoch so an in-flight request from a previously selected
// type can never overwrite the current panel state.
const epoch = guard.start();
loading.value = true;
error.value = null;
details.value = null;
tab.value = "members";
try {
const result = await api.getCustomTypeDetails(props.connection.id, props.database, props.schema, props.name);
if (guard.isStale(epoch)) return;
details.value = result;
if (tab.value === "members" && result.kind !== "composite" && result.kind !== "enum") {
tab.value = "properties";
}
} catch (e: any) {
if (guard.isStale(epoch)) return;
error.value = e?.message || String(e);
} finally {
if (guard.isFresh(epoch)) loading.value = false;
}
}
watch(
() => [props.connection.id, props.database, props.schema, props.name] as const,
() => load(),
{ immediate: true },
);
const ddlText = computed(() => details.value?.ddl?.sql ?? "");
async function copyDdl() {
if (!ddlText.value) return;
try {
await copyToClipboard(ddlText.value);
toast.toast(t("contextMenu.ddlCopied"));
} catch (error) {
toast.toast(t("grid.copyFailed", { message: translateBackendError(t, error) }));
}
}
const propertyRows = computed<Array<{ label: string; value: string }>>(() => {
const p = details.value?.properties;
if (!p) return [];
const rows: Array<{ label: string; value: string }> = [];
const push = (label: string, value: string | null | undefined) => {
if (value != null && value !== "") rows.push({ label, value: String(value) });
};
push(t("customType.properties.baseType"), p.baseType);
if (p.notNull) push(t("customType.properties.notNull"), "true");
push(t("customType.properties.default"), p.default);
push(t("customType.properties.collation"), p.collation);
for (const constraint of p.domainConstraints) {
rows.push({
label: constraint.name ? `${t("customType.properties.domainConstraint")} · ${constraint.name}` : t("customType.properties.domainConstraint"),
value: constraint.definition,
});
}
push(t("customType.properties.rangeSubtype"), p.rangeSubtype);
push(t("customType.properties.rangeMultirange"), p.rangeMultirangeName);
return rows;
});
function isFieldKind(kind: CustomTypeKind | undefined): boolean {
return kind === "composite";
}
function isEnumKind(kind: CustomTypeKind | undefined): boolean {
return kind === "enum";
}
function selectTab(next: "members" | "properties" | "ddl") {
tab.value = next;
}
defineExpose({ selectTab });
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-2 border-b px-3 py-1.5 bg-muted/20 h-9 shrink-0">
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ name }}</span>
<span class="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground shrink-0">{{ kindLabel }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('close')">
<X class="w-3 h-3" />
</Button>
</div>
<div v-if="loading" class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
{{ t("common.loading") }}
</div>
<div v-else-if="error" class="flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center">
<div class="text-xs text-destructive break-words max-w-full">{{ error }}</div>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="load">
<RefreshCw class="w-3 h-3" />
{{ t("common.retry") }}
</Button>
</div>
<template v-else-if="details">
<div class="flex items-center gap-1 border-b px-2 py-1 shrink-0">
<button v-for="item in tabs" :key="item.id" type="button" class="rounded px-2 py-1 text-xs" :class="tab === item.id ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'" @click="tab = item.id">
{{ item.label }}
</button>
<span class="flex-1" />
<Button v-if="tab === 'ddl' && ddlText" variant="ghost" size="sm" class="h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyDdl">
<Copy class="w-3 h-3" />
<span class="table-info-action-label">{{ t("grid.copyDdl") }}</span>
</Button>
</div>
<div class="flex-1 min-h-0 overflow-auto">
<!-- Members: empty state for composite/enum -->
<div v-if="tab === 'members' && details.members.length === 0" class="flex flex-col items-center justify-center gap-1 px-4 py-8 text-center">
<span class="text-xs text-muted-foreground">{{ t("customType.members.empty") }}</span>
</div>
<!-- Members: composite fields -->
<table v-else-if="tab === 'members' && isFieldKind(details.kind)" class="w-full text-[11px]">
<thead class="sticky top-0 bg-muted/40 text-left text-muted-foreground">
<tr>
<th class="px-3 py-1.5 font-medium">#</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.name") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.type") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.nullable") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.default") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.comment") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in details.members" :key="member.ordinal" class="border-t border-border/60">
<td class="px-3 py-1.5 text-muted-foreground">{{ member.ordinal }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.name }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.dataType }}</td>
<td class="px-3 py-1.5">{{ member.nullable == null ? "—" : member.nullable ? "✓" : "✗" }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.default ?? "—" }}</td>
<td class="px-3 py-1.5">{{ member.comment ?? "—" }}</td>
</tr>
</tbody>
</table>
<!-- Members: enum values -->
<ul v-else-if="tab === 'members' && isEnumKind(details.kind) && details.members.length > 0" class="divide-y divide-border/60">
<li v-for="member in details.members" :key="member.ordinal" class="flex items-center gap-3 px-3 py-1.5 text-[11px]">
<span class="w-6 text-muted-foreground text-right">{{ member.ordinal }}</span>
<span class="font-mono">{{ member.enumValue }}</span>
</li>
</ul>
<!-- Properties -->
<table v-else-if="tab === 'properties'" class="w-full text-[11px]">
<tbody>
<tr v-if="details.comment" class="border-b border-border/60">
<td class="px-3 py-1.5 text-muted-foreground w-44">{{ t("customType.members.comment") }}</td>
<td class="px-3 py-1.5">{{ details.comment }}</td>
</tr>
<tr v-for="row in propertyRows" :key="row.label" class="border-b border-border/60">
<td class="px-3 py-1.5 text-muted-foreground w-44 align-top">{{ row.label }}</td>
<td class="px-3 py-1.5 font-mono whitespace-pre-wrap break-all">{{ row.value }}</td>
</tr>
<tr v-if="propertyRows.length === 0 && !details.comment" class="border-b border-border/60">
<td class="px-3 py-2 text-xs text-muted-foreground" colspan="2">{{ t("customType.properties.empty") }}</td>
</tr>
</tbody>
</table>
<!-- DDL -->
<div v-else class="flex h-full flex-col">
<div v-if="details.ddl && !details.ddl.complete" class="border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5">
<div v-for="warning in details.ddl.warnings" :key="warning" class="text-[11px] text-amber-600 dark:text-amber-400">{{ warning }}</div>
</div>
<pre class="flex-1 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed">{{ ddlText || t("customType.ddl.empty") }}</pre>
</div>
</div>
</template>
</div>
</template>
@@ -18,6 +18,7 @@ import {
Eraser,
Eye,
FileCode,
Info,
GripVertical,
KeyRound,
LayoutGrid,
@@ -55,6 +56,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
@@ -118,6 +120,7 @@ import {
type ObjectBrowserSortKey,
} from "@/lib/table/objectBrowserRows";
import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
@@ -173,7 +176,7 @@ const sourceCanEdit = ref(true);
// --- Right-side panel state ---
// Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.)
const sidePanelRow = ref<ObjectBrowserRow | null>(null);
const sidePanelMode = ref<"table-info" | "source">("source");
const sidePanelMode = ref<"table-info" | "source" | "type-info">("source");
// Table info panel state
const tableInfoTab = ref<TableInfoTab>("ddl");
const tableColumns = ref<ColumnInfo[]>([]);
@@ -196,6 +199,7 @@ let sidePanelResizeStartX = 0;
let sidePanelResizeStartWidth = 0;
const isResizingSidePanel = ref(false);
const sidePanelGuard = createSidePanelRequestGuard();
const sidePanelRef = ref<InstanceType<typeof CustomTypeInfoPanel> | null>(null);
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getTableMetadataCapabilities(effectiveDatabaseType.value));
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
@@ -822,6 +826,9 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
case "table-info":
void openTableInfo(row);
break;
case "type-info":
void openTypeInfo(row);
break;
case "open-table":
emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog });
break;
@@ -833,7 +840,7 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
const activation = settingsStore.editorSettings.sidebarActivation;
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation);
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value);
// Double click: cancel any pending single-click and fire immediately
if (isDouble) {
if (singleClickTimer) {
@@ -1107,6 +1114,28 @@ function closeSidePanel() {
sidePanelGuard.bump();
}
async function openTypeInfo(row: ObjectBrowserRow) {
if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "type-info") {
closeSidePanel();
return;
}
sidePanelRow.value = row;
sidePanelMode.value = "type-info";
sidePanelGuard.bump();
}
function openTypeDdl(row: ObjectBrowserRow) {
sidePanelRow.value = row;
sidePanelMode.value = "type-info";
sidePanelGuard.bump();
// DDL tab is selected by the panel once details load; switching the tab is
// deferred via a dedicated request so the panel can focus it.
nextTick(() => {
const panel = sidePanelRef.value;
panel?.selectTab("ddl");
});
}
const canOpenTableStructureEditor = computed(() => sidePanelRow.value?.type === "TABLE" && canOpenStructureEditor.value);
function openTableStructureEditor() {
@@ -2737,9 +2766,32 @@ function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
];
}
function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
const items: ContextMenuItem[] = [];
const capabilities = customTypeCapabilities(effectiveDatabaseType.value);
// Verified PG-family types open the read-only details panel; Xugu keeps its
// “view source” entry for TYPE/TYPE_BODY rows.
if (supportsTypeObjectSource(effectiveDatabaseType.value)) {
items.push({ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 });
}
if (capabilities.details) {
items.push({ label: t("contextMenu.viewDetails"), action: () => void openTypeInfo(item), icon: Info });
if (capabilities.ddl) {
items.push({ label: t("contextMenu.viewDdl"), action: () => openTypeDdl(item), icon: FileCode });
}
}
// Only separate when an action precedes copy-name.
if (items.length > 0) {
items.push({ label: "", separator: true });
}
items.push({ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy });
return items;
}
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (item.type === "TABLE") return getTableMenuItems(item);
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
if (item.type === "TYPE" || item.type === "TYPE_BODY") return getTypeMenuItems(item);
if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item);
return getProcFuncMenuItems(item);
}
@@ -3233,6 +3285,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</template>
<!-- Type info mode (read-only user-defined type details) -->
<template v-else-if="sidePanelMode === 'type-info'">
<CustomTypeInfoPanel ref="sidePanelRef" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name || ''" :catalog="props.catalog" @close="closeSidePanel" />
</template>
<!-- Source mode (views, procedures, functions, sequences) -->
<template v-else>
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
@@ -0,0 +1,246 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
import type { ConnectionConfig, CustomTypeDetails } from "@/types/database";
const mocks = vi.hoisted(() => ({
getCustomTypeDetails: vi.fn(),
copyToClipboard: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
getCustomTypeDetails: (...args: unknown[]) => mocks.getCustomTypeDetails(...args),
}));
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return {
Copy: Icon,
RefreshCw: Icon,
X: Icon,
};
});
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
const connection: ConnectionConfig = {
id: "pg-1",
name: "pg",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "test",
password: "secret",
database: Some("demo"),
visible_databases: null,
visible_schemas: null,
show_system_schemas: false,
attached_databases: [],
init_script: null,
color: null,
transport_layers: [],
connection_string: null,
driver_profile: null,
driver_label: null,
note: "",
url_params: null,
agent_java_options: [],
} as unknown as ConnectionConfig;
function Some<T>(value: T): T | null {
return value;
}
function enumDetails(overrides: Partial<CustomTypeDetails> = {}): CustomTypeDetails {
return {
name: "status",
schema: "app",
kind: "enum",
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
],
properties: { domainConstraints: [] },
ddl: { sql: "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');", complete: true, warnings: [] },
...overrides,
};
}
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
function mountPanel(props: Record<string, unknown>) {
root = document.createElement("div");
document.body.append(root);
app = createApp(CustomTypeInfoPanel, props);
app.mount(root);
}
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
async function flush() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function clickButton(text: string) {
const button = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent?.trim() === text);
button?.dispatchEvent(new MouseEvent("click"));
return button;
}
describe("CustomTypeInfoPanel", () => {
it("shows loading while details are being fetched", async () => {
let release!: () => void;
mocks.getCustomTypeDetails.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await nextTick();
expect(root!.textContent).toContain("common.loading");
release();
});
it("renders enum values and complete DDL", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("draft");
expect(root!.textContent).toContain("published");
});
it("shows the explanatory empty state for an enum with no values", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ members: [] }));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("customType.members.empty");
});
it("shows the explanatory empty state for a composite with no fields", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ kind: "composite", members: [], ddl: { sql: "CREATE TYPE ...;", complete: true, warnings: [] } }));
mountPanel({ connection, database: "demo", schema: "app", name: "address" });
await flush();
expect(root!.textContent).toContain("customType.members.empty");
});
it("shows DDL warnings when the DDL is incomplete", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(
enumDetails({
ddl: { sql: "", complete: false, warnings: ["multirange companion; no standalone CREATE"] },
}),
);
mountPanel({ connection, database: "demo", schema: "app", name: "_price_range" });
await flush();
const ddlButton = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent === "customType.tabs.ddl");
ddlButton?.dispatchEvent(new MouseEvent("click"));
await nextTick();
expect(root!.textContent).toContain("multirange companion; no standalone CREATE");
});
it("hides kernel-level type implementation attributes", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(
enumDetails({
kind: "base",
members: [],
properties: {
domainConstraints: [],
inputFunction: "base_in",
alignment: "i",
storage: "x",
},
}),
);
mountPanel({ connection, database: "demo", schema: "app", name: "base_type" });
await flush();
expect(clickButton("customType.tabs.members")).toBeUndefined();
expect(root!.textContent).toContain("customType.tabs.properties");
expect(root!.textContent).not.toContain("base_in");
expect(root!.textContent).not.toContain("customType.properties.alignment");
expect(root!.textContent).not.toContain("customType.properties.storage");
});
it("renders an error state with a working retry button", async () => {
mocks.getCustomTypeDetails.mockRejectedValueOnce(new Error("catalog unavailable"));
mocks.getCustomTypeDetails.mockResolvedValueOnce(enumDetails());
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("catalog unavailable");
clickButton("common.retry");
await vi.waitFor(() => expect(root!.textContent).toContain("draft"));
});
it("reports copy success through the toast", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mocks.copyToClipboard.mockResolvedValue(undefined);
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
clickButton("customType.tabs.ddl");
await flush();
clickButton("grid.copyDdl");
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');"));
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.ddlCopied");
});
it("reports copy failure through the toast", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mocks.copyToClipboard.mockRejectedValue(new Error("denied"));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
clickButton("customType.tabs.ddl");
await flush();
clickButton("grid.copyDdl");
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("grid.copyFailed"));
});
it("ignores a stale response when the type is switched quickly on the same instance", async () => {
let releaseFirst!: () => void;
const first = new Promise<CustomTypeDetails>((resolve) => (releaseFirst = () => resolve(enumDetails({ name: "status" }))));
const second = Promise.resolve(enumDetails({ name: "email", kind: "domain", members: [], properties: { baseType: "text", domainConstraints: [] } }));
mocks.getCustomTypeDetails.mockReturnValueOnce(first).mockReturnValueOnce(second);
const hostName = ref("status");
const Host = defineComponent({
setup() {
return () => h(CustomTypeInfoPanel, { connection, database: "demo", schema: "app", name: hostName.value });
},
});
root = document.createElement("div");
document.body.append(root);
app = createApp(Host);
app.mount(root);
await nextTick();
hostName.value = "email";
await nextTick();
releaseFirst();
await flush();
// The stale "status" response must not overwrite the "email" details.
expect(root!.textContent).toContain("email");
expect(root!.textContent).not.toContain("draft");
});
});
@@ -13,6 +13,7 @@ import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
import { copyNameForTreeNode, objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { copyToClipboard } from "@/lib/common/clipboard";
import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch";
@@ -1338,6 +1339,9 @@ function openSidebarDdlForSelection(): boolean {
function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
// connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(store.getConfig(node.connectionId)?.db_type)) return;
const target = createSidebarActionTarget(node);
const requestGeneration = beginSidebarAction();
void store
@@ -97,6 +97,7 @@ import {
isSingleDatabase,
} from "@/lib/database/databaseCapabilities";
import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
@@ -565,6 +566,9 @@ async function toggle() {
}
return;
}
// PostgreSQL-family custom types without members (enum/domain/range/base) do
// not toggle; only composite types expand into their member list.
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.hasMembers === false) return;
if (node.isLoading) {
if (node.isExpanded) {
node.isExpanded = false;
@@ -593,6 +597,12 @@ async function toggle() {
return;
}
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.children !== undefined) {
node.isExpanded = node.children.length > 0 ? !node.isExpanded : false;
emitNodeToggled(node, wasExpanded);
return;
}
// Keep the click path aligned with every object-group definition. In
// particular, schema-level trigger/type groups have no tableName, so they
// must use the generic object loader rather than the table-trigger loader.
@@ -625,6 +635,12 @@ async function toggle() {
}
try {
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
await connectionStore.loadCustomTypeChildren(node);
emitNodeToggled(node, wasExpanded);
return;
}
if (await loadSidebarObjectGroup(node, connectionStore)) {
emitNodeToggled(node, wasExpanded);
return;
@@ -803,7 +819,7 @@ function runRowClickAction(clickDetail: number) {
openMongoTreeData(node);
return;
}
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation);
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation, currentDatabaseType());
if (!shouldRunTreeNodeRowAction(action, clickDetail, isGroupLabel(node))) return;
if (action === "open-data") {
scheduleOpenData(node);
@@ -1112,7 +1128,7 @@ 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);
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType());
if (action === "open-object-browser") {
void openObjectBrowser();
} else if (action === "open-object-browser-and-expand") {
@@ -1506,6 +1522,28 @@ async function copyName() {
}
}
async function copyCustomTypeDdl() {
const node = createSidebarActionTarget(activeNode.value);
if (!node.connectionId || !node.database) return;
try {
await connectionStore.ensureConnected(node.connectionId);
const details = await api.getCustomTypeDetails(node.connectionId, node.database, node.schema || node.database, node.objectName || node.label);
const sql = details.ddl?.sql?.trim();
if (!sql) {
toast(t("customType.ddl.empty"), 3000);
return;
}
if (!details.ddl?.complete) {
toast(t("customType.ddl.incomplete"), 5000);
return;
}
await copyToClipboard(sql);
toast(t("contextMenu.ddlCopied"), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: translateBackendError(t, error) }), 5000);
}
}
async function copySelectedNames() {
const selectedNodes = selectedTreeNodesInVisibleOrder();
const nodes = selectedNodes.length > 1 && selectedNodes.some((node) => node.id === activeNode.value.id) ? selectedNodes : [activeNode.value];
@@ -1686,6 +1724,9 @@ async function refreshDropTableChildObjectPreviewSql() {
function openObjectSourceDialog(initialEditing: boolean) {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
// connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(currentDatabaseType())) return;
const connectionId = node.connectionId;
const database = node.database;
const sourceTarget = objectSourceTargetForTreeNode(node);
@@ -3408,13 +3449,16 @@ function createMysqlObjectTemplate() {
queryStore.updateSql(tabId, template.sql);
}
const canExpand = computed(() =>
canTreeNodeShowExpander({
const canExpand = computed(() => {
if (activeNode.value.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
return activeNode.value.hasMembers === true;
}
return canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: activeNode.value.xuguTypeMembersExpandable === true,
}),
);
});
});
const canPin = computed(() => canTreeNodePin(activeNode.value.type));
@@ -4702,7 +4746,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "trigger" || node.type === "package" || node.type === "package-body" || node.type === "type" || node.type === "type-body") {
if (node.type === "trigger" || node.type === "package" || node.type === "package-body") {
if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) {
items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench });
}
@@ -4711,6 +4755,23 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
return true;
}
// PostgreSQL-family types expand in the tree and expose DDL as a lightweight
// copy action. Xugu keeps its source/change-open-mode entries.
if (node.type === "type" || node.type === "type-body") {
if (supportsTypeObjectSource(currentDatabaseType())) {
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
return true;
}
if (customTypeCapabilities(currentDatabaseType()).details) {
items.push({ label: t("contextMenu.copyDdl"), action: copyCustomTypeDdl, icon: Copy });
items.push({ label: "", separator: true });
}
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
return true;
}
return false;
}
@@ -49,7 +49,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import type { ColumnInfo, ConnectionConfig, CustomTypeTreeMemberMeta, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
@@ -72,6 +72,7 @@ import { useDragSort } from "@/composables/useDragSort";
import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime";
import { treeNodePinKey } from "@/lib/app/pinnedItems";
import { isTreeGroupNodeType } from "@/lib/sidebar/treeNodeGroup";
import { customTypeCapabilities } from "@/lib/database/databaseObjectCapabilities";
import { shouldActivateTreeNodeOnSingleClick } from "@/lib/sidebar/treeNodeClick";
const { t } = useI18n();
@@ -299,6 +300,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Braces, colorClass: "text-violet-500" };
case "type-body":
return { icon: FileCode, colorClass: "text-violet-400" };
case "type-member":
return { icon: Columns3, colorClass: "text-muted-foreground" };
case "group-tables":
return { icon: Table, colorClass: "text-green-500" };
case "group-views":
@@ -343,6 +346,12 @@ function displayLabel(node: TreeNode): string {
return isGroupLabel(node) ? t(node.label) : node.label;
}
function treeNodeSecondaryValue(node: TreeNode): string | undefined {
if (node.type === "type" && node.customTypeKind) return t(`customType.kinds.${node.customTypeKind}`);
if (node.type === "type-member") return (node.meta as CustomTypeTreeMemberMeta | undefined)?.displayValue;
return undefined;
}
function visibleLabel(node: TreeNode): string {
const withValidity = (label: string) => (node.valid === false ? `${label} · INVALID` : label);
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
@@ -572,13 +581,19 @@ async function cancelConnectionAttempt() {
}
}
const canExpand = computed(() =>
canTreeNodeShowExpander({
const canExpand = computed(() => {
// PostgreSQL-family custom types: only show the expander when the type has
// loadable members; types without members (enum/domain/range/base) do not
// expand even if the catalog row lists child metadata.
if (activeNode.value.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
return activeNode.value.hasMembers === true;
}
return canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
}),
);
});
});
const isPinned = computed(() => activeNode.value.pinned || connectionStore.isTreeNodePinned(activeNode.value));
@@ -1205,6 +1220,7 @@ function onKeydown(event: KeyboardEvent) {
@click.stop
/>
<span v-else ref="labelRef" :class="[labelWidthClass, { 'flex-1': node.type === 'connection' && !trailingComment }]">{{ visibleLabel(node) }}</span>
<span v-if="treeNodeSecondaryValue(node)" class="min-w-0 max-w-[55%] shrink truncate text-xs text-muted-foreground" :title="treeNodeSecondaryValue(node)">{{ treeNodeSecondaryValue(node) }}</span>
<button
v-if="canDragPinnedOrder()"
type="button"
@@ -1226,9 +1242,11 @@ function onKeydown(event: KeyboardEvent) {
node.type === 'group-materialized-views' ||
node.type === 'group-procedures' ||
node.type === 'group-functions' ||
node.type === 'group-triggers' ||
node.type === 'group-sequences' ||
node.type === 'group-synonyms' ||
node.type === 'group-packages' ||
node.type === 'group-types' ||
node.type === 'group-partitions') &&
node.objectCount != null
"