Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 路径不受影响)

Expand Down
69 changes: 52 additions & 17 deletions emrg/gui/renderer/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -463,43 +463,78 @@ 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;
align-items: center;
gap: var(--sp-1);
padding: 2px var(--sp-2);
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-head {
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;
position: relative; /* 正常流布局:位于 .ft-head 之下(不再作为 flex 行溢出) */
}
.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);
Expand Down
4 changes: 2 additions & 2 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@
<div class="result-tabs" id="result-tabs" role="tablist">
<button class="result-tab" id="result-tab-files" data-tab="files" data-i18n="result.tabFiles">文件</button>
<button class="result-tab active" id="result-tab-artifacts" data-tab="artifacts" data-i18n="result.tabArtifacts">产物</button>
<!-- 打开文件 Tab 条(P3 查看器接入;rant 17:28 与「文件/产物」同排并列,VS Code 工作区 tab 风格) -->
<div id="result-tabbar" class="result-tabbar"></div>
</div>
<button id="result-toggle" class="result-toggle" title="折叠/展开 (⌘\)" data-i18n-title="result.collapse">»</button>
</div>
<!-- 打开文件 Tab 条(P3 查看器接入;框架已就绪) -->
<div id="result-tabbar" class="result-tabbar"></div>
<!-- 文件浏览 pane(P3.1 文件树,file-tree.js 渲染) -->
<div id="result-files" class="result-pane result-files">
<div class="result-empty" data-i18n="result.filesEmpty">文件浏览器即将推出</div>
Expand Down
140 changes: 112 additions & 28 deletions emrg/gui/renderer/js/file-tree.js
Original file line number Diff line number Diff line change
@@ -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<path, expanded>(重新 render 不丢,切会话清空)
* - 滚动条 hover 显示(.result-files overlay thumb,默认透明)
* - 排序(目录在前)/5000 截断/符号链接不展开/绝对路径校验由 daemon 保证(P1 #661)
* 切会话 → setSession 重设根 + 清缓存(per-session 隔离)
*/
Expand All @@ -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: '<path d="M1.5 2.5h4.1c.32 0 .62.13.84.36l1.1 1.14h6.96c.83 0 1.5.67 1.5 1.5V12.5c0 .83-.67 1.5-1.5 1.5H1.5A1.5 1.5 0 0 1 0 12.5V4c0-.83.67-1.5 1.5-1.5z"/>',
dirOpen: '<path d="M1.5 2.5h4.1c.32 0 .62.13.84.36l1.1 1.14h6.96c.83 0 1.5.67 1.5 1.5V6H2.1c-.6 0-1.13.36-1.34.92L0 12.8V4c0-.83.67-1.5 1.5-1.5z"/><path d="M14.6 6.5H2.3c-.42 0-.8.27-.94.66L0 13.5h14.5c.83 0 1.5-.67 1.5-1.5V8c0-.83-.67-1.5-1.5-1.5z"/>',
fileDefault: '<path d="M3 1h6.5l3.5 3.5V15H3V1z"/><path d="M9.5 1v3.5H13" fill="none" stroke="currentColor" stroke-width="1.5"/>',
fileImg: '<path d="M3 1h6.5l3.5 3.5V15H3V1z"/><path d="M9.5 1v3.5H13" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="6" cy="6.5" r="1.2"/><path d="M4.5 12.5l2.5-2.5 2 2 1.5-1.5 1.5 2z"/>',
fileMd: '<path d="M3 1h6.5l3.5 3.5V15H3V1z"/><path d="M9.5 1v3.5H13" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.5 6h7M4.5 8.5h7M4.5 11h4.5"/>',
fileJson: '<path d="M3 1h6.5l3.5 3.5V15H3V1z"/><path d="M9.5 1v3.5H13" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M7.2 6.5L5.5 8.5l1.7 2M8.8 6.5l1.7 2-1.7 2"/>',
fileCode: '<path d="M3 1h6.5l3.5 3.5V15H3V1z"/><path d="M9.5 1v3.5H13" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.5 6.5L4.5 8.5l2 2M9.5 6.5l2 2-2 2"/>',
fileYml: '<path d="M3 1h6.5l3.5 3.5V15H3V1z"/><path d="M9.5 1v3.5H13" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5 6.5h.01M5 9h.01M5 11.5h.01M8 6.5h3M8 9h3M8 11.5h3"/>',
fileTxt: '<path d="M3 1h6.5l3.5 3.5V15H3V1z"/><path d="M9.5 1v3.5H13" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.5 6h7M4.5 8.5h7M4.5 11h7"/>',
};
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);
Expand All @@ -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 = "";
Expand All @@ -60,42 +110,72 @@ 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";
// 行头(图标+名称)单独包装:.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 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");
}
}

Expand All @@ -114,14 +194,18 @@ 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.appendChild(el("span", { class: "ft-name" }, rootName));
rootRow.style.paddingLeft = "8px";
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);
// 根自动展开(fire-and-forget)
expandDir(root, kids, ensure(root));
// 根自动展开(fire-and-forget;根子项 depth=1
expandDir(root, kids, ensure(root), 1);
}

return { setRoot, setSession };
Expand Down
Loading
Loading