') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); emrg: GUI workspace panel P2.3+P3.4 — WebContentsView HTML preview (rant 2026-08-11T12:20:35) by argszero · Pull Request #671 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 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` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 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 DownExpand Up@@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (694) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 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` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 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
2 changes: 1 addition & 1 deletion README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,7 +147,7 @@ EMRG 不只是追赶——它自己追上来。

贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。

快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(205 项:43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)
快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(212 项:43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,7 +147,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve

Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md).

Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)
Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)

---

Expand Down
118 changes: 117 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,10 +5,11 @@
* 安全:contextIsolation + nodeIntegration:false + sandbox:true(renderer 零网络权限)。
*/

const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
const { app, BrowserWindow, dialog, ipcMain, shell, WebContentsView } = require("electron");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { pathToFileURL } = require("url");
const { spawn } = require("child_process");
const { parse: parseToml, stringify: stringifyToml } = require("smol-toml");
const { generateSessionId, SESSION_ID_RE } = require("./daemon_client");
Expand DownExpand Up@@ -45,6 +46,13 @@ function main() {
let openSessions = new Map();
let guiStateTimer = null;
const GUI_STATE_DEBOUNCE_MS = 1000;
// P2.3(rant 12:20:35):HTML 预览 WebContentsView——懒创建、单实例复用、右对齐 bounds 同步。
// renderer 崩溃 reload 后由 renderer 侧拉取(emrg:getPreviewState)恢复(main 是真相源)。
let previewView = null; // WebContentsView 实例(首次打开 HTML tab 才创建)
let previewAdded = false; // 已 addChildView(防重复添加)
let previewPath = null; // 当前加载的预览文件绝对路径(null = 无)
let previewVisible = false; // 当前是否显示(激活 HTML tab)
let previewLayout = { width: 280, collapsed: false, contentTop: 0 }; // renderer 上报的面板布局

// ── 窗口 ────────────────────────────────────────────────

Expand All@@ -61,12 +69,17 @@ function main() {
},
});
win.loadFile(path.join(__dirname, "renderer", "index.html"));
// P2.3:窗口尺寸变化 → 预览 bounds 跟随(右对齐矩形)
win.on("resize", () => updatePreviewBounds());
// G87:窗口状态持久化
restoreWindowBounds(win);
win.on("close", () => saveWindowBounds(win));
// G101:renderer 崩溃恢复
win.webContents.on("render-process-gone", () => {
logger.warn("[gui] renderer gone — reloading");
// P2.3:预览 view 是独立 WebContents,崩溃不影响它;reload 完成后 renderer
// 经 emrg:getPreviewState 拉取当前预览路径重新开 Tab(恢复 bounds/loadURL 一致,
// 防预览与 Tab 栏不匹配,R5-④)
win.loadFile(path.join(__dirname, "renderer", "index.html"));
});
win.webContents.on("unresponsive", () => {
Expand DownExpand Up@@ -462,6 +475,38 @@ vision = false
return { content: frame.content || "", binary: !!frame.binary, truncated: !!frame.truncated, totalLines: frame.total_lines };
});

// ── P2.3 + P3.4(rant 12:20:35):HTML 预览 WebContentsView ──────────
// 混合模型:非 HTML 走 renderer DOM 查看器;.html/.htm 走内嵌浏览器预览。
// 懒创建(R7-⑤)+ 单实例复用(一次只显示一个预览,切换 = 重新加载 R7-⑥)。
ipcMain.handle("emrg:previewHtml", async (_e, { path: p } = {}) => showPreview(p));

ipcMain.handle("emrg:closePreview", async (_e, { path: p } = {}) => {
// 关闭/切走 HTML tab → 隐藏预览(懒销毁:窗口关闭自动回收,R5-⑤)
const target = typeof p === "string" && p.trim() ? path.resolve(p.trim()) : null;
if (previewVisible && (!target || previewPath === target)) {
previewVisible = false;
previewPath = null;
updatePreviewBounds();
}
return { ok: true };
});

// renderer 上报面板布局(宽度/折叠/内容区顶部 = Tab 栏高)→ main 调 setBounds(R2-⑧)
ipcMain.handle("emrg:panelResized", async (_e, { width, collapsed, contentTop } = {}) => {
previewLayout = {
width: typeof width === "number" && width > 0 ? width : previewLayout.width,
collapsed: collapsed === true,
contentTop: typeof contentTop === "number" && contentTop >= 0 ? contentTop : previewLayout.contentTop,
};
updatePreviewBounds();
return { ok: true };
});

// renderer 崩溃 reload 后拉取当前预览状态(main 是真相源,R5-④ 恢复通道)
ipcMain.handle("emrg:getPreviewState", async () => ({
path: previewVisible && previewPath ? previewPath : null,
}));

ipcMain.handle("emrg:listSkills", async () => {
// GUI / 指令 P3:/skills — 读取技能列表(TUI 本地 load_skills 等价物,daemon 无协议)
// 技能在 ~/.emrg/skills/*.md(user)与 <projectDir>/.emrg/skills/*.md(project)
Expand DownExpand Up@@ -1017,6 +1062,72 @@ vision = false
}
}

// ── P2.3 + P3.4:HTML 预览 WebContentsView ────────────────────────

