fix(ui): scroll long error messages in rename dialogs

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-03 17:51:43 +08:00
1 parent 20f312d650
commit d421bd33fc
6 files changed
+97 -14

No files matched your search

@@ -3856,8 +3856,8 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</DialogHeader>
<div class="grid gap-3">
<Input v-model="renameInput" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRename" />
<pre v-if="renamePreviewSqlText" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renamePreviewSqlText)"></pre>
<p v-if="renameError" class="text-sm text-destructive">{{ renameError }}</p>
<pre v-if="renamePreviewSqlText" class="max-h-32 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renamePreviewSqlText)"></pre>
<p v-if="renameError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameError }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="showRenameDialog = false">{{ t("dangerDialog.cancel") }}</Button>
@@ -279,8 +279,8 @@ watch(
</DialogHeader>
<div class="grid gap-3">
<Input v-model="renameObjectName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRenameObject" />
<pre v-if="renameObjectPreviewSql" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renameObjectPreviewSql)"></pre>
<p v-if="renameObjectError" class="text-sm text-destructive">{{ renameObjectError }}</p>
<pre v-if="renameObjectPreviewSql" class="max-h-32 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renameObjectPreviewSql)"></pre>
<p v-if="renameObjectError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameObjectError }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="showRenameObjectDialog = false">{{ t("dangerDialog.cancel") }}</Button>
@@ -298,8 +298,8 @@ watch(
</DialogHeader>
<div class="grid gap-3">
<Input v-model="renameMongoCollectionName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" :disabled="renameMongoCollectionLoading" @keydown.enter.prevent="confirmRenameMongoCollection" />
<pre v-if="renameMongoCollectionPreview" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{{ renameMongoCollectionPreview }}</pre>
<p v-if="renameMongoCollectionError" class="text-sm text-destructive">{{ renameMongoCollectionError }}</p>
<pre v-if="renameMongoCollectionPreview" class="max-h-32 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{{ renameMongoCollectionPreview }}</pre>
<p v-if="renameMongoCollectionError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameMongoCollectionError }}</p>
</div>
<DialogFooter>
<Button variant="outline" :disabled="renameMongoCollectionLoading" @click="showRenameMongoCollectionDialog = false">{{ t("dangerDialog.cancel") }}</Button>
+13 -3
View File
@@ -77,10 +77,20 @@ async function copy() {
<!-- centered: 居中占满 -->
<div v-else class="flex-1 min-h-0 flex flex-col items-center justify-center gap-2 px-6 py-4 text-center">
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
<div data-native-clipboard class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
<TriangleAlert class="h-8 w-8 shrink-0 text-destructive/50" aria-hidden="true" />
<!--
The message box must NOT carry a fixed max-height cap: every ancestor up
the results pane is overflow-hidden (DataGrid root/cell, splitpanes pane),
so this box is the only scroll surface and the flex chain
(flex-1 min-h-0) is what bounds it to the pane height. A fixed rem cap
clips long error text with an unreachable scrollbar instead — see issue
#7960. min-h-0 is the vertical shrink enabler; min-w-0 lets long
unspaced backend tokens wrap instead of widening this centered child.
Do not remove either.
-->
<div data-native-clipboard class="min-h-0 min-w-0 max-w-lg overflow-y-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
<div class="text-sm font-medium">{{ displayTitle }}</div>
<div class="text-xs break-all cursor-text text-destructive/80 select-text">{{ message }}</div>
<div class="text-xs whitespace-pre-wrap break-words text-left cursor-text text-destructive/80 select-text">{{ message }}</div>
</div>
<div class="shrink-0 flex flex-wrap items-center justify-center gap-2 text-foreground">
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click.stop="copy">
@@ -14,6 +14,14 @@ function findNativeClipboardRegion(container: HTMLElement, text: string): HTMLEl
return region;
}
// The centered variant's clipboard region wraps title + message in one node,
// so match by containment instead of exact text.
function findClipboardRegionContaining(container: HTMLElement, text: string): HTMLElement {
const region = Array.from(container.querySelectorAll<HTMLElement>("[data-native-clipboard]")).find((element) => element.textContent?.includes(text));
if (!region) throw new Error(`Native clipboard region not found containing: ${text}`);
return region;
}
function selectRegion(region: HTMLElement) {
const selection = window.getSelection();
const range = document.createRange();
@@ -22,15 +30,15 @@ function selectRegion(region: HTMLElement) {
selection?.addRange(range);
}
async function mountCardBanner() {
async function mountBanner(variant: "card" | "centered", message: string) {
const container = document.createElement("div");
mountedContainers.push(container);
document.body.append(container);
const app = createApp(ErrorBanner, {
variant: "card",
variant,
title: "Save failed",
message: "Conditional request failed",
message,
});
app.use(
createI18n({
@@ -53,7 +61,7 @@ afterEach(() => {
describe("ErrorBanner native clipboard selection", () => {
it.each(["Save failed", "Conditional request failed"])("preserves native copy for selected %s text", async (text) => {
const container = await mountCardBanner();
const container = await mountBanner("card", "Conditional request failed");
selectRegion(findNativeClipboardRegion(container, text));
expect(
@@ -65,8 +73,21 @@ describe("ErrorBanner native clipboard selection", () => {
).toBe(true);
});
it("preserves native copy for selected centered error message", async () => {
const container = await mountBanner("centered", "fatal error\n\nlong detail line");
selectRegion(findClipboardRegionContaining(container, "long detail line"));
expect(
eventTargetAllowsNativeClipboard({
key: "c",
metaKey: true,
target: container,
}),
).toBe(true);
});
it("leaves grid copy shortcuts active without a text selection", async () => {
const container = await mountCardBanner();
const container = await mountBanner("card", "Conditional request failed");
expect(
eventTargetAllowsNativeClipboard({
@@ -0,0 +1,41 @@
// Source-contract guard for the ErrorBanner "centered" variant (the query
// execution error panel rendered by DataGrid).
// Overview: the centered banner is the only scroll surface for execution
// errors — every ancestor up the results pane is overflow-hidden (DataGrid
// root and content cell, splitpanes pane), so the pane height must reach the
// message box through the flex chain (banner root `flex-1 min-h-0`). A fixed
// `max-h-*` cap on the message box breaks that chain: long error text gets
// clipped at the cap, and in a short pane the tiny internal scrollbar is
// clipped out of view entirely — see #7960 (错误信息面板无法展示完整错误信息).
//
// These guarantees are what stop that from re-happening:
// 1. The message box keeps `min-h-0` (the flex-shrink enabler) and
// `overflow-y-auto` (the internal scroller) with NO fixed max-height.
// 2. The message text keeps `whitespace-pre-wrap break-words`: backend
// errors are multi-line ("summary\n\ndetail"); `break-all` collapsed
// those newlines into a dense wall of text, and without wrap guards a
// long token could force horizontal overflow.
// If either is removed, the clipping regresses. Keep these assertions updated
// alongside the component whenever the class strings are touched.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const errorBannerSource = readFileSync(new URL("../ErrorBanner.vue", import.meta.url), "utf8");
describe("ErrorBanner centered layout guard", () => {
it("bounds the centered message box by the flex chain, not a fixed max-height", () => {
// The box must stay shrinkable in both axes (`min-h-0 min-w-0`) and scroll internally
// (`overflow-y-auto`); a fixed `max-h-48` cap re-introduces #7960.
expect(errorBannerSource).toContain('class="min-h-0 min-w-0 max-w-lg overflow-y-auto space-y-1 select-text text-destructive"');
expect(errorBannerSource).not.toContain("max-h-48");
});
it("preserves backend error newlines and wraps long tokens", () => {
// `text-left` keeps multi-line errors scannable under the centered
// variant's inherited `text-center` (single-line messages are unaffected:
// the box hugs its content).
expect(errorBannerSource).toContain("text-xs whitespace-pre-wrap break-words text-left cursor-text");
});
});