From 6a5edd893a1c9fd8fbdd363dbd338a05d50469d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 23 Sep 2026 11:00:36 -0400 Subject: [PATCH] fix(studio): reveal the shown tab only when its strip narrows A strip the user scrolled by hand no longer snaps back when another panel changes. The load smoke measures the focused group's strip directly. --- .../src/components/dock/DockTabs.test.tsx | 17 ++++++++++++++++- .../studio/src/components/dock/dockTabFill.ts | 18 ++++++++++++++---- scripts/studio-runtime-smoke.mjs | 11 ++++++----- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/components/dock/DockTabs.test.tsx b/packages/studio/src/components/dock/DockTabs.test.tsx index c552e6922e..db0763f3eb 100644 --- a/packages/studio/src/components/dock/DockTabs.test.tsx +++ b/packages/studio/src/components/dock/DockTabs.test.tsx @@ -177,10 +177,25 @@ describe("dock tab fill", () => { const strip = stripOf("design"); if (!strip) throw new Error("no design strip"); await activate("variables"); - // The four tabs fit, so the strip is unscrolled. + // The four tabs fit, so the strip is unscrolled; then it narrows to two and a half tabs. strip.scrollLeft = 0; + Object.defineProperty(strip, "clientWidth", { value: 1000, configurable: true }); + act(() => resizeAll()); Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true }); act(() => resizeAll()); expect(strip.scrollLeft).toBe(3 * TAB_STEP + TAB_WIDTH + 1 - 250); }); + + it("leaves a strip the user scrolled alone when something else in the dock changes", async () => { + const strip = stripOf("design"); + if (!strip) throw new Error("no design strip"); + await activate("variables"); + Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true }); + act(() => resizeAll()); + // Scrolled back by hand to look at the first tabs, then another group takes focus. + strip.scrollLeft = 0; + await activate("assets"); + act(() => resizeAll()); + expect(strip.scrollLeft).toBe(0); + }); }); diff --git a/packages/studio/src/components/dock/dockTabFill.ts b/packages/studio/src/components/dock/dockTabFill.ts index 7b6c3d5721..11424f9548 100644 --- a/packages/studio/src/components/dock/dockTabFill.ts +++ b/packages/studio/src/components/dock/dockTabFill.ts @@ -22,13 +22,16 @@ function revealedScroll(box: { left: number; width: number }, scrollLeft: number return Math.max(scrollLeft, box.left + box.width + 1 - width); } -/** Reads where the shown tab sits; the write happens after every strip is read, so layout runs once. */ -function measureFill(list: HTMLElement): () => void { +/** + * Reads where the shown tab sits; the write happens after every strip is read, so layout runs once. + * Only a strip that got narrower is scrolled, so a strip the user scrolled by hand stays put. + */ +function measureFill(list: HTMLElement, narrowed: boolean): () => void { const fill = fillOf(list); const tab = list.querySelector(":scope > .dv-active-tab"); const box = tab ? { width: tab.offsetWidth, left: tab.offsetLeft } : null; const scrollLeft = list.scrollLeft; - const target = box ? revealedScroll(box, scrollLeft, list.clientWidth) : scrollLeft; + const target = box && narrowed ? revealedScroll(box, scrollLeft, list.clientWidth) : scrollLeft; return () => { fill.hidden = !box; if (!box) return; @@ -70,9 +73,16 @@ export function installTabFill(api: DockviewApi, root: HTMLElement): () => void observed.add(element); } } + const widths = new WeakMap(); + function narrowed(list: HTMLElement) { + const width = list.clientWidth; + const before = widths.get(list) ?? width; + widths.set(list, width); + return width < before; + } function placeAll() { const strips = [...lists()]; - const writes = strips.map(measureFill); + const writes = strips.map((list) => measureFill(list, narrowed(list))); for (const write of writes) write(); for (const list of strips) markClippedEdges(list); observe(strips.flatMap((list) => [list, ...list.querySelectorAll(":scope > .dv-tab")])); diff --git a/scripts/studio-runtime-smoke.mjs b/scripts/studio-runtime-smoke.mjs index cc524702bf..dc56bf532d 100644 --- a/scripts/studio-runtime-smoke.mjs +++ b/scripts/studio-runtime-smoke.mjs @@ -101,9 +101,10 @@ export function studioSmokeApiResponse(method, requestUrl) { // about 10px narrower than macOS does; keep that much room so a strip that fits here fits on a Mac. const MAC_FONT_ALLOWANCE_PX = 10; -/** Runs in the page: the strip's tabs, plus the allowance, must fit before its action slot. */ -function clippedStrip(index, allowance) { - const strip = document.querySelectorAll(".dv-tabs-container")[index]; +/** Runs in the page: the active group's tabs, plus the allowance, must fit before its actions. */ +function clippedStrip(allowance) { + const strip = document.querySelector(".dv-groupview.dv-active-group .dv-tabs-container"); + if (!strip) return "no active dock strip"; const actions = strip .closest(".dv-tabs-and-actions-container") ?.querySelector(".dv-right-actions-container"); @@ -125,13 +126,13 @@ async function dockStripErrors(page) { found.push(`${reservedSlots} inactive dock strips hold width for actions they do not draw`); } const shownTabs = await page.$$(".dv-tabs-container .dv-active-tab"); - for (const [index, tab] of shownTabs.entries()) { + for (const tab of shownTabs) { // An active group draws its strip actions, so this is the least room its tabs get. await tab.click(); await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), ); - const clipped = await page.evaluate(clippedStrip, index, MAC_FONT_ALLOWANCE_PX); + const clipped = await page.evaluate(clippedStrip, MAC_FONT_ALLOWANCE_PX); if (clipped) { found.push( `Dock tab strip clips a label at the default layout (${MAC_FONT_ALLOWANCE_PX}px macOS allowance): ${clipped}`,