@@ -78,10 +95,22 @@ function onConfirm() {
{{ detailsText }}
-
-
+
+
+
+
+
+
+
+ {{ t("dangerDialog.previewTruncated", { lines: preview.omittedLines.toLocaleString(), characters: preview.omittedCharacters.toLocaleString() }) }}
+
+
+
diff --git a/apps/desktop/src/components/editor/__tests__/DangerConfirmDialog.spec.ts b/apps/desktop/src/components/editor/__tests__/DangerConfirmDialog.spec.ts
new file mode 100644
index 000000000..8ada13746
--- /dev/null
+++ b/apps/desktop/src/components/editor/__tests__/DangerConfirmDialog.spec.ts
@@ -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) => `${sql}`);
+
+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);
+ });
+});
diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts
index 28a39b698..69deea434 100644
--- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts
@@ -294,6 +294,35 @@ describe("useSqlExecution", () => {
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: "relation does not exist" }));
});
+ it("keeps the full dangerous script pending and executes it unchanged after confirmation", async () => {
+ const activeTab = ref(queryTab("app"));
+ const activeConnection = ref(connection("mysql"));
+ const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
+ const queryStore = useQueryStore();
+ const sql = Array.from({ length: 40_000 }, (_, index) => `${index === 0 ? "DROP TABLE IF EXISTS t;" : ""} INSERT INTO t VALUES (${index});`).join("\n");
+ const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
+ if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 40_000, execution_time_ms: 1 };
+ });
+ vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
+
+ const execution = useSqlExecution({
+ activeTab: computed(() => activeTab.value),
+ activeConnection: computed(() => activeConnection.value),
+ executableSql: computed(() => sql),
+ activeOutputView,
+ });
+
+ await execution.tryExecute();
+
+ expect(execution.showDangerDialog.value).toBe(true);
+ expect(execution.pendingDangerSql.value).toBe(sql);
+ expect(executeCurrentSql).not.toHaveBeenCalled();
+
+ await execution.onDangerConfirm();
+
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
+ });
+
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {
const activeTab = ref(queryTab("prod_app"));
const activeConnection = ref({ ...connection("mysql"), production_databases: ["prod_app"] });
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 4782e319d..b01012323 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -2860,6 +2860,8 @@ export default {
redisCommandMessage: "This Redis command may modify data and cannot be undone automatically. Continue?",
suppressFuturePrompts: "Do not ask again for dangerous SQL",
wrapLines: "Toggle word wrap",
+ copyFullText: "Copy full text",
+ previewTruncated: "Preview truncated: {characters} characters across {lines} lines omitted. Copy still includes the full text.",
deleteMessage: "This delete operation may be irreversible. Continue?",
deleteConfirm: "Confirm Delete",
deleteRowMessage: "This row will be marked for deletion and removed from the database after saving. Continue?",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index cfb2976d7..d9dbc50b4 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -2714,6 +2714,8 @@ export default withEnglishFallback({
redisCommandMessage: "Este comando de Redis puede modificar datos y no se puede deshacer automáticamente. ¿Continuar?",
suppressFuturePrompts: "No volver a preguntar para SQL peligroso",
wrapLines: "Alternar ajuste de línea",
+ copyFullText: "Copiar texto completo",
+ previewTruncated: "Vista previa truncada: se omitieron {lines} líneas y {characters} caracteres. La copia incluye el texto completo.",
deleteMessage: "Esta operación de eliminación puede ser irreversible. ¿Continuar?",
deleteConfirm: "Confirmar eliminación",
deleteRowMessage: "Esta fila quedará marcada para eliminación y se borrará de la base de datos al guardar. ¿Continuar?",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index f06c919d2..b67d0d238 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -2712,6 +2712,8 @@ export default withEnglishFallback({
redisCommandMessage: "Questo comando Redis può modificare i dati e non può essere annullato automaticamente. Continuare?",
suppressFuturePrompts: "Non chiedere più per SQL pericolosi",
wrapLines: "Attiva/disattiva ritorno a capo",
+ copyFullText: "Copia testo completo",
+ previewTruncated: "Anteprima troncata: omesse {lines} righe e {characters} caratteri. La copia include il testo completo.",
deleteMessage: "Questa operazione di eliminazione potrebbe essere irreversibile. Continuare?",
deleteConfirm: "Conferma Eliminazione",
deleteRowMessage: "Questa riga verrà contrassegnata per l'eliminazione e rimossa dal database dopo il salvataggio. Continuare?",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 8a53f4716..172e90220 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -2713,6 +2713,8 @@ export default withEnglishFallback({
redisCommandMessage: "このRedisコマンドはデータを変更し、自動的に元に戻せない可能性があります。続行しますか?",
suppressFuturePrompts: "危険なSQLの確認を今後表示しない",
wrapLines: "折り返し表示を切り替え",
+ copyFullText: "全文をコピー",
+ previewTruncated: "プレビューを省略しました: {lines} 行、{characters} 文字を省略。コピーには全文が含まれます。",
deleteMessage: "この削除操作は元に戻せない可能性があります。続行しますか?",
deleteConfirm: "削除を確認",
deleteRowMessage: "この行は削除対象としてマークされ、保存後にデータベースから削除されます。続行しますか?",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 81da73116..bbf307323 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -2714,6 +2714,8 @@ export default withEnglishFallback({
redisCommandMessage: "Este comando Redis pode modificar dados e não pode ser desfeito automaticamente. Continuar?",
suppressFuturePrompts: "Não perguntar novamente para SQL perigoso",
wrapLines: "Alternar quebra de linha",
+ copyFullText: "Copiar texto completo",
+ previewTruncated: "Visualização truncada: {lines} linhas e {characters} caracteres omitidos. A cópia inclui o texto completo.",
deleteMessage: "Esta operação de exclusão pode ser irreversível. Continuar?",
deleteConfirm: "Confirmar exclusão",
deleteRowMessage: "Esta linha será marcada para exclusão e removida do banco de dados após salvar. Continuar?",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 3f3734f4a..ca0115075 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2850,6 +2850,8 @@ export default withEnglishFallback({
redisCommandMessage: "此 Redis 命令可能会修改数据,且无法自动撤销,确认要继续吗?",
suppressFuturePrompts: "以后执行危险 SQL 不再提示",
wrapLines: "切换自动换行",
+ copyFullText: "复制完整内容",
+ previewTruncated: "预览已截断:省略 {lines} 行、{characters} 个字符。复制仍会包含完整内容。",
deleteMessage: "此删除操作可能不可逆,确认要继续吗?",
deleteConfirm: "确认删除",
deleteRowMessage: "此行将被标记为删除,保存后会从数据库删除,确认要继续吗?",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index f8875b7be..93b079e1f 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -2531,6 +2531,8 @@ export default withEnglishFallback({
redisCommandMessage: "此 Redis 命令可能會修改資料,且無法自動復原,確認要繼續嗎?",
suppressFuturePrompts: "之後執行危險 SQL 不再提示",
wrapLines: "切換自動換行",
+ copyFullText: "複製完整內容",
+ previewTruncated: "預覽已截斷:省略 {lines} 行、{characters} 個字元。複製仍會包含完整內容。",
deleteMessage: "此刪除操作可能不可逆,確認要繼續嗎?",
deleteConfirm: "確認刪除",
deleteRowMessage: "此列將被標記為刪除,儲存後會從資料庫刪除,確認要繼續嗎?",
diff --git a/apps/desktop/src/lib/common/__tests__/boundedTextPreview.spec.ts b/apps/desktop/src/lib/common/__tests__/boundedTextPreview.spec.ts
new file mode 100644
index 000000000..913b76e32
--- /dev/null
+++ b/apps/desktop/src/lib/common/__tests__/boundedTextPreview.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { createBoundedTextPreview } from "@/lib/common/boundedTextPreview";
+
+describe("createBoundedTextPreview", () => {
+ it("keeps short text unchanged", () => {
+ const sql = "DROP TABLE IF EXISTS users;\nSELECT 1;";
+
+ expect(createBoundedTextPreview(sql, { maxCharacters: 8192, maxLines: 200 })).toEqual({
+ head: sql,
+ tail: "",
+ truncated: false,
+ omittedCharacters: 0,
+ omittedLines: 0,
+ totalCharacters: sql.length,
+ totalLines: 2,
+ });
+ });
+
+ it("bounds a 40k-line preview by both characters and lines", () => {
+ const sql = Array.from({ length: 40_000 }, (_, index) => `INSERT INTO t VALUES (${index});`).join("\n");
+ const preview = createBoundedTextPreview(sql, { maxCharacters: 8192, maxLines: 200 });
+
+ expect(preview.truncated).toBe(true);
+ expect(preview.head.length + preview.tail.length).toBeLessThanOrEqual(8192);
+ expect(preview.head.split("\n").length + preview.tail.split("\n").length).toBeLessThanOrEqual(200);
+ expect(preview.head).toContain("VALUES (0)");
+ expect(preview.tail).toContain("VALUES (39999)");
+ expect(preview.omittedCharacters).toBeGreaterThan(1_000_000);
+ expect(preview.omittedLines).toBeGreaterThanOrEqual(39_800);
+ });
+});
diff --git a/apps/desktop/src/lib/common/boundedTextPreview.ts b/apps/desktop/src/lib/common/boundedTextPreview.ts
new file mode 100644
index 000000000..56eef7f89
--- /dev/null
+++ b/apps/desktop/src/lib/common/boundedTextPreview.ts
@@ -0,0 +1,92 @@
+export interface BoundedTextPreviewOptions {
+ maxCharacters: number;
+ maxLines: number;
+}
+
+export interface BoundedTextPreview {
+ head: string;
+ tail: string;
+ truncated: boolean;
+ omittedCharacters: number;
+ omittedLines: number;
+ totalCharacters: number;
+ totalLines: number;
+}
+
+function countLines(text: string): number {
+ let lines = 1;
+ for (let index = 0; index < text.length; index += 1) {
+ if (text.charCodeAt(index) === 10) lines += 1;
+ }
+ return lines;
+}
+
+function clampCodePointEnd(text: string, end: number): number {
+ if (end <= 0 || end >= text.length) return end;
+ const previous = text.charCodeAt(end - 1);
+ const current = text.charCodeAt(end);
+ return previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff ? end - 1 : end;
+}
+
+function clampCodePointStart(text: string, start: number): number {
+ if (start <= 0 || start >= text.length) return start;
+ const previous = text.charCodeAt(start - 1);
+ const current = text.charCodeAt(start);
+ return previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff ? start + 1 : start;
+}
+
+function headBoundary(text: string, maxCharacters: number, maxLines: number): number {
+ const characterBoundary = Math.min(text.length, maxCharacters);
+ let lines = 1;
+ for (let index = 0; index < characterBoundary; index += 1) {
+ if (text.charCodeAt(index) !== 10) continue;
+ lines += 1;
+ if (lines > maxLines) return index;
+ }
+ return clampCodePointEnd(text, characterBoundary);
+}
+
+function tailBoundary(text: string, maxCharacters: number, maxLines: number): number {
+ const characterBoundary = Math.max(0, text.length - maxCharacters);
+ let lines = 1;
+ for (let index = text.length - 1; index >= characterBoundary; index -= 1) {
+ if (text.charCodeAt(index) !== 10) continue;
+ lines += 1;
+ if (lines > maxLines) return index + 1;
+ }
+ return clampCodePointStart(text, characterBoundary);
+}
+
+export function createBoundedTextPreview(text: string, options: BoundedTextPreviewOptions): BoundedTextPreview {
+ const maxCharacters = Math.max(2, Math.floor(options.maxCharacters));
+ const maxLines = Math.max(2, Math.floor(options.maxLines));
+ const totalLines = countLines(text);
+
+ if (text.length <= maxCharacters && totalLines <= maxLines) {
+ return {
+ head: text,
+ tail: "",
+ truncated: false,
+ omittedCharacters: 0,
+ omittedLines: 0,
+ totalCharacters: text.length,
+ totalLines,
+ };
+ }
+
+ const headEnd = headBoundary(text, Math.ceil(maxCharacters / 2), Math.ceil(maxLines / 2));
+ const tailStart = Math.max(headEnd, tailBoundary(text, Math.floor(maxCharacters / 2), Math.floor(maxLines / 2)));
+ const head = text.slice(0, headEnd);
+ const tail = text.slice(tailStart);
+ const visibleLines = countLines(head) + (tail ? countLines(tail) : 0);
+
+ return {
+ head,
+ tail,
+ truncated: true,
+ omittedCharacters: tailStart - headEnd,
+ omittedLines: Math.max(0, totalLines - visibleLines),
+ totalCharacters: text.length,
+ totalLines,
+ };
+}