refactor: migrate all context menus to CustomContextMenu

Replace reka-ui ContextMenu usage in QueryHistory, DataGrid,
AppTabBar, ObjectBrowser, and TreeItem with the lightweight
CustomContextMenu component. This removes the last reka-ui
context menu dependencies and eliminates the performance
overhead of multiple cached ContextMenu instances.
This commit is contained in:
t8y2
2026-05-28 13:24:03 +08:00
parent 6491442192
commit 56c937f844
5 changed files with 2476 additions and 2480 deletions
@@ -6,7 +6,7 @@ import { Clock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "l
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useHistoryStore } from "@/stores/historyStore";
import { useToast } from "@/composables/useToast";
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
@@ -170,6 +170,17 @@ async function rollback(entry: HistoryEntry) {
}
}
function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
return [
{ label: t("history.viewDetails"), action: () => (selectedEntry = entry) },
{ label: t("history.restore"), action: () => restore(entry) },
{ label: t("history.analyzeWithAi"), action: () => emit("analyzeAi", entry), icon: Sparkles },
{ label: t("history.copy"), action: () => copyText(entry.sql) },
...(canRollbackHistoryEntry(entry) ? [{ label: t("history.rollback"), action: () => rollback(entry) }] : []),
{ label: t("history.delete"), action: () => confirmDeleteEntry(entry.id), variant: "destructive" as const },
];
}
onMounted(() => store.load());
</script>
@@ -224,51 +235,35 @@ onMounted(() => store.load());
key-field="id"
>
<template #default="{ item: entry }">
<ContextMenu>
<ContextMenuTrigger as-child>
<div
class="h-[72px] cursor-pointer border-b border-border/50 px-3 py-2 text-xs hover:bg-accent/50"
@click="selectedEntry = entry"
>
<div class="mb-0.5 flex items-center gap-1">
<span
class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground"
>
{{ kindShortLabel(entry) }}
</span>
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
</div>
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
<div class="mt-0.5 flex items-center gap-2">
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3 shrink-0" />
<span class="truncate">
{{ entry.connection_name }}<template v-if="entry.database"> / {{ entry.database }}</template>
</span>
</span>
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
</span>
</div>
<CustomContextMenu :items="getHistoryMenuItems(entry)" v-slot="{ onContextMenu }">
<div
class="h-[72px] cursor-pointer border-b border-border/50 px-3 py-2 text-xs hover:bg-accent/50"
@click="selectedEntry = entry"
@contextmenu="onContextMenu"
>
<div class="mb-0.5 flex items-center gap-1">
<span
class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground"
>
{{ kindShortLabel(entry) }}
</span>
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-44">
<ContextMenuItem @click="selectedEntry = entry">{{ t("history.viewDetails") }}</ContextMenuItem>
<ContextMenuItem @click="restore(entry)">{{ t("history.restore") }}</ContextMenuItem>
<ContextMenuItem @click="emit('analyzeAi', entry)">
<Sparkles class="h-3.5 w-3.5" />
{{ t("history.analyzeWithAi") }}
</ContextMenuItem>
<ContextMenuItem @click="copyText(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
<ContextMenuItem v-if="canRollbackHistoryEntry(entry)" @click="rollback(entry)">{{
t("history.rollback")
}}</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{
t("history.delete")
}}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
<div class="mt-0.5 flex items-center gap-2">
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3 shrink-0" />
<span class="truncate">
{{ entry.connection_name }}<template v-if="entry.database"> / {{ entry.database }}</template>
</span>
</span>
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
</span>
</div>
</div>
</CustomContextMenu>
</template>
</RecycleScroller>
File diff suppressed because it is too large Load Diff
@@ -3,13 +3,7 @@ import { computed, ref, watch, nextTick } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, Package, Check } from "lucide-vue-next";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import {
DropdownMenu,
DropdownMenuContent,
@@ -48,6 +42,38 @@ function toggleCompactTabTitle() {
compactTabTitle.value = !compactTabTitle.value;
}
function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
return [
{
label: t("contextMenu.compactTabTitle"),
action: toggleCompactTabTitle,
icon: Check,
iconClass: compactTabTitle.value ? "opacity-100" : "opacity-0",
},
{ label: "", separator: true },
{
label: tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin"),
action: () => queryStore.togglePinnedTab(tab.id),
icon: Pin,
iconClass: tab.pinned ? "fill-current" : "",
},
{ label: "", separator: true },
{ label: t("contextMenu.closeTab"), action: () => queryStore.closeTab(tab.id), icon: X },
{
label: t("contextMenu.closeOtherTabs"),
action: () => queryStore.closeOtherTabs(tab.id),
disabled: queryStore.tabs.length <= 1,
icon: X,
},
{
label: t("contextMenu.closeAllTabs"),
action: () => queryStore.closeAllTabs(),
variant: "destructive" as const,
icon: X,
},
];
}
const tabsContainerRef = ref<HTMLElement | null>(null);
const { canScrollLeft, canScrollRight, updateScrollButtons } = useTabScroll(tabsContainerRef);
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
@@ -172,8 +198,13 @@ const dataTabsMenuContainerClass = computed(() =>
:style="tabsContainerStyle"
@scroll="updateScrollButtons"
>
<ContextMenu v-for="tab in queryStore.tabs" :key="tab.id">
<ContextMenuTrigger :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''">
<CustomContextMenu
v-for="tab in queryStore.tabs"
:key="tab.id"
:items="getTabMenuItems(tab)"
v-slot="{ onContextMenu }"
>
<div :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''" @contextmenu="onContextMenu">
<Tooltip>
<TooltipTrigger as-child>
<div
@@ -237,33 +268,8 @@ const dataTabsMenuContainerClass = computed(() =>
</template>
</TooltipContent>
</Tooltip>
</ContextMenuTrigger>
<ContextMenuContent class="w-44">
<ContextMenuItem @click="toggleCompactTabTitle">
<Check class="w-3.5 h-3.5 mr-2" :class="compactTabTitle ? 'opacity-100' : 'opacity-0'" />
{{ t("contextMenu.compactTabTitle") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="queryStore.togglePinnedTab(tab.id)">
<Pin class="w-3.5 h-3.5 mr-2" :class="{ 'fill-current': tab.pinned }" />
{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="queryStore.closeTab(tab.id)">
<X class="w-3.5 h-3.5 mr-2" />
{{ t("contextMenu.closeTab") }}
</ContextMenuItem>
<ContextMenuItem :disabled="queryStore.tabs.length <= 1" @click="queryStore.closeOtherTabs(tab.id)">
<X class="w-3.5 h-3.5 mr-2" />
{{ t("contextMenu.closeOtherTabs") }}
</ContextMenuItem>
<ContextMenuItem variant="destructive" @click="queryStore.closeAllTabs">
<X class="w-3.5 h-3.5 mr-2" />
{{ t("contextMenu.closeAllTabs") }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
</CustomContextMenu>
<!-- Driver Store Tab -->
<div
@@ -35,16 +35,7 @@ import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import * as api from "@/lib/api";
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
@@ -1033,6 +1024,120 @@ watch(
},
{ immediate: true },
);
// ---- CustomContextMenu helpers ----
function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
return {
label: t("contextMenu.exportData"),
icon: Download,
children: [
{ label: "CSV", action: () => exportData(item, "csv") },
{ label: "JSON", action: () => exportData(item, "json") },
{ label: "SQL INSERT", action: () => exportData(item, "sql") },
{ label: "XLSX", action: () => exportDataXlsx(item) },
],
};
}
function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
return [
{ label: t("contextMenu.viewData"), action: () => openRow(item), icon: Table2 },
...(canOpenStructureEditor.value
? [{ label: t("contextMenu.editStructure"), action: () => openStructureEditor(item), icon: PencilRuler }]
: []),
...(canRename(item)
? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }]
: []),
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
...(canOpenTableImport.value
? [{ label: t("contextMenu.importData"), action: () => openTableImport(item), icon: FileUp }]
: []),
{ label: t("dataCompare.title"), action: () => openDataCompare(item), icon: ArrowRightLeft },
{ label: "", separator: true },
exportDataSubmenu(item),
{ label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Download },
{ label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
{ label: "", separator: true },
{ label: t("contextMenu.duplicateStructure"), action: () => requestDuplicateStructure(item), icon: CopyPlus },
{ label: "", separator: true },
...(supportsTruncateTable.value
? [
{
label: t("contextMenu.truncateTable"),
action: () => requestTruncateTable(item),
icon: Scissors,
variant: "destructive" as const,
},
]
: []),
{
label: t("contextMenu.emptyTable"),
action: () => requestEmptyTable(item),
icon: Eraser,
variant: "destructive" as const,
},
{
label: t("contextMenu.dropTable"),
action: () => requestDrop(item),
icon: Trash2,
variant: "destructive" as const,
},
{ label: "", separator: true },
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
];
}
function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
return [
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
{ label: t("contextMenu.viewDdl"), action: () => openViewDdl(item), icon: ScrollText },
...(canRename(item)
? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }]
: []),
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
{ label: "", separator: true },
exportDataSubmenu(item),
{ label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Download },
{ label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
{ label: "", separator: true },
{
label: t("contextMenu.dropView"),
action: () => requestDrop(item),
icon: Trash2,
variant: "destructive" as const,
},
{ label: "", separator: true },
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
];
}
function getProcFuncMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
return [
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
...(canRename(item)
? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }]
: []),
{ label: "", separator: true },
{
label: item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction"),
action: () => requestDrop(item),
icon: Trash2,
variant: "destructive" as const,
},
{ label: "", separator: true },
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
];
}
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (item.type === "TABLE") return getTableMenuItems(item);
if (item.type === "VIEW") return getViewMenuItems(item);
return getProcFuncMenuItems(item);
}
</script>
<template>
@@ -1179,182 +1284,50 @@ watch(
key-field="id"
>
<template #default="{ item }">
<ContextMenu>
<ContextMenuTrigger as-child>
<div
class="grid h-[38px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
:class="{
'bg-accent/40': sourceRow?.id === item.id,
'bg-primary/5': selectedTableIds.has(item.id),
}"
:style="{ gridTemplateColumns }"
@click="onRowClick(item, $event)"
<CustomContextMenu :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
<div
class="grid h-[38px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
:class="{
'bg-accent/40': sourceRow?.id === item.id,
'bg-primary/5': selectedTableIds.has(item.id),
}"
:style="{ gridTemplateColumns }"
@click="onRowClick(item, $event)"
@contextmenu="onContextMenu"
>
<button
class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
type="button"
:class="{ invisible: item.type !== 'TABLE' }"
@click.stop="toggleTableSelection(item)"
>
<button
class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
type="button"
:class="{ invisible: item.type !== 'TABLE' }"
@click.stop="toggleTableSelection(item)"
>
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<div class="flex min-w-0 items-center gap-2">
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
<span class="truncate text-[13px] font-medium text-foreground">{{ item.name }}</span>
</div>
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
<div
v-if="hasCreatedAt"
class="truncate text-xs tabular-nums text-muted-foreground"
:title="formatObjectBrowserTimestamp(item.created_at)"
>
{{ formatObjectBrowserTimestamp(item.created_at) }}
</div>
<div
v-if="hasUpdatedAt"
class="truncate text-xs tabular-nums text-muted-foreground"
:title="formatObjectBrowserTimestamp(item.updated_at)"
>
{{ formatObjectBrowserTimestamp(item.updated_at) }}
</div>
<div v-if="hasComments" class="truncate text-xs text-muted-foreground" :title="item.comment || ''">
{{ item.comment || "" }}
</div>
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<div class="flex min-w-0 items-center gap-2">
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
<span class="truncate text-[13px] font-medium text-foreground">{{ item.name }}</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-auto min-w-40">
<!-- TABLE -->
<template v-if="item.type === 'TABLE'">
<ContextMenuItem @click="openRow(item)">
<Table2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewData") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenStructureEditor" @click="openStructureEditor(item)">
<PencilRuler class="w-4 h-4 mr-2" /> {{ t("contextMenu.editStructure") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuItem @click="openNewQuery(item)">
<TerminalSquare class="w-4 h-4 mr-2" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram(item)">
<Network class="w-4 h-4 mr-2" /> {{ t("diagram.open") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenTableImport" @click="openTableImport(item)">
<FileUp class="w-4 h-4 mr-2" /> {{ t("contextMenu.importData") }}
</ContextMenuItem>
<ContextMenuItem @click="openDataCompare(item)">
<ArrowRightLeft class="w-4 h-4 mr-2" /> {{ t("dataCompare.title") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportData") }}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem @click="exportData(item, 'csv')">CSV</ContextMenuItem>
<ContextMenuItem @click="exportData(item, 'json')">JSON</ContextMenuItem>
<ContextMenuItem @click="exportData(item, 'sql')">SQL INSERT</ContextMenuItem>
<ContextMenuItem @click="exportDataXlsx(item)">XLSX</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem @click="openDatabaseExport(item)">
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportDatabase") }}
</ContextMenuItem>
<ContextMenuItem @click="exportStructure(item)">
<FileCode class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportStructure") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="requestDuplicateStructure(item)">
<CopyPlus class="w-4 h-4 mr-2" /> {{ t("contextMenu.duplicateStructure") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
v-if="supportsTruncateTable"
class="text-destructive"
@click="requestTruncateTable(item)"
>
<Scissors class="w-4 h-4 mr-2" /> {{ t("contextMenu.truncateTable") }}
</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="requestEmptyTable(item)">
<Eraser class="w-4 h-4 mr-2" /> {{ t("contextMenu.emptyTable") }}
</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropTable") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="copyName(item)">
<Copy class="w-4 h-4 mr-2" /> {{ t("contextMenu.copyName") }}
</ContextMenuItem>
</template>
<!-- VIEW -->
<template v-else-if="item.type === 'VIEW'">
<ContextMenuItem @click="openViewData(item)">
<Table2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewData") }}
</ContextMenuItem>
<ContextMenuItem @click="openSource(item)">
<Code2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewSource") }}
</ContextMenuItem>
<ContextMenuItem @click="openViewDdl(item)">
<ScrollText class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewDdl") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuItem @click="openNewQuery(item)">
<TerminalSquare class="w-4 h-4 mr-2" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram(item)">
<Network class="w-4 h-4 mr-2" /> {{ t("diagram.open") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportData") }}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem @click="exportData(item, 'csv')">CSV</ContextMenuItem>
<ContextMenuItem @click="exportData(item, 'json')">JSON</ContextMenuItem>
<ContextMenuItem @click="exportData(item, 'sql')">SQL INSERT</ContextMenuItem>
<ContextMenuItem @click="exportDataXlsx(item)">XLSX</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem @click="openDatabaseExport(item)">
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportDatabase") }}
</ContextMenuItem>
<ContextMenuItem @click="exportStructure(item)">
<FileCode class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportStructure") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropView") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="copyName(item)">
<Copy class="w-4 h-4 mr-2" /> {{ t("contextMenu.copyName") }}
</ContextMenuItem>
</template>
<!-- PROCEDURE / FUNCTION -->
<template v-else>
<ContextMenuItem @click="openSource(item)">
<Code2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewSource") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" />
{{ item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="copyName(item)">
<Copy class="w-4 h-4 mr-2" /> {{ t("contextMenu.copyName") }}
</ContextMenuItem>
</template>
</ContextMenuContent>
</ContextMenu>
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
<div
v-if="hasCreatedAt"
class="truncate text-xs tabular-nums text-muted-foreground"
:title="formatObjectBrowserTimestamp(item.created_at)"
>
{{ formatObjectBrowserTimestamp(item.created_at) }}
</div>
<div
v-if="hasUpdatedAt"
class="truncate text-xs tabular-nums text-muted-foreground"
:title="formatObjectBrowserTimestamp(item.updated_at)"
>
{{ formatObjectBrowserTimestamp(item.updated_at) }}
</div>
<div v-if="hasComments" class="truncate text-xs text-muted-foreground" :title="item.comment || ''">
{{ item.comment || "" }}
</div>
</div>
</CustomContextMenu>
</template>
</RecycleScroller>
<div v-if="sourceRow" class="flex h-[42%] min-h-44 shrink-0 flex-col border-t bg-background">
+411 -412
View File
@@ -45,16 +45,7 @@ import {
Code2,
ListFilter,
} from "lucide-vue-next";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
} from "@/components/ui/context-menu";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -1809,416 +1800,424 @@ const showDropInside = computed(
() => dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "inside",
);
const isDragging = computed(() => dragState.active && dragState.draggedId === props.node.id);
// ---- CustomContextMenu ----
function exportDataSubmenu(): ContextMenuItem {
return {
label: t("contextMenu.exportData"),
icon: Download,
children: [
{ label: "CSV", action: () => exportData("csv") },
{ label: "JSON", action: () => exportData("json") },
{ label: "SQL INSERT", action: () => exportData("sql") },
{ label: "XLSX", action: () => exportDataXlsx() },
],
};
}
function treeItemMenuItems(): ContextMenuItem[] {
const node = props.node;
const items: ContextMenuItem[] = [];
// 1. Pin toggle
if (canPin.value) {
items.push({
label: isPinned.value ? t("contextMenu.unpin") : t("contextMenu.pin"),
action: togglePin,
icon: Pin,
});
if (hasTypeMenu.value) items.push({ label: "", separator: true });
}
// 2. Connection
if (node.type === "connection") {
if (!isConnected.value) {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Plug });
} else {
items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug });
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (canOpenSqlFileExecution.value) {
items.push({ label: t("sqlFile.title"), action: openSqlFileExecution, icon: FileCode });
}
if (canCreateDatabase.value) {
items.push({
label: isDuckDbConnection.value ? t("contextMenu.createDuckDbFile") : t("contextMenu.createDatabase"),
action: openCreateDatabase,
icon: Plus,
});
}
items.push({ label: "", separator: true });
if (availableGroups.value.length > 0 || currentGroupId.value) {
const groupChildren: ContextMenuItem[] = [
...availableGroups.value.map((group: { id: string; name: string }) => ({
label: group.name,
action: () => moveToGroup(group.id),
icon: FolderOpen,
disabled: group.id === currentGroupId.value,
})),
];
if (currentGroupId.value) {
groupChildren.push({ label: "", separator: true });
groupChildren.push({ label: t("connectionGroup.ungrouped"), action: () => moveToGroup(null) });
}
groupChildren.push({ label: "", separator: true });
groupChildren.push({ label: t("connectionGroup.newGroup"), action: moveToNewGroup, icon: FolderPlus });
items.push({ label: t("connectionGroup.moveToGroup"), icon: FolderInput, children: groupChildren });
} else {
items.push({ label: t("connectionGroup.moveToNewGroup"), action: moveToNewGroup, icon: FolderPlus });
}
items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw });
if (canConfigureVisibleDatabases.value) {
items.push({
label: t("contextMenu.selectVisibleDatabases"),
action: openVisibleDatabasesDialog,
icon: ListFilter,
});
}
items.push({ label: t("contextMenu.editConnection"), action: editConnection, icon: Pencil });
items.push({ label: t("contextMenu.duplicateConnection"), action: duplicateConnection, icon: CopyPlus });
items.push({ label: "", separator: true });
items.push({
label: t("contextMenu.deleteConnection"),
action: deleteConnection,
icon: Trash2,
variant: "destructive" as const,
});
return items;
}
// 3. Connection Group
if (node.type === "connection-group") {
items.push({ label: t("toolbar.newConnection"), action: newConnectionInGroup, icon: Plus });
items.push({ label: "", separator: true });
items.push({ label: t("connectionGroup.renameGroup"), action: startRenameGroup, icon: Pencil });
items.push({ label: "", separator: true });
items.push({
label: t("connectionGroup.deleteGroup"),
action: deleteConnectionGroup,
icon: Trash2,
variant: "destructive" as const,
});
return items;
}
// 4. Database / Schema
if (node.type === "database" || node.type === "schema") {
if (canOpenObjectBrowser.value) {
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (node.type === "database") {
if (!isNodeDefaultDatabase.value) {
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });
} else {
items.push({ label: t("contextMenu.clearDefaultDatabase"), action: clearNodeDefaultDatabase, icon: Database });
}
}
if (canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
}
if (canCreateSchema.value) {
items.push({ label: t("contextMenu.createSchema"), action: openCreateSchemaDialog, icon: Plus });
}
if (canOpenSqlFileExecution.value) {
items.push({ label: t("sqlFile.title"), action: openSqlFileExecution, icon: FileCode });
}
if (canOpenDiagram.value) {
items.push({ label: t("diagram.open"), action: openDiagram, icon: Network });
}
if (canOpenDatabaseSearch.value) {
items.push({ label: t("databaseSearch.open"), action: openDatabaseSearch, icon: Search });
}
items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw });
items.push({ label: "", separator: true });
items.push({ label: t("transfer.dataTransfer"), action: openTransfer, icon: ArrowRightLeft });
items.push({ label: t("diff.title"), action: openSchemaDiff, icon: ArrowRightLeft });
items.push({ label: t("dataCompare.title"), action: openDataCompare, icon: ArrowRightLeft });
items.push({ label: t("contextMenu.exportDatabase"), action: openDatabaseExport, icon: Download });
if (canDropDatabase.value || canDropSchema.value) {
items.push({ label: "", separator: true });
}
if (canDropDatabase.value) {
items.push({
label: t("contextMenu.dropDatabase"),
action: dropDatabase,
icon: Trash2,
variant: "destructive" as const,
});
}
if (canDropSchema.value) {
items.push({
label: t("contextMenu.dropSchema"),
action: dropSchema,
icon: Trash2,
variant: "destructive" as const,
});
}
return items;
}
// 5. Redis DB / Mongo DB
if (node.type === "redis-db" || node.type === "mongo-db") {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (!isNodeDefaultDatabase.value) {
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });
} else {
items.push({ label: t("contextMenu.clearDefaultDatabase"), action: clearNodeDefaultDatabase, icon: Database });
}
if (node.type === "redis-db") {
items.push({ label: "", separator: true });
items.push({ label: t("redis.flushDb"), action: flushRedisDb, icon: Eraser, variant: "destructive" as const });
}
return items;
}
// 6. Table / View
if (node.type === "table" || node.type === "view") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.viewData"), action: openData, icon: TableProperties });
if (node.type === "view") {
items.push({ label: t("contextMenu.viewSource"), action: viewObjectSource, icon: Code2 });
items.push({ label: t("contextMenu.viewDdl"), action: viewObjectDdl, icon: FileCode });
}
if (canOpenStructureEditor.value) {
items.push({ label: t("contextMenu.editStructure"), action: openStructureEditor, icon: PencilRuler });
}
if (canRenameObject.value) {
items.push({ label: t("contextMenu.renameObject"), action: openRenameObjectDialog, icon: Pencil });
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (canOpenDiagram.value) {
items.push({ label: t("diagram.open"), action: openDiagram, icon: Network });
}
if (canOpenTableImport.value) {
items.push({ label: t("contextMenu.importData"), action: openTableImport, icon: FileUp });
}
if (isTableNotView.value) {
items.push({ label: t("dataCompare.title"), action: openDataCompare, icon: ArrowRightLeft });
}
items.push({ label: "", separator: true });
items.push(exportDataSubmenu());
items.push({ label: t("contextMenu.exportDatabase"), action: openDatabaseExport, icon: Download });
items.push({ label: t("contextMenu.exportStructure"), action: exportStructure, icon: FileCode });
if (isTableNotView.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus });
items.push({ label: "", separator: true });
if (supportsTruncate.value) {
items.push({
label: t("contextMenu.truncateTable"),
action: truncateTable,
icon: Scissors,
variant: "destructive" as const,
});
}
items.push({
label: t("contextMenu.emptyTable"),
action: emptyTable,
icon: Eraser,
variant: "destructive" as const,
});
items.push({
label: t("contextMenu.dropTable"),
action: dropTable,
icon: Trash2,
variant: "destructive" as const,
});
}
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw });
return items;
}
// 7. Column
if (node.type === "column") {
if (canOpenFieldLineage.value) {
items.push({ label: t("lineage.open"), action: openFieldLineage, icon: Network });
}
return items;
}
// 8. Procedure / Function
if (node.type === "procedure" || node.type === "function") {
items.push({ label: t("contextMenu.viewSource"), action: viewObjectSource, icon: Code2 });
if (canRenameObject.value) {
items.push({ label: t("contextMenu.renameObject"), action: openRenameObjectDialog, icon: Pencil });
}
items.push({ label: "", separator: true });
items.push({
label: node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction"),
action: requestDropObject,
icon: Trash2,
variant: "destructive" as const,
});
return items;
}
// 9. Group Labels (saved-sql-root, saved-sql-folder, group-columns, etc.)
if (isGroupLabel(node)) {
if (node.type === "saved-sql-root") {
items.push({ label: t("savedSql.newFolder"), action: openCreateSavedSqlFolder, icon: FolderPlus });
}
if (node.type === "saved-sql-folder") {
items.push({ label: t("savedSql.renameFolder"), action: openRenameSavedSqlFolder, icon: Pencil });
items.push({
label: t("savedSql.deleteFolder"),
action: deleteSavedSqlFolder,
icon: Trash2,
variant: "destructive" as const,
});
items.push({ label: "", separator: true });
}
if (node.type !== "saved-sql-root" && node.type !== "saved-sql-folder") {
items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw });
}
return items;
}
// 10. Saved SQL File
if (node.type === "saved-sql-file") {
items.push({ label: t("savedSql.open"), action: openSavedSqlFile, icon: FileText });
items.push({ label: t("savedSql.renameFile"), action: openRenameSavedSqlFile, icon: Pencil });
items.push({ label: "", separator: true });
items.push({
label: t("savedSql.deleteFile"),
action: deleteSavedSqlFile,
icon: Trash2,
variant: "destructive" as const,
});
return items;
}
// 11. Universal Copy Name (for all types except connection)
if (node.type !== "connection" && hasTypeMenu.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy });
}
return items;
}
</script>
<template>
<ContextMenu>
<ContextMenuTrigger as-child>
<div>
<CustomContextMenu :items="treeItemMenuItems()" v-slot="{ onContextMenu }">
<div @contextmenu="onContextMenu">
<div
ref="rowRef"
class="group flex min-w-0 items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
style="contain: layout style paint"
:class="{
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
'opacity-50': isDragging,
'rounded-none': connectionColor && !isSelected,
'rounded-sm': !connectionColor && !isSelected,
'tree-item-active rounded-md': isSelected,
}"
:tabindex="isSelected ? 0 : -1"
:style="rowStyle"
@click="onClick"
@dblclick="onDoubleClick"
@keydown="onKeydown"
@mousedown="isDraggable ? startDrag($event, node.id, node.type) : undefined"
@mousemove="isDropTarget ? updateTarget($event, node.id, node.type) : undefined"
@mouseleave="clearTarget(node.id)"
>
<div
ref="rowRef"
class="group flex min-w-0 items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
style="contain: layout style paint"
:class="{
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
'opacity-50': isDragging,
'rounded-none': connectionColor && !isSelected,
'rounded-sm': !connectionColor && !isSelected,
'tree-item-active rounded-md': isSelected,
}"
:tabindex="isSelected ? 0 : -1"
:style="rowStyle"
@click="onClick"
@dblclick="onDoubleClick"
@keydown="onKeydown"
@mousedown="isDraggable ? startDrag($event, node.id, node.type) : undefined"
@mousemove="isDropTarget ? updateTarget($event, node.id, node.type) : undefined"
@mouseleave="clearTarget(node.id)"
>
<div
v-if="showDropBefore"
class="absolute right-2 top-0 h-0.5 bg-primary rounded-full pointer-events-none"
:style="{ left: paddingLeft }"
/>
<div
v-if="showDropAfter"
class="absolute right-2 bottom-0 h-0.5 bg-primary rounded-full pointer-events-none"
:style="{ left: paddingLeft }"
/>
<template v-if="canExpand">
<button
type="button"
class="-m-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
@click.stop="toggle"
>
<Loader2 v-if="node.isLoading" class="w-3.5 h-3.5 animate-spin" />
<ChevronDown v-else-if="node.isExpanded" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />
</button>
</template>
<span v-else class="w-3.5 h-3.5 shrink-0" />
<DatabaseIcon
v-if="node.type === 'connection'"
:db-type="connectionIconType(node.connectionId)"
class="w-3.5 h-3.5 shrink-0"
/>
<component
v-else
:is="getIconInfo(node)?.icon || Database"
class="w-3.5 h-3.5 shrink-0"
:class="getIconInfo(node)?.colorClass"
/>
<input
v-if="isRenamingGroup"
ref="renameInputRef"
v-model="renameInput"
class="min-w-0 flex-1 truncate bg-transparent border border-primary/50 rounded px-1 outline-none"
@blur="finishRenameGroup"
@keydown.enter.prevent="finishRenameGroup"
@keydown.escape.prevent="isRenamingGroup = false"
@click.stop
/>
<Tooltip v-else :disabled="isTooltipDisabled(node)">
<TooltipTrigger as-child>
<span ref="labelRef" class="min-w-0 flex-1 truncate">{{ visibleLabel(node) }}</span>
</TooltipTrigger>
<TooltipContent side="right" :side-offset="8">{{ displayLabel(node) }}</TooltipContent>
</Tooltip>
<span
v-if="
(node.type === 'group-tables' ||
node.type === 'group-views' ||
node.type === 'group-procedures' ||
node.type === 'group-functions') &&
node.objectCount != null
"
class="text-muted-foreground text-[10px] shrink-0"
>{{ node.objectCount }}</span
>
<Badge v-if="isNodeDefaultDatabase" variant="secondary" class="h-4 px-1.5 text-[10px]">
{{ t("editor.defaultDatabase") }}
</Badge>
<span v-if="columnComment" class="truncate text-muted-foreground/60 text-[10px] max-w-[40%]">{{
columnComment
}}</span>
<span
v-if="tableComment && !settingsStore.editorSettings.sidebarHideTableComments"
class="truncate text-muted-foreground/60 text-[10px] max-w-[25%] group-hover:hidden"
:title="tableComment"
>{{ tableComment }}</span
>
<span
v-if="
node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)
"
class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0"
/>
<ConnectionErrorIndicator
v-if="node.type === 'connection'"
:connection-id="node.connectionId"
trigger-class="h-4 w-4"
/>
v-if="showDropBefore"
class="absolute right-2 top-0 h-0.5 bg-primary rounded-full pointer-events-none"
:style="{ left: paddingLeft }"
/>
<div
v-if="showDropAfter"
class="absolute right-2 bottom-0 h-0.5 bg-primary rounded-full pointer-events-none"
:style="{ left: paddingLeft }"
/>
<template v-if="canExpand">
<button
v-if="canPin"
class="rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus:opacity-100"
:class="isPinned ? 'opacity-100 text-primary' : 'opacity-0 group-hover:opacity-100'"
:title="isPinned ? t('contextMenu.unpin') : t('contextMenu.pin')"
@click.stop="togglePin"
type="button"
class="-m-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
@click.stop="toggle"
>
<Pin class="w-3 h-3" :class="{ 'fill-current': isPinned }" />
<Loader2 v-if="node.isLoading" class="w-3.5 h-3.5 animate-spin" />
<ChevronDown v-else-if="node.isExpanded" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />
</button>
</div>
</template>
<span v-else class="w-3.5 h-3.5 shrink-0" />
<DatabaseIcon
v-if="node.type === 'connection'"
:db-type="connectionIconType(node.connectionId)"
class="w-3.5 h-3.5 shrink-0"
/>
<component
v-else
:is="getIconInfo(node)?.icon || Database"
class="w-3.5 h-3.5 shrink-0"
:class="getIconInfo(node)?.colorClass"
/>
<input
v-if="isRenamingGroup"
ref="renameInputRef"
v-model="renameInput"
class="min-w-0 flex-1 truncate bg-transparent border border-primary/50 rounded px-1 outline-none"
@blur="finishRenameGroup"
@keydown.enter.prevent="finishRenameGroup"
@keydown.escape.prevent="isRenamingGroup = false"
@click.stop
/>
<Tooltip v-else :disabled="isTooltipDisabled(node)">
<TooltipTrigger as-child>
<span ref="labelRef" class="min-w-0 flex-1 truncate">{{ visibleLabel(node) }}</span>
</TooltipTrigger>
<TooltipContent side="right" :side-offset="8">{{ displayLabel(node) }}</TooltipContent>
</Tooltip>
<span
v-if="
(node.type === 'group-tables' ||
node.type === 'group-views' ||
node.type === 'group-procedures' ||
node.type === 'group-functions') &&
node.objectCount != null
"
class="text-muted-foreground text-[10px] shrink-0"
>{{ node.objectCount }}</span
>
<Badge v-if="isNodeDefaultDatabase" variant="secondary" class="h-4 px-1.5 text-[10px]">
{{ t("editor.defaultDatabase") }}
</Badge>
<span v-if="columnComment" class="truncate text-muted-foreground/60 text-[10px] max-w-[40%]">{{
columnComment
}}</span>
<span
v-if="tableComment && !settingsStore.editorSettings.sidebarHideTableComments"
class="truncate text-muted-foreground/60 text-[10px] max-w-[25%] group-hover:hidden"
:title="tableComment"
>{{ tableComment }}</span
>
<span
v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)"
class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0"
/>
<ConnectionErrorIndicator
v-if="node.type === 'connection'"
:connection-id="node.connectionId"
trigger-class="h-4 w-4"
/>
<button
v-if="canPin"
class="rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus:opacity-100"
:class="isPinned ? 'opacity-100 text-primary' : 'opacity-0 group-hover:opacity-100'"
:title="isPinned ? t('contextMenu.unpin') : t('contextMenu.pin')"
@click.stop="togglePin"
>
<Pin class="w-3 h-3" :class="{ 'fill-current': isPinned }" />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-auto min-w-36" @close-auto-focus="onContextMenuCloseAutoFocus">
<ContextMenuItem v-if="canPin" @click="togglePin">
<Pin class="w-4 h-4" :class="{ 'fill-current': isPinned }" />
{{ isPinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}
</ContextMenuItem>
<ContextMenuSeparator v-if="canPin && hasTypeMenu" />
<template v-if="node.type === 'connection'">
<ContextMenuItem v-if="!isConnected" @click="toggle">
<Plug class="w-4 h-4" /> {{ t("contextMenu.openConnection") }}
</ContextMenuItem>
<ContextMenuItem v-else @click="disconnectConnection">
<Unplug class="w-4 h-4" /> {{ t("contextMenu.closeConnection") }}
</ContextMenuItem>
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
<FileCode class="w-4 h-4" /> {{ t("sqlFile.title") }}
</ContextMenuItem>
<ContextMenuItem v-if="canCreateDatabase" @click="openCreateDatabase">
<Plus class="w-4 h-4" />
{{ isDuckDbConnection ? t("contextMenu.createDuckDbFile") : t("contextMenu.createDatabase") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub v-if="availableGroups.length > 0 || currentGroupId">
<ContextMenuSubTrigger>
<FolderInput class="w-4 h-4" /> {{ t("connectionGroup.moveToGroup") }}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
v-for="group in availableGroups"
:key="group.id"
:disabled="group.id === currentGroupId"
@click="moveToGroup(group.id)"
>
<FolderOpen class="w-4 h-4" /> {{ group.name }}
</ContextMenuItem>
<ContextMenuSeparator v-if="currentGroupId" />
<ContextMenuItem v-if="currentGroupId" @click="moveToGroup(null)">
{{ t("connectionGroup.ungrouped") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="moveToNewGroup">
<FolderPlus class="w-4 h-4" /> {{ t("connectionGroup.newGroup") }}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem v-else @click="moveToNewGroup">
<FolderPlus class="w-4 h-4" /> {{ t("connectionGroup.moveToNewGroup") }}
</ContextMenuItem>
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
</ContextMenuItem>
<ContextMenuItem v-if="canConfigureVisibleDatabases" @click="openVisibleDatabasesDialog">
<ListFilter class="w-4 h-4" /> {{ t("contextMenu.selectVisibleDatabases") }}
</ContextMenuItem>
<ContextMenuItem @click="editConnection">
<Pencil class="w-4 h-4" /> {{ t("contextMenu.editConnection") }}
</ContextMenuItem>
<ContextMenuItem @click="duplicateConnection">
<CopyPlus class="w-4 h-4" /> {{ t("contextMenu.duplicateConnection") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="deleteConnection">
<Trash2 class="w-4 h-4" /> {{ t("contextMenu.deleteConnection") }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'connection-group'">
<ContextMenuItem @click="newConnectionInGroup">
<Plus class="w-4 h-4" /> {{ t("toolbar.newConnection") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @select="startRenameGroup">
<Pencil class="w-4 h-4" /> {{ t("connectionGroup.renameGroup") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="deleteConnectionGroup">
<Trash2 class="w-4 h-4" /> {{ t("connectionGroup.deleteGroup") }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'database' || node.type === 'schema'">
<ContextMenuItem v-if="canOpenObjectBrowser" @click="openObjectBrowser">
<TableProperties class="w-4 h-4" /> {{ t("contextMenu.openObjectBrowser") }}
</ContextMenuItem>
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
<ContextMenuItem v-if="node.type === 'database' && !isNodeDefaultDatabase" @click="setNodeAsDefaultDatabase">
<Database class="w-4 h-4" /> {{ t("contextMenu.setDefaultDatabase") }}
</ContextMenuItem>
<ContextMenuItem v-if="node.type === 'database' && isNodeDefaultDatabase" @click="clearNodeDefaultDatabase">
<Database class="w-4 h-4" /> {{ t("contextMenu.clearDefaultDatabase") }}
</ContextMenuItem>
<ContextMenuItem v-if="canCreateTable" @click="createTable">
<Plus class="w-4 h-4" /> {{ t("contextMenu.createTable") }}
</ContextMenuItem>
<ContextMenuItem v-if="canCreateSchema" @click="openCreateSchemaDialog">
<Plus class="w-4 h-4" /> {{ t("contextMenu.createSchema") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
<FileCode class="w-4 h-4" /> {{ t("sqlFile.title") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
<Network class="w-4 h-4" /> {{ t("diagram.open") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenDatabaseSearch" @click="openDatabaseSearch">
<Search class="w-4 h-4" /> {{ t("databaseSearch.open") }}
</ContextMenuItem>
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="openTransfer">
<ArrowRightLeft class="w-4 h-4" /> {{ t("transfer.dataTransfer") }}
</ContextMenuItem>
<ContextMenuItem @click="openSchemaDiff">
<ArrowRightLeft class="w-4 h-4" /> {{ t("diff.title") }}
</ContextMenuItem>
<ContextMenuItem @click="openDataCompare">
<ArrowRightLeft class="w-4 h-4" /> {{ t("dataCompare.title") }}
</ContextMenuItem>
<ContextMenuItem @click="openDatabaseExport">
<Download class="w-4 h-4" />
{{ t("contextMenu.exportDatabase") }}
</ContextMenuItem>
<template v-if="canDropDatabase || canDropSchema">
<ContextMenuSeparator />
<ContextMenuItem v-if="canDropDatabase" class="text-destructive" @click="dropDatabase">
<Trash2 class="w-4 h-4" /> {{ t("contextMenu.dropDatabase") }}
</ContextMenuItem>
<ContextMenuItem v-if="canDropSchema" class="text-destructive" @click="dropSchema">
<Trash2 class="w-4 h-4" /> {{ t("contextMenu.dropSchema") }}
</ContextMenuItem>
</template>
</template>
<template v-if="node.type === 'redis-db' || node.type === 'mongo-db'">
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
<ContextMenuItem v-if="!isNodeDefaultDatabase" @click="setNodeAsDefaultDatabase">
<Database class="w-4 h-4" /> {{ t("contextMenu.setDefaultDatabase") }}
</ContextMenuItem>
<ContextMenuItem v-if="isNodeDefaultDatabase" @click="clearNodeDefaultDatabase">
<Database class="w-4 h-4" /> {{ t("contextMenu.clearDefaultDatabase") }}
</ContextMenuItem>
<template v-if="node.type === 'redis-db'">
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="flushRedisDb">
<Eraser class="w-4 h-4" /> {{ t("redis.flushDb") }}
</ContextMenuItem>
</template>
</template>
<template v-if="node.type === 'table' || node.type === 'view'">
<ContextMenuItem @click="copyName"> <Copy class="w-4 h-4" /> {{ t("contextMenu.copyName") }} </ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="openData">
<TableProperties class="w-4 h-4" /> {{ t("contextMenu.viewData") }}
</ContextMenuItem>
<ContextMenuItem v-if="node.type === 'view'" @click="viewObjectSource">
<Code2 class="w-4 h-4" /> {{ t("contextMenu.viewSource") }}
</ContextMenuItem>
<ContextMenuItem v-if="node.type === 'view'" @click="viewObjectDdl">
<FileCode class="w-4 h-4" /> {{ t("contextMenu.viewDdl") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenStructureEditor" @click="openStructureEditor">
<PencilRuler class="w-4 h-4" /> {{ t("contextMenu.editStructure") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRenameObject" @click="openRenameObjectDialog">
<Pencil class="w-4 h-4" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
<Network class="w-4 h-4" /> {{ t("diagram.open") }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenTableImport" @click="openTableImport">
<FileUp class="w-4 h-4" /> {{ t("contextMenu.importData") }}
</ContextMenuItem>
<ContextMenuItem v-if="isTableNotView" @click="openDataCompare">
<ArrowRightLeft class="w-4 h-4" /> {{ t("dataCompare.title") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<Download class="w-4 h-4" /> {{ t("contextMenu.exportData") }}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem @click="exportData('csv')">CSV</ContextMenuItem>
<ContextMenuItem @click="exportData('json')">JSON</ContextMenuItem>
<ContextMenuItem @click="exportData('sql')">SQL INSERT</ContextMenuItem>
<ContextMenuItem @click="exportDataXlsx">XLSX</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem @click="openDatabaseExport">
<Download class="w-4 h-4" />
{{ t("contextMenu.exportDatabase") }}
</ContextMenuItem>
<ContextMenuItem @click="exportStructure">
<FileCode class="w-4 h-4" /> {{ t("contextMenu.exportStructure") }}
</ContextMenuItem>
<template v-if="isTableNotView">
<ContextMenuSeparator />
<ContextMenuItem @click="duplicateStructure">
<CopyPlus class="w-4 h-4" /> {{ t("contextMenu.duplicateStructure") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem v-if="supportsTruncate" class="text-destructive" @click="truncateTable">
<Scissors class="w-4 h-4" /> {{ t("contextMenu.truncateTable") }}
</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="emptyTable">
<Eraser class="w-4 h-4" /> {{ t("contextMenu.emptyTable") }}
</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="dropTable">
<Trash2 class="w-4 h-4" /> {{ t("contextMenu.dropTable") }}
</ContextMenuItem>
</template>
<ContextMenuSeparator />
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'column'">
<ContextMenuItem v-if="canOpenFieldLineage" @click="openFieldLineage">
<Network class="w-4 h-4" /> {{ t("lineage.open") }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'procedure' || node.type === 'function'">
<ContextMenuItem @click="viewObjectSource">
<Code2 class="w-4 h-4 mr-2" />
{{ t("contextMenu.viewSource") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRenameObject" @click="openRenameObjectDialog">
<Pencil class="w-4 h-4 mr-2" />
{{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDropObject">
<Trash2 class="w-4 h-4 mr-2" />
{{ node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
</ContextMenuItem>
</template>
<template v-if="isGroupLabel(node)">
<ContextMenuItem v-if="node.type === 'saved-sql-root'" @click="openCreateSavedSqlFolder">
<FolderPlus class="w-4 h-4" /> {{ t("savedSql.newFolder") }}
</ContextMenuItem>
<template v-if="node.type === 'saved-sql-folder'">
<ContextMenuItem @click="openRenameSavedSqlFolder">
<Pencil class="w-4 h-4" /> {{ t("savedSql.renameFolder") }}
</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="deleteSavedSqlFolder">
<Trash2 class="w-4 h-4" /> {{ t("savedSql.deleteFolder") }}
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem v-if="node.type !== 'saved-sql-root' && node.type !== 'saved-sql-folder'" @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'saved-sql-file'">
<ContextMenuItem @click="openSavedSqlFile">
<FileText class="w-4 h-4" /> {{ t("savedSql.open") }}
</ContextMenuItem>
<ContextMenuItem @click="openRenameSavedSqlFile">
<Pencil class="w-4 h-4" /> {{ t("savedSql.renameFile") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="deleteSavedSqlFile">
<Trash2 class="w-4 h-4" /> {{ t("savedSql.deleteFile") }}
</ContextMenuItem>
</template>
<template v-if="node.type !== 'connection'">
<ContextMenuSeparator v-if="hasTypeMenu" />
<ContextMenuItem @click="copyName"> <Copy class="w-4 h-4" /> {{ t("contextMenu.copyName") }} </ContextMenuItem>
</template>
</ContextMenuContent>
</ContextMenu>
</div>
</CustomContextMenu>
<VisibleDatabasesDialog
v-if="node.type === 'connection' && node.connectionId"
v-model:open="showVisibleDatabasesDialog"