/** 懒创建预览 view(R7-⑤):sandbox + contextIsolation 对齐主窗口;安全清单(缺口 8):
* setWindowOpenHandler 禁新窗 + will-frame-navigate 仅允许 file:// 主框架导航(防 HTML
* 内跳转远程 URL)。程序化 loadURL 不走 will-frame-navigate——入口只有 showPreview,
* 已校验扩展名 + 绝对路径 + 文件存在,无绕过路径。 */
function ensurePreviewView() {
if (previewView) return previewView;
previewView = new WebContentsView({
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false },
});
previewView.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
previewView.webContents.on("will-frame-navigate", (event, url, isInPlace, isMainFrame) => {
if (isMainFrame && !url.startsWith("file:")) event.preventDefault();
});
return previewView;
}

/** 右对齐矩形(R4-②):x = winW - panelW、y = contentTop(Tab 栏高)、w = panelW、
* h = winH - contentTop。折叠 → 置不可见区域(0 尺寸 + 不可见)。纯函数,便于单测。 */
function previewRect(winW, winH, layout) {
if (layout.collapsed) return { x: winW, y: 0, width: 0, height: 0 };
const width = Math.max(40, Math.min(layout.width || 280, winW));
const contentTop = layout.contentTop || 0;
return {
x: Math.max(0, winW - width),
y: contentTop,
width,
height: Math.max(0, winH - contentTop),
};
}

function updatePreviewBounds() {
if (!previewView || !win || win.isDestroyed()) return;
if (!previewVisible) { previewView.setVisible(false); return; }
const cb = win.getContentBounds();
const r = previewRect(cb.width, cb.height, previewLayout);
previewView.setBounds(r);
previewView.setVisible(true);
}

/** 显示 HTML 预览:校验 → 懒创建 → loadURL(切换 = 重新加载 R7-⑥)→ bounds 同步 */
async function showPreview(p) {
if (typeof p !== "string" || !p.trim()) return { ok: false, error: "invalid path" };
const abs = path.resolve(p.trim());
if (!/\.html?$/i.test(abs)) return { ok: false, error: "not_html" };
if (!fs.existsSync(abs)) return { ok: false, error: "file_not_found" };
try {
const view = ensurePreviewView();
if (!previewAdded && win && !win.isDestroyed()) {
win.contentView.addChildView(view);
previewAdded = true;
}
if (previewPath !== abs) {
await view.webContents.loadURL(pathToFileURL(abs).href);
previewPath = abs;
}
previewVisible = true;
updatePreviewBounds();
return { ok: true };
} catch (e) {
logger.warn(`[gui] preview load failed: ${e.message}`);
return { ok: false, error: String(e.message || "load_failed") };
}
}

// ── 应用生命周期 ────────────────────────────────────────

app.on("second-instance", () => {
Expand DownExpand Up@@ -1073,6 +1184,11 @@ vision = false
cancelReconnect();
persistGuiStateNow(); // P4:退出前冲刷未落盘的打开会话状态(防抖 timer 取消)
connManager?.closeAll(); // P2:关闭全部会话连接 + daemon 级连接
// P2.3:窗口关闭自动销毁子 view(R5-⑤ 无需手动清理);复位状态防二次使用
previewView = null;
previewAdded = false;
previewPath = null;
previewVisible = false;
if (process.platform !== "darwin") app.quit();
});

Expand Down
4 changes: 4 additions & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,10 @@ const api = {
readMemory: (payload) => ipcRenderer.invoke("emrg:readMemory", payload),
listFiles: (payload) => ipcRenderer.invoke("emrg:listFiles", payload), // 右栏工作区 P1:目录树
readFile: (payload) => ipcRenderer.invoke("emrg:readFile", payload), // 右栏工作区 P1:文件查看器
previewHtml: (payload) => ipcRenderer.invoke("emrg:previewHtml", payload), // P2.3:HTML 预览(WebContentsView)
closePreview: (payload) => ipcRenderer.invoke("emrg:closePreview", payload), // P2.3:关闭/切走 HTML 预览
panelResized: (payload) => ipcRenderer.invoke("emrg:panelResized", payload), // P2.3:面板布局上报 → bounds 同步
getPreviewState: () => ipcRenderer.invoke("emrg:getPreviewState"), // P2.3:renderer 崩溃恢复拉取
listSkills: () => ipcRenderer.invoke("emrg:listSkills"),
listProjects: () => ipcRenderer.invoke("emrg:listProjects"),
listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload),
Expand Down
12 changes: 12 additions & 0 deletions emrg/gui/renderer/css/layout.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,18 @@ body.sidebar-collapsed #app {
padding: var(--sp-3);
overflow-x: auto;
}
/* P3.4:HTML 预览占位(混合模型——WebContentsView 叠加其上;折叠/隐藏时占位可见) */
.viewer-html {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-3);
box-sizing: border-box;
}
.viewer-html .result-empty {
max-width: 100%;
}

/* ── P3.2:产物文件行(write/edit 成功文件,点击打开查看器 Tab) ── */
.artifact-row {
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ const I18N = (() => {
"result.viewerBinary": "二进制文件,用系统工具打开",
"result.viewerError": "无法读取文件(可能已被删除或无权限)",
"result.viewerOpen": "用系统工具打开",
"result.htmlPreview": "HTML 预览(内嵌浏览器)",

// 设置
"settings.title": "设置",
Expand DownExpand Up@@ -397,6 +398,7 @@ const I18N = (() => {
"result.viewerBinary": "Binary file — open with system tool",
"result.viewerError": "Cannot read file (deleted or no permission)",
"result.viewerOpen": "Open with system tool",
"result.htmlPreview": "HTML preview (embedded browser)",

// Settings
"settings.title": "Settings",
Expand Down
Loading
Loading