diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 4906aa4c95..06d34e43ea 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -573,6 +573,12 @@ useScheduledDatabaseBackups({ scheduler: true }); const appVersion = ref(""); const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic"); +const isVerticalTabPlacement = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right"); +const tabWorkspaceLayoutClass = computed(() => { + if (settingsStore.editorSettings.tabPlacement === "bottom") return "flex-col-reverse"; + if (settingsStore.editorSettings.tabPlacement === "right") return "flex-row-reverse"; + return isVerticalTabPlacement.value ? "flex-row" : "flex-col"; +}); const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled); function openSettings(initialTab = "appearance", initialSection?: string) { @@ -3046,7 +3052,7 @@ onUnmounted(() => {
-
+
{ @discard-all-tab-close="handleDiscardAllPendingTabClose" @cancel-tab-close="cancelPendingAppClose" /> - - -
- + + - - + + + - + schema: target.schema, + catalog: target.catalog, + tableName: target.tableName, + tableType: target.tableType, + }) + " + @object-schema-change="(schema) => activeTab && queryStore.updateSchema(activeTab.id, schema)" + @object-browser-viewport-change="(tabId, viewport) => queryStore.updateObjectBrowserViewport(tabId, viewport)" + @structure-editor-saved=" + (commentChanged) => + activeTab && + onStructureEditorSaved( + onReloadData, + toast, + { + connectionId: activeTab.connectionId, + database: activeTab.database, + schema: activeTab.schema, + catalog: activeTab.catalog, + tableName: activeTab.structureTableName || '', + }, + commentChanged, + ) + " + @structure-editor-close="activeTab && queryStore.closeTab(activeTab.id)" + @open-settings="openSettings" + @open-connection-settings="openConnectionSettings" + /> + +
+
-
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index f36f4aa680..13e9867b42 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -80,6 +80,9 @@ import { type SidebarObjectInfoMode, type SqlSemanticDiagnosticsMode, type SavedSqlOpenTargetMode, + type TabGroupMode, + type TabPlacement, + type TabSortMode, type UpdateDownloadSource, type CustomThemeColors, type CustomTheme, @@ -411,6 +414,9 @@ const editAppCloseUnsavedTabsMode = ref(settingsStore.e const editSavedSqlOpenTargetMode = ref(settingsStore.editorSettings.savedSqlOpenTargetMode); const editAppLayout = ref(settingsStore.editorSettings.appLayout); const editTabLayout = ref(settingsStore.editorSettings.tabLayout); +const editTabPlacement = ref(settingsStore.editorSettings.tabPlacement); +const editTabGroupMode = ref(settingsStore.editorSettings.tabGroupMode); +const editTabSortMode = ref(settingsStore.editorSettings.tabSortMode); const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon); const editQuitOnClose = ref(settingsStore.desktopSettings.quit_on_close); const desktopCloseBehaviorResetPending = ref(false); @@ -629,6 +635,9 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { savedSqlOpenTargetMode: editSavedSqlOpenTargetMode.value, appLayout: editAppLayout.value, tabLayout: editTabLayout.value, + tabPlacement: editTabPlacement.value, + tabGroupMode: editTabGroupMode.value, + tabSortMode: editTabSortMode.value, showColumnCommentsInHeader: editShowColumnCommentsInHeader.value, showColumnTypesInHeader: editShowColumnTypesInHeader.value, dataGridShowTransposeFieldMetadata: editDataGridShowTransposeFieldMetadata.value, @@ -1027,6 +1036,9 @@ function syncEditorSettingsDraftFromStore() { editSavedSqlOpenTargetMode.value = settingsStore.editorSettings.savedSqlOpenTargetMode; editAppLayout.value = settingsStore.editorSettings.appLayout; editTabLayout.value = settingsStore.editorSettings.tabLayout; + editTabPlacement.value = settingsStore.editorSettings.tabPlacement; + editTabGroupMode.value = settingsStore.editorSettings.tabGroupMode; + editTabSortMode.value = settingsStore.editorSettings.tabSortMode; editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader; editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader; editDataGridShowTransposeFieldMetadata.value = settingsStore.editorSettings.dataGridShowTransposeFieldMetadata; @@ -1311,6 +1323,9 @@ function resetDefaultsForTab(tab: SettingsCategory) { editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId; editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout; editTabLayout.value = DEFAULT_EDITOR_SETTINGS.tabLayout; + editTabPlacement.value = DEFAULT_EDITOR_SETTINGS.tabPlacement; + editTabGroupMode.value = DEFAULT_EDITOR_SETTINGS.tabGroupMode; + editTabSortMode.value = DEFAULT_EDITOR_SETTINGS.tabSortMode; editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon; editQuitOnClose.value = DEFAULT_DESKTOP_SETTINGS.quit_on_close; desktopCloseBehaviorResetPending.value = true; @@ -1784,6 +1799,18 @@ function setTabLayout(value: "scroll" | "wrap") { editTabLayout.value = value; } +function setTabPlacement(value: TabPlacement) { + editTabPlacement.value = value; +} + +function setTabGroupMode(value: TabGroupMode) { + editTabGroupMode.value = value; +} + +function setTabSortMode(value: TabSortMode) { + editTabSortMode.value = value; +} + function setSidebarActivation(value: "single" | "double") { editSidebarActivation.value = value; } @@ -5284,6 +5311,49 @@ onUnmounted(() => { +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+

{{ t("settings.tabPlacementDescription") }}

+

{{ t("settings.tabOrganizationDescription") }}

+
+
+
diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index ba8c73858a..1f5f449696 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -52,15 +52,60 @@ const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); const { toast } = useToast(); -const tabDrag = useTabDrag((draggedId, targetId, position) => { - return queryStore.reorderTab(draggedId, targetId, position); -}); const editingTabId = ref(null); const editingTitle = ref(""); const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic"); -const isWrapLayout = computed(() => settingsStore.editorSettings.tabLayout === "wrap"); -const fixedTabs = computed(() => queryStore.tabs.filter((tab) => tab.pinned)); -const regularTabs = computed(() => queryStore.tabs.filter((tab) => !tab.pinned)); +const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right"); +const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap"); +const isManualTabOrder = computed(() => settingsStore.editorSettings.tabSortMode === "manual"); +const tabDrag = useTabDrag( + (draggedId, targetId, position) => queryStore.reorderTab(draggedId, targetId, position), + () => (isVerticalLayout.value ? "vertical" : "horizontal"), +); + +function tabGroupKey(tab: QueryTab) { + const connection = connectionStore.getConfig(tab.connectionId); + if (settingsStore.editorSettings.tabGroupMode === "connection") return tab.connectionId; + return connection?.driver_profile || connection?.db_type || tab.connectionId || "unknown"; +} + +function sortDisplayedTabs(tabs: QueryTab[]) { + const sortMode = settingsStore.editorSettings.tabSortMode; + const groupMode = settingsStore.editorSettings.tabGroupMode; + return tabs + .map((tab, index) => ({ tab, index })) + .sort((left, right) => { + if (groupMode !== "none") { + const group = tabGroupKey(left.tab).localeCompare(tabGroupKey(right.tab), undefined, { sensitivity: "base", numeric: true }); + if (group) return group; + } + if (sortMode === "manual") return left.index - right.index; + if (sortMode === "created-asc") { + const created = (left.tab.createdAt ?? left.index) - (right.tab.createdAt ?? right.index); + if (created) return created; + } else { + const title = tabTitleText(left.tab).localeCompare(tabTitleText(right.tab), undefined, { sensitivity: "base", numeric: true }); + if (title) return title; + } + return left.index - right.index; + }) + .map(({ tab }) => tab); +} + +const fixedTabs = computed(() => sortDisplayedTabs(queryStore.tabs.filter((tab) => tab.pinned))); +const regularTabs = computed(() => sortDisplayedTabs(queryStore.tabs.filter((tab) => !tab.pinned))); +const displayedTabs = computed(() => [...fixedTabs.value, ...regularTabs.value]); + +function isGroupStart(tabs: QueryTab[], index: number) { + if (settingsStore.editorSettings.tabGroupMode === "none") return false; + return index === 0 || tabGroupKey(tabs[index - 1]!) !== tabGroupKey(tabs[index]!); +} + +function tabGroupLabel(tab: QueryTab) { + const connection = connectionStore.getConfig(tab.connectionId); + if (settingsStore.editorSettings.tabGroupMode === "connection") return connection?.name || tab.connectionId; + return connection?.driver_label || connection?.driver_profile || connection?.db_type || tab.connectionId; +} const hasFixedTabs = computed(() => fixedTabs.value.length > 0); const regularSurfaceCount = computed(() => regularTabs.value.length + (props.driverStoreOpen ? 1 : 0) + (props.settingsPageOpen ? 1 : 0)); const closeConfirmDirtyCount = computed(() => queryStore.closeConfirmDirtyTabIds.length); @@ -202,7 +247,9 @@ function hasTabsToRight(tab: QueryTab) { function closeTabsToRightFromTab(tab: QueryTab) { const shouldActivateTarget = !tab.pinned && (!!props.settingsPageActive || !!props.driverStoreActive); - queryStore.closeRightTabs(tab.id, () => { + const tabsToClose = tabsToRightInGroup(tab).map((item) => item.id); + const finalActiveTabId = queryStore.activeTabId && !tabsToClose.includes(queryStore.activeTabId) ? queryStore.activeTabId : tab.id; + queryStore.closeTabsByIds(tabsToClose, finalActiveTabId, () => { if (tab.pinned) return; closeSpecialRegularSurfaces(); if (shouldActivateTarget) activateTab(tab.id); @@ -220,7 +267,12 @@ function hasTabsToLeft(tab: QueryTab) { } function closeTabsToLeftFromTab(tab: QueryTab) { - queryStore.closeLeftTabs(tab.id); + // Mirror the close-right path: compute the ids from the sorted display + // order (fixedTabs/regularTabs), not the underlying array order, so the + // menu closes exactly the tabs shown to the left of the target. + const tabsToClose = tabsToLeftInGroup(tab).map((item) => item.id); + const finalActiveTabId = queryStore.activeTabId && !tabsToClose.includes(queryStore.activeTabId) ? queryStore.activeTabId : tab.id; + queryStore.closeTabsByIds(tabsToClose, finalActiveTabId); } function hasSpecialRegularSurfaceToRight(surface: SpecialRegularSurface) { @@ -448,7 +500,9 @@ watch( if (!container) continue; const activeEl = container.querySelector('[data-active-tab="true"]'); if (activeEl) { - activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: activeTabScrollInline(container, queryStore.activeTabId) }); + activeEl.scrollIntoView( + isVerticalLayout.value ? { behavior: tabScrollBehavior.value, block: activeTabScrollInline(container, queryStore.activeTabId), inline: "nearest" } : { behavior: tabScrollBehavior.value, block: "nearest", inline: activeTabScrollInline(container, queryStore.activeTabId) }, + ); break; } } @@ -469,7 +523,7 @@ watch( if (!container) return; const el = container.querySelector("[data-driver-store-tab]"); if (el) { - el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + el.scrollIntoView(isVerticalLayout.value ? { behavior: "smooth", block: "center", inline: "nearest" } : { behavior: "smooth", block: "nearest", inline: "center" }); } updateAllScrollButtons(); }); @@ -486,7 +540,7 @@ watch( if (!container) return; const el = container.querySelector("[data-settings-page-tab]"); if (el) { - el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + el.scrollIntoView(isVerticalLayout.value ? { behavior: "smooth", block: "center", inline: "nearest" } : { behavior: "smooth", block: "nearest", inline: "center" }); } updateAllScrollButtons(); }); @@ -550,19 +604,42 @@ function tabDatabaseIconType(tab: QueryTab) { return connection.driver_profile || connection.db_type; } -const showRegularTabScrollbar = computed(() => hasTabOverflow.value && !isWrapLayout.value); -const showFixedTabScrollbar = computed(() => hasFixedTabOverflow.value && !isWrapLayout.value); -const showRegularTabOverflowControls = computed(() => regularTabs.value.length > 0 && hasTabOverflow.value && !isWrapLayout.value); +const showRegularTabScrollbar = computed(() => hasTabOverflow.value && !isWrapLayout.value && !isVerticalLayout.value); +const showFixedTabScrollbar = computed(() => hasFixedTabOverflow.value && !isWrapLayout.value && !isVerticalLayout.value); +const showRegularTabOverflowControls = computed(() => regularTabs.value.length > 0 && hasTabOverflow.value && !isWrapLayout.value && !isVerticalLayout.value); const regularTabOverflowOpen = ref(false); const fixedTabOverflowOpen = ref(false); const tabSearchQuery = ref(""); const filteredOpenTabs = computed(() => { const query = tabSearchQuery.value.trim().toLocaleLowerCase(); - if (!query) return queryStore.tabs; - return queryStore.tabs.filter((tab) => tabTitleText(tab).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query)); + if (!query) return displayedTabs.value; + return displayedTabs.value.filter((tab) => tabMatchesSearch(tab, query)); }); -const tabBarClass = computed(() => [isClassicLayout.value ? "bg-muted" : "border-b bg-background", hasFixedTabs.value ? "flex-col" : "", isClassicLayout.value && hasFixedTabs.value ? "border-b" : ""]); -const regularTabRowClass = computed(() => [isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2", isClassicLayout.value && !hasFixedTabs.value ? "border-b" : ""]); + +function tabMatchesSearch(tab: QueryTab, query: string) { + const connectionName = connectionStore.getConfig(tab.connectionId)?.name || ""; + return tabTitleText(tab).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query) || connectionName.toLocaleLowerCase().includes(query); +} + +const filteredRegularTabs = computed(() => { + const query = tabSearchQuery.value.trim().toLocaleLowerCase(); + return query ? regularTabs.value.filter((tab) => tabMatchesSearch(tab, query)) : regularTabs.value; +}); +const filteredFixedTabs = computed(() => { + const query = tabSearchQuery.value.trim().toLocaleLowerCase(); + return query ? fixedTabs.value.filter((tab) => tabMatchesSearch(tab, query)) : fixedTabs.value; +}); +const hasVisibleFixedTabs = computed(() => filteredFixedTabs.value.length > 0); +const tabBarClass = computed(() => [ + isVerticalLayout.value + ? `vertical-tab-layout h-full w-60 flex-col bg-background ${settingsStore.editorSettings.tabPlacement === "right" ? "border-l" : "border-r"}` + : isClassicLayout.value + ? "w-full bg-muted" + : `w-full bg-background ${settingsStore.editorSettings.tabPlacement === "bottom" ? "border-t" : "border-b"}`, + hasFixedTabs.value ? "flex-col" : "", + isClassicLayout.value && hasFixedTabs.value && !isVerticalLayout.value ? "border-b" : "", +]); +const regularTabRowClass = computed(() => (isVerticalLayout.value ? ["min-h-0 flex-1 flex-col items-stretch"] : [isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2", isClassicLayout.value && !hasFixedTabs.value ? "border-b" : ""])); watch(regularTabOverflowOpen, (open) => { tabSearchQuery.value = ""; @@ -617,10 +694,11 @@ function handleTabMouseDown(event: PointerEvent, tabId: string) { // overflowing tab bar. if (event.pointerType !== "touch") event.preventDefault(); } - tabDrag.startDrag(event, tabId); + if (isManualTabOrder.value) tabDrag.startDrag(event, tabId); } function handleTabDragTarget(event: MouseEvent, tab: QueryTab) { + if (!isManualTabOrder.value) return; const draggedTab = queryStore.tabs.find((item) => item.id === tabDrag.state.draggedId); if (draggedTab && draggedTab.pinned !== tab.pinned) { tabDrag.clearTarget(tab.id); @@ -634,10 +712,8 @@ function tabDropStyle(tabId: string) { if (tabDrag.state.draggedId === tabId) return { opacity: 0.4 }; if (tabDrag.state.targetId !== tabId) return {}; const dropColor = `var(--ring)`; - if (tabDrag.state.dropPosition === "before") { - return { boxShadow: `inset 3px 0 0 0 ${dropColor}` }; - } - return { boxShadow: `inset -3px 0 0 0 ${dropColor}` }; + if (isVerticalLayout.value) return tabDrag.state.dropPosition === "before" ? { boxShadow: `inset 0 3px 0 0 ${dropColor}` } : { boxShadow: `inset 0 -3px 0 0 ${dropColor}` }; + return tabDrag.state.dropPosition === "before" ? { boxShadow: `inset 3px 0 0 0 ${dropColor}` } : { boxShadow: `inset -3px 0 0 0 ${dropColor}` }; } const tabsContainerStyle = computed(() => ({ @@ -656,8 +732,8 @@ const fixedTabScrollbarThumbStyle = computed(() => ({ width: `${fixedScrollThumbWidthPercent.value}%`, })); -const tabTailDragRegionClass = computed(() => (showRegularTabOverflowControls.value || isWrapLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); -const fixedTabTailDragRegionClass = computed(() => (showFixedTabScrollbar.value || isWrapLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); +const tabTailDragRegionClass = computed(() => (isVerticalLayout.value || showRegularTabOverflowControls.value || isWrapLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); +const fixedTabTailDragRegionClass = computed(() => (isVerticalLayout.value || showFixedTabScrollbar.value || isWrapLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); const tabOverflowControlClass = computed(() => isClassicLayout.value @@ -697,7 +773,11 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul