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@@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg

Python: `uv run pytest tests/ -v` (1148) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (451: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 15 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (467: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 31 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `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
38 changes: 38 additions & 0 deletions emrg/gui/renderer/css/layout.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -393,6 +393,44 @@
margin-top: var(--sp-2);
}

/* ── Stage 2 mini 格式栏(rant 2026-08-28T14:07:29:粗体/斜体/删除线/代码/链接/列表/引用/标题) ── */
.fmt-bar {
display: flex;
gap: 2px;
padding: 0 var(--sp-2) var(--sp-2);
}
.fmt-btn {
min-width: 28px;
height: 26px;
padding: 0 var(--sp-2);
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-input);
background: transparent;
color: var(--text-2);
font-size: var(--fs-secondary);
line-height: 1;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.fmt-btn:hover {
background: var(--bg-soft);
color: var(--text-1);
}
.fmt-btn.active {
background: var(--accent-soft);
color: var(--accent);
}
.fmt-label-bold { font-weight: 700; }
.fmt-label-italic { font-style: italic; }
.fmt-label-strike { text-decoration: line-through; }
.fmt-label-code { font-family: var(--font-mono, monospace); font-size: 12px; }
.fmt-label-heading { font-weight: 600; }

/* ── 断连横幅 ───────────────────────────── */
#conn-banner {
position: absolute;
Expand Down
236 changes: 233 additions & 3 deletions emrg/gui/renderer/src/components/Composer.test.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Editor } from "@tiptap/react";
Expand DownExpand Up@@ -37,11 +37,11 @@ function setup(
});
};
/** 在编辑器 DOM 上派发真实 keydown(tiptap editorProps.handleKeyDown 拦截) */
const press = (key: string, mods: { shiftKey?: boolean; ctrlKey?: boolean } = {}) => {
const press = (key: string, mods: { shiftKey?: boolean; ctrlKey?: boolean } = {}, code?: string) => {
act(() => {
const dom = editorRef.current?.view.dom;
if (!dom) throw new Error("editor not mounted");
dom.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...mods }));
dom.dispatchEvent(new KeyboardEvent("keydown", { key, code, bubbles: true, cancelable: true, ...mods }));
});
};
return { ...utils, editor, input, type, press };
Expand DownExpand Up@@ -308,3 +308,233 @@ describe("Composer — 发送流", () => {
void version;
});
});

describe("Composer — 格式栏与快捷键(Stage 2, rant 14:07:29)", () => {
/** 全选 + 取 markdown(tiptap-markdown 序列化断言) */
function mdOf(s: ReturnType<typeof setup>, editor: Editor): string {
const md = (editor.storage as unknown as { markdown: { getMarkdown(): string } }).markdown.getMarkdown();
return md.trim();
}

it("格式栏渲染 9 个按钮(粗体/斜体/删除线/代码/链接/无序/有序/引用/标题)", async () => {
const store = createTranscriptStore();
const s = setup(store);
await waitEditor(s);
expect(screen.getByTestId("fmt-bar")).toBeInTheDocument();
const ids = ["bold", "italic", "strike", "code", "link", "bullet", "ordered", "quote", "heading"];
for (const id of ids) expect(screen.getByTestId(`fmt-${id}`)).toBeInTheDocument();
});

it("粗体按钮 → 选中文本序列化为 **bold**", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("hello bold");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-bold"));
expect(mdOf(s, editor)).toContain("**hello bold**");
// 激活态高亮
await waitFor(() => expect(screen.getByTestId("fmt-bold")).toHaveClass("active"));
});

it("斜体按钮 → *italic*", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("emph");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-italic"));
expect(mdOf(s, editor)).toContain("*emph*");
});

it("删除线按钮 → ~~strike~~", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("obsolete");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-strike"));
expect(mdOf(s, editor)).toContain("~~obsolete~~");
});

it("行内代码按钮 → `code`", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("const x");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-code"));
expect(mdOf(s, editor)).toContain("`const x`");
});

it("链接按钮(window.prompt 输入 URL)→ [label](url);再次点击解除链接", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://example.com");
act(() => {
editor.commands.insertContent("my link");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-link"));
expect(mdOf(s, editor)).toContain("[my link](https://example.com)");
expect(promptSpy).toHaveBeenCalledTimes(1);
// 已激活链接 → 再点解除(不再弹 prompt)
await userEvent.click(screen.getByTestId("fmt-link"));
expect(mdOf(s, editor)).not.toContain("[my link]");
expect(promptSpy).toHaveBeenCalledTimes(1);
promptSpy.mockRestore();
});

it("无序列表按钮 → - item", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("todo");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-bullet"));
expect(mdOf(s, editor)).toContain("- todo");
});

it("有序列表按钮 → 1. item", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("step");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-ordered"));
expect(mdOf(s, editor)).toContain("1. step");
});

it("引用按钮 → > quote", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("noted");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-quote"));
expect(mdOf(s, editor)).toContain("> noted");
});

it("标题按钮循环 p→h1→h2→h3→p(单按钮四态)", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("section");
});
// 每次点击前选中文本区间(0..7,"section" 长度)——selectAll 会把 toggleHeading
// 追加的尾随空段落也选上导致级别误读(tiptap v3 混合选区 isActive 不可靠)
const selectText = () => {
act(() => {
editor.commands.setTextSelection({ from: 0, to: 7 });
});
};
const click = async () => {
selectText();
await userEvent.click(screen.getByTestId("fmt-heading"));
};
await click();
expect(mdOf(s, editor)).toContain("# section");
await click();
expect(mdOf(s, editor)).toContain("## section");
await click();
expect(mdOf(s, editor)).toContain("### section");
await click();
expect(mdOf(s, editor)).toContain("section"); // 回到段落(无 # 前缀)
expect(mdOf(s, editor)).not.toMatch(/^#+ /);
});

it("⌘B 快捷键(StarterKit 内置)→ 粗体激活", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("shortcut");
editor.commands.selectAll();
});
s.press("b", { ctrlKey: true }); // jsdom 无 metaKey → ctrlKey 等价 Mod
await waitFor(() => expect(editor.isActive("bold")).toBe(true));
expect(mdOf(s, editor)).toContain("**shortcut**");
});

it("⌘K 快捷键(无内置绑定,显式补挂)→ 链接 prompt", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://shortcut.dev");
act(() => {
editor.commands.insertContent("kb");
editor.commands.selectAll();
});
s.press("k", { ctrlKey: true });
await waitFor(() => expect(mdOf(s, editor)).toContain("[kb](https://shortcut.dev)"));
expect(promptSpy).toHaveBeenCalledTimes(1);
promptSpy.mockRestore();
});

it("⌘⇧8 快捷键(无内置绑定)→ 无序列表", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("listy");
editor.commands.selectAll();
});
s.press("8", { ctrlKey: true, shiftKey: true }, "Digit8");
// isActive("bulletList") 在 tiptap v3 切换后选区不可靠 → 断言 markdown 输出
await waitFor(() => expect(mdOf(s, editor)).toContain("- listy"));
});

it("⌘⇧7 快捷键(无内置绑定)→ 有序列表", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("seq");
editor.commands.selectAll();
});
s.press("7", { ctrlKey: true, shiftKey: true }, "Digit7");
await waitFor(() => expect(mdOf(s, editor)).toContain("1. seq"));
});

it("粘贴净化:transformPastedHTML 剥离 script/事件属性(DOMPurify)", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
const sanitize = editor.options.editorProps.transformPastedHTML as (html: string) => string;
const out = sanitize('<b>ok</b><script>alert(1)</script><img src="x" onerror="alert(2)"><a href="https://a.dev" onclick="evil()">lnk</a>');
expect(out).toContain("<b>ok</b>");
expect(out).toContain('href="https://a.dev"');
expect(out).not.toContain("<script");
expect(out).not.toContain("onerror");
expect(out).not.toContain("onclick");
});

it("格式命令作用于格式栏点击后选区保持(mousedown preventDefault 防失焦)", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("keep selection");
editor.commands.selectAll();
});
await userEvent.click(screen.getByTestId("fmt-italic"));
// 点击格式按钮后选区仍在(mousedown preventDefault 防失焦)→ 命令作用于选中文本
expect(mdOf(s, editor)).toContain("*keep selection*");
});
});
Loading
Loading