feat: add controlled workbench sidebar resize

Merge PR #326: controlled left workbench resize with layout smoke coverage.
This commit is contained in:
Lyon
2026-05-23 18:26:23 +08:00
committed by GitHub
7 changed files with 638 additions and 3 deletions
+333 -2
View File
@@ -1019,6 +1019,27 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
summary: "Mobile drawer expansion makes resource controls reachable and can collapse back through the same accessible button.",
observations: mobile.drawer
},
{
id: "layout-explorer-resize-desktop",
status: desktop.resize?.pass ? "pass" : "blocked",
viewport: desktop.resize?.viewport ?? desktop.expanded.viewport,
summary: "Desktop left resource tree resize supports pointer drag, keyboard bounds, clamping, localStorage v1 restore, and collapse/expand width preservation.",
observations: desktop.resize
},
{
id: "layout-explorer-resize-narrow-desktop",
status: narrowDesktop.resize?.pass ? "pass" : "blocked",
viewport: narrowDesktop.resize?.viewport ?? narrowDesktop.expanded.viewport,
summary: "Narrow desktop left resource tree resize clamps against the stacked layout so M3 controls and Code Agent input remain reachable.",
observations: narrowDesktop.resize
},
{
id: "layout-explorer-resize-mobile",
status: mobile.resize?.pass ? "pass" : "blocked",
viewport: mobile.resize?.viewport ?? mobile.collapsed.viewport,
summary: "Mobile layout disables the drag handle as a hit target and keeps the drawer/collapse flow reachable without horizontal overflow.",
observations: mobile.resize
},
{
id: "layout-gate-desktop",
status: desktop.gate.currentRoute.pass ? "pass" : "blocked",
@@ -2086,12 +2107,32 @@ function hasSidebarCollapseContract({ html, app, styles }) {
/aria-label=["']收起左侧资源树["']/u.test(html) &&
/title=["']收起左侧资源树,给工作区更多宽度["']/u.test(html) &&
/>收起资源树<\/button>/u.test(html) &&
/id=["']explorer-resize["']/u.test(html) &&
/role=["']separator["']/u.test(html) &&
/aria-label=["']拖拽调整左侧资源树宽度["']/u.test(html) &&
/aria-valuemin=["']220["']/u.test(html) &&
/aria-valuemax=["']420["']/u.test(html) &&
/aria-valuenow=["']292["']/u.test(html) &&
/LAYOUT_STORAGE_KEY\s*=\s*["']hwlab\.workbench\.layout\.v1["']/u.test(app) &&
/function\s+initExplorerResize\s*\(/u.test(app) &&
/function\s+clampExplorerWidth\s*\(/u.test(app) &&
/setPointerCapture/u.test(app) &&
/localStorage\?\.setItem\(\s*LAYOUT_STORAGE_KEY/u.test(app) &&
/event\.key === "Home"/u.test(app) &&
/event\.key === "End"/u.test(app) &&
/ArrowLeft/u.test(app) &&
/ArrowRight/u.test(app) &&
/setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/u.test(setCollapsedBody) &&
/textContent = collapsed \? "展开资源树" : "收起资源树"/u.test(setCollapsedBody) &&
/title = collapsed \? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度"/u.test(setCollapsedBody) &&
/--explorer-width:\s*292px/u.test(styles) &&
/--explorer-min-width:\s*220px/u.test(styles) &&
/--explorer-max-width:\s*420px/u.test(styles) &&
/--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/u.test(styles) &&
/--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/u.test(styles) &&
/\.explorer-resize\s*\{[^}]*cursor:\s*col-resize;/su.test(styles) &&
/\.explorer-collapsed\s+\.explorer-resize\s*\{[^}]*display:\s*none;/su.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.explorer-resize\s*\{[\s\S]*?display:\s*none;/u.test(styles) &&
/\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) &&
/\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles)
);
@@ -4861,8 +4902,9 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
keyTargetsReachable: recollapsed.keyTargetsReachable
};
drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable;
const resize = await inspectExplorerResizeMobile(page, { viewport });
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { collapsed, drawer, gate, artifacts };
return { collapsed, drawer, resize, gate, artifacts };
}
const expanded = await inspectLayoutState(page, {
@@ -4886,8 +4928,9 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
viewport,
compareTo: expanded
});
const resize = await inspectExplorerResizeDesktop(page, { viewport });
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { expanded, collapsed, restored, gate, artifacts };
return { expanded, collapsed, restored, resize, gate, artifacts };
} finally {
await page.close();
}
@@ -4913,6 +4956,292 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {})
}
}
async function inspectExplorerResizeDesktop(page, { viewport }) {
const before = await page.evaluate(() => {
const shell = document.querySelector("[data-app-shell]");
const explorer = document.querySelector("#resource-explorer");
const center = document.querySelector(".center-workspace");
const right = document.querySelector(".right-sidebar");
const handle = document.querySelector("#explorer-resize");
return {
explorerWidth: explorer?.getBoundingClientRect().width ?? 0,
centerWidth: center?.getBoundingClientRect().width ?? 0,
rightWidth: right?.getBoundingClientRect().width ?? 0,
cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--explorer-width")),
storage: localStorage.getItem("hwlab.workbench.layout.v1"),
handle: {
ariaLabel: handle?.getAttribute("aria-label") ?? "",
title: handle?.getAttribute("title") ?? "",
role: handle?.getAttribute("role") ?? "",
tabIndex: handle?.tabIndex ?? null,
valueMin: handle?.getAttribute("aria-valuemin") ?? "",
valueMax: handle?.getAttribute("aria-valuemax") ?? "",
valueNow: handle?.getAttribute("aria-valuenow") ?? "",
valueText: handle?.getAttribute("aria-valuetext") ?? ""
}
};
});
const min = Number(before.handle.valueMin);
const max = Number(before.handle.valueMax);
const growTarget = Math.min(max, before.explorerWidth + 72);
const shrinkTarget = Math.max(min, before.explorerWidth - 48);
const dragTarget = growTarget - before.explorerWidth >= 32 ? growTarget : shrinkTarget;
await dragExplorerResizeHandle(page, dragTarget);
const afterDrag = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("ArrowLeft");
const afterArrowLeft = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("End");
const afterEnd = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("Home");
const afterHome = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("ArrowRight");
const afterArrowRight = await explorerResizeMetrics(page);
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const collapsed = await explorerResizeMetrics(page);
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const restored = await explorerResizeMetrics(page);
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 });
await page.locator("#explorer-toggle").waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
const restoredFromStorage = await explorerResizeMetrics(page);
const expectedArrow = Math.min(afterHome.bounds.max, afterHome.bounds.min + 16);
const pass =
before.handle.role === "separator" &&
before.handle.ariaLabel === "拖拽调整左侧资源树宽度" &&
before.handle.title.includes("左右方向键") &&
before.handle.tabIndex === 0 &&
Number.isFinite(min) &&
Number.isFinite(max) &&
max >= min &&
Math.abs(afterDrag.explorerWidth - before.explorerWidth) >= 24 &&
afterDrag.explorerWidth >= min &&
afterDrag.explorerWidth <= max &&
afterArrowLeft.explorerWidth <= afterDrag.explorerWidth - 12 &&
Math.abs(afterEnd.explorerWidth - afterEnd.bounds.max) <= 2 &&
Math.abs(afterHome.explorerWidth - afterHome.bounds.min) <= 2 &&
Math.abs(afterArrowRight.explorerWidth - expectedArrow) <= 2 &&
collapsed.explorerCollapsed === true &&
collapsed.explorerWidth <= 1 &&
collapsed.handleTabIndex === -1 &&
Math.abs(restored.explorerWidth - afterArrowRight.explorerWidth) <= 2 &&
restored.storage?.version === 1 &&
restored.storage?.explorerWidth === afterArrowRight.explorerWidth &&
Math.abs(restoredFromStorage.explorerWidth - afterArrowRight.explorerWidth) <= 2 &&
restoredFromStorage.keyTargetsReachable &&
restoredFromStorage.rootScrollLocked &&
restoredFromStorage.noHorizontalOverflow;
return {
viewport,
pass,
targetWidth: dragTarget,
before,
afterDrag,
afterArrowLeft,
afterEnd,
afterHome,
afterArrowRight,
collapsed,
restored,
restoredFromStorage
};
}
async function dragExplorerResizeHandle(page, targetWidth) {
const start = await page.evaluate(() => {
const explorer = document.querySelector("#resource-explorer");
const handle = document.querySelector("#explorer-resize");
const explorerBox = explorer.getBoundingClientRect();
const handleBox = handle.getBoundingClientRect();
return {
startX: handleBox.left + handleBox.width / 2,
startY: handleBox.top + Math.min(80, handleBox.height / 2),
currentWidth: explorerBox.width
};
});
const targetX = start.startX + targetWidth - start.currentWidth;
await page.mouse.move(start.startX, start.startY);
await page.mouse.down();
await page.mouse.move(targetX, start.startY, { steps: 8 });
await page.mouse.up();
}
async function explorerResizeMetrics(page) {
return page.evaluate(async () => {
const shell = document.querySelector("[data-app-shell]");
const explorer = document.querySelector("#resource-explorer");
const center = document.querySelector(".center-workspace");
const right = document.querySelector(".right-sidebar");
const handle = document.querySelector("#explorer-resize");
const ownsHit = (element, hit) => hit === element || element.contains(hit);
const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none";
const nearestScrollableAncestor = (element) => {
let current = element.parentElement;
while (current && current !== document.documentElement) {
const style = getComputedStyle(current);
const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY);
if (scrollable) return current;
current = current.parentElement;
}
return null;
};
const scrollIntoNearestContainer = async (element) => {
const container = nearestScrollableAncestor(element);
if (!container) {
element.scrollIntoView({ block: "center", inline: "nearest" });
await new Promise((resolve) => requestAnimationFrame(resolve));
return null;
}
const elementBox = element.getBoundingClientRect();
const containerBox = container.getBoundingClientRect();
const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2));
container.scrollTop += elementBox.top - containerBox.top - inset;
await new Promise((resolve) => requestAnimationFrame(resolve));
return container;
};
const clipBoxForElement = (element) => {
let visible = {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight
};
let current = element.parentElement;
while (current && current !== document.documentElement) {
const style = getComputedStyle(current);
if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) {
const box = current.getBoundingClientRect();
visible = {
left: Math.max(visible.left, box.left),
top: Math.max(visible.top, box.top),
right: Math.min(visible.right, box.right),
bottom: Math.min(visible.bottom, box.bottom)
};
}
current = current.parentElement;
}
return visible;
};
const targetSelectors = [
"#command-input",
"#command-send",
"#m3-write-do",
"#m3-read-di",
"[data-hardware-tab='overview']",
"[data-hardware-tab='box1']",
"[data-hardware-tab='box2']",
"[data-hardware-tab='patch']"
];
const keyTargets = [];
for (const selector of targetSelectors) {
const element = document.querySelector(selector);
if (!element) {
keyTargets.push({ selector, ok: false, missing: true });
continue;
}
const scrollContainer = await scrollIntoNearestContainer(element);
const box = element.getBoundingClientRect();
const clipBox = clipBoxForElement(element);
const visibleBox = {
left: Math.max(box.left, clipBox.left),
top: Math.max(box.top, clipBox.top),
right: Math.min(box.right, clipBox.right),
bottom: Math.min(box.bottom, clipBox.bottom)
};
const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left);
const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top);
const x = visibleBox.left + visibleWidth / 2;
const y = visibleBox.top + visibleHeight / 2;
const stack = document.elementsFromPoint(x, y);
keyTargets.push({
selector,
ok:
box.width > 0 &&
box.height > 0 &&
visibleWidth > 0 &&
visibleHeight > 0 &&
x >= 0 &&
x <= window.innerWidth &&
y >= 0 &&
y <= window.innerHeight &&
stack.some((candidate) => ownsHit(element, candidate)),
hit: hitLabel(stack[0] ?? null),
center: { x, y },
visibleBox,
scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null
});
}
let storage = null;
try {
storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null");
} catch {
storage = "parse_failed";
}
return {
explorerCollapsed: shell?.classList.contains("explorer-collapsed") === true,
explorerWidth: explorer?.getBoundingClientRect().width ?? 0,
centerWidth: center?.getBoundingClientRect().width ?? 0,
rightWidth: right?.getBoundingClientRect().width ?? 0,
cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--explorer-width")),
bounds: {
min: Number(handle?.getAttribute("aria-valuemin")),
max: Number(handle?.getAttribute("aria-valuemax"))
},
handleTabIndex: handle?.tabIndex ?? null,
handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "",
handleAriaValueNow: Number(handle?.getAttribute("aria-valuenow")),
handleAriaValueText: handle?.getAttribute("aria-valuetext") ?? "",
storage,
keyTargets,
keyTargetsReachable: keyTargets.every((target) => target.ok),
noHorizontalOverflow:
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2 &&
(!right || right.scrollWidth <= right.clientWidth + 2),
rootScrollLocked:
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2
};
});
}
async function inspectExplorerResizeMobile(page, { viewport }) {
const observation = await page.evaluate(() => {
const handle = document.querySelector("#explorer-resize");
const box = handle?.getBoundingClientRect();
const style = handle ? getComputedStyle(handle) : null;
const shell = document.querySelector("[data-app-shell]");
const right = document.querySelector(".right-sidebar");
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
explorerCollapsed: shell?.classList.contains("explorer-collapsed") === true,
handlePresent: Boolean(handle),
handleDisplay: style?.display ?? "missing",
handleWidth: box?.width ?? 0,
handleTabIndex: handle?.tabIndex ?? null,
handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "",
noHorizontalOverflow:
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2 &&
(!right || right.scrollWidth <= right.clientWidth + 2)
};
});
return {
viewport,
pass:
observation.explorerCollapsed === true &&
observation.handlePresent &&
observation.handleDisplay === "none" &&
observation.handleTabIndex === -1 &&
observation.handleAriaDisabled === "true" &&
observation.noHorizontalOverflow,
...observation
};
}
async function inspectLayoutState(page, { mode, viewport, compareTo }) {
return page.evaluate(async ({ mode, viewport, compareTo }) => {
const failures = [];
@@ -5173,6 +5502,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
for (const [selector, label] of selectorLabels) {
keyTargets.push(await inspectHitTarget(selector, label));
}
const resizeHandle = await inspectHitTarget("#explorer-resize", "左侧资源树宽度调整手柄");
const drawerTargets = [];
for (const [selector, label] of drawerSelectorLabels) {
drawerTargets.push(await inspectFirstHit(selector, label));
@@ -5554,6 +5884,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
keyTargetsReachable,
keyTargetsRequirement,
blockedKeyTargets,
resizeHandle,
drawerReachable,
blockedDrawerTargets,
sidePanelScroll,