fix(explain): fix execution plan node detail overflow

This commit is contained in:
onenewcode 2026-07-24 23:50:08 +08:00 committed by GitHub
parent 80ef2e579e
commit 85b6afd3c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 185 additions and 27 deletions

View File

@ -1,57 +1,107 @@
<script setup lang="ts">
import { ref } from "vue";
import { ChevronRight, ChevronDown } from "@lucide/vue";
import { computed, ref } from "vue";
import { ChevronRight, ChevronDown, Info } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { ExplainPlanNode } from "@/lib/diagram/explainPlan";
const props = defineProps<{
node: ExplainPlanNode;
depth?: number;
}>();
const { t } = useI18n();
const collapsed = ref(false);
const hasChildren = computed(() => props.node.children.length > 0);
function toggle() {
if (props.node.children.length > 0) {
collapsed.value = !collapsed.value;
}
interface ExplainPlanDetailEntry {
label?: string;
value: string;
}
function actualRowsFromDetails(): string | undefined {
for (const d of props.node.details) {
const m = d.match(/Actual Rows:\s*(\S+)/);
if (m) return m[1];
const detailEntries = computed<ExplainPlanDetailEntry[]>(() =>
props.node.details
.map((detail): ExplainPlanDetailEntry => {
const separatorIndex = detail.indexOf(":");
if (separatorIndex === -1) return { value: detail.trim() };
const label = detail.slice(0, separatorIndex).trim();
return {
label: label || undefined,
value: detail.slice(separatorIndex + 1).trim(),
};
})
.filter((detail) => detail.label || detail.value),
);
const detailPreviewEntries = computed(() => detailEntries.value.filter((detail) => detail.label !== "Actual Rows").slice(0, 2));
function toggleCollapsed() {
collapsed.value = !collapsed.value;
}
const actualRows = computed(() => {
for (const detail of props.node.details) {
const match = detail.match(/Actual Rows:\s*(\S+)/);
if (match) return match[1];
}
return undefined;
}
});
const actualRows = actualRowsFromDetails();
const hasActualStats = !!actualRows;
const rowDiffers = hasActualStats && actualRows !== props.node.rows;
const hasActualStats = computed(() => !!actualRows.value);
const rowDiffers = computed(() => hasActualStats.value && actualRows.value !== props.node.rows);
</script>
<template>
<div>
<!-- Single line: collapse icon + title + badges all in one row -->
<div class="flex cursor-pointer items-center gap-1 rounded border bg-background px-2 py-1 text-xs hover:bg-muted/30" :class="{ 'border-green-300 dark:border-green-700': hasActualStats }" @click="toggle">
<ChevronRight v-if="node.children.length > 0 && collapsed" class="h-3 w-3 shrink-0 text-muted-foreground" />
<ChevronDown v-else-if="node.children.length > 0" class="h-3 w-3 shrink-0 text-muted-foreground" />
<div class="flex min-w-0 items-center gap-1 rounded border bg-background px-2 py-1 text-xs">
<Button v-if="hasChildren" variant="ghost" size="icon-xs" class="-ml-1 h-5 w-5 shrink-0 text-foreground/80" :aria-label="node.title" :aria-expanded="!collapsed" @click="toggleCollapsed">
<ChevronRight v-if="collapsed" class="h-3 w-3" aria-hidden="true" />
<ChevronDown v-else class="h-3 w-3" aria-hidden="true" />
</Button>
<span v-else class="h-5 w-5 shrink-0" aria-hidden="true" />
<span class="shrink-0 rounded bg-muted px-1 py-0.5 font-medium">{{ node.nodeType }}</span>
<span v-if="node.relation" class="shrink-0 truncate max-w-[120px] text-blue-600 dark:text-blue-400">{{ node.relation }}</span>
<span v-if="node.index" class="shrink-0 text-emerald-600 dark:text-emerald-400">[{{ node.index }}]</span>
<span v-if="node.cost" class="shrink-0 tabular-nums text-muted-foreground">c:{{ node.cost }}</span>
<span v-if="node.rows" class="shrink-0 tabular-nums text-amber-600 dark:text-amber-400">e:{{ node.rows }}</span>
<span v-if="hasActualStats" class="shrink-0 tabular-nums font-semibold" :class="rowDiffers ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'"
<span v-if="node.relation" class="shrink-0 truncate max-w-[120px] text-foreground">{{ node.relation }}</span>
<span v-if="node.index" class="shrink-0 font-medium text-foreground/80">[{{ node.index }}]</span>
<span v-if="node.cost" class="shrink-0 tabular-nums text-foreground/80">c:{{ node.cost }}</span>
<span v-if="node.rows" class="shrink-0 tabular-nums text-foreground/80">e:{{ node.rows }}</span>
<span v-if="hasActualStats" class="shrink-0 tabular-nums font-semibold text-foreground"
>a:{{ actualRows }}<span v-if="rowDiffers">({{ Math.round((Number(actualRows) / Number(node.rows)) * 100) }}%)</span></span
>
<!-- Details collapsed into tooltip on hover -->
<span v-if="node.details.length" class="ml-auto shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground/40" :title="node.details.join('\n')">{{ node.details.join(" ") }}</span>
<div v-if="detailPreviewEntries.length" class="ml-1 flex min-w-0 flex-1 items-center gap-2 text-foreground/80">
<span v-for="(detail, index) in detailPreviewEntries" :key="index" class="min-w-0 flex-1 truncate">
<span v-if="detail.label" class="font-medium text-foreground">{{ detail.label }}:</span>
{{ detail.value }}
</span>
</div>
<Popover v-if="detailEntries.length">
<PopoverTrigger as-child>
<Button variant="outline" size="xs" class="ml-auto h-5 shrink-0 gap-1 px-1.5 text-[11px] font-normal text-foreground/80" :aria-label="t('explain.details')">
<Info class="h-3 w-3" aria-hidden="true" />
{{ t("explain.details") }}
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-[min(32rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0" @click.stop>
<div data-native-clipboard class="max-h-[min(24rem,calc(100vh-8rem))] overflow-auto p-3">
<div v-for="(detail, index) in detailEntries" :key="index" class="space-y-1 border-b py-2 first:pt-0 last:border-b-0 last:pb-0">
<div v-if="detail.label" class="text-[11px] font-medium text-popover-foreground">
{{ detail.label }}
</div>
<div class="select-text whitespace-pre-wrap break-words text-xs leading-5">
{{ detail.value }}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Children (collapsible) -->
<div v-if="node.children.length && !collapsed" class="ml-3 mt-px space-y-px border-l pl-2">
<ExplainPlanNodeTree v-for="child in node.children" :key="child.id" :node="child" :depth="(depth || 0) + 1" />
<ExplainPlanNodeTree v-for="child in node.children" :key="child.id" :node="child" />
</div>
</div>
</template>

View File

@ -0,0 +1,108 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const nodeTreeSource = readFileSync(new URL("../ExplainPlanNodeTree.vue", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../../../styles/globals.css", import.meta.url), "utf8");
type Rgb = [number, number, number];
type ThemeColors = {
selector: string;
background: Rgb;
foreground: Rgb;
popover: Rgb;
popoverForeground: Rgb;
};
function parseRgb(value: string): Rgb {
const channels = value
.match(/\d+(?:\.\d+)?/g)
?.slice(0, 3)
.map(Number);
if (!channels || channels.length !== 3) throw new Error(`Expected an opaque RGB color, received ${value}`);
return [channels[0], channels[1], channels[2]];
}
function tokenColor(block: string, token: string): Rgb | undefined {
const value = block.match(new RegExp(`--${token}:\\s*rgb\\(([^)]+)\\)`))?.[1];
return value ? parseRgb(value) : undefined;
}
const themeColors: ThemeColors[] = [...globalStyles.matchAll(/(?<selector>:root|\.dark|html\.theme-[\w-]+(?:\.dark)?)\s*\{(?<block>[^{}]*)\}/g)]
.map((match) => {
const block = match.groups?.block ?? "";
const background = tokenColor(block, "background");
const foreground = tokenColor(block, "foreground");
const popover = tokenColor(block, "popover");
const popoverForeground = tokenColor(block, "popover-foreground");
if (!background || !foreground || !popover || !popoverForeground) return undefined;
return { selector: match.groups?.selector ?? "", background, foreground, popover, popoverForeground };
})
.filter((theme): theme is ThemeColors => !!theme);
function linearRgbChannel(channel: number): number {
const normalized = channel / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
}
function luminance([red, green, blue]: Rgb): number {
return 0.2126 * linearRgbChannel(red) + 0.7152 * linearRgbChannel(green) + 0.0722 * linearRgbChannel(blue);
}
function contrastRatio(first: Rgb, second: Rgb): number {
const [lighter, darker] = [luminance(first), luminance(second)].sort((left, right) => right - left);
return (lighter + 0.05) / (darker + 0.05);
}
function blend(foreground: Rgb, background: Rgb, opacity: number): Rgb {
return foreground.map((channel, index) => Math.round(channel * opacity + background[index] * (1 - opacity))) as Rgb;
}
describe("ExplainPlanNodeTree interactions", () => {
it("keeps a concise preview of the most useful details in the tree row", () => {
expect(nodeTreeSource).toContain('const detailPreviewEntries = computed(() => detailEntries.value.filter((detail) => detail.label !== "Actual Rows").slice(0, 2))');
expect(nodeTreeSource).toContain('<div v-if="detailPreviewEntries.length"');
expect(nodeTreeSource).toContain('v-for="(detail, index) in detailPreviewEntries"');
expect(nodeTreeSource).toContain('class="min-w-0 flex-1 truncate"');
expect(nodeTreeSource).not.toContain("text-muted-foreground/40");
});
it("shows long details in a click-triggered, selectable popover", () => {
expect(nodeTreeSource).toContain('import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"');
expect(nodeTreeSource).toContain('<Popover v-if="detailEntries.length">');
expect(nodeTreeSource).toContain("<PopoverTrigger as-child>");
expect(nodeTreeSource).toContain('<PopoverContent align="end"');
expect(nodeTreeSource).toContain('data-native-clipboard class="max-h-');
expect(nodeTreeSource).toContain("select-text whitespace-pre-wrap break-words");
expect(nodeTreeSource).not.toContain("node.details.join");
expect(nodeTreeSource).not.toContain("{{ detailEntries.length }}");
expect(nodeTreeSource.match(/\{\{ t\("explain\.details"\) \}\}/g)).toHaveLength(1);
});
it("keeps detail fields distinct and tree disclosure on its own button", () => {
expect(nodeTreeSource).toContain('const separatorIndex = detail.indexOf(":")');
expect(nodeTreeSource).toContain('v-if="detail.label"');
expect(nodeTreeSource).toContain('<Button v-if="hasChildren"');
expect(nodeTreeSource).toContain(':aria-expanded="!collapsed"');
expect(nodeTreeSource).toContain('@click="toggleCollapsed"');
expect(nodeTreeSource).not.toContain("cursor-pointer");
expect(nodeTreeSource).not.toContain('@click="toggle"');
});
it("uses semantic foreground tokens instead of fixed status hues", () => {
expect(nodeTreeSource).toContain("text-foreground/80");
expect(nodeTreeSource).toContain("text-popover-foreground");
expect(nodeTreeSource).not.toMatch(/text-(?:blue|emerald|amber|green)-/);
expect(nodeTreeSource).not.toMatch(/border-(?:blue|emerald|amber|green)-/);
});
it("keeps foreground-based tree metadata readable in every application palette", () => {
expect(themeColors).toHaveLength(26);
for (const theme of themeColors) {
expect(contrastRatio(blend(theme.foreground, theme.background, 0.8), theme.background), theme.selector).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(theme.popoverForeground, theme.popover), theme.selector).toBeGreaterThanOrEqual(4.5);
}
});
});