diff --git a/apps/desktop/src/components/objects/__tests__/CustomTypeInfoPanel.component.spec.ts b/apps/desktop/src/components/objects/__tests__/CustomTypeInfoPanel.component.spec.ts
new file mode 100644
index 0000000000..fb3e7254d4
--- /dev/null
+++ b/apps/desktop/src/components/objects/__tests__/CustomTypeInfoPanel.component.spec.ts
@@ -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
(value: T): T | null {
+ return value;
+}
+
+function enumDetails(overrides: Partial = {}): 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 | undefined;
+let root: HTMLDivElement | undefined;
+
+function mountPanel(props: Record) {
+ 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((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((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");
+ });
+});
diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue
index 4ac2f6280a..6e0aafc504 100644
--- a/apps/desktop/src/components/sidebar/ConnectionTree.vue
+++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue
@@ -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
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index 83a6cb0602..b7a07d1f48 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -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;
}
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue
index f58611f64d..7d872d826c 100644
--- a/apps/desktop/src/components/sidebar/TreeItem.vue
+++ b/apps/desktop/src/components/sidebar/TreeItem.vue
@@ -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
/>
{{ visibleLabel(node) }}
+ {{ treeNodeSecondaryValue(node) }}