mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 07:08:16 +00:00
feat(data-grid): add SQL preview panel for pending data changes
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -32,6 +32,8 @@ export default {
|
||||
stopExplain: "停止执行计划",
|
||||
formatSql: "格式化 SQL",
|
||||
formatSqlFailed: "SQL 格式化失败",
|
||||
previewSql: "预览 SQL",
|
||||
hidePreviewSql: "隐藏 SQL 预览",
|
||||
saveSql: "保存到 SQL 库",
|
||||
openSql: "打开 SQL 文件",
|
||||
theme: "主题",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user