From 4c4faf8b175231f4a8a29ff8f96deda6fbb80c80 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 12 Aug 2026 18:01:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20VS=20Code-style=20file=20tree=20?= =?UTF-8?q?=E2=80=94=20icons/selection/compact=20rows/indent=20guides/tab?= =?UTF-8?q?=20merge=20(rant=202026-08-12T17:28:19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- emrg/gui/renderer/css/layout.css | 58 +++++++++--- emrg/gui/renderer/index.html | 4 +- emrg/gui/renderer/js/file-tree.js | 132 +++++++++++++++++++++------ emrg/gui/test/renderer.smoke.test.js | 98 ++++++++++++++++++++ 5 files changed, 252 insertions(+), 44 deletions(-) diff --git a/Agent.md b/Agent.md index 620e873..fbe7cf0 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (221: 43 daemon_client + 19 conn-manager + 22 app-commands + 100 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (225: 43 daemon_client + 19 conn-manager + 22 app-commands + 104 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (730) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (221: 43 daemon_client + 19 conn-manager + 22 app-commands + 100 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (225: 43 daemon_client + 19 conn-manager + 22 app-commands + 104 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 3a8adcf..42d29d8 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -397,17 +397,16 @@ body.sidebar-collapsed #app { background: var(--bg-soft); font-weight: 600; } -/* 打开文件 Tab 条(P3 查看器接入;每文件一个可关闭 Tab) */ +/* 打开文件 Tab 条(P3 查看器接入;rant 17:28 并入 .result-tabs 同排,每文件一个可关闭 Tab) */ .result-tabbar { display: none; flex-direction: row; + align-items: center; gap: var(--sp-1); - padding: var(--sp-1) var(--sp-3); - border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; } -.result-tabbar.has-tabs { display: flex; } +.result-tabbar.has-tabs { display: inline-flex; } .result-tabbar::-webkit-scrollbar { display: none; } .result-filetab { display: inline-flex; @@ -423,6 +422,7 @@ body.sidebar-collapsed #app { white-space: nowrap; max-width: 160px; overflow: hidden; + height: 22px; /* 与静态 tab 同高(同排并列) */ } .result-filetab.active { color: var(--text-1); @@ -463,15 +463,23 @@ body.sidebar-collapsed #app { opacity: 0.5; } -/* ── P3.1:文件浏览器(懒加载目录树) ── */ +/* ── P3.1:文件浏览器(懒加载目录树,VS Code 风格 rant 2026-08-12T17:28:19) ── */ .result-files { overflow-y: auto; + /* 滚动条 hover 显示(VS Code 行为:平时隐藏,移入文件树区域显示) */ + scrollbar-width: thin; + scrollbar-gutter: stable; } +.result-files::-webkit-scrollbar { width: 8px; } +.result-files::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; } +.result-files:hover::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); } +.result-files::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); } + .ft-row { display: flex; + flex-wrap: wrap; /* kids(flex-basis:100%)换行到下一行,DOM 仍嵌套在行内 */ align-items: center; - gap: var(--sp-1); - padding: 2px var(--sp-2); + height: 24px; /* VS Code 紧凑行高(默认 22px,取 24px 适配 14px 图标) */ cursor: pointer; border-radius: 4px; font-size: var(--fs-small); @@ -483,23 +491,47 @@ body.sidebar-collapsed #app { background: var(--bg-soft); color: var(--text-1); } +/* 选中态:背景 + 左侧 2px accent 竖条(VS Code active 行) */ +.ft-row.active { + background: var(--bg-soft); + color: var(--text-1); + box-shadow: inset 2px 0 0 var(--accent); +} .ft-dir { color: var(--text-1); font-weight: 500; } -.ft-arrow { - width: 14px; +.ft-icon { flex: none; - text-align: center; - color: var(--text-3); - font-size: 10px; + width: 14px; + height: 14px; + margin-right: 4px; + color: currentColor; /* mono:图标颜色跟随文字色 */ + fill: currentColor; +} +.ft-icon path { fill: currentColor; } +.ft-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; /* 超长名省略号 */ + white-space: nowrap; } .ft-kids { - margin-left: 12px; + flex-basis: 100%; /* 子项换行到行下(独立一行),保持 .ft-row 内 DOM 嵌套 */ + position: relative; } .ft-kids.hidden { display: none; } +/* 缩进引导线:嵌套结构天然对齐父行图标列(left:2px ≈ 父行图标起点) */ +.ft-kids::before { + content: ""; + position: absolute; + left: 2px; + top: 0; + bottom: 0; + border-left: 1px solid var(--border); +} .ft-hint { padding: var(--sp-2) var(--sp-3); color: var(--text-3); diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 11b0604..2548b7f 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -91,11 +91,11 @@
+ +
- -
文件浏览器即将推出
diff --git a/emrg/gui/renderer/js/file-tree.js b/emrg/gui/renderer/js/file-tree.js index ddb4f1e..b36451a 100644 --- a/emrg/gui/renderer/js/file-tree.js +++ b/emrg/gui/renderer/js/file-tree.js @@ -1,11 +1,21 @@ "use strict"; /** - * file-tree.js — 工作区文件浏览器(workspace panel P3.1,rant 2026-08-11T12:20:35) + * file-tree.js — 工作区文件浏览器(workspace panel P3.1,rant 2026-08-11T12:20:35; + * VS Code 风格 UI 对齐,rant 2026-08-12T17:28:19) * - * Tab「文件」内容:懒加载目录树。 + * Tab「文件」内容:懒加载目录树,视觉对齐 VS Code Explorer: * - 根 = 当前激活会话 projectPath(app.js 经 FileTree.setSession(sid, path) 注入) * - 目录行点击 → emrg.listFiles(path) 懒加载子项(已加载目录缓存,折叠不重新拉取) * - 文件行点击 → ResultPanel.openFileTab(sid, path)(P2.2 框架 Tab + P3.3 查看器) + * - 文件类型图标(mono 内联 SVG,fill=currentColor 跟随文字色):目录折叠/展开 + * 文件夹图标 + 按扩展名映射文件图标(js/ts/py/md/json/css/html/yml/图片/文本), + * 默认通用文件图标 + * - 递归渲染 + 深度缩进(每层 padding-left = 8 + depth*16,行内 --indent CSS 变量) + * - 选中态:文件行点击 → .active(背景 --bg-soft + 左侧 2px accent 竖条,单选) + * - 紧凑 24px 行高 + 超长名省略号(.ft-name ellipsis) + * - 缩进引导线(.ft-kids::before 1px 竖线,嵌套结构天然对齐父箭头列) + * - 展开态持久:Map(重新 render 不丢,切会话清空) + * - 滚动条 hover 显示(.result-files overlay thumb,默认透明) * - 排序(目录在前)/5000 截断/符号链接不展开/绝对路径校验由 daemon 保证(P1 #661) * 切会话 → setSession 重设根 + 清缓存(per-session 隔离) */ @@ -14,17 +24,55 @@ const FileTree = (() => { let sid = null; let root = ""; const cache = new Map(); // path -> { loaded, loading, error, entries } + const expanded = new Map(); // path -> bool(展开态持久,VS Code 行为) + let selectedPath = null; // 当前选中文件行(单选) function container() { return $("result-files"); } function t(key) { return window.EMRG_I18N ? window.EMRG_I18N.t(key) : key; } + // ── mono 内联 SVG 图标(16x16 viewBox,fill: currentColor 跟随文字色) ── + const ICON = { + dirClosed: '', + dirOpen: '', + fileDefault: '', + fileImg: '', + fileMd: '', + fileJson: '', + fileCode: '', + fileYml: '', + fileTxt: '', + }; + const EXT_ICON = { + png: "fileImg", jpg: "fileImg", jpeg: "fileImg", gif: "fileImg", svg: "fileImg", + webp: "fileImg", bmp: "fileImg", ico: "fileImg", + md: "fileMd", markdown: "fileMd", + json: "fileJson", + js: "fileCode", jsx: "fileCode", mjs: "fileCode", cjs: "fileCode", + ts: "fileCode", tsx: "fileCode", + html: "fileCode", htm: "fileCode", css: "fileCode", scss: "fileCode", + py: "fileCode", rb: "fileCode", go: "fileCode", rs: "fileCode", java: "fileCode", c: "fileCode", cpp: "fileCode", + yml: "fileYml", yaml: "fileYml", toml: "fileYml", cfg: "fileYml", ini: "fileYml", + txt: "fileTxt", log: "fileTxt", csv: "fileTxt", tsv: "fileTxt", + }; + + function iconSvg(d) { + const svg = el("svg", { class: "ft-icon", viewBox: "0 0 16 16", width: 14, height: 14, "aria-hidden": "true" }); + svg.innerHTML = d; // 静态白名单路径,无注入风险 + return svg; + } + function iconFor(entry) { + if (entry.type === "dir") return expanded.get(entry.path) ? ICON.dirOpen : ICON.dirClosed; + const ext = String(entry.name).split(".").pop().toLowerCase(); + return ICON[EXT_ICON[ext] || "fileDefault"]; + } + function ensure(path) { let st = cache.get(path); if (!st) { st = { loaded: false, loading: false, error: false, entries: [] }; cache.set(path, st); } return st; } - /** 设置会话根目录(切会话调用:重设根 + 清缓存) */ + /** 设置会话根目录(切会话调用:重设根 + 清缓存 + 清展开态/选中态) */ function setSession(s, rootPath) { sid = s || null; setRoot(rootPath); @@ -33,11 +81,13 @@ const FileTree = (() => { function setRoot(path) { root = path || ""; cache.clear(); + expanded.clear(); + selectedPath = null; render(); } - /** 拉取 + 渲染目录子项(st 为 ensure(path) 的缓存态) */ - async function expandDir(path, kidsEl, st) { + /** 拉取 + 渲染目录子项(st 为 ensure(path) 的缓存态;childDepth = 子项缩进层级) */ + async function expandDir(path, kidsEl, st, childDepth) { if (!st.loaded && !st.loading) { st.loading = true; kidsEl.innerHTML = ""; @@ -60,42 +110,68 @@ const FileTree = (() => { kidsEl.appendChild(el("div", { class: "ft-hint" }, t("result.treeLoadFailed"))); return; } - for (const e of st.entries) kidsEl.appendChild(renderEntry(e)); + for (const e of st.entries) kidsEl.appendChild(renderEntry(e, childDepth)); } - function renderEntry(entry) { + /** 渲染一条目行(depth 相对其父容器;padding-left = 8 + depth*16 对齐箭头列) */ + function renderEntry(entry, depth) { + const row = el("div", { + class: "ft-row" + (entry.type === "dir" ? " ft-dir" : " ft-file"), + dataset: { path: entry.path }, + }); + row.style.paddingLeft = (8 + depth * 16) + "px"; + row.appendChild(iconSvg(iconFor(entry))); + row.appendChild(el("span", { class: "ft-name" }, entry.name)); if (entry.type === "dir") { - const row = el("div", { class: "ft-row ft-dir", dataset: { path: entry.path } }); - const arrow = el("span", { class: "ft-arrow" }, "▸"); - row.appendChild(arrow); - row.appendChild(el("span", { class: "ft-name" }, entry.name)); - const kids = el("div", { class: "ft-kids hidden" }); + const kids = el("div", { class: "ft-kids" + (expanded.get(entry.path) ? "" : " hidden") }); row.appendChild(kids); const st = ensure(entry.path); row.addEventListener("click", (e) => { if (e && e.stopPropagation) e.stopPropagation(); - toggleDir(entry.path, arrow, kids, st); + toggleDir(entry.path, kids, st, depth + 1); + }); + if (expanded.get(entry.path) && !st.loaded) expandDir(entry.path, kids, st, depth + 1); + } else { + if (selectedPath === entry.path) row.classList.add("active"); + row.addEventListener("click", (e) => { + if (e && e.stopPropagation) e.stopPropagation(); + selectFile(entry.path); + openFile(entry.path); }); - return row; } - const row = el("div", { class: "ft-row ft-file", dataset: { path: entry.path } }); - row.appendChild(el("span", { class: "ft-name" }, entry.name)); - row.addEventListener("click", (e) => { - if (e && e.stopPropagation) e.stopPropagation(); - openFile(entry.path); - }); return row; } - async function toggleDir(path, arrow, kidsEl, st) { + /** 目录展开/折叠:状态持久到 expanded Map(VS Code 行为,重新 render 不丢) */ + async function toggleDir(path, kidsEl, st, childDepth) { const wasHidden = kidsEl.classList.contains("hidden"); if (wasHidden) { - await expandDir(path, kidsEl, st); + expanded.set(path, true); + await expandDir(path, kidsEl, st, childDepth); kidsEl.classList.remove("hidden"); - arrow.textContent = "▾"; } else { + expanded.set(path, false); kidsEl.classList.add("hidden"); - arrow.textContent = "▸"; + } + // 目录行图标随展开态切换(重新渲染子行不影响兄弟行) + const row = kidsEl.parentNode; + if (row) { + const ic = row.querySelector(".ft-icon"); + if (ic) ic.innerHTML = expanded.get(path) ? ICON.dirOpen : ICON.dirClosed; + } + } + + /** 文件行选中(单选):去旧 active 加新 active */ + function selectFile(path) { + const c = container(); + if (!c) return; + const rows = c.querySelectorAll(".ft-file"); + for (const r of rows) { + if (r.dataset && r.dataset.path === selectedPath) r.classList.remove("active"); + } + selectedPath = path; + for (const r of rows) { + if (r.dataset && r.dataset.path === path) r.classList.add("active"); } } @@ -114,14 +190,16 @@ const FileTree = (() => { return; } const rootName = String(root).split(/[\\/]/).filter(Boolean).pop() || root; + expanded.set(root, true); // 根默认展开 const rootRow = el("div", { class: "ft-row ft-dir ft-root", dataset: { path: root } }); - rootRow.appendChild(el("span", { class: "ft-arrow" }, "▾")); + rootRow.style.paddingLeft = "8px"; + rootRow.appendChild(iconSvg(ICON.dirOpen)); rootRow.appendChild(el("span", { class: "ft-name" }, rootName)); const kids = el("div", { class: "ft-kids" }); rootRow.appendChild(kids); c.appendChild(rootRow); - // 根自动展开(fire-and-forget) - expandDir(root, kids, ensure(root)); + // 根自动展开(fire-and-forget;根子项 depth=1) + expandDir(root, kids, ensure(root), 1); } return { setRoot, setSession }; diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 8d871d5..c9cc4ba 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1258,6 +1258,104 @@ test("P3:切会话 → 文件树根跟随(per-session)", async () => { assert.strictEqual(rootName2, "other", "s2 根 = other"); }); +test("P3.5(rant 17:28):VS Code 风格图标 — 目录折叠/展开 + 文件类型映射", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx); + await tick(); + const pathCount = (ic) => (ic ? (ic.innerHTML.match(/ r.dataset.path === "/proj/README.md"); + const mdIcon = mdRow.querySelector(".ft-icon"); + assert.ok(mdIcon, "文件行应有 .ft-icon"); + assert.ok(mdIcon.innerHTML.includes("M4.5 6h7M4.5 8.5h7M4.5 11h4.5"), "md → fileMd 横线组"); + const pyRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/src/main.py"); + assert.ok(pyRow.querySelector(".ft-icon").innerHTML.includes("M6.5 6.5L4.5 8.5l2 2"), "py → fileCode 尖括号"); +}); + +test("P3.5(rant 17:28):文件行选中态 — 点击选中 + 单选切换", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext('ResultPanel.switchSession("s1")', ctx); + await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx); + await tick(); + const mdRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/README.md"); + // 点击 → active + mdRow.dispatch("click", { stopPropagation() {} }); + assert.ok(mdRow.classList.contains("active"), "点击文件行应加 .active"); + // 点击 src 目录 → 选中不移除(目录不高亮),展开目录 + const srcRow = els["result-files"].querySelectorAll(".ft-dir")[1]; + srcRow.dispatch("click", { stopPropagation() {} }); + await tick(); + assert.ok(mdRow.classList.contains("active"), "目录行点击不应清除文件选中"); + assert.ok(!srcRow.classList.contains("active"), "目录行不应有 active"); + // 点击另一个文件 → 单选切换 + const pyRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/src/main.py"); + pyRow.dispatch("click", { stopPropagation() {} }); + assert.ok(pyRow.classList.contains("active"), "新点击文件应 active"); + assert.ok(!mdRow.classList.contains("active"), "旧选中应移除(单选)"); +}); + +test("P3.5(rant 17:28):深度缩进 — 子行 padding-left 递增 16px", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx); + await tick(); + const rootRow = els["result-files"].querySelectorAll(".ft-root")[0]; + assert.strictEqual(rootRow.style.paddingLeft, "8px", "根 depth=0 → 8px"); + // 根子项 README.md(depth 1) + const mdRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/README.md"); + assert.strictEqual(mdRow.style.paddingLeft, "24px", "根子项 depth=1 → 24px"); + // 展开 src → main.py(depth 2) + const srcRow = els["result-files"].querySelectorAll(".ft-dir")[1]; + srcRow.dispatch("click", { stopPropagation() {} }); + await tick(); + const pyRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/src/main.py"); + assert.strictEqual(pyRow.style.paddingLeft, "40px", "src 子项 depth=2 → 40px"); +}); + +test("P3.5(rant 17:28):展开态持久 + 滚动条 hover 样式(CSS 源级断言)", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx); + await tick(); + const srcRow = els["result-files"].querySelectorAll(".ft-dir")[1]; + // 展开 src → 折叠 → 再展开:缓存命中 + 展开态持久(expandDir 不重复拉取已由既有测试覆盖,这里验证 DOM 状态往返) + srcRow.dispatch("click", { stopPropagation() {} }); + await tick(); + assert.ok(!srcRow.querySelector(".ft-kids").classList.contains("hidden"), "展开后 kids 可见"); + srcRow.dispatch("click", { stopPropagation() {} }); + assert.ok(srcRow.querySelector(".ft-kids").classList.contains("hidden"), "折叠后 kids 隐藏"); + srcRow.dispatch("click", { stopPropagation() {} }); + await tick(); + assert.ok(!srcRow.querySelector(".ft-kids").classList.contains("hidden"), "再次展开恢复可见"); + const iconHtml = srcRow.querySelector(".ft-icon").innerHTML; + assert.strictEqual((iconHtml.match(/= 0 && tabbarIdx >= 0 && toggleIdx >= 0, "三个锚点都应存在"); + assert.ok(tabsIdx < tabbarIdx && tabbarIdx < toggleIdx, "tabbar 应嵌套在 result-tabs 内(同排),旧布局 toggle 在 tabbar 之前"); +}); + test("工具调用上限中断 → 系统提示可继续(对齐 TUI,跨项目教训)", async () => { const { ctx, els } = makeSandbox({}); await tick(); From 26dd780c54db4afcee7d1ad2496fc88e38bdc9fb Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 12 Aug 2026 18:10:54 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20fix=20file-tree=20row=20layout=20?= =?UTF-8?q?=E2=80=94=20block-level=20head+kids=20wrapper=20(no=20sibling?= =?UTF-8?q?=20overlap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/gui/renderer/css/layout.css | 21 ++++++++++++--------- emrg/gui/renderer/js/file-tree.js | 14 ++++++++++---- emrg/gui/test/renderer.smoke.test.js | 6 ++++++ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 42d29d8..566fb44 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -476,23 +476,27 @@ body.sidebar-collapsed #app { .result-files::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); } .ft-row { - display: flex; - flex-wrap: wrap; /* kids(flex-basis:100%)换行到下一行,DOM 仍嵌套在行内 */ - align-items: center; - height: 24px; /* VS Code 紧凑行高(默认 22px,取 24px 适配 14px 图标) */ + display: block; /* 块级排布:.ft-head 行 + .ft-kids 子树纵向排列,行高自适应 + (修复布局 bug:定高 flex-wrap 使下一兄弟行与展开目录首个子项 + 重叠,headless Chrome 像素实证——兄弟行遮挡子项) */ cursor: pointer; - border-radius: 4px; font-size: var(--fs-small); color: var(--text-2); white-space: nowrap; overflow: hidden; } -.ft-row:hover { +.ft-head { + display: flex; + align-items: center; + height: 24px; /* VS Code 紧凑行高(默认 22px,取 24px 适配 14px 图标) */ + border-radius: 4px; +} +.ft-row:hover > .ft-head { background: var(--bg-soft); color: var(--text-1); } /* 选中态:背景 + 左侧 2px accent 竖条(VS Code active 行) */ -.ft-row.active { +.ft-row.active > .ft-head { background: var(--bg-soft); color: var(--text-1); box-shadow: inset 2px 0 0 var(--accent); @@ -517,8 +521,7 @@ body.sidebar-collapsed #app { white-space: nowrap; } .ft-kids { - flex-basis: 100%; /* 子项换行到行下(独立一行),保持 .ft-row 内 DOM 嵌套 */ - position: relative; + position: relative; /* 正常流布局:位于 .ft-head 之下(不再作为 flex 行溢出) */ } .ft-kids.hidden { display: none; diff --git a/emrg/gui/renderer/js/file-tree.js b/emrg/gui/renderer/js/file-tree.js index b36451a..e07aee2 100644 --- a/emrg/gui/renderer/js/file-tree.js +++ b/emrg/gui/renderer/js/file-tree.js @@ -120,8 +120,12 @@ const FileTree = (() => { dataset: { path: entry.path }, }); row.style.paddingLeft = (8 + depth * 16) + "px"; - row.appendChild(iconSvg(iconFor(entry))); - row.appendChild(el("span", { class: "ft-name" }, entry.name)); + // 行头(图标+名称)单独包装:.ft-row 为块级容器(head + kids 纵向排布), + // 修复定高 flex-wrap 下兄弟行与展开子项重叠的布局 bug(headless Chrome 像素实证) + const head = el("div", { class: "ft-head" }); + head.appendChild(iconSvg(iconFor(entry))); + head.appendChild(el("span", { class: "ft-name" }, entry.name)); + row.appendChild(head); if (entry.type === "dir") { const kids = el("div", { class: "ft-kids" + (expanded.get(entry.path) ? "" : " hidden") }); row.appendChild(kids); @@ -193,8 +197,10 @@ const FileTree = (() => { expanded.set(root, true); // 根默认展开 const rootRow = el("div", { class: "ft-row ft-dir ft-root", dataset: { path: root } }); rootRow.style.paddingLeft = "8px"; - rootRow.appendChild(iconSvg(ICON.dirOpen)); - rootRow.appendChild(el("span", { class: "ft-name" }, rootName)); + const rootHead = el("div", { class: "ft-head" }); + rootHead.appendChild(iconSvg(ICON.dirOpen)); + rootHead.appendChild(el("span", { class: "ft-name" }, rootName)); + rootRow.appendChild(rootHead); const kids = el("div", { class: "ft-kids" }); rootRow.appendChild(kids); c.appendChild(rootRow); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index c9cc4ba..d5e00f9 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1324,6 +1324,12 @@ test("P3.5(rant 17:28):深度缩进 — 子行 padding-left 递增 16px", await tick(); const pyRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/src/main.py"); assert.strictEqual(pyRow.style.paddingLeft, "40px", "src 子项 depth=2 → 40px"); + // 结构契约(修复布局 bug):.ft-head 包装图标+名称,.ft-kids 紧随其后——块级排布, + // 兄弟行不再与展开子项重叠(headless Chrome 像素实证:定高 flex-wrap 下行遮挡子项) + const srcHead = srcRow.children[0]; + assert.ok(srcHead && srcHead.classList.contains("ft-head"), "目录行第一个子元素应为 .ft-head 包装"); + assert.ok(srcHead.querySelector(".ft-icon"), "图标在 .ft-head 内"); + assert.strictEqual(srcRow.children[1], srcRow.querySelector(".ft-kids"), ".ft-kids 紧随 .ft-head"); }); test("P3.5(rant 17:28):展开态持久 + 滚动条 hover 样式(CSS 源级断言)", async () => {