1085 lines
49 KiB
Vue
1085 lines
49 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
|
||
import type { CSSProperties } from "vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge } from "@lucide/vue";
|
||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||
import { useConnectionStore } from "@/stores/connectionStore";
|
||
import { useQueryStore } from "@/stores/queryStore";
|
||
import { useSettingsStore } from "@/stores/settingsStore";
|
||
import { useTabScroll } from "@/composables/useTabScroll";
|
||
import { useTabDrag } from "@/composables/useTabDrag";
|
||
import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||
import { hexToRgba } from "@/lib/common/color";
|
||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||
import { useToast } from "@/composables/useToast";
|
||
import type { QueryTab } from "@/types/database";
|
||
|
||
const props = defineProps<{
|
||
driverStoreOpen?: boolean;
|
||
driverStoreActive?: boolean;
|
||
settingsPageOpen?: boolean;
|
||
settingsPageActive?: boolean;
|
||
agentDriverUpdateCount?: number;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
"activate-tab": [];
|
||
"activate-driver-store": [];
|
||
"close-driver-store": [];
|
||
"activate-settings-page": [];
|
||
"close-settings-page": [];
|
||
"save-tab": [tabId: string];
|
||
"discard-tab-close": [];
|
||
"save-all-tab-close": [];
|
||
"discard-all-tab-close": [];
|
||
"cancel-tab-close": [];
|
||
}>();
|
||
|
||
const { t } = useI18n();
|
||
const connectionStore = useConnectionStore();
|
||
const queryStore = useQueryStore();
|
||
const settingsStore = useSettingsStore();
|
||
const { toast } = useToast();
|
||
const tabDrag = useTabDrag((draggedId, targetId, position) => {
|
||
queryStore.reorderTab(draggedId, targetId, position);
|
||
});
|
||
const editingTabId = ref<string | null>(null);
|
||
const editingTitle = ref("");
|
||
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
|
||
const isWrapLayout = computed(() => settingsStore.editorSettings.tabLayout === "wrap");
|
||
const fixedTabs = computed(() => queryStore.tabs.filter((tab) => tab.pinned));
|
||
const regularTabs = computed(() => queryStore.tabs.filter((tab) => !tab.pinned));
|
||
const hasFixedTabs = computed(() => fixedTabs.value.length > 0);
|
||
const regularSurfaceCount = computed(() => regularTabs.value.length + (props.driverStoreOpen ? 1 : 0) + (props.settingsPageOpen ? 1 : 0));
|
||
const closeConfirmDirtyCount = computed(() => queryStore.closeConfirmDirtyTabIds.length);
|
||
const showCloseConfirmBulkActions = computed(() => closeConfirmDirtyCount.value > 1);
|
||
const closeConfirmDirtyTabs = computed(() => queryStore.closeConfirmDirtyTabIds.map((id) => queryStore.tabs.find((tab) => tab.id === id)).filter((tab): tab is QueryTab => !!tab));
|
||
const closeConfirmCurrentTitle = computed(() => {
|
||
const focusedTab = closeConfirmDirtyTabs.value.find((tab) => tab.id === queryStore.pendingCloseTabId) ?? closeConfirmDirtyTabs.value[0];
|
||
return focusedTab ? tabDisplayTitle(focusedTab, t) : "";
|
||
});
|
||
const closeConfirmMessage = computed(() => {
|
||
const params = {
|
||
count: closeConfirmDirtyCount.value,
|
||
title: closeConfirmCurrentTitle.value,
|
||
};
|
||
if (closeConfirmDirtyCount.value > 1) {
|
||
if (queryStore.closeConfirmContext === "app") return t("editor.unsavedChangesAppCloseMultipleMessage", params);
|
||
return t("editor.unsavedChangesBatchCloseMultipleMessage", params);
|
||
}
|
||
if (queryStore.closeConfirmContext === "app") return t("editor.unsavedChangesAppCloseMessage", params);
|
||
return t("editor.unsavedChangesMessage", params);
|
||
});
|
||
const closeConfirmListOpen = ref(false);
|
||
let closeConfirmListCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const compactTabTitle = computed({
|
||
get: () => settingsStore.editorSettings.compactTabTitle,
|
||
set: (checked: boolean | "indeterminate") => {
|
||
settingsStore.updateEditorSettings({ compactTabTitle: checked === true });
|
||
},
|
||
});
|
||
|
||
function openCloseConfirmList() {
|
||
if (closeConfirmListCloseTimer) {
|
||
clearTimeout(closeConfirmListCloseTimer);
|
||
closeConfirmListCloseTimer = null;
|
||
}
|
||
closeConfirmListOpen.value = true;
|
||
}
|
||
|
||
function scheduleCloseConfirmListClose() {
|
||
if (closeConfirmListCloseTimer) clearTimeout(closeConfirmListCloseTimer);
|
||
closeConfirmListCloseTimer = setTimeout(() => {
|
||
closeConfirmListOpen.value = false;
|
||
closeConfirmListCloseTimer = null;
|
||
}, 120);
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
if (closeConfirmListCloseTimer) {
|
||
clearTimeout(closeConfirmListCloseTimer);
|
||
closeConfirmListCloseTimer = null;
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => queryStore.showCloseConfirm,
|
||
(open) => {
|
||
if (!open) closeConfirmListOpen.value = false;
|
||
},
|
||
);
|
||
|
||
function toggleCompactTabTitle() {
|
||
compactTabTitle.value = !compactTabTitle.value;
|
||
}
|
||
|
||
function canRenameTab(tab: QueryTab) {
|
||
return tab.mode === "query";
|
||
}
|
||
|
||
function startRenameTab(tab: QueryTab) {
|
||
if (!canRenameTab(tab)) return;
|
||
editingTabId.value = tab.id;
|
||
editingTitle.value = tab.title;
|
||
nextTick(() => {
|
||
const input = document.querySelector<HTMLInputElement>(`[data-tab-title-input="${tab.id}"]`);
|
||
input?.focus();
|
||
input?.select();
|
||
});
|
||
}
|
||
|
||
function commitRenameTab(tab: QueryTab) {
|
||
if (editingTabId.value !== tab.id) return;
|
||
const title = editingTitle.value.trim();
|
||
if (title) queryStore.renameTab(tab.id, title);
|
||
editingTabId.value = null;
|
||
}
|
||
|
||
function cancelRenameTab() {
|
||
editingTabId.value = null;
|
||
}
|
||
|
||
function isDirtyTab(tab: QueryTab) {
|
||
return queryStore.isTabDirty(tab);
|
||
}
|
||
|
||
function tabTitleLabel(tab: QueryTab) {
|
||
const title = tabDisplayTitle(tab, t);
|
||
return isDirtyTab(tab) ? `* ${title}` : title;
|
||
}
|
||
|
||
function tabTitleText(tab: QueryTab) {
|
||
return tabDisplayTitle(tab, t);
|
||
}
|
||
|
||
function tabTitleStyle(tab: QueryTab): CSSProperties | undefined {
|
||
if (!isDirtyTab(tab)) return undefined;
|
||
return {
|
||
fontStyle: "italic",
|
||
fontWeight: 700,
|
||
transform: "skewX(-8deg)",
|
||
transformOrigin: "left center",
|
||
};
|
||
}
|
||
|
||
type SpecialRegularSurface = "driverStore" | "settings";
|
||
|
||
function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
|
||
if (keep !== "driverStore" && props.driverStoreOpen) emit("close-driver-store");
|
||
if (keep !== "settings" && props.settingsPageOpen) emit("close-settings-page");
|
||
}
|
||
|
||
function closeOtherRegularTabsFromTab(tab: QueryTab) {
|
||
queryStore.closeOtherRegularTabs(tab.id);
|
||
closeSpecialRegularSurfaces();
|
||
}
|
||
|
||
function closeAllRegularSurfaces() {
|
||
queryStore.closeRegularTabs();
|
||
closeSpecialRegularSurfaces();
|
||
}
|
||
|
||
function closeOtherActiveTabs() {
|
||
if (props.settingsPageActive) {
|
||
queryStore.closeRegularTabs();
|
||
closeSpecialRegularSurfaces("settings");
|
||
return;
|
||
}
|
||
if (props.driverStoreActive) {
|
||
queryStore.closeRegularTabs();
|
||
closeSpecialRegularSurfaces("driverStore");
|
||
return;
|
||
}
|
||
|
||
const tab = queryStore.tabs.find((item) => item.id === queryStore.activeTabId);
|
||
if (!tab) return;
|
||
if (tab.pinned) queryStore.closeOtherFixedTabs(tab.id);
|
||
else closeOtherRegularTabsFromTab(tab);
|
||
}
|
||
|
||
defineExpose({ closeOtherActiveTabs });
|
||
|
||
function getSpecialRegularTabMenuItems(surface: SpecialRegularSurface): ContextMenuItem[] {
|
||
const keep = surface;
|
||
const closeCurrent = surface === "driverStore" ? () => emit("close-driver-store") : () => emit("close-settings-page");
|
||
const closeOtherDisabled = regularSurfaceCount.value <= 1;
|
||
const closeOtherLabel = hasFixedTabs.value ? t("contextMenu.closeOtherRegularTabs") : t("contextMenu.closeOtherTabs");
|
||
const closeAllLabel = hasFixedTabs.value ? t("contextMenu.closeAllRegularTabs") : t("contextMenu.closeAllTabs");
|
||
|
||
return [
|
||
{
|
||
label: compactTabTitle.value ? t("contextMenu.fullTabTitle") : t("contextMenu.compactTabTitle"),
|
||
action: toggleCompactTabTitle,
|
||
icon: compactTabTitle.value ? Maximize2 : Minimize2,
|
||
},
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.closeTab"), action: closeCurrent, icon: X },
|
||
{
|
||
label: closeOtherLabel,
|
||
action: () => {
|
||
queryStore.closeRegularTabs();
|
||
closeSpecialRegularSurfaces(keep);
|
||
},
|
||
disabled: closeOtherDisabled,
|
||
icon: X,
|
||
shortcut: settingsStore.editorSettings.shortcuts.closeOtherTabs,
|
||
},
|
||
{
|
||
label: closeAllLabel,
|
||
action: closeAllRegularSurfaces,
|
||
variant: "destructive" as const,
|
||
icon: X,
|
||
},
|
||
];
|
||
}
|
||
|
||
function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
|
||
const closeCurrentLabel = tab.pinned ? t("contextMenu.closeFixedTab") : t("contextMenu.closeTab");
|
||
const closeOtherLabel = tab.pinned ? t("contextMenu.closeOtherFixedTabs") : hasFixedTabs.value ? t("contextMenu.closeOtherRegularTabs") : t("contextMenu.closeOtherTabs");
|
||
const closeAllLabel = tab.pinned ? t("contextMenu.closeAllFixedTabs") : hasFixedTabs.value ? t("contextMenu.closeAllRegularTabs") : t("contextMenu.closeAllTabs");
|
||
const closeOtherDisabled = tab.pinned ? fixedTabs.value.length <= 1 : regularSurfaceCount.value <= 1;
|
||
const closeOtherAction = tab.pinned ? () => queryStore.closeOtherFixedTabs(tab.id) : () => closeOtherRegularTabsFromTab(tab);
|
||
const closeAllAction = tab.pinned ? () => queryStore.closeFixedTabs() : closeAllRegularSurfaces;
|
||
|
||
return [
|
||
{
|
||
label: compactTabTitle.value ? t("contextMenu.fullTabTitle") : t("contextMenu.compactTabTitle"),
|
||
action: toggleCompactTabTitle,
|
||
icon: compactTabTitle.value ? Maximize2 : Minimize2,
|
||
},
|
||
{
|
||
label: t("contextMenu.renameTab"),
|
||
action: () => startRenameTab(tab),
|
||
icon: Pencil,
|
||
visible: canRenameTab(tab),
|
||
},
|
||
{
|
||
label: t("contextMenu.duplicateTab"),
|
||
action: () => queryStore.duplicateTab(tab.id),
|
||
icon: Copy,
|
||
visible: canRenameTab(tab),
|
||
},
|
||
{
|
||
label: t("contextMenu.copyName"),
|
||
action: async () => {
|
||
try {
|
||
await copyToClipboard(tabDisplayTitle(tab, t));
|
||
toast(t("connection.copied"), 2000);
|
||
} catch (e: any) {
|
||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
},
|
||
icon: Copy,
|
||
},
|
||
{ label: "", separator: true },
|
||
{
|
||
label: tab.pinned ? t("contextMenu.unfixTab") : t("contextMenu.fixTab"),
|
||
action: () => queryStore.togglePinnedTab(tab.id),
|
||
icon: Pin,
|
||
iconClass: tab.pinned ? "fill-current" : "",
|
||
},
|
||
{ label: "", separator: true },
|
||
{ label: closeCurrentLabel, action: () => queryStore.closeTab(tab.id), icon: X },
|
||
{
|
||
label: closeOtherLabel,
|
||
action: closeOtherAction,
|
||
disabled: closeOtherDisabled,
|
||
icon: X,
|
||
shortcut: settingsStore.editorSettings.shortcuts.closeOtherTabs,
|
||
},
|
||
{
|
||
label: closeAllLabel,
|
||
action: closeAllAction,
|
||
variant: "destructive" as const,
|
||
icon: X,
|
||
},
|
||
];
|
||
}
|
||
|
||
function handleSaveAndClose() {
|
||
const id = queryStore.saveAndClosePendingTab();
|
||
if (id) emit("save-tab", id);
|
||
}
|
||
|
||
function handleDiscardAndClose() {
|
||
queryStore.forceClosePendingTab();
|
||
emit("discard-tab-close");
|
||
}
|
||
|
||
function handleSaveAllAndClose() {
|
||
emit("save-all-tab-close");
|
||
}
|
||
|
||
function handleDiscardAllAndClose() {
|
||
queryStore.forceCloseAllPendingTabs();
|
||
emit("discard-all-tab-close");
|
||
}
|
||
|
||
function handleCancelClose() {
|
||
queryStore.cancelClosePendingTab();
|
||
emit("cancel-tab-close");
|
||
}
|
||
|
||
const tabsContainerRef = ref<HTMLElement | null>(null);
|
||
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(tabsContainerRef);
|
||
const fixedTabsContainerRef = ref<HTMLElement | null>(null);
|
||
const {
|
||
hasTabOverflow: hasFixedTabOverflow,
|
||
scrollThumbLeftPercent: fixedScrollThumbLeftPercent,
|
||
scrollThumbWidthPercent: fixedScrollThumbWidthPercent,
|
||
isScrollbarDragging: isFixedScrollbarDragging,
|
||
updateScrollButtons: updateFixedScrollButtons,
|
||
onTabsWheel: onFixedTabsWheel,
|
||
startScrollbarDrag: startFixedScrollbarDrag,
|
||
} = useTabScroll(fixedTabsContainerRef);
|
||
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
|
||
|
||
function updateAllScrollButtons() {
|
||
updateScrollButtons();
|
||
updateFixedScrollButtons();
|
||
}
|
||
|
||
function activeTabScrollInline(container: HTMLElement, tabId: string | null): ScrollLogicalPosition {
|
||
if (!tabId) return "center";
|
||
const lastRegularTab = regularTabs.value[regularTabs.value.length - 1];
|
||
const lastFixedTab = fixedTabs.value[fixedTabs.value.length - 1];
|
||
if (container === tabsContainerRef.value && lastRegularTab?.id === tabId) return "end";
|
||
if (container === fixedTabsContainerRef.value && lastFixedTab?.id === tabId) return "end";
|
||
return "center";
|
||
}
|
||
|
||
watch(
|
||
() => queryStore.tabs.map((tab) => `${tab.id}:${tab.pinned ? "1" : "0"}`).join("|"),
|
||
() => {
|
||
nextTick(updateAllScrollButtons);
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => queryStore.activeTabId,
|
||
() => {
|
||
nextTick(() => {
|
||
if (!isWrapLayout.value) {
|
||
for (const container of [tabsContainerRef.value, fixedTabsContainerRef.value]) {
|
||
if (!container) continue;
|
||
const activeEl = container.querySelector('[data-active-tab="true"]');
|
||
if (activeEl) {
|
||
activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: activeTabScrollInline(container, queryStore.activeTabId) });
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
updateAllScrollButtons();
|
||
tabScrollBehavior.value = "smooth";
|
||
});
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => props.driverStoreActive,
|
||
(show) => {
|
||
if (!show) return;
|
||
nextTick(() => {
|
||
if (isWrapLayout.value) return;
|
||
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" });
|
||
}
|
||
updateAllScrollButtons();
|
||
});
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => props.settingsPageActive,
|
||
(show) => {
|
||
if (!show) return;
|
||
nextTick(() => {
|
||
if (isWrapLayout.value) return;
|
||
const container = tabsContainerRef.value;
|
||
if (!container) return;
|
||
const el = container.querySelector("[data-settings-page-tab]");
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||
}
|
||
updateAllScrollButtons();
|
||
});
|
||
},
|
||
);
|
||
|
||
function tabColorStyle(tab: QueryTab) {
|
||
const color = connectionColor(tab.connectionId);
|
||
const isActive = tab.id === queryStore.activeTabId && !props.driverStoreActive && !props.settingsPageActive;
|
||
const isClassic = isClassicLayout.value;
|
||
if (!color) {
|
||
if (isClassic) {
|
||
return isActive ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : 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 === "mq") return "";
|
||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||
return "text-blue-600 dark:text-blue-400";
|
||
}
|
||
|
||
function tabDatabaseIconType(tab: QueryTab) {
|
||
const connection = connectionStore.getConfig(tab.connectionId);
|
||
if (!connection) return "mq";
|
||
if (connection.db_type === "mq") {
|
||
const externalConfig = connection.external_config as { systemKind?: unknown } | undefined;
|
||
const systemKind = typeof externalConfig?.systemKind === "string" ? externalConfig.systemKind : "";
|
||
if (connection.driver_profile === "kafka" || systemKind === "kafka") return "kafka";
|
||
if (connection.driver_profile === "rocketmq" || systemKind === "rocketmq") return "rocketmq";
|
||
if (connection.driver_profile === "pulsar" || systemKind === "pulsar") return "pulsar";
|
||
}
|
||
return connection.driver_profile || connection.db_type;
|
||
}
|
||
|
||
const showRegularTabScrollbar = computed(() => hasTabOverflow.value && !isWrapLayout.value);
|
||
const showFixedTabScrollbar = computed(() => hasFixedTabOverflow.value && !isWrapLayout.value);
|
||
const showRegularTabOverflowControls = computed(() => regularTabs.value.length > 0 && hasTabOverflow.value && !isWrapLayout.value);
|
||
const regularTabOverflowOpen = ref(false);
|
||
const fixedTabOverflowOpen = ref(false);
|
||
const tabBarClass = computed(() => [isClassicLayout.value ? "bg-muted" : "border-b bg-background", hasFixedTabs.value ? "flex-col" : "", isClassicLayout.value && hasFixedTabs.value ? "border-b" : ""]);
|
||
const regularTabRowClass = computed(() => [isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2", isClassicLayout.value && !hasFixedTabs.value ? "border-b" : ""]);
|
||
|
||
function tabMenuIcon(tab: QueryTab) {
|
||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis") return Table2;
|
||
if (tab.mode === "vector") return TableProperties;
|
||
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
|
||
if (tab.mode === "nacos") return Network;
|
||
if (tab.mode === "objects") return TableProperties;
|
||
if (tab.mode === "structure") return PencilRuler;
|
||
if (tab.mode === "dameng-jobs") return CalendarClock;
|
||
if (tab.mode === "processlist") return Activity;
|
||
if (tab.mode === "mysql-dashboard" || tab.mode === "postgres-dashboard") return Gauge;
|
||
return Code2;
|
||
}
|
||
|
||
function handleTabClick(tab: QueryTab) {
|
||
if (tabDrag.state.wasDragged) return;
|
||
activateTab(tab.id);
|
||
}
|
||
|
||
function handleTabMouseDown(event: MouseEvent, tabId: string) {
|
||
if (event.button === 0) {
|
||
dispatchBeforeTabSwitch(tabId);
|
||
event.preventDefault();
|
||
}
|
||
tabDrag.startDrag(event, tabId);
|
||
}
|
||
|
||
function handleTabDragTarget(event: MouseEvent, tab: QueryTab) {
|
||
const draggedTab = queryStore.tabs.find((item) => item.id === tabDrag.state.draggedId);
|
||
if (draggedTab && draggedTab.pinned !== tab.pinned) {
|
||
tabDrag.clearTarget(tab.id);
|
||
return;
|
||
}
|
||
tabDrag.updateTarget(event, tab.id);
|
||
}
|
||
|
||
function tabDropStyle(tabId: string) {
|
||
if (!tabDrag.state.active) return {};
|
||
if (tabDrag.state.draggedId === tabId) return { opacity: 0.4 };
|
||
if (tabDrag.state.targetId !== tabId) return {};
|
||
const dropColor = `var(--ring)`;
|
||
if (tabDrag.state.dropPosition === "before") {
|
||
return { boxShadow: `inset 3px 0 0 0 ${dropColor}` };
|
||
}
|
||
return { boxShadow: `inset -3px 0 0 0 ${dropColor}` };
|
||
}
|
||
|
||
const tabsContainerStyle = computed<CSSProperties>(() => ({
|
||
msOverflowStyle: "none",
|
||
scrollbarWidth: "none",
|
||
WebkitOverflowScrolling: "touch",
|
||
}));
|
||
|
||
const tabScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
||
insetInlineStart: `${scrollThumbLeftPercent.value}%`,
|
||
width: `${scrollThumbWidthPercent.value}%`,
|
||
}));
|
||
|
||
const fixedTabScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
||
insetInlineStart: `${fixedScrollThumbLeftPercent.value}%`,
|
||
width: `${fixedScrollThumbWidthPercent.value}%`,
|
||
}));
|
||
|
||
const tabTailDragRegionClass = computed(() => (showRegularTabOverflowControls.value || isWrapLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch"));
|
||
const fixedTabTailDragRegionClass = computed(() => (showFixedTabScrollbar.value || isWrapLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch"));
|
||
|
||
const tabOverflowControlClass = computed(() =>
|
||
isClassicLayout.value
|
||
? "h-full w-8 border-r border-border/80 dark:border-border/45 bg-background/80 text-foreground/75 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||
: "h-7 w-7 rounded-md border border-border/60 bg-background text-foreground/70 hover:border-border hover:text-foreground",
|
||
);
|
||
|
||
function dispatchBeforeTabSwitch(tabId: string) {
|
||
if (tabId === queryStore.activeTabId) return;
|
||
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId: queryStore.activeTabId } }));
|
||
}
|
||
|
||
function activateTab(tabId: string) {
|
||
dispatchBeforeTabSwitch(tabId);
|
||
tabScrollBehavior.value = "auto";
|
||
queryStore.activeTabId = tabId;
|
||
emit("activate-tab");
|
||
}
|
||
|
||
function activateTabFromOverflow(tabId: string, kind: "regular" | "fixed") {
|
||
activateTab(tabId);
|
||
if (kind === "regular") regularTabOverflowOpen.value = false;
|
||
else fixedTabOverflowOpen.value = false;
|
||
}
|
||
|
||
function closeTabFromOverflow(tabId: string, event: MouseEvent) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
queryStore.closeTab(tabId);
|
||
}
|
||
|
||
function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regular" | "fixed") {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
activateTabFromOverflow(tabId, kind);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden" :class="tabBarClass">
|
||
<div class="flex w-full min-w-0 shrink-0 overflow-hidden" :class="regularTabRowClass">
|
||
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
|
||
<div v-if="showRegularTabScrollbar" class="app-tab-scrollbar" :class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }" @pointerdown="startScrollbarDrag">
|
||
<div class="app-tab-scrollbar__thumb" :style="tabScrollbarThumbStyle" />
|
||
</div>
|
||
<div
|
||
ref="tabsContainerRef"
|
||
class="app-tab-scroll flex w-full min-w-0 flex-1 items-center overflow-x-auto"
|
||
:class="[isClassicLayout ? 'h-full' : 'h-full gap-1.5 py-1.5', isWrapLayout ? 'wrap-mode' : '', isWrapLayout && isClassicLayout ? 'classic-wrap' : '']"
|
||
:style="tabsContainerStyle"
|
||
@scroll="updateScrollButtons"
|
||
@wheel="onTabsWheel"
|
||
>
|
||
<CustomContextMenu v-for="tab in regularTabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
|
||
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<div
|
||
class="app-tab-pill group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
|
||
:class="
|
||
isClassicLayout
|
||
? [
|
||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||
'h-full border-r border-border/80 font-medium dark:border-border/45',
|
||
tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90',
|
||
]
|
||
: [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-7 rounded-md border', tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
|
||
"
|
||
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
|
||
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
|
||
@click="handleTabClick(tab)"
|
||
@dblclick.stop="startRenameTab(tab)"
|
||
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
|
||
@mousedown="handleTabMouseDown($event, tab.id)"
|
||
@mouseenter="handleTabDragTarget($event, tab)"
|
||
@mousemove="handleTabDragTarget($event, tab)"
|
||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||
>
|
||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
|
||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||
<Network v-else-if="tab.mode === 'nacos'" 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" />
|
||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard'" class="h-3.5 w-3.5" />
|
||
<Code2 v-else class="h-3.5 w-3.5" />
|
||
</span>
|
||
<input
|
||
v-if="editingTabId === tab.id"
|
||
v-model="editingTitle"
|
||
:data-tab-title-input="tab.id"
|
||
:aria-label="t('contextMenu.renameTab')"
|
||
class="h-5 min-w-0 flex-1 rounded border border-ring bg-background px-1.5 text-xs font-normal text-foreground outline-none"
|
||
@click.stop
|
||
@mousedown.stop
|
||
@keydown.enter.prevent="commitRenameTab(tab)"
|
||
@keydown.escape.prevent="cancelRenameTab"
|
||
@blur="commitRenameTab(tab)"
|
||
/>
|
||
<span v-else class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||
</span>
|
||
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
|
||
<TooltipTrigger as-child>
|
||
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ t("connection.readOnlyBadge") }}</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, t)" :key="line.label">
|
||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||
<span>{{ line.value }}</span>
|
||
</template>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</CustomContextMenu>
|
||
|
||
<!-- Settings Page Tab -->
|
||
<CustomContextMenu v-if="settingsPageOpen" :items="getSpecialRegularTabMenuItems('settings')" v-slot="{ onContextMenu }">
|
||
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||
<div
|
||
data-settings-page-tab
|
||
class="app-tab-pill group flex min-w-36 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
|
||
:class="
|
||
isClassicLayout
|
||
? ['h-full border-r border-border/80 dark:border-border/45 font-medium', settingsPageActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90']
|
||
: ['h-7 rounded-md border font-medium', settingsPageActive ? 'border-ring text-foreground' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
|
||
"
|
||
:style="isClassicLayout && settingsPageActive ? { boxShadow: '0 1px 0 0 var(--color-background)' } : {}"
|
||
:data-active-tab="settingsPageActive"
|
||
@click="emit('activate-settings-page')"
|
||
@mousedown.middle.prevent="emit('close-settings-page')"
|
||
>
|
||
<span class="shrink-0 text-sky-600 dark:text-sky-400">
|
||
<Settings class="h-3.5 w-3.5" />
|
||
</span>
|
||
<span class="min-w-0 truncate flex-1">{{ t("settings.title") }}</span>
|
||
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-settings-page')">
|
||
<X class="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</CustomContextMenu>
|
||
|
||
<!-- Driver Store Tab -->
|
||
<CustomContextMenu v-if="driverStoreOpen" :items="getSpecialRegularTabMenuItems('driverStore')" v-slot="{ onContextMenu }">
|
||
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||
<div
|
||
data-driver-store-tab
|
||
class="app-tab-pill group flex min-w-38 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
|
||
:class="
|
||
isClassicLayout
|
||
? ['h-full border-r border-border/80 dark:border-border/45 font-medium', driverStoreActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90']
|
||
: ['h-7 rounded-md border font-medium', driverStoreActive ? 'border-ring text-foreground' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
|
||
"
|
||
:style="isClassicLayout && driverStoreActive ? { boxShadow: '0 1px 0 0 var(--color-background)' } : {}"
|
||
:data-active-tab="driverStoreActive"
|
||
@click="emit('activate-driver-store')"
|
||
@mousedown.middle.prevent="emit('close-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">{{ t("toolbar.driverManager") }}</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="t('toolbar.updatableDriverCount')">
|
||
{{ (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>
|
||
</CustomContextMenu>
|
||
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
|
||
</div>
|
||
</div>
|
||
<div v-if="showRegularTabOverflowControls" class="relative z-30 flex shrink-0 items-center">
|
||
<Popover v-model:open="regularTabOverflowOpen">
|
||
<PopoverTrigger as-child>
|
||
<button type="button" :class="['inline-flex shrink-0 items-center justify-center', tabOverflowControlClass].join(' ')" :aria-label="t('tabs.openTabs')" :title="t('tabs.openTabs')">
|
||
<ChevronDown class="h-4 w-4" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" class="w-auto min-w-56 max-w-80 max-h-[min(70vh,28rem)] gap-0 overflow-y-auto rounded-[6px] p-1" @click.stop @keydown.stop>
|
||
<CustomContextMenu v-for="tab in queryStore.tabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
|
||
<div
|
||
class="group flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm outline-hidden hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
|
||
:class="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-accent/70 text-accent-foreground' : ''"
|
||
:title="tabTitleLabel(tab)"
|
||
role="menuitem"
|
||
tabindex="0"
|
||
@click="activateTabFromOverflow(tab.id, 'regular')"
|
||
@contextmenu="onContextMenu"
|
||
@keydown="onOverflowItemKeydown($event, tab.id, 'regular')"
|
||
>
|
||
<DatabaseIcon v-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5 shrink-0" />
|
||
<component :is="tabMenuIcon(tab)" v-else :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||
</span>
|
||
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||
<span class="w-5 shrink-0">
|
||
<button
|
||
type="button"
|
||
class="inline-flex rounded p-1 text-muted-foreground opacity-70 hover:bg-muted-foreground/20 hover:text-foreground group-hover:opacity-100"
|
||
:aria-label="t('contextMenu.closeTab')"
|
||
:title="t('contextMenu.closeTab')"
|
||
@click="closeTabFromOverflow(tab.id, $event)"
|
||
@mousedown.stop
|
||
>
|
||
<X class="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</CustomContextMenu>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="hasFixedTabs" class="flex w-full min-w-0 shrink-0 overflow-hidden border-t" :class="isClassicLayout ? 'h-8 items-stretch border-border/80 bg-background/45 dark:border-border/45 dark:bg-background/20' : 'h-9 items-center border-border/70 bg-muted/45 px-2 dark:bg-muted/25'">
|
||
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
|
||
<div v-if="showFixedTabScrollbar" class="app-tab-scrollbar app-tab-scrollbar--bottom" :class="{ 'app-tab-scrollbar--dragging': isFixedScrollbarDragging }" @pointerdown="startFixedScrollbarDrag">
|
||
<div class="app-tab-scrollbar__thumb" :style="fixedTabScrollbarThumbStyle" />
|
||
</div>
|
||
<div
|
||
ref="fixedTabsContainerRef"
|
||
class="app-tab-scroll flex w-full min-w-0 flex-1 items-center overflow-x-auto"
|
||
:class="[isClassicLayout ? 'h-full' : 'h-full gap-1.5 py-1', isWrapLayout ? 'wrap-mode' : '', isWrapLayout && isClassicLayout ? 'classic-wrap' : '']"
|
||
:style="tabsContainerStyle"
|
||
@scroll="updateFixedScrollButtons"
|
||
@wheel="onFixedTabsWheel"
|
||
>
|
||
<CustomContextMenu v-for="tab in fixedTabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
|
||
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<div
|
||
class="app-tab-pill group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
|
||
:class="
|
||
isClassicLayout
|
||
? [
|
||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||
'h-full border-r border-border/80 font-medium dark:border-border/45',
|
||
tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90',
|
||
]
|
||
: [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-7 rounded-md border', tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
|
||
"
|
||
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
|
||
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
|
||
@click="handleTabClick(tab)"
|
||
@dblclick.stop="startRenameTab(tab)"
|
||
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
|
||
@mousedown="handleTabMouseDown($event, tab.id)"
|
||
@mouseenter="handleTabDragTarget($event, tab)"
|
||
@mousemove="handleTabDragTarget($event, tab)"
|
||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||
>
|
||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
|
||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||
<Network v-else-if="tab.mode === 'nacos'" 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" />
|
||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard'" class="h-3.5 w-3.5" />
|
||
<Code2 v-else class="h-3.5 w-3.5" />
|
||
</span>
|
||
<input
|
||
v-if="editingTabId === tab.id"
|
||
v-model="editingTitle"
|
||
:data-tab-title-input="tab.id"
|
||
:aria-label="t('contextMenu.renameTab')"
|
||
class="h-5 min-w-0 flex-1 rounded border border-ring bg-background px-1.5 text-xs font-normal text-foreground outline-none"
|
||
@click.stop
|
||
@mousedown.stop
|
||
@keydown.enter.prevent="commitRenameTab(tab)"
|
||
@keydown.escape.prevent="cancelRenameTab"
|
||
@blur="commitRenameTab(tab)"
|
||
/>
|
||
<span v-else class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden text-foreground">
|
||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||
</span>
|
||
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
|
||
<TooltipTrigger as-child>
|
||
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
|
||
</Tooltip>
|
||
<button class="rounded p-0.5 text-primary hover:bg-muted-foreground/20 shrink-0" :aria-label="t('contextMenu.unfixTab')" :title="t('contextMenu.unfixTab')" @click.stop="queryStore.togglePinnedTab(tab.id)">
|
||
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
|
||
</button>
|
||
<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, t)" :key="line.label">
|
||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||
<span>{{ line.value }}</span>
|
||
</template>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</CustomContextMenu>
|
||
<div :class="fixedTabTailDragRegionClass" data-tauri-drag-region />
|
||
</div>
|
||
</div>
|
||
<div v-if="showFixedTabScrollbar" class="relative z-30 flex shrink-0 items-center">
|
||
<Popover v-model:open="fixedTabOverflowOpen">
|
||
<PopoverTrigger as-child>
|
||
<button type="button" :class="['inline-flex shrink-0 items-center justify-center', tabOverflowControlClass].join(' ')" :aria-label="t('tabs.fixedTabs')" :title="t('tabs.fixedTabs')">
|
||
<ChevronDown class="h-4 w-4" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" class="w-auto min-w-56 max-w-80 max-h-[min(70vh,28rem)] gap-0 overflow-y-auto rounded-[6px] p-1" @click.stop @keydown.stop>
|
||
<CustomContextMenu v-for="tab in fixedTabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
|
||
<div
|
||
class="group flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm outline-hidden hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
|
||
:class="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-accent/70 text-accent-foreground' : ''"
|
||
:title="tabTitleLabel(tab)"
|
||
role="menuitem"
|
||
tabindex="0"
|
||
@click="activateTabFromOverflow(tab.id, 'fixed')"
|
||
@contextmenu="onContextMenu"
|
||
@keydown="onOverflowItemKeydown($event, tab.id, 'fixed')"
|
||
>
|
||
<DatabaseIcon v-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5 shrink-0" />
|
||
<component :is="tabMenuIcon(tab)" v-else :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||
</span>
|
||
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||
<Pin class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||
<span class="w-5 shrink-0">
|
||
<button
|
||
type="button"
|
||
class="inline-flex rounded p-1 text-muted-foreground opacity-70 hover:bg-muted-foreground/20 hover:text-foreground group-hover:opacity-100"
|
||
:aria-label="t('contextMenu.closeTab')"
|
||
:title="t('contextMenu.closeTab')"
|
||
@click="closeTabFromOverflow(tab.id, $event)"
|
||
@mousedown.stop
|
||
>
|
||
<X class="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</CustomContextMenu>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Dialog
|
||
:open="queryStore.showCloseConfirm"
|
||
@update:open="
|
||
(open) => {
|
||
if (!open) queryStore.cancelClosePendingTab();
|
||
}
|
||
"
|
||
>
|
||
<DialogContent class="min-w-0 sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle class="flex items-center gap-2">
|
||
<AlertTriangle class="h-5 w-5 text-amber-500" />
|
||
{{ t("editor.unsavedChangesTitle") }}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<!-- Grid items use min-content sizing by default; shrink and wrap long file paths before they can displace the footer actions. -->
|
||
<div class="min-w-0 space-y-2">
|
||
<p class="wrap-anywhere text-sm text-muted-foreground">{{ closeConfirmMessage }}</p>
|
||
<Popover v-if="showCloseConfirmBulkActions" :open="closeConfirmListOpen" @update:open="closeConfirmListOpen = $event">
|
||
<PopoverTrigger as-child>
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||
@mouseenter="openCloseConfirmList"
|
||
@mouseleave="scheduleCloseConfirmListClose"
|
||
>
|
||
{{ t("editor.unsavedChangesViewList", { count: closeConfirmDirtyCount }) }}
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" side="bottom" class="w-72 max-w-[calc(100vw-2rem)] gap-1 p-2" @mouseenter="openCloseConfirmList" @mouseleave="scheduleCloseConfirmListClose" @pointerdown.stop @click.stop @keydown.stop>
|
||
<div class="px-2 pb-1 text-xs font-medium text-muted-foreground">
|
||
{{ t("editor.unsavedChangesListTitle", { count: closeConfirmDirtyCount }) }}
|
||
</div>
|
||
<div class="max-h-48 overflow-y-auto">
|
||
<div v-for="tab in closeConfirmDirtyTabs" :key="tab.id" class="flex min-w-0 items-center gap-2 rounded-[6px] px-2 py-1.5 text-sm" :class="tab.id === queryStore.pendingCloseTabId ? 'bg-muted text-foreground' : 'text-muted-foreground'">
|
||
<span class="h-1.5 w-1.5 shrink-0 rounded-full" :class="tab.id === queryStore.pendingCloseTabId ? 'bg-foreground' : 'bg-muted-foreground/50'" />
|
||
<span class="min-w-0 truncate">{{ tabDisplayTitle(tab, t) }}</span>
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
<DialogFooter class="min-w-0 sm:flex-wrap">
|
||
<Button variant="outline" @click="handleCancelClose">{{ t("common.cancel") }}</Button>
|
||
<Button v-if="showCloseConfirmBulkActions" variant="secondary" @click="handleDiscardAllAndClose">{{ t("editor.discardAllChanges") }}</Button>
|
||
<Button v-if="showCloseConfirmBulkActions" @click="handleSaveAllAndClose">{{ t("editor.saveAllChanges") }}</Button>
|
||
<Button variant="secondary" @click="handleDiscardAndClose">{{ t("editor.discardChanges") }}</Button>
|
||
<Button @click="handleSaveAndClose">{{ t("savedSql.save") }}</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 多行平铺模式:覆盖滚动相关样式,让标签换行展示 */
|
||
.app-tab-scroll.wrap-mode {
|
||
height: auto !important;
|
||
overflow: visible !important;
|
||
overflow-x: visible !important;
|
||
overflow-y: visible !important;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* 父级 strip 容器在包含 wrap-mode 时也需要解除高度约束和裁剪 */
|
||
.app-tab-strip:has(.wrap-mode) {
|
||
height: auto !important;
|
||
overflow: visible !important;
|
||
}
|
||
|
||
/* 标签栏行容器(直接子 div)在包含 wrap-mode 时解除固定高度和裁剪 */
|
||
.app-tab-bar > div:has(.wrap-mode) {
|
||
height: auto !important;
|
||
overflow: visible !important;
|
||
}
|
||
|
||
/* 标签栏本身也解除裁剪,让换行内容自然撑高 */
|
||
.app-tab-bar:has(.wrap-mode) {
|
||
overflow: visible !important;
|
||
}
|
||
|
||
/* 经典布局 + 多行模式:优化多行标签显示 */
|
||
.app-tab-scroll.classic-wrap {
|
||
row-gap: 0.25rem;
|
||
padding-top: 0.25rem;
|
||
padding-bottom: 0.25rem;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
/* 经典布局下 h-full 在 height:auto 容器中失效,改为固定高度 */
|
||
.app-tab-scroll.classic-wrap > div {
|
||
height: 2rem;
|
||
}
|
||
|
||
.dirty-tab-marker {
|
||
display: inline-flex;
|
||
width: 0.5rem;
|
||
height: 0.75rem;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: currentColor;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 12px;
|
||
opacity: 0.9;
|
||
transform: translateY(2px);
|
||
}
|
||
|
||
.app-tab-scroll::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.app-tab-scrollbar {
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
top: 0;
|
||
z-index: 20;
|
||
height: 6px;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
touch-action: none;
|
||
transition: opacity 140ms ease;
|
||
}
|
||
|
||
.app-tab-strip:hover .app-tab-scrollbar,
|
||
.app-tab-strip:focus-within .app-tab-scrollbar,
|
||
.app-tab-scrollbar--dragging {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.app-tab-scrollbar::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
top: 0;
|
||
height: 2px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
||
}
|
||
|
||
.app-tab-scrollbar--bottom {
|
||
top: auto;
|
||
bottom: 0;
|
||
}
|
||
|
||
.app-tab-scrollbar--bottom::before {
|
||
top: auto;
|
||
bottom: 0;
|
||
}
|
||
|
||
.app-tab-scrollbar__thumb {
|
||
position: absolute;
|
||
top: 0;
|
||
height: 2px;
|
||
min-width: 20px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklch, var(--foreground) 30%, transparent);
|
||
transition:
|
||
height 120ms ease,
|
||
background-color 120ms ease;
|
||
}
|
||
|
||
.app-tab-scrollbar--bottom .app-tab-scrollbar__thumb {
|
||
top: auto;
|
||
bottom: 0;
|
||
}
|
||
|
||
.app-tab-scrollbar:hover .app-tab-scrollbar__thumb,
|
||
.app-tab-scrollbar--dragging .app-tab-scrollbar__thumb {
|
||
height: 5px;
|
||
background: color-mix(in oklch, var(--foreground) 52%, transparent);
|
||
}
|
||
</style>
|