Fix sidebar and tab overflow navigation
This commit is contained in:
parent
c24e3a5086
commit
e94de447c9
|
|
@ -951,7 +951,7 @@ onUnmounted(() => {
|
|||
<div v-show="!setupRequired && (!needsAuth || authenticated)">
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<div
|
||||
class="h-screen w-screen min-w-[900px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden"
|
||||
class="h-screen w-screen min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden"
|
||||
>
|
||||
<AppToolbar
|
||||
:is-dark="isDark"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,27 @@
|
|||
import { computed, ref, watch, nextTick } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, Package, Check } from "lucide-vue-next";
|
||||
import {
|
||||
X,
|
||||
Pin,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Table2,
|
||||
Code2,
|
||||
TableProperties,
|
||||
PencilRuler,
|
||||
Package,
|
||||
Check,
|
||||
} from "lucide-vue-next";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabPresentation";
|
||||
import { shouldShowTabOverflowControls, tabGroupColor, tabGroupKey } from "@/lib/tabPresentation";
|
||||
import { hexToRgba } from "@/lib/color";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
|
|
@ -24,6 +37,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const compactTabTitle = computed({
|
||||
|
|
@ -70,7 +84,8 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
|
|||
}
|
||||
|
||||
const tabsContainerRef = ref<HTMLElement | null>(null);
|
||||
const { canScrollLeft, canScrollRight, updateScrollButtons } = useTabScroll(tabsContainerRef);
|
||||
const { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel } =
|
||||
useTabScroll(tabsContainerRef);
|
||||
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
|
||||
|
||||
watch(
|
||||
|
|
@ -113,24 +128,14 @@ watch(
|
|||
);
|
||||
|
||||
function tabColorStyle(tab: QueryTab) {
|
||||
const color = connectionColor(tab.connectionId);
|
||||
const color = tabGroupColor(tab);
|
||||
const isActive = tab.id === queryStore.activeTabId && !props.showDriverStore;
|
||||
const isClassic = settingsStore.editorSettings.appLayout === "classic";
|
||||
if (!color) {
|
||||
if (isClassic) {
|
||||
return isActive ? { boxShadow: "0 1px 0 0 var(--color-background)" } : undefined;
|
||||
}
|
||||
return isActive
|
||||
? {
|
||||
borderColor: "var(--ring)",
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (isClassic) {
|
||||
return {
|
||||
backgroundColor: hexToRgba(color, isActive ? 0.16 : 0.07),
|
||||
boxShadow: isActive ? `inset 0 -2px 0 ${color}` : undefined,
|
||||
boxShadow: isActive ? `inset 0 -2px 0 ${color}` : `inset 0 -1px 0 ${hexToRgba(color, 0.32)}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -146,21 +151,143 @@ function tabIconClass(tab: QueryTab) {
|
|||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
const dataTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "data"));
|
||||
const showPinnedDataTabsMenu = computed(
|
||||
() => dataTabs.value.length > 0 && (canScrollLeft.value || canScrollRight.value),
|
||||
);
|
||||
const dataTabMenuItems = computed(() =>
|
||||
dataTabs.value.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: tabDisplayTitle(tab),
|
||||
title: tabDisplayTitle(tab),
|
||||
icon: Table2,
|
||||
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||
})),
|
||||
const showTabOverflowControls = computed(() =>
|
||||
shouldShowTabOverflowControls(
|
||||
queryStore.tabs.length,
|
||||
hasTabOverflow.value,
|
||||
canScrollLeft.value,
|
||||
canScrollRight.value,
|
||||
),
|
||||
);
|
||||
|
||||
function activateDataTab(tabId: string) {
|
||||
interface TabGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
tabs: QueryTab[];
|
||||
}
|
||||
|
||||
const connectionSummariesById = computed(
|
||||
() =>
|
||||
new Map(
|
||||
connectionStore.connections.map((connection) => [
|
||||
connection.id,
|
||||
{ name: connection.name, databaseType: connection.db_type },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
function connectionNameForTab(tab: Pick<QueryTab, "connectionId">): string {
|
||||
return connectionSummariesById.value.get(tab.connectionId)?.name || tab.connectionId;
|
||||
}
|
||||
|
||||
function databaseNameForTab(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
const connection = connectionSummariesById.value.get(tab.connectionId);
|
||||
if (connection?.databaseType === "redis" && tab.database !== "") return `db${tab.database}`;
|
||||
return tab.database || t("editor.noDatabase");
|
||||
}
|
||||
|
||||
function isPreviewTab(tab: Pick<QueryTab, "connectionId">): boolean {
|
||||
return !!connectionSummariesById.value.get(tab.connectionId)?.name.startsWith("[Preview]");
|
||||
}
|
||||
|
||||
function tabTitle(tab: QueryTab): string {
|
||||
const database = databaseNameForTab(tab);
|
||||
if (isPreviewTab(tab)) return tab.title;
|
||||
if (tab.mode === "data" && tab.tableMeta?.tableName) {
|
||||
if (compactTabTitle.value) return tab.tableMeta.tableName;
|
||||
const suffix =
|
||||
tab.tableMeta.schema && tab.tableMeta.schema !== tab.database
|
||||
? `@${database}.${tab.tableMeta.schema}`
|
||||
: `@${database}`;
|
||||
return `${tab.tableMeta.tableName}${suffix}`;
|
||||
}
|
||||
if (tab.mode === "query") {
|
||||
if (compactTabTitle.value) return connectionNameForTab(tab);
|
||||
return `${connectionNameForTab(tab)}@${database}`;
|
||||
}
|
||||
if (tab.mode === "mongo" && tab.sql) {
|
||||
if (compactTabTitle.value) return tab.sql;
|
||||
return `${tab.sql}@${database}`;
|
||||
}
|
||||
if (tab.mode === "redis") {
|
||||
if (compactTabTitle.value) return connectionNameForTab(tab);
|
||||
return `${connectionNameForTab(tab)}@${database}`;
|
||||
}
|
||||
if (tab.mode === "objects") {
|
||||
const schema = tab.objectBrowser?.schema;
|
||||
if (compactTabTitle.value) return schema || tab.title;
|
||||
return schema ? `${schema}@${database}` : `${tab.title}@${database}`;
|
||||
}
|
||||
return tab.title;
|
||||
}
|
||||
|
||||
function tabTooltipRows(tab: QueryTab): { label: string; value: string }[] {
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: t("tabs.tooltipConnection"), value: connectionNameForTab(tab) },
|
||||
{ label: t("tabs.tooltipDatabase"), value: databaseNameForTab(tab) },
|
||||
];
|
||||
if (tab.mode === "data" && tab.tableMeta?.tableName) {
|
||||
rows.push({ label: t("tabs.tooltipTable"), value: tab.tableMeta.tableName });
|
||||
}
|
||||
if (tab.mode === "mongo" && tab.sql) {
|
||||
rows.push({ label: t("tabs.tooltipCollection"), value: tab.sql });
|
||||
}
|
||||
if (tab.mode === "objects" && tab.objectBrowser?.schema) {
|
||||
rows.push({ label: t("tabs.tooltipSchema"), value: tab.objectBrowser.schema });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function groupLabelForTab(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
return `${connectionNameForTab(tab)} / ${databaseNameForTab(tab)}`;
|
||||
}
|
||||
|
||||
const tabGroups = computed<TabGroup[]>(() => {
|
||||
const groups: TabGroup[] = [];
|
||||
const byKey = new Map<string, TabGroup>();
|
||||
for (const tab of queryStore.tabs) {
|
||||
const key = tabGroupKey(tab);
|
||||
let group = byKey.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
label: groupLabelForTab(tab),
|
||||
color: tabGroupColor(tab),
|
||||
tabs: [],
|
||||
};
|
||||
byKey.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.tabs.push(tab);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const showTabGroupLabels = computed(() => tabGroups.value.length > 1);
|
||||
const openTabMenuItems = computed(() =>
|
||||
tabGroups.value.flatMap((group, groupIndex) =>
|
||||
group.tabs.map((tab, tabIndex) => ({
|
||||
value: tab.id,
|
||||
label: tabTitle(tab),
|
||||
title: tabTitle(tab),
|
||||
sectionLabel: tabIndex === 0 ? group.label : undefined,
|
||||
sectionColor: tabIndex === 0 ? group.color : undefined,
|
||||
separatorBefore: groupIndex > 0 && tabIndex === 0,
|
||||
icon: tabMenuIcon(tab),
|
||||
iconClass: tabIconClass(tab),
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
function tabMenuIcon(tab: QueryTab) {
|
||||
if (tab.mode === "data") return Table2;
|
||||
if (tab.mode === "objects") return TableProperties;
|
||||
if (tab.mode === "structure") return PencilRuler;
|
||||
return Code2;
|
||||
}
|
||||
|
||||
function activateTab(tabId: string) {
|
||||
tabScrollBehavior.value = "auto";
|
||||
queryStore.activeTabId = tabId;
|
||||
emit("close-driver-store");
|
||||
|
|
@ -170,17 +297,22 @@ const tabsContainerStyle = computed<CSSProperties>(() => ({
|
|||
msOverflowStyle: "none",
|
||||
scrollbarWidth: "none",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
paddingRight: showPinnedDataTabsMenu.value && settingsStore.editorSettings.appLayout !== "classic" ? "28px" : "0px",
|
||||
}));
|
||||
|
||||
const tabTailDragRegionClass = computed(() =>
|
||||
showPinnedDataTabsMenu.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
|
||||
showTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
|
||||
);
|
||||
|
||||
const dataTabsMenuContainerClass = computed(() =>
|
||||
const tabOverflowControlClass = computed(() =>
|
||||
settingsStore.editorSettings.appLayout === "classic"
|
||||
? "relative z-30 flex shrink-0 items-stretch"
|
||||
: "absolute inset-y-0 -right-2 z-30 flex items-stretch",
|
||||
? "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",
|
||||
);
|
||||
|
||||
const tabGroupLabelClass = computed(() =>
|
||||
settingsStore.editorSettings.appLayout === "classic"
|
||||
? "h-full border-r border-border/80 dark:border-border/45 bg-muted/55 px-2"
|
||||
: "h-7 rounded-md border border-border/50 bg-muted/35 px-2",
|
||||
);
|
||||
</script>
|
||||
|
||||
|
|
@ -194,90 +326,119 @@ const dataTabsMenuContainerClass = computed(() =>
|
|||
: 'h-10 items-center bg-background px-2'
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="canScrollLeft"
|
||||
class="pointer-events-none absolute left-0 z-10 h-full w-6 bg-linear-to-r from-background from-40% to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
v-if="showTabOverflowControls"
|
||||
type="button"
|
||||
class="z-30 shrink-0 inline-flex items-center justify-center"
|
||||
:class="[tabOverflowControlClass, canScrollLeft ? '' : 'opacity-40']"
|
||||
:aria-label="t('tabs.scrollLeft')"
|
||||
:title="t('tabs.scrollLeft')"
|
||||
:disabled="!canScrollLeft"
|
||||
@click="scrollTabs('left')"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4 stroke-[2.5]" />
|
||||
</button>
|
||||
<div
|
||||
ref="tabsContainerRef"
|
||||
class="flex-1 flex items-center overflow-x-auto min-w-0"
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? '' : 'gap-1.5'"
|
||||
:style="tabsContainerStyle"
|
||||
@scroll="updateScrollButtons"
|
||||
@wheel="onTabsWheel"
|
||||
>
|
||||
<CustomContextMenu
|
||||
v-for="tab in queryStore.tabs"
|
||||
:key="tab.id"
|
||||
:items="getTabMenuItems(tab)"
|
||||
v-slot="{ onContextMenu }"
|
||||
<div
|
||||
v-for="group in tabGroups"
|
||||
:key="group.key"
|
||||
class="flex h-full shrink-0 items-center"
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? '' : 'gap-1.5'"
|
||||
:data-tab-group="group.key"
|
||||
>
|
||||
<div :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-full border-r border-border/80 dark:border-border/45',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'bg-background text-foreground font-medium'
|
||||
: 'text-foreground/70 hover:text-foreground/90',
|
||||
]
|
||||
: [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-7 rounded-md border',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'text-foreground font-medium'
|
||||
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
|
||||
]
|
||||
"
|
||||
:style="tabColorStyle(tab)"
|
||||
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
|
||||
@click="
|
||||
queryStore.activeTabId = tab.id;
|
||||
emit('close-driver-store');
|
||||
"
|
||||
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
|
||||
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
|
||||
@click.stop="queryStore.togglePinnedTab(tab.id)"
|
||||
>
|
||||
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
|
||||
@click.stop="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipLines(tab)" :key="line.label">
|
||||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div
|
||||
v-if="showTabGroupLabels"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium text-muted-foreground"
|
||||
:class="tabGroupLabelClass"
|
||||
:title="group.label"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: group.color }" />
|
||||
<span class="max-w-32 truncate">{{ group.label }}</span>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
<CustomContextMenu
|
||||
v-for="tab in group.tabs"
|
||||
:key="tab.id"
|
||||
:items="getTabMenuItems(tab)"
|
||||
v-slot="{ onContextMenu }"
|
||||
>
|
||||
<div
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-full border-r border-border/80 dark:border-border/45',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'bg-background text-foreground font-medium'
|
||||
: 'text-foreground/70 hover:text-foreground/90',
|
||||
]
|
||||
: [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-7 rounded-md border',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'text-foreground font-medium'
|
||||
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
|
||||
]
|
||||
"
|
||||
:style="tabColorStyle(tab)"
|
||||
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
|
||||
@click="
|
||||
queryStore.activeTabId = tab.id;
|
||||
emit('close-driver-store');
|
||||
"
|
||||
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: tabGroupColor(tab) }" />
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabTitle(tab) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
|
||||
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
|
||||
@click.stop="queryStore.togglePinnedTab(tab.id)"
|
||||
>
|
||||
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
|
||||
@click.stop="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipRows(tab)" :key="line.label">
|
||||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
</div>
|
||||
|
||||
<!-- Driver Store Tab -->
|
||||
<div
|
||||
|
|
@ -311,29 +472,37 @@ const dataTabsMenuContainerClass = computed(() =>
|
|||
</div>
|
||||
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
|
||||
</div>
|
||||
<div
|
||||
v-if="canScrollRight"
|
||||
class="pointer-events-none absolute right-0 z-20 h-full w-6 bg-linear-to-l from-background from-40% to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div v-if="showPinnedDataTabsMenu" :class="dataTabsMenuContainerClass">
|
||||
<div v-if="showTabOverflowControls" class="relative z-30 flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
v-if="showTabOverflowControls"
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center justify-center"
|
||||
:class="[tabOverflowControlClass, canScrollRight ? '' : 'opacity-40']"
|
||||
:aria-label="t('tabs.scrollRight')"
|
||||
:title="t('tabs.scrollRight')"
|
||||
:disabled="!canScrollRight"
|
||||
@click="scrollTabs('right')"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4 stroke-[2.5]" />
|
||||
</button>
|
||||
<LightDropdown
|
||||
:model-value="queryStore.activeTabId || ''"
|
||||
:items="dataTabMenuItems"
|
||||
:aria-label="t('tabs.openDataTabs')"
|
||||
:items="openTabMenuItems"
|
||||
:aria-label="t('tabs.openTabs')"
|
||||
:trigger-title="t('tabs.openTabs')"
|
||||
:trigger-icon="ChevronDown"
|
||||
trigger-class="h-full w-7 rounded-none text-foreground/70 hover:text-foreground inline-flex items-center justify-center bg-background"
|
||||
:trigger-class="['inline-flex shrink-0 items-center justify-center', tabOverflowControlClass].join(' ')"
|
||||
trigger-icon-class="h-4 w-4"
|
||||
item-icon-class="w-3.5 h-3.5 mr-2"
|
||||
item-class="max-w-full"
|
||||
content-class="w-auto min-w-36 max-w-60"
|
||||
content-class="w-auto min-w-48 max-w-72"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
:highlight-selected="false"
|
||||
:match-trigger-width="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="activateDataTab"
|
||||
@update:model-value="activateTab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -546,6 +546,8 @@ defineExpose({ focusSearch });
|
|||
}
|
||||
|
||||
.connection-tree-scroller :deep(.vue-recycle-scroller__item-view) {
|
||||
contain: layout style paint;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
contain: style;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import type { DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
|||
import * as api from "@/lib/api";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { canTreeNodeShowExpander, treeItemPaddingLeft } from "@/lib/sidebarTreeItemLayout";
|
||||
import { canTreeNodeShowExpander, treeItemPaddingLeft, usesFullWidthTreeLabel } from "@/lib/sidebarTreeItemLayout";
|
||||
import { buildTableSelectSql } from "@/lib/tableSelectSql";
|
||||
import {
|
||||
clearActiveTableReferencePayload,
|
||||
|
|
@ -157,6 +157,12 @@ const emit = defineEmits<{
|
|||
"search-toggle": [node: TreeNode];
|
||||
}>();
|
||||
|
||||
const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(props.node.type));
|
||||
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
|
||||
const labelWidthClass = computed(() =>
|
||||
usesFullWidthLabel.value ? "shrink-0 whitespace-nowrap" : "min-w-0 flex-1 truncate",
|
||||
);
|
||||
|
||||
function currentDatabaseType(): DatabaseType | undefined {
|
||||
return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined;
|
||||
}
|
||||
|
|
@ -2245,16 +2251,19 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
<div @contextmenu="onContextMenu">
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="group flex min-w-0 items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
|
||||
class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
|
||||
style="contain: layout style paint"
|
||||
:class="{
|
||||
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
|
||||
'opacity-50': isDragging,
|
||||
'rounded-none': connectionColor && !isSelected,
|
||||
'rounded-sm': !connectionColor && !isSelected,
|
||||
'tree-item-active rounded-md': isSelected,
|
||||
'tree-item-highlight': highlighted,
|
||||
}"
|
||||
:class="[
|
||||
rowWidthClass,
|
||||
{
|
||||
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
|
||||
'opacity-50': isDragging,
|
||||
'rounded-none': connectionColor && !isSelected,
|
||||
'rounded-sm': !connectionColor && !isSelected,
|
||||
'tree-item-active rounded-md': isSelected,
|
||||
'tree-item-highlight': highlighted,
|
||||
},
|
||||
]"
|
||||
:tabindex="isSelected ? 0 : -1"
|
||||
:style="rowStyle"
|
||||
@click="onClick"
|
||||
|
|
@ -2308,7 +2317,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
@click.stop
|
||||
/>
|
||||
<LightTooltip v-else :text="displayLabel(node)" :disabled="isTooltipDisabled" side="right" :side-offset="8">
|
||||
<span ref="labelRef" class="min-w-0 flex-1 truncate">{{ visibleLabel(node) }}</span>
|
||||
<span ref="labelRef" :class="labelWidthClass">{{ visibleLabel(node) }}</span>
|
||||
</LightTooltip>
|
||||
<span
|
||||
v-if="
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export interface LightDropdownItem {
|
|||
icon?: Component;
|
||||
iconClass?: string;
|
||||
leadingText?: string;
|
||||
sectionLabel?: string;
|
||||
sectionColor?: string;
|
||||
disabled?: boolean;
|
||||
separatorBefore?: boolean;
|
||||
}
|
||||
|
|
@ -187,6 +189,17 @@ onBeforeUnmount(close);
|
|||
<div v-if="label" class="bg-border -mx-1 my-1 h-px" />
|
||||
<template v-for="item in items" :key="item.value">
|
||||
<div v-if="item.separatorBefore" class="bg-border -mx-1 my-1 h-px" />
|
||||
<div
|
||||
v-if="item.sectionLabel"
|
||||
class="flex items-center gap-1.5 px-1.5 pb-1 pt-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
v-if="item.sectionColor"
|
||||
class="h-2 w-2 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: item.sectionColor }"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ item.sectionLabel }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-sm outline-hidden hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
|
|
|
|||
|
|
@ -1,26 +1,85 @@
|
|||
import { ref, type Ref } from "vue";
|
||||
import { onBeforeUnmount, ref, watch, type Ref } from "vue";
|
||||
|
||||
export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
|
||||
const hasTabOverflow = ref(false);
|
||||
const canScrollLeft = ref(false);
|
||||
const canScrollRight = ref(false);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let updateFrame = 0;
|
||||
|
||||
function updateScrollButtons() {
|
||||
const el = tabsContainerRef.value;
|
||||
if (!el) {
|
||||
hasTabOverflow.value = false;
|
||||
canScrollLeft.value = false;
|
||||
canScrollRight.value = false;
|
||||
return;
|
||||
}
|
||||
canScrollLeft.value = el.scrollLeft > 0;
|
||||
canScrollRight.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
hasTabOverflow.value = maxScrollLeft > 1;
|
||||
canScrollLeft.value = el.scrollLeft > 1;
|
||||
canScrollRight.value = el.scrollLeft < maxScrollLeft - 1;
|
||||
}
|
||||
|
||||
function scheduleScrollButtonUpdate() {
|
||||
if (updateFrame) return;
|
||||
const update = () => {
|
||||
updateFrame = 0;
|
||||
updateScrollButtons();
|
||||
};
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
updateFrame = requestAnimationFrame(update);
|
||||
} else {
|
||||
window.setTimeout(update, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTabs(direction: "left" | "right") {
|
||||
const el = tabsContainerRef.value;
|
||||
if (!el) return;
|
||||
const scrollAmount = el.clientWidth * 0.8;
|
||||
const scrollAmount = Math.max(160, el.clientWidth * 0.65);
|
||||
el.scrollBy({ left: direction === "left" ? -scrollAmount : scrollAmount, behavior: "smooth" });
|
||||
scheduleScrollButtonUpdate();
|
||||
window.setTimeout(updateScrollButtons, 260);
|
||||
}
|
||||
|
||||
return { canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs };
|
||||
function onTabsWheel(event: WheelEvent) {
|
||||
const el = tabsContainerRef.value;
|
||||
if (!el) return;
|
||||
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
if (maxScrollLeft <= 1) return;
|
||||
|
||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||
if (delta === 0) return;
|
||||
|
||||
const previousScrollLeft = el.scrollLeft;
|
||||
el.scrollLeft = Math.min(maxScrollLeft, Math.max(0, previousScrollLeft + delta));
|
||||
if (el.scrollLeft !== previousScrollLeft) {
|
||||
event.preventDefault();
|
||||
updateScrollButtons();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
tabsContainerRef,
|
||||
(el) => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
if (el && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(scheduleScrollButtonUpdate);
|
||||
resizeObserver.observe(el);
|
||||
}
|
||||
scheduleScrollButtonUpdate();
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
if (updateFrame && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(updateFrame);
|
||||
}
|
||||
});
|
||||
|
||||
return { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ export default {
|
|||
resultN: "Result {n}",
|
||||
scrollLeft: "Scroll tabs left",
|
||||
scrollRight: "Scroll tabs right",
|
||||
openTabs: "Open tabs",
|
||||
openDataTabs: "Open tables",
|
||||
},
|
||||
chart: {
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ export default {
|
|||
resultN: "Resultado {n}",
|
||||
scrollLeft: "Desplazar pestañas a la izquierda",
|
||||
scrollRight: "Desplazar pestañas a la derecha",
|
||||
openTabs: "Pestañas abiertas",
|
||||
openDataTabs: "Tablas abiertas",
|
||||
},
|
||||
chart: {
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ export default {
|
|||
resultN: "结果 {n}",
|
||||
scrollLeft: "向左滚动标签页",
|
||||
scrollRight: "向右滚动标签页",
|
||||
openTabs: "已打开的标签页",
|
||||
openDataTabs: "已打开的数据表",
|
||||
},
|
||||
chart: {
|
||||
|
|
|
|||
|
|
@ -13,10 +13,16 @@ const leafTypes: Set<TreeNodeType> = new Set([
|
|||
"saved-sql-file",
|
||||
]);
|
||||
|
||||
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "mongo-collection"]);
|
||||
|
||||
export function treeItemPaddingLeft(depth: number): string {
|
||||
return `${depth * 16 + 8}px`;
|
||||
}
|
||||
|
||||
export function usesFullWidthTreeLabel(type: TreeNodeType): boolean {
|
||||
return fullWidthLabelTypes.has(type);
|
||||
}
|
||||
|
||||
export function canTreeNodeExpand(type: TreeNodeType): boolean {
|
||||
return !leafTypes.has(type);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,19 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
const tabGroupPalette = [
|
||||
"#2563eb",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
"#dc2626",
|
||||
"#7c3aed",
|
||||
"#0891b2",
|
||||
"#c2410c",
|
||||
"#4f46e5",
|
||||
"#be185d",
|
||||
"#16a34a",
|
||||
];
|
||||
|
||||
export function connectionDisplayName(connectionId: string): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
return connectionStore.getConfig(connectionId)?.name || connectionId;
|
||||
|
|
@ -21,6 +34,28 @@ export function databaseDisplayNameForTab(connectionId: string, database: string
|
|||
return database || t("editor.noDatabase");
|
||||
}
|
||||
|
||||
export function tabGroupKey(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
return `${tab.connectionId}::${tab.database || ""}`;
|
||||
}
|
||||
|
||||
export function colorForTabGroupKey(groupKey: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < groupKey.length; i += 1) {
|
||||
hash = (hash * 31 + groupKey.charCodeAt(i)) | 0;
|
||||
}
|
||||
return tabGroupPalette[Math.abs(hash) % tabGroupPalette.length];
|
||||
}
|
||||
|
||||
export function tabGroupColor(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
return colorForTabGroupKey(tabGroupKey(tab));
|
||||
}
|
||||
|
||||
export function tabGroupLabel(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
const connection = connectionDisplayName(tab.connectionId);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
return database ? `${connection} / ${database}` : connection;
|
||||
}
|
||||
|
||||
export function isPreviewTab(tab: QueryTab): boolean {
|
||||
const connectionStore = useConnectionStore();
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
|
|
@ -80,6 +115,15 @@ export function tabTooltipLines(tab: QueryTab): { label: string; value: string }
|
|||
return lines;
|
||||
}
|
||||
|
||||
export function shouldShowTabOverflowControls(
|
||||
tabCount: number,
|
||||
hasTabOverflow: boolean,
|
||||
canScrollLeft: boolean,
|
||||
canScrollRight: boolean,
|
||||
): boolean {
|
||||
return tabCount > 0 && (hasTabOverflow || canScrollLeft || canScrollRight);
|
||||
}
|
||||
|
||||
export function tabModeLabel(tab: QueryTab): string {
|
||||
const { t } = useI18n();
|
||||
if (tab.mode === "data") return t("tabs.table");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { canTreeNodeExpand, canTreeNodeShowExpander, treeItemPaddingLeft } from "../../apps/desktop/src/lib/sidebarTreeItemLayout.ts";
|
||||
import {
|
||||
canTreeNodeExpand,
|
||||
canTreeNodeShowExpander,
|
||||
treeItemPaddingLeft,
|
||||
usesFullWidthTreeLabel,
|
||||
} from "../../apps/desktop/src/lib/sidebarTreeItemLayout.ts";
|
||||
|
||||
test("treeItemPaddingLeft converts tree depth to sidebar indentation", () => {
|
||||
assert.equal(treeItemPaddingLeft(0), "8px");
|
||||
|
|
@ -31,3 +36,13 @@ test("canTreeNodeShowExpander hides empty saved SQL containers", () => {
|
|||
assert.equal(canTreeNodeShowExpander({ type: "saved-sql-root", childCount: 1 }), true);
|
||||
assert.equal(canTreeNodeShowExpander({ type: "saved-sql-folder", childCount: 1 }), true);
|
||||
});
|
||||
|
||||
test("usesFullWidthTreeLabel only expands object names that users need to read horizontally", () => {
|
||||
assert.equal(usesFullWidthTreeLabel("table"), true);
|
||||
assert.equal(usesFullWidthTreeLabel("view"), true);
|
||||
assert.equal(usesFullWidthTreeLabel("mongo-collection"), true);
|
||||
|
||||
assert.equal(usesFullWidthTreeLabel("connection"), false);
|
||||
assert.equal(usesFullWidthTreeLabel("schema"), false);
|
||||
assert.equal(usesFullWidthTreeLabel("column"), false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
colorForTabGroupKey,
|
||||
shouldShowTabOverflowControls,
|
||||
tabGroupColor,
|
||||
tabGroupKey,
|
||||
} from "../../apps/desktop/src/lib/tabPresentation.ts";
|
||||
|
||||
test("tab overflow controls only show when there are hidden tabs to reach", () => {
|
||||
assert.equal(shouldShowTabOverflowControls(0, true, true, true), false);
|
||||
assert.equal(shouldShowTabOverflowControls(3, false, false, false), false);
|
||||
assert.equal(shouldShowTabOverflowControls(3, true, false, false), true);
|
||||
assert.equal(shouldShowTabOverflowControls(3, false, true, false), true);
|
||||
assert.equal(shouldShowTabOverflowControls(3, false, false, true), true);
|
||||
});
|
||||
|
||||
test("tab group key and color are stable per connection database pair", () => {
|
||||
const first = { connectionId: "local", database: "orders" };
|
||||
const same = { connectionId: "local", database: "orders" };
|
||||
const other = { connectionId: "local", database: "billing" };
|
||||
|
||||
assert.equal(tabGroupKey(first), "local::orders");
|
||||
assert.equal(tabGroupKey(first), tabGroupKey(same));
|
||||
assert.notEqual(tabGroupKey(first), tabGroupKey(other));
|
||||
assert.equal(tabGroupColor(first), tabGroupColor(same));
|
||||
assert.equal(colorForTabGroupKey(tabGroupKey(first)), tabGroupColor(first));
|
||||
});
|
||||
Loading…
Reference in New Issue