Files
dbx/apps/desktop/src/components/layout/EditorGroupTabBar.vue
T

1877 lines
88 KiB
Vue

<script lang="ts">
import { reactive } from "vue";
/**
* Drag session state lives at module scope, shared by every group's tab bar
* instance: the source bar starts the drag, but the insertion indicator must
* render on the targeted pill — which may belong to another group's bar.
*/
const groupTabDrag = reactive({
active: false,
tabId: null as string | null,
sourceGroupId: null as string | null,
payload: "",
startX: 0,
startY: 0,
targetGroupId: null as string | null,
targetTabId: null as string | null,
position: null as "before" | "after" | null,
pointerId: null as number | null,
});
let groupTabDragGhost: HTMLElement | null = null;
let groupTabDragSourceEl: HTMLElement | null = null;
/** Horizontal distance (px) required before a press becomes a tab drag; absorbs click jitter. */
const TAB_DRAG_HORIZONTAL_THRESHOLD = 24;
</script>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import {
ArrowDown,
ArrowDownAZ,
ArrowRight,
ChevronDown,
ChevronsDownUp,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
Clock3,
Copy,
Database,
ListFilter,
ListOrdered,
Maximize2,
Minimize2,
Package,
PanelBottom,
PanelLeft,
PanelRight,
PanelTop,
Pencil,
Pin,
PlugZap,
RotateCcw,
RotateCw,
Search,
Server,
Settings,
Ungroup,
X,
} from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue";
import TabModeIcon from "@/components/layout/TabModeIcon.vue";
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { EditorSettings } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { useToast } from "@/composables/useToast";
import { hexToRgba } from "@/lib/common/color";
import { copyToClipboard } from "@/lib/common/clipboard";
import { parseTabDragPayload, serializeTabDragPayload } from "@/lib/tabs/tabDrag";
import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu";
import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabDisplayTitles, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import "./appTabBar.css";
import type { QueryTab } from "@/types/database";
const props = defineProps<{
groupId: string;
tabs: QueryTab[];
activeTabId: string | null;
/** Shared vertical-strip width/collapse state owned by App (usePanelResize). */
tabBarWidth?: number;
tabBarCollapsed?: boolean;
/** Detaching tabs into their own window is a desktop-only capability. */
canDetachTabs?: boolean;
/** A detached tab is being dragged over this bar — highlight it as the drop target. */
detachedDropTarget?: boolean;
/** App-level special pages appended after the tabs. */
specialPageTabs?: { settingsOpen: boolean; settingsActive: boolean; driverStoreOpen: boolean; driverStoreActive: boolean; pluginCenterOpen: boolean; pluginCenterActive: boolean; driverUpdateCount: number };
}>();
const emit = defineEmits<{
"activate-tab": [tabId: string];
"locate-tab": [tab: QueryTab];
"toggle-zen-mode": [];
"start-resize": [event: PointerEvent];
"toggle-collapse": [];
"detach-tab": [tab: QueryTab];
"activate-settings": [];
"close-settings": [];
"activate-driver-store": [];
"close-driver-store": [];
"activate-plugin-center": [];
"close-plugin-center": [];
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const { toast } = useToast();
const tabsContainerRef = ref<HTMLElement | null>(null);
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(tabsContainerRef);
const fixedTabsRowRef = ref<HTMLElement | null>(null);
const regularTabsRowRef = ref<HTMLElement | null>(null);
const fixedTabsScroll = useTabScroll(fixedTabsRowRef);
const regularTabsScroll = useTabScroll(regularTabsRowRef);
const editingTabId = ref<string | null>(null);
const editingTitle = ref("");
const openTabTooltipId = ref<string | null>(null);
// Drag suppression must survive pointerup: the browser fires click *after*
// pointerup, so the flag is consumed by the click instead of being cleared
// with the drag state. A fresh pointerdown always resets it.
const suppressNextTabClick = ref(false);
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
// Special pages append to the focused group's strip only: one instance at a
// time, in the pane the user is working in (v0.6.2 kept them in the single strip).
const showSpecialPageTabs = computed(() => {
if (!props.specialPageTabs || !(props.specialPageTabs.settingsOpen || props.specialPageTabs.driverStoreOpen || props.specialPageTabs.pluginCenterOpen)) return false;
// With no regular query tabs there is no focus event to establish the
// focused group. Render the special-page tab in the sole (main) group so
// opening Plugin Center or Driver Manager by itself still creates a tab.
const isFocusedGroup = queryStore.focusedGroupId === props.groupId;
const isEmptyWorkspaceMainGroup = queryStore.tabs.length === 0 && props.groupId === queryStore.groups[0]?.id;
return isFocusedGroup || isEmptyWorkspaceMainGroup;
});
const specialPageActive = computed(() => !!(props.specialPageTabs?.settingsActive || props.specialPageTabs?.driverStoreActive || props.specialPageTabs?.pluginCenterActive));
function isTabActive(tab: QueryTab): boolean {
return !specialPageActive.value && tab.id === props.activeTabId;
}
function specialPageTabClass(active: boolean): string[] {
if (isVerticalLayout.value) {
return ["h-8 w-full rounded-md border", active ? "border-ring font-medium text-foreground" : "border-transparent text-foreground/70 hover:text-foreground/90"];
}
if (isClassicLayout.value) {
return ["h-full border-r border-border/80 font-medium dark:border-border/45", active ? "bg-background text-foreground" : "text-foreground/70 hover:text-foreground/90"];
}
return ["h-7 rounded-md border", active ? "border-ring font-medium text-foreground" : "border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90"];
}
function specialPageTabStyle(active: boolean) {
if (!active) return undefined;
const activeBackground = "color-mix(in srgb, var(--foreground) 18%, var(--background))";
if (isVerticalLayout.value) return { "--app-tab-background": "var(--accent)" };
if (!isClassicLayout.value) return { "--app-tab-background": activeBackground, borderColor: "var(--ring)" };
return { "--app-tab-background": activeBackground, boxShadow: "inset 0 -2px 0 color-mix(in srgb, var(--foreground) 72%, transparent)" };
}
const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");
const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap");
// The icon-only collapse only exists in the vertical toolbar; horizontal
// placements must ignore the persisted collapse state entirely.
const isTabBarCollapsed = computed(() => isVerticalLayout.value && !!props.tabBarCollapsed);
// Outer [data-workspace-tab-navigation] / [data-special-page-navigation] owns
// the shared vertical rail width; fill that rail so nested pane bars stay in
// sync while the resize handle updates the outer panel (issue #9977).
const tabBarStyle = computed<CSSProperties | undefined>(() => {
if (!isVerticalLayout.value) return undefined;
return { width: "100%", flex: "0 0 100%" };
});
const tabBarCollapseIcon = computed(() => {
const isLeft = settingsStore.editorSettings.tabPlacement === "left";
if (props.tabBarCollapsed) return isLeft ? ChevronsRight : ChevronsLeft;
return isLeft ? ChevronsLeft : ChevronsRight;
});
const tabBarCollapseLabel = computed(() => t(props.tabBarCollapsed ? "tabs.expandTabBar" : "tabs.collapseTabBar"));
const verticalTabToolbarButtonClass = "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const groupCapacityReached = computed(() => queryStore.groups.length >= 4);
const splitUnavailable = computed(() => groupCapacityReached.value || queryStore.tabs.length <= 1);
const canChangeOrientation = computed(() => queryStore.groups.length >= 2);
const isSecondaryGroup = computed(() => queryStore.groups[0]?.id !== props.groupId);
const compactTabTitle = computed({
get: () => settingsStore.editorSettings.compactTabTitle,
set: (checked: boolean | "indeterminate") => {
settingsStore.updateEditorSettings({ compactTabTitle: checked === true });
},
});
function toggleCompactTabTitle() {
compactTabTitle.value = !compactTabTitle.value;
}
function getSpecialPageTabMenuItems(surface: "settings" | "driverStore" | "pluginCenter"): ContextMenuItem[] {
const closeCurrent = surface === "settings" ? () => emit("close-settings") : surface === "driverStore" ? () => emit("close-driver-store") : () => emit("close-plugin-center");
const otherOpen =
surface === "settings" ? props.specialPageTabs?.driverStoreOpen || props.specialPageTabs?.pluginCenterOpen : surface === "driverStore" ? props.specialPageTabs?.settingsOpen || props.specialPageTabs?.pluginCenterOpen : props.specialPageTabs?.settingsOpen || props.specialPageTabs?.driverStoreOpen;
const closeOthers = () => {
if (surface !== "settings") emit("close-settings");
if (surface !== "driverStore") emit("close-driver-store");
if (surface !== "pluginCenter") emit("close-plugin-center");
};
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: t("contextMenu.closeOtherTabs"),
action: closeOthers,
disabled: !otherOpen,
icon: X,
},
{ label: t("contextMenu.closeAllTabs"), action: closeCurrent, variant: "destructive", icon: X },
];
}
const hasHorizontalFixedRows = computed(() => !isVerticalLayout.value && props.tabs.some((tab) => tab.pinned) && (props.tabs.some((tab) => !tab.pinned) || showSpecialPageTabs.value));
const tabBarClass = computed(() => [
isClassicLayout.value ? "classic-tab-layout" : "separated-tab-layout",
isVerticalLayout.value
? `vertical-tab-layout h-full w-60 flex-col bg-background ${settingsStore.editorSettings.tabPlacement === "right" ? "border-l" : "border-r"}`
: isClassicLayout.value
? "bg-muted"
: `bg-background ${settingsStore.editorSettings.tabPlacement === "bottom" ? "border-t" : "border-b"}`,
isVerticalLayout.value && props.tabBarCollapsed ? "vertical-tab-layout--collapsed" : "",
hasHorizontalFixedRows.value ? "horizontal-fixed-tabs" : "",
]);
const tabsContainerStyle = computed<CSSProperties>(() => ({
msOverflowStyle: "none",
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
}));
const tabScrollbarThumbStyle = computed<CSSProperties>(() => ({
insetInlineStart: `${scrollThumbLeftPercent.value}%`,
width: `${scrollThumbWidthPercent.value}%`,
}));
// Overflow search lists this group's tabs (mirrors the legacy AppTabBar
// overflow popover, scoped to the group that owns the strip).
const tabOverflowOpen = ref(false);
const tabLayoutRevision = ref(0);
// The overflow popover's "search opened tabs" query is scoped to the popover
// list only. It must not reach the strip: with a shared query, typing a term
// with no match would empty the always-visible top tab bar while the active
// tab's content stays on screen.
const tabOverflowSearchQuery = ref("");
// The strip's own search box lives only in the vertical toolbar, where
// filtering the strip itself is the point of the search.
const tabSearchQuery = ref("");
const filteredGroupTabs = computed(() => {
const query = tabOverflowSearchQuery.value.trim().toLocaleLowerCase();
if (!query) {
return props.tabs;
}
return props.tabs.filter((tab) => tabTitleText(tab).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query));
});
watch(tabOverflowOpen, (open) => {
tabOverflowSearchQuery.value = "";
if (open) {
nextTick(() => document.querySelector<HTMLInputElement>("[data-group-tab-search-input]")?.focus());
}
});
function setTabSectionRef(key: string, element: Element | null) {
const row = element instanceof HTMLElement ? element : null;
if (key === "fixed") {
fixedTabsRowRef.value = row;
} else {
regularTabsRowRef.value = row;
}
}
function tabSectionScroll(key: string) {
return key === "fixed" ? fixedTabsScroll : regularTabsScroll;
}
function tabSectionThumbStyle(key: string): CSSProperties {
const scroll = tabSectionScroll(key);
return {
insetInlineStart: `${scroll.scrollThumbLeftPercent.value}%`,
width: `${scroll.scrollThumbWidthPercent.value}%`,
};
}
function tabSectionHasOverflow(key: string): boolean {
const row = key === "fixed" ? fixedTabsRowRef.value : regularTabsRowRef.value;
return !!row && row.scrollWidth - row.clientWidth > 1;
}
function hasHorizontalRowOverflow(): boolean {
return tabSectionHasOverflow("fixed") || tabSectionHasOverflow("regular");
}
function onHorizontalTabWheel(event: WheelEvent) {
// A trackpad can emit vertical and horizontal deltas together. Only let a
// clearly horizontal gesture reach the row's native horizontal scroller.
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) {
event.preventDefault();
event.stopPropagation();
}
}
function revealActiveTabAfterLayout() {
const activeTab = tabsContainerRef.value?.querySelector<HTMLElement>('[data-active-tab="true"]');
if (!activeTab) return;
activeTab.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: "nearest" });
}
const showOverflowControl = computed(() => {
void tabLayoutRevision.value;
const hasOverflow = hasHorizontalFixedRows.value ? fixedTabsScroll.hasTabOverflow.value || regularTabsScroll.hasTabOverflow.value || hasHorizontalRowOverflow() : hasTabOverflow.value;
return props.tabs.length > 0 && hasOverflow && !isWrapLayout.value && !isVerticalLayout.value;
});
const tabTailDragRegionClass = computed(() => {
if (isWrapLayout.value || isVerticalLayout.value) return "w-0 flex-none self-stretch";
if (showOverflowControl.value) return "tab-tail-overflow-spacer flex-none self-stretch";
return "min-w-8 flex-1 self-stretch";
});
watch(
() => props.tabBarCollapsed,
(collapsed) => {
if (collapsed) tabSearchQuery.value = "";
},
);
/**
* Semantic tab groups (pills) are a render-time clustering of the tabs this
* pane's strip displays, keyed by the global tabGroupMode. Names/colors live
* in the global tabGroupCustomizations profile store, so every pane renders
* the same pill identity for the same key. Profile actions — rename, recolor,
* reset — act on that global profile and therefore reach every pane. Closing
* a cluster is the one destructive action and stays bar-local: it removes
* only this pane's tabs of the key (see tabsInSemanticGroup).
*/
const tabGroupItems = computed(() => [
{ value: "none", label: t("settings.tabGroupNone") },
{ value: "database-type", label: t("settings.tabGroupDatabaseType") },
{ value: "database", label: t("settings.tabGroupDatabase") },
{ value: "connection", label: t("settings.tabGroupConnection") },
]);
const tabSortItems = computed(() => [
{ value: "manual", label: t("settings.tabSortManual") },
{ value: "created-asc", label: t("settings.tabSortCreated") },
{ value: "title-asc", label: t("settings.tabSortTitle") },
]);
const tabPlacementItems = computed(() => [
{ value: "top", label: t("settings.tabPlacementTop") },
{ value: "bottom", label: t("settings.tabPlacementBottom") },
{ value: "left", label: t("settings.tabPlacementLeft") },
{ value: "right", label: t("settings.tabPlacementRight") },
]);
type TabPreferencePatch = Partial<Pick<EditorSettings, "tabPlacement" | "tabGroupMode" | "tabSortMode" | "tabGroupCustomizations">>;
async function persistTabPreferences(partial: TabPreferencePatch) {
try {
await settingsStore.updateEditorSettingsAndPersist(partial);
} catch (error) {
toast(t("tabs.settingsSaveFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
}
}
function updateTabGroupMode(value: string) {
if (value === "none" || value === "database-type" || value === "database" || value === "connection") void persistTabPreferences({ tabGroupMode: value });
}
function updateTabSortMode(value: string) {
if (value === "manual" || value === "created-asc" || value === "title-asc") void persistTabPreferences({ tabSortMode: value });
}
function updateTabPlacement(value: string) {
if (value === "top" || value === "bottom" || value === "left" || value === "right") void persistTabPreferences({ tabPlacement: value });
}
function databaseTabGroupKey(tab: QueryTab) {
const database = tab.database || "";
if (connectionStore.getConfig(tab.connectionId)?.db_type === "redis") {
return JSON.stringify([tab.connectionId, tab.catalog || "", "redis"]);
}
// A connection-level tab has no database scope, so its catalog cannot split the group.
return JSON.stringify([tab.connectionId, database ? tab.catalog || "" : "", database]);
}
function tabGroupKey(tab: QueryTab) {
const connection = connectionStore.getConfig(tab.connectionId);
if (settingsStore.editorSettings.tabGroupMode === "connection") return tab.connectionId;
if (settingsStore.editorSettings.tabGroupMode === "database") return databaseTabGroupKey(tab);
return connection?.driver_profile || connection?.db_type || tab.connectionId || "unknown";
}
function compareTabGroupKeys(left: string, right: string) {
if (left === right) return 0;
const localized = left.localeCompare(right, undefined, { sensitivity: "base", numeric: true });
// Collation may consider case-distinct identities equal; the raw tie-break keeps clusters contiguous.
return localized || (left < right ? -1 : 1);
}
// Numbered across every open tab (not just this group's) so a tab keeps the
// same label when the same query tab is moved between panes.
const tabTitles = computed(() => tabDisplayTitles(queryStore.tabs, t));
function tabTitleText(tab: QueryTab) {
return tabTitles.value.get(tab.id) ?? tabDisplayTitle(tab, t);
}
function tabConnectionLabel(tab: QueryTab) {
// 连接已删除但页签被保留时,回退到原连接名,避免显示成裸 uuid。
return connectionStore.getConfig(tab.connectionId)?.name || tab.detachedConnectionName || tab.connectionId;
}
function tabConnectionTargetLabel(tab: QueryTab) {
const connection = connectionStore.getConfig(tab.connectionId);
const host = connection?.host.trim();
return connection && host ? `${host}:${connection.port}` : tab.detachedConnectionName || tab.connectionId;
}
function databaseTabGroupBaseLabel(tab: QueryTab) {
if (connectionStore.getConfig(tab.connectionId)?.db_type === "redis") return tabConnectionLabel(tab);
if (!tab.database) return tabConnectionLabel(tab);
return [tab.database, ...(tab.catalog ? [tab.catalog] : [])].join(" · ");
}
const databaseTabGroupIdentityDisplaysByLabel = computed(() => {
const identitiesByLabel = new Map<string, Map<string, { connectionLabel: string; connectionTargetLabel: string }>>();
for (const tab of props.tabs) {
const label = databaseTabGroupBaseLabel(tab);
const identities = identitiesByLabel.get(label) ?? new Map<string, { connectionLabel: string; connectionTargetLabel: string }>();
identities.set(databaseTabGroupKey(tab), {
connectionLabel: tabConnectionLabel(tab),
connectionTargetLabel: tabConnectionTargetLabel(tab),
});
identitiesByLabel.set(label, identities);
}
return identitiesByLabel;
});
/**
* Sorts a section of this pane's tabs for display. With a group mode active,
* tabs cluster by group key first; within a cluster (and entirely under
* manual mode) the stored per-pane order wins unless another sort mode is set.
*/
function sortDisplayedTabs(tabs: QueryTab[]) {
const sortMode = settingsStore.editorSettings.tabSortMode;
const groupMode = settingsStore.editorSettings.tabGroupMode;
return tabs
.map((tab, index) => ({ tab, index }))
.sort((left, right) => {
if (groupMode !== "none") {
const group = compareTabGroupKeys(tabGroupKey(left.tab), tabGroupKey(right.tab));
if (group) return group;
}
if (sortMode === "manual") return left.index - right.index;
if (sortMode === "created-asc") {
const created = (left.tab.createdAt ?? left.index) - (right.tab.createdAt ?? right.index);
if (created) return created;
} else {
const title = tabTitleText(left.tab).localeCompare(tabTitleText(right.tab), undefined, { sensitivity: "base", numeric: true });
if (title) return title;
}
return left.index - right.index;
})
.map(({ tab }) => tab);
}
const sortedPinnedTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab) => tab.pinned)));
const sortedRegularTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab) => !tab.pinned)));
const collapsedTabGroups = ref<Set<string>>(new Set());
const collapsingTabGroups = ref<Set<string>>(new Set());
const tabGroupCollapseTimers = new Map<string, number>();
const TAB_GROUP_COLLAPSE_MS = 140;
const pendingTabScrollRestore = ref<{ fixed: number; regular: number } | null>(null);
const pendingExpandedTabGroupReveal = ref<string | null>(null);
const tabGroupPalette = ["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"];
const tabGroupEditorOpen = ref(false);
const editingTabGroupKey = ref("");
const editingTabGroupDefaultLabel = ref("");
const editingTabGroupName = ref("");
const editingTabGroupColor = ref("");
const editingTabGroupFallbackColor = ref(tabGroupPalette[0]!);
function tabGroupDefaultLabel(tab: QueryTab) {
const connection = connectionStore.getConfig(tab.connectionId);
if (settingsStore.editorSettings.tabGroupMode === "connection") return connection?.name || tab.connectionId;
if (settingsStore.editorSettings.tabGroupMode === "database") {
const baseLabel = databaseTabGroupBaseLabel(tab);
const identityDisplays = databaseTabGroupIdentityDisplaysByLabel.value.get(baseLabel);
if ((identityDisplays?.size ?? 0) <= 1) return baseLabel;
const connectionLabel = tabConnectionLabel(tab);
const sameNameDisplays = [...identityDisplays!.values()].filter((display) => display.connectionLabel === connectionLabel);
if (sameNameDisplays.length <= 1) return `${baseLabel} · ${connectionLabel}`;
const connectionTargetLabel = tabConnectionTargetLabel(tab);
if (sameNameDisplays.filter((display) => display.connectionTargetLabel === connectionTargetLabel).length <= 1) return `${baseLabel} · ${connectionLabel} · ${connectionTargetLabel}`;
return `${baseLabel} · ${connectionLabel} · ${connectionTargetLabel} · ${tab.connectionId}`;
}
return connection?.driver_label || connection?.driver_profile || connection?.db_type || tab.connectionId;
}
function tabGroupCustomizationKey(tab: QueryTab) {
return `${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`;
}
function tabGroupCustomization(tab: QueryTab) {
return settingsStore.editorSettings.tabGroupCustomizations[tabGroupCustomizationKey(tab)];
}
function tabGroupLabel(tab: QueryTab) {
return tabGroupCustomization(tab)?.name || tabGroupDefaultLabel(tab);
}
// Pinned and regular tabs form separate clusters even under the same key.
function tabGroupId(tab: QueryTab) {
return `${tab.pinned ? "fixed" : "regular"}:${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`;
}
function isTabGroupCollapsed(tab: QueryTab) {
if (settingsStore.editorSettings.tabGroupMode === "none") return false;
return collapsedTabGroups.value.has(tabGroupId(tab));
}
const activeTabGroupId = computed(() => {
const activeTab = props.tabs.find((item) => isTabActive(item));
return activeTab ? tabGroupId(activeTab) : null;
});
function isTabGroupActive(tab: QueryTab) {
return activeTabGroupId.value === tabGroupId(tab);
}
function preserveTabScrollPosition() {
pendingTabScrollRestore.value = {
fixed: fixedTabsRowRef.value?.scrollLeft ?? 0,
regular: regularTabsRowRef.value?.scrollLeft ?? 0,
};
}
function restoreTabScrollPosition(position: { fixed: number; regular: number }) {
if (fixedTabsRowRef.value) fixedTabsRowRef.value.scrollLeft = position.fixed;
if (regularTabsRowRef.value) regularTabsRowRef.value.scrollLeft = position.regular;
}
function revealExpandedTabGroupStartIfHidden(groupId: string) {
if (pendingExpandedTabGroupReveal.value !== groupId) return;
pendingExpandedTabGroupReveal.value = null;
if (isWrapLayout.value || isVerticalLayout.value) return;
const entries = Array.from(tabsContainerRef.value?.querySelectorAll<HTMLElement>(".tab-group-entry[data-tab-group-id]") ?? []).filter((entry) => entry.dataset.tabGroupId === groupId && !entry.classList.contains("tab-group-entry--collapsed"));
const pills = entries.map((entry) => entry.querySelector<HTMLElement>(".tab-group-tab")).filter((pill): pill is HTMLElement => !!pill);
const firstPill = pills[0];
if (!firstPill) return;
const scrollContainer = hasHorizontalFixedRows.value ? firstPill.closest<HTMLElement>(".tab-section--horizontal") : tabsContainerRef.value;
if (!scrollContainer) return;
const viewport = scrollContainer.getBoundingClientRect();
const firstRect = firstPill.getBoundingClientRect();
const viewportPadding = 4;
const scrollRight = firstRect.right - (viewport.right - viewportPadding);
const scrollLeft = firstRect.left - (viewport.left + viewportPadding);
const scrollDelta = scrollRight > 0 ? scrollRight : scrollLeft < 0 ? scrollLeft : 0;
if (scrollDelta === 0) return;
scrollContainer.scrollBy({ left: scrollDelta, behavior: tabScrollBehavior.value });
}
function captureExpandedTabGroupWidths(groupIds: Set<string>) {
if (isWrapLayout.value || isVerticalLayout.value) return;
tabsContainerRef.value?.querySelectorAll<HTMLElement>(".tab-group-entry[data-tab-group-id]").forEach((entry) => {
const groupId = entry.dataset.tabGroupId;
if (!groupId || !groupIds.has(groupId) || entry.classList.contains("tab-group-entry--collapsed")) return;
const width = entry.getBoundingClientRect().width;
if (width <= 0) return;
entry.style.setProperty("--tab-group-entry-expanded-width", String(width) + "px");
});
}
function cancelTabGroupCollapse(groupId: string) {
const timer = tabGroupCollapseTimers.get(groupId);
if (timer !== undefined) window.clearTimeout(timer);
tabGroupCollapseTimers.delete(groupId);
if (!collapsingTabGroups.value.has(groupId)) return;
const next = new Set(collapsingTabGroups.value);
next.delete(groupId);
collapsingTabGroups.value = next;
}
function beginTabGroupCollapse(groupIds: Set<string>) {
if (isWrapLayout.value) {
collapsedTabGroups.value = new Set([...collapsedTabGroups.value, ...groupIds]);
nextTick(refreshHorizontalTabOverflow);
return;
}
const pending = [...groupIds].filter((groupId) => !collapsedTabGroups.value.has(groupId) && !collapsingTabGroups.value.has(groupId));
if (!pending.length) return;
captureExpandedTabGroupWidths(new Set(pending));
collapsingTabGroups.value = new Set([...collapsingTabGroups.value, ...pending]);
collapsedTabGroups.value = new Set([...collapsedTabGroups.value, ...pending]);
pending.forEach((groupId) => {
tabGroupCollapseTimers.set(
groupId,
window.setTimeout(() => {
tabGroupCollapseTimers.delete(groupId);
const collapsing = new Set(collapsingTabGroups.value);
collapsing.delete(groupId);
collapsingTabGroups.value = collapsing;
nextTick(refreshHorizontalTabOverflow);
}, TAB_GROUP_COLLAPSE_MS),
);
});
}
function refreshHorizontalTabOverflow() {
updateScrollButtons();
fixedTabsScroll.updateScrollButtons();
regularTabsScroll.updateScrollButtons();
tabLayoutRevision.value += 1;
}
function handleTabGroupTransitionEnd(event: TransitionEvent) {
if (event.propertyName !== "max-width") return;
const entry = event.currentTarget as HTMLElement;
if (!entry.classList.contains("tab-group-entry--collapsed")) {
entry.style.removeProperty("--tab-group-entry-expanded-width");
const groupId = entry.dataset.tabGroupId;
if (groupId) revealExpandedTabGroupStartIfHidden(groupId);
}
refreshHorizontalTabOverflow();
}
function toggleTabGroup(tab: QueryTab) {
preserveTabScrollPosition();
const groupId = tabGroupId(tab);
if (collapsingTabGroups.value.has(groupId)) {
cancelTabGroupCollapse(groupId);
}
const next = new Set(collapsedTabGroups.value);
if (next.has(groupId)) {
pendingExpandedTabGroupReveal.value = !isWrapLayout.value && !isVerticalLayout.value ? groupId : null;
next.delete(groupId);
collapsedTabGroups.value = next;
nextTick(refreshHorizontalTabOverflow);
return;
}
if (pendingExpandedTabGroupReveal.value === groupId) pendingExpandedTabGroupReveal.value = null;
beginTabGroupCollapse(new Set([groupId]));
}
function tabGroupIdsInPane() {
if (settingsStore.editorSettings.tabGroupMode === "none") return [];
return [...new Set(props.tabs.map((tab) => tabGroupId(tab)))];
}
function collapseAllTabGroups() {
preserveTabScrollPosition();
beginTabGroupCollapse(new Set(tabGroupIdsInPane()));
}
function expandAllTabGroups() {
preserveTabScrollPosition();
[...tabGroupCollapseTimers.keys()].forEach(cancelTabGroupCollapse);
collapsedTabGroups.value = new Set();
nextTick(refreshHorizontalTabOverflow);
}
function expandTabGroupForTab(tabId: string | null) {
if (!tabId || settingsStore.editorSettings.tabGroupMode === "none") return;
const tab = queryStore.tabs.find((item) => item.id === tabId);
if (!tab) return;
const groupId = tabGroupId(tab);
cancelTabGroupCollapse(groupId);
if (!collapsedTabGroups.value.has(groupId)) return;
const next = new Set(collapsedTabGroups.value);
next.delete(groupId);
collapsedTabGroups.value = next;
}
function defaultTabGroupColor(tab: QueryTab) {
const connectionGroupColor = settingsStore.editorSettings.tabGroupMode === "connection" ? connectionColor(tab.connectionId) : undefined;
let hash = 0;
for (const character of tabGroupKey(tab)) hash = (hash * 31 + character.codePointAt(0)!) | 0;
return connectionGroupColor || tabGroupPalette[Math.abs(hash) % tabGroupPalette.length]!;
}
function resolvedTabGroupColor(tab: QueryTab) {
return tabGroupCustomization(tab)?.color || defaultTabGroupColor(tab);
}
function groupColorStyle(color: string): CSSProperties {
return {
"--tab-group-color": color,
"--tab-group-soft": hexToRgba(color, 0.12),
} as CSSProperties;
}
function tabGroupStyle(tab: QueryTab): CSSProperties {
return groupColorStyle(resolvedTabGroupColor(tab));
}
const tabGroupEditorPreviewStyle = computed(() => groupColorStyle(editingTabGroupColor.value || editingTabGroupFallbackColor.value));
const editingTabGroupHasCustomization = computed(() => {
const customization = settingsStore.editorSettings.tabGroupCustomizations[editingTabGroupKey.value];
return !!(customization?.name || customization?.color);
});
function openTabGroupEditor(tab: QueryTab) {
const customization = tabGroupCustomization(tab);
editingTabGroupKey.value = tabGroupCustomizationKey(tab);
editingTabGroupDefaultLabel.value = tabGroupDefaultLabel(tab);
editingTabGroupName.value = customization?.name || "";
editingTabGroupColor.value = customization?.color || "";
editingTabGroupFallbackColor.value = defaultTabGroupColor(tab);
tabGroupEditorOpen.value = true;
nextTick(() => document.querySelector<HTMLInputElement>("[data-tab-group-name-input]")?.focus());
}
async function persistTabGroupCustomization(name: string, color: string) {
if (!editingTabGroupKey.value) return false;
const customizations = { ...settingsStore.editorSettings.tabGroupCustomizations };
const normalizedName = name.trim();
if (normalizedName || color) customizations[editingTabGroupKey.value] = { ...(normalizedName ? { name: normalizedName } : {}), ...(color ? { color } : {}) };
else delete customizations[editingTabGroupKey.value];
try {
await settingsStore.updateEditorSettingsAndPersist({ tabGroupCustomizations: customizations });
return true;
} catch (error) {
toast(t("tabs.settingsSaveFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
return false;
}
}
async function saveTabGroupCustomization() {
if (await persistTabGroupCustomization(editingTabGroupName.value, editingTabGroupColor.value)) tabGroupEditorOpen.value = false;
}
function updateCustomTabGroupColor(event: Event) {
editingTabGroupColor.value = (event.target as HTMLInputElement).value;
}
async function resetTabGroupCustomization(tab?: QueryTab) {
if (tab) editingTabGroupKey.value = tabGroupCustomizationKey(tab);
if ((await persistTabGroupCustomization("", "")) && !tab) tabGroupEditorOpen.value = false;
}
/**
* The semantic cluster as displayed in THIS pane's strip: same group key,
* same pinned section. Closing a cluster is a destructive, bar-local action —
* the same-key cluster in another pane is left untouched, while profile edits
* (rename/color/reset) keep their global reach.
*/
function tabsInSemanticGroup(tab: QueryTab) {
if (settingsStore.editorSettings.tabGroupMode === "none") return [];
const groupKey = tabGroupKey(tab);
return props.tabs.filter((item) => item.pinned === tab.pinned && tabGroupKey(item) === groupKey);
}
/**
* Closes this pane's cluster of the semantic group. The store's batch close
* prunes the pane if the removal empties it, so the pane invariant (panes
* never die with their tabs inside) is preserved.
*/
function closeTabGroup(tab: QueryTab) {
const tabsToClose = tabsInSemanticGroup(tab).map((item) => item.id);
if (tabsToClose.length === 0) return;
const finalActiveTabId = queryStore.activeTabId && !tabsToClose.includes(queryStore.activeTabId) ? queryStore.activeTabId : (queryStore.tabs.find((item) => !tabsToClose.includes(item.id))?.id ?? null);
queryStore.closeTabsByIds(tabsToClose, finalActiveTabId);
}
const tabOrganizationItems = computed(() => [
...tabPlacementItems.value.map((item, index) => ({ ...item, value: `placement:${item.value}`, icon: { top: PanelTop, bottom: PanelBottom, left: PanelLeft, right: PanelRight }[item.value], groupLabel: index === 0 ? t("settings.tabPlacement") : undefined })),
...tabGroupItems.value.map((item, index) => ({ ...item, value: `group:${item.value}`, icon: { none: Ungroup, "database-type": Database, database: Database, connection: Server }[item.value], separatorBefore: index === 0, groupLabel: index === 0 ? t("settings.tabGroup") : undefined })),
...tabSortItems.value.map((item, index) => ({ ...item, value: `sort:${item.value}`, icon: { manual: ListOrdered, "created-asc": Clock3, "title-asc": ArrowDownAZ }[item.value], separatorBefore: index === 0, groupLabel: index === 0 ? t("settings.tabSort") : undefined })),
]);
const selectedTabOrganizationItems = computed(() => [`placement:${settingsStore.editorSettings.tabPlacement}`, `group:${settingsStore.editorSettings.tabGroupMode}`, `sort:${settingsStore.editorSettings.tabSortMode}`]);
function selectTabOrganizationItem(value: string) {
const [section, option] = value.split(":");
if (!option) return;
if (section === "placement") updateTabPlacement(option);
else if (section === "group") updateTabGroupMode(option);
else if (section === "sort") updateTabSortMode(option);
}
function getTabGroupMenuItems(tab: QueryTab): ContextMenuItem[] {
const customization = tabGroupCustomization(tab);
return [
{
label: t("contextMenu.editTabGroup"),
action: () => openTabGroupEditor(tab),
icon: Pencil,
},
{
label: t("contextMenu.resetTabGroup"),
action: () => resetTabGroupCustomization(tab),
icon: RotateCcw,
visible: !!(customization?.name || customization?.color),
},
{ label: "", separator: true },
{
label: t("contextMenu.collapseAll"),
action: collapseAllTabGroups,
icon: ChevronsDownUp,
visible: settingsStore.editorSettings.tabGroupMode !== "none",
},
{
label: t("contextMenu.expandAll"),
action: expandAllTabGroups,
icon: ChevronsUpDown,
visible: settingsStore.editorSettings.tabGroupMode !== "none",
},
{ label: "", separator: true },
{
label: t("contextMenu.closeTabGroup"),
action: () => closeTabGroup(tab),
icon: X,
variant: "destructive",
},
];
}
function openTabGroupContextMenu(event: MouseEvent, open: (event: MouseEvent) => void) {
event.preventDefault();
document.getSelection()?.removeAllRanges();
open(event);
}
type StripEntry = { kind: "header"; key: string; tab: QueryTab; pinned: boolean; count: number } | { kind: "tab"; key: string; tab: QueryTab; groupFirst: boolean; groupLast: boolean; grouping: boolean };
/**
* Flattens the strip's two sections (pinned, then regular) into render
* entries: a group header before each semantic cluster, then that cluster's
* pills. Collapsed pills remain mounted so their visibility can animate.
*/
function tabMatchesSearch(tab: QueryTab, query: string) {
const title = tabTitleText(tab).toLocaleLowerCase();
return title.includes(query) || tab.title.toLocaleLowerCase().includes(query);
}
/**
* The strips apply the vertical toolbar search box: sections filter by title
* before clustering. The overflow popover's query (tabOverflowSearchQuery) is
* scoped to the popover list and deliberately does not reach the strip.
*/
const filteredPinnedTabs = computed(() => {
const query = tabSearchQuery.value.trim().toLocaleLowerCase();
return query ? sortedPinnedTabs.value.filter((tab) => tabMatchesSearch(tab, query)) : sortedPinnedTabs.value;
});
const filteredRegularTabs = computed(() => {
const query = tabSearchQuery.value.trim().toLocaleLowerCase();
return query ? sortedRegularTabs.value.filter((tab) => tabMatchesSearch(tab, query)) : sortedRegularTabs.value;
});
function buildStripEntries(section: QueryTab[], pinned: boolean): StripEntry[] {
const entries: StripEntry[] = [];
const grouping = settingsStore.editorSettings.tabGroupMode !== "none";
const groupKeys = grouping ? section.map(tabGroupKey) : [];
const groupCounts = new Map<string, number>();
for (const groupKey of groupKeys) {
groupCounts.set(groupKey, (groupCounts.get(groupKey) ?? 0) + 1);
}
section.forEach((tab, index) => {
const groupKey = groupKeys[index];
const first = grouping && (index === 0 || groupKeys[index - 1] !== groupKey);
const last = grouping && (index === section.length - 1 || groupKeys[index + 1] !== groupKey);
if (first) {
entries.push({ kind: "header", key: `header:${tab.id}`, tab, pinned, count: groupCounts.get(groupKey!) ?? 0 });
}
entries.push({ kind: "tab", key: tab.id, tab, groupFirst: first, groupLast: last, grouping });
});
return entries;
}
const pinnedStripEntries = computed(() => buildStripEntries(filteredPinnedTabs.value, true));
const regularStripEntries = computed(() => buildStripEntries(filteredRegularTabs.value, false));
const stripSections = computed(() => {
const fixed = { key: "fixed", pinned: true, entries: pinnedStripEntries.value };
const regular = { key: "regular", pinned: false, entries: regularStripEntries.value };
if (isVerticalLayout.value) return [fixed, regular];
if (props.tabs.some((tab) => tab.pinned) && !props.tabs.some((tab) => !tab.pinned) && !showSpecialPageTabs.value) return [fixed];
if (!hasHorizontalFixedRows.value) return [regular];
return settingsStore.editorSettings.tabPlacement === "top" ? [regular, fixed] : [fixed, regular];
});
function tabColorStyle(tab: QueryTab): CSSProperties | undefined {
// Sidebar tabs carry their group/connection color as a soft active wash;
// the active indicator itself comes from the vertical CSS rules.
if (isVerticalLayout.value) {
if (!isTabActive(tab)) {
return undefined;
}
const color = connectionColor(tab.connectionId);
return { "--app-tab-background": color ? hexToRgba(color, 0.12) : "var(--accent)" } as CSSProperties;
}
return sharedTabColorStyle(tab, isTabActive(tab), isClassicLayout.value);
}
const tabTooltipSide = computed(() => {
if (!isVerticalLayout.value) return "bottom" as const;
return settingsStore.editorSettings.tabPlacement === "left" ? ("right" as const) : ("left" as const);
});
function updateTabTooltipOpen(tabId: string, open: boolean) {
if (open) {
openTabTooltipId.value = tabId;
} else if (openTabTooltipId.value === tabId) {
openTabTooltipId.value = null;
}
}
function closeTabTooltip(tabId: string) {
if (openTabTooltipId.value === tabId) {
openTabTooltipId.value = null;
}
}
function isConnectionlessPluginTab(tab: QueryTab): boolean {
return (tab.mode === "plugin-workbench" || tab.mode === "plugin-filesystem") && !tab.connectionId;
}
/**
* Drag visuals for a pill, ported from the legacy tab bar's tabDropStyle: the
* dragged pill dims, and the hovered pill shows an inset ring line marking
* whether the drop lands before or after it.
*/
function tabDropStyle(tab: QueryTab): CSSProperties | undefined {
if (!groupTabDrag.active) {
return undefined;
}
if (groupTabDrag.tabId === tab.id) {
return { opacity: 0.4 };
}
if (groupTabDrag.targetTabId !== tab.id) {
return undefined;
}
if (isVerticalLayout.value) {
return groupTabDrag.position === "before" ? { borderTop: "2px solid var(--ring)" } : { borderBottom: "2px solid var(--ring)" };
}
if (groupTabDrag.position === "before") {
return { boxShadow: "inset 3px 0 0 0 var(--ring)" };
}
return { boxShadow: "inset -3px 0 0 0 var(--ring)" };
}
function createTabDragGhost(sourceEl: HTMLElement, x: number, y: number) {
const ghost = document.createElement("div");
const textNode = sourceEl.querySelector(".truncate");
ghost.textContent = textNode?.textContent || "";
ghost.style.cssText = `position: fixed; pointer-events: none; z-index: 9999; opacity: 0.9; box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: var(--dbx-radius-fixed-6); background: var(--background, #fff); border: 1px solid var(--border, #e5e7eb); max-width: 200px; height: 28px; padding: 0 12px; font-size: 12px; line-height: 28px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; left: ${x + 12}px; top: ${y - 14}px;`;
document.body.appendChild(ghost);
return ghost;
}
function moveTabDragGhost(x: number, y: number) {
if (groupTabDragGhost) {
groupTabDragGhost.style.left = `${x + 8}px`;
groupTabDragGhost.style.top = `${y - 14}px`;
}
}
function removeTabDragGhost() {
groupTabDragGhost?.remove();
groupTabDragGhost = null;
}
function isDirtyTab(tab: QueryTab) {
return queryStore.isTabDirty(tab);
}
function tabTitleStyle(tab: QueryTab): CSSProperties | undefined {
return dirtyTabTitleStyle(isDirtyTab(tab));
}
function dispatchBeforeTabSwitch(tabId: string) {
if (tabId === props.activeTabId) {
return;
}
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId: props.activeTabId } }));
}
function activateTab(tabId: string) {
emit("activate-tab", tabId);
}
function handleTabPointerDown(event: PointerEvent, tab: QueryTab) {
if (event.button !== 0) {
return;
}
// Reordering only has a stable meaning when the strip follows the manual
// order. Derived sort modes would immediately move a dragged tab back to
// their computed position, so keep those strips click-only.
if (settingsStore.editorSettings.tabSortMode !== "manual") {
return;
}
// A drag session is already in progress (second pointer device): ignore.
if (groupTabDrag.active) {
return;
}
suppressNextTabClick.value = false;
if (event.target instanceof Element && event.target.closest("button, input, [role='button']")) {
return;
}
// Match the legacy tab bar: flush pending grid edits before the visible tab changes.
dispatchBeforeTabSwitch(tab.id);
if (event.pointerType === "touch") {
// Touch does not arm the drag; the strip's native scroll owns the gesture.
return;
}
// macOS reports a trackpad tap as a mouse pointer with button=0, but
// buttons=0. It is a click gesture, not a held primary-button drag.
if ((event.buttons & 1) !== 1) {
return;
}
groupTabDragSourceEl = event.currentTarget as HTMLElement | null;
groupTabDrag.active = false;
groupTabDrag.tabId = tab.id;
groupTabDrag.sourceGroupId = props.groupId;
groupTabDrag.payload = serializeTabDragPayload({ tabId: tab.id, sourceGroupId: props.groupId });
groupTabDrag.startX = event.clientX;
groupTabDrag.startY = event.clientY;
groupTabDrag.targetGroupId = null;
groupTabDrag.targetTabId = null;
groupTabDrag.position = null;
groupTabDrag.pointerId = event.pointerId;
window.addEventListener("pointermove", handleTabPointerMove);
window.addEventListener("pointerup", handleTabPointerUp);
window.addEventListener("pointercancel", cleanupTabDrag);
window.addEventListener("blur", cleanupTabDrag);
}
function canRenameTab(tab: QueryTab) {
return tab.mode === "query";
}
function isDetachableTab(tab: QueryTab) {
return tab.mode === "query" || tab.mode === "data";
}
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}"]`);
if (input) {
input.focus();
const dotIndex = input.value.lastIndexOf(".");
const selectEnd = dotIndex > 0 ? dotIndex : input.value.length;
input.setSelectionRange(0, selectEnd);
}
});
}
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 closeTab(tab: QueryTab) {
queryStore.closeTab(tab.id);
}
/**
* Close-left/right operate on this pane's *display* order within the tab's
* pinned/regular section, so the menu closes exactly the pills shown on that
* side even when a non-manual sort mode reorders the strip.
*/
function tabsToLeftInDisplayOrder(tab: QueryTab) {
const section = tab.pinned ? sortedPinnedTabs.value : sortedRegularTabs.value;
const targetIndex = section.findIndex((item) => item.id === tab.id);
return targetIndex < 0 ? [] : section.slice(0, targetIndex);
}
function tabsToRightInDisplayOrder(tab: QueryTab) {
const section = tab.pinned ? sortedPinnedTabs.value : sortedRegularTabs.value;
const targetIndex = section.findIndex((item) => item.id === tab.id);
return targetIndex < 0 ? [] : section.slice(targetIndex + 1);
}
function hasTabsToRight(tab: QueryTab) {
return tabsToRightInDisplayOrder(tab).length > 0;
}
function hasTabsToLeft(tab: QueryTab) {
return tabsToLeftInDisplayOrder(tab).length > 0;
}
function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
const items: ContextMenuItem[] = [
{
label: compactTabTitle.value ? t("contextMenu.fullTabTitle") : t("contextMenu.compactTabTitle"),
action: toggleCompactTabTitle,
icon: compactTabTitle.value ? Maximize2 : Minimize2,
},
...createRenameDuplicateTabItems({
tab,
t,
canRename: canRenameTab(tab),
onRename: () => startRenameTab(tab),
onDuplicate: () => queryStore.duplicateTab(tab.id),
}),
{
label: t("contextMenu.copyName"),
action: async () => {
try {
await copyToClipboard(tabTitleText(tab));
toast(t("connection.copied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
},
icon: Copy,
},
{
label: t("tabs.openInNewWindow"),
action: () => emit("detach-tab", tab),
icon: Maximize2,
visible: !!props.canDetachTabs && isDetachableTab(tab),
},
createLocateTabMenuItem({
t,
visible: !!activeTabSidebarTarget(tab),
onLocate: () => emit("locate-tab", tab),
}),
{ label: "", separator: true },
createPinTabMenuItem({
label: tab.pinned ? t("contextMenu.unpinTab") : t("contextMenu.pinTab"),
onToggle: () => queryStore.togglePinnedTab(tab.id),
}),
// Split actions stay visible but render disabled when they cannot produce
// a new layout — at the four-group cap, or with a single open tab (the
// store rejects with the same rules). Every tab type can split: groups
// host non-query tabs via ContentArea, same as moving them between groups.
{
label: t("contextMenu.splitRight"),
action: () => queryStore.splitTabRight(tab.id),
disabled: splitUnavailable.value,
icon: ArrowRight,
},
{
label: t("contextMenu.splitDown"),
action: () => queryStore.splitTabDown(tab.id),
disabled: splitUnavailable.value,
icon: ArrowDown,
},
...(canChangeOrientation.value
? [
{
label: t("contextMenu.changeOrientation"),
action: () => queryStore.setOrientation(queryStore.orientation === "vertical" ? "horizontal" : "vertical"),
icon: RotateCw,
},
]
: []),
...(isSecondaryGroup.value
? [
{
label: t("contextMenu.unsplit"),
action: () => queryStore.unsplitTab(tab.id),
icon: ArrowRight,
visible: true,
},
]
: []),
createCloseOtherTabMenuItem({
label: t("contextMenu.closeOtherTabs"),
onClose: () => queryStore.closeOtherTabsInGroup(props.groupId, tab.id),
}),
createCloseLeftTabMenuItem({
label: t("contextMenu.closeLeftTabs"),
disabled: !hasTabsToLeft(tab),
onClose: () => {
const tabsToClose = tabsToLeftInDisplayOrder(tab).map((item) => item.id);
const finalActiveTabId = queryStore.activeTabId && !tabsToClose.includes(queryStore.activeTabId) ? queryStore.activeTabId : tab.id;
queryStore.closeTabsByIds(tabsToClose, finalActiveTabId);
},
}),
createCloseRightTabMenuItem({
label: t("contextMenu.closeRightTabs"),
disabled: !hasTabsToRight(tab),
onClose: () => {
const tabsToClose = tabsToRightInDisplayOrder(tab).map((item) => item.id);
const finalActiveTabId = queryStore.activeTabId && !tabsToClose.includes(queryStore.activeTabId) ? queryStore.activeTabId : tab.id;
queryStore.closeTabsByIds(tabsToClose, finalActiveTabId);
},
}),
createCloseAllTabMenuItem({
label: t("contextMenu.closeAllTabs"),
onClose: () => queryStore.closeAllTabsInGroup(props.groupId, tab.id),
}),
{
label: t("contextMenu.closeTabGroup"),
action: () => closeTabGroup(tab),
visible: settingsStore.editorSettings.tabGroupMode !== "none",
icon: X,
},
createCloseTabMenuItem({
label: t("contextMenu.closeTab"),
onClose: () => closeTab(tab),
}),
];
return items;
}
function handleTabDoubleClick(tab: QueryTab, event: MouseEvent) {
event.stopPropagation();
if (event.target instanceof Element && event.target.closest("button, input, [role='button']")) {
return;
}
if (tab.mode === "data") {
if (tab.id !== props.activeTabId) {
activateTab(tab.id);
}
emit("toggle-zen-mode");
return;
}
startRenameTab(tab);
}
function handleTabClick(tab: QueryTab) {
if (suppressNextTabClick.value) {
suppressNextTabClick.value = false;
return;
}
activateTab(tab.id);
}
function cleanupTabDrag(event?: Event) {
const trackedPointerId = groupTabDrag.pointerId;
if (event && "pointerId" in event && trackedPointerId !== null && (event as PointerEvent).pointerId !== trackedPointerId) {
return;
}
window.removeEventListener("pointermove", handleTabPointerMove);
window.removeEventListener("pointerup", handleTabPointerUp);
window.removeEventListener("pointercancel", cleanupTabDrag);
window.removeEventListener("blur", cleanupTabDrag);
// The shared drag session belongs to the bar that started it: another
// group's tab bar unmounting mid-drag must not kill the source's drag.
if (groupTabDrag.sourceGroupId === props.groupId) {
groupTabDrag.active = false;
groupTabDrag.tabId = null;
groupTabDrag.sourceGroupId = null;
groupTabDrag.payload = "";
groupTabDrag.targetGroupId = null;
groupTabDrag.targetTabId = null;
groupTabDrag.position = null;
groupTabDrag.pointerId = null;
groupTabDragSourceEl = null;
removeTabDragGhost();
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
}
function handleTabPointerMove(event: PointerEvent) {
const drag = groupTabDrag;
if (!drag.tabId) {
return;
}
if (drag.pointerId !== null && event.pointerId !== drag.pointerId) {
return;
}
// macOS reports a trackpad tap as a mouse pointer with button=0, but
// buttons=0. It is a click gesture, not a held primary-button drag.
if ((event.buttons & 1) !== 1) {
cleanupTabDrag();
return;
}
if (!drag.active) {
// Use the strip's primary axis for the drag threshold: horizontal strips
// react to horizontal movement, while left/right strips react vertically.
const distance = isVerticalLayout.value ? Math.abs(event.clientY - drag.startY) : Math.abs(event.clientX - drag.startX);
if (distance < TAB_DRAG_HORIZONTAL_THRESHOLD) {
return;
}
drag.active = true;
suppressNextTabClick.value = true;
if (groupTabDragSourceEl) {
groupTabDragGhost = createTabDragGhost(groupTabDragSourceEl, event.clientX, event.clientY);
}
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
event.preventDefault();
moveTabDragGhost(event.clientX, event.clientY);
const element = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
const tabElement = element?.closest<HTMLElement>("[data-tab-id]");
const groupElement = element?.closest<HTMLElement>("[data-group-id]");
drag.targetGroupId = groupElement?.dataset.groupId ?? null;
drag.targetTabId = null;
drag.position = null;
if (tabElement && groupElement) {
drag.targetTabId = tabElement.dataset.tabId ?? null;
const rect = tabElement.getBoundingClientRect();
const pointerPosition = isVerticalLayout.value ? event.clientY : event.clientX;
const targetMiddle = isVerticalLayout.value ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
drag.position = pointerPosition < targetMiddle ? "before" : "after";
}
}
function handleTabPointerUp(event: PointerEvent) {
if (groupTabDrag.pointerId !== null && event.pointerId !== groupTabDrag.pointerId) {
return;
}
const drag = {
active: groupTabDrag.active,
tabId: groupTabDrag.tabId,
payload: groupTabDrag.payload,
targetGroupId: groupTabDrag.targetGroupId,
targetTabId: groupTabDrag.targetTabId,
position: groupTabDrag.position,
};
cleanupTabDrag();
if (!drag.active) {
return;
}
event.preventDefault();
const payload = parseTabDragPayload(drag.payload);
if (!payload) {
return;
}
const targetGroupId = drag.targetGroupId;
if (!targetGroupId) {
return;
}
// Validate the payload against live store state: both groups must exist and
// the tab's *current* owner must still be the payload's source group.
const sourceGroupExists = queryStore.groups.some((group) => group.id === payload.sourceGroupId);
const targetGroupExists = queryStore.groups.some((group) => group.id === targetGroupId);
if (!sourceGroupExists || !targetGroupExists) {
return;
}
const currentOwner = queryStore.groups.find((group) => group.tabIds.includes(payload.tabId));
if (!currentOwner || currentOwner.id !== payload.sourceGroupId) {
return;
}
let index: number | undefined;
if (drag.targetTabId) {
const targetGroup = queryStore.groups.find((group) => group.id === targetGroupId);
const targetTabs = targetGroup ? targetGroup.tabIds.map((id) => queryStore.tabs.find((tab) => tab.id === id)).filter((tab): tab is QueryTab => !!tab) : [];
const targetIndex = targetTabs.findIndex((tab) => tab.id === drag.targetTabId);
if (targetIndex >= 0) {
index = drag.position === "before" ? targetIndex : targetIndex + 1;
}
}
queryStore.moveTabToGroup(payload.tabId, targetGroupId, index);
}
onUnmounted(() => {
cleanupTabDrag();
tabGroupCollapseTimers.forEach((timer) => window.clearTimeout(timer));
tabGroupCollapseTimers.clear();
});
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
watch(
() => props.activeTabId,
(tabId) => {
// A tab that just became active must be visible: reveal its group if collapsed.
expandTabGroupForTab(tabId);
nextTick(() => {
if (!isWrapLayout.value) {
const container = tabsContainerRef.value;
if (container) {
const activeEl = container.querySelector('[data-active-tab="true"]');
if (activeEl) {
activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: isVerticalLayout.value ? "nearest" : "center" });
}
}
}
updateScrollButtons();
tabScrollBehavior.value = "smooth";
});
},
);
watch(isVerticalLayout, (vertical) => {
if (!vertical) tabSearchQuery.value = "";
});
watch(
() => [
props.tabs.map((tab) => `${tab.id}:${tab.pinned ? "1" : "0"}:${tab.title}:${tab.mode}`).join("|"),
props.specialPageTabs?.settingsOpen,
props.specialPageTabs?.driverStoreOpen,
settingsStore.editorSettings.tabLayout,
settingsStore.editorSettings.tabGroupMode,
compactTabTitle.value,
Array.from(collapsedTabGroups.value).sort().join("|"),
],
() => {
// Tab content can change without changing the scroll container's size.
// Re-measure after Vue has committed the new pills to avoid stale overflow controls.
const scrollPositionToRestore = pendingTabScrollRestore.value;
pendingTabScrollRestore.value = null;
nextTick(() =>
requestAnimationFrame(() => {
tabLayoutRevision.value++;
updateScrollButtons();
fixedTabsScroll.updateScrollButtons();
regularTabsScroll.updateScrollButtons();
if (scrollPositionToRestore) {
nextTick(() =>
requestAnimationFrame(() => {
restoreTabScrollPosition(scrollPositionToRestore);
updateScrollButtons();
fixedTabsScroll.updateScrollButtons();
regularTabsScroll.updateScrollButtons();
}),
);
} else {
nextTick(() => requestAnimationFrame(revealActiveTabAfterLayout));
}
}),
);
},
{ flush: "post" },
);
watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?.driverStoreActive, () => props.specialPageTabs?.pluginCenterActive, () => settingsStore.editorSettings.tabPlacement, () => props.tabBarCollapsed], () => {
nextTick(() => {
updateScrollButtons();
if (showSpecialPageTabs.value && specialPageActive.value) {
tabsContainerRef.value?.querySelector<HTMLElement>('[data-active-tab="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
});
});
</script>
<template>
<!-- data-main-tab-bar is the drag-back hit-test anchor: dropping a detached window over ANY pane's strip returns the tab. -->
<div
class="app-tab-bar group-tabbar relative flex w-full min-w-0 shrink-0 overflow-hidden"
:class="[tabBarClass, isWrapLayout ? 'tab-wrap-mode' : '', { 'ring-2 ring-primary ring-inset': detachedDropTarget }]"
:style="tabBarStyle"
data-main-tab-bar
:data-group-id="groupId"
:data-group-mode="settingsStore.editorSettings.tabGroupMode"
:data-placement="settingsStore.editorSettings.tabPlacement"
>
<!-- Compact vertical toolbar: search, tab organization, collapse. -->
<div v-if="isVerticalLayout" class="flex h-9 shrink-0 items-center gap-0.5 border-b p-1" :class="isTabBarCollapsed ? 'justify-center' : ''">
<div v-if="!isTabBarCollapsed" class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="tabSearchQuery" type="search" :placeholder="t('tabs.searchOpenTabs')" class="h-7 w-full pl-7 text-sm" />
</div>
<LightDropdown
v-if="!isTabBarCollapsed"
model-value=""
:items="tabOrganizationItems"
:selected-values="selectedTabOrganizationItems"
:aria-label="t('settings.tabOrganization')"
:trigger-title="t('settings.tabOrganization')"
:trigger-icon="ListFilter"
:trigger-class="verticalTabToolbarButtonClass"
trigger-icon-class="h-4 w-4"
item-icon-class="h-3.5 w-3.5"
content-class="w-max min-w-0"
selected-item-class="bg-primary/10 text-primary"
selected-check-class="text-primary"
:show-trigger-label="false"
:show-chevron="false"
:close-on-select="false"
:match-trigger-width="false"
align="end"
@update:model-value="selectTabOrganizationItem"
/>
<button type="button" :class="verticalTabToolbarButtonClass" :title="tabBarCollapseLabel" :aria-label="tabBarCollapseLabel" :aria-expanded="!isTabBarCollapsed" @click="emit('toggle-collapse')">
<component :is="tabBarCollapseIcon" class="h-4 w-4" />
</button>
</div>
<div class="relative flex w-full min-w-0 shrink-0 overflow-hidden" :class="[isVerticalLayout ? ['min-h-0 flex-1 flex-col items-stretch'] : isClassicLayout ? 'h-9 items-stretch' : 'h-10 items-center px-2', { 'has-tab-overflow-control': showOverflowControl }]">
<LightDropdown
v-if="!isVerticalLayout"
model-value=""
:items="tabOrganizationItems"
:selected-values="selectedTabOrganizationItems"
:aria-label="t('settings.tabOrganization')"
:trigger-title="t('settings.tabOrganization')"
:trigger-icon="ListFilter"
trigger-class="tab-organization-button"
trigger-icon-class="h-4 w-4"
item-icon-class="h-3.5 w-3.5"
content-class="w-max min-w-0"
selected-item-class="bg-primary/10 text-primary"
selected-check-class="text-primary"
:show-trigger-label="false"
:show-chevron="false"
:close-on-select="false"
:match-trigger-width="false"
@update:model-value="selectTabOrganizationItem"
/>
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
<div v-if="showOverflowControl && !hasHorizontalFixedRows" 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"
:class="[
isVerticalLayout ? 'flex-col items-stretch overflow-y-auto overflow-x-hidden py-1' : isClassicLayout ? 'h-full items-center overflow-x-auto' : 'h-full items-center gap-0 overflow-x-auto py-1.5',
isWrapLayout ? 'wrap-mode' : '',
isWrapLayout && isClassicLayout ? 'classic-wrap' : '',
hasHorizontalFixedRows ? 'horizontal-fixed-tabs-scroll' : '',
]"
:style="tabsContainerStyle"
@scroll="!isVerticalLayout && !hasHorizontalFixedRows && updateScrollButtons()"
@wheel="!isVerticalLayout && !hasHorizontalFixedRows && onTabsWheel($event)"
>
<template v-for="section in stripSections" :key="section.key">
<div
class="tab-section-frame"
:class="[
isVerticalLayout ? 'tab-section-frame--vertical' : ['tab-section-frame--horizontal', isClassicLayout && !hasHorizontalFixedRows ? 'h-full' : ''],
{ 'tab-section-frame--with-overflow-control': hasHorizontalFixedRows && section.key === stripSections[0]?.key && showOverflowControl },
]"
>
<div
v-if="hasHorizontalFixedRows && (tabSectionScroll(section.key).hasTabOverflow.value || tabSectionHasOverflow(section.key))"
class="app-tab-scrollbar"
:class="[section.pinned ? 'app-tab-scrollbar--bottom' : '', { 'app-tab-scrollbar--dragging': tabSectionScroll(section.key).isScrollbarDragging.value }]"
@pointerdown="tabSectionScroll(section.key).startScrollbarDrag($event)"
>
<div class="app-tab-scrollbar__thumb" :style="tabSectionThumbStyle(section.key)" />
</div>
<div
:ref="(element) => setTabSectionRef(section.key, element as Element | null)"
class="tab-section"
:class="isVerticalLayout ? 'tab-section--vertical' : ['tab-section--horizontal', isClassicLayout && !hasHorizontalFixedRows ? 'h-full' : '']"
@scroll="hasHorizontalFixedRows && tabSectionScroll(section.key).updateScrollButtons()"
@wheel="hasHorizontalFixedRows && onHorizontalTabWheel($event)"
>
<template v-for="entry in section.entries" :key="entry.key">
<CustomContextMenu v-if="entry.kind === 'header'" :items="() => getTabGroupMenuItems(entry.tab)" v-slot="{ onContextMenu }">
<button
type="button"
class="tab-group-header"
:class="{ 'tab-group-header--collapsed': isTabGroupCollapsed(entry.tab), 'tab-group-header--active': isTabGroupActive(entry.tab) }"
:style="tabGroupStyle(entry.tab)"
:aria-expanded="!isTabGroupCollapsed(entry.tab)"
:title="tabGroupLabel(entry.tab)"
@click="toggleTabGroup(entry.tab)"
@contextmenu="openTabGroupContextMenu($event, onContextMenu)"
>
<span class="tab-group-header-content">
<span class="tab-group-marker" aria-hidden="true" />
<Pin v-if="entry.pinned" class="tab-group-pin" aria-hidden="true" />
<ChevronDown class="tab-group-chevron" :class="{ 'tab-group-chevron--collapsed': isTabGroupCollapsed(entry.tab) }" aria-hidden="true" />
<DatabaseIcon :db-type="tabDatabaseIconType(entry.tab)" class="tab-group-database-icon" aria-hidden="true" />
<span class="tab-group-label">{{ tabGroupLabel(entry.tab) }}</span>
<span v-if="isTabGroupCollapsed(entry.tab)" class="tab-group-count">{{ entry.count }}</span>
</span>
</button>
</CustomContextMenu>
<CustomContextMenu v-else-if="entry.kind === 'tab'" :items="getTabMenuItems(entry.tab)" v-slot="{ onContextMenu }">
<div
:class="[
'tab-group-entry',
isClassicLayout && !isVerticalLayout ? 'h-full' : '',
{
'tab-group-entry--collapsing': entry.grouping && !tabSearchQuery.trim() && collapsingTabGroups.has(tabGroupId(entry.tab)),
'tab-group-entry--collapsed': entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab),
},
]"
:data-tab-group-id="entry.grouping ? tabGroupId(entry.tab) : undefined"
:aria-hidden="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
:inert="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
@contextmenu="onContextMenu"
@transitionend.self="handleTabGroupTransitionEnd"
>
<Tooltip :open="openTabTooltipId === entry.tab.id" @update:open="updateTabTooltipOpen(entry.tab.id, $event)">
<TooltipTrigger as-child>
<div
class="app-tab-pill group flex cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="[
isClassicLayout
? ['h-full border-r border-border/80 font-medium dark:border-border/45', isTabActive(entry.tab) ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90']
: ['h-7 rounded-md border', isTabActive(entry.tab) ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90'],
{
'tab-group-tab': entry.grouping,
'tab-group-tab--first': entry.grouping && entry.groupFirst,
'tab-group-tab--last': entry.grouping && entry.groupLast,
},
]"
:style="[tabColorStyle(entry.tab), entry.grouping ? tabGroupStyle(entry.tab) : undefined, tabDropStyle(entry.tab)]"
:data-active-tab="isTabActive(entry.tab)"
:data-tab-id="entry.tab.id"
@pointerdown="handleTabPointerDown($event, entry.tab)"
@mouseleave="closeTabTooltip(entry.tab.id)"
@click="handleTabClick(entry.tab)"
@dblclick="handleTabDoubleClick(entry.tab, $event)"
@mousedown.middle.prevent="closeTab(entry.tab)"
>
<TabExecutionStatus :tab="entry.tab">
<span class="shrink-0" :class="tabIconClass(entry.tab)">
<TabModeIcon :tab="entry.tab" class="h-3.5 w-3.5" />
</span>
</TabExecutionStatus>
<span v-if="isTabBarCollapsed && isDirtyTab(entry.tab)" class="compact-dirty-tab-marker" aria-hidden="true" />
<input
v-if="editingTabId === entry.tab.id && !isTabBarCollapsed"
v-model="editingTitle"
:data-tab-title-input="entry.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(entry.tab)"
@keydown.escape.prevent="cancelRenameTab"
@blur="commitRenameTab(entry.tab)"
/>
<span v-else-if="!isTabBarCollapsed" class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden text-foreground">
<span v-if="isDirtyTab(entry.tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(entry.tab)">{{ tabTitleText(entry.tab) }}</span>
</span>
<ReadOnlySessionControl v-if="!isTabBarCollapsed" :connection-id="entry.tab.connectionId" compact />
<button
v-if="entry.tab.pinned && !isTabBarCollapsed"
class="rounded p-0.5 text-primary hover:bg-muted-foreground/20 shrink-0"
:aria-label="t('contextMenu.unpinTab')"
:title="t('contextMenu.unpinTab')"
@pointerdown.stop
@click.stop="queryStore.togglePinnedTab(entry.tab.id)"
>
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
</button>
<button v-if="!isTabBarCollapsed" class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" :aria-label="t('contextMenu.closeTab')" :title="t('contextMenu.closeTab')" @pointerdown.stop @click.stop="closeTab(entry.tab)">
<X class="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent v-if="isConnectionlessPluginTab(entry.tab)" :side="tabTooltipSide" data-plugin-title-tooltip>
{{ tabTitleText(entry.tab) }}
</TooltipContent>
<TooltipContent v-else :side="tabTooltipSide" class="grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 text-xs">
<template v-for="line in tabTooltipLines(entry.tab, t)" :key="line.label">
<span class="whitespace-nowrap font-medium opacity-70">{{ line.label }}</span>
<span class="min-w-0 break-all">{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
</template>
<template v-if="!section.pinned && showSpecialPageTabs">
<CustomContextMenu v-if="specialPageTabs?.pluginCenterOpen" :items="getSpecialPageTabMenuItems('pluginCenter')" v-slot="{ onContextMenu }">
<Tooltip :open="openTabTooltipId === 'special:plugin-center'" @update:open="updateTabTooltipOpen('special:plugin-center', $event)">
<TooltipTrigger as-child>
<div
data-plugin-center-tab
class="app-tab-pill group flex shrink-0 cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialPageTabClass(!!specialPageTabs?.pluginCenterActive)"
:style="specialPageTabStyle(!!specialPageTabs?.pluginCenterActive)"
:data-active-tab="specialPageTabs?.pluginCenterActive"
:aria-label="t('toolbar.pluginCenter')"
:aria-pressed="!!specialPageTabs?.pluginCenterActive"
role="button"
tabindex="0"
@mouseleave="closeTabTooltip('special:plugin-center')"
@click="emit('activate-plugin-center')"
@keydown.enter.self.prevent="emit('activate-plugin-center')"
@keydown.space.self.prevent="emit('activate-plugin-center')"
@contextmenu="onContextMenu"
@mousedown.middle.prevent="emit('close-plugin-center')"
>
<PlugZap class="h-3.5 w-3.5 shrink-0 text-violet-600 dark:text-violet-400" />
<span v-if="!isTabBarCollapsed" class="min-w-0 flex-1 truncate">{{ t("toolbar.pluginCenter") }}</span>
<button v-if="!isTabBarCollapsed" class="shrink-0 rounded p-0.5 hover:bg-muted-foreground/20" :aria-label="t('common.close')" :title="t('common.close')" @click.stop="emit('close-plugin-center')">
<X class="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent :side="tabTooltipSide">{{ t("toolbar.pluginCenter") }}</TooltipContent>
</Tooltip>
</CustomContextMenu>
<CustomContextMenu v-if="specialPageTabs?.settingsOpen" :items="getSpecialPageTabMenuItems('settings')" v-slot="{ onContextMenu }">
<div
data-settings-page-tab
class="app-tab-pill group flex shrink-0 cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialPageTabClass(!!specialPageTabs?.settingsActive)"
:style="specialPageTabStyle(!!specialPageTabs?.settingsActive)"
:data-active-tab="specialPageTabs?.settingsActive"
:title="t('settings.title')"
:aria-label="t('settings.title')"
:aria-pressed="!!specialPageTabs?.settingsActive"
role="button"
tabindex="0"
@click="emit('activate-settings')"
@keydown.enter.self.prevent="emit('activate-settings')"
@keydown.space.self.prevent="emit('activate-settings')"
@contextmenu="onContextMenu"
@mousedown.middle.prevent="emit('close-settings')"
>
<Settings class="h-3.5 w-3.5 shrink-0 text-sky-600 dark:text-sky-400" />
<span v-if="!isTabBarCollapsed" class="min-w-0 flex-1 truncate">{{ t("settings.title") }}</span>
<button v-if="!isTabBarCollapsed" class="shrink-0 rounded p-0.5 hover:bg-muted-foreground/20" :aria-label="t('common.close')" :title="t('common.close')" @click.stop="emit('close-settings')">
<X class="h-3 w-3" />
</button>
</div>
</CustomContextMenu>
<CustomContextMenu v-if="specialPageTabs?.driverStoreOpen" :items="getSpecialPageTabMenuItems('driverStore')" v-slot="{ onContextMenu }">
<div
data-driver-store-tab
class="app-tab-pill group flex shrink-0 cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialPageTabClass(!!specialPageTabs?.driverStoreActive)"
:style="specialPageTabStyle(!!specialPageTabs?.driverStoreActive)"
:data-active-tab="specialPageTabs?.driverStoreActive"
:title="t('toolbar.driverManager')"
:aria-label="t('toolbar.driverManager')"
:aria-pressed="!!specialPageTabs?.driverStoreActive"
role="button"
tabindex="0"
@click="emit('activate-driver-store')"
@keydown.enter.self.prevent="emit('activate-driver-store')"
@keydown.space.self.prevent="emit('activate-driver-store')"
@contextmenu="onContextMenu"
@mousedown.middle.prevent="emit('close-driver-store')"
>
<Package class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
<span v-if="!isTabBarCollapsed" class="min-w-0 flex-1 truncate">{{ t("toolbar.driverManager") }}</span>
<span v-if="!isTabBarCollapsed && (specialPageTabs?.driverUpdateCount ?? 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')">
{{ (specialPageTabs?.driverUpdateCount ?? 0) > 99 ? "99+" : specialPageTabs?.driverUpdateCount }}
</span>
<button v-if="!isTabBarCollapsed" class="shrink-0 rounded p-0.5 hover:bg-muted-foreground/20" :aria-label="t('common.close')" :title="t('common.close')" @click.stop="emit('close-driver-store')">
<X class="h-3 w-3" />
</button>
</div>
</CustomContextMenu>
</template>
<div v-if="!section.pinned" :class="tabTailDragRegionClass" data-tauri-drag-region />
</div>
</div>
</template>
</div>
</div>
<div v-if="showOverflowControl" class="tab-overflow-control absolute right-0 top-0 z-30 flex h-full items-center">
<Popover v-model:open="tabOverflowOpen">
<PopoverTrigger as-child>
<button type="button" class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/60 bg-background text-foreground/70 hover:border-border hover:text-foreground" :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 gap-0 rounded-[6px] p-1" @click.stop @keydown.stop>
<div class="relative border-b px-1 pb-1">
<Search class="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="tabOverflowSearchQuery" data-group-tab-search-input type="search" :placeholder="t('tabs.searchOpenTabs')" class="h-8 pl-7 text-sm" />
</div>
<div class="max-h-[min(70vh,28rem)] overflow-y-auto pt-1">
<CustomContextMenu v-for="tab in filteredGroupTabs" :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="isTabActive(tab) ? 'bg-accent/70 text-accent-foreground' : ''"
:title="tabTitleText(tab)"
role="menuitem"
tabindex="0"
@click="
activateTab(tab.id);
tabOverflowOpen = false;
"
@contextmenu="onContextMenu"
@keydown.enter.prevent="
activateTab(tab.id);
tabOverflowOpen = false;
"
>
<TabExecutionStatus :tab="tab">
<TabModeIcon :tab="tab" class="h-3.5 w-3.5 shrink-0" :class="tabIconClass(tab)" />
</TabExecutionStatus>
<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>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<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.stop.prevent="queryStore.closeTab(tab.id)"
@mousedown.stop
>
<X class="h-3 w-3" />
</button>
</span>
</div>
</CustomContextMenu>
<p v-if="filteredGroupTabs.length === 0" class="px-2 py-4 text-center text-sm text-muted-foreground">{{ t("tabs.noMatchingTabs") }}</p>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<!-- Dragging any pane's handle resizes the shared vertical width; every pane follows. -->
<div v-if="isVerticalLayout && !isTabBarCollapsed" class="panel-resize-handle" :class="settingsStore.editorSettings.tabPlacement === 'right' ? 'panel-resize-handle--left' : 'panel-resize-handle--right'" @pointerdown="emit('start-resize', $event)" />
<Dialog v-model:open="tabGroupEditorOpen">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle class="flex min-w-0 items-center gap-2">
<span class="h-4 w-1 shrink-0 rounded-full" :style="{ background: editingTabGroupColor || editingTabGroupFallbackColor }" />
<span class="truncate">{{ t("tabs.editGroupTitle", { name: editingTabGroupDefaultLabel }) }}</span>
</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<label class="grid gap-1.5 text-sm">
<span class="font-medium">{{ t("tabs.groupName") }}</span>
<Input v-model="editingTabGroupName" data-tab-group-name-input maxlength="80" :placeholder="editingTabGroupDefaultLabel" class="h-9" @keydown.enter.prevent="saveTabGroupCustomization" />
</label>
<fieldset class="grid gap-2">
<legend class="text-sm font-medium">{{ t("tabs.groupColor") }}</legend>
<div class="flex flex-wrap items-center gap-2" :style="tabGroupEditorPreviewStyle">
<button type="button" class="tab-group-color-option tab-group-color-option--auto" :class="{ 'tab-group-color-option--selected': editingTabGroupColor === '' }" :aria-label="t('tabs.groupColorAuto')" :title="t('tabs.groupColorAuto')" @click="editingTabGroupColor = ''">
<RotateCcw class="h-3.5 w-3.5" />
</button>
<button
v-for="color in tabGroupPalette"
:key="color"
type="button"
class="tab-group-color-option"
:class="{ 'tab-group-color-option--selected': editingTabGroupColor === color }"
:style="{ '--tab-group-option-color': color }"
:aria-label="color"
:title="color"
@click="editingTabGroupColor = color"
/>
<label
class="tab-group-custom-color"
:class="{ 'tab-group-color-option--selected': editingTabGroupColor !== '' && !tabGroupPalette.includes(editingTabGroupColor) }"
:style="{ '--tab-group-option-color': editingTabGroupColor || editingTabGroupFallbackColor }"
:title="t('tabs.groupColorCustom')"
>
<input type="color" :value="editingTabGroupColor || editingTabGroupFallbackColor" :aria-label="t('tabs.groupColorCustom')" @input="updateCustomTabGroupColor" />
</label>
</div>
</fieldset>
</div>
<DialogFooter class="sm:justify-between">
<Button v-if="editingTabGroupHasCustomization" variant="ghost" class="mr-auto" @click="resetTabGroupCustomization()">{{ t("tabs.resetGroup") }}</Button>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="tabGroupEditorOpen = false">{{ t("common.cancel") }}</Button>
<Button @click="saveTabGroupCustomization">{{ t("common.save") }}</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>