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
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,7 +120,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (1102) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (151 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar) + `npm run build` → `renderer/dist/`
GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (168 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 8 FileTree) + `npm run build` → `renderer/dist/`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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 路径不受影响)
Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径)
Expand Down
123 changes: 123 additions & 0 deletions emrg/gui/renderer/src/components/FileTree.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileTree, type FileTreeProps } from "./FileTree";
import type { FileEntry } from "../lib/fileTree";

/**
* FileTree.test.tsx — 文件树 React 组件测试(Batch 3)。
* 镜像 vanilla file-tree.js:懒加载目录树、展开/折叠、文件选中 + onOpenFile、
* 根默认展开、加载失败提示。类名与 vanilla CSS 一致(Batch 5 复用)。
*/

const dir = (name: string, path: string): FileEntry => ({ name, path, type: "dir" });
const file = (name: string, path: string): FileEntry => ({ name, path, type: "file" });

function makeListFiles(childrenByPath: Record<string, FileEntry[]>) {
return vi.fn(async (path: string) => ({ entries: childrenByPath[path] || [] }));
}

function setup(props: Partial<FileTreeProps> = {}) {
const utils = render(<FileTree root="/proj" {...props} />);
return { ...utils };
}

describe("FileTree", () => {
it("根目录默认展开并懒加载子项(目录在前排序)", async () => {
const listFiles = makeListFiles({
"/proj": [file("b.txt", "/proj/b.txt"), dir("src", "/proj/src")],
});
setup({ listFiles });
expect(listFiles).toHaveBeenCalledWith("/proj");
await waitFor(() => {
expect(screen.getByText("src")).toBeInTheDocument();
expect(screen.getByText("b.txt")).toBeInTheDocument();
});
const rows = document.querySelectorAll(".ft-row");
// 根行 + src(dir)+ b.txt(file)
expect(rows[1]).toHaveClass("ft-dir");
expect(rows[2]).toHaveClass("ft-file");
});

it("目录行点击 → 懒加载子项(一次拉取缓存);折叠 → hidden", async () => {
const listFiles = makeListFiles({
"/proj": [dir("src", "/proj/src")],
"/proj/src": [file("index.ts", "/proj/src/index.ts")],
});
setup({ listFiles });
await waitFor(() => expect(screen.getByText("src")).toBeInTheDocument());
const srcRow = screen.getByText("src").closest(".ft-row")!;
await userEvent.click(srcRow);
await waitFor(() => expect(screen.getByText("index.ts")).toBeInTheDocument());
expect(listFiles).toHaveBeenCalledWith("/proj/src");
// 折叠 → 子项隐藏(缓存保留,不重新拉取)
await userEvent.click(srcRow);
const kids = srcRow.querySelector(".ft-kids")!;
expect(kids).toHaveClass("hidden");
await userEvent.click(srcRow);
await waitFor(() => expect(srcRow.querySelector(".ft-kids")!).not.toHaveClass("hidden"));
expect(listFiles).toHaveBeenCalledTimes(2); // 仅 /proj + /proj/src,无重复拉取
});

it("文件行点击 → 选中 .active + onOpenFile(path)", async () => {
const listFiles = makeListFiles({ "/proj": [file("a.py", "/proj/a.py")] });
const onOpenFile = vi.fn();
setup({ listFiles, onOpenFile });
await waitFor(() => expect(screen.getByText("a.py")).toBeInTheDocument());
const row = screen.getByText("a.py").closest(".ft-row")!;
await userEvent.click(row);
expect(row).toHaveClass("active");
expect(onOpenFile).toHaveBeenCalledWith("/proj/a.py");
});

it("单选:切换选中只保留一个 .active", async () => {
const listFiles = makeListFiles({
"/proj": [file("a.py", "/proj/a.py"), file("b.py", "/proj/b.py")],
});
setup({ listFiles });
await waitFor(() => expect(screen.getByText("a.py")).toBeInTheDocument());
const rowA = screen.getByText("a.py").closest(".ft-row")!;
const rowB = screen.getByText("b.py").closest(".ft-row")!;
await userEvent.click(rowA);
await userEvent.click(rowB);
expect(rowA).not.toHaveClass("active");
expect(rowB).toHaveClass("active");
});

it("加载失败 → 显示失败提示(error 态)", async () => {
const listFiles = vi.fn(async () => {
throw new Error("boom");
});
setup({ listFiles, t: (k) => (k === "result.treeLoadFailed" ? "LOAD_FAILED" : k) });
await waitFor(() => expect(screen.getByText("LOAD_FAILED")).toBeInTheDocument());
});

it("root 为空 → 显示 empty 提示(result.filesEmpty)", () => {
setup({ root: "" });
expect(screen.getByTestId("filetree-empty")).toHaveTextContent("No workspace");
});

it("根目录可折叠/展开(toggleDir 绑定,rant 2026-08-13T12:47:18)", async () => {
const listFiles = makeListFiles({ "/proj": [file("x.txt", "/proj/x.txt")] });
setup({ listFiles });
await waitFor(() => expect(screen.getByText("x.txt")).toBeInTheDocument());
const rootRow = document.querySelector(".ft-root")!;
await userEvent.click(rootRow);
expect(rootRow.querySelector(".ft-kids")).toHaveClass("hidden");
await userEvent.click(rootRow);
await waitFor(() => expect(rootRow.querySelector(".ft-kids")!).not.toHaveClass("hidden"));
});

it("行内缩进随深度递增(padding-left = 8 + depth*16)", async () => {
const listFiles = makeListFiles({
"/proj": [dir("src", "/proj/src")],
"/proj/src": [file("deep.ts", "/proj/src/deep.ts")],
});
setup({ listFiles });
await waitFor(() => expect(screen.getByText("src")).toBeInTheDocument());
await userEvent.click(screen.getByText("src").closest(".ft-row")!);
await waitFor(() => expect(screen.getByText("deep.ts")).toBeInTheDocument());
const deepRow = screen.getByText("deep.ts").closest(".ft-row")!;
expect((deepRow as HTMLElement).style.paddingLeft).toBe("40px"); // 8 + 2*16
});
});
231 changes: 231 additions & 0 deletions emrg/gui/renderer/src/components/FileTree.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from "react";
import { chevronFor, iconFor, rootNameFrom, sortEntries, type FileEntry, type ListFiles, type OpenFileHandler } from "../lib/fileTree";

