feat(editor): add a SQL word-wrap toolbar toggle

This commit is contained in:
ccq authored and GitHub committed 2026-09-22 11:01:22 +08:00
1 parent cc966142ac
commit d1bd67b828
2 files changed
+121 -1

No files matched your search

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye } from "@lucide/vue";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText } from "@lucide/vue";
import { supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
@@ -254,6 +254,11 @@ const explainAnalyzeTooltip = computed(() => {
const canSaveSql = computed(() => canSaveSqlTab(props.activeTab));
const keywordCaseIsLower = computed(() => props.sqlKeywordCase === "lower");
const keywordCaseToggleTooltip = computed(() => (keywordCaseIsLower.value ? t("toolbar.keywordCaseUpper") : t("toolbar.keywordCaseLower")));
const wordWrapEnabled = computed(() => props.activeTab.forceWordWrap === true || settingsStore.editorSettings.wordWrap);
function toggleWordWrap() {
if (props.activeTab.forceWordWrap) return;
settingsStore.updateEditorSettings({ wordWrap: !wordWrapEnabled.value });
}
const sqlSemanticDiagnosticsEnabled = computed(() => settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
const sqlSemanticDiagnosticsToggleTooltip = computed(() => (sqlSemanticDiagnosticsEnabled.value ? t("toolbar.sqlSemanticDiagnosticsToggleOn") : t("toolbar.sqlSemanticDiagnosticsToggleOff")));
const supportsSqlSemanticDiagnosticsToggle = computed(() => {
@@ -359,6 +364,7 @@ const showFormatButton = computed(() => canFormatSql.value && toolbarTier.value
const showExplainAnalyzeToggle = computed(() => toolbarTier.value < 3);
const showCompressButton = computed(() => toolbarTier.value < 1);
const showKeywordCaseButton = computed(() => toolbarTier.value < 1);
const showWordWrapButton = computed(() => toolbarTier.value < 1);
const showSemanticDiagnosticsButton = computed(() => supportsSqlSemanticDiagnosticsToggle.value && toolbarTier.value < 1);
const showPreviewButton = computed(() => previewButtonVisible.value && toolbarTier.value < 1);
const showInsertValueHintsButton = computed(() => supportsInsertValueHintsToggle.value && toolbarTier.value < 1);
@@ -509,6 +515,23 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ keywordCaseToggleTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="showWordWrapButton">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:class="wordWrapEnabled ? 'bg-sky-500/10 text-sky-700 hover:bg-sky-500/20 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200' : 'text-muted-foreground/50 hover:bg-muted hover:text-muted-foreground'"
:disabled="activeTab.forceWordWrap === true"
:aria-label="t('settings.wordWrap')"
:aria-pressed="wordWrapEnabled"
@click="toggleWordWrap"
>
<WrapText class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("settings.wordWrap") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="showSemanticDiagnosticsButton">
<TooltipTrigger as-child>
<Button
@@ -613,6 +636,10 @@ async function changeCatalog(selectedCatalog: string) {
</span>
{{ keywordCaseToggleTooltip }}
</DropdownMenuItem>
<DropdownMenuCheckboxItem :model-value="wordWrapEnabled" :disabled="activeTab.forceWordWrap === true" @select.prevent="toggleWordWrap">
<WrapText class="h-3.5 w-3.5" />
{{ t("settings.wordWrap") }}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem v-if="supportsSqlSemanticDiagnosticsToggle" :model-value="sqlSemanticDiagnosticsEnabled" @select.prevent="toggleSqlSemanticDiagnostics">
<SpellCheck2 class="h-3.5 w-3.5" />
{{ t("settings.sqlSemanticDiagnosticsEnabled") }}
@@ -63,6 +63,7 @@ vi.mock("@/components/common/ProductionContextBadge.vue", () => ({
import EditorToolbar from "../EditorToolbar.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { resolveExecutableSql, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import { EditorView } from "@codemirror/view";
@@ -307,6 +308,98 @@ describe("EditorToolbar mount contract", () => {
host.remove();
});
it("toggles word wrap from the SQL toolbar and updates the mounted editor", async () => {
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const connection = {
id: "conn-1",
name: "conn",
db_type: "mysql",
color: "",
} as never;
connectionStore.connections = [connection];
settingsStore.editorSettings.wordWrap = false;
const sql = "SELECT this_is_a_very_long_column_name FROM this_is_a_very_long_table_name";
const forceWordWrap = ref(false);
const host = createHost();
const app = createApp({
setup() {
return () =>
h("div", [
h(QueryEditor, {
modelValue: sql,
tabId: "tab-1",
connectionId: "conn-1",
database: "db",
databaseType: "mysql",
dialect: "mysql",
forceWordWrap: forceWordWrap.value,
autoFocus: false,
}),
h(EditorToolbar, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql,
mode: "query",
isExecuting: false,
isCancelling: false,
isExplaining: false,
forceWordWrap: forceWordWrap.value,
},
activeConnection: connection,
executableSql: sql,
explainMode: "explain",
blockDangerousRedisCommands: false,
sqlKeywordCase: "preserve",
databaseRequiredSignal: 0,
autoCommit: true,
txnSessionId: undefined,
txnAutoRolledBack: false,
txnPossiblyDirty: false,
stickyProvenReadOnlyState: false,
}),
]);
},
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 });
const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!;
expect(view.contentDOM.classList.contains("cm-lineWrapping")).toBe(false);
const wordWrapButton = host.querySelector<HTMLButtonElement>('button[aria-label="settings.wordWrap"]');
expect(wordWrapButton).not.toBeNull();
expect(wordWrapButton?.getAttribute("aria-pressed")).toBe("false");
wordWrapButton?.click();
await vi.waitFor(() => expect(settingsStore.editorSettings.wordWrap).toBe(true));
await nextTick();
await vi.waitFor(() => expect(view.contentDOM.classList.contains("cm-lineWrapping")).toBe(true));
expect(wordWrapButton?.getAttribute("aria-pressed")).toBe("true");
wordWrapButton?.click();
await vi.waitFor(() => expect(settingsStore.editorSettings.wordWrap).toBe(false));
await vi.waitFor(() => expect(view.contentDOM.classList.contains("cm-lineWrapping")).toBe(false));
expect(wordWrapButton?.getAttribute("aria-pressed")).toBe("false");
forceWordWrap.value = true;
await nextTick();
await vi.waitFor(() => expect(view.contentDOM.classList.contains("cm-lineWrapping")).toBe(true));
expect(wordWrapButton?.disabled).toBe(true);
expect(wordWrapButton?.getAttribute("aria-pressed")).toBe("true");
wordWrapButton?.click();
expect(settingsStore.editorSettings.wordWrap).toBe(false);
app.unmount();
host.remove();
});
it("does not expose SQL formatting for Redis connections", async () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [