feat: add SSH config aliases and PostgreSQL extensions
This commit is contained in:
1 parent
8b035d0989
commit
f800d80f90
35 files changed
+1191
-33
No files matched your search
@@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
|
||||
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -200,9 +200,24 @@ function defaultSshTunnel(): SshTunnelConfig {
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: "",
|
||||
auth_method: "password",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers a login method for connections saved before `auth_method` existed
|
||||
* (or imported from a source that never set it), so the dropdown shows a
|
||||
* sensible current state instead of defaulting blindly to "password".
|
||||
* Mirrors the priority `connect_and_authenticate` actually uses at connect
|
||||
* time (key > password > agent > none) — see `db/ssh_tunnel.rs`.
|
||||
*/
|
||||
function inferSshAuthMethod(hop: Partial<SshTunnelConfig>): "password" | "key" | "agent" | "none" {
|
||||
if (hop.key_path?.trim()) return "key";
|
||||
if (hop.password) return "password";
|
||||
if (hop.use_ssh_agent) return "agent";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
|
||||
return {
|
||||
id: hop.id || uuid(),
|
||||
@@ -218,6 +233,7 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
|
||||
expose_lan: !!hop.expose_lan,
|
||||
use_ssh_agent: !!hop.use_ssh_agent,
|
||||
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
|
||||
auth_method: hop.auth_method || inferSshAuthMethod(hop),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,6 +362,7 @@ const mongoUseUrl = ref(false);
|
||||
const jdbcDriverPathsInput = ref("");
|
||||
const jdbcDrivers = ref<JdbcDriverInfo[]>([]);
|
||||
const jdbcMavenBundles = ref<JdbcMavenBundleInfo[]>([]);
|
||||
const sshConfigHosts = ref<SshConfigHostEntry[]>([]);
|
||||
const agentDrivers = ref<AgentDriverInstallState[]>([]);
|
||||
const selectedJdbcDriverPath = ref("");
|
||||
const jdbcManualClasspathOpen = ref(false);
|
||||
@@ -2796,6 +2813,7 @@ watch(
|
||||
if (!props.prefillConfig?.oneTime) {
|
||||
void loadJdbcDrivers();
|
||||
void loadAgentDrivers();
|
||||
void loadSshConfigHosts();
|
||||
}
|
||||
// Preload database names so the summary count is accurate right away.
|
||||
void nextTick(() => {
|
||||
@@ -2930,6 +2948,33 @@ function updateSelectedProxyType(value: unknown) {
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
/**
|
||||
* "agent" is legacy-only: it's never chosen from this dropdown, only ever
|
||||
* inherited from a connection saved before this selector existed. Once the
|
||||
* user picks something else, the option (and its underlying checkbox) is
|
||||
* gone from the form for good.
|
||||
*/
|
||||
function isLegacySshAgentMethod(hop: Partial<SshTunnelConfig> | null | undefined) {
|
||||
return hop?.auth_method === "agent";
|
||||
}
|
||||
|
||||
function updateSelectedSshAuthMethod(value: unknown) {
|
||||
const layer = selectedSshLayer.value;
|
||||
if (!layer) return;
|
||||
layer.auth_method = value === "key" ? "key" : value === "none" ? "none" : "password";
|
||||
// Scrub credential fields that do not apply to the selected method so
|
||||
// they are not accidentally submitted or used by the backend fallback.
|
||||
if (layer.auth_method !== "password") layer.password = "";
|
||||
if (layer.auth_method !== "key") {
|
||||
layer.key_path = "";
|
||||
layer.key_passphrase = "";
|
||||
}
|
||||
if (layer.auth_method !== "key") {
|
||||
layer.use_ssh_agent = false;
|
||||
}
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function validateTransportLayers(config: LegacyConnectionConfig) {
|
||||
const layers = config.transport_layers || [];
|
||||
layers.forEach((layer, index) => {
|
||||
@@ -3013,6 +3058,28 @@ watch([() => editingId.value, () => open.value], () => {
|
||||
dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title");
|
||||
});
|
||||
|
||||
const sshConfigHostAliases = computed(() => sshConfigHosts.value.map((entry) => entry.alias));
|
||||
|
||||
/**
|
||||
* Prefills user/port/key_path from a matching ~/.ssh/config alias, without
|
||||
* overwriting values the user already changed away from the form defaults.
|
||||
* This is a UX preview only — the authoritative resolution happens in the
|
||||
* Rust backend at connect time (see resolve_ssh_tunnel_config), so imported
|
||||
* configs that never touched this UI still resolve correctly.
|
||||
*/
|
||||
function applySshConfigHostAliasPrefill(target: SshTunnelConfig) {
|
||||
const entry = sshConfigHosts.value.find((candidate) => candidate.alias === target.host);
|
||||
if (!entry) return;
|
||||
if (target.user === DEFAULT_SSH_USER && entry.user) target.user = entry.user;
|
||||
if (target.port === 22 && entry.port) target.port = entry.port;
|
||||
if (!target.key_path && entry.identity_file) {
|
||||
target.key_path = entry.identity_file;
|
||||
if ((!target.auth_method || target.auth_method === "password") && !target.password?.trim()) {
|
||||
target.auth_method = "key";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function browseSshKeyPath(target?: SshTunnelConfig | null) {
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
@@ -3229,6 +3296,14 @@ async function loadJdbcDrivers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSshConfigHosts() {
|
||||
try {
|
||||
sshConfigHosts.value = await api.listSshConfigHosts();
|
||||
} catch {
|
||||
sshConfigHosts.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentDrivers() {
|
||||
try {
|
||||
agentDrivers.value = await api.listInstalledAgentsLocal();
|
||||
@@ -4733,7 +4808,10 @@ function openExternalUrl(url: string) {
|
||||
<template v-if="selectedSshLayer">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshHost") }}</Label>
|
||||
<Input v-model="selectedSshLayer.host" class="col-span-2" placeholder="ssh.example.com" :disabled="selectedSshLayer.enabled === false" />
|
||||
<Input v-model="selectedSshLayer.host" class="col-span-2" list="ssh-config-host-aliases" :placeholder="t('connection.sshHostPlaceholder')" :disabled="selectedSshLayer.enabled === false" @change="applySshConfigHostAliasPrefill(selectedSshLayer!)" />
|
||||
<datalist id="ssh-config-host-aliases">
|
||||
<option v-for="alias in sshConfigHostAliases" :key="alias" :value="alias" />
|
||||
</datalist>
|
||||
<Input v-model.number="selectedSshLayer.port" type="number" min="1" max="65535" class="col-span-1" :disabled="selectedSshLayer.enabled === false" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
@@ -4741,10 +4819,20 @@ function openExternalUrl(url: string) {
|
||||
<Input v-model="selectedSshLayer.user" class="col-span-3" placeholder="root" :disabled="selectedSshLayer.enabled === false" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshPassword") }}</Label>
|
||||
<PasswordInput v-model="selectedSshLayer.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAuthMethod") }}</Label>
|
||||
<Select :model-value="selectedSshLayer.auth_method || 'password'" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshAuthMethod">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
|
||||
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
|
||||
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
|
||||
<SelectItem v-if="isLegacySshAgentMethod(selectedSshLayer)" value="agent" disabled>{{ t("connection.sshAuthMethodAgentLegacy") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="selectedSshLayer.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshKeyPath") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="selectedSshLayer.key_path" class="flex-1" placeholder="~/.ssh/id_rsa" :disabled="selectedSshLayer.enabled === false" />
|
||||
@@ -4758,21 +4846,31 @@ function openExternalUrl(url: string) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="selectedSshLayer.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshKeyPassphrase") }}</Label>
|
||||
<PasswordInput v-model="selectedSshLayer.key_passphrase" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" :disabled="selectedSshLayer.enabled === false" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="!selectedSshLayer.auth_method || selectedSshLayer.auth_method === 'password'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshPassword") }}</Label>
|
||||
<PasswordInput v-model="selectedSshLayer.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
|
||||
</div>
|
||||
<div v-if="selectedSshLayer.auth_method === 'none'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="selectedSshLayer.use_ssh_agent" class="mr-0" :disabled="selectedSshLayer.enabled === false" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshUseAgent") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="selectedSshLayer.use_ssh_agent" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAgentSockPath") }}</Label>
|
||||
<Input v-model="selectedSshLayer.ssh_agent_sock_path" class="col-span-3" :placeholder="t('connection.sshAgentSockPathPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.sshAuthMethodNoneHint") }}</p>
|
||||
</div>
|
||||
<template v-if="isLegacySshAgentMethod(selectedSshLayer)">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="selectedSshLayer.use_ssh_agent" class="mr-0" :disabled="selectedSshLayer.enabled === false" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshUseAgent") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="selectedSshLayer.use_ssh_agent" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAgentSockPath") }}</Label>
|
||||
<Input v-model="selectedSshLayer.ssh_agent_sock_path" class="col-span-3" :placeholder="t('connection.sshAgentSockPathPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Loader2, Package, Plus, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import type { ExtensionInfo, TreeNode } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const props = defineProps<{
|
||||
node: TreeNode;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const available = ref<ExtensionInfo[]>([]);
|
||||
const installed = ref<ExtensionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const installing = ref<string | null>(null);
|
||||
const dropping = ref<string | null>(null);
|
||||
|
||||
function show() {
|
||||
open.value = true;
|
||||
void loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [avail, inst] = await Promise.all([api.listAvailableExtensions(props.node.connectionId, props.node.database).catch(() => [] as ExtensionInfo[]), api.listExtensions(props.node.connectionId, props.node.database, props.node.schema || "public").catch(() => [] as ExtensionInfo[])]);
|
||||
available.value = avail;
|
||||
installed.value = inst;
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installExtension(name: string) {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
installing.value = name;
|
||||
try {
|
||||
const sql = buildCreateExtensionSql(name, props.node.schema ?? null);
|
||||
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
|
||||
await loadData();
|
||||
emit("close");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
installing.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function dropExtension(name: string) {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
dropping.value = name;
|
||||
try {
|
||||
const sql = buildDropExtensionSql(name, false);
|
||||
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
dropping.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("extension.manageTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-4">
|
||||
<!-- Left: Available -->
|
||||
<div class="flex flex-col min-h-[300px]">
|
||||
<div class="flex items-center gap-1.5 mb-2 text-sm font-medium text-muted-foreground">
|
||||
<Package class="h-4 w-4" />
|
||||
{{ t("extension.available") }}
|
||||
<span class="ml-auto text-xs">({{ available.length }})</span>
|
||||
</div>
|
||||
<ScrollArea class="flex-1 rounded-md border">
|
||||
<div v-if="available.length === 0" class="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{{ t("extension.noAvailable") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in available" :key="ext.name" class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{{ ext.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ ext.comment || ext.version }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="installing === ext.name" @click="installExtension(ext.name)">
|
||||
<Loader2 v-if="installing === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Plus v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.install") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<!-- Right: Installed -->
|
||||
<div class="flex flex-col min-h-[300px]">
|
||||
<div class="flex items-center gap-1.5 mb-2 text-sm font-medium text-muted-foreground">
|
||||
<Package class="h-4 w-4" />
|
||||
{{ t("extension.installed") }}
|
||||
<span class="ml-auto text-xs">({{ installed.length }})</span>
|
||||
</div>
|
||||
<ScrollArea class="flex-1 rounded-md border">
|
||||
<div v-if="installed.length === 0" class="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{{ t("extension.noInstalled") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in installed" :key="ext.name" class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{{ ext.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ ext.version }}{{ ext.comment ? ` — ${ext.comment}` : "" }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="dropping === ext.name" @click="dropExtension(ext.name)">
|
||||
<Loader2 v-if="dropping === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Trash2 v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.drop") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -127,6 +127,7 @@ import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/li
|
||||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||||
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
|
||||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
@@ -370,6 +371,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: Package, colorClass: "text-cyan-500" };
|
||||
case "group-partitions":
|
||||
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" };
|
||||
case "group-extensions":
|
||||
return { icon: Package, colorClass: "text-violet-500" };
|
||||
case "extension":
|
||||
return { icon: Package, colorClass: "text-violet-400" };
|
||||
case "load-more":
|
||||
return { icon: Plus, colorClass: "text-primary" };
|
||||
default:
|
||||
@@ -377,7 +382,7 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
}
|
||||
}
|
||||
|
||||
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions"]);
|
||||
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions", "group-extensions"]);
|
||||
function isGroupLabel(node: TreeNode): boolean {
|
||||
return groupTypes.has(node.type);
|
||||
}
|
||||
@@ -1752,6 +1757,13 @@ const showEditSchemaCommentDialog = ref(false);
|
||||
const schemaCommentText = ref("");
|
||||
const schemaCommentLoading = ref(false);
|
||||
|
||||
// --- Extension Management ---
|
||||
const installExtensionDialogRef = ref<InstanceType<typeof InstallExtensionDialog> | null>(null);
|
||||
|
||||
function openInstallExtensionDialog(_node: TreeNode) {
|
||||
installExtensionDialogRef.value?.show();
|
||||
}
|
||||
|
||||
// --- Procedure / Function Management ---
|
||||
const showDropObjectConfirm = ref(false);
|
||||
const showProcedureExecutionConfirm = ref(false);
|
||||
@@ -4648,6 +4660,12 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
// 8.5 Extension
|
||||
if (node.type === "extension") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
return items;
|
||||
}
|
||||
|
||||
// 9. Group Labels (group-columns, group-tables, etc.)
|
||||
if (isGroupLabel(node)) {
|
||||
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database);
|
||||
@@ -4664,6 +4682,14 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
if (hasGroupCreateAction) {
|
||||
items.push({ label: "", separator: true });
|
||||
}
|
||||
if (node.type === "group-extensions") {
|
||||
items.push({
|
||||
label: t("contextMenu.manageExtension"),
|
||||
action: () => openInstallExtensionDialog(node),
|
||||
icon: Plus,
|
||||
});
|
||||
items.push({ label: "", separator: true });
|
||||
}
|
||||
if (canLoadAllObjectGroup) {
|
||||
items.push({
|
||||
label: t("contextMenu.expandAll"),
|
||||
@@ -5225,6 +5251,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
<DangerConfirmDialog v-model:open="showDropSchemaConfirm" :title="t('contextMenu.confirmDropSchemaTitle')" :message="t('contextMenu.confirmDropSchemaMessage', { name: node.label })" :sql="dropSchemaPreviewSql" :confirm-label="t('contextMenu.dropSchema')" @confirm="confirmDropSchema" />
|
||||
|
||||
<DdlViewDialog v-if="ddlTarget" :connection-id="ddlTarget.connectionId!" :database="ddlTarget.database!" :schema="ddlTarget.schema" :table-name="ddlTarget.label" :dialect="ddlDialect" :format-dialect="ddlFormatDialect" v-model:open="showDdlDialog" />
|
||||
|
||||
<InstallExtensionDialog ref="installExtensionDialogRef" :node="node" @close="refresh" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in new issue
Block a user