feat(data-grid): add SQL preview panel for pending data changes

This commit is contained in:
t8y2
2026-06-10 12:06:36 +08:00
parent bfec556e9b
commit 8599ab8200
6 changed files with 302 additions and 1 deletions
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { AlignLeft, Copy, ChevronDown } 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/clipboard";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
import type { Highlighter } from "shiki";
const props = defineProps<{
sql: string;
sqlFormatDialect?: SqlFormatDialect;
loading?: boolean;
}>();
const emit = defineEmits<{
close: [];
}>();
const { t } = useI18n();
const { isDark } = useTheme();
const { toast } = useToast();
const codeContainerRef = ref<HTMLDivElement>();
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" @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="!hasSql" @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" @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("editor.pressToExecute", { mod: "Cmd/Ctrl" }) }}
</div>
<!-- Shiki highlighted SQL -->
<div v-else-if="highlightedHtml" ref="codeContainerRef" 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 class="p-3 text-xs font-mono whitespace-pre-wrap select-text">{{ displaySql }}</pre>
</div>
</div>
</template>
+71 -1
View File
@@ -4,7 +4,7 @@ const globalDdlOpen = ref(false);
</script>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, onActivated, onDeactivated, useSlots, watch, type Component } from "vue";
import { computed, nextTick, onMounted, onUnmounted, onActivated, onDeactivated, useSlots, watch, ref, defineAsyncComponent, type Component } from "vue";
import { useI18n } from "vue-i18n";
import {
ArrowUp,
@@ -118,6 +118,8 @@ import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { nextDataGridSortState, type DataGridSortDirection } from "@/lib/dataGridSort";
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
const { t } = useI18n();
const slots = useSlots();
const settingsStore = useSettingsStore();
@@ -1995,8 +1997,59 @@ const {
clearResetScrollAfterResult,
cleanupFrames,
recordScrollPosition,
previewStatements,
isPreviewLoading,
previewChanges,
} = editor;
const showSqlPreview = ref(false);
const previewSqlText = ref("");
let previewRefreshTimer: ReturnType<typeof setTimeout> | null = null;
async function refreshPreviewSql() {
if (!showSqlPreview.value) return;
const stmts = await previewChanges();
if (showSqlPreview.value) {
previewSqlText.value = stmts.join("\n");
}
}
function schedulePreviewRefresh() {
if (!showSqlPreview.value) return;
if (pendingChangeCount.value === 0) {
// All changes were discarded — close the preview
previewSqlText.value = "";
return;
}
if (previewRefreshTimer) clearTimeout(previewRefreshTimer);
previewRefreshTimer = setTimeout(() => {
previewRefreshTimer = null;
void refreshPreviewSql();
}, 500);
}
async function openSqlPreview() {
const stmts = await previewChanges();
previewSqlText.value = stmts.join("\n");
if (stmts.length > 0) {
showSqlPreview.value = true;
}
}
function closeSqlPreview() {
showSqlPreview.value = false;
if (previewRefreshTimer) {
clearTimeout(previewRefreshTimer);
previewRefreshTimer = null;
}
}
// Watch for edits — auto-refresh preview when panel is open
watch([pendingChangeCount, dirtyRows, newRows, deletedRows], () => {
schedulePreviewRefresh();
});
const saveActionMode = computed(() =>
dataGridSaveActionMode({
pendingChangeCount: pendingChangeCount.value,
@@ -5844,6 +5897,18 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</Tooltip>
<Button v-if="editable && (tableMeta || customSave)" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow"> <Plus class="w-3 h-3 mr-1" /> {{ t("grid.addRow") }} </Button>
<template v-if="saveToolbarState.showActions">
<Tooltip v-if="pendingChangeCount > 0">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700" :disabled="isPreviewLoading" @click="openSqlPreview">
<Loader2 v-if="isPreviewLoading" class="w-3 h-3 mr-1 animate-spin" />
<Eye v-else class="w-3 h-3 mr-1" />
{{ t("toolbar.previewSql") }}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
{{ t("toolbar.previewSql") }}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="default" size="sm" class="h-5 text-xs px-1.5 shrink-0" :disabled="saveToolbarState.actionsDisabled" @click="onToolbarCommit">
@@ -7299,6 +7364,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</DialogContent>
</Dialog>
<!-- SQL Preview panel for pending data changes -->
<div v-if="showSqlPreview" class="h-52 shrink-0 border-t">
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" @close="closeSqlPreview" />
</div>
<DangerConfirmDialog
v-model:open="showDeleteRowConfirm"
:message="pendingDeleteRowIds.length > 1 ? t('dangerDialog.deleteRowsMessage', { count: pendingDeleteRowIds.length }) : t('dangerDialog.deleteRowMessage')"
@@ -432,6 +432,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
rowChanges?.delete(col);
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
dirtyRows.value = new Map(dirtyRows.value);
editingCell.value = null;
}
@@ -887,6 +888,36 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
});
}
// --- SQL Preview for pending changes ---
const previewStatements = ref<string[]>([]);
const isPreviewLoading = ref(false);
async function previewChanges(): Promise<string[]> {
isPreviewLoading.value = true;
previewStatements.value = [];
try {
if (customSave?.value) {
// customSave doesn't expose SQL — return empty
return [];
}
const stmtOptions = saveStatementOptions();
if (!stmtOptions) return [];
const prepared = await api.prepareDataGridSave(stmtOptions);
if (prepared?.validationError) {
saveError.value = prepared.validationError;
return [];
}
const stmts = prepared?.statements ?? [];
previewStatements.value = stmts;
return stmts;
} catch (e: any) {
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
return [];
} finally {
isPreviewLoading.value = false;
}
}
return {
editingCell,
editValue,
@@ -934,6 +965,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
clearResetScrollAfterResult,
cleanupFrames,
recordScrollPosition,
previewStatements,
isPreviewLoading,
previewChanges,
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
if (headerRef.value) {
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;
+2
View File
@@ -32,6 +32,8 @@ export default {
stopExplain: "Stop explain",
formatSql: "Format SQL",
formatSqlFailed: "Failed to format SQL",
previewSql: "Preview SQL",
hidePreviewSql: "Hide SQL Preview",
saveSql: "Save to SQL Library",
openSql: "Open SQL file",
theme: "Theme",
+2
View File
@@ -32,6 +32,8 @@ export default {
stopExplain: "停止执行计划",
formatSql: "格式化 SQL",
formatSqlFailed: "SQL 格式化失败",
previewSql: "预览 SQL",
hidePreviewSql: "隐藏 SQL 预览",
saveSql: "保存到 SQL 库",
openSql: "打开 SQL 文件",
theme: "主题",
+1
View File
@@ -442,6 +442,7 @@ export interface QueryTab {
queryEditabilityReason?: "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable";
resultEvicted?: boolean;
whereInput?: string;
previewSql?: string;
}
export interface SavedSqlFolder {