feat(tabs): configurable tab bar position, grouping and sorting

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-01 10:59:50 +08:00
1 parent d69cf9da79
commit 5f9ba51769
18 files changed
+728 -353

No files matched your search

+147 -139
View File
@@ -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(() => {
</div>
<div v-show="!isAiPanelMaximized || isZenMode" :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
<div class="h-full flex flex-col min-w-0">
<div class="h-full flex min-w-0" :class="tabWorkspaceLayoutClass">
<AppTabBar
ref="appTabBarRef"
:driver-store-open="driverStoreTabOpen"
@@ -3067,155 +3073,157 @@ onUnmounted(() => {
@discard-all-tab-close="handleDiscardAllPendingTabClose"
@cancel-tab-close="cancelPendingAppClose"
/>
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
<EditorSettingsPage
v-if="settingsPageTabOpen"
v-show="settingsStore.settingsPageActive"
variant="page"
:open="settingsPageTabOpen"
:initial-tab="settingsInitialTab"
:initial-section="settingsInitialSection"
:navigation-request-id="settingsNavigationRequestId"
:ai-config-draft="settingsAiConfigDraft"
:ai-config-request-id="settingsAiConfigRequestId"
:app-version="appVersion"
:checking-updates="checkingUpdates"
class="flex-1 min-h-0"
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
@check-updates="checkUpdates()"
/>
<div v-if="activeTab" v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex flex-col flex-1 min-h-0">
<EditorToolbar
v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:explain-mode="explainMode"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
:sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase"
:database-required-signal="databaseRequiredTabId === activeTab.id ? databaseRequiredSignal : 0"
:auto-commit="activeTab.autoCommit ?? true"
:txn-session-id="activeTab?.txnSessionId"
:txn-auto-rolled-back="activeTab?.txnAutoRolledBack"
:oracle-txn-possibly-dirty="activeTab?.oracleTxnPossiblyDirty"
:is-oracle-manual-transaction="isOracleManualTransaction"
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
@update:auto-commit="
(v: boolean) => {
if (activeTab) queryStore.setAutoCommit(activeTab.id, v);
}
"
@commit="activeTab && queryStore.commitTransaction(activeTab.id)"
@rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)"
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
@execute-pointer-down="captureActiveEditorExecutionSnapshot()"
@execute="requestActiveEditorExecute($event)"
@multi-execute="requestMultiDbExecute()"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@format-sql="formatActiveSql"
@compress-sql="compressActiveSql"
@toggle-sql-keyword-case="toggleSqlKeywordCase"
@save-sql="void openSaveSqlDialog()"
@open-sql="openSqlFile"
@import-result-archive="importResultArchive"
@paste-sql-in-condition="pasteClipboardAsSqlInCondition"
@change-connection="changeActiveConnection"
@change-database="changeActiveDatabase"
@change-catalog="changeActiveCatalog"
@change-schema="changeActiveSchema"
@set-default-database="setActiveDatabaseAsDefault"
@clear-default-database="clearActiveDefaultDatabase"
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
<EditorSettingsPage
v-if="settingsPageTabOpen"
v-show="settingsStore.settingsPageActive"
variant="page"
:open="settingsPageTabOpen"
:initial-tab="settingsInitialTab"
:initial-section="settingsInitialSection"
:navigation-request-id="settingsNavigationRequestId"
:ai-config-draft="settingsAiConfigDraft"
:ai-config-request-id="settingsAiConfigRequestId"
:app-version="appVersion"
:checking-updates="checkingUpdates"
class="flex-1 min-h-0"
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
@check-updates="checkUpdates()"
/>
<KeepAlive :max="4">
<ContentArea
ref="contentAreaRef"
:key="activeTab.id"
<div v-if="activeTab" v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex flex-col flex-1 min-h-0">
<EditorToolbar
v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:active-output-view="activeOutputView"
:format-sql-request="formatSqlRequest"
:compress-sql-request="compressSqlRequest"
:selected-sql="selectedSql"
:cursor-pos="cursorPos"
:explain-mode="explainMode"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
@update:active-output-view="activeOutputView = $event"
@fix-with-ai="fixWithAi"
@send-selection-to-ai="sendSelectionToAi"
@execute="tryExecute($event)"
@execute-in-new-result-tab="tryExecuteInNewResultTab($event)"
:sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase"
:database-required-signal="databaseRequiredTabId === activeTab.id ? databaseRequiredSignal : 0"
:auto-commit="activeTab.autoCommit ?? true"
:txn-session-id="activeTab?.txnSessionId"
:txn-auto-rolled-back="activeTab?.txnAutoRolledBack"
:oracle-txn-possibly-dirty="activeTab?.oracleTxnPossiblyDirty"
:is-oracle-manual-transaction="isOracleManualTransaction"
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
@update:auto-commit="
(v: boolean) => {
if (activeTab) queryStore.setAutoCommit(activeTab.id, v);
}
"
@commit="activeTab && queryStore.commitTransaction(activeTab.id)"
@rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)"
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
@execute-pointer-down="captureActiveEditorExecutionSnapshot()"
@execute="requestActiveEditorExecute($event)"
@multi-execute="requestMultiDbExecute()"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"
@editor-selection-change="(v: string) => (selectedSql = v)"
@editor-cursor-change="(p: number) => (cursorPos = p)"
@editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)"
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
@format-error="toast(t('toolbar.formatSqlFailed'))"
@format-sql="formatActiveSql"
@compress-sql="compressActiveSql"
@toggle-sql-keyword-case="toggleSqlKeywordCase"
@save-sql="void openSaveSqlDialog()"
@reload="(sql, searchText, whereInput, orderBy, limit, offset, intent) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="onPaginate"
@sort="onSort"
@execute-sql="onExecuteSql"
@click-table="onClickTable"
@view-table-data="onViewTableData"
@edit-table-structure="onEditTableStructure"
@view-table-ddl="onViewTableDdl"
@open-object-source="onOpenObjectSource"
@open-object-table="
(target) =>
activeTab &&
openObjectBrowserTableTarget({
connectionId: activeTab.connectionId,
database: activeTab.database,
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,
{
@open-sql="openSqlFile"
@import-result-archive="importResultArchive"
@paste-sql-in-condition="pasteClipboardAsSqlInCondition"
@change-connection="changeActiveConnection"
@change-database="changeActiveDatabase"
@change-catalog="changeActiveCatalog"
@change-schema="changeActiveSchema"
@set-default-database="setActiveDatabaseAsDefault"
@clear-default-database="clearActiveDefaultDatabase"
/>
<KeepAlive :max="4">
<ContentArea
ref="contentAreaRef"
:key="activeTab.id"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:active-output-view="activeOutputView"
:format-sql-request="formatSqlRequest"
:compress-sql-request="compressSqlRequest"
:selected-sql="selectedSql"
:cursor-pos="cursorPos"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
@update:active-output-view="activeOutputView = $event"
@fix-with-ai="fixWithAi"
@send-selection-to-ai="sendSelectionToAi"
@execute="tryExecute($event)"
@execute-in-new-result-tab="tryExecuteInNewResultTab($event)"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"
@editor-selection-change="(v: string) => (selectedSql = v)"
@editor-cursor-change="(p: number) => (cursorPos = p)"
@editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)"
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
@format-error="toast(t('toolbar.formatSqlFailed'))"
@save-sql="void openSaveSqlDialog()"
@reload="(sql, searchText, whereInput, orderBy, limit, offset, intent) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="onPaginate"
@sort="onSort"
@execute-sql="onExecuteSql"
@click-table="onClickTable"
@view-table-data="onViewTableData"
@edit-table-structure="onEditTableStructure"
@view-table-ddl="onViewTableDdl"
@open-object-source="onOpenObjectSource"
@open-object-table="
(target) =>
activeTab &&
openObjectBrowserTableTarget({
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"
/>
</KeepAlive>
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"
/>
</KeepAlive>
</div>
<WelcomeScreen
v-else-if="!driverStoreActive && !settingsStore.settingsPageActive"
:connection-stats="connectionStats"
:recent-connections="recentConnections"
:saved-sql-history-items="savedSqlHistoryItems"
:app-version="appVersion"
:has-connections="connectionStore.connections.length > 0"
@open-connection-query="openConnectionQuery"
@open-saved-sql="openSavedSqlFromWelcome"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@show-history="openRightSidebarPanel('history')"
@import-config="dialogs.onImportClick"
@open-github="openGitHub"
@open-mcp-guide="openMcpGuide"
/>
</div>
<WelcomeScreen
v-else-if="!driverStoreActive && !settingsStore.settingsPageActive"
:connection-stats="connectionStats"
:recent-connections="recentConnections"
:saved-sql-history-items="savedSqlHistoryItems"
:app-version="appVersion"
:has-connections="connectionStore.connections.length > 0"
@open-connection-query="openConnectionQuery"
@open-saved-sql="openSavedSqlFromWelcome"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@show-history="openRightSidebarPanel('history')"
@import-config="dialogs.onImportClick"
@open-github="openGitHub"
@open-mcp-guide="openMcpGuide"
/>
</div>
</div>
@@ -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<AppCloseUnsavedTabsMode>(settingsStore.e
const editSavedSqlOpenTargetMode = ref<SavedSqlOpenTargetMode>(settingsStore.editorSettings.savedSqlOpenTargetMode);
const editAppLayout = ref(settingsStore.editorSettings.appLayout);
const editTabLayout = ref(settingsStore.editorSettings.tabLayout);
const editTabPlacement = ref<TabPlacement>(settingsStore.editorSettings.tabPlacement);
const editTabGroupMode = ref<TabGroupMode>(settingsStore.editorSettings.tabGroupMode);
const editTabSortMode = ref<TabSortMode>(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(() => {
</div>
</div>
<div class="settings-appearance-group">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label>{{ t("settings.tabPlacement") }}</Label>
<Select :model-value="editTabPlacement" @update:model-value="setTabPlacement($event as TabPlacement)">
<SelectTrigger class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top">{{ t("settings.tabPlacementTop") }}</SelectItem>
<SelectItem value="bottom">{{ t("settings.tabPlacementBottom") }}</SelectItem>
<SelectItem value="left">{{ t("settings.tabPlacementLeft") }}</SelectItem>
<SelectItem value="right">{{ t("settings.tabPlacementRight") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.tabGroup") }}</Label>
<Select :model-value="editTabGroupMode" @update:model-value="setTabGroupMode($event as TabGroupMode)">
<SelectTrigger class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ t("settings.tabGroupNone") }}</SelectItem>
<SelectItem value="database-type">{{ t("settings.tabGroupDatabaseType") }}</SelectItem>
<SelectItem value="connection">{{ t("settings.tabGroupConnection") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.tabSort") }}</Label>
<Select :model-value="editTabSortMode" @update:model-value="setTabSortMode($event as TabSortMode)">
<SelectTrigger class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">{{ t("settings.tabSortManual") }}</SelectItem>
<SelectItem value="created-asc">{{ t("settings.tabSortCreated") }}</SelectItem>
<SelectItem value="title-asc">{{ t("settings.tabSortTitle") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-1 text-xs text-muted-foreground">
<p>{{ t("settings.tabPlacementDescription") }}</p>
<p>{{ t("settings.tabOrganizationDescription") }}</p>
</div>
</div>
<div v-if="!isWeb" class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-tray-icon">{{ t("settings.showTrayIcon") }}</Label>
+328 -190
View File
@@ -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<string | null>(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<CSSProperties>(() => ({
@@ -656,8 +732,8 @@ const fixedTabScrollbarThumbStyle = computed<CSSProperties>(() => ({
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
</script>
<template>
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden" :class="tabBarClass">
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex min-w-0 shrink-0 overflow-hidden" :class="tabBarClass">
<div v-if="isVerticalLayout" class="relative shrink-0 border-b p-2">
<Search class="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="tabSearchQuery" type="search" :placeholder="t('tabs.searchOpenTabs')" class="h-8 w-full pl-7 text-sm" />
</div>
<div class="flex w-full min-w-0 shrink-0 overflow-hidden" :class="regularTabRowClass">
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
<div v-if="showRegularTabScrollbar" class="app-tab-scrollbar" :class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }" @pointerdown="startScrollbarDrag">
@@ -705,89 +785,96 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
</div>
<div
ref="tabsContainerRef"
class="app-tab-scroll flex w-full min-w-0 flex-1 items-center overflow-x-auto"
:class="[isClassicLayout ? 'h-full' : 'h-full gap-1.5 py-1.5', isWrapLayout ? 'wrap-mode' : '', isWrapLayout && isClassicLayout ? 'classic-wrap' : '']"
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',
isWrapLayout ? 'wrap-mode' : '',
isWrapLayout && isClassicLayout ? 'classic-wrap' : '',
]"
:style="tabsContainerStyle"
@scroll="updateScrollButtons"
@wheel="onTabsWheel"
@scroll="!isVerticalLayout && updateScrollButtons()"
@wheel="!isVerticalLayout && onTabsWheel($event)"
>
<CustomContextMenu v-for="tab in regularTabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
<div :class="isClassicLayout ? '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
? [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-full border-r border-border/80 font-medium dark:border-border/45',
tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90',
]
: [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-7 rounded-md border', tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
"
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
@click="handleTabClick(tab)"
@dblclick="handleTabDoubleClick(tab, $event)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@pointerdown="handleTabMouseDown($event, tab.id)"
@mouseenter="handleTabDragTarget($event, tab)"
@mousemove="handleTabDragTarget($event, tab)"
@mouseleave="tabDrag.clearTarget(tab.id)"
>
<TabExecutionStatus :tab="tab">
<span class="shrink-0" :class="tabIconClass(tab)">
<AlertTriangle v-if="tab.externalSqlFileMissing" class="h-3.5 w-3.5" />
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'consul-overview'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<Database v-else-if="tab.mode === 'databases'" 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" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
<template v-for="(tab, index) in filteredRegularTabs" :key="tab.id">
<div v-if="isGroupStart(filteredRegularTabs, index)" class="tab-group-header">{{ tabGroupLabel(tab) }}</div>
<CustomContextMenu :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
<div :class="isClassicLayout ? '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
? [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-full border-r border-border/80 font-medium dark:border-border/45',
tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90',
]
: [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-7 rounded-md border', tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
"
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
@click="handleTabClick(tab)"
@dblclick="handleTabDoubleClick(tab, $event)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@pointerdown="handleTabMouseDown($event, tab.id)"
@mouseenter="handleTabDragTarget($event, tab)"
@mousemove="handleTabDragTarget($event, tab)"
@mouseleave="tabDrag.clearTarget(tab.id)"
>
<TabExecutionStatus :tab="tab">
<span class="shrink-0" :class="tabIconClass(tab)">
<AlertTriangle v-if="tab.externalSqlFileMissing" class="h-3.5 w-3.5" />
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'consul-overview'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<Database v-else-if="tab.mode === 'databases'" 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" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
</TabExecutionStatus>
<input
v-if="editingTabId === tab.id"
v-model="editingTitle"
:data-tab-title-input="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(tab)"
@keydown.escape.prevent="cancelRenameTab"
@blur="commitRenameTab(tab)"
/>
<span v-else 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>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
</TabExecutionStatus>
<input
v-if="editingTabId === tab.id"
v-model="editingTitle"
:data-tab-title-input="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(tab)"
@keydown.escape.prevent="cancelRenameTab"
@blur="commitRenameTab(tab)"
/>
<span v-else 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>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<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, t)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<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, t)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
</template>
<!-- Settings Page Tab -->
<CustomContextMenu v-if="settingsPageOpen" :items="getSpecialRegularTabMenuItems('settings')" v-slot="{ onContextMenu }">
@@ -903,99 +990,110 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
</div>
</div>
<div v-if="hasFixedTabs" class="flex w-full min-w-0 shrink-0 overflow-hidden border-t" :class="isClassicLayout ? 'h-8 items-stretch border-border/80 bg-background/45 dark:border-border/45 dark:bg-background/20' : 'h-9 items-center border-border/70 bg-muted/45 px-2 dark:bg-muted/25'">
<div
v-if="hasVisibleFixedTabs"
class="flex w-full min-w-0 shrink-0 overflow-hidden border-t"
:class="isVerticalLayout ? 'max-h-[40%] flex-col border-border/70 bg-muted/45 py-1 dark:bg-muted/25' : isClassicLayout ? 'h-8 items-stretch border-border/80 bg-background/45 dark:border-border/45 dark:bg-background/20' : 'h-9 items-center border-border/70 bg-muted/45 px-2 dark:bg-muted/25'"
>
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
<div v-if="showFixedTabScrollbar" class="app-tab-scrollbar app-tab-scrollbar--bottom" :class="{ 'app-tab-scrollbar--dragging': isFixedScrollbarDragging }" @pointerdown="startFixedScrollbarDrag">
<div class="app-tab-scrollbar__thumb" :style="fixedTabScrollbarThumbStyle" />
</div>
<div
ref="fixedTabsContainerRef"
class="app-tab-scroll flex w-full min-w-0 flex-1 items-center overflow-x-auto"
:class="[isClassicLayout ? 'h-full' : 'h-full gap-1.5 py-1', isWrapLayout ? 'wrap-mode' : '', isWrapLayout && isClassicLayout ? 'classic-wrap' : '']"
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',
isWrapLayout ? 'wrap-mode' : '',
isWrapLayout && isClassicLayout ? 'classic-wrap' : '',
]"
:style="tabsContainerStyle"
@scroll="updateFixedScrollButtons"
@wheel="onFixedTabsWheel"
@scroll="!isVerticalLayout && updateFixedScrollButtons()"
@wheel="!isVerticalLayout && onFixedTabsWheel($event)"
>
<CustomContextMenu v-for="tab in fixedTabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
<div :class="isClassicLayout ? '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
? [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-full border-r border-border/80 font-medium dark:border-border/45',
tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90',
]
: [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-7 rounded-md border', tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
"
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
@click="handleTabClick(tab)"
@dblclick="handleTabDoubleClick(tab, $event)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@pointerdown="handleTabMouseDown($event, tab.id)"
@mouseenter="handleTabDragTarget($event, tab)"
@mousemove="handleTabDragTarget($event, tab)"
@mouseleave="tabDrag.clearTarget(tab.id)"
>
<TabExecutionStatus :tab="tab">
<span class="shrink-0" :class="tabIconClass(tab)">
<AlertTriangle v-if="tab.externalSqlFileMissing" class="h-3.5 w-3.5" />
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'consul-overview'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<Database v-else-if="tab.mode === 'databases'" 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" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
<template v-for="(tab, index) in filteredFixedTabs" :key="tab.id">
<div v-if="isGroupStart(filteredFixedTabs, index)" class="tab-group-header">{{ tabGroupLabel(tab) }}</div>
<CustomContextMenu :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
<div :class="isClassicLayout ? '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
? [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-full border-r border-border/80 font-medium dark:border-border/45',
tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90',
]
: [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-7 rounded-md border', tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
"
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
@click="handleTabClick(tab)"
@dblclick="handleTabDoubleClick(tab, $event)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@pointerdown="handleTabMouseDown($event, tab.id)"
@mouseenter="handleTabDragTarget($event, tab)"
@mousemove="handleTabDragTarget($event, tab)"
@mouseleave="tabDrag.clearTarget(tab.id)"
>
<TabExecutionStatus :tab="tab">
<span class="shrink-0" :class="tabIconClass(tab)">
<AlertTriangle v-if="tab.externalSqlFileMissing" class="h-3.5 w-3.5" />
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'consul-overview'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<Database v-else-if="tab.mode === 'databases'" 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" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
</TabExecutionStatus>
<input
v-if="editingTabId === tab.id"
v-model="editingTitle"
:data-tab-title-input="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(tab)"
@keydown.escape.prevent="cancelRenameTab"
@blur="commitRenameTab(tab)"
/>
<span v-else class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden text-foreground">
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
</TabExecutionStatus>
<input
v-if="editingTabId === tab.id"
v-model="editingTitle"
:data-tab-title-input="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(tab)"
@keydown.escape.prevent="cancelRenameTab"
@blur="commitRenameTab(tab)"
/>
<span v-else class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden text-foreground">
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<button class="rounded p-0.5 text-primary hover:bg-muted-foreground/20 shrink-0" :aria-label="t('contextMenu.unfixTab')" :title="t('contextMenu.unfixTab')" @click.stop="queryStore.togglePinnedTab(tab.id)">
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
</button>
<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, t)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<button class="rounded p-0.5 text-primary hover:bg-muted-foreground/20 shrink-0" :aria-label="t('contextMenu.unfixTab')" :title="t('contextMenu.unfixTab')" @click.stop="queryStore.togglePinnedTab(tab.id)">
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
</button>
<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, t)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
</template>
<div :class="fixedTabTailDragRegionClass" data-tauri-drag-region />
</div>
</div>
@@ -1119,6 +1217,46 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
flex-wrap: wrap;
}
.vertical-tab-layout {
flex: 0 0 15rem;
}
.vertical-tab-layout .app-tab-strip {
height: auto;
min-height: 0;
width: 100%;
}
.vertical-tab-layout .app-tab-pill {
width: 100%;
min-width: 0 !important;
min-height: 2rem;
height: 2rem !important;
border-right: 0;
}
.vertical-tab-layout .app-tab-scroll > * {
width: 100%;
flex: none;
}
.vertical-tab-layout .tab-group-header {
width: 100%;
padding: 0.45rem 0.6rem 0.2rem;
color: var(--muted-foreground);
font-size: 0.7rem;
font-weight: 600;
line-height: 1rem;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-tab-scroll:not(.wrap-mode) .tab-group-header {
align-self: stretch;
}
/* 父级 strip 容器在包含 wrap-mode 时也需要解除高度约束和裁剪 */
.app-tab-strip:has(.wrap-mode) {
height: auto !important;
@@ -98,7 +98,7 @@ describe("AppTabBar right-side close action", () => {
});
it("waits for query tab confirmation before closing special surfaces", () => {
expect(tabBarSource).toMatch(/queryStore\.closeRightTabs\(tab\.id, \(\) => \{[\s\S]*closeSpecialRegularSurfaces\(\);/);
expect(tabBarSource).toMatch(/queryStore\.closeTabsByIds\(tabsToClose, finalActiveTabId, \(\) => \{[\s\S]*closeSpecialRegularSurfaces\(\);/);
expect(tabBarSource).toContain("if (shouldActivateTarget) activateTab(tab.id)");
});
@@ -142,7 +142,8 @@ describe("AppTabBar overflow search", () => {
it("filters every open tab by its display and source titles", () => {
expect(tabBarSource).toContain('const tabSearchQuery = ref("");');
expect(tabBarSource).toContain("const filteredOpenTabs = computed(() => {");
expect(tabBarSource).toContain("return queryStore.tabs.filter((tab) => tabTitleText(tab).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query));");
expect(tabBarSource).toContain("return displayedTabs.value.filter((tab) => tabMatchesSearch(tab, query));");
expect(tabBarSource).toContain("return tabTitleText(tab).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query) || connectionName.toLocaleLowerCase().includes(query);");
});
it("provides the same focused search control and empty state in both overflow menus", () => {
+9 -6
View File
@@ -35,6 +35,7 @@ let pending: {
sourceEl: HTMLElement | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
let dragAxis: () => "horizontal" | "vertical" = () => "horizontal";
let ghostEl: HTMLElement | null = null;
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
@@ -82,8 +83,8 @@ function onPointerMove(event: PointerEvent) {
if (!pending && !state.active) return;
if (pending && !state.active) {
const dx = event.clientX - pending.x;
if (Math.abs(dx) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
const delta = dragAxis() === "vertical" ? event.clientY - pending.y : event.clientX - pending.x;
if (Math.abs(delta) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
state.active = true;
state.draggedId = pending.id;
state.startX = pending.x;
@@ -131,9 +132,10 @@ function ensureListeners() {
listenersAttached = true;
}
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean) {
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean, axis: () => "horizontal" | "vertical" = () => "horizontal") {
ensureListeners();
onDropCallback = onDrop;
dragAxis = axis;
function startDrag(event: PointerEvent, tabId: string) {
if (event.button !== 0) return;
@@ -163,9 +165,10 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const x = event.clientX - rect.left;
state.dropPosition = x < rect.width / 2 ? "before" : "after";
const vertical = axis() === "vertical";
const offset = vertical ? event.clientY - rect.top : event.clientX - rect.left;
const size = vertical ? rect.height : rect.width;
state.dropPosition = offset < size / 2 ? "before" : "after";
}
function clearTarget(tabId: string) {
+15
View File
@@ -6355,6 +6355,21 @@ export default {
tabLayoutScrollDescription: "Tabs scroll horizontally in one row when overflowing.",
tabLayoutWrap: "Multi-row wrap",
tabLayoutWrapDescription: "Tabs wrap to multiple rows to show all at once.",
tabPlacement: "Tab bar position",
tabPlacementTop: "Top",
tabPlacementBottom: "Bottom",
tabPlacementLeft: "Left side",
tabPlacementRight: "Right side",
tabPlacementDescription: "Side tab bars scroll vertically; multi-row wrapping applies only above or below content.",
tabGroup: "Group by",
tabGroupNone: "No grouping",
tabGroupDatabaseType: "Group by database type",
tabGroupConnection: "Group by connection",
tabSort: "Sort by",
tabSortManual: "Manual order",
tabSortCreated: "Creation time",
tabSortTitle: "Tab name",
tabOrganizationDescription: "Grouping and sorting affect only the displayed order. Dragging is available in manual order.",
iconTheme: "Icon and logo style",
iconThemeDefault: "Default",
iconThemeDefaultDescription: "Use the color DBX app icon.",
+15
View File
@@ -6031,6 +6031,21 @@ export default withEnglishFallback({
tabLayoutScrollDescription: "Las pestañas se desplazan horizontalmente al desbordarse.",
tabLayoutWrap: "Ajuste multilínea",
tabLayoutWrapDescription: "Las pestañas se ajustan en varias filas para mostrarlas todas.",
tabPlacement: "Posición de la barra de pestañas",
tabPlacementTop: "Arriba",
tabPlacementBottom: "Abajo",
tabPlacementLeft: "Lado izquierdo",
tabPlacementRight: "Lado derecho",
tabPlacementDescription: "Las barras laterales se desplazan verticalmente; el ajuste multilínea solo se aplica arriba o abajo.",
tabGroup: "Agrupar por",
tabGroupNone: "Sin agrupación",
tabGroupDatabaseType: "Agrupar por tipo de base de datos",
tabGroupConnection: "Agrupar por conexión",
tabSort: "Ordenar por",
tabSortManual: "Orden manual",
tabSortCreated: "Hora de creación",
tabSortTitle: "Nombre de pestaña",
tabOrganizationDescription: "La agrupación y la ordenación solo afectan al orden visible. Arrastre disponible en orden manual.",
iconTheme: "Estilo de icono y logo",
iconThemeDefault: "Predeterminado",
iconThemeDefaultDescription: "Usar el icono de color de DBX.",
+15
View File
@@ -6031,6 +6031,21 @@ export default withEnglishFallback({
tabLayoutScrollDescription: "Le schede scorrono orizzontalmente quando traboccano.",
tabLayoutWrap: "Righe multiple",
tabLayoutWrapDescription: "Le schede vanno a capo su più righe per mostrarle tutte.",
tabPlacement: "Posizione barra schede",
tabPlacementTop: "In alto",
tabPlacementBottom: "In basso",
tabPlacementLeft: "Lato sinistro",
tabPlacementRight: "Lato destro",
tabPlacementDescription: "Le barre laterali scorrono verticalmente; le righe multiple valgono solo sopra o sotto il contenuto.",
tabGroup: "Raggruppa per",
tabGroupNone: "Nessun raggruppamento",
tabGroupDatabaseType: "Raggruppa per tipo di database",
tabGroupConnection: "Raggruppa per connessione",
tabSort: "Ordina per",
tabSortManual: "Ordine manuale",
tabSortCreated: "Data di creazione",
tabSortTitle: "Nome scheda",
tabOrganizationDescription: "Raggruppamento e ordinamento influenzano solo l'ordine visibile. Il trascinamento è disponibile nell'ordine manuale.",
iconTheme: "Stile icona e logo",
iconThemeDefault: "Predefinito",
iconThemeDefaultDescription: "Utilizza l'icona colorata dell'app DBX.",
+15
View File
@@ -6069,6 +6069,21 @@ export default withEnglishFallback({
tabLayoutScrollDescription: "タブが1行に収まらないとき水平スクロールします。",
tabLayoutWrap: "複数行折り返し",
tabLayoutWrapDescription: "タブが1行に収まらないとき折り返してすべて表示します。",
tabPlacement: "タブバーの位置",
tabPlacementTop: "上",
tabPlacementBottom: "下",
tabPlacementLeft: "左側",
tabPlacementRight: "右側",
tabPlacementDescription: "サイドのタブバーは縦にスクロールします。複数行表示はコンテンツの上下でのみ利用できます。",
tabGroup: "グループ化",
tabGroupNone: "グループ化しない",
tabGroupDatabaseType: "データベース種別でグループ化",
tabGroupConnection: "接続ごとにグループ化",
tabSort: "並べ替え",
tabSortManual: "手動順",
tabSortCreated: "作成日時",
tabSortTitle: "タブ名",
tabOrganizationDescription: "グループ化と並べ替えは表示順にのみ影響します。ドラッグは手動順で利用できます。",
iconTheme: "アイコンとロゴのスタイル",
iconThemeDefault: "デフォルト",
iconThemeDefaultDescription: "カラーDBXアプリアイコンを使用。",
+15
View File
@@ -5757,6 +5757,21 @@ export default withEnglishFallback({
tabLayoutScrollDescription: "탭이 넘칠 때 한 행에서 가로로 스크롤됩니다.",
tabLayoutWrap: "다중 행 줄바꿈",
tabLayoutWrapDescription: "탭이 여러 행으로 줄바꿈되어 모두 한 번에 표시됩니다.",
tabPlacement: "탭 표시줄 위치",
tabPlacementTop: "위쪽",
tabPlacementBottom: "아래쪽",
tabPlacementLeft: "왼쪽",
tabPlacementRight: "오른쪽",
tabPlacementDescription: "사이드 탭 표시줄은 세로로 스크롤됩니다. 여러 줄 표시는 콘텐츠 위 또는 아래에서만 적용됩니다.",
tabGroup: "그룹 기준",
tabGroupNone: "그룹화 안 함",
tabGroupDatabaseType: "데이터베이스 유형별 그룹화",
tabGroupConnection: "연결별 그룹화",
tabSort: "정렬 기준",
tabSortManual: "수동 순서",
tabSortCreated: "생성 시간",
tabSortTitle: "탭 이름",
tabOrganizationDescription: "그룹화와 정렬은 표시 순서에만 영향을 줍니다. 끌기는 수동 순서에서 사용할 수 있습니다.",
iconTheme: "아이콘 및 로고 스타일",
iconThemeDefault: "기본",
iconThemeDefaultDescription: "컬러 DBX 앱 아이콘을 사용합니다.",
+15
View File
@@ -6033,6 +6033,21 @@ export default withEnglishFallback({
tabLayoutScrollDescription: "As abas rolam horizontalmente quando excedem o espaço.",
tabLayoutWrap: "Quebra em múltiplas linhas",
tabLayoutWrapDescription: "As abas quebram em múltiplas linhas para exibir todas.",
tabPlacement: "Posição da barra de abas",
tabPlacementTop: "Superior",
tabPlacementBottom: "Inferior",
tabPlacementLeft: "Lado esquerdo",
tabPlacementRight: "Lado direito",
tabPlacementDescription: "As barras laterais rolam verticalmente; a quebra em múltiplas linhas só se aplica acima ou abaixo do conteúdo.",
tabGroup: "Agrupar por",
tabGroupNone: "Sem agrupamento",
tabGroupDatabaseType: "Agrupar por tipo de banco de dados",
tabGroupConnection: "Agrupar por conexão",
tabSort: "Ordenar por",
tabSortManual: "Ordem manual",
tabSortCreated: "Hora de criação",
tabSortTitle: "Nome da aba",
tabOrganizationDescription: "Agrupamento e ordenação afetam apenas a ordem exibida. Arrastar fica disponível na ordem manual.",
iconTheme: "Estilo do ícone e do logo",
iconThemeDefault: "Padrão",
iconThemeDefaultDescription: "Usar o ícone colorido do app DBX.",
+15
View File
@@ -6338,6 +6338,21 @@ export default withEnglishFallback({
tabLayoutScrollDescription: "标签页超出一行时水平滚动显示。",
tabLayoutWrap: "多行平铺",
tabLayoutWrapDescription: "标签页超出一行时自动换行,平铺显示所有标签。",
tabPlacement: "标签栏位置",
tabPlacementTop: "上方",
tabPlacementBottom: "下方",
tabPlacementLeft: "左侧",
tabPlacementRight: "右侧",
tabPlacementDescription: "侧边标签栏使用纵向滚动;多行平铺仅适用于上方或下方。",
tabGroup: "分组方式",
tabGroupNone: "不分组",
tabGroupDatabaseType: "按数据库类型分组",
tabGroupConnection: "按连接分组",
tabSort: "排序方式",
tabSortManual: "手动排序",
tabSortCreated: "按创建时间",
tabSortTitle: "按标签页名称",
tabOrganizationDescription: "分组和排序只影响显示顺序;仅手动排序时支持拖拽。",
iconTheme: "图标与 Logo 样式",
iconThemeDefault: "默认",
iconThemeDefaultDescription: "使用彩色 DBX 应用图标。",
+15
View File
@@ -5353,6 +5353,21 @@ export default withEnglishFallback({
tabLayoutScrollDescription: "標籤頁超出一行時水平滾動顯示。",
tabLayoutWrap: "多行平鋪",
tabLayoutWrapDescription: "標籤頁超出一行時自動換行,平鋪顯示所有標籤。",
tabPlacement: "標籤欄位置",
tabPlacementTop: "上方",
tabPlacementBottom: "下方",
tabPlacementLeft: "左側",
tabPlacementRight: "右側",
tabPlacementDescription: "側邊標籤欄使用垂直捲動;多行平鋪僅適用於上方或下方。",
tabGroup: "分組方式",
tabGroupNone: "不分組",
tabGroupDatabaseType: "依資料庫類型分組",
tabGroupConnection: "依連線分組",
tabSort: "排序方式",
tabSortManual: "手動排序",
tabSortCreated: "依建立時間",
tabSortTitle: "依標籤頁名稱",
tabOrganizationDescription: "分組和排序只影響顯示順序;只有手動排序時可拖曳。",
iconTheme: "圖示與 Logo 樣式",
iconThemeDefault: "預設",
iconThemeDefaultDescription: "使用彩色 DBX 應用程式圖示。",
@@ -17,6 +17,7 @@ export interface SavedQueryResultRun {
export interface SavedOpenTab {
id: string;
createdAt?: number;
title: string;
customTitle?: boolean;
connectionId: string;
@@ -91,6 +92,7 @@ function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: str
export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
return tabs.map((tab) => ({
id: tab.id,
...(typeof tab.createdAt === "number" ? { createdAt: tab.createdAt } : {}),
title: tab.title,
...(tab.customTitle ? { customTitle: true } : {}),
connectionId: tab.connectionId,
@@ -34,6 +34,9 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"savedSqlOpenTargetMode",
"appLayout",
"tabLayout",
"tabPlacement",
"tabGroupMode",
"tabSortMode",
"showColumnCommentsInHeader",
"showColumnTypesInHeader",
"dataGridShowTransposeFieldMetadata",
+14 -16
View File
@@ -733,6 +733,16 @@ export const useQueryStore = defineStore("query", () => {
const t = getI18nT();
const settingsStore = useSettingsStore();
const tabs = ref<QueryTab[]>([]);
// New tabs are created by many feature-specific entry points. Tracking id
// changes here gives every one a stable timestamp without duplicating the
// field at each call site; restored legacy tabs retain their saved order.
function ensureTabCreationTimes() {
const migrationBase = Date.now() - tabs.value.length;
tabs.value.forEach((tab, index) => {
if (typeof tab.createdAt !== "number" || !Number.isFinite(tab.createdAt)) tab.createdAt = migrationBase + index;
});
}
watch(() => tabs.value.map((tab) => tab.id), ensureTabCreationTimes, { immediate: true, flush: "sync" });
// Default auto-commit for a fresh query tab follows the user's global
// "默认事务提交方式" setting (Settings > Editor), not a hard-coded constant.
const defaultAutoCommitForDbTypeWithSetting = (dbType?: string) => defaultAutoCommitForDbType(dbType, settingsStore.editorSettings.defaultTransactionMode);
@@ -2858,20 +2868,8 @@ export const useQueryStore = defineStore("query", () => {
);
}
function closeLeftTabs(id: string, onComplete?: () => void) {
const target = tabs.value.find((tab) => tab.id === id);
if (!target) return;
const groupedTabs = tabs.value.filter((tab) => Boolean(tab.pinned) === Boolean(target.pinned));
const targetIndex = groupedTabs.findIndex((tab) => tab.id === id);
const ids = groupedTabs.slice(0, targetIndex).map((tab) => tab.id);
if (ids.length === 0) {
onComplete?.();
return;
}
const finalActiveTabId = activeTabId.value && !ids.includes(activeTabId.value) ? activeTabId.value : id;
beginBatchClose(ids, finalActiveTabId, onComplete);
function closeTabsByIds(ids: Iterable<string>, finalActiveTabId?: string | null, onComplete?: () => void) {
beginBatchClose([...ids], finalActiveTabId, onComplete);
}
function closeRightTabs(id: string, onComplete?: () => void) {
@@ -2887,7 +2885,7 @@ export const useQueryStore = defineStore("query", () => {
}
const finalActiveTabId = activeTabId.value && !ids.includes(activeTabId.value) ? activeTabId.value : id;
beginBatchClose(ids, finalActiveTabId, onComplete);
closeTabsByIds(ids, finalActiveTabId, onComplete);
}
function finalActiveTabAfterClosing(ids: string[]) {
@@ -6852,7 +6850,7 @@ export const useQueryStore = defineStore("query", () => {
discardTabChanges,
requestAppCloseConfirmation,
closeOtherTabs,
closeLeftTabs,
closeTabsByIds,
closeRightTabs,
closeOtherRegularTabs,
closeRegularTabs,
+30
View File
@@ -476,6 +476,12 @@ const CELL_DETAIL_PANEL_LAYOUTS = ["bottom", "right"] as const;
export type CellDetailPanelLayout = (typeof CELL_DETAIL_PANEL_LAYOUTS)[number];
const TAB_LAYOUT_MODES = ["scroll", "wrap"] as const;
export type TabLayoutMode = (typeof TAB_LAYOUT_MODES)[number];
const TAB_PLACEMENTS = ["top", "bottom", "left", "right"] as const;
export type TabPlacement = (typeof TAB_PLACEMENTS)[number];
const TAB_GROUP_MODES = ["none", "database-type", "connection"] as const;
export type TabGroupMode = (typeof TAB_GROUP_MODES)[number];
const TAB_SORT_MODES = ["manual", "created-asc", "title-asc"] as const;
export type TabSortMode = (typeof TAB_SORT_MODES)[number];
const DATA_GRID_RENDER_MODES = ["dom", "canvas"] as const;
export type DataGridRenderMode = (typeof DATA_GRID_RENDER_MODES)[number];
const DATA_GRID_SEARCH_MODES = ["filter", "highlight"] as const;
@@ -607,6 +613,9 @@ export interface EditorSettings {
savedSqlOpenTargetMode: SavedSqlOpenTargetMode;
compactTabTitle: boolean;
tabLayout: TabLayoutMode;
tabPlacement: TabPlacement;
tabGroupMode: TabGroupMode;
tabSortMode: TabSortMode;
appLayout: "separated" | "classic";
pageSize: number;
tableOpenPageSize: number;
@@ -829,6 +838,9 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
savedSqlOpenTargetMode: "saved",
compactTabTitle: false,
tabLayout: "scroll",
tabPlacement: "top",
tabGroupMode: "none",
tabSortMode: "manual",
appLayout: "classic",
pageSize: 100,
tableOpenPageSize: 100,
@@ -970,6 +982,18 @@ function normalizeTabLayout(value: unknown): TabLayoutMode {
return TAB_LAYOUT_MODES.includes(value as TabLayoutMode) ? (value as TabLayoutMode) : DEFAULT_EDITOR_SETTINGS.tabLayout;
}
function normalizeTabPlacement(value: unknown): TabPlacement {
return TAB_PLACEMENTS.includes(value as TabPlacement) ? (value as TabPlacement) : DEFAULT_EDITOR_SETTINGS.tabPlacement;
}
function normalizeTabGroupMode(value: unknown): TabGroupMode {
return TAB_GROUP_MODES.includes(value as TabGroupMode) ? (value as TabGroupMode) : DEFAULT_EDITOR_SETTINGS.tabGroupMode;
}
function normalizeTabSortMode(value: unknown): TabSortMode {
return TAB_SORT_MODES.includes(value as TabSortMode) ? (value as TabSortMode) : DEFAULT_EDITOR_SETTINGS.tabSortMode;
}
function normalizeCellDetailPanelLayout(value: unknown): CellDetailPanelLayout {
return CELL_DETAIL_PANEL_LAYOUTS.includes(value as CellDetailPanelLayout) ? (value as CellDetailPanelLayout) : DEFAULT_EDITOR_SETTINGS.cellDetailPanelLayout;
}
@@ -1240,6 +1264,9 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
savedSqlOpenTargetMode: settings.savedSqlOpenTargetMode === "current" ? "current" : DEFAULT_EDITOR_SETTINGS.savedSqlOpenTargetMode,
compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle,
tabLayout: normalizeTabLayout(settings.tabLayout),
tabPlacement: normalizeTabPlacement(settings.tabPlacement),
tabGroupMode: normalizeTabGroupMode(settings.tabGroupMode),
tabSortMode: normalizeTabSortMode(settings.tabSortMode),
appLayout: settings.appLayout ?? DEFAULT_EDITOR_SETTINGS.appLayout,
pageSize: normalizeResultPageSize(settings.pageSize),
tableOpenPageSize: normalizeResultPageSize(settings.tableOpenPageSize, DEFAULT_EDITOR_SETTINGS.tableOpenPageSize),
@@ -1864,6 +1891,9 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.savedSqlOpenTargetMode !== undefined) editorSettings.value.savedSqlOpenTargetMode = partial.savedSqlOpenTargetMode === "current" ? "current" : "saved";
if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle;
if (partial.tabLayout !== undefined) editorSettings.value.tabLayout = normalizeTabLayout(partial.tabLayout);
if (partial.tabPlacement !== undefined) editorSettings.value.tabPlacement = normalizeTabPlacement(partial.tabPlacement);
if (partial.tabGroupMode !== undefined) editorSettings.value.tabGroupMode = normalizeTabGroupMode(partial.tabGroupMode);
if (partial.tabSortMode !== undefined) editorSettings.value.tabSortMode = normalizeTabSortMode(partial.tabSortMode);
if (partial.appLayout !== undefined) editorSettings.value.appLayout = partial.appLayout;
if (partial.pageSize !== undefined) editorSettings.value.pageSize = normalizeResultPageSize(partial.pageSize);
if (partial.tableOpenPageSize !== undefined) editorSettings.value.tableOpenPageSize = normalizeResultPageSize(partial.tableOpenPageSize, DEFAULT_EDITOR_SETTINGS.tableOpenPageSize);
+2
View File
@@ -1115,6 +1115,8 @@ export interface QueryPageJumpProgress {
export interface QueryTab {
id: string;
/** Stable creation time used when tabs are displayed in creation order. */
createdAt?: number;
title: string;
customTitle?: boolean;
/** Force the editor to word-wrap regardless of the global setting, e.g. for auto-generated single-line templates. */