chore: add oxfmt + husky + lint-staged for code formatting and CI format checks
This commit is contained in:
1 parent
c295bf5780
commit
3acd9280ac
154 files changed
+5787
-3681
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user