chore: add oxfmt + husky + lint-staged for code formatting and CI format checks

This commit is contained in:
t8y2 committed 2026-05-05 15:41:34 +08:00
1 parent c295bf5780
commit 3acd9280ac
154 files changed
+5787 -3681

No files matched your search

+195 -61
View File
@@ -2,19 +2,37 @@
import { computed, nextTick, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import {
ArrowUp, ArrowRightLeft, Bot, Check, ChevronRight, Copy, Database, HelpCircle, History,
Loader2, MessageSquarePlus, Replace, Server, Settings, Play, Square, Trash2,
Wand2, Wrench, X, Zap, TestTube,
ArrowUp,
ArrowRightLeft,
Bot,
Check,
ChevronRight,
Copy,
Database,
HelpCircle,
History,
Loader2,
MessageSquarePlus,
Replace,
Server,
Settings,
Play,
Square,
Trash2,
Wand2,
Wrench,
X,
Zap,
TestTube,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -26,8 +44,12 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useQueryStore } from "@/stores/queryStore";
import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
import {
aiTestConnection, aiCancelStream,
saveAiConversation, loadAiConversations, deleteAiConversation, type AiConversation,
aiTestConnection,
aiCancelStream,
saveAiConversation,
loadAiConversations,
deleteAiConversation,
type AiConversation,
} from "@/lib/api";
import type { AiMessage } from "@/lib/api";
import type { ConnectionConfig, QueryTab } from "@/types/database";
@@ -99,7 +121,6 @@ const isWaitingForFirstDelta = computed(() => {
const activePlaceholder = computed(() => t(`ai.placeholders.${activeAction.value}`));
const { databaseOptions: allDbOptions, loadDatabaseOptions } = useDatabaseOptions();
const dbOptions = computed(() => {
@@ -256,16 +277,22 @@ async function send() {
role: m.role,
content: m.content,
}));
await runAiStream({
config: settings.aiConfig,
action: activeAction.value,
instruction: text,
context,
}, history, (delta) => {
appendAssistantDelta(assistantIdx, delta);
}, sessionId, (reasoningDelta) => {
appendAssistantReasoning(assistantIdx, reasoningDelta);
});
await runAiStream(
{
config: settings.aiConfig,
action: activeAction.value,
instruction: text,
context,
},
history,
(delta) => {
appendAssistantDelta(assistantIdx, delta);
},
sessionId,
(reasoningDelta) => {
appendAssistantReasoning(assistantIdx, reasoningDelta);
},
);
} catch (e: any) {
messages.value[assistantIdx].content = `Error: ${e.message || e}`;
} finally {
@@ -298,7 +325,9 @@ const copiedIndex = ref("");
async function copyCode(code: string, key: string) {
await navigator.clipboard.writeText(code);
copiedIndex.value = key;
setTimeout(() => { if (copiedIndex.value === key) copiedIndex.value = ""; }, 2000);
setTimeout(() => {
if (copiedIndex.value === key) copiedIndex.value = "";
}, 2000);
}
function clearMessages() {
@@ -315,7 +344,11 @@ async function persistConversation() {
title: first ? first.content.slice(0, 50) : "Untitled",
connectionName: props.connection.name,
database: props.tab?.database || "",
messages: messages.value.map((m) => ({ role: m.role, content: m.content, ...(m.reasoning ? { reasoning: m.reasoning } : {}) })),
messages: messages.value.map((m) => ({
role: m.role,
content: m.content,
...(m.reasoning ? { reasoning: m.reasoning } : {}),
})),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}).catch(() => {});
@@ -412,7 +445,14 @@ function formatInlineText(text: string): string {
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">
<MessageSquarePlus class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :class="{ 'bg-accent': showConversationList }" @click="loadConversationList" :title="t('history.title')">
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:class="{ 'bg-accent': showConversationList }"
@click="loadConversationList"
:title="t('history.title')"
>
<History class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="clearMessages" :title="t('ai.clear')">
@@ -428,7 +468,7 @@ function formatInlineText(text: string): string {
<div v-if="showConversationList" class="border-b max-h-48 overflow-auto">
<div v-if="!conversations.length" class="p-3 text-xs text-muted-foreground text-center">
{{ t('history.empty') }}
{{ t("history.empty") }}
</div>
<div
v-for="conv in conversations"
@@ -438,19 +478,24 @@ function formatInlineText(text: string): string {
@click="selectConversation(conv)"
>
<span class="flex-1 truncate">{{ conv.title }}</span>
<button class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive" @click.stop="deleteConversation(conv.id)">
<button
class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive"
@click.stop="deleteConversation(conv.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</div>
<div v-if="messages.length === 0" class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground">
<div
v-if="messages.length === 0"
class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground"
>
<Bot class="h-10 w-10 mb-3 opacity-30" />
<p class="text-sm">{{ t('ai.welcome') }}</p>
<p class="text-sm">{{ t("ai.welcome") }}</p>
</div>
<ScrollArea v-else ref="scrollRef" class="min-h-0 flex-1 overflow-hidden">
<div class="flex flex-col gap-3 p-3">
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="flex justify-end">
<div class="max-w-[85%] rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
@@ -470,16 +515,20 @@ function formatInlineText(text: string): string {
:class="{ 'rotate-90': expandedReasoning.has(i) || msg.isThinking }"
/>
<Loader2 v-if="msg.isThinking" class="h-3 w-3 animate-spin" />
<span>{{ t('ai.reasoningProcess') }}</span>
<span>{{ t("ai.reasoningProcess") }}</span>
</button>
<div
class="overflow-hidden transition-all duration-200 ease-in-out"
:style="{
maxHeight: (expandedReasoning.has(i) || msg.isThinking) ? '2000px' : '0px',
opacity: (expandedReasoning.has(i) || msg.isThinking) ? '1' : '0',
maxHeight: expandedReasoning.has(i) || msg.isThinking ? '2000px' : '0px',
opacity: expandedReasoning.has(i) || msg.isThinking ? '1' : '0',
}"
>
<div class="mt-1.5 pl-4 border-l-2 border-muted-foreground/20 text-[11px] text-muted-foreground whitespace-pre-wrap">{{ msg.reasoning }}</div>
<div
class="mt-1.5 pl-4 border-l-2 border-muted-foreground/20 text-[11px] text-muted-foreground whitespace-pre-wrap"
>
{{ msg.reasoning }}
</div>
</div>
</div>
<template v-for="(seg, j) in parseMessage(msg.content)" :key="j">
@@ -487,24 +536,40 @@ function formatInlineText(text: string): string {
<span v-html="formatInlineText(seg.content)" />
</div>
<div v-else class="my-2 rounded-md overflow-hidden bg-zinc-900 dark:bg-zinc-900">
<div class="flex items-center px-3 py-1.5 text-[10px] text-zinc-400 font-medium border-b border-zinc-700/50">
<div
class="flex items-center px-3 py-1.5 text-[10px] text-zinc-400 font-medium border-b border-zinc-700/50"
>
<Database class="h-3 w-3 mr-1.5" />
<span>{{ seg.lang }}</span>
<span class="flex-1" />
<div class="flex items-center gap-1.5">
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="t('ai.executeSql')" @click="executeSql(seg.content)">
<button
class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
:title="t('ai.executeSql')"
@click="executeSql(seg.content)"
>
<Play class="h-3.5 w-3.5" />
</button>
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
<button
class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
:title="t('ai.apply')"
@click="applySql(seg.content)"
>
<Replace class="h-3.5 w-3.5" />
</button>
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="copiedIndex === `${i}-${j}` ? t('ai.copied') : t('ai.copySql')" @click="copyCode(seg.content, `${i}-${j}`)">
<button
class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
:title="copiedIndex === `${i}-${j}` ? t('ai.copied') : t('ai.copySql')"
@click="copyCode(seg.content, `${i}-${j}`)"
>
<Check v-if="copiedIndex === `${i}-${j}`" class="h-3.5 w-3.5 text-green-400" />
<Copy v-else class="h-3.5 w-3.5" />
</button>
</div>
</div>
<pre class="whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-100"><code>{{ seg.content }}</code></pre>
<pre
class="whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-100"
><code>{{ seg.content }}</code></pre>
</div>
</template>
</div>
@@ -513,7 +578,7 @@ function formatInlineText(text: string): string {
<div v-if="isWaitingForFirstDelta" class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<span>{{ t('ai.thinking') }}</span>
<span>{{ t("ai.thinking") }}</span>
</div>
</div>
</ScrollArea>
@@ -524,8 +589,12 @@ function formatInlineText(text: string): string {
<DatabaseIcon v-if="connection" :db-type="connectionIconType(connection)" class="h-3 w-3 shrink-0" />
<Server v-else class="h-3 w-3 shrink-0" />
<Select :model-value="connection?.id || ''" @update:model-value="(v: any) => changeConnection(v)">
<SelectTrigger class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
<SelectValue :placeholder="t('editor.selectConnection')">{{ connection?.name || t('editor.selectConnection') }}</SelectValue>
<SelectTrigger
class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3"
>
<SelectValue :placeholder="t('editor.selectConnection')">{{
connection?.name || t("editor.selectConnection")
}}</SelectValue>
</SelectTrigger>
<SelectContent class="min-w-48">
<SelectItem v-for="conn in connectionStore.connections" :key="conn.id" :value="conn.id">
@@ -538,13 +607,27 @@ function formatInlineText(text: string): string {
</Select>
<template v-if="connection">
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
<Select :model-value="tab?.database || ''" @update:model-value="(v: any) => changeDatabase(v)" @update:open="(open: boolean) => { if (open) loadDatabases() }">
<SelectTrigger class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
<SelectValue :placeholder="t('editor.selectDatabase')">{{ tab?.database || t('editor.selectDatabase') }}</SelectValue>
<Select
:model-value="tab?.database || ''"
@update:model-value="(v: any) => changeDatabase(v)"
@update:open="
(open: boolean) => {
if (open) loadDatabases();
}
"
>
<SelectTrigger
class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3"
>
<SelectValue :placeholder="t('editor.selectDatabase')">{{
tab?.database || t("editor.selectDatabase")
}}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in dbOptions" :key="db" :value="db">{{ db }}</SelectItem>
<SelectItem v-if="!dbOptions.length && tab?.database" :value="tab.database">{{ tab.database }}</SelectItem>
<SelectItem v-if="!dbOptions.length && tab?.database" :value="tab.database">{{
tab.database
}}</SelectItem>
</SelectContent>
</Select>
</template>
@@ -560,14 +643,32 @@ function formatInlineText(text: string): string {
<div class="flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
<component :is="actionButtons.find(b => b.action === activeAction)?.icon" class="h-3 w-3" />
<button
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
>
<component :is="actionButtons.find((b) => b.action === activeAction)?.icon" class="h-3 w-3" />
<span>{{ t(`ai.actions.${activeAction}`) }}</span>
<svg class="h-3 w-3 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
<svg
class="h-3 w-3 opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-max min-w-0">
<DropdownMenuItem v-for="btn in actionButtons" :key="btn.action" class="text-xs gap-1.5" @click="selectAction(btn.action)">
<DropdownMenuItem
v-for="btn in actionButtons"
:key="btn.action"
class="text-xs gap-1.5"
@click="selectAction(btn.action)"
>
<component :is="btn.icon" class="h-3 w-3" />
<span>{{ t(btn.key) }}</span>
</DropdownMenuItem>
@@ -598,11 +699,11 @@ function formatInlineText(text: string): string {
<Dialog v-model:open="showSettings">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t('ai.settings') }}</DialogTitle>
<DialogTitle>{{ t("ai.settings") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-2">
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t('ai.provider') }}</Label>
<Label class="text-right text-xs">{{ t("ai.provider") }}</Label>
<Select :model-value="tempProvider" @update:model-value="(v: any) => selectProvider(v)">
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -618,7 +719,12 @@ function formatInlineText(text: string): string {
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Endpoint</Label>
<Input v-model="tempEndpoint" placeholder="https://api.openai.com/v1" autocomplete="off" class="col-span-2 h-8 text-xs" />
<Input
v-model="tempEndpoint"
placeholder="https://api.openai.com/v1"
autocomplete="off"
class="col-span-2 h-8 text-xs"
/>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Model</Label>
@@ -627,21 +733,49 @@ function formatInlineText(text: string): string {
<div v-if="tempProvider !== 'claude'" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API</Label>
<div class="col-span-2 flex gap-2">
<Button size="sm" variant="outline" class="h-8 flex-1 text-xs" :class="{ 'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'completions' }" @click="tempApiStyle = 'completions'">/chat/completions</Button>
<Button size="sm" variant="outline" class="h-8 flex-1 text-xs" :class="{ 'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'responses' }" @click="tempApiStyle = 'responses'">/responses</Button>
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{
'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'completions',
}"
@click="tempApiStyle = 'completions'"
>/chat/completions</Button
>
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{
'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'responses',
}"
@click="tempApiStyle = 'responses'"
>/responses</Button
>
</div>
</div>
</div>
<DialogFooter class="flex items-center gap-2">
<div class="flex-1 flex items-center gap-2">
<Button size="sm" variant="outline" :disabled="testingAi || !tempApiKey?.trim() || !tempEndpoint?.trim() || !tempModel?.trim()" @click="testAiConnection">
<Button
size="sm"
variant="outline"
:disabled="testingAi || !tempApiKey?.trim() || !tempEndpoint?.trim() || !tempModel?.trim()"
@click="testAiConnection"
>
<Loader2 v-if="testingAi" class="h-3 w-3 animate-spin mr-1" />
{{ t('connection.test') }}
{{ t("connection.test") }}
</Button>
<span v-if="testResult === 'success'" class="text-xs text-green-500">{{ t('connection.testSuccess') }}</span>
<span v-else-if="testResult === 'error'" class="text-xs text-destructive truncate max-w-[200px]" :title="testError">{{ testError }}</span>
<span v-if="testResult === 'success'" class="text-xs text-green-500">{{ t("connection.testSuccess") }}</span>
<span
v-else-if="testResult === 'error'"
class="text-xs text-destructive truncate max-w-[200px]"
:title="testError"
>{{ testError }}</span
>
</div>
<Button size="sm" @click="saveSettings">{{ t('grid.save') }}</Button>
<Button size="sm" @click="saveSettings">{{ t("grid.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+26 -21
View File
@@ -2,27 +2,28 @@
import { useI18n } from "vue-i18n";
import { AlertTriangle } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
withDefaults(defineProps<{
sql?: string;
title?: string;
message?: string;
details?: string;
confirmLabel?: string;
}>(), {
sql: "",
title: "",
message: "",
details: "",
confirmLabel: "",
});
withDefaults(
defineProps<{
sql?: string;
title?: string;
message?: string;
details?: string;
confirmLabel?: string;
}>(),
{
sql: "",
title: "",
message: "",
details: "",
confirmLabel: "",
},
);
const emit = defineEmits<{
confirm: [];
@@ -40,18 +41,22 @@ function onConfirm() {
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-destructive">
<AlertTriangle class="h-5 w-5" />
{{ title || t('dangerDialog.title') }}
{{ title || t("dangerDialog.title") }}
</DialogTitle>
</DialogHeader>
<div class="py-4">
<p class="text-sm text-muted-foreground mb-3">{{ message || t('dangerDialog.message') }}</p>
<pre v-if="details || sql" class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ details || sql }}</pre>
<p class="text-sm text-muted-foreground mb-3">{{ message || t("dangerDialog.message") }}</p>
<pre
v-if="details || sql"
class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap"
>{{ details || sql }}</pre
>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
<Button variant="destructive" @click="onConfirm">{{ confirmLabel || t('dangerDialog.confirm') }}</Button>
<Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" @click="onConfirm">{{ confirmLabel || t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+67 -68
View File
@@ -4,17 +4,11 @@ import type { EditorView as EditorViewType } from "@codemirror/view";
import { useI18n } from "vue-i18n";
import { Settings } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
useSettingsStore, EDITOR_THEMES, FONT_FAMILIES, DEFAULT_EDITOR_SETTINGS,
} from "@/stores/settingsStore";
import { useSettingsStore, EDITOR_THEMES, FONT_FAMILIES, DEFAULT_EDITOR_SETTINGS } from "@/stores/settingsStore";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
const { t } = useI18n();
@@ -35,14 +29,17 @@ const editTheme = ref(settingsStore.editorSettings.theme);
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
// Sync from store when dialog opens
watch(() => props.open, (open) => {
if (open) {
editFontFamily.value = settingsStore.editorSettings.fontFamily;
editFontSize.value = settingsStore.editorSettings.fontSize;
editTheme.value = settingsStore.editorSettings.theme;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
}
});
watch(
() => props.open,
(open) => {
if (open) {
editFontFamily.value = settingsStore.editorSettings.fontFamily;
editFontSize.value = settingsStore.editorSettings.fontSize;
editTheme.value = settingsStore.editorSettings.theme;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
}
},
);
function hasChanges(): boolean {
return (
@@ -75,11 +72,11 @@ function onExecuteModeChange(v: any) {
}
function onFontFamilyChange(v: any) {
if (typeof v === 'string') editFontFamily.value = v;
if (typeof v === "string") editFontFamily.value = v;
}
function onThemeChange(v: any) {
if (typeof v === 'string') editTheme.value = v as typeof DEFAULT_EDITOR_SETTINGS.theme;
if (typeof v === "string") editTheme.value = v as typeof DEFAULT_EDITOR_SETTINGS.theme;
}
// ---------- CodeMirror preview ----------
@@ -100,19 +97,21 @@ let fontThemeComp: import("@codemirror/state").Compartment | null = null;
let themeComp: import("@codemirror/state").Compartment | null = null;
let editorViewModule: typeof import("@codemirror/view") | null = null;
watch(previewSettings, async (ss) => {
if (!previewView.value || !fontThemeComp || !themeComp || !editorViewModule) return;
watch(
previewSettings,
async (ss) => {
if (!previewView.value || !fontThemeComp || !themeComp || !editorViewModule) return;
const themeExt = await loadEditorTheme(ss.theme);
previewView.value.dispatch({
effects: [
themeComp.reconfigure(themeExt),
fontThemeComp.reconfigure(
editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)
),
],
});
}, { deep: true });
const themeExt = await loadEditorTheme(ss.theme);
previewView.value.dispatch({
effects: [
themeComp.reconfigure(themeExt),
fontThemeComp.reconfigure(editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)),
],
});
},
{ deep: true },
);
let previewInitialized = false;
@@ -121,12 +120,7 @@ watch(previewRef, async (el) => {
previewInitialized = true;
if (previewView.value) return;
const [
{ EditorView },
{ EditorState, Compartment },
{ sql, MySQL },
{ basicSetup },
] = await Promise.all([
const [{ EditorView }, { EditorState, Compartment }, { sql, MySQL }, { basicSetup }] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-sql"),
@@ -146,25 +140,26 @@ watch(previewRef, async (el) => {
basicSetup,
sql({ dialect: MySQL }),
themeComp.of(themeExt),
fontThemeComp.of(
editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)
),
fontThemeComp.of(editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)),
],
});
previewView.value = new EditorView({ state, parent: previewRef.value });
});
watch(() => props.open, (open) => {
if (!open && previewView.value) {
previewView.value.destroy();
previewView.value = null;
previewInitialized = false;
fontThemeComp = null;
themeComp = null;
editorViewModule = null;
}
});
watch(
() => props.open,
(open) => {
if (!open && previewView.value) {
previewView.value.destroy();
previewView.value = null;
previewInitialized = false;
fontThemeComp = null;
themeComp = null;
editorViewModule = null;
}
},
);
</script>
<template>
@@ -173,14 +168,14 @@ watch(() => props.open, (open) => {
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Settings class="h-4 w-4" />
{{ t('settings.title') }}
{{ t("settings.title") }}
</DialogTitle>
</DialogHeader>
<div class="space-y-5 py-2">
<!-- Font Family -->
<div class="space-y-2">
<Label>{{ t('settings.fontFamily') }}</Label>
<Label>{{ t("settings.fontFamily") }}</Label>
<Select :model-value="editFontFamily" @update:model-value="onFontFamilyChange">
<SelectTrigger>
<SelectValue :placeholder="t('settings.selectFont')" />
@@ -206,7 +201,7 @@ watch(() => props.open, (open) => {
<!-- Font Size -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>{{ t('settings.fontSize') }}</Label>
<Label>{{ t("settings.fontSize") }}</Label>
<span class="text-xs text-muted-foreground tabular-nums">{{ editFontSize }}px</span>
</div>
<input
@@ -229,21 +224,21 @@ watch(() => props.open, (open) => {
<!-- Theme -->
<div class="space-y-2">
<Label>{{ t('settings.theme') }}</Label>
<Label>{{ t("settings.theme") }}</Label>
<Select :model-value="editTheme" @update:model-value="onThemeChange">
<SelectTrigger>
<SelectValue :placeholder="t('settings.selectTheme')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="theme in EDITOR_THEMES"
:key="theme.value"
:value="theme.value"
>
<SelectItem v-for="theme in EDITOR_THEMES" :key="theme.value" :value="theme.value">
<div class="flex items-center gap-2">
<span
class="h-3 w-3 rounded-full border"
:class="theme.dark ? 'bg-foreground border-foreground/20' : 'bg-muted-foreground/30 border-muted-foreground/40'"
:class="
theme.dark
? 'bg-foreground border-foreground/20'
: 'bg-muted-foreground/30 border-muted-foreground/40'
"
/>
{{ theme.label }}
</div>
@@ -253,14 +248,14 @@ watch(() => props.open, (open) => {
</div>
<div class="space-y-2">
<Label>{{ t('settings.executeMode') }}</Label>
<Label>{{ t("settings.executeMode") }}</Label>
<Select :model-value="editExecuteMode" @update:model-value="onExecuteModeChange">
<SelectTrigger>
<SelectValue :placeholder="t('settings.executeMode')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t('settings.executeModeAll') }}</SelectItem>
<SelectItem value="current">{{ t('settings.executeModeCurrent') }}</SelectItem>
<SelectItem value="all">{{ t("settings.executeModeAll") }}</SelectItem>
<SelectItem value="current">{{ t("settings.executeModeCurrent") }}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -269,10 +264,14 @@ watch(() => props.open, (open) => {
<!-- Live Preview -->
<div class="space-y-2">
<Label>{{ t('settings.preview') }}</Label>
<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'"
:class="
editTheme === 'vscode-light' || editTheme === 'duotone-light' || editTheme === 'xcode'
? 'border-border'
: 'border-border/50'
"
>
<div ref="previewRef" style="min-width: 100%" />
</div>
@@ -281,14 +280,14 @@ watch(() => props.open, (open) => {
<DialogFooter class="gap-2 sm:gap-0">
<Button variant="outline" @click="resetDefaults">
{{ t('settings.resetDefaults') }}
{{ t("settings.resetDefaults") }}
</Button>
<div class="flex-1" />
<Button variant="outline" @click="emit('update:open', false)">
{{ t('common.close') }}
{{ t("common.close") }}
</Button>
<Button :disabled="!hasChanges()" @click="applySettings">
{{ t('settings.apply') }}
{{ t("settings.apply") }}
</Button>
</DialogFooter>
</DialogContent>
+30 -33
View File
@@ -6,10 +6,7 @@ import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import {
buildSqlCompletionItemsFromContext,
getSqlCompletionContext,
} from "@/lib/sqlCompletion";
import { buildSqlCompletionItemsFromContext, getSqlCompletionContext } from "@/lib/sqlCompletion";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
const props = defineProps<{
@@ -23,9 +20,9 @@ const props = defineProps<{
const emit = defineEmits<{
"update:modelValue": [value: string];
"selectionChange": [value: string];
"cursorChange": [pos: number];
"formatError": [message: string];
selectionChange: [value: string];
cursorChange: [pos: number];
formatError: [message: string];
execute: [sql: string];
}>();
@@ -51,7 +48,7 @@ function setFontSize(size: number) {
editorFontTheme(editorViewModule.EditorView, next, ss.fontFamily, {
fixedHeight: true,
scrollable: true,
})
}),
),
],
});
@@ -104,10 +101,7 @@ async function formatCurrentSql() {
}
}
async function provideSqlCompletions(
currentState: import("@codemirror/state").EditorState,
position: number,
) {
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number) {
if (!props.connectionId || !props.database) return null;
const completionContext = getSqlCompletionContext(currentState.doc.toString(), position);
@@ -116,20 +110,24 @@ async function provideSqlCompletions(
if (completionContext.suggestColumns) {
const relatedTables = completionContext.qualifier
? completionContext.referencedTables.filter((table) => table.alias === completionContext.qualifier || table.name === completionContext.qualifier)
? completionContext.referencedTables.filter(
(table) => table.alias === completionContext.qualifier || table.name === completionContext.qualifier,
)
: completionContext.referencedTables;
await Promise.all(relatedTables.map(async (table) => {
const cacheKey = table.schema ? `${table.schema}.${table.name}` : table.name;
if (columnsByTable.has(cacheKey)) return;
const columns = await connectionStore.listCompletionColumns(
props.connectionId!,
props.database!,
table.name,
table.schema,
);
columnsByTable.set(cacheKey, columns);
}));
await Promise.all(
relatedTables.map(async (table) => {
const cacheKey = table.schema ? `${table.schema}.${table.name}` : table.name;
if (columnsByTable.has(cacheKey)) return;
const columns = await connectionStore.listCompletionColumns(
props.connectionId!,
props.database!,
table.name,
table.schema,
);
columnsByTable.set(cacheKey, columns);
}),
);
}
const items = buildSqlCompletionItemsFromContext(completionContext, {
@@ -174,7 +172,8 @@ onMounted(async () => {
const ss = settingsStore.editorSettings;
const baseDialect = props.dialect === "postgres" ? PostgreSQL : MySQL;
const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
const extraKeywords =
"PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
const dialect = SQLDialect.define({
...baseDialect.spec,
keywords: (baseDialect.spec.keywords || "") + " " + extraKeywords,
@@ -227,9 +226,7 @@ onMounted(async () => {
sql({ dialect }),
autocompletion({
activateOnTyping: true,
override: [
async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos),
],
override: [async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos)],
}),
codeMirrorTheme.of(theme),
runKeymap,
@@ -253,7 +250,7 @@ onMounted(async () => {
editorFontTheme(EditorView, ss.fontSize, ss.fontFamily, {
fixedHeight: true,
scrollable: true,
})
}),
),
EditorView.domEventHandlers({
wheel(event) {
@@ -278,14 +275,14 @@ watch(
changes: { from: 0, to: view.value.state.doc.length, insert: val },
});
}
}
},
);
watch(
() => props.formatRequestId,
(val, oldVal) => {
if (val && val !== oldVal) formatCurrentSql();
}
},
);
// Reactively apply editor settings changes (theme, font family, font size)
@@ -301,12 +298,12 @@ watch(
editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily, {
fixedHeight: true,
scrollable: true,
})
}),
),
],
});
},
{ deep: true }
{ deep: true },
);
onBeforeUnmount(() => {
+14 -11
View File
@@ -3,9 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, Trash2, Clock, X } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import { useHistoryStore } from "@/stores/historyStore";
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
@@ -22,8 +20,11 @@ const searchText = ref("");
const filtered = computed(() => {
if (!searchText.value) return store.entries;
const q = searchText.value.toLowerCase();
return store.entries.filter((e) =>
e.sql.toLowerCase().includes(q) || e.connection_name.toLowerCase().includes(q) || e.database.toLowerCase().includes(q)
return store.entries.filter(
(e) =>
e.sql.toLowerCase().includes(q) ||
e.connection_name.toLowerCase().includes(q) ||
e.database.toLowerCase().includes(q),
);
});
@@ -65,7 +66,7 @@ onMounted(() => store.load());
<div class="h-full flex flex-col overflow-hidden border-l">
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0 bg-muted/20">
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="text-xs font-medium">{{ t('history.title') }}</span>
<span class="text-xs font-medium">{{ t("history.title") }}</span>
<span class="flex-1" />
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
<Trash2 class="h-3 w-3" />
@@ -102,20 +103,22 @@ onMounted(() => store.load());
<div class="font-mono text-muted-foreground truncate">{{ truncateSql(entry.sql) }}</div>
<div class="flex items-center gap-2 mt-0.5">
<span :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t('history.failed') }}
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
</span>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-40">
<ContextMenuItem @click="restore(entry.sql)">{{ t('history.restore') }}</ContextMenuItem>
<ContextMenuItem @click="copySql(entry.sql)">{{ t('history.copy') }}</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
<ContextMenuItem @click="restore(entry.sql)">{{ t("history.restore") }}</ContextMenuItem>
<ContextMenuItem @click="copySql(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{
t("history.delete")
}}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t('history.empty') }}
{{ t("history.empty") }}
</div>
</div>
</div>