/**
* FileTree — Batch 3: 工作区文件浏览器(Tab「文件」内容)。
* 从 vanilla `js/file-tree.js` 迁移为 React 组件:
*
* - 懒加载目录树:目录行点击 → listFiles(path) 拉子项(已加载目录缓存,折叠不重新拉取)
* - 展开态持久:Map<path, bool>(VS Code 行为,重新 render 不丢);根默认展开
* - 选中态:文件行点击 → .active(单选)+ onOpenFile(path)
* - 类名与 vanilla CSS 完全一致(.ft-row/.ft-dir/.ft-file/.ft-head/.ft-icon/
* .ft-chevron/.ft-name/.ft-kids/.ft-hint/.result-empty),Batch 5 CSS 直接复用
* - listFiles/onOpenFile 注入(不直接碰 daemon/IPC,Batch 5 接线)
*/
export interface FileTreeProps {
root: string;
listFiles?: ListFiles;
onOpenFile?: OpenFileHandler;
/** 测试可注入 i18n 文案(默认英文占位,Batch 5 接 t()) */
t?: (key: string) => string;
}

interface DirState {
loaded: boolean;
loading: boolean;
error: boolean;
entries: FileEntry[];
}

const defaultT = (key: string): string => {
switch (key) {
case "result.treeLoading":
return "Loading…";
case "result.treeLoadFailed":
return "Failed to load";
case "result.filesEmpty":
return "No workspace";
default:
return key;
}
};

/** 默认 listFiles(Batch 5 前不接线 IPC;组件测试注入假实现) */
const noopList: ListFiles = async () => ({ entries: [] });

function FileTreeNode({
entry,
depth,
expanded,
expandedMap,
selectedPath,
dirs,
onToggleDir,
onSelectFile,
onOpenFile,
t,
}: {
entry: FileEntry;
depth: number;
expanded: boolean;
expandedMap: Map<string, boolean>;
selectedPath: string | null;
dirs: Map<string, DirState>;
onToggleDir: (entry: FileEntry) => void;
onSelectFile: (path: string) => void;
onOpenFile: OpenFileHandler;
t: (key: string) => string;
}) {
const isDir = entry.type === "dir";
const st = dirs.get(entry.path);
const kids = isDir && st ? st.entries : [];

return (
<div
className={`ft-row ${isDir ? "ft-dir" : "ft-file"}${selectedPath === entry.path ? " active" : ""}`}
data-path={entry.path}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={(e) => {
e.stopPropagation();
if (isDir) onToggleDir(entry);
else {
onSelectFile(entry.path);
onOpenFile(entry.path);
}
}}
>
<div className="ft-head">
{isDir && (
<span
className="ft-chevron"
dangerouslySetInnerHTML={{ __html: chevronFor(expanded) }}
/>
)}
<span className="ft-icon" dangerouslySetInnerHTML={{ __html: iconFor(entry, expanded) }} />
<span className="ft-name">{entry.name}</span>
</div>
{isDir && (
<div className={`ft-kids${expanded ? "" : " hidden"}`}>
{expanded && st && !st.loaded && !st.loading && st.error && (
<div className="ft-hint">{t("result.treeLoadFailed")}</div>
)}
{expanded && st && st.loading && !st.loaded && (
<div className="ft-hint">{t("result.treeLoading")}</div>
)}
{expanded &&
st &&
st.loaded &&
kids.map((child) => (
<FileTreeNode
key={child.path}
entry={child}
depth={depth + 1}
expanded={expandedMap.get(child.path) || false}
expandedMap={expandedMap}
selectedPath={selectedPath}
dirs={dirs}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onOpenFile={onOpenFile}
t={t}
/>
))}
</div>
)}
</div>
);
}

