From e94de447c9c44f9e87f624a5379fcc1b6396f6b8 Mon Sep 17 00:00:00 2001
From: vrustx <279631638@qq.com>
Date: Sun, 31 May 2026 02:41:43 +0800
Subject: [PATCH] Fix sidebar and tab overflow navigation
---
apps/desktop/src/App.vue | 2 +-
.../src/components/layout/AppTabBar.vue | 407 +++++++++++++-----
.../src/components/sidebar/ConnectionTree.vue | 4 +-
.../src/components/sidebar/TreeItem.vue | 31 +-
.../src/components/ui/LightDropdown.vue | 13 +
apps/desktop/src/composables/useTabScroll.ts | 69 ++-
apps/desktop/src/i18n/locales/en.ts | 1 +
apps/desktop/src/i18n/locales/es.ts | 1 +
apps/desktop/src/i18n/locales/zh-CN.ts | 1 +
apps/desktop/src/lib/sidebarTreeItemLayout.ts | 6 +
apps/desktop/src/lib/tabPresentation.ts | 44 ++
.../app-tests/sidebarTreeItemLayout.test.ts | 17 +-
packages/app-tests/tabPresentation.test.ts | 28 ++
13 files changed, 486 insertions(+), 138 deletions(-)
create mode 100644 packages/app-tests/tabPresentation.test.ts
diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue
index d49f73722..ee5de9962 100644
--- a/apps/desktop/src/App.vue
+++ b/apps/desktop/src/App.vue
@@ -951,7 +951,7 @@ onUnmounted(() => {
();
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(null);
-const { canScrollLeft, canScrollRight, updateScrollButtons } = useTabScroll(tabsContainerRef);
+const { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel } =
+ useTabScroll(tabsContainerRef);
const tabScrollBehavior = ref("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): string {
+ return connectionSummariesById.value.get(tab.connectionId)?.name || tab.connectionId;
+}
+
+function databaseNameForTab(tab: Pick): 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): 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): string {
+ return `${connectionNameForTab(tab)} / ${databaseNameForTab(tab)}`;
+}
+
+const tabGroups = computed(() => {
+ const groups: TabGroup[] = [];
+ const byKey = new Map();
+ 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(() => ({
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",
);
@@ -194,90 +326,119 @@ const dataTabsMenuContainerClass = computed(() =>
: 'h-10 items-center bg-background px-2'
"
>
-
+
-
-
-
-
-
-
-
-
-
-
-
-
{{ tabDisplayTitle(tab) }}
-
-
-
-
- {{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}
-
-
-
-
-
-
- {{ line.label }}
- {{ line.value }}
-
-
-
+
+
+ {{ group.label }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ tabTitle(tab) }}
+
+
+
+
+ {{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}
+
+
+
+
+
+
+ {{ line.label }}
+ {{ line.value }}
+
+
+
+
+
+
-
-
diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue
index 81f33c6ef..117dd0be3 100644
--- a/apps/desktop/src/components/sidebar/ConnectionTree.vue
+++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue
@@ -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;
}
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue
index a954cd552..a3a99f900 100644
--- a/apps/desktop/src/components/sidebar/TreeItem.vue
+++ b/apps/desktop/src/components/sidebar/TreeItem.vue
@@ -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[] {
- {{ visibleLabel(node) }}
+ {{ visibleLabel(node) }}
+
+
+ {{ item.sectionLabel }}
+