Files
dbx/apps/desktop/src/components/layout/AppTabBar.vue
T
t8y2 56c937f844 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.
2026-05-28 13:24:03 +08:00

335 lines
12 KiB
Vue

<script setup lang="ts">
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 CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabPresentation";
import { hexToRgba } from "@/lib/color";
import type { QueryTab } from "@/types/database";
const props = defineProps<{
showDriverStore?: boolean;
agentDriverUpdateCount?: number;
}>();
const emit = defineEmits<{
"toggle-driver-store": [];
"close-driver-store": [];
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const compactTabTitle = computed({
get: () => settingsStore.editorSettings.compactTabTitle,
set: (checked: boolean | "indeterminate") => {
settingsStore.updateEditorSettings({ compactTabTitle: checked === true });
},
});
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");
watch(
() => queryStore.tabs.length,
() => {
nextTick(updateScrollButtons);
},
);
watch(
() => queryStore.activeTabId,
() => {
nextTick(() => {
const container = tabsContainerRef.value;
if (!container) return;
const activeEl = container.querySelector('[data-active-tab="true"]');
if (activeEl) {
activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: "center" });
}
updateScrollButtons();
tabScrollBehavior.value = "smooth";
});
},
);
watch(
() => props.showDriverStore,
(show) => {
if (!show) return;
nextTick(() => {
const container = tabsContainerRef.value;
if (!container) return;
const el = container.querySelector("[data-driver-store-tab]");
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}
updateScrollButtons();
});
},
);
function tabColorStyle(tab: QueryTab) {
const color = connectionColor(tab.connectionId);
const isActive = tab.id === queryStore.activeTabId && !props.showDriverStore;
const isClassic = settingsStore.editorSettings.appLayout === "classic";
if (!color) {
if (isClassic) {
return isActive ? { boxShadow: "0 1px 0 0 var(--color-background)" } : undefined;
}
return isActive
? {
borderColor: "var(--ring)",
}
: undefined;
}
if (isClassic) {
return {
backgroundColor: hexToRgba(color, isActive ? 0.16 : 0.07),
boxShadow: isActive ? `inset 0 -2px 0 ${color}` : undefined,
};
}
return {
backgroundColor: hexToRgba(color, isActive ? 0.16 : 0.09),
borderColor: isActive ? hexToRgba(color, 0.72) : hexToRgba(color, 0.18),
};
}
function tabIconClass(tab: QueryTab) {
if (tab.mode === "data" || tab.mode === "objects" || tab.mode === "structure")
return "text-emerald-600 dark:text-emerald-400";
return "text-blue-600 dark:text-blue-400";
}
const dataTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "data"));
const showPinnedDataTabsMenu = computed(
() => dataTabs.value.length > 0 && (canScrollLeft.value || canScrollRight.value),
);
function activateDataTab(tabId: string) {
tabScrollBehavior.value = "auto";
queryStore.activeTabId = tabId;
emit("close-driver-store");
}
const tabsContainerStyle = computed<CSSProperties>(() => ({
msOverflowStyle: "none",
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
paddingRight: showPinnedDataTabsMenu.value ? "36px" : "0px",
}));
const dataTabsMenuContainerClass = computed(() =>
settingsStore.editorSettings.appLayout === "classic"
? "absolute inset-y-0 right-0 z-30 flex items-stretch"
: "absolute inset-y-0 -right-2 z-30 flex items-stretch",
);
</script>
<template>
<div
v-if="queryStore.tabs.length > 0 || showDriverStore"
class="relative flex border-b shrink-0"
:class="
settingsStore.editorSettings.appLayout === 'classic'
? 'h-9 items-stretch bg-muted'
: 'h-10 items-center bg-background px-2'
"
>
<div
v-if="canScrollLeft"
class="pointer-events-none absolute left-0 z-10 h-full w-6 bg-linear-to-r from-background from-40% to-transparent"
aria-hidden="true"
/>
<div
ref="tabsContainerRef"
class="flex-1 flex items-center overflow-x-auto min-w-0"
:class="settingsStore.editorSettings.appLayout === 'classic' ? '' : 'gap-1.5'"
:style="tabsContainerStyle"
@scroll="updateScrollButtons"
>
<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
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
:class="
settingsStore.editorSettings.appLayout === 'classic'
? [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-full border-r border-border/80 dark:border-border/45',
tab.id === queryStore.activeTabId && !showDriverStore
? 'bg-background text-foreground font-medium'
: 'text-foreground/70 hover:text-foreground/90',
]
: [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-7 rounded-md border',
tab.id === queryStore.activeTabId && !showDriverStore
? 'text-foreground font-medium'
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
]
"
:style="tabColorStyle(tab)"
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
@click="
queryStore.activeTabId = tab.id;
emit('close-driver-store');
"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
>
<span class="shrink-0" :class="tabIconClass(tab)">
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
<Tooltip>
<TooltipTrigger as-child>
<button
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
@click.stop="queryStore.togglePinnedTab(tab.id)"
>
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
</Tooltip>
<button
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
@click.stop="queryStore.closeTab(tab.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
<template v-for="line in tabTooltipLines(tab)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
<!-- Driver Store Tab -->
<div
v-if="showDriverStore"
data-driver-store-tab
class="group flex min-w-38 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
:class="
settingsStore.editorSettings.appLayout === 'classic'
? ['h-full border-r border-border/80 dark:border-border/45 bg-background text-foreground font-medium']
: ['h-7 rounded-md border text-foreground font-medium', 'border-ring']
"
:style="
settingsStore.editorSettings.appLayout === 'classic' ? { boxShadow: '0 1px 0 0 var(--color-background)' } : {}
"
@click="emit('toggle-driver-store')"
>
<span class="shrink-0 text-amber-600 dark:text-amber-400">
<Package class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 truncate flex-1">驱动管理</span>
<span
v-if="(agentDriverUpdateCount ?? 0) > 0"
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
aria-label="可更新驱动数量"
>
{{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }}
</span>
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-driver-store')">
<X class="h-3 w-3" />
</button>
</div>
</div>
<div
v-if="canScrollRight"
class="pointer-events-none absolute right-0 z-20 h-full w-6 bg-linear-to-l from-background from-40% to-transparent"
aria-hidden="true"
/>
<div v-if="showPinnedDataTabsMenu" :class="dataTabsMenuContainerClass">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="h-full w-7 rounded-none text-foreground/70 hover:text-foreground inline-flex items-center justify-center bg-background"
:aria-label="t('tabs.openDataTabs')"
>
<ChevronDown class="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-auto min-w-36 max-w-60">
<DropdownMenuItem
v-for="tab in dataTabs"
:key="tab.id"
class="text-xs max-w-full"
@click="activateDataTab(tab.id)"
>
<Table2 class="w-3.5 h-3.5 mr-2 shrink-0 text-emerald-600 dark:text-emerald-400" />
<span class="truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>