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:
+116
-5
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"`));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user