feat(editor): show pinned tabs in separate row with per-row scroll
This commit is contained in:
18 files changed
+958
-252
No files matched your search
@@ -30,39 +30,7 @@ const TAB_DRAG_HORIZONTAL_THRESHOLD = 24;
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Code2,
|
||||
Copy,
|
||||
Database,
|
||||
Gauge,
|
||||
KeyRound,
|
||||
ListFilter,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Network,
|
||||
Package,
|
||||
PanelTop,
|
||||
Pencil,
|
||||
PencilRuler,
|
||||
Pin,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Search,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
TableProperties,
|
||||
X,
|
||||
Activity,
|
||||
} from "@lucide/vue";
|
||||
import { ArrowDown, ArrowDownUp, ArrowRight, ChevronDown, ChevronsDownUp, ChevronsLeft, ChevronsRight, ChevronsUpDown, Copy, ListFilter, Maximize2, Minimize2, Package, PanelTop, Pencil, Pin, RotateCcw, RotateCw, Search, Settings, 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";
|
||||
@@ -70,8 +38,8 @@ 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";
|
||||
@@ -83,7 +51,7 @@ 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, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import "./appTabBar.css";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -123,6 +91,10 @@ 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("");
|
||||
// Drag suppression must survive pointerup: the browser fires click *after*
|
||||
@@ -204,13 +176,16 @@ function getSpecialPageTabMenuItems(surface: "settings" | "driverStore"): Contex
|
||||
];
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -225,6 +200,7 @@ const tabScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
||||
// 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
|
||||
@@ -248,8 +224,61 @@ watch(tabOverflowOpen, (open) => {
|
||||
}
|
||||
});
|
||||
|
||||
const showOverflowControl = computed(() => props.tabs.length > 0 && hasTabOverflow.value && !isWrapLayout.value && !isVerticalLayout.value);
|
||||
const tabTailDragRegionClass = computed(() => (showOverflowControl.value || isWrapLayout.value || isVerticalLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch"));
|
||||
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,
|
||||
@@ -392,6 +421,7 @@ const sortedPinnedTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab
|
||||
const sortedRegularTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab) => !tab.pinned)));
|
||||
|
||||
const collapsedTabGroups = ref<Set<string>>(new Set());
|
||||
const pendingTabScrollRestore = ref<{ fixed: number; regular: number } | null>(null);
|
||||
const tabGroupPalette = ["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"];
|
||||
const tabGroupEditorOpen = ref(false);
|
||||
const editingTabGroupKey = ref("");
|
||||
@@ -445,13 +475,55 @@ function isTabGroupActive(tab: QueryTab) {
|
||||
return section.some((item) => tabGroupKey(item) === groupKey && isTabActive(item));
|
||||
}
|
||||
|
||||
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 refreshHorizontalTabOverflow() {
|
||||
updateScrollButtons();
|
||||
fixedTabsScroll.updateScrollButtons();
|
||||
regularTabsScroll.updateScrollButtons();
|
||||
tabLayoutRevision.value += 1;
|
||||
}
|
||||
|
||||
function handleTabGroupTransitionEnd(event: TransitionEvent) {
|
||||
if (event.propertyName !== "max-width") return;
|
||||
refreshHorizontalTabOverflow();
|
||||
}
|
||||
|
||||
function toggleTabGroup(tab: QueryTab) {
|
||||
preserveTabScrollPosition();
|
||||
const groupId = tabGroupId(tab);
|
||||
const next = new Set(collapsedTabGroups.value);
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
else next.add(groupId);
|
||||
collapsedTabGroups.value = next;
|
||||
nextTick(updateScrollButtons);
|
||||
nextTick(refreshHorizontalTabOverflow);
|
||||
}
|
||||
|
||||
function tabGroupIdsInPane() {
|
||||
if (settingsStore.editorSettings.tabGroupMode === "none") return [];
|
||||
return [...new Set(props.tabs.map((tab) => tabGroupId(tab)))];
|
||||
}
|
||||
|
||||
function collapseAllTabGroups() {
|
||||
preserveTabScrollPosition();
|
||||
collapsedTabGroups.value = new Set(tabGroupIdsInPane());
|
||||
nextTick(refreshHorizontalTabOverflow);
|
||||
}
|
||||
|
||||
function expandAllTabGroups() {
|
||||
preserveTabScrollPosition();
|
||||
collapsedTabGroups.value = new Set();
|
||||
nextTick(refreshHorizontalTabOverflow);
|
||||
}
|
||||
|
||||
function expandTabGroupForTab(tabId: string | null) {
|
||||
@@ -603,6 +675,19 @@ function getTabGroupMenuItems(tab: QueryTab): ContextMenuItem[] {
|
||||
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 },
|
||||
...getTabPreferenceMenuItems(),
|
||||
{ label: "", separator: true },
|
||||
{
|
||||
@@ -625,7 +710,7 @@ type StripEntry = { kind: "header"; key: string; tab: QueryTab; pinned: 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 clusters contribute no tab entries, only their header.
|
||||
* pills. Collapsed pills remain mounted so their visibility can animate.
|
||||
*/
|
||||
function tabMatchesSearch(tab: QueryTab, query: string) {
|
||||
const title = tabDisplayTitle(tab, t).toLocaleLowerCase();
|
||||
@@ -646,28 +731,30 @@ const filteredRegularTabs = computed(() => {
|
||||
return query ? sortedRegularTabs.value.filter((tab) => tabMatchesSearch(tab, query)) : sortedRegularTabs.value;
|
||||
});
|
||||
|
||||
const stripEntries = computed<StripEntry[]>(() => {
|
||||
function buildStripEntries(section: QueryTab[], pinned: boolean): StripEntry[] {
|
||||
const entries: StripEntry[] = [];
|
||||
const grouping = settingsStore.editorSettings.tabGroupMode !== "none";
|
||||
for (const [section, pinned] of [
|
||||
[filteredPinnedTabs.value, true],
|
||||
[filteredRegularTabs.value, false],
|
||||
] as const) {
|
||||
section.forEach((tab, index) => {
|
||||
const first = grouping && (index === 0 || tabGroupKey(section[index - 1]!) !== tabGroupKey(tab));
|
||||
const last = grouping && (index === section.length - 1 || tabGroupKey(section[index + 1]!) !== tabGroupKey(tab));
|
||||
if (first) {
|
||||
const groupKey = tabGroupKey(tab);
|
||||
entries.push({ kind: "header", key: `header:${tab.id}`, tab, pinned, count: section.filter((item) => tabGroupKey(item) === groupKey).length });
|
||||
}
|
||||
// Searching must reveal collapsed clusters, matching the upstream bar.
|
||||
if (grouping && !tabSearchQuery.value.trim() && isTabGroupCollapsed(tab)) {
|
||||
return;
|
||||
}
|
||||
entries.push({ kind: "tab", key: tab.id, tab, groupFirst: first, groupLast: last, grouping });
|
||||
});
|
||||
}
|
||||
section.forEach((tab, index) => {
|
||||
const first = grouping && (index === 0 || tabGroupKey(section[index - 1]!) !== tabGroupKey(tab));
|
||||
const last = grouping && (index === section.length - 1 || tabGroupKey(section[index + 1]!) !== tabGroupKey(tab));
|
||||
if (first) {
|
||||
const groupKey = tabGroupKey(tab);
|
||||
entries.push({ kind: "header", key: `header:${tab.id}`, tab, pinned, count: section.filter((item) => tabGroupKey(item) === groupKey).length });
|
||||
}
|
||||
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 {
|
||||
@@ -1145,11 +1232,47 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(isVerticalLayout, (vertical) => {
|
||||
if (!vertical) tabSearchQuery.value = "";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.tabs.map((tab) => `${tab.id}:${tab.pinned ? "1" : "0"}`).join("|"),
|
||||
() => [
|
||||
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("|"),
|
||||
],
|
||||
() => {
|
||||
nextTick(updateScrollButtons);
|
||||
// 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, () => settingsStore.editorSettings.tabPlacement, () => props.tabBarCollapsed], () => {
|
||||
@@ -1164,7 +1287,15 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
|
||||
<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, { 'ring-2 ring-primary ring-inset': detachedDropTarget }]" :style="tabBarStyle" data-main-tab-bar :data-group-id="groupId" :data-placement="settingsStore.editorSettings.tabPlacement">
|
||||
<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, grouping preference, collapse. -->
|
||||
<div v-if="isVerticalLayout" class="flex shrink-0 items-center gap-0.5 border-b p-1.5" :class="isTabBarCollapsed ? 'justify-center' : ''">
|
||||
<div v-if="!isTabBarCollapsed" class="relative min-w-0 flex-1">
|
||||
@@ -1191,185 +1322,212 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
<component :is="tabBarCollapseIcon" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="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'">
|
||||
<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 }]">
|
||||
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
|
||||
<div v-if="showOverflowControl" class="app-tab-scrollbar" :class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }" @pointerdown="startScrollbarDrag">
|
||||
<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-1.5 overflow-x-auto py-1.5',
|
||||
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 && updateScrollButtons()"
|
||||
@wheel="!isVerticalLayout && onTabsWheel($event)"
|
||||
@scroll="!isVerticalLayout && !hasHorizontalFixedRows && updateScrollButtons()"
|
||||
@wheel="!isVerticalLayout && !hasHorizontalFixedRows && onTabsWheel($event)"
|
||||
>
|
||||
<template v-for="entry in stripEntries" :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)"
|
||||
<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)"
|
||||
>
|
||||
<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="isTabGroupCollapsed(entry.tab) ? '-rotate-90' : ''" 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 :items="getTabMenuItems(entry.tab)" v-slot="{ onContextMenu }">
|
||||
<div :class="isClassicLayout && !isVerticalLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||||
<Tooltip>
|
||||
<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)"
|
||||
@click="handleTabClick(entry.tab)"
|
||||
@dblclick="handleTabDoubleClick(entry.tab, $event)"
|
||||
@mousedown.middle.prevent="closeTab(entry.tab)"
|
||||
<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)"
|
||||
>
|
||||
<TabExecutionStatus :tab="entry.tab">
|
||||
<span class="shrink-0" :class="tabIconClass(entry.tab)">
|
||||
<AlertTriangle v-if="entry.tab.externalSqlFileMissing" class="h-3.5 w-3.5" />
|
||||
<Table2 v-else-if="entry.tab.mode === 'data' || entry.tab.mode === 'mongo' || entry.tab.mode === 'redis' || entry.tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="entry.tab.mode === 'mq'" :db-type="tabDatabaseIconType(entry.tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="entry.tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="entry.tab.mode === 'etcd' || entry.tab.mode === 'zookeeper' || entry.tab.mode === 'consul'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="entry.tab.mode === 'consul-overview' || entry.tab.mode === 'etcd-dashboard' || entry.tab.mode === 'mysql-dashboard' || entry.tab.mode === 'postgres-dashboard' || entry.tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
|
||||
<ShieldCheck v-else-if="entry.tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
|
||||
<Network v-else-if="entry.tab.mode === 'nacos'" class="h-3.5 w-3.5" />
|
||||
<Database v-else-if="entry.tab.mode === 'databases'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="entry.tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="entry.tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<CalendarClock v-else-if="entry.tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||||
<Activity v-else-if="entry.tab.mode === 'processlist' || entry.tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="entry.tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else 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)">{{ tabDisplayTitle(entry.tab, t) }}</span>
|
||||
<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="isTabGroupCollapsed(entry.tab) ? '-rotate-90' : ''" 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>
|
||||
<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)">
|
||||
</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--collapsed': entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab) }]"
|
||||
:aria-hidden="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
|
||||
:inert="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
|
||||
@contextmenu="onContextMenu"
|
||||
@transitionend.self="handleTabGroupTransitionEnd"
|
||||
>
|
||||
<Tooltip>
|
||||
<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--collapsed': entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab),
|
||||
'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)"
|
||||
@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)">{{ tabDisplayTitle(entry.tab, t) }}</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 :side="tabTooltipSide" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipLines(entry.tab, t)" :key="line.label">
|
||||
<span class="font-medium opacity-70">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
</template>
|
||||
<template v-if="!section.pinned && showSpecialPageTabs">
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent :side="tabTooltipSide" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipLines(entry.tab, t)" :key="line.label">
|
||||
<span class="font-medium opacity-70">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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>
|
||||
</CustomContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showSpecialPageTabs">
|
||||
<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 :class="tabTailDragRegionClass" data-tauri-drag-region />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showOverflowControl" class="relative z-30 flex shrink-0 items-center">
|
||||
<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-7 w-7 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')">
|
||||
<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>
|
||||
@@ -1397,10 +1555,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
"
|
||||
>
|
||||
<TabExecutionStatus :tab="tab">
|
||||
<DatabaseIcon v-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<AlertTriangle v-else-if="tab.externalSqlFileMissing" class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<component :is="Code2" v-else-if="tab.mode === 'query'" class="h-3.5 w-3.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<component :is="Table2" v-else class="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<TabModeIcon :tab="tab" class="h-3.5 w-3.5 shrink-0" />
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle, CalendarClock, Code2, Database, Gauge, KeyRound, Network, PencilRuler, ShieldCheck, Table2, TableProperties } from "@lucide/vue";
|
||||
import { Activity, AlertTriangle, CalendarClock, Code2, Database, Eye, FunctionSquare, Gauge, KeyRound, ListOrdered, Network, PencilRuler, ShieldCheck, Table, TableProperties, Zap } from "@lucide/vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -11,7 +11,9 @@ defineProps<{ tab: QueryTab }>();
|
||||
|
||||
<template>
|
||||
<AlertTriangle v-if="tab.externalSqlFileMissing" />
|
||||
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" />
|
||||
<Eye v-else-if="tab.objectSource?.objectType === 'VIEW' || tab.objectSource?.objectType === 'MATERIALIZED_VIEW' || tab.tableMeta?.tableType?.toUpperCase() === 'VIEW' || tab.tableMeta?.tableType?.toUpperCase() === 'MATERIALIZED_VIEW'" />
|
||||
<Database v-else-if="tab.mode === 'redis'" />
|
||||
<Table v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'hbase'" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" />
|
||||
@@ -21,6 +23,10 @@ defineProps<{ tab: QueryTab }>();
|
||||
<Database v-else-if="tab.mode === 'databases'" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" />
|
||||
<FunctionSquare v-else-if="tab.objectSource?.objectType === 'PROCEDURE' || tab.objectSource?.objectType === 'FUNCTION'" />
|
||||
<Zap v-else-if="tab.objectSource?.objectType === 'TRIGGER'" />
|
||||
<CalendarClock v-else-if="tab.objectSource?.objectType === 'EVENT' || tab.objectSource?.objectType === 'JOB'" />
|
||||
<ListOrdered v-else-if="tab.objectSource?.objectType === 'SEQUENCE'" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" />
|
||||
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" />
|
||||
<Gauge v-else-if="tab.mode === 'dolt-version-control'" />
|
||||
|
||||
+31
-5
@@ -94,7 +94,17 @@ describe("EditorGroupTabBar semantic tab groups", () => {
|
||||
});
|
||||
|
||||
it("hides collapsed cluster pills but reveals them while searching", () => {
|
||||
expect(source).toContain("grouping && !tabSearchQuery.value.trim() && isTabGroupCollapsed(tab)");
|
||||
expect(source).toContain("grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)");
|
||||
expect(sharedStyles).toContain(".tab-group-entry--collapsed");
|
||||
});
|
||||
|
||||
it("remeasures overflow after a single-row group expansion finishes", () => {
|
||||
expect(source).toContain('@transitionend.self="handleTabGroupTransitionEnd"');
|
||||
const handler = sourceBetween("function handleTabGroupTransitionEnd", "function toggleTabGroup");
|
||||
expect(handler).toContain('event.propertyName !== "max-width"');
|
||||
expect(handler).toContain("refreshHorizontalTabOverflow();");
|
||||
expect(source).toContain('return "tab-tail-overflow-spacer flex-none self-stretch";');
|
||||
expect(sharedStyles).toContain(".tab-tail-overflow-spacer[data-tauri-drag-region]");
|
||||
});
|
||||
|
||||
it("keeps the overflow search scoped to the popover list without filtering the strip", () => {
|
||||
@@ -106,7 +116,7 @@ describe("EditorGroupTabBar semantic tab groups", () => {
|
||||
expect(overflowFilter).not.toContain("tabSearchQuery");
|
||||
const overflowOpenWatch = sourceBetween("watch(tabOverflowOpen", "const showOverflowControl");
|
||||
expect(overflowOpenWatch).toContain('tabOverflowSearchQuery.value = "";');
|
||||
const stripFilter = sourceBetween("const filteredPinnedTabs", "const stripEntries");
|
||||
const stripFilter = sourceBetween("const filteredPinnedTabs", "function buildStripEntries");
|
||||
expect(stripFilter).toContain("tabSearchQuery.value.trim()");
|
||||
expect(stripFilter).not.toContain("tabOverflowSearchQuery");
|
||||
expect(source).toContain('<Input v-model="tabOverflowSearchQuery" data-group-tab-search-input');
|
||||
@@ -117,12 +127,26 @@ describe("EditorGroupTabBar semantic tab groups", () => {
|
||||
});
|
||||
|
||||
it("uses compact group pills and places the accent next to content for horizontal bars", () => {
|
||||
expect(source).toContain('isClassicLayout.value ? "classic-tab-layout" : "separated-tab-layout"');
|
||||
expect(source).toContain(':data-placement="settingsStore.editorSettings.tabPlacement"');
|
||||
expect(source).toContain(':data-group-mode="settingsStore.editorSettings.tabGroupMode"');
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header");
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after");
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after");
|
||||
expect(sharedStyles).toContain('.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after');
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after");
|
||||
expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode.classic-wrap .tab-section--horizontal > .app-tab-pill");
|
||||
expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode:not(.classic-wrap) .tab-section--horizontal > .app-tab-pill");
|
||||
expect(sharedStyles).toContain("row-gap: 0.375rem;");
|
||||
expect(sharedStyles).toContain(".app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)");
|
||||
expect(sharedStyles).toContain('[data-group-mode="none"] .tab-section--horizontal');
|
||||
expect(sharedStyles).toContain("column-gap: 4px;");
|
||||
expect(sharedStyles).toContain(".app-tab-bar.separated-tab-layout.horizontal-fixed-tabs .app-tab-scroll:not(.wrap-mode)");
|
||||
expect(sharedStyles).toContain(".horizontal-fixed-tabs-scroll.wrap-mode");
|
||||
expect(sharedStyles).toContain("row-gap: 0.375rem !important;");
|
||||
expect(sharedStyles).toContain("bottom: 0.375rem;");
|
||||
expect(sharedStyles).toContain("bottom: 0.25rem;");
|
||||
expect(sharedStyles).toContain("scroll-margin-inline-end: 1px;");
|
||||
});
|
||||
|
||||
function sourceBetween(start: string, end: string): string {
|
||||
@@ -298,10 +322,11 @@ describe("EditorGroupTabBar group behavior", () => {
|
||||
expect(headers.map((header) => header.title)).toEqual(["mysql-1", "pg-1"]);
|
||||
expect(host.querySelectorAll("[data-tab-id]").length).toBe(3);
|
||||
|
||||
// Collapse the pg cluster: its pills hide, the count badge appears.
|
||||
// Collapse the pg cluster: its pills contract, the count badge appears.
|
||||
headers[1]!.click();
|
||||
await settle();
|
||||
expect(Array.from(host.querySelectorAll("[data-tab-id]")).map((pill) => pill.getAttribute("data-tab-id"))).toEqual([my]);
|
||||
expect(host.querySelectorAll(".tab-group-entry--collapsed")).toHaveLength(2);
|
||||
expect(host.querySelectorAll("[data-tab-id]")).toHaveLength(3);
|
||||
const pgHeader = host.querySelectorAll<HTMLButtonElement>(".tab-group-header")[1]!;
|
||||
expect(pgHeader.querySelector(".tab-group-count")?.textContent).toBe("2");
|
||||
expect(pgHeader.getAttribute("aria-expanded")).toBe("false");
|
||||
@@ -335,7 +360,8 @@ describe("EditorGroupTabBar group behavior", () => {
|
||||
// Collapsing one "app" cluster leaves the other same-name cluster expanded.
|
||||
headers[0]!.click();
|
||||
await settle();
|
||||
expect(Array.from(host.querySelectorAll("[data-tab-id]")).map((pill) => pill.getAttribute("data-tab-id"))).toEqual([pgConn, pgApp]);
|
||||
expect(host.querySelectorAll(".tab-group-entry--collapsed")).toHaveLength(1);
|
||||
expect(host.querySelectorAll("[data-tab-id]")).toHaveLength(3);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
|
||||
@@ -38,4 +38,13 @@ describe("EditorGroupTabBar compatibility with AppTabBar", () => {
|
||||
expect(source).toContain("const targetGroupExists = queryStore.groups.some((group) => group.id === targetGroupId)");
|
||||
expect(source).toContain("if (!sourceGroupExists || !targetGroupExists) {");
|
||||
});
|
||||
|
||||
it("keeps pinned tabs in a separate row for horizontal placements", () => {
|
||||
expect(source).toContain('hasHorizontalFixedRows.value ? "horizontal-fixed-tabs" : ""');
|
||||
expect(source).toContain("horizontal-fixed-tabs-scroll");
|
||||
expect(source).toContain("const stripSections = computed(() =>");
|
||||
expect(source).toContain('{ key: "regular", pinned: false, entries: regularStripEntries.value }');
|
||||
expect(source).toContain('{ key: "fixed", pinned: true, entries: pinnedStripEntries.value }');
|
||||
expect(source).toContain("!isVerticalLayout.value && props.tabs.some((tab) => tab.pinned)");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@
|
||||
/* 多行平铺模式:覆盖滚动相关样式,让标签换行展示 */
|
||||
.app-tab-scroll.wrap-mode {
|
||||
height: auto !important;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible !important;
|
||||
overflow-x: visible !important;
|
||||
overflow-y: visible !important;
|
||||
@@ -16,21 +19,231 @@
|
||||
|
||||
/* 父级 strip 容器在包含 wrap-mode 时也需要解除高度约束和裁剪 */
|
||||
.app-tab-strip:has(.wrap-mode) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* 标签栏行容器(直接子 div)在包含 wrap-mode 时解除固定高度和裁剪 */
|
||||
.app-tab-bar > div:has(.wrap-mode) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* 标签栏本身也解除裁剪,让换行内容自然撑高 */
|
||||
.app-tab-bar:has(.wrap-mode) {
|
||||
max-width: 100%;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Use an explicit root marker as well as the inner scroll marker. This keeps
|
||||
* the auto-height rules reliable when a fixed-row strip contains wrapped
|
||||
* sections and prevents the outer tab bar from reverting to two-row height. */
|
||||
.app-tab-bar.tab-wrap-mode {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.app-tab-bar.tab-wrap-mode > div {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Horizontal tab bars keep pinned tabs in their own row. Vertical bars use
|
||||
* the same strip for both sections so pinned tabs remain at the top. */
|
||||
.app-tab-bar.horizontal-fixed-tabs {
|
||||
height: 4.5rem !important;
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
|
||||
/* Wrapped rows must grow the tab bar. The fixed two-row height otherwise lets
|
||||
* additional rows paint over the editor while the workspace starts at 4.5rem. */
|
||||
.app-tab-bar.horizontal-fixed-tabs:has(.wrap-mode) {
|
||||
height: auto !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-tab-bar.horizontal-fixed-tabs:not(:has(.wrap-mode)) > div:has(.horizontal-fixed-tabs-scroll) {
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll:not(.wrap-mode) {
|
||||
height: 100% !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0 !important;
|
||||
overscroll-behavior-x: none;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs .app-tab-scroll:not(.wrap-mode) {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
/* A wrapped fixed-tab strip must size itself from its rows. The regular
|
||||
* fixed-row rules above are intentionally excluded because 100% height on an
|
||||
* auto-height flex container creates a cyclic height calculation. */
|
||||
.app-tab-bar.horizontal-fixed-tabs:has(.wrap-mode) > div:has(.horizontal-fixed-tabs-scroll) {
|
||||
height: auto !important;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll.wrap-mode {
|
||||
height: auto !important;
|
||||
min-height: 0;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
column-gap: 0 !important;
|
||||
row-gap: 0.375rem !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.tab-section-frame--vertical,
|
||||
.tab-section--vertical {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.tab-section-frame--horizontal {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section-frame--horizontal {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
height: 2.25rem;
|
||||
min-height: 2.25rem;
|
||||
max-height: 2.25rem;
|
||||
flex: 0 0 2.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-section--horizontal {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section--horizontal {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: none;
|
||||
touch-action: pan-x;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section--horizontal::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-section--horizontal > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Keep grouped tabs flush so their accent forms one continuous rule. With
|
||||
* grouping disabled, flex gap also behaves correctly at wrapped line starts. */
|
||||
.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout)[data-group-mode="none"] .tab-section--horizontal {
|
||||
column-gap: 4px;
|
||||
}
|
||||
|
||||
/* The fixed two-row layout does not need the trailing flex spacer. Keeping it
|
||||
* in the max-content row would manufacture horizontal overflow even when all
|
||||
* tabs already fit. */
|
||||
.horizontal-fixed-tabs-scroll .tab-section--horizontal > [data-tauri-drag-region] {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
flex: 0 0 0;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section--horizontal > .tab-tail-overflow-spacer[data-tauri-drag-region] {
|
||||
width: 1px;
|
||||
min-width: 1px;
|
||||
flex-basis: 1px;
|
||||
}
|
||||
|
||||
/* Keep the overflow affordance out of the measurement box. Otherwise its own
|
||||
* width reduces the scroll viewport and can create a false overflow state. */
|
||||
.tab-overflow-control {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.has-tab-overflow-control .app-tab-strip {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
|
||||
.has-tab-overflow-control .app-tab-scrollbar {
|
||||
inset-inline-end: 2.5rem;
|
||||
}
|
||||
|
||||
/* In the two-row layout, only the first row shares its right edge with the
|
||||
* overflow button. The fixed row must retain the full width of its own strip. */
|
||||
.horizontal-fixed-tabs .has-tab-overflow-control .app-tab-strip {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs > .has-tab-overflow-control {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section-frame--with-overflow-control {
|
||||
padding-inline-end: 35px;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section-frame--with-overflow-control .app-tab-scrollbar {
|
||||
inset-inline-end: 35px;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section-frame--horizontal:not(:first-child) .app-tab-scrollbar {
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs .tab-overflow-control {
|
||||
box-sizing: border-box;
|
||||
width: 35px;
|
||||
padding-inline: 0 1px;
|
||||
height: 2.25rem !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-overflow-control > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tab-overflow-control button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 0;
|
||||
border-color: color-mix(in oklch, var(--foreground) 18%, var(--border));
|
||||
background-color: color-mix(in oklch, var(--muted) 72%, var(--background));
|
||||
box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
[data-settings-page-tab],
|
||||
[data-driver-store-tab] {
|
||||
scroll-margin-inline-end: 1px;
|
||||
}
|
||||
|
||||
/* 经典布局 + 多行模式:优化多行标签显示 */
|
||||
.app-tab-scroll.classic-wrap {
|
||||
row-gap: 0.25rem;
|
||||
@@ -44,6 +257,38 @@
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
/* Wrap mode must keep the old multi-line behavior inside each semantic
|
||||
* section, including when pinned and regular sections are rendered. */
|
||||
.app-tab-scroll.wrap-mode .tab-section-frame--horizontal,
|
||||
.app-tab-scroll.wrap-mode .tab-section--horizontal {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none;
|
||||
flex: 0 0 auto !important;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-tab-scroll.wrap-mode .tab-section--horizontal {
|
||||
flex: 0 0 auto !important;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Modern separated tabs originally kept 0.375rem between wrapped rows. Keep
|
||||
* horizontal tabs flush while preserving that vertical breathing room. */
|
||||
.app-tab-scroll.wrap-mode:not(.classic-wrap) .tab-section--horizontal {
|
||||
column-gap: 0;
|
||||
row-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.app-tab-scroll.wrap-mode .tab-section-frame--horizontal {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.app-tab-pill {
|
||||
background-color: var(--app-tab-background);
|
||||
}
|
||||
@@ -52,6 +297,18 @@
|
||||
background-color: var(--app-tab-hover-background, color-mix(in oklch, var(--foreground) 8%, transparent));
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .app-tab-pill {
|
||||
border: 0.5px solid color-mix(in oklch, var(--foreground) 14%, var(--border));
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .app-tab-pill[data-active-tab="true"] {
|
||||
border-color: color-mix(in oklch, var(--foreground) 42%, var(--border));
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .app-tab-pill[data-active-tab="false"]:hover {
|
||||
border-color: color-mix(in oklch, var(--foreground) 28%, var(--border));
|
||||
}
|
||||
|
||||
.dirty-tab-marker {
|
||||
display: inline-flex;
|
||||
width: 0.5rem;
|
||||
@@ -91,6 +348,20 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* With two horizontal rows, reveal only the scrollbar belonging to the row
|
||||
* under the pointer. The dragging state keeps it visible after the pointer
|
||||
* leaves the row. */
|
||||
.horizontal-fixed-tabs-scroll .app-tab-scrollbar {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.horizontal-fixed-tabs-scroll .tab-section-frame--horizontal:hover .app-tab-scrollbar,
|
||||
.horizontal-fixed-tabs-scroll .app-tab-scrollbar--dragging {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-tab-scrollbar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -166,7 +437,9 @@
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
box-shadow 150ms ease,
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.tab-group-marker {
|
||||
@@ -225,9 +498,42 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Wrapped rows must use their own content height. In particular, avoid
|
||||
* height: 100% here: when a group expands into another row it makes the
|
||||
* auto-sized flex container feed its height back into the row measurement. */
|
||||
.app-tab-scroll.wrap-mode .tab-group-header {
|
||||
height: auto !important;
|
||||
min-height: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.app-tab-scroll.wrap-mode.classic-wrap .tab-group-tab {
|
||||
height: 2rem !important;
|
||||
min-height: 2rem !important;
|
||||
}
|
||||
|
||||
/* Settings and Driver Manager are rendered directly in the section instead
|
||||
* of inside a tab-group-entry wrapper. Their classic h-full class must size
|
||||
* to one wrapped row, not to the full multi-row section. */
|
||||
.app-tab-scroll.wrap-mode.classic-wrap .tab-section--horizontal > .app-tab-pill {
|
||||
height: 2rem !important;
|
||||
min-height: 2rem !important;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.app-tab-scroll.wrap-mode:not(.classic-wrap) .tab-group-tab {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.app-tab-scroll.wrap-mode:not(.classic-wrap) .tab-section--horizontal > .app-tab-pill {
|
||||
height: 1.75rem !important;
|
||||
min-height: 1.75rem !important;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header-content {
|
||||
height: 1.625rem;
|
||||
border-radius: 9999px;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--dbx-radius-md);
|
||||
padding-inline: 0.4rem 0.5rem;
|
||||
background: var(--tab-group-soft);
|
||||
}
|
||||
@@ -245,9 +551,9 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: -0.45rem;
|
||||
right: -0.2rem;
|
||||
bottom: 0;
|
||||
left: 0.3rem;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: var(--tab-group-color);
|
||||
@@ -256,7 +562,7 @@
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after {
|
||||
right: 0.3rem;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-header::after {
|
||||
@@ -268,10 +574,68 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab::after {
|
||||
.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab) {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* Separated tabs are 1.75rem tall inside a row with vertical padding. Group
|
||||
* entries stretch to the row for the continuous accent, so center the pill
|
||||
* within that wrapper instead of leaving it attached to the row's top edge. */
|
||||
.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: var(--tab-group-color);
|
||||
pointer-events: none;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
/* Wrapped group members need the same continuous group rule as the single
|
||||
* row layout. Keep the stripe on the entry so it spans each full tab and
|
||||
* meets the group header stripe without relying on the pill's own height. */
|
||||
.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode) .tab-group-entry:has(.tab-group-tab) {
|
||||
position: relative;
|
||||
height: auto !important;
|
||||
min-height: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode) .tab-group-entry:has(.tab-group-tab)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: var(--tab-group-color);
|
||||
pointer-events: none;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-tab::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: -0.2rem;
|
||||
bottom: 0;
|
||||
left: -0.2rem;
|
||||
@@ -283,16 +647,115 @@
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-tab::after {
|
||||
top: 0;
|
||||
right: -0.2rem;
|
||||
bottom: auto;
|
||||
left: -0.2rem;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-entry:has(.tab-group-tab)::after {
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode)[data-placement="bottom"] .tab-group-entry:has(.tab-group-tab)::after {
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* The separated pill is 1.75rem high and centered in a 2.5rem row. Align the
|
||||
* group rail with the pill edge, not with the outer row edge. */
|
||||
.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-header::after,
|
||||
.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)::after {
|
||||
bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-header::after,
|
||||
.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)::after {
|
||||
bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-header::after,
|
||||
.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-entry:has(.tab-group-tab)::after {
|
||||
top: 0.375rem;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-header::after,
|
||||
.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-entry:has(.tab-group-tab)::after {
|
||||
top: 0.25rem;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--first::after {
|
||||
left: 0;
|
||||
left: -0.2rem;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after {
|
||||
right: 0.3rem;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Keep collapsed group members in the DOM so the cluster can contract and
|
||||
* expand smoothly instead of appearing and disappearing in one frame. */
|
||||
.app-tab-bar:not(:has(.wrap-mode)) .tab-group-entry {
|
||||
max-width: 100%;
|
||||
max-height: 2rem;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-width 140ms ease,
|
||||
max-height 140ms ease,
|
||||
margin 140ms ease,
|
||||
opacity 100ms ease;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(:has(.wrap-mode)) .tab-group-entry--collapsed {
|
||||
max-width: 0 !important;
|
||||
max-height: 0 !important;
|
||||
margin: 0 !important;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
max-width 140ms ease,
|
||||
max-height 140ms ease,
|
||||
margin 140ms ease,
|
||||
opacity 100ms ease;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(:has(.wrap-mode)) .tab-group-entry--collapsed .tab-group-tab--collapsed {
|
||||
max-width: 0 !important;
|
||||
max-height: 0 !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
padding-inline: 0 !important;
|
||||
border-width: 0 !important;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-width 140ms ease,
|
||||
max-height 140ms ease,
|
||||
padding 140ms ease,
|
||||
border-width 140ms ease;
|
||||
}
|
||||
|
||||
/* Wrapped tabs must retain their intrinsic row height. Collapsed groups use
|
||||
* the original immediate-hide behavior here rather than the single-row
|
||||
* width animation, which would otherwise disturb row measurement. */
|
||||
.app-tab-bar:has(.wrap-mode) .tab-group-entry--collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Do not animate dimensions in wrapped mode. The browser must reflow the
|
||||
* complete set of rows immediately when a group is expanded or collapsed. */
|
||||
.app-tab-scroll.wrap-mode .tab-group-entry,
|
||||
.app-tab-scroll.wrap-mode .tab-group-entry--collapsed,
|
||||
.app-tab-scroll.wrap-mode .tab-group-tab,
|
||||
.app-tab-scroll.wrap-mode .tab-group-header,
|
||||
.app-tab-scroll.wrap-mode .tab-group-header-content {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.tab-group-color-option,
|
||||
@@ -393,6 +856,21 @@
|
||||
0 3px 9px color-mix(in srgb, var(--tab-group-color, var(--foreground)) 8%, transparent);
|
||||
}
|
||||
|
||||
/* Without semantic grouping there is no group color to distinguish the
|
||||
* active item, so give the selected vertical tab a stronger neutral state. */
|
||||
.vertical-tab-layout .app-tab-pill:not(.tab-group-tab)[data-active-tab="true"] {
|
||||
background-color: color-mix(in srgb, var(--foreground) 16%, var(--background));
|
||||
border-color: color-mix(in srgb, var(--foreground) 32%, var(--border));
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--ring),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--foreground) 18%, transparent),
|
||||
0 1px 2px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
.vertical-tab-layout .tab-group-tab[data-active-tab="true"] {
|
||||
background-color: var(--tab-group-soft);
|
||||
}
|
||||
|
||||
.vertical-tab-layout .app-tab-scroll > * {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
@@ -468,7 +946,7 @@
|
||||
.vertical-tab-layout .tab-group-header-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: var(--dbx-radius-md);
|
||||
padding-inline: 0.55rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Table,
|
||||
Table2,
|
||||
TableProperties,
|
||||
TerminalSquare,
|
||||
@@ -704,7 +705,7 @@ function iconFor(row: ObjectBrowserRow) {
|
||||
if (row.type === "SEQUENCE") return ListTree;
|
||||
if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") return Package;
|
||||
if (row.type === "TYPE" || row.type === "TYPE_BODY") return Braces;
|
||||
return Table2;
|
||||
return Table;
|
||||
}
|
||||
|
||||
function typeLabel(row: ObjectBrowserRow) {
|
||||
@@ -913,7 +914,8 @@ function groupedFilteredRows() {
|
||||
}
|
||||
|
||||
function iconClass(type: ObjectBrowserRow["type"]) {
|
||||
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return "text-purple-500";
|
||||
if (type === "VIEW") return "text-purple-500";
|
||||
if (type === "MATERIALIZED_VIEW") return "text-indigo-500";
|
||||
if (type === "PROCEDURE") return "text-blue-500";
|
||||
if (type === "FUNCTION") return "text-amber-500";
|
||||
if (type === "TRIGGER") return "text-rose-500";
|
||||
|
||||
Reference in new issue
Block a user