feat(editor): pin live preview atop settings panel

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-31 23:46:38 +08:00
1 parent 662037295c
commit ba11e4be8d
2 files changed
+111 -37

No files matched your search

@@ -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<Record<string, string>>(() => ({
[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(() => {
</button>
</div>
</div>
<div v-if="activeSettingsTab === 'editor' && !settingsSearchVisible" class="settings-editor-live-preview shrink-0 px-1 pr-2" data-editor-live-preview>
<div class="space-y-2 py-2">
<Label>{{ t("settings.preview") }}</Label>
<div class="settings-editor-live-preview-surface rounded-md border max-w-full" :class="editTheme === 'vscode-light' || editTheme === 'duotone-light' || editTheme === 'xcode' ? 'border-border' : 'border-border/50'">
<div ref="previewRef" :style="{ minWidth: '100%', ...previewFontStyle }" />
</div>
<p v-if="editSqlSemanticDiagnosticsEnabled" class="text-xs text-muted-foreground">
{{ t("settings.previewSyntaxErrorHint") }}
</p>
</div>
</div>
<div v-if="settingsSearchVisible" id="settings-search-results" role="listbox" :aria-label="t('settings.searchSettingsResults')" class="min-h-0 flex-1 overflow-y-auto px-1 pr-2">
<div class="mx-auto w-full max-w-3xl pb-4">
<button type="button" class="mb-4 inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-sm text-muted-foreground transition-colors hover:text-foreground" @click="exitSettingsSearch">
@@ -4437,17 +4484,6 @@ onUnmounted(() => {
</div>
</div>
<!-- Live Preview -->
<div class="space-y-2">
<Label>{{ t("settings.preview") }}</Label>
<div class="rounded-md border overflow-auto max-w-full" :class="editTheme === 'vscode-light' || editTheme === 'duotone-light' || editTheme === 'xcode' ? 'border-border' : 'border-border/50'">
<div ref="previewRef" style="min-width: 100%" />
</div>
<p v-if="editSqlSemanticDiagnosticsEnabled" class="text-xs text-muted-foreground">
{{ t("settings.previewSyntaxErrorHint") }}
</p>
</div>
<Separator />
<div class="grid gap-4 md:grid-cols-2" data-editor-execution-settings>
@@ -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;
@@ -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(`<section v-if="activeSettingsTab === 'editor'"`);
const formatterSectionStart = dialogSource.indexOf(`activeSettingsTab === 'formatter'`, editorSectionStart);
const editorSection = dialogSource.slice(editorSectionStart, formatterSectionStart);
const editorPreviewStart = dialogSource.indexOf("data-editor-live-preview");
const settingsSearchResultsStart = dialogSource.indexOf('v-if="settingsSearchVisible"', editorPreviewStart);
const editorPreview = dialogSource.slice(editorPreviewStart, settingsSearchResultsStart);
const dataSectionStart = dialogSource.indexOf(`activeSettingsTab === 'data'`);
const shortcutsSectionStart = dialogSource.indexOf(`activeSettingsTab === 'shortcuts'`, dataSectionStart);
const dataSection = dialogSource.slice(dataSectionStart, shortcutsSectionStart);
describe("EditorSettingsDialog live preview placement", () => {
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<Record<string, string>>");
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) => {