dbx/apps/desktop/src/components/editor/SqlPreviewPanel.vue

212 lines
6.7 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { AlignLeft, Copy, ChevronDown, Undo2, Redo2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useTheme } from "@/composables/useTheme";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import type { Highlighter } from "shiki";
const props = defineProps<{
sql: string;
sqlFormatDialect?: SqlFormatDialect;
loading?: boolean;
canUndo?: boolean;
canRedo?: boolean;
}>();
const emit = defineEmits<{
close: [];
undo: [];
redo: [];
}>();
const { t } = useI18n();
const { isDark } = useTheme();
const { toast } = useToast();
const isFormatted = ref(false);
const formattedSql = ref("");
const formatting = ref(false);
const highlightedHtml = ref("");
const highlighterReady = ref(false);
let highlighter: Highlighter | null = null;
const displaySql = computed(() => {
if (isFormatted.value && formattedSql.value) {
return formattedSql.value;
}
return props.sql;
});
const hasSql = computed(() => props.sql.trim().length > 0);
async function initHighlighter() {
if (highlighter) return;
try {
const { createHighlighter } = await import("shiki");
highlighter = await createHighlighter({
themes: ["dark-plus", "min-light"],
langs: ["sql"],
});
highlighterReady.value = true;
await highlightSql();
} catch (e) {
console.error("[DBX][SqlPreviewPanel] Failed to init shiki:", e);
}
}
async function highlightSql() {
if (!highlighter || !displaySql.value) return;
try {
const theme = isDark.value ? "dark-plus" : "min-light";
highlightedHtml.value = highlighter.codeToHtml(displaySql.value, {
lang: "sql",
theme,
});
} catch {
// fallback to plain text
highlightedHtml.value = "";
}
}
async function toggleFormat() {
if (formatting.value || !hasSql.value) return;
if (isFormatted.value) {
isFormatted.value = false;
await highlightSql();
return;
}
formatting.value = true;
try {
formattedSql.value = await formatSqlText(props.sql, props.sqlFormatDialect ?? "generic");
isFormatted.value = true;
await highlightSql();
} catch {
toast(t("toolbar.formatSqlFailed"), 3000);
} finally {
formatting.value = false;
}
}
async function handleCopy() {
const text = displaySql.value;
if (!text.trim()) return;
try {
await copyToClipboard(text);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
watch(
() => props.sql,
async (newSql) => {
isFormatted.value = false;
formattedSql.value = "";
if (highlighterReady.value && newSql.trim()) {
await highlightSql();
}
},
);
watch(isDark, async () => {
if (highlighterReady.value && displaySql.value) {
await highlightSql();
}
});
watch(displaySql, async () => {
if (highlighterReady.value) {
await highlightSql();
}
});
onMounted(() => {
nextTick(() => {
void initHighlighter();
});
});
onBeforeUnmount(() => {
highlighter?.dispose();
highlighter = null;
highlighterReady.value = false;
});
</script>
<template>
<div class="h-full flex flex-col bg-background border-t">
<!-- Header bar -->
<div class="h-8 shrink-0 border-b bg-muted/30 px-2 flex items-center gap-1 text-xs text-muted-foreground">
<span class="font-medium text-muted-foreground/70 select-none">SQL</span>
<span class="flex-1 min-w-0" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :class="isFormatted ? 'text-amber-600 bg-amber-500/10' : 'text-amber-600/60 hover:text-amber-700 hover:bg-amber-500/10'" :disabled="formatting || !hasSql" :aria-label="t('toolbar.formatSql')" @click="toggleFormat">
<AlignLeft class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.formatSql") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :disabled="!canUndo" :aria-label="t('grid.undoChange')" @click="emit('undo')">
<Undo2 class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("grid.undoChange") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :disabled="!canRedo" :aria-label="t('grid.redoChange')" @click="emit('redo')">
<Redo2 class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("grid.redoChange") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :disabled="!hasSql" :aria-label="t('grid.copy')" @click="handleCopy">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("grid.copy") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :aria-label="t('toolbar.hidePreviewSql')" @click="emit('close')">
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.hidePreviewSql") }}</TooltipContent>
</Tooltip>
</div>
<!-- Content -->
<div class="flex-1 min-h-0 overflow-auto">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center h-full text-xs text-muted-foreground">
{{ t("common.loading") }}
</div>
<!-- Empty -->
<div v-else-if="!hasSql" class="flex items-center justify-center h-full text-xs text-muted-foreground">
{{ t("grid.previewSqlEmpty") }}
</div>
<!-- Shiki highlighted SQL -->
<div v-else-if="highlightedHtml" data-native-clipboard class="p-3 text-xs leading-relaxed [&_pre]:!bg-transparent [&_pre]:!p-0 [&_code]:!font-mono [&_code]:text-xs" v-html="highlightedHtml" />
<!-- Plain text fallback -->
<pre v-else data-native-clipboard class="p-3 text-xs font-mono whitespace-pre-wrap select-text">{{ displaySql }}</pre>
</div>
</div>
</template>