diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 9bf33404..0d68425f 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -40,6 +40,17 @@ The default workbench follows the #99 VS Code-style structure: Unavailable or future controls must be disabled with blocker context. The frontend must not imply generic direct hardware control. +The left resource explorer is a controlled resizable column on desktop-class +viewports. Its width defaults to `292px`, clamps inside a bounded range +(`220px` to `420px` before viewport-specific clamping), and is changed through +the `#explorer-resize` separator. The separator has a Chinese accessible name, +supports pointer drag plus Arrow/Page/Home/End keyboard adjustment, and stores +only the clamped width in versioned `localStorage` key +`hwlab.workbench.layout.v1`. Collapsing the resource tree hides the column but +keeps the saved width so expanding restores it. At `<=860px`, the resize handle +is removed from the focus/hit-target flow and the mobile drawer/collapse flow +owns the layout. + The `panel-wiring` / `hwlab-patch-panel` view must use a two-column long table instead of an eight-column wide table. The first table row/header names the two connected devices, for example `res_boxsimu_1` and `res_boxsimu_2`; following @@ -121,6 +132,12 @@ runner 入口。它使用 Playwright 覆盖 `1366x768`、`1024x768` 和 `390x844 点击与 `document.elementsFromPoint()`;不得使用 `force: true` 或纯 DOM dispatch 绕过用户命中。 +Layout smoke also covers #278 resize behavior. Desktop and narrow desktop runs +drag `#explorer-resize`, exercise Arrow/Home/End keyboard changes, assert +min/max/clamp and versioned storage restore, then collapse and expand the +resource tree to prove width state is retained. The mobile run asserts the +handle is not displayed or focusable and that no horizontal overflow appears. + 常用命令: ```sh @@ -171,6 +188,8 @@ blockers/failures:`viewport`、`selector`、`failureType`、summary 和 artifa Playwright layout and overlap guardrail. - [pikasTech/HWLAB#276](https://github.com/pikasTech/HWLAB/issues/276): patch-panel wiring panel two-column long-table feedback. +- [pikasTech/HWLAB#278](https://github.com/pikasTech/HWLAB/issues/278): + resource explorer collapse and controlled resize feedback. - [code-agent-chat-readiness.md](code-agent-chat-readiness.md): Code Agent chat 同源 readiness 和 provider credential blocker 边界。 - [docs/cloud-web-workbench.md](../cloud-web-workbench.md): detailed frontend diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 4cc1890c..72bef74c 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -517,6 +517,9 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async "layout-narrow-desktop-restored", "layout-mobile-collapsed", "layout-mobile-drawer", + "layout-explorer-resize-desktop", + "layout-explorer-resize-narrow-desktop", + "layout-explorer-resize-mobile", "layout-gate-desktop", "layout-gate-narrow-desktop", "layout-gate-mobile" @@ -549,6 +552,7 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async assert.equal(desktopCollapsed.wiring.headers.length, 2); assert.deepEqual(desktopCollapsed.wiring.legacyHeaderHits, []); assert.equal(desktopCollapsed.wiring.horizontalScroll.panelScrollWidth <= desktopCollapsed.wiring.horizontalScroll.panelClientWidth + 2, true); + assert.equal(desktopCollapsed.resizeHandle.ok, false); const mobileCollapsed = report.checks.find((check) => check.id === "layout-mobile-collapsed")?.observations; assert.equal(mobileCollapsed.explorerCollapsed, true); @@ -556,6 +560,7 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async assert.equal(mobileCollapsed.keyTargetsReachable, true); assert.equal(mobileCollapsed.wiring.longTableOk, true); assert.equal(mobileCollapsed.wiring.noHorizontalScroll, true); + assert.equal(mobileCollapsed.resizeHandle.ok, false); const desktopExpanded = report.checks.find((check) => check.id === "layout-desktop-expanded")?.observations; assert.equal(Object.hasOwn(desktopExpanded.boxes.shell, "text"), false); @@ -563,6 +568,29 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async assert.equal(desktopExpanded.failures.length, 0); assert.equal(desktopExpanded.semanticOverlapChecks.every((check) => !check.overlaps), true); assert.equal(desktopExpanded.overflowChecks.every((check) => check.ok), true); + assert.equal(desktopExpanded.resizeHandle.ok, true); + + const desktopResize = report.checks.find((check) => check.id === "layout-explorer-resize-desktop")?.observations; + assert.equal(desktopResize.pass, true); + assert.equal(desktopResize.before.handle.ariaLabel, "拖拽调整左侧资源树宽度"); + assert.equal(desktopResize.before.handle.role, "separator"); + assert.equal(Math.abs(desktopResize.afterDrag.explorerWidth - desktopResize.before.explorerWidth) >= 24, true); + assert.equal(desktopResize.afterEnd.explorerWidth, desktopResize.afterEnd.bounds.max); + assert.equal(desktopResize.afterHome.explorerWidth, desktopResize.afterHome.bounds.min); + assert.equal(desktopResize.collapsed.explorerCollapsed, true); + assert.equal(desktopResize.collapsed.handleTabIndex, -1); + assert.equal(desktopResize.restored.storage.version, 1); + assert.equal(desktopResize.restoredFromStorage.keyTargetsReachable, true); + + const narrowResize = report.checks.find((check) => check.id === "layout-explorer-resize-narrow-desktop")?.observations; + assert.equal(narrowResize.pass, true); + assert.equal(narrowResize.afterEnd.explorerWidth <= narrowResize.afterEnd.bounds.max, true); + assert.equal(narrowResize.restoredFromStorage.noHorizontalOverflow, true); + + const mobileResize = report.checks.find((check) => check.id === "layout-explorer-resize-mobile")?.observations; + assert.equal(mobileResize.pass, true); + assert.equal(mobileResize.handleDisplay, "none"); + assert.equal(mobileResize.handleTabIndex, -1); const compact = compactLayoutSmokeCliOutput(report); assert.equal(compact.status, "pass"); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index b0a446e5..dba0155d 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 8acaee46..4b171d08 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -55,6 +55,10 @@ const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined" const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli", "codex-readonly-runner"]); const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-readonly-runner"]); const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu; +const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1"; +const EXPLORER_DEFAULT_WIDTH = 292; +const EXPLORER_MIN_WIDTH = 220; +const EXPLORER_MAX_WIDTH = 420; const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); let rpcSequence = 0; @@ -62,6 +66,7 @@ let rpcSequence = 0; const el = { shell: query("[data-app-shell]"), explorerToggle: byId("explorer-toggle"), + explorerResize: byId("explorer-resize"), explorerStatus: byId("explorer-status"), treeCount: byId("tree-count"), resourceTree: byId("resource-tree"), @@ -119,12 +124,18 @@ const state = { hardwareTab: "overview", operation: null, pending: false + }, + layout: { + explorerWidth: EXPLORER_DEFAULT_WIDTH, + drag: null } }; initRoutes(); +initLayoutSizing(); initExplorerToggle(); syncMobileExplorer(); +initExplorerResize(); initSideTabs(); initHardwareTabs(); initQuickActions(); @@ -169,6 +180,93 @@ function initRoutes() { showView(routeFromLocation()); } +function initLayoutSizing() { + const persisted = readPersistedLayout(); + const width = clampExplorerWidth(persisted?.explorerWidth ?? EXPLORER_DEFAULT_WIDTH); + setExplorerWidth(width, { persist: false }); +} + +function readPersistedLayout() { + try { + const raw = window.localStorage?.getItem(LAYOUT_STORAGE_KEY); + if (!raw) return null; + const payload = JSON.parse(raw); + if (payload?.version !== 1) return null; + return { + explorerWidth: Number(payload.explorerWidth) + }; + } catch { + return null; + } +} + +function persistLayout() { + try { + window.localStorage?.setItem( + LAYOUT_STORAGE_KEY, + JSON.stringify({ + version: 1, + explorerWidth: state.layout.explorerWidth + }) + ); + } catch { + // Browser storage can be unavailable; layout still works for the session. + } +} + +function setExplorerWidth(width, options = {}) { + const clamped = clampExplorerWidth(width); + state.layout.explorerWidth = clamped; + el.shell.style.setProperty("--explorer-width", `${clamped}px`); + syncExplorerResizeA11y(); + if (options.persist !== false) persistLayout(); + return clamped; +} + +function clampExplorerWidth(value) { + const numeric = Number(value); + const width = Number.isFinite(numeric) ? numeric : EXPLORER_DEFAULT_WIDTH; + const bounds = explorerResizeBounds(); + 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); + const cssMax = readCssPixel(styles.getPropertyValue("--explorer-max-width"), EXPLORER_MAX_WIDTH); + if (window.matchMedia("(max-width: 860px)").matches) { + return { min: EXPLORER_MIN_WIDTH, max: EXPLORER_MAX_WIDTH }; + } + if (window.matchMedia("(max-width: 1240px)").matches) { + const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 86); + const dynamicMax = window.innerWidth - railWidth - 420 - 4; + return boundedResizeRange(cssMin, cssMax, dynamicMax); + } + const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 92); + const rightWidth = Math.min(Math.max(window.innerWidth * 0.45, 560), 740); + const dynamicMax = window.innerWidth - railWidth - 360 - rightWidth - 4; + return boundedResizeRange(cssMin, cssMax, dynamicMax); +} + +function boundedResizeRange(min, max, dynamicMax) { + const safeMin = Math.max(0, Math.round(min)); + const safeMax = Math.max(safeMin, Math.round(Math.min(max, Number.isFinite(dynamicMax) ? dynamicMax : max))); + return { min: safeMin, max: safeMax }; +} + +function readCssPixel(value, fallback) { + const parsed = Number.parseFloat(String(value ?? "").trim()); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function syncExplorerResizeA11y() { + const bounds = explorerResizeBounds(); + el.explorerResize.setAttribute("aria-valuemin", String(bounds.min)); + el.explorerResize.setAttribute("aria-valuemax", String(bounds.max)); + el.explorerResize.setAttribute("aria-valuenow", String(state.layout.explorerWidth)); + el.explorerResize.setAttribute("aria-valuetext", `左侧资源树宽度 ${state.layout.explorerWidth} 像素`); +} + function routeFromLocation() { const hashRoute = window.location.hash.replace(/^#\/?/, ""); if (viewIds.has(hashRoute)) return hashRoute; @@ -199,13 +297,75 @@ function initExplorerToggle() { }); } +function initExplorerResize() { + window.addEventListener("resize", () => { + if (canResizeExplorer()) setExplorerWidth(state.layout.explorerWidth, { persist: false }); + syncExplorerResizeA11y(); + }); + el.explorerResize.addEventListener("pointerdown", (event) => { + if (!canResizeExplorer()) return; + event.preventDefault(); + el.explorerResize.setPointerCapture?.(event.pointerId); + state.layout.drag = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: state.layout.explorerWidth + }; + el.shell.classList.add("is-resizing"); + }); + el.explorerResize.addEventListener("pointermove", (event) => { + const drag = state.layout.drag; + if (!drag || drag.pointerId !== event.pointerId) return; + setExplorerWidth(drag.startWidth + event.clientX - drag.startX, { persist: false }); + }); + el.explorerResize.addEventListener("pointerup", finishExplorerResize); + el.explorerResize.addEventListener("pointercancel", finishExplorerResize); + el.explorerResize.addEventListener("keydown", (event) => { + if (!canResizeExplorer()) return; + const keySteps = { + ArrowLeft: -16, + ArrowRight: 16, + PageDown: -48, + PageUp: 48 + }; + if (Object.hasOwn(keySteps, event.key)) { + event.preventDefault(); + setExplorerWidth(state.layout.explorerWidth + keySteps[event.key]); + } else if (event.key === "Home") { + event.preventDefault(); + setExplorerWidth(explorerResizeBounds().min); + } else if (event.key === "End") { + event.preventDefault(); + setExplorerWidth(explorerResizeBounds().max); + } + }); +} + +function finishExplorerResize(event) { + const drag = state.layout.drag; + if (!drag || drag.pointerId !== event.pointerId) return; + state.layout.drag = null; + el.shell.classList.remove("is-resizing"); + persistLayout(); +} + +function canResizeExplorer() { + return !window.matchMedia("(max-width: 860px)").matches && !el.shell.classList.contains("explorer-collapsed"); +} + function setExplorerCollapsed(collapsed) { + if (!collapsed) { + setExplorerWidth(state.layout.explorerWidth, { persist: false }); + } el.shell.classList.toggle("explorer-collapsed", collapsed); el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false"); el.explorerToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); el.explorerToggle.setAttribute("aria-label", collapsed ? "展开左侧资源树" : "收起左侧资源树"); el.explorerToggle.title = collapsed ? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度"; el.explorerToggle.textContent = collapsed ? "展开资源树" : "收起资源树"; + const resizeDisabled = collapsed || window.matchMedia("(max-width: 860px)").matches; + el.explorerResize.tabIndex = resizeDisabled ? -1 : 0; + el.explorerResize.setAttribute("aria-disabled", resizeDisabled ? "true" : "false"); } function syncMobileExplorer() { @@ -215,6 +375,7 @@ function syncMobileExplorer() { setExplorerCollapsed(true); } else { setExplorerCollapsed(false); + setExplorerWidth(state.layout.explorerWidth, { persist: false }); } }; apply(); diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 25bb03f7..8a2552ee 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -44,6 +44,19 @@
+