export function FileTree({ root, listFiles = noopList, onOpenFile = () => {}, t = defaultT }: FileTreeProps) {
const [dirs, setDirs] = useState<Map<string, DirState>>(new Map());
const [expanded, setExpanded] = useState<Map<string, boolean>>(new Map());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const listRef = useRef(listFiles);
listRef.current = listFiles;

// 切会话 → 重置根 + 清缓存(vanilla setSession/setRoot)
useEffect(() => {
setDirs(new Map());
setExpanded(new Map([[root, true]]));
setSelectedPath(null);
}, [root]);

const ensureDir = (path: string): DirState => {
let st = dirs.get(path);
if (!st) {
st = { loaded: false, loading: false, error: false, entries: [] };
setDirs((prev) => new Map(prev).set(path, st!));
}
return st;
};

const expandDir = async (path: string) => {
let st = dirs.get(path);
if (!st) {
st = { loaded: false, loading: false, error: false, entries: [] };
setDirs((prev) => new Map(prev).set(path, st!));
}
if (st.loaded || st.loading) return;
setDirs((prev) => new Map(prev).set(path, { ...st!, loading: true }));
try {
const res = await listRef.current(path);
setDirs((prev) => new Map(prev).set(path, { loaded: true, loading: false, error: false, entries: res.entries || [] }));
} catch {
setDirs((prev) => new Map(prev).set(path, { loaded: true, loading: false, error: true, entries: [] }));
}
};

// 根自动展开(fire-and-forget)
useEffect(() => {
if (root) void expandDir(root);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [root]);

const toggleDir = (entry: FileEntry) => {
const wasExpanded = expanded.get(entry.path) || false;
if (wasExpanded) {
setExpanded((prev) => new Map(prev).set(entry.path, false));
} else {
setExpanded((prev) => new Map(prev).set(entry.path, true));
void expandDir(entry.path);
}
};

if (!root) {
return <div className="result-empty" data-testid="filetree-empty">{t("result.filesEmpty")}</div>;
}

const st = dirs.get(root);
const rootExpanded = expanded.get(root) !== false; // 根默认展开
const rootKids = st && st.loaded ? sortEntries(st.entries) : [];

return (
<div className="ft-tree" data-testid="filetree" data-root={root}>
<div
className="ft-row ft-dir ft-root"
data-path={root}
style={{ paddingLeft: "8px" }}
onClick={(e) => {
e.stopPropagation();
toggleDir({ name: rootNameFrom(root), path: root, type: "dir" });
}}
>
<div className="ft-head">
<span className="ft-chevron" dangerouslySetInnerHTML={{ __html: chevronFor(rootExpanded) }} />
<span className="ft-icon" dangerouslySetInnerHTML={{ __html: iconFor({ name: rootNameFrom(root), type: "dir" }, rootExpanded) }} />
<span className="ft-name">{rootNameFrom(root)}</span>
</div>
<div className={`ft-kids${rootExpanded ? "" : " hidden"}`}>
{rootExpanded && st && st.loading && !st.loaded && <div className="ft-hint">{t("result.treeLoading")}</div>}
{rootExpanded && st && st.loaded && st.error && <div className="ft-hint">{t("result.treeLoadFailed")}</div>}
{rootExpanded &&
rootKids.map((child) => (
<FileTreeNode
key={child.path}
entry={child}
depth={1}
expanded={expanded.get(child.path) || false}
expandedMap={expanded}
selectedPath={selectedPath}
dirs={dirs}
onToggleDir={toggleDir}
onSelectFile={(p) => setSelectedPath(p)}
onOpenFile={onOpenFile}
t={t}
/>
))}
</div>
</div>
</div>
);
}
Loading
Loading