fix(editor): bound dangerous SQL preview size

This commit is contained in:
t8y2 committed 2026-07-22 15:01:17 +08:00
1 parent 1cf594eac3
commit f6bba1cb6b
12 files changed
+287 -6

No files matched your search

@@ -1,12 +1,17 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Loader2, TextWrap } from "@lucide/vue";
import { AlertTriangle, Check, Copy, Loader2, TextWrap } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { copyToClipboard } from "@/lib/common/clipboard";
import { createBoundedTextPreview } from "@/lib/common/boundedTextPreview";
const DANGER_PREVIEW_MAX_CHARACTERS = 8192;
const DANGER_PREVIEW_MAX_LINES = 200;
const { t } = useI18n();
const { highlight } = useSqlHighlighter();
@@ -14,6 +19,7 @@ const { highlight } = useSqlHighlighter();
const open = defineModel<boolean>("open", { default: false });
const suppressFuturePrompts = defineModel<boolean>("suppressFuturePrompts", { default: false });
const wrap = ref(false);
const copied = ref(false);
const props = withDefaults(
defineProps<{
@@ -47,7 +53,10 @@ const emit = defineEmits<{
}>();
const code = computed(() => props.details || props.sql);
const highlightedCode = computed(() => highlight(code.value));
// Keep the confirmation payload intact, but never feed an unbounded script to Shiki or the DOM.
const preview = computed(() => createBoundedTextPreview(code.value, { maxCharacters: DANGER_PREVIEW_MAX_CHARACTERS, maxLines: DANGER_PREVIEW_MAX_LINES }));
const highlightedHead = computed(() => highlight(preview.value.head));
const highlightedTail = computed(() => highlight(preview.value.tail));
const dialogOpen = computed({
get: () => open.value,
set: (value) => {
@@ -61,6 +70,14 @@ function onConfirm() {
if (props.closeOnConfirm) open.value = false;
emit("confirm");
}
async function copyFullCode() {
await copyToClipboard(code.value);
copied.value = true;
window.setTimeout(() => {
copied.value = false;
}, 1500);
}
</script>
<template>
@@ -78,10 +95,22 @@ function onConfirm() {
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line">{{ detailsText }}</p>
<slot name="options" />
<div v-if="code" class="relative">
<Button variant="ghost" size="icon-xs" class="absolute top-1 right-1 z-10 h-6 w-6" :class="wrap ? 'text-foreground bg-accent' : 'text-muted-foreground'" :title="t('dangerDialog.wrapLines')" @click="wrap = !wrap">
<TextWrap class="h-3.5 w-3.5" />
</Button>
<pre class="text-xs bg-muted px-3 pt-3 pb-3 pr-7 rounded overflow-auto max-h-40 min-w-0 font-mono" :class="wrap ? 'whitespace-pre-wrap' : 'whitespace-pre'" v-html="highlightedCode" />
<div class="absolute top-1 right-1 z-10 flex items-center gap-0.5">
<Button variant="ghost" size="icon-xs" class="h-6 w-6 text-muted-foreground" :title="t('dangerDialog.copyFullText')" @click="copyFullCode">
<Check v-if="copied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" class="h-6 w-6" :class="wrap ? 'text-foreground bg-accent' : 'text-muted-foreground'" :title="t('dangerDialog.wrapLines')" @click="wrap = !wrap">
<TextWrap class="h-3.5 w-3.5" />
</Button>
</div>
<div data-native-clipboard data-testid="danger-code-preview" class="text-xs bg-muted px-3 pt-3 pb-3 pr-14 rounded overflow-auto max-h-40 min-w-0 font-mono" :class="wrap ? 'whitespace-pre-wrap' : 'whitespace-pre'">
<pre class="font-inherit whitespace-inherit" v-html="highlightedHead" />
<div v-if="preview.truncated" data-testid="danger-preview-truncated" class="my-2 rounded border border-border/70 bg-background/70 px-2 py-1.5 text-center text-[11px] leading-4 text-muted-foreground whitespace-normal">
{{ t("dangerDialog.previewTruncated", { lines: preview.omittedLines.toLocaleString(), characters: preview.omittedCharacters.toLocaleString() }) }}
</div>
<pre v-if="preview.tail" class="font-inherit whitespace-inherit" v-html="highlightedTail" />
</div>
</div>
<div v-if="showSuppressToggle" class="mt-3 flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<Label for="danger-confirm-suppress" class="text-sm leading-5">{{ suppressToggleLabel || t("dangerDialog.suppressFuturePrompts") }}</Label>
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { copyToClipboard } from "@/lib/common/clipboard";
const DANGER_PREVIEW_MAX_CHARACTERS = 8192;
const DANGER_PREVIEW_MAX_LINES = 200;
const highlight = vi.fn((sql: string) => `<span>${sql}</span>`);
vi.mock("@/composables/useSqlHighlighter", () => ({
useSqlHighlighter: () => ({ highlight }),
}));
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: vi.fn(),
}));
const mountedApps: App[] = [];
async function mountDialog(sql: string) {
const state = reactive({ open: true });
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () =>
h(DangerConfirmDialog, {
open: state.open,
sql,
"onUpdate:open": (value: boolean) => {
state.open = value;
},
}),
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
highlight.mockClear();
vi.mocked(copyToClipboard).mockClear();
});
describe("DangerConfirmDialog SQL preview", () => {
it("fully highlights short SQL without a truncation notice", async () => {
const sql = "DROP TABLE IF EXISTS users;";
await mountDialog(sql);
expect(highlight).toHaveBeenCalledOnce();
expect(highlight).toHaveBeenCalledWith(sql);
expect(document.body.querySelector('[data-testid="danger-preview-truncated"]')).toBeNull();
});
it("highlights only bounded head and tail fragments for huge SQL", async () => {
const sql = Array.from({ length: 40_000 }, (_, index) => `INSERT INTO t VALUES (${index});`).join("\n");
await mountDialog(sql);
const highlightedCharacters = highlight.mock.calls.reduce((total, [fragment]) => total + fragment.length, 0);
expect(highlight).toHaveBeenCalledTimes(2);
expect(highlightedCharacters).toBeLessThanOrEqual(DANGER_PREVIEW_MAX_CHARACTERS);
expect(highlight.mock.calls.flatMap(([fragment]) => fragment.split("\n"))).toHaveLength(DANGER_PREVIEW_MAX_LINES);
expect(document.body.querySelector('[data-testid="danger-preview-truncated"]')?.textContent).toContain("Preview truncated");
});
it("copies the full SQL instead of the bounded preview", async () => {
const sql = Array.from({ length: 40_000 }, (_, index) => `INSERT INTO t VALUES (${index});`).join("\n");
await mountDialog(sql);
const copyButton = Array.from(document.body.querySelectorAll("button")).find((button) => button.title === "Copy full text");
copyButton?.click();
await nextTick();
expect(copyToClipboard).toHaveBeenCalledWith(sql);
});
});