fix: harden workbench sidebar resizing

Merge #348: add right hardware sidebar resize state and expand source/build Playwright layout coverage for #278 without changing M3 control paths or claiming DEV-LIVE hardware acceptance.
This commit is contained in:
Lyon
2026-05-23 22:08:36 +08:00
committed by GitHub
parent c65fc85c59
commit ab4e41374c
5 changed files with 537 additions and 15 deletions
+350 -4
View File
@@ -1054,6 +1054,27 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
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-right-sidebar-resize-desktop",
status: desktop.rightResize?.pass ? "pass" : "blocked",
viewport: desktop.rightResize?.viewport ?? desktop.expanded.viewport,
summary: "Desktop right hardware sidebar resize supports pointer drag, keyboard bounds, localStorage v1 restore, and keeps Code Agent plus M3 controls reachable.",
observations: desktop.rightResize
},
{
id: "layout-right-sidebar-resize-narrow-desktop",
status: narrowDesktop.rightResize?.pass ? "pass" : "blocked",
viewport: narrowDesktop.rightResize?.viewport ?? narrowDesktop.expanded.viewport,
summary: "Narrow desktop disables the right-sidebar drag handle while the stacked M3 hardware area remains reachable without outer page scroll.",
observations: narrowDesktop.rightResize
},
{
id: "layout-right-sidebar-resize-mobile",
status: mobile.rightResize?.pass ? "pass" : "blocked",
viewport: mobile.rightResize?.viewport ?? mobile.collapsed.viewport,
summary: "Mobile disables the right-sidebar drag handle so it cannot cover M3 tabs, Code Agent input, or the hardware status area.",
observations: mobile.rightResize
},
{
id: "layout-gate-desktop",
status: desktop.gate.currentRoute.pass ? "pass" : "blocked",
@@ -2158,11 +2179,30 @@ function hasSidebarCollapseContract({ html, app, styles }) {
/--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) &&
/id=["']hardware-sidebar["']/u.test(html) &&
/id=["']right-sidebar-resize["']/u.test(html) &&
/aria-label=["']拖拽调整右侧硬件状态栏宽度["']/u.test(html) &&
/aria-valuemin=["']560["']/u.test(html) &&
/aria-valuemax=["']760["']/u.test(html) &&
/aria-valuenow=["']620["']/u.test(html) &&
/RIGHT_SIDEBAR_DEFAULT_WIDTH\s*=\s*620/u.test(app) &&
/RIGHT_SIDEBAR_MIN_WIDTH\s*=\s*560/u.test(app) &&
/RIGHT_SIDEBAR_MAX_WIDTH\s*=\s*760/u.test(app) &&
/function\s+initRightSidebarResize\s*\(/u.test(app) &&
/function\s+clampRightSidebarWidth\s*\(/u.test(app) &&
/function\s+rightSidebarResizeBounds\s*\(/u.test(app) &&
/setRightSidebarWidth\(drag\.startWidth \+ drag\.startX - event\.clientX/u.test(app) &&
/rightSidebarWidth:\s*state\.layout\.rightSidebarWidth/u.test(app) &&
/--right-sidebar-width:\s*620px/u.test(styles) &&
/--right-sidebar-min-width:\s*560px/u.test(styles) &&
/--right-sidebar-max-width:\s*760px/u.test(styles) &&
/--right-width:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/u.test(styles) &&
/--right-width-expanded:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/u.test(styles) &&
/\.explorer-resize\s*\{[^}]*cursor:\s*col-resize;/su.test(styles) &&
/\.right-sidebar-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) &&
/@media\s*\(max-width:\s*1240px\)[\s\S]*?\.right-sidebar-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)
);
@@ -4963,8 +5003,9 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
};
drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable;
const resize = await inspectExplorerResizeMobile(page, { viewport });
const rightResize = await inspectRightSidebarResizeDisabled(page, { viewport });
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { collapsed, drawer, resize, gate, artifacts };
return { collapsed, drawer, resize, rightResize, gate, artifacts };
}
const expanded = await inspectLayoutState(page, {
@@ -4989,8 +5030,11 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
compareTo: expanded
});
const resize = await inspectExplorerResizeDesktop(page, { viewport });
const rightResize = viewport.width > 1240
? await inspectRightSidebarResizeDesktop(page, { viewport })
: await inspectRightSidebarResizeDisabled(page, { viewport });
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { expanded, collapsed, restored, resize, gate, artifacts };
return { expanded, collapsed, restored, resize, rightResize, gate, artifacts };
} finally {
await page.close();
}
@@ -5305,6 +5349,306 @@ async function inspectExplorerResizeMobile(page, { viewport }) {
};
}
async function inspectRightSidebarResizeDesktop(page, { viewport }) {
const before = await page.evaluate(() => {
const shell = document.querySelector("[data-app-shell]");
const right = document.querySelector("#hardware-sidebar");
const center = document.querySelector(".center-workspace");
const handle = document.querySelector("#right-sidebar-resize");
return {
rightWidth: right?.getBoundingClientRect().width ?? 0,
centerWidth: center?.getBoundingClientRect().width ?? 0,
cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--right-sidebar-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.rightWidth + 56);
const shrinkTarget = Math.max(min, before.rightWidth - 48);
const dragTarget = growTarget - before.rightWidth >= 24 ? growTarget : shrinkTarget;
await dragRightSidebarResizeHandle(page, dragTarget);
const afterDrag = await rightSidebarResizeMetrics(page);
await page.locator("#right-sidebar-resize").press("ArrowRight");
const afterArrowRight = await rightSidebarResizeMetrics(page);
await page.locator("#right-sidebar-resize").press("End");
const afterEnd = await rightSidebarResizeMetrics(page);
await page.locator("#right-sidebar-resize").press("Home");
const afterHome = await rightSidebarResizeMetrics(page);
await page.locator("#right-sidebar-resize").press("ArrowLeft");
const afterArrowLeft = await rightSidebarResizeMetrics(page);
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const collapsedLeft = await rightSidebarResizeMetrics(page);
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const restoredLeft = await rightSidebarResizeMetrics(page);
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 });
await page.locator("#right-sidebar-resize").waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
const restoredFromStorage = await rightSidebarResizeMetrics(page);
const expectedArrowLeft = 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.rightWidth - before.rightWidth) >= 20 &&
afterDrag.rightWidth >= min &&
afterDrag.rightWidth <= max &&
afterArrowRight.rightWidth <= afterDrag.rightWidth - 12 &&
Math.abs(afterEnd.rightWidth - afterEnd.bounds.max) <= 2 &&
Math.abs(afterHome.rightWidth - afterHome.bounds.min) <= 2 &&
Math.abs(afterArrowLeft.rightWidth - expectedArrowLeft) <= 2 &&
collapsedLeft.leftExplorerCollapsed === true &&
collapsedLeft.handleTabIndex === 0 &&
collapsedLeft.keyTargetsReachable &&
Math.abs(restoredLeft.rightWidth - afterArrowLeft.rightWidth) <= 2 &&
restoredLeft.storage?.version === 1 &&
restoredLeft.storage?.rightSidebarWidth === afterArrowLeft.rightWidth &&
Math.abs(restoredFromStorage.rightWidth - afterArrowLeft.rightWidth) <= 2 &&
restoredFromStorage.keyTargetsReachable &&
restoredFromStorage.rootScrollLocked &&
restoredFromStorage.noHorizontalOverflow;
return {
viewport,
pass,
targetWidth: dragTarget,
before,
afterDrag,
afterArrowRight,
afterEnd,
afterHome,
afterArrowLeft,
collapsedLeft,
restoredLeft,
restoredFromStorage
};
}
async function dragRightSidebarResizeHandle(page, targetWidth) {
const start = await page.evaluate(() => {
const right = document.querySelector("#hardware-sidebar");
const handle = document.querySelector("#right-sidebar-resize");
const rightBox = right.getBoundingClientRect();
const handleBox = handle.getBoundingClientRect();
return {
startX: handleBox.left + handleBox.width / 2,
startY: handleBox.top + Math.min(80, handleBox.height / 2),
currentWidth: rightBox.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 rightSidebarResizeMetrics(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("#hardware-sidebar");
const handle = document.querySelector("#right-sidebar-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",
"#tab-control",
"#tab-wiring",
"[data-hardware-tab='overview']",
"[data-hardware-tab='gateways']",
"[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 {
leftExplorerCollapsed: 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("--right-sidebar-width")),
bounds: {
min: Number(handle?.getAttribute("aria-valuemin")),
max: Number(handle?.getAttribute("aria-valuemax"))
},
handleTabIndex: handle?.tabIndex ?? null,
handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "",
handleAriaHidden: handle?.getAttribute("aria-hidden") ?? "",
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 inspectRightSidebarResizeDisabled(page, { viewport }) {
const observation = await page.evaluate(() => {
const handle = document.querySelector("#right-sidebar-resize");
const box = handle?.getBoundingClientRect();
const style = handle ? getComputedStyle(handle) : null;
const right = document.querySelector("#hardware-sidebar");
const command = document.querySelector("#command-form");
const hardwareStatus = document.querySelector(".hardware-status");
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
handlePresent: Boolean(handle),
handleDisplay: style?.display ?? "missing",
handleWidth: box?.width ?? 0,
handleTabIndex: handle?.tabIndex ?? null,
handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "",
handleAriaHidden: handle?.getAttribute("aria-hidden") ?? "",
rightWidth: right?.getBoundingClientRect().width ?? 0,
commandVisible: Boolean(command && command.getBoundingClientRect().width > 0 && command.getBoundingClientRect().height > 0),
hardwareVisible: Boolean(hardwareStatus && hardwareStatus.getBoundingClientRect().width > 0 && hardwareStatus.getBoundingClientRect().height > 0),
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
};
});
return {
viewport,
pass:
observation.handlePresent &&
observation.handleDisplay === "none" &&
observation.handleTabIndex === -1 &&
observation.handleAriaDisabled === "true" &&
observation.handleAriaHidden === "true" &&
observation.commandVisible &&
observation.hardwareVisible &&
observation.noHorizontalOverflow &&
observation.rootScrollLocked,
...observation
};
}
async function inspectLayoutState(page, { mode, viewport, compareTo }) {
return page.evaluate(async ({ mode, viewport, compareTo }) => {
const failures = [];
@@ -5566,6 +5910,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
keyTargets.push(await inspectHitTarget(selector, label));
}
const resizeHandle = await inspectHitTarget("#explorer-resize", "左侧资源树宽度调整手柄");
const rightResizeHandle = await inspectHitTarget("#right-sidebar-resize", "右侧硬件状态栏宽度调整手柄");
const drawerTargets = [];
for (const [selector, label] of drawerSelectorLabels) {
drawerTargets.push(await inspectFirstHit(selector, label));
@@ -5984,6 +6329,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
keyTargetsRequirement,
blockedKeyTargets,
resizeHandle,
rightResizeHandle,
drawerReachable,
blockedDrawerTargets,
sidePanelScroll,
+116 -5
View File
@@ -68,6 +68,9 @@ const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1";
const EXPLORER_DEFAULT_WIDTH = 292;
const EXPLORER_MIN_WIDTH = 220;
const EXPLORER_MAX_WIDTH = 420;
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 620;
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
const RIGHT_SIDEBAR_MAX_WIDTH = 760;
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
let rpcSequence = 0;
@@ -76,6 +79,7 @@ const el = {
shell: query("[data-app-shell]"),
explorerToggle: byId("explorer-toggle"),
explorerResize: byId("explorer-resize"),
rightSidebarResize: byId("right-sidebar-resize"),
explorerStatus: byId("explorer-status"),
treeCount: byId("tree-count"),
resourceTree: byId("resource-tree"),
@@ -136,7 +140,9 @@ const state = {
},
layout: {
explorerWidth: EXPLORER_DEFAULT_WIDTH,
drag: null
rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
drag: null,
rightDrag: null
}
};
@@ -145,6 +151,7 @@ initLayoutSizing();
initExplorerToggle();
syncMobileExplorer();
initExplorerResize();
initRightSidebarResize();
initSideTabs();
initHardwareTabs();
initQuickActions();
@@ -191,8 +198,10 @@ function initRoutes() {
function initLayoutSizing() {
const persisted = readPersistedLayout();
const width = clampExplorerWidth(persisted?.explorerWidth ?? EXPLORER_DEFAULT_WIDTH);
setExplorerWidth(width, { persist: false });
const explorerWidth = clampExplorerWidth(persisted?.explorerWidth ?? EXPLORER_DEFAULT_WIDTH);
const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH);
setExplorerWidth(explorerWidth, { persist: false });
setRightSidebarWidth(rightSidebarWidth, { persist: false });
}
function readPersistedLayout() {
@@ -202,7 +211,8 @@ function readPersistedLayout() {
const payload = JSON.parse(raw);
if (payload?.version !== 1) return null;
return {
explorerWidth: Number(payload.explorerWidth)
explorerWidth: Number(payload.explorerWidth),
rightSidebarWidth: Number(payload.rightSidebarWidth)
};
} catch {
return null;
@@ -215,7 +225,8 @@ function persistLayout() {
LAYOUT_STORAGE_KEY,
JSON.stringify({
version: 1,
explorerWidth: state.layout.explorerWidth
explorerWidth: state.layout.explorerWidth,
rightSidebarWidth: state.layout.rightSidebarWidth
})
);
} catch {
@@ -228,6 +239,16 @@ function setExplorerWidth(width, options = {}) {
state.layout.explorerWidth = clamped;
el.shell.style.setProperty("--explorer-width", `${clamped}px`);
syncExplorerResizeA11y();
syncRightSidebarResizeA11y();
if (options.persist !== false) persistLayout();
return clamped;
}
function setRightSidebarWidth(width, options = {}) {
const clamped = clampRightSidebarWidth(width);
state.layout.rightSidebarWidth = clamped;
el.shell.style.setProperty("--right-sidebar-width", `${clamped}px`);
syncRightSidebarResizeA11y();
if (options.persist !== false) persistLayout();
return clamped;
}
@@ -239,6 +260,13 @@ function clampExplorerWidth(value) {
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
}
function clampRightSidebarWidth(value) {
const numeric = Number(value);
const width = Number.isFinite(numeric) ? numeric : RIGHT_SIDEBAR_DEFAULT_WIDTH;
const bounds = rightSidebarResizeBounds();
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
}
function explorerResizeBounds() {
const styles = getComputedStyle(el.shell);
const cssMin = readCssPixel(styles.getPropertyValue("--explorer-min-width"), EXPLORER_MIN_WIDTH);
@@ -263,6 +291,20 @@ function boundedResizeRange(min, max, dynamicMax) {
return { min: safeMin, max: safeMax };
}
function rightSidebarResizeBounds() {
const styles = getComputedStyle(el.shell);
const cssMin = readCssPixel(styles.getPropertyValue("--right-sidebar-min-width"), RIGHT_SIDEBAR_MIN_WIDTH);
const cssMax = readCssPixel(styles.getPropertyValue("--right-sidebar-max-width"), RIGHT_SIDEBAR_MAX_WIDTH);
if (window.matchMedia("(max-width: 1240px)").matches) {
return { min: 0, max: Math.max(0, window.innerWidth) };
}
const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 92);
const explorerWidth = el.shell.classList.contains("explorer-collapsed") ? 0 : state.layout.explorerWidth;
const centerMinWidth = el.shell.classList.contains("explorer-collapsed") ? 420 : 360;
const dynamicMax = window.innerWidth - railWidth - explorerWidth - centerMinWidth - 4;
return boundedResizeRange(cssMin, cssMax, dynamicMax);
}
function readCssPixel(value, fallback) {
const parsed = Number.parseFloat(String(value ?? "").trim());
return Number.isFinite(parsed) ? parsed : fallback;
@@ -276,6 +318,18 @@ function syncExplorerResizeA11y() {
el.explorerResize.setAttribute("aria-valuetext", `左侧资源树宽度 ${state.layout.explorerWidth} 像素`);
}
function syncRightSidebarResizeA11y() {
const bounds = rightSidebarResizeBounds();
const disabled = !canResizeRightSidebar();
el.rightSidebarResize.tabIndex = disabled ? -1 : 0;
el.rightSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false");
el.rightSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false");
el.rightSidebarResize.setAttribute("aria-valuemin", String(bounds.min));
el.rightSidebarResize.setAttribute("aria-valuemax", String(bounds.max));
el.rightSidebarResize.setAttribute("aria-valuenow", String(state.layout.rightSidebarWidth));
el.rightSidebarResize.setAttribute("aria-valuetext", `右侧硬件状态栏宽度 ${state.layout.rightSidebarWidth} 像素`);
}
function routeFromLocation() {
const hashRoute = window.location.hash.replace(/^#\/?/, "");
if (viewIds.has(hashRoute)) return hashRoute;
@@ -350,6 +404,50 @@ function initExplorerResize() {
});
}
function initRightSidebarResize() {
window.addEventListener("resize", () => {
if (canResizeRightSidebar()) setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false });
syncRightSidebarResizeA11y();
});
el.rightSidebarResize.addEventListener("pointerdown", (event) => {
if (!canResizeRightSidebar()) return;
event.preventDefault();
el.rightSidebarResize.setPointerCapture?.(event.pointerId);
state.layout.rightDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: state.layout.rightSidebarWidth
};
el.shell.classList.add("is-resizing");
});
el.rightSidebarResize.addEventListener("pointermove", (event) => {
const drag = state.layout.rightDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
setRightSidebarWidth(drag.startWidth + drag.startX - event.clientX, { persist: false });
});
el.rightSidebarResize.addEventListener("pointerup", finishRightSidebarResize);
el.rightSidebarResize.addEventListener("pointercancel", finishRightSidebarResize);
el.rightSidebarResize.addEventListener("keydown", (event) => {
if (!canResizeRightSidebar()) return;
const keySteps = {
ArrowLeft: 16,
ArrowRight: -16,
PageUp: 48,
PageDown: -48
};
if (Object.hasOwn(keySteps, event.key)) {
event.preventDefault();
setRightSidebarWidth(state.layout.rightSidebarWidth + keySteps[event.key]);
} else if (event.key === "Home") {
event.preventDefault();
setRightSidebarWidth(rightSidebarResizeBounds().min);
} else if (event.key === "End") {
event.preventDefault();
setRightSidebarWidth(rightSidebarResizeBounds().max);
}
});
}
function finishExplorerResize(event) {
const drag = state.layout.drag;
if (!drag || drag.pointerId !== event.pointerId) return;
@@ -358,10 +456,22 @@ function finishExplorerResize(event) {
persistLayout();
}
function finishRightSidebarResize(event) {
const drag = state.layout.rightDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.layout.rightDrag = null;
el.shell.classList.remove("is-resizing");
persistLayout();
}
function canResizeExplorer() {
return !window.matchMedia("(max-width: 860px)").matches && !el.shell.classList.contains("explorer-collapsed");
}
function canResizeRightSidebar() {
return !window.matchMedia("(max-width: 1240px)").matches;
}
function setExplorerCollapsed(collapsed) {
if (!collapsed) {
setExplorerWidth(state.layout.explorerWidth, { persist: false });
@@ -376,6 +486,7 @@ function setExplorerCollapsed(collapsed) {
el.explorerResize.tabIndex = resizeDisabled ? -1 : 0;
el.explorerResize.setAttribute("aria-disabled", resizeDisabled ? "true" : "false");
el.explorerResize.setAttribute("aria-hidden", resizeDisabled ? "true" : "false");
setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false });
}
function syncMobileExplorer() {
+14 -1
View File
@@ -176,7 +176,20 @@
</form>
</section>
<aside class="right-sidebar" aria-label="运行状态与可信记录">
<aside class="right-sidebar" id="hardware-sidebar" aria-label="运行状态与可信记录">
<div
class="resize-handle right-sidebar-resize"
id="right-sidebar-resize"
role="separator"
tabindex="0"
aria-label="拖拽调整右侧硬件状态栏宽度"
aria-controls="hardware-sidebar"
aria-orientation="vertical"
aria-valuemin="560"
aria-valuemax="760"
aria-valuenow="620"
title="拖拽调整右侧硬件状态栏宽度;用左右方向键微调,Home/End 到最小或最大。"
></div>
<section class="hardware-status">
<div class="panel-title-row">
<div>
+24 -2
View File
@@ -168,6 +168,12 @@ for (const layoutCheckId of [
"layout-narrow-desktop-restored",
"layout-mobile-collapsed",
"layout-mobile-drawer",
"layout-explorer-resize-desktop",
"layout-explorer-resize-narrow-desktop",
"layout-explorer-resize-mobile",
"layout-right-sidebar-resize-desktop",
"layout-right-sidebar-resize-narrow-desktop",
"layout-right-sidebar-resize-mobile",
"layout-gate-desktop",
"layout-gate-narrow-desktop",
"layout-gate-mobile"
@@ -304,6 +310,7 @@ for (const workbenchElement of [
"agent-chat-status",
"task-list",
"right-sidebar",
"right-sidebar-resize",
"command-form",
"command-send",
"hardware-list",
@@ -326,13 +333,23 @@ assert.match(html, /aria-label="拖拽调整左侧资源树宽度"/);
assert.match(html, /aria-valuemin="220"/);
assert.match(html, /aria-valuemax="420"/);
assert.match(html, /aria-valuenow="292"/);
assert.match(html, /id="hardware-sidebar"/);
assert.match(html, /id="right-sidebar-resize"/);
assert.match(html, /aria-label="拖拽调整右侧硬件状态栏宽度"/);
assert.match(html, /aria-valuemin="560"/);
assert.match(html, /aria-valuemax="760"/);
assert.match(html, /aria-valuenow="620"/);
assert.match(app, /setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/);
assert.match(app, /textContent = collapsed \? "展开资源树" : "收起资源树"/);
assert.match(app, /LAYOUT_STORAGE_KEY\s*=\s*"hwlab\.workbench\.layout\.v1"/);
assert.match(app, /function initExplorerResize/);
assert.match(app, /function clampExplorerWidth/);
assert.match(app, /function initRightSidebarResize/);
assert.match(app, /function clampRightSidebarWidth/);
assert.match(app, /function rightSidebarResizeBounds/);
assert.match(app, /setPointerCapture/);
assert.match(app, /localStorage\?\.setItem\(\s*LAYOUT_STORAGE_KEY/);
assert.match(app, /rightSidebarWidth:\s*state\.layout\.rightSidebarWidth/);
assert.match(app, /ArrowLeft/);
assert.match(app, /ArrowRight/);
assert.match(app, /event\.key === "Home"/);
@@ -340,11 +357,16 @@ assert.match(app, /event\.key === "End"/);
assert.match(styles, /--explorer-width:\s*292px/);
assert.match(styles, /--explorer-min-width:\s*220px/);
assert.match(styles, /--explorer-max-width:\s*420px/);
assert.match(styles, /--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/);
assert.match(styles, /--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/);
assert.match(styles, /--right-sidebar-width:\s*620px/);
assert.match(styles, /--right-sidebar-min-width:\s*560px/);
assert.match(styles, /--right-sidebar-max-width:\s*760px/);
assert.match(styles, /--right-width:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/);
assert.match(styles, /--right-width-expanded:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/);
assert.match(styles, /\.explorer-resize\s*{[^}]*cursor:\s*col-resize;/s);
assert.match(styles, /\.right-sidebar-resize\s*{[^}]*cursor:\s*col-resize;/s);
assert.match(styles, /\.explorer-collapsed \.explorer-resize\s*{[^}]*display:\s*none;/s);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.explorer-resize\s*{[\s\S]*?display:\s*none;/);
assert.match(styles, /@media \(max-width: 1240px\)[\s\S]*?\.right-sidebar-resize\s*{[\s\S]*?display:\s*none;/);
assert.match(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\);/s);
for (const viewId of ["workspace", "gate"]) {
assert.match(html, new RegExp(`data-view="${viewId}"`));
+33 -3
View File
@@ -21,8 +21,11 @@
--explorer-width: 292px;
--explorer-min-width: 220px;
--explorer-max-width: 420px;
--right-width: clamp(560px, 38vw, 740px);
--right-width-expanded: clamp(560px, 45vw, 740px);
--right-sidebar-width: 620px;
--right-sidebar-min-width: 560px;
--right-sidebar-max-width: 760px;
--right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
--right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--body: "Aptos", "Segoe UI", system-ui, sans-serif;
}
@@ -73,6 +76,8 @@ ul {
}
.workbench-shell {
--right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
--right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
height: 100vh;
height: 100dvh;
max-height: 100dvh;
@@ -191,6 +196,21 @@ ul {
width: 2px;
}
.right-sidebar-resize {
top: 0;
left: 0;
width: 12px;
height: 100%;
cursor: col-resize;
}
.right-sidebar-resize::before {
top: 10px;
bottom: 10px;
left: 5px;
width: 2px;
}
.resize-handle:hover::before,
.resize-handle:focus-visible::before,
.workbench-shell.is-resizing .resize-handle::before {
@@ -800,10 +820,11 @@ h3 {
}
.right-sidebar {
position: relative;
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(0, 0.9fr);
gap: 10px;
padding: 10px;
padding: 10px 10px 10px 16px;
background: rgba(18, 20, 18, 0.98);
border-left: 1px solid var(--line);
overflow: hidden;
@@ -1421,9 +1442,14 @@ tbody tr:last-child td {
grid-column: 2 / 4;
grid-row: 2;
min-height: 0;
padding-left: 10px;
border-top: 1px solid var(--line);
border-left: 0;
}
.right-sidebar-resize {
display: none;
}
}
@media (max-width: 860px) {
@@ -1462,6 +1488,10 @@ tbody tr:last-child td {
display: none;
}
.right-sidebar-resize {
display: none;
}
.center-workspace,
.right-sidebar {
grid-column: 2;