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