feat: format sql (#19)

Co-authored-by: SuLe <sule@SuLedeMacBook-Air.local>
This commit is contained in:
SuLea-IT 2026-04-30 15:56:16 +08:00 committed by GitHub
parent ac76aadbed
commit 5ee4f9300f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 196 additions and 1 deletions

View File

@ -28,6 +28,7 @@
"shadcn-vue": "^2.6.2",
"simple-icons": "^16.18.0",
"splitpanes": "^4.0.4",
"sql-formatter": "^15.7.3",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vue": "^3.5.0",

View File

@ -62,6 +62,9 @@ importers:
splitpanes:
specifier: ^4.0.4
version: 4.0.4(vue@3.5.33(typescript@5.6.3))
sql-formatter:
specifier: ^15.7.3
version: 15.7.3
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@ -1078,6 +1081,9 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@ -1214,6 +1220,9 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@ -1345,6 +1354,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@ -1994,6 +2006,9 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
moo@0.5.3:
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -2008,6 +2023,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@ -2220,6 +2239,13 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@ -2259,6 +2285,10 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@ -2379,6 +2409,10 @@ packages:
peerDependencies:
vue: ^3.2.0
sql-formatter@15.7.3:
resolution: {integrity: sha512-5+zl9Nqg5aNjss0tb1G+StpC4dJKbjv3+g8CL/+V+00PfZop+2RKGyi53ScFl0dr+Dkx1LjmUO54Q3N7K3EtMw==}
hasBin: true
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -3611,6 +3645,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
@ -3751,6 +3787,8 @@ snapshots:
commander@14.0.3: {}
commander@2.20.3: {}
concat-map@0.0.1: {}
confbox@0.2.4: {}
@ -3841,6 +3879,8 @@ snapshots:
diff@8.0.4: {}
discontinuous-range@1.0.0: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@ -4467,6 +4507,8 @@ snapshots:
mitt@3.0.1: {}
moo@0.5.3: {}
ms@2.1.3: {}
muggle-string@0.4.1: {}
@ -4475,6 +4517,13 @@ snapshots:
natural-compare@1.4.0: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.3
railroad-diagrams: 1.0.0
randexp: 0.4.6
negotiator@1.0.0: {}
node-fetch-native@1.6.7: {}
@ -4676,6 +4725,13 @@ snapshots:
queue-microtask@1.2.3: {}
railroad-diagrams@1.0.0: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
range-parser@1.2.1: {}
raw-body@3.0.2:
@ -4726,6 +4782,8 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
ret@0.1.15: {}
reusify@1.1.0: {}
rfdc@1.4.1: {}
@ -4923,6 +4981,11 @@ snapshots:
dependencies:
vue: 3.5.33(typescript@5.6.3)
sql-formatter@15.7.3:
dependencies:
argparse: 2.0.1
nearley: 2.20.1
statuses@2.0.2: {}
stdin-discarder@0.3.2: {}

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin } from "lucide-vue-next";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin, AlignLeft } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -34,6 +34,7 @@ import { getCurrentWindow, type Theme } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import * as api from "@/lib/tauri";
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
const { t } = useI18n();
const connectionStore = useConnectionStore();
@ -47,6 +48,7 @@ const showHistory = ref(false);
const dangerSql = ref("");
const pendingDangerSql = ref("");
const selectedSql = ref("");
const formatSqlRequestId = ref(0);
const showDangerDialog = ref(false);
const databaseOptions = ref<Record<string, string[]>>({});
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
@ -84,6 +86,25 @@ const activeConnection = computed(() => {
return tab ? connectionStore.getConfig(tab.connectionId) : undefined;
});
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
switch (activeConnection.value?.db_type) {
case "mysql":
return "mysql";
case "postgres":
return "postgres";
case "sqlite":
return "sqlite";
case "sqlserver":
return "sqlserver";
default:
return "generic";
}
});
const editorDialect = computed<"mysql" | "postgres">(() =>
activeConnection.value?.db_type === "postgres" ? "postgres" : "mysql"
);
const activeTabContext = computed(() => {
const tab = activeTab.value;
const connection = activeConnection.value;
@ -208,6 +229,16 @@ function onEditorSelectionChange(val: string) {
selectedSql.value = val;
}
function formatActiveSql() {
const tab = activeTab.value;
if (!tab || tab.mode !== "query" || !tab.sql.trim()) return;
formatSqlRequestId.value++;
}
function onFormatSqlError() {
toast(t("toolbar.formatSqlFailed"));
}
function newQuery() {
if (!connectionStore.activeConnectionId) return;
const conn = connectionStore.connections.find(
@ -511,6 +542,21 @@ async function setupFileDrop() {
<TooltipContent>{{ t('toolbar.executeShortcut') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="!activeTab || activeTab.mode !== 'query' || activeTab.isExecuting || !activeTab.sql.trim()"
@click="formatActiveSql"
>
<AlignLeft class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('toolbar.formatSql') }}</TooltipContent>
</Tooltip>
<div class="flex-1" />
<Tooltip>
@ -689,8 +735,12 @@ async function setupFileDrop() {
<QueryEditor
class="flex-1"
:model-value="activeTab.sql"
:dialect="editorDialect"
:format-dialect="activeSqlFormatDialect"
:format-request-id="formatSqlRequestId"
@update:model-value="onEditorUpdate"
@selection-change="onEditorSelectionChange"
@format-error="onFormatSqlError"
@execute="tryExecute"
/>
</div>

View File

@ -2,15 +2,19 @@
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
const props = defineProps<{
modelValue: string;
dialect?: "mysql" | "postgres";
formatDialect?: SqlFormatDialect;
formatRequestId?: number;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
"selectionChange": [value: string];
"formatError": [message: string];
execute: [sql: string];
}>();
@ -69,6 +73,31 @@ function executableSqlFromView(currentView: EditorViewType): string {
return resolveExecutableSql(currentView.state.doc.toString(), selectedSqlFromView(currentView));
}
async function formatCurrentSql() {
const currentView = view.value;
if (!currentView) return;
const selection = currentView.state.selection.main;
const formatsSelection = !selection.empty;
const from = formatsSelection ? selection.from : 0;
const to = formatsSelection ? selection.to : currentView.state.doc.length;
const source = currentView.state.sliceDoc(from, to);
if (!source.trim()) return;
try {
const formatted = await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic");
if (formatted === source) return;
currentView.dispatch({
changes: { from, to, insert: formatted },
selection: formatsSelection
? { anchor: from, head: from + formatted.length }
: { anchor: from + formatted.length },
});
} catch (e: any) {
emit("formatError", String(e?.message || e));
}
}
onMounted(async () => {
if (!editorRef.value) return;
@ -170,6 +199,13 @@ watch(
}
);
watch(
() => props.formatRequestId,
(val, oldVal) => {
if (val && val !== oldVal) formatCurrentSql();
}
);
onBeforeUnmount(() => {
view.value?.destroy();
});

View File

@ -7,6 +7,8 @@ export default {
newQuery: "New Query",
execute: "Execute",
executeShortcut: "Execute selection/query (Cmd+Enter)",
formatSql: "Format SQL",
formatSqlFailed: "Failed to format SQL",
},
sidebar: {
connections: "CONNECTIONS",

View File

@ -7,6 +7,8 @@ export default {
newQuery: "新建查询",
execute: "执行",
executeShortcut: "执行选中/全部 (Cmd+Enter)",
formatSql: "格式化 SQL",
formatSqlFailed: "SQL 格式化失败",
},
sidebar: {
connections: "连接",

25
src/lib/sqlFormatter.ts Normal file
View File

@ -0,0 +1,25 @@
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "generic";
function formatterLanguage(dialect: SqlFormatDialect) {
switch (dialect) {
case "mysql":
return "mysql";
case "postgres":
return "postgresql";
case "sqlite":
return "sqlite";
case "sqlserver":
return "transactsql";
default:
return "sql";
}
}
export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "generic"): Promise<string> {
if (!sql.trim()) return sql;
const { format } = await import("sql-formatter");
return format(sql, {
language: formatterLanguage(dialect),
keywordCase: "upper",
});
}

View File

@ -0,0 +1,16 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { formatSqlText } from "../src/lib/sqlFormatter.js";
test("formats SQL with uppercase keywords and readable line breaks", async () => {
const formatted = await formatSqlText("select id, name from users where active = 1 order by name", "postgres");
assert.match(formatted, /^SELECT\b/);
assert.match(formatted, /\nFROM\b/);
assert.match(formatted, /\nWHERE\b/);
assert.match(formatted, /\nORDER BY\b/);
});
test("leaves blank SQL unchanged", async () => {
assert.equal(await formatSqlText(" \n\t", "mysql"), " \n\t");
});