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> </DialogHeader>
<div class="grid gap-3"> <div class="grid gap-3">
<Input v-model="renameInput" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRename" /> <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> <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="text-sm text-destructive">{{ renameError }}</p> <p v-if="renameError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameError }}</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" @click="showRenameDialog = false">{{ t("dangerDialog.cancel") }}</Button> <Button variant="outline" @click="showRenameDialog = false">{{ t("dangerDialog.cancel") }}</Button>
@@ -279,8 +279,8 @@ watch(
</DialogHeader> </DialogHeader>
<div class="grid gap-3"> <div class="grid gap-3">
<Input v-model="renameObjectName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRenameObject" /> <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> <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="text-sm text-destructive">{{ renameObjectError }}</p> <p v-if="renameObjectError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameObjectError }}</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" @click="showRenameObjectDialog = false">{{ t("dangerDialog.cancel") }}</Button> <Button variant="outline" @click="showRenameObjectDialog = false">{{ t("dangerDialog.cancel") }}</Button>
@@ -298,8 +298,8 @@ watch(
</DialogHeader> </DialogHeader>
<div class="grid gap-3"> <div class="grid gap-3">
<Input v-model="renameMongoCollectionName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" :disabled="renameMongoCollectionLoading" @keydown.enter.prevent="confirmRenameMongoCollection" /> <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> <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="text-sm text-destructive">{{ renameMongoCollectionError }}</p> <p v-if="renameMongoCollectionError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameMongoCollectionError }}</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" :disabled="renameMongoCollectionLoading" @click="showRenameMongoCollectionDialog = false">{{ t("dangerDialog.cancel") }}</Button> <Button variant="outline" :disabled="renameMongoCollectionLoading" @click="showRenameMongoCollectionDialog = false">{{ t("dangerDialog.cancel") }}</Button>
+13 -3
View File
@@ -77,10 +77,20 @@ async function copy() {
<!-- centered: 居中占满 --> <!-- 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"> <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" /> <TriangleAlert class="h-8 w-8 shrink-0 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> <!--
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-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>
<div class="shrink-0 flex flex-wrap items-center justify-center gap-2 text-foreground"> <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"> <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; 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) { function selectRegion(region: HTMLElement) {
const selection = window.getSelection(); const selection = window.getSelection();
const range = document.createRange(); const range = document.createRange();
@@ -22,15 +30,15 @@ function selectRegion(region: HTMLElement) {
selection?.addRange(range); selection?.addRange(range);
} }
async function mountCardBanner() { async function mountBanner(variant: "card" | "centered", message: string) {
const container = document.createElement("div"); const container = document.createElement("div");
mountedContainers.push(container); mountedContainers.push(container);
document.body.append(container); document.body.append(container);
const app = createApp(ErrorBanner, { const app = createApp(ErrorBanner, {
variant: "card", variant,
title: "Save failed", title: "Save failed",
message: "Conditional request failed", message,
}); });
app.use( app.use(
createI18n({ createI18n({
@@ -53,7 +61,7 @@ afterEach(() => {
describe("ErrorBanner native clipboard selection", () => { describe("ErrorBanner native clipboard selection", () => {
it.each(["Save failed", "Conditional request failed"])("preserves native copy for selected %s text", async (text) => { 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)); selectRegion(findNativeClipboardRegion(container, text));
expect( expect(
@@ -65,8 +73,21 @@ describe("ErrorBanner native clipboard selection", () => {
).toBe(true); ).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 () => { it("leaves grid copy shortcuts active without a text selection", async () => {
const container = await mountCardBanner(); const container = await mountBanner("card", "Conditional request failed");
expect( expect(
eventTargetAllowsNativeClipboard({ 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");
});
});
+11
View File
@@ -6,6 +6,7 @@ const connectionTree = readFileSync("apps/desktop/src/components/sidebar/Connect
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
const dialogHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue", "utf8"); const dialogHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue", "utf8");
const objectBrowser = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
const dialogState = readFileSync("apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts", "utf8"); const dialogState = readFileSync("apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts", "utf8");
const visibleDatabasesDialog = readFileSync("apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue", "utf8"); const visibleDatabasesDialog = readFileSync("apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue", "utf8");
const visibleSchemasDialog = readFileSync("apps/desktop/src/components/sidebar/VisibleSchemasDialog.vue", "utf8"); const visibleSchemasDialog = readFileSync("apps/desktop/src/components/sidebar/VisibleSchemasDialog.vue", "utf8");
@@ -41,6 +42,16 @@ test("remaining form dialogs render once at tree level and keep confirm/cancel b
assert.match(dialogHost, /@click="confirmPasteTable"/); assert.match(dialogHost, /@click="confirmPasteTable"/);
}); });
test("rename dialogs preserve their layout while scrolling unbounded identifiers", () => {
const previewClass = "max-h-32 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap";
const errorClass = "min-w-0 max-w-full overflow-x-auto text-sm text-destructive";
assert.equal(occurrences(dialogHost, previewClass), 2);
assert.equal(occurrences(dialogHost, errorClass), 2);
assert.ok(objectBrowser.includes(previewClass));
assert.ok(objectBrowser.includes(errorClass));
});
test("dialog controller routing preserves shared open-flag refs instead of spreading them", () => { test("dialog controller routing preserves shared open-flag refs instead of spreading them", () => {
assert.match(runtimeHost, /createRoutedSidebarDialogController\(/); assert.match(runtimeHost, /createRoutedSidebarDialogController\(/);
assert.doesNotMatch(runtimeHost, /reactive<Record<string, any>>\(\{ \.\.\.controller/); assert.doesNotMatch(runtimeHost, /reactive<Record<string, any>>\(\{ \.\.\.controller/);