fix(editor): bound dangerous SQL preview size
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user