dbx/apps/desktop/src/components/layout/AppTabBar.vue

1085 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>