feat(sidebar): add exclusive right panel setting

This commit is contained in:
t8y2 2026-07-21 21:56:24 +08:00
parent bbfbdf7ff5
commit d4c28c5a9a
12 changed files with 197 additions and 32 deletions

View File

@ -14,7 +14,7 @@ import WelcomeScreen from "@/components/layout/WelcomeScreen.vue";
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { enforceRightSidebarPanelExclusivity, RIGHT_SIDEBAR_PANEL_IDS, transitionRightSidebarPanels, useSettingsStore, type RightSidebarPanelId, type RightSidebarPanelState } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
@ -179,6 +179,18 @@ const showHistory = ref(false);
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true");
const rightSidebarPanelRefs: Record<RightSidebarPanelId, typeof showAiPanel> = {
ai: showAiPanel,
history: showHistory,
sqlLibrary: showSqlLibraryPanel,
sqlFile: showSqlFilePanel,
};
const rightSidebarPanelStorageKeys: Partial<Record<RightSidebarPanelId, string>> = {
ai: "dbx-ai-panel-open",
sqlLibrary: "dbx-sql-library-open",
sqlFile: "dbx-sql-file-panel-open",
};
let lastOpenedRightSidebarPanel = RIGHT_SIDEBAR_PANEL_IDS.find((panelId) => rightSidebarPanelRefs[panelId].value);
const sidebarOpen = ref(safeLocalStorageGet("dbx-sidebar-open") !== "false");
const aiPanelReady = ref(false);
const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, sqlFilePanelWidth, startSidebarResize, startAiPanelResize, startHistoryResize, startSqlLibraryResize, startSqlFilePanelResize } = usePanelResize();
@ -549,19 +561,50 @@ watch(
{ immediate: true },
);
function toggleAiPanel() {
showAiPanel.value = !showAiPanel.value;
safeLocalStorageSet("dbx-ai-panel-open", String(showAiPanel.value));
watch(
[() => settingsStore.isEditorSettingsLoaded, () => settingsStore.editorSettings.toolbarItems.exclusiveRightSidebarPanels],
([loaded, exclusive]) => {
if (!loaded || !exclusive) return;
// Compatibility: old persisted panel flags may contain multiple open panels.
applyRightSidebarPanelState(enforceRightSidebarPanelExclusivity(currentRightSidebarPanelState(), lastOpenedRightSidebarPanel));
},
{ immediate: true },
);
function currentRightSidebarPanelState(): RightSidebarPanelState {
return Object.fromEntries(RIGHT_SIDEBAR_PANEL_IDS.map((panelId) => [panelId, rightSidebarPanelRefs[panelId].value])) as RightSidebarPanelState;
}
function toggleSqlLibrary() {
showSqlLibraryPanel.value = !showSqlLibraryPanel.value;
safeLocalStorageSet("dbx-sql-library-open", String(showSqlLibraryPanel.value));
function applyRightSidebarPanelState(next: RightSidebarPanelState) {
for (const panelId of RIGHT_SIDEBAR_PANEL_IDS) {
const panelRef = rightSidebarPanelRefs[panelId];
if (panelRef.value === next[panelId]) continue;
panelRef.value = next[panelId];
const storageKey = rightSidebarPanelStorageKeys[panelId];
if (storageKey) safeLocalStorageSet(storageKey, String(next[panelId]));
}
}
function toggleSqlFilePanel() {
showSqlFilePanel.value = !showSqlFilePanel.value;
safeLocalStorageSet("dbx-sql-file-panel-open", String(showSqlFilePanel.value));
function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) {
const exclusive = settingsStore.isEditorSettingsLoaded && settingsStore.editorSettings.toolbarItems.exclusiveRightSidebarPanels;
applyRightSidebarPanelState(transitionRightSidebarPanels(currentRightSidebarPanelState(), panelId, open, exclusive));
if (open) {
lastOpenedRightSidebarPanel = panelId;
} else if (lastOpenedRightSidebarPanel === panelId) {
lastOpenedRightSidebarPanel = RIGHT_SIDEBAR_PANEL_IDS.find((candidate) => rightSidebarPanelRefs[candidate].value);
}
}
function toggleRightSidebarPanel(panelId: RightSidebarPanelId) {
setRightSidebarPanelOpen(panelId, !rightSidebarPanelRefs[panelId].value);
}
function openRightSidebarPanel(panelId: RightSidebarPanelId) {
setRightSidebarPanelOpen(panelId, true);
}
function closeRightSidebarPanel(panelId: RightSidebarPanelId) {
setRightSidebarPanelOpen(panelId, false);
}
function invokeWhenAiReady(invoke: (handle: AiAssistantHandle) => void) {
@ -580,26 +623,17 @@ function invokeWhenAiReady(invoke: (handle: AiAssistantHandle) => void) {
}
function fixWithAi(errorMessage: string) {
if (!showAiPanel.value) {
showAiPanel.value = true;
safeLocalStorageSet("dbx-ai-panel-open", "true");
}
openRightSidebarPanel("ai");
invokeWhenAiReady((handle) => handle.triggerAction("fix", errorMessage));
}
function sendSelectionToAi(sql: string) {
if (!showAiPanel.value) {
showAiPanel.value = true;
safeLocalStorageSet("dbx-ai-panel-open", "true");
}
openRightSidebarPanel("ai");
invokeWhenAiReady((handle) => handle.setPrompt(sql));
}
function openAiPanel() {
if (!showAiPanel.value) {
showAiPanel.value = true;
safeLocalStorageSet("dbx-ai-panel-open", "true");
}
openRightSidebarPanel("ai");
}
function analyzeHistoryWithAi(entry: HistoryEntry) {
@ -2035,10 +2069,10 @@ onUnmounted(() => {
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@set-theme-mode="setThemeMode"
@toggle-ai="toggleAiPanel"
@toggle-history="showHistory = !showHistory"
@toggle-sql-library="toggleSqlLibrary"
@toggle-sql-file-panel="toggleSqlFilePanel"
@toggle-ai="toggleRightSidebarPanel('ai')"
@toggle-history="toggleRightSidebarPanel('history')"
@toggle-sql-library="toggleRightSidebarPanel('sqlLibrary')"
@toggle-sql-file-panel="toggleRightSidebarPanel('sqlFile')"
@open-github="openGitHub"
@open-settings="openSettings()"
@open-driver-store="openDriverStorePage"
@ -2210,7 +2244,7 @@ onUnmounted(() => {
@open-saved-sql="openSavedSqlFromWelcome"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@show-history="showHistory = true"
@show-history="openRightSidebarPanel('history')"
@import-config="dialogs.onImportClick"
@open-github="openGitHub"
@open-mcp-guide="openMcpGuide"
@ -2231,27 +2265,27 @@ onUnmounted(() => {
@temp-run-sql="onAiTempRunSql"
@request-auto-execute-sql="onAiRequestAutoExecuteSql"
@open-explain-plan="onAiOpenExplainPlan"
@close="toggleAiPanel"
@close="closeRightSidebarPanel('ai')"
/>
</div>
</div>
<div v-if="showHistory" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
<QueryHistory @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="showHistory = false" />
<QueryHistory @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
</div>
<div v-if="showSqlLibraryPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlLibraryResize" />
<div class="h-full min-h-0 overflow-hidden">
<SqlLibraryPanel @close="toggleSqlLibrary" />
<SqlLibraryPanel @close="closeRightSidebarPanel('sqlLibrary')" />
</div>
</div>
<div v-if="showSqlFilePanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlFilePanelWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlFilePanelResize" />
<div class="h-full min-h-0 overflow-hidden">
<SqlFilePanel @close="toggleSqlFilePanel" />
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
</div>
</div>
</div>

View File

@ -3678,6 +3678,13 @@ onUnmounted(cleanupPreviewEditor);
<p>{{ t("settings.toolbarHiddenHint") }}</p>
</HelpTooltip>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border border-border/60 p-3">
<div class="space-y-1">
<Label for="exclusive-right-sidebar-panels" class="text-sm cursor-pointer">{{ t("settings.exclusiveRightSidebarPanels") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.exclusiveRightSidebarPanelsDescription") }}</p>
</div>
<Switch id="exclusive-right-sidebar-panels" v-model="editToolbarItems.exclusiveRightSidebarPanels" />
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div
v-for="item in [

View File

@ -3529,6 +3529,8 @@ export default {
fontSize: "Font Size",
toolbarTitle: "Toolbar",
toolbarHiddenHint: 'Some hidden or overflowed buttons appear in the "More" dropdown.',
exclusiveRightSidebarPanels: "Show one right sidebar panel at a time",
exclusiveRightSidebarPanelsDescription: "Opening a right sidebar panel closes the others. Disable this to allow multiple panels.",
uiScale: "UI Scale",
uiScaleDescription: "Scale the entire desktop UI for high-DPI displays. Changes apply immediately and are restored on next launch.",
theme: "Theme",

View File

@ -3307,6 +3307,8 @@ export default withEnglishFallback({
fontSize: "Tamaño de fuente",
toolbarTitle: "Barra de herramientas",
toolbarHiddenHint: "Algunos botones ocultos o desbordados aparecerán en el menú «Más».",
exclusiveRightSidebarPanels: "Mostrar un panel lateral derecho a la vez",
exclusiveRightSidebarPanelsDescription: "Al abrir un panel lateral derecho se cierran los demás. Desactívalo para permitir varios paneles.",
uiScale: "Escala de interfaz",
uiScaleDescription: "Escala toda la interfaz de escritorio para pantallas de alta densidad. Los cambios se aplican al instante y se restauran al volver a abrir.",
theme: "Tema",

View File

@ -3305,6 +3305,8 @@ export default withEnglishFallback({
fontSize: "Dimensione Carattere",
toolbarTitle: "Barra degli strumenti",
toolbarHiddenHint: "Alcuni pulsanti nascosti o in eccesso appariranno nel menu «Altro».",
exclusiveRightSidebarPanels: "Mostra un pannello laterale destro alla volta",
exclusiveRightSidebarPanelsDescription: "L'apertura di un pannello laterale destro chiude gli altri. Disattiva per consentire più pannelli.",
uiScale: "Scala UI",
uiScaleDescription: "Scala l'intera interfaccia utente desktop per display ad alta densità (High-DPI). Le modifiche si applicano immediatamente e vengono ripristinate al prossimo avvio.",
theme: "Tema",

View File

@ -3306,6 +3306,8 @@ export default withEnglishFallback({
fontSize: "フォントサイズ",
toolbarTitle: "ツールバー",
toolbarHiddenHint: "非表示または幅が足りない一部のボタンは、「もっと見る」ドロップダウン内に表示されます。",
exclusiveRightSidebarPanels: "右サイドバーのパネルを一度に1つだけ表示",
exclusiveRightSidebarPanelsDescription: "右サイドバーのパネルを開くと他のパネルを閉じます。複数表示するには無効にします。",
uiScale: "UIスケール",
uiScaleDescription: "高DPIディスプレイ向けにデスクトップUI全体をスケーリングします。変更は即座に適用され、次回起動時に復元されます。",
theme: "テーマ",

View File

@ -3307,6 +3307,8 @@ export default withEnglishFallback({
fontSize: "Tamanho da fonte",
toolbarTitle: "Barra de ferramentas",
toolbarHiddenHint: "Alguns botões ocultos ou excedentes aparecerão no menu «Mais».",
exclusiveRightSidebarPanels: "Mostrar um painel lateral direito por vez",
exclusiveRightSidebarPanelsDescription: "Abrir um painel lateral direito fecha os demais. Desative para permitir vários painéis.",
uiScale: "Escala da UI",
uiScaleDescription: "Dimensione toda a UI do desktop para telas de alta resolução (high-DPI). As alterações são aplicadas imediatamente e restauradas na próxima inicialização.",
theme: "Tema",

View File

@ -3519,6 +3519,8 @@ export default withEnglishFallback({
fontSize: "字号",
toolbarTitle: "工具栏",
toolbarHiddenHint: '部分关闭或空间不足的按钮会自动收进"更多"下拉菜单。',
exclusiveRightSidebarPanels: "右侧边栏一次仅显示一个面板",
exclusiveRightSidebarPanelsDescription: "打开一个右侧面板时关闭其他面板。关闭此选项可同时显示多个面板。",
uiScale: "界面缩放",
uiScaleDescription: "按比例缩放整个桌面端界面,适合高清屏;修改后立即生效,并在下次启动时恢复。",
theme: "主题",

View File

@ -3123,6 +3123,8 @@ export default withEnglishFallback({
fontSize: "字級",
toolbarTitle: "工具列",
toolbarHiddenHint: "部分關閉或空間不足的按鈕會自動收進「更多」下拉選單。",
exclusiveRightSidebarPanels: "右側邊欄一次僅顯示一個面板",
exclusiveRightSidebarPanelsDescription: "開啟一個右側面板時關閉其他面板。關閉此選項可同時顯示多個面板。",
uiScale: "介面縮放",
uiScaleDescription: "按比例縮放整個桌面端介面,適合高 DPI 螢幕;修改後立即生效,並在下次啟動時復原。",
theme: "主題",

View File

@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("../../../components/layout/AppToolbar.vue", import.meta.url), "utf8");
function functionSource(name: string, nextName: string): string {
const start = appSource.indexOf(`function ${name}`);
const end = appSource.indexOf(`function ${nextName}`, start + 1);
return appSource.slice(start, end);
}
describe("right sidebar panel entry points", () => {
it("routes toolbar and close actions through the centralized controller", () => {
expect(appSource).toContain("@toggle-ai=\"toggleRightSidebarPanel('ai')\"");
expect(appSource).toContain("@toggle-history=\"toggleRightSidebarPanel('history')\"");
expect(appSource).toContain("@toggle-sql-library=\"toggleRightSidebarPanel('sqlLibrary')\"");
expect(appSource).toContain("@toggle-sql-file-panel=\"toggleRightSidebarPanel('sqlFile')\"");
expect(appSource).toContain("@close=\"closeRightSidebarPanel('history')\"");
expect(appSource).toContain("@close=\"closeRightSidebarPanel('sqlLibrary')\"");
expect(appSource).toContain("@close=\"closeRightSidebarPanel('sqlFile')\"");
});
it("routes welcome, history analysis, selection, and error-fix opens through the same controller", () => {
expect(appSource).toContain("@show-history=\"openRightSidebarPanel('history')\"");
expect(functionSource("fixWithAi", "sendSelectionToAi")).toContain('openRightSidebarPanel("ai")');
expect(functionSource("sendSelectionToAi", "openAiPanel")).toContain('openRightSidebarPanel("ai")');
expect(functionSource("openAiPanel", "analyzeHistoryWithAi")).toContain('openRightSidebarPanel("ai")');
});
it("keeps existing persisted panel keys and synchronizes exclusivity after settings load", () => {
expect(appSource).toContain('ai: "dbx-ai-panel-open"');
expect(appSource).toContain('sqlLibrary: "dbx-sql-library-open"');
expect(appSource).toContain('sqlFile: "dbx-sql-file-panel-open"');
expect(appSource).not.toContain('history: "dbx-');
expect(appSource).toContain("settingsStore.isEditorSettingsLoaded");
expect(appSource).toContain("enforceRightSidebarPanelExclusivity(currentRightSidebarPanelState(), lastOpenedRightSidebarPanel)");
});
it("does not couple toolbar visibility to panel closing", () => {
for (const [setting, event] of [
["sqlLibrary", "toggle-sql-library"],
["sqlFileTree", "toggle-sql-file-panel"],
["history", "toggle-history"],
["ai", "toggle-ai"],
]) {
expect(toolbarSource).toContain(`<Tooltip v-if="toolbarItems.${setting}">`);
expect(toolbarSource).toContain(`@click="emit('${event}')"`);
}
expect(appSource).not.toMatch(/watch\([\s\S]{0,180}toolbarItems\.(ai|history|sqlLibrary|sqlFileTree)[\s\S]{0,180}closeRightSidebarPanel/);
});
});

View File

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy } from "@/stores/settingsStore";
import { enforceRightSidebarPanelExclusivity, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy, transitionRightSidebarPanels, type RightSidebarPanelState } from "@/stores/settingsStore";
import { createPinia, setActivePinia } from "pinia";
import type { AiConfigItem } from "@/types/ai";
@ -124,6 +124,41 @@ describe("normalizeEditorSettings", () => {
expect(settings.toolbarItems.sqlFileTree).toBe(false);
expect(settings.toolbarItems.history).toBe(false);
expect(settings.toolbarItems.sqlLibrary).toBe(true);
expect(settings.toolbarItems.exclusiveRightSidebarPanels).toBe(true);
});
it("preserves disabled right sidebar panel exclusivity", () => {
expect(
normalizeEditorSettings({
toolbarItems: {
exclusiveRightSidebarPanels: false,
} as any,
}).toolbarItems.exclusiveRightSidebarPanels,
).toBe(false);
});
});
describe("right sidebar panel transitions", () => {
const state = (overrides: Partial<RightSidebarPanelState> = {}): RightSidebarPanelState => ({
ai: false,
history: false,
sqlLibrary: false,
sqlFile: false,
...overrides,
});
it("allows multiple panels when exclusivity is disabled", () => {
expect(transitionRightSidebarPanels(state({ ai: true }), "history", true, false)).toEqual(state({ ai: true, history: true }));
});
it("switches panels and allows the active panel to toggle closed", () => {
const switched = transitionRightSidebarPanels(state({ ai: true }), "sqlLibrary", true, true);
expect(switched).toEqual(state({ sqlLibrary: true }));
expect(transitionRightSidebarPanels(switched, "sqlLibrary", false, true)).toEqual(state());
});
it("collapses synchronized multi-panel state to the preferred open panel", () => {
expect(enforceRightSidebarPanelExclusivity(state({ ai: true, history: true, sqlFile: true }), "history")).toEqual(state({ history: true }));
});
});

View File

@ -471,6 +471,7 @@ export interface ToolbarItems {
ai: boolean;
theme: boolean;
github: boolean;
exclusiveRightSidebarPanels: boolean;
}
export const DEFAULT_TOOLBAR_ITEMS: ToolbarItems = {
@ -486,8 +487,28 @@ export const DEFAULT_TOOLBAR_ITEMS: ToolbarItems = {
ai: true,
theme: true,
github: true,
exclusiveRightSidebarPanels: true,
};
export const RIGHT_SIDEBAR_PANEL_IDS = ["ai", "history", "sqlLibrary", "sqlFile"] as const;
export type RightSidebarPanelId = (typeof RIGHT_SIDEBAR_PANEL_IDS)[number];
export type RightSidebarPanelState = Record<RightSidebarPanelId, boolean>;
export function transitionRightSidebarPanels(current: RightSidebarPanelState, panel: RightSidebarPanelId, open: boolean, exclusive: boolean): RightSidebarPanelState {
const next = { ...current };
if (open && exclusive) {
for (const panelId of RIGHT_SIDEBAR_PANEL_IDS) next[panelId] = false;
}
next[panel] = open;
return next;
}
export function enforceRightSidebarPanelExclusivity(current: RightSidebarPanelState, preferred?: RightSidebarPanelId): RightSidebarPanelState {
const panelToKeep = preferred && current[preferred] ? preferred : RIGHT_SIDEBAR_PANEL_IDS.find((panelId) => current[panelId]);
if (!panelToKeep) return { ...current };
return transitionRightSidebarPanels(current, panelToKeep, true, true);
}
export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [
{ value: "app", label: "Follow app theme", dark: false },
{ value: "one-dark", label: "One Dark", dark: true },
@ -752,6 +773,8 @@ function normalizeToolbarItems(items: Partial<ToolbarItems> | undefined): Toolba
ai: items.ai ?? defaults.ai,
theme: items.theme ?? defaults.theme,
github: items.github ?? defaults.github,
// Saved settings from before right-sidebar exclusivity must adopt the new default.
exclusiveRightSidebarPanels: items.exclusiveRightSidebarPanels !== false,
};
}