fix(editor): stop toolbar flicker at resize threshold width

This commit is contained in:
zipg authored and GitHub committed 2026-09-15 17:00:04 +08:00
1 parent e255c49e7d
commit 256917d8cf
3 files changed
+57 -21

No files matched your search

@@ -84,39 +84,56 @@ const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, catalogOpt
const { loadSchemaOptions, getSchemaOptionsForDb, isLoadingSchemas, isSchemaAware } = useSchemaOptions();
const toolbarRootRef = ref<HTMLElement | null>(null);
const toolbarActionsRef = ref<HTMLElement | null>(null);
const toolbarTier = ref<EditorToolbarTier>(0);
// Available width when the current tier was condensed into; anchors the
// step-down hysteresis so a static narrow layout cannot oscillate.
const condensedAtWidth = ref(0);
const expandedTierRequiredWidths: Partial<Record<EditorToolbarTier, number>> = {};
let toolbarResizeObserver: ResizeObserver | undefined;
let toolbarMeasureRaf = 0;
function measureToolbarTier() {
const element = toolbarRootRef.value;
if (!element) {
const element = toolbarActionsRef.value;
const root = toolbarRootRef.value;
if (!element || !root) {
return;
}
const availableWidth = root.clientWidth;
// Use the stable full-row coordinate space, including the right controls.
// Tier-dependent helpers must not appear to grow the pane when they hide.
const contentWidth = element.scrollWidth + availableWidth - element.clientWidth;
const next = resolveNextEditorToolbarTier({
tier: toolbarTier.value,
availableWidth: element.clientWidth,
contentWidth: element.scrollWidth,
availableWidth,
contentWidth,
condensedAtWidth: condensedAtWidth.value,
expandedTierRequiredWidths,
});
if (next !== toolbarTier.value) {
if (next > toolbarTier.value) {
const currentTier = toolbarTier.value;
expandedTierRequiredWidths[currentTier] = Math.max(expandedTierRequiredWidths[currentTier] ?? 0, element.scrollWidth);
condensedAtWidth.value = element.clientWidth;
expandedTierRequiredWidths[currentTier] = Math.max(expandedTierRequiredWidths[currentTier] ?? 0, contentWidth);
condensedAtWidth.value = availableWidth;
}
toolbarTier.value = next;
}
}
function scheduleToolbarMeasurement() {
if (toolbarMeasureRaf) {
return;
}
toolbarMeasureRaf = requestAnimationFrame(() => {
toolbarMeasureRaf = 0;
measureToolbarTier();
});
}
// Hiding or restoring controls changes the row content without resizing the
// toolbar box, so every tier change re-measures until the row settles.
watch(toolbarTier, () => {
void nextTick(measureToolbarTier);
void nextTick(scheduleToolbarMeasurement);
});
// The visible control set also changes with connection type and transaction
@@ -124,7 +141,7 @@ watch(toolbarTier, () => {
watch(
() => [props.activeConnection?.id, props.activeConnection?.db_type, props.txnSessionId, props.activeTab.isExecuting, props.activeTab.isExplaining] as const,
() => {
void nextTick(measureToolbarTier);
void nextTick(scheduleToolbarMeasurement);
},
);
@@ -134,15 +151,19 @@ watch(
toolbarResizeObserver?.disconnect();
toolbarResizeObserver = undefined;
if (element && typeof ResizeObserver !== "undefined") {
toolbarResizeObserver = new ResizeObserver(measureToolbarTier);
toolbarResizeObserver = new ResizeObserver(scheduleToolbarMeasurement);
toolbarResizeObserver.observe(element);
}
void nextTick(measureToolbarTier);
void nextTick(scheduleToolbarMeasurement);
},
{ flush: "post" },
);
onUnmounted(() => {
if (toolbarMeasureRaf) {
cancelAnimationFrame(toolbarMeasureRaf);
toolbarMeasureRaf = 0;
}
toolbarResizeObserver?.disconnect();
toolbarResizeObserver = undefined;
});
@@ -153,7 +174,10 @@ const activeCatalogs = computed(() => {
});
const activeCatalogNames = computed(() => activeCatalogs.value.map((catalog) => catalog.name));
const catalogSelectorAvailable = computed(() => connectionIsDorisFamilyCatalogCapable(props.activeConnection) && queryCatalogSelectorVisible(activeCatalogs.value));
const showCatalogSelector = computed(() => catalogSelectorAvailable.value && toolbarTier.value < 3);
// Keep connection context controls mounted while the action group condenses.
// Removing them changes the action group's available width and can make the
// tier immediately expand again at the boundary, causing visible flicker.
const showCatalogSelector = computed(() => catalogSelectorAvailable.value);
const activeCatalogValue = computed(() => selectedQueryCatalogName(activeCatalogs.value, props.activeTab.catalog));
const activeCatalogDatabaseKey = computed(() => (props.activeConnection && props.activeTab.catalog ? catalogDatabaseOptionsKey(props.activeConnection.id, props.activeTab.catalog) : ""));
const activeDatabaseOptions = computed(() => {
@@ -277,7 +301,7 @@ const schemaSelectorAvailable = computed(() => {
const connection = props.activeConnection;
return connection && isSchemaAware(connection.id) && (props.activeTab.database || isSingleDb.value || hasDefaultDatabaseOption.value);
});
const showSchemaSelector = computed(() => schemaSelectorAvailable.value && toolbarTier.value < 3);
const showSchemaSelector = computed(() => schemaSelectorAvailable.value);
const activeSchemaOptions = computed(() => {
const connection = props.activeConnection;
@@ -389,8 +413,8 @@ async function changeCatalog(selectedCatalog: string) {
</script>
<template>
<div ref="toolbarRootRef" class="app-editor-toolbar h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10 overflow-hidden" :style="toolbarStyle">
<div class="flex items-center gap-0.5">
<div ref="toolbarRootRef" class="app-editor-toolbar h-9 min-w-0 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10 overflow-hidden" :style="toolbarStyle">
<div ref="toolbarActionsRef" class="min-w-0 flex flex-1 items-center gap-0.5 overflow-hidden">
<Tooltip>
<TooltipTrigger as-child>
<Button
@@ -677,9 +701,8 @@ async function changeCatalog(selectedCatalog: string) {
</Tooltip>
</div>
</div>
<span class="flex-1 min-w-0" />
<div class="flex min-w-0 items-center gap-2">
<div class="flex min-w-0 items-center gap-1">
<div class="flex shrink-0 items-center gap-2">
<div class="flex shrink-0 items-center gap-1">
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
<ConnectionTreeSelect
:model-value="activeConnectionValue"
@@ -703,7 +726,7 @@ async function changeCatalog(selectedCatalog: string) {
</template>
</ConnectionTreeSelect>
</div>
<div v-if="showCatalogSelector" class="flex min-w-0 items-center gap-1">
<div v-if="showCatalogSelector" class="flex shrink-0 items-center gap-1">
<SearchableSelect
:model-value="activeCatalogValue"
:options="activeCatalogNames"
@@ -741,7 +764,7 @@ async function changeCatalog(selectedCatalog: string) {
activeConnection?.db_type !== 'consul' &&
!isSingleDb
"
class="flex items-center gap-1"
class="flex shrink-0 items-center gap-1"
:class="{ 'database-required-prompt': databaseRequiredVisible }"
>
<SearchableSelect
@@ -790,7 +813,7 @@ async function changeCatalog(selectedCatalog: string) {
{{ isActiveDatabaseDefault ? t("editor.defaultDatabase") : t("editor.setDefaultDatabase") }}
</Button>
</div>
<div v-if="showSchemaSelector" class="flex min-w-0 items-center gap-1">
<div v-if="showSchemaSelector" class="flex shrink-0 items-center gap-1">
<SearchableSelect
:model-value="activeSchemaValue"
:options="activeSchemaOptions.length ? activeSchemaOptions : activeSchemaValue ? [activeSchemaValue] : []"
@@ -56,6 +56,18 @@ describe("editor toolbar measured condensation", () => {
).toBe(2);
});
it("does not restore a measured tier before the pane has grown past the hysteresis gap", () => {
expect(
resolveNextEditorToolbarTier({
tier: 2,
availableWidth: 460,
contentWidth: 460,
condensedAtWidth: 420,
expandedTierRequiredWidths: { 1: 400 },
}),
).toBe(2);
});
it("never oscillates: slack alone without pane growth keeps the condensed tier", () => {
// The row fits with plenty of slack, but the pane barely grew since the
// toolbar condensed at this width — stepping down would overflow again.
@@ -56,9 +56,10 @@ export function resolveNextEditorToolbarTier(input: EditorToolbarTierInput): Edi
return (tier + 1) as EditorToolbarTier;
}
if (tier > 0 && expandedTierRequiredWidths) {
const grewSinceCondensation = condensedAtWidth <= 0 || availableWidth - condensedAtWidth >= EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX;
for (let candidate = 0; candidate < tier; candidate += 1) {
const requiredWidth = expandedTierRequiredWidths[candidate as EditorToolbarTier];
if (requiredWidth && availableWidth >= requiredWidth + EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX) {
if (grewSinceCondensation && requiredWidth && availableWidth >= requiredWidth + EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX) {
return candidate as EditorToolbarTier;
}
}