fix: add Shiki SQL syntax highlighting to all SQL display components

Add useSqlHighlighter composable and integrate Shiki highlighting into
DangerConfirmDialog, QueryHistory, DataGrid (replacing regex-based
highlighting), SqlFileExecutionDialog, SchemaDiffDialog, TreeItem,
ObjectBrowser, and FieldLineageDialog. Also fix danger dialog overflow
by adding overflow-hidden to DialogContent and wrap/scroll support.
This commit is contained in:
t8y2 2026-05-26 15:31:15 +08:00
parent a1f718d6bd
commit f5f986cdd6
10 changed files with 221 additions and 26 deletions

View File

@ -16,6 +16,7 @@ import type { TableInfo } from "@/types/database";
import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh";
import { useToast } from "@/composables/useToast";
import { Loader2, Copy, Play, GitCompareArrows, ArrowLeftRight } from "lucide-vue-next";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
interface SelectableTableDiff extends TableDiff {
selected: boolean;
@ -23,6 +24,7 @@ interface SelectableTableDiff extends TableDiff {
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
@ -52,6 +54,7 @@ const executedCount = ref(0);
const executeTotal = ref(0);
const syncErrors = ref<{ sql: string; error: string }[]>([]);
const syncSql = ref("");
const highlightedSyncSql = computed(() => highlight(syncSql.value));
const allSelected = computed(() => diffs.value.length > 0 && diffs.value.every((d) => d.selected));
const someSelected = computed(() => diffs.value.some((d) => d.selected) && !allSelected.value);
@ -616,11 +619,10 @@ watch(
<!-- SQL Preview -->
<div class="space-y-1">
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
<textarea
:value="syncSql"
readonly
class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring"
/>
<pre
class="w-full h-48 overflow-auto rounded-lg border bg-muted/20 p-3 font-mono text-xs whitespace-pre"
v-html="highlightedSyncSql"
></pre>
</div>
<!-- Sync Errors -->

View File

@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed } from "vue";
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 { useSqlHighlighter } from "@/composables/useSqlHighlighter";
const { t } = useI18n();
const { highlight } = useSqlHighlighter();
const open = defineModel<boolean>("open", { default: false });
withDefaults(
const props = withDefaults(
defineProps<{
sql?: string;
title?: string;
@ -29,6 +32,9 @@ const emit = defineEmits<{
confirm: [];
}>();
const code = computed(() => props.details || props.sql);
const highlightedCode = computed(() => highlight(code.value));
function onConfirm() {
open.value = false;
emit("confirm");
@ -45,13 +51,13 @@ function onConfirm() {
</DialogTitle>
</DialogHeader>
<div class="py-4">
<div class="py-4 min-w-0">
<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 min-w-0 font-mono whitespace-pre-wrap"
>{{ details || sql }}</pre
>
v-if="code"
class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 min-w-0 font-mono whitespace-pre"
v-html="highlightedCode"
/>
</div>
<DialogFooter>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { Clock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "lucide-vue-next";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
@ -18,6 +19,7 @@ import * as api from "@/lib/api";
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const store = useHistoryStore();
const emit = defineEmits<{
@ -297,7 +299,8 @@ onMounted(() => store.load());
</div>
<pre
class="max-h-48 overflow-auto rounded border bg-muted/30 p-3 text-xs"
><code>{{ selectedEntry.sql }}</code></pre>
v-html="highlight(selectedEntry.sql)"
></pre>
</div>
<div v-if="selectedEntry.rollback_sql">
<div class="mb-1 flex items-center justify-between">
@ -309,7 +312,8 @@ onMounted(() => store.load());
</div>
<pre
class="max-h-40 overflow-auto rounded border bg-muted/30 p-3 text-xs"
><code>{{ selectedEntry.rollback_sql }}</code></pre>
v-html="highlight(selectedEntry.rollback_sql || '')"
></pre>
</div>
</div>
<DialogFooter>

View File

@ -157,11 +157,13 @@ import { useDataGridExport } from "@/composables/useDataGridExport";
import { useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
import { useDataGridEditor } from "@/composables/useDataGridEditor";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useSettingsStore } from "@/stores/settingsStore";
const { t } = useI18n();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
interface PreparedCopyValue {
key: string;
@ -3330,6 +3332,8 @@ onUnmounted(() => {
const SQL_KEYWORDS =
/\b(CREATE|TABLE|INDEX|UNIQUE|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|NOT|NULL|DEFAULT|INT|INTEGER|BIGINT|SMALLINT|VARCHAR|CHARACTER|VARYING|TEXT|BOOLEAN|DOUBLE|PRECISION|REAL|FLOAT|NUMERIC|DECIMAL|TIMESTAMP|DATE|TIME|SERIAL|AUTOINCREMENT|AUTO_INCREMENT|IF|EXISTS|ON|SET|CASCADE|RESTRICT|CHECK|WITH|WITHOUT|ZONE)\b/gi;
const highlightedDdlContent = computed(() => highlight(ddlContent.value));
function highlightSql(sql: string): string {
const tokens: string[] = [];
let rest = sql;
@ -4850,7 +4854,7 @@ defineExpose({
v-else-if="activeTableInfoTab === 'ddl' && !ddlLoading"
class="flex-1 min-w-0 text-xs font-mono p-3 overflow-auto ddl-code leading-5 select-text"
:class="ddlWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
v-html="highlightSql(ddlContent)"
v-html="highlightedDdlContent"
></pre>
<div v-else class="flex-1 flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import {
ArrowUpRight,
Check,
@ -58,6 +59,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const connectionStore = useConnectionStore();
const dialogOpen = computed({
get: () => props.open,
@ -452,8 +454,8 @@ function openItemTarget(item: FieldLineageItem) {
<pre
v-if="item.sqlSnippet"
class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap"
>{{ item.sqlSnippet }}</pre
>
v-html="highlight(item.sqlSnippet)"
/>
</div>
<Button
variant="ghost"

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import {
ArrowDown,
ArrowRightLeft,
ArrowUp,
Braces,
CheckSquare,
Code2,
Copy,
CopyPlus,
@ -23,6 +25,7 @@ import {
Scissors,
Search,
ScrollText,
Square,
Table2,
TerminalSquare,
Trash2,
@ -105,6 +108,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
@ -144,6 +148,9 @@ const emptyPreviewSql = ref("");
const showDuplicateDialog = ref(false);
const duplicateTarget = ref<ObjectBrowserRow | null>(null);
const duplicateTableName = ref("");
const selectedTableIds = ref<Set<string>>(new Set());
const showBatchDropConfirm = ref(false);
const batchDropPreviewSql = ref("");
let loadId = 0;
const needsSchema = computed(() => isSchemaAware(props.connection.db_type));
@ -189,7 +196,7 @@ const hasComments = computed(() => rows.value.some((row) => row.comment?.trim())
const hasCreatedAt = computed(() => rows.value.some((row) => row.created_at?.trim()));
const hasUpdatedAt = computed(() => rows.value.some((row) => row.updated_at?.trim()));
const gridTemplateColumns = computed(() => {
const columns = ["minmax(0,1fr)", "120px"];
const columns = ["34px", "minmax(0,1fr)", "120px"];
if (hasCreatedAt.value) columns.push("150px");
if (hasUpdatedAt.value) columns.push("150px");
if (hasComments.value) columns.push("minmax(160px,0.7fr)");
@ -206,6 +213,18 @@ const filteredRows = computed(() => {
if (objectFilter.value === "functions") rows = rows.filter((row) => row.type === "FUNCTION");
return sortObjectBrowserRows(rows, sortKey.value, sortDirection.value);
});
const selectableRows = computed(() => rows.value.filter((row) => row.type === "TABLE"));
const visibleSelectableRows = computed(() => filteredRows.value.filter((row) => row.type === "TABLE"));
const selectedTableRows = computed(() => {
const ids = selectedTableIds.value;
return selectableRows.value.filter((row) => ids.has(row.id));
});
const selectedTableCount = computed(() => selectedTableRows.value.length);
const allVisibleTablesSelected = computed(
() =>
visibleSelectableRows.value.length > 0 &&
visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)),
);
function iconFor(row: ObjectBrowserRow) {
if (row.type === "VIEW") return Eye;
@ -551,6 +570,89 @@ function openDatabaseExport(row: ObjectBrowserRow) {
};
}
function setSelectedTableIds(ids: Set<string>) {
selectedTableIds.value = new Set(ids);
}
function toggleTableSelection(row: ObjectBrowserRow) {
if (row.type !== "TABLE") return;
const next = new Set(selectedTableIds.value);
if (next.has(row.id)) {
next.delete(row.id);
} else {
next.add(row.id);
}
setSelectedTableIds(next);
}
function toggleVisibleTableSelection() {
const next = new Set(selectedTableIds.value);
if (allVisibleTablesSelected.value) {
for (const row of visibleSelectableRows.value) next.delete(row.id);
} else {
for (const row of visibleSelectableRows.value) next.add(row.id);
}
setSelectedTableIds(next);
}
function clearTableSelection() {
setSelectedTableIds(new Set());
}
function openBatchDatabaseExport() {
const selectedTables = selectedTableRows.value.map((row) => row.name);
if (selectedTables.length === 0) return;
connectionStore.databaseExportSource = {
connectionId: props.connection.id,
database: props.database,
schema: selectedTableRows.value[0]?.schema || selectedSchema.value,
tableNames: selectedTables,
};
}
async function refreshBatchDropPreviewSql() {
const statements: string[] = [];
for (const row of selectedTableRows.value) {
const sql = await buildDropObjectSql({
databaseType: props.connection.db_type,
objectType: "TABLE",
schema: row.schema || selectedSchema.value,
name: row.name,
}).catch(() => "");
if (sql) statements.push(sql);
}
batchDropPreviewSql.value = statements.join("\n");
}
function requestBatchDropTables() {
if (selectedTableCount.value === 0) return;
batchDropPreviewSql.value = "";
void refreshBatchDropPreviewSql();
showBatchDropConfirm.value = true;
}
async function confirmBatchDropTables() {
const targets = [...selectedTableRows.value];
if (targets.length === 0) return;
try {
for (const row of targets) {
const sql = await buildDropObjectSql({
databaseType: props.connection.db_type,
objectType: "TABLE",
schema: row.schema || selectedSchema.value,
name: row.name,
});
await api.executeQuery(props.connection.id, props.database, sql);
}
toast(t("objects.batchDropSuccess", { count: targets.length }));
clearTableSelection();
await reload();
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
}
}
async function exportStructure(row: ObjectBrowserRow) {
try {
const schema = row.schema || selectedSchema.value || props.database;
@ -839,6 +941,8 @@ async function loadObjects() {
fallbackSchema: schema,
needsSchema: needsSchema.value,
});
const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id));
setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id))));
} catch (e: any) {
if (id !== loadId) return;
error.value = e?.message || String(e);
@ -913,6 +1017,7 @@ watch(
selectedSchema.value = props.schema;
userHasSelectedFilter.value = false;
objectFilter.value = "all";
clearTableSelection();
void reload();
},
{ immediate: true },
@ -973,6 +1078,23 @@ watch(
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingObjects }" />
</Button>
</div>
<div v-if="selectedTableCount > 0" class="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/30 px-3 text-xs">
<div class="min-w-0 flex-1 truncate text-muted-foreground">
{{ t("objects.selectedTables", { count: selectedTableCount }) }}
</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
<Download class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.exportSelected") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.dropSelected") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="clearTableSelection">
<X class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.clearSelection") }}
</Button>
</div>
<div v-if="loadingObjects" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
@ -992,6 +1114,15 @@ watch(
class="grid h-8 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground"
:style="{ gridTemplateColumns }"
>
<button
class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent"
type="button"
:disabled="visibleSelectableRows.length === 0"
@click="toggleVisibleTableSelection"
>
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<button class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('name')">
<span class="truncate">{{ t("objects.name") }}</span>
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
@ -1041,10 +1172,22 @@ watch(
<ContextMenuTrigger as-child>
<div
class="grid h-[38px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
:class="{ 'bg-accent/40': sourceRow?.id === item.id }"
:class="{
'bg-accent/40': sourceRow?.id === item.id,
'bg-primary/5': selectedTableIds.has(item.id),
}"
:style="{ gridTemplateColumns }"
@click="openRow(item)"
>
<button
class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
type="button"
:class="{ invisible: item.type !== 'TABLE' }"
@click.stop="toggleTableSelection(item)"
>
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<div class="flex min-w-0 items-center gap-2">
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
<span class="truncate text-[13px] font-medium text-foreground">{{ item.name }}</span>
@ -1298,6 +1441,15 @@ watch(
@confirm="confirmDrop"
/>
<DangerConfirmDialog
v-model:open="showBatchDropConfirm"
:title="t('objects.confirmBatchDropTitle')"
:message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })"
:sql="batchDropPreviewSql"
:confirm-label="t('objects.dropSelected')"
@confirm="confirmBatchDropTables"
/>
<Dialog v-model:open="showRenameDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
@ -1312,8 +1464,8 @@ watch(
<pre
v-if="renamePreviewSqlText"
class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
>{{ renamePreviewSqlText }}</pre
>
v-html="highlight(renamePreviewSqlText)"
></pre>
<p v-if="renameError" class="text-sm text-destructive">{{ renameError }}</p>
</div>
<DialogFooter>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import {
@ -137,6 +138,7 @@ const queryStore = useQueryStore();
const savedSqlStore = useSavedSqlStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const { getDatabaseOptions } = useDatabaseOptions();
const showVisibleDatabasesDialog = ref(false);
@ -2299,8 +2301,8 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<pre
v-if="renameObjectPreviewSql"
class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
>{{ renameObjectPreviewSql }}</pre
>
v-html="highlight(renameObjectPreviewSql)"
></pre>
<p v-if="renameObjectError" class="text-sm text-destructive">{{ renameObjectError }}</p>
</div>
<DialogFooter>

View File

@ -2,6 +2,7 @@
import { computed, ref, watch } from "vue";
import { uuid } from "@/lib/utils";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@ -25,6 +26,7 @@ import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } fr
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
@ -434,9 +436,10 @@ watch(
</div>
<span class="text-muted-foreground shrink-0">{{ formatBytes(preview.sizeBytes) }}</span>
</div>
<pre class="max-h-40 max-w-full overflow-auto p-3 text-xs font-mono whitespace-pre bg-muted/15">{{
preview.preview
}}</pre>
<pre
class="max-h-40 max-w-full overflow-auto p-3 text-xs font-mono whitespace-pre bg-muted/15"
v-html="highlight(preview.preview)"
></pre>
</div>
</div>

View File

@ -41,7 +41,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none',
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none overflow-hidden',
props.class,
)
"

View File

@ -0,0 +1,20 @@
import { computed, onMounted, ref } from "vue";
import { useTheme } from "@/composables/useTheme";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sqlHighlighter";
export function useSqlHighlighter() {
const { isDark } = useTheme();
const sqlHighlighter = ref<SqlHighlighter>();
onMounted(async () => {
sqlHighlighter.value = await createShikiSqlHighlighter({
appearance: () => (isDark.value ? "dark" : "light"),
});
});
function highlight(sql: string): string {
return sqlHighlighter.value?.(sql) ?? sql;
}
return { highlight, sqlHighlighter };
}