From ba11e4be8db893d7ad0e839e4344f471708b5933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Mon, 31 Aug 2026 23:46:38 +0800 Subject: [PATCH] feat(editor): pin live preview atop settings panel --- .../editor/EditorSettingsDialog.vue | 90 ++++++++++++++----- ...itorSettingsDialog.previewPosition.spec.ts | 58 ++++++++---- 2 files changed, 111 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 43783b5eef..e3770e1f42 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -89,7 +89,7 @@ import { SIDEBAR_FONT_SIZE_MIN, SIDEBAR_FONT_SIZE_MAX, } from "@/stores/settingsStore"; -import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; +import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; import { isAiConnectionTestConfigCurrent } from "@/lib/ai/aiConnectionTest"; import { MAX_AGENT_TURNS_DEFAULT, MAX_AGENT_TURNS_MAX, MAX_AGENT_TURNS_MIN, maxAgentTurnsOutOfRange, normalizeMaxAgentTurns } from "@/lib/ai/maxAgentTurns"; @@ -3895,6 +3895,11 @@ const previewSettings = computed<{ showCurrentStatementFrame: editShowCurrentStatementFrame.value, })); +const previewFontStyle = computed>(() => ({ + [EDITOR_FONT_SIZE_CSS_VAR]: `${editFontSize.value}px`, + [EDITOR_FONT_FAMILY_CSS_VAR]: editFontFamily.value, +})); + const previewSqlNormal = `SELECT u.id, u.name FROM users u ORDER BY u.id LIMIT 5; @@ -4042,20 +4047,44 @@ watch( async ([ss]) => { if (!previewView.value || !fontThemeComp || !themeComp || !previewLineNumbersComp || !editorViewModule) return; + const currentPreviewView = previewView.value; + const currentFontThemeComp = fontThemeComp; + const currentThemeComp = themeComp; + const currentPreviewLineNumbersComp = previewLineNumbersComp; + const currentPreviewRunGutterComp = previewRunGutterComp; + const currentPreviewStatementFrameComp = currentStatementFrameComp; + const currentEditorViewModule = editorViewModule; + const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors, ss.appPalette); - previewView.value.dispatch({ + if ( + previewView.value !== currentPreviewView || + fontThemeComp !== currentFontThemeComp || + themeComp !== currentThemeComp || + previewLineNumbersComp !== currentPreviewLineNumbersComp || + previewRunGutterComp !== currentPreviewRunGutterComp || + currentStatementFrameComp !== currentPreviewStatementFrameComp || + editorViewModule !== currentEditorViewModule + ) { + return; + } + + currentPreviewView.dispatch({ effects: [ - themeComp.reconfigure(themeExt), - fontThemeComp.reconfigure(editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)), - previewLineNumbersComp.reconfigure(buildPreviewLineNumbersExtension(ss.showLineNumbers)), - ...(previewRunGutterComp ? [previewRunGutterComp.reconfigure(buildPreviewRunGutterExtension())] : []), - ...(currentStatementFrameComp ? [currentStatementFrameComp.reconfigure(buildPreviewCurrentStatementFrameExtension(editorViewModule, ss.showCurrentStatementFrame))] : []), + currentThemeComp.reconfigure(themeExt), + currentFontThemeComp.reconfigure(editorFontTheme(currentEditorViewModule.EditorView, ss.fontSize, ss.fontFamily)), + currentPreviewLineNumbersComp.reconfigure(buildPreviewLineNumbersExtension(ss.showLineNumbers)), + ...(currentPreviewRunGutterComp ? [currentPreviewRunGutterComp.reconfigure(buildPreviewRunGutterExtension())] : []), + ...(currentPreviewStatementFrameComp ? [currentPreviewStatementFrameComp.reconfigure(buildPreviewCurrentStatementFrameExtension(currentEditorViewModule, ss.showCurrentStatementFrame))] : []), ], }); }, { deep: true }, ); +watch([editFontFamily, editFontSize], () => { + previewView.value?.requestMeasure(); +}); + watch(editSqlSemanticDiagnosticsEnabled, () => { updatePreviewSqlDiagnostics(); }); @@ -4063,8 +4092,7 @@ watch(editSqlSemanticDiagnosticsEnabled, () => { let previewInitialized = false; function cleanupPreviewEditor() { - if (!previewView.value) return; - previewView.value.destroy(); + previewView.value?.destroy(); previewView.value = null; previewInitialized = false; fontThemeComp = null; @@ -4094,9 +4122,14 @@ watch(activeSettingsTab, (tab) => { }); watch(previewRef, async (el) => { - if (!el || previewInitialized) return; + if (!el) { + cleanupPreviewEditor(); + return; + } + if (previewInitialized) return; previewInitialized = true; if (previewView.value) return; + const previewHost = el; const [{ EditorView, Decoration, ViewPlugin, gutter, GutterMarker, layer, RectangleMarker, lineNumbers, highlightActiveLineGutter }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([ import("@codemirror/view"), @@ -4105,6 +4138,8 @@ watch(previewRef, async (el) => { import("codemirror"), ]); + if (!previewInitialized || previewRef.value !== previewHost) return; + editorViewModule = { Decoration, EditorView, @@ -4128,6 +4163,7 @@ watch(previewRef, async (el) => { const ss = previewSettings.value; const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors, ss.appPalette); + if (!previewInitialized || previewRef.value !== previewHost) return; const previewBasicSetup = (basicSetup as readonly import("@codemirror/state").Extension[]).slice(2); const diagnosticTheme = EditorView.baseTheme({ ".cm-settings-preview-sql-error": { @@ -4223,7 +4259,7 @@ watch(previewRef, async (el) => { ], }); - previewView.value = new EditorView({ state, parent: previewRef.value }); + previewView.value = new EditorView({ state, parent: previewHost }); }); watch( @@ -4279,6 +4315,17 @@ onUnmounted(() => { +
+
+ +
+
+
+

+ {{ t("settings.previewSyntaxErrorHint") }} +

+
+
- -
- -
-
-
-

- {{ t("settings.previewSyntaxErrorHint") }} -

-
-
@@ -8451,6 +8487,16 @@ onUnmounted(() => { margin-top: 0.625rem; } +.settings-editor-live-preview { + border-bottom: 1px solid color-mix(in oklab, var(--border) 70%, transparent); + background: var(--background); +} + +.settings-editor-live-preview-surface { + max-height: min(16rem, 34vh); + overflow: auto; +} + .settings-mcp-config-tabs { scrollbar-width: thin; scrollbar-color: color-mix(in oklab, var(--muted-foreground) 22%, transparent) transparent; diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewPosition.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewPosition.spec.ts index f208ccf30f..f3e9a33934 100644 --- a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewPosition.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewPosition.spec.ts @@ -2,21 +2,25 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8"); -const editorSectionStart = dialogSource.indexOf(`activeSettingsTab === 'editor'`); +const editorSectionStart = dialogSource.indexOf(`
{ - it("renders preview-linked controls after appearance settings and before the live preview", () => { + it("keeps the live preview below search and outside the editor settings scroller", () => { const fontFamily = editorSection.indexOf('t("settings.fontFamily")'); const theme = editorSection.indexOf('t("settings.theme")'); const fontSize = editorSection.indexOf('t("settings.fontSize")'); const previewControls = editorSection.indexOf("data-editor-preview-controls"); - const preview = editorSection.indexOf('ref="previewRef"'); const executeMode = editorSection.indexOf("executeModeLabel"); + const searchInput = dialogSource.indexOf("settingsSearchInputContainerRef"); + const scroller = dialogSource.indexOf('ref="settingsContentScrollRef"'); expect(editorSectionStart).toBeGreaterThanOrEqual(0); expect(formatterSectionStart).toBeGreaterThan(editorSectionStart); @@ -24,31 +28,44 @@ describe("EditorSettingsDialog live preview placement", () => { expect(theme).toBeGreaterThan(fontFamily); expect(fontSize).toBeGreaterThan(theme); expect(previewControls).toBeGreaterThan(fontSize); - expect(preview).toBeGreaterThan(previewControls); - expect(preview).toBeLessThan(executeMode); - expect(editorSection.match(/ref="previewRef"/g)).toHaveLength(1); + expect(executeMode).toBeGreaterThan(previewControls); + expect(editorPreviewStart).toBeGreaterThan(searchInput); + expect(editorPreviewStart).toBeLessThan(scroller); + expect(dialogSource.indexOf("activeSettingsTab === 'editor' && !settingsSearchVisible", searchInput)).toBeLessThan(editorPreviewStart); + expect(editorPreview).toContain('ref="previewRef"'); + expect(editorPreview).toContain("settings-editor-live-preview-surface"); + expect(dialogSource.match(/ref="previewRef"/g)).toHaveLength(1); + expect(editorSection).not.toContain('ref="previewRef"'); for (const id of ["editor-show-statement-run-buttons", "editor-show-line-numbers", "editor-show-current-statement-frame", "editor-sql-semantic-diagnostics"]) { expect(editorSection.indexOf(`id="${id}"`)).toBeGreaterThan(previewControls); - expect(editorSection.indexOf(`id="${id}"`)).toBeLessThan(preview); + expect(editorSection.indexOf(`id="${id}"`)).toBeLessThan(executeMode); expect(editorSection.match(new RegExp(`id="${id}"`, "g"))).toHaveLength(1); } }); - it("keeps the preview lifecycle tied to the same template ref", () => { + it("unmounts the preview for search results and safely cancels pending initialization or updates", () => { expect(dialogSource).toContain("watch(previewRef, async (el) => {"); + expect(dialogSource).toContain("if (!el) {"); expect(dialogSource).toContain("cleanupPreviewEditor();"); + expect(dialogSource).toContain("const previewHost = el;"); + expect(dialogSource).toContain("previewRef.value !== previewHost"); + expect(dialogSource).toContain("parent: previewHost"); + expect(dialogSource).toContain("const currentPreviewView = previewView.value;"); + expect(dialogSource).toContain("previewView.value !== currentPreviewView"); + expect(dialogSource).toContain("currentPreviewView.dispatch({"); + expect(dialogSource).toContain(".settings-editor-live-preview-surface {"); + expect(dialogSource).toContain("max-height: min(16rem, 34vh);"); }); it("places execution behavior before SQL completion with high-priority controls first", () => { - const preview = editorSection.indexOf('ref="previewRef"'); const completionSection = editorSection.indexOf("data-editor-sql-completion-settings"); const completionTriggerMode = editorSection.indexOf('t("settings.completionTriggerMode")'); const selectFirstCompletion = editorSection.indexOf('t("settings.selectFirstCompletionOnOpen")'); const executionSection = editorSection.indexOf("data-editor-execution-settings"); const executeMode = editorSection.indexOf("executeModeLabel", executionSection); - expect(executionSection).toBeGreaterThan(preview); + expect(executionSection).toBeGreaterThan(editorSection.indexOf("data-editor-preview-controls")); expect(executeMode).toBeGreaterThan(executionSection); expect(completionSection).toBeGreaterThan(executeMode); expect(completionTriggerMode).toBeGreaterThan(completionSection); @@ -85,7 +102,7 @@ describe("EditorSettingsDialog live preview placement", () => { }); it("describes the execute shortcut below the mode selector in every supported locale", () => { - expect(editorSection.indexOf("executeModeDescription")).toBeGreaterThan(editorSection.indexOf('ref="previewRef"')); + expect(editorSection.indexOf("executeModeDescription")).toBeGreaterThan(editorSection.indexOf("executeModeLabel")); for (const locale of ["zh-CN", "zh-TW", "en", "es", "it", "ja", "ko", "pt-BR"]) { const source = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8"); @@ -169,10 +186,21 @@ describe("EditorSettingsDialog live preview placement", () => { expect(dialogSource).toContain('let previewLineNumbersComp: import("@codemirror/state").Compartment | null = null;'); expect(dialogSource).toContain('const previewBasicSetup = (basicSetup as readonly import("@codemirror/state").Extension[]).slice(2);'); expect(dialogSource).toContain("previewLineNumbersComp.of(buildPreviewLineNumbersExtension(ss.showLineNumbers))"); - expect(dialogSource).toContain("previewLineNumbersComp.reconfigure(buildPreviewLineNumbersExtension(ss.showLineNumbers))"); + expect(dialogSource).toContain("currentPreviewLineNumbersComp.reconfigure(buildPreviewLineNumbersExtension(ss.showLineNumbers))"); expect(dialogSource).toContain("return buildQueryEditorLineNumbersExtension(previewLineNumbersFactory, enabled"); }); + it("uses the unsaved font draft for the preview CSS variables", () => { + expect(dialogSource).toContain("EDITOR_FONT_FAMILY_CSS_VAR"); + expect(dialogSource).toContain("EDITOR_FONT_SIZE_CSS_VAR"); + expect(dialogSource).toContain("const previewFontStyle = computed>"); + expect(dialogSource).toContain("[EDITOR_FONT_FAMILY_CSS_VAR]: editFontFamily.value"); + expect(dialogSource).toContain("[EDITOR_FONT_SIZE_CSS_VAR]: `${editFontSize.value}px`"); + expect(dialogSource).toContain(":style=\"{ minWidth: '100%', ...previewFontStyle }\""); + expect(dialogSource).toContain("watch([editFontFamily, editFontSize], () => {"); + expect(dialogSource).toContain("previewView.value?.requestMeasure();"); + }); + it("creates the preview editor as read-only so demo edits cannot desync the Apply buttons", () => { expect(dialogSource).toContain("EditorState.readOnly.of(true)"); expect(dialogSource).toContain("EditorView.editable.of(false)"); @@ -180,9 +208,9 @@ describe("EditorSettingsDialog live preview placement", () => { }); it("explains the intentional syntax-error demo below the preview when diagnostics are enabled", () => { - const preview = editorSection.indexOf('ref="previewRef"'); - expect(editorSection).toContain('v-if="editSqlSemanticDiagnosticsEnabled"'); - expect(editorSection.indexOf('t("settings.previewSyntaxErrorHint")')).toBeGreaterThan(preview); + const preview = editorPreview.indexOf('ref="previewRef"'); + expect(editorPreview).toContain('v-if="editSqlSemanticDiagnosticsEnabled"'); + expect(editorPreview.indexOf('t("settings.previewSyntaxErrorHint")')).toBeGreaterThan(preview); }); it.each(["en", "zh-CN", "zh-TW", "es", "pt-BR", "it", "ja", "ko"])("locale %s translates settings.previewSyntaxErrorHint", (locale) => {