diff --git a/MiniApp/Demo/git-graph/README.md b/MiniApp/Demo/git-graph/README.md
new file mode 100644
index 00000000..86c8a8f9
--- /dev/null
+++ b/MiniApp/Demo/git-graph/README.md
@@ -0,0 +1,181 @@
+# Git Graph Demo MiniApp / Git Graph 示例 MiniApp
+
+[English](#english) | [中文](#中文)
+
+---
+
+## English
+
+This demo showcases BitFun MiniApp's full-stack collaboration capability — specifically, using a third-party npm package (`simple-git`) inside a Node.js/Bun Worker to read a local Git repository and render an interactive commit graph.
+
+### Features
+
+- **Pick a repository** — opens a native directory picker via `app.dialog.open({ directory: true })`
+- **Commit graph** — Worker fetches `git.graphData` (log + refs + stashes + uncommitted in one call); UI renders commit nodes and branch lines as SVG
+- **Branches & status** — shows current branch, full branch list, and workspace status (modified / staged / untracked)
+- **Commit detail** — click any commit to view author, timestamp, diff stat, and per-file diff
+- **Branch management** — create, delete, rename, checkout local/remote branches
+- **Merge / Rebase** — merge a branch into HEAD, rebase HEAD onto a branch
+- **Cherry-pick / Revert / Reset** — cherry-pick a commit, revert with a new commit, or hard/mixed/soft reset
+- **Push / Fetch** — push to remote, fetch with prune, fetch a remote branch into a local branch
+- **Remote management** — add, remove, update remote URLs via the Remotes panel
+- **Stash** — push, apply, pop, drop, and branch-from-stash operations
+- **Search commits** — filter commit list by message, hash, or author
+- **Tag management** — add lightweight or annotated tags, delete tags, push tags
+
+### Data Flow
+
+1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC)
+2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call`
+3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC)
+4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package
+5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel
+
+### Directory Structure
+
+```
+miniapps/git-graph/
+├── README.md # this file
+├── meta.json # metadata & permissions (fs/shell/node)
+├── package.json # npm deps (simple-git) + build script
+├── storage.json # app KV (e.g. last opened repo path)
+└── source/
+ ├── index.html # UI skeleton
+ ├── build.js # build script: concat ui/*.js → ui.js, styles/*.css → style.css
+ ├── ui.js # frontend entry (generated by build, do not edit directly)
+ ├── style.css # style entry (generated by build, do not edit directly)
+ ├── ui/ # frontend modules (run `npm run build` after editing)
+ │ ├── state.js, theme.js, main.js, bootstrap.js
+ │ ├── graph/layout.js, graph/renderRowSvg.js
+ │ ├── components/contextMenu.js, modal.js, findWidget.js
+ │ ├── panels/detailPanel.js, remotePanel.js
+ │ └── services/gitClient.js
+ ├── styles/ # style modules (run `npm run build` after editing)
+ │ ├── tokens.css, layout.css, graph.css
+ │ ├── detail-panel.css, overlay.css
+ │ └── (merge order defined in build.js)
+ ├── worker.js # backend CJS (simple-git wrapper)
+ └── esm_dependencies.json # ESM deps (empty for this demo)
+```
+
+**During development**: after editing `source/ui/*.js` or `source/styles/*.css`, run `npm run build` inside the `miniapps/git-graph` directory to regenerate `source/ui.js` and `source/style.css`. BitFun will pick up the latest build on next load.
+
+### Running in BitFun
+
+1. **Install to user data directory**: copy this folder into BitFun's MiniApp data directory under an `app_id` subdirectory, e.g.:
+ - The data directory is typically `{user_data}/miniapps/`
+ - Create a subdirectory like `git-graph-sample` and place all files from this folder inside it (i.e. `meta.json`, `package.json`, `source/` etc. at the root of that subdirectory)
+
+2. **Or import via API**: if BitFun supports path-based import, use `create_miniapp` or equivalent, pointing to this directory as the source; make sure the `id` in `meta.json` matches the directory name.
+
+3. **Install dependencies**: inside the MiniApp's app directory, run:
+ - `bun install` or `npm install` (matching the runtime BitFun detected)
+ - Or use the "Install Dependencies" action in Toolbox (calls `miniapp_install_deps`)
+
+4. **Compile**: to regenerate `compiled.html`, call `miniapp_recompile` or let BitFun compile automatically when the MiniApp is opened.
+
+5. Open the MiniApp in the Toolbox scene, pick a repository, and the Git Graph will appear.
+
+### Permissions
+
+| Permission | Value | Purpose |
+|---|---|---|
+| `fs.read` | `{appdata}`, `{workspace}`, `{user-selected}` | Read app data, workspace, and user-selected repo |
+| `fs.write` | `{appdata}` | Write app-own data only (e.g. storage) |
+| `shell.allow` | `["git"]` | `simple-git` needs to invoke the system `git` binary |
+| `node.enabled` | `true` | Enable JS Worker to execute `simple-git` logic in `worker.js` |
+
+### Technical Highlights
+
+- **Client-side third-party library**: `require('simple-git')` in `worker.js` runs inside a Bun or Node.js Worker process — no need to reimplement Git in Rust
+- **Zero custom dialect**: UI is plain concatenated JavaScript (IIFE modules sharing `window.__GG`), Worker is standard CJS — no custom framework or transpiler required; `window.app` is the unified Bridge API
+- **ESM dependencies**: this demo uses plain vanilla JS; `esm_dependencies.json` is empty — add React, D3, etc. there to have them served via Import Map from esm.sh
+
+---
+
+## 中文
+
+本示例展示 BitFun MiniApp 的前后端协同能力,尤其是通过 Node.js/Bun 在端侧使用三方 npm 库(`simple-git`)读取 Git 仓库并渲染交互式提交图谱。
+
+### 功能
+
+- **选择仓库**:通过 `app.dialog.open({ directory: true })` 打开原生目录选择器,选择本地 Git 仓库
+- **提交图谱**:Worker 端通过 `git.graphData`(一次调用返回提交列表 + refs + stash + 未提交变更),UI 端用 SVG 绘制提交节点与分支线
+- **分支与状态**:展示当前分支、所有分支列表及工作区状态(modified/staged/untracked)
+- **提交详情**:点击某个 commit 可查看作者、时间、diff stat 及逐文件 diff
+- **分支管理**:创建、删除、重命名、checkout 本地/远程分支
+- **Merge / Rebase**:将分支合并到 HEAD,或将 HEAD rebase 到目标分支
+- **Cherry-pick / Revert / Reset**:cherry-pick 指定 commit,revert 生成新提交,或 hard/mixed/soft reset
+- **Push / Fetch**:推送到远程,带 prune 的 fetch,将远程分支 fetch 到本地分支
+- **远程管理**:通过远程面板添加、删除、修改远程 URL
+- **Stash**:push、apply、pop、drop 及从 stash 创建分支
+- **搜索提交**:按消息、hash 或作者过滤提交列表
+- **Tag 管理**:添加轻量/注解 tag,删除 tag,推送 tag
+
+### 前后端协同流程
+
+1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC
+2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call`
+3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC)
+4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包
+5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情
+
+### 目录结构
+
+```
+miniapps/git-graph/
+├── README.md # 本说明
+├── meta.json # 元数据与权限(fs/shell/node)
+├── package.json # npm 依赖(simple-git)及 build 脚本
+├── storage.json # 应用 KV(如最近打开的仓库路径)
+└── source/
+ ├── index.html # UI 骨架
+ ├── build.js # 构建脚本:合并 ui/*.js → ui.js,styles/*.css → style.css
+ ├── ui.js # 前端入口(由 build 生成,勿直接编辑)
+ ├── style.css # 样式入口(由 build 生成,勿直接编辑)
+ ├── ui/ # 前端模块(编辑后需运行 npm run build)
+ │ ├── state.js, theme.js, main.js, bootstrap.js
+ │ ├── graph/layout.js, graph/renderRowSvg.js
+ │ ├── components/contextMenu.js, modal.js, findWidget.js
+ │ ├── panels/detailPanel.js, remotePanel.js
+ │ └── services/gitClient.js
+ ├── styles/ # 样式模块(编辑后需运行 npm run build)
+ │ ├── tokens.css, layout.css, graph.css
+ │ ├── detail-panel.css, overlay.css
+ │ └── (合并顺序见 build.js)
+ ├── worker.js # 后端 CJS(simple-git 封装)
+ └── esm_dependencies.json # ESM 依赖(本示例为空)
+```
+
+**开发时**:修改 `source/ui/*.js` 或 `source/styles/*.css` 后,在 `miniapps/git-graph` 目录执行 `npm run build` 生成 `source/ui.js` 与 `source/style.css`,BitFun 加载时会使用最新构建结果。
+
+### 在 BitFun 中运行
+
+1. **安装到用户数据目录**:将本目录复制到 BitFun 的 MiniApp 数据目录下,并赋予一个 app_id 子目录,例如:
+ - 数据目录一般为 `{user_data}/miniapps/`
+ - 新建子目录如 `git-graph-sample`,将本目录中所有文件按相同结构放入其中(即 `meta.json`、`package.json`、`source/` 等在该子目录下)
+
+2. **或通过 API 创建**:若 BitFun 支持从路径导入,可使用 `create_miniapp` 或等价方式,将本目录作为 source 路径导入,并确保 `meta.json` 中的 `id` 与目录名一致。
+
+3. **安装依赖**:在 MiniApp 的 app 目录下执行:
+ - `bun install` 或 `npm install`(与 BitFun 检测到的运行时一致)
+ - 或在 Toolbox 中对该 MiniApp 执行「安装依赖」操作(调用 `miniapp_install_deps`)
+
+4. **编译**:若需重新生成 `compiled.html`,可调用 `miniapp_recompile` 或由 BitFun 在打开该 MiniApp 时自动编译。
+
+5. 在 Toolbox 场景中打开该 MiniApp,选择仓库后即可查看 Git Graph。
+
+### 权限说明
+
+| 权限 | 值 | 用途 |
+|---|---|---|
+| `fs.read` | `{appdata}`、`{workspace}`、`{user-selected}` | 读取应用数据、工作区及用户选择的仓库目录 |
+| `fs.write` | `{appdata}` | 仅写入应用自身数据(如 storage) |
+| `shell.allow` | `["git"]` | `simple-git` 需调用系统 `git` 命令 |
+| `node.enabled` | `true` | 启用 JS Worker,以便执行 `worker.js` 中的 `simple-git` 逻辑 |
+
+### 技术要点
+
+- **端侧三方库**:`worker.js` 中 `require('simple-git')`,在 Bun 或 Node.js Worker 进程中运行,无需在 Rust 中重新实现 Git 能力
+- **无自定义方言**:UI 为普通拼接脚本(IIFE 模块通过 `window.__GG` 共享状态),Worker 为标准 CJS,无需自定义框架或转译器;`window.app` 为统一 Bridge API
+- **ESM 依赖**:本示例 UI 使用纯 vanilla JS,`esm_dependencies.json` 为空;若需 React/D3 等,可在其中声明并由 Import Map 从 esm.sh 加载
diff --git a/MiniApp/Demo/git-graph/meta.json b/MiniApp/Demo/git-graph/meta.json
new file mode 100644
index 00000000..f029c91e
--- /dev/null
+++ b/MiniApp/Demo/git-graph/meta.json
@@ -0,0 +1,25 @@
+{
+ "id": "git-graph-sample",
+ "name": "Git Graph",
+ "description": "交互式 Git 提交图谱,通过 Worker 端 simple-git 读取仓库,UI 端 SVG 渲染。展示前后端协同与端侧三方库使用。",
+ "icon": "📊",
+ "category": "developer",
+ "tags": ["git", "graph", "example"],
+ "version": 1,
+ "created_at": 0,
+ "updated_at": 0,
+ "permissions": {
+ "fs": {
+ "read": ["{appdata}", "{workspace}", "{user-selected}"],
+ "write": ["{appdata}"]
+ },
+ "shell": { "allow": ["git"] },
+ "net": { "allow": [] },
+ "node": {
+ "enabled": true,
+ "max_memory_mb": 256,
+ "timeout_ms": 30000
+ }
+ },
+ "ai_context": null
+}
diff --git a/MiniApp/Demo/git-graph/package.json b/MiniApp/Demo/git-graph/package.json
new file mode 100644
index 00000000..b384c003
--- /dev/null
+++ b/MiniApp/Demo/git-graph/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "miniapp-git-graph",
+ "private": true,
+ "scripts": {
+ "build": "node source/build.js"
+ },
+ "dependencies": {
+ "simple-git": "^3.27.0"
+ }
+}
diff --git a/MiniApp/Demo/git-graph/source/build.js b/MiniApp/Demo/git-graph/source/build.js
new file mode 100644
index 00000000..846ef1c0
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/build.js
@@ -0,0 +1,53 @@
+/**
+ * Git Graph MiniApp — build: concatenate source/ui/*.js → ui.js, source/styles/*.css → style.css.
+ * Run from miniapps/git-graph: node source/build.js
+ */
+const fs = require('fs');
+const path = require('path');
+
+const SOURCE_DIR = path.join(__dirname);
+const ROOT = path.dirname(SOURCE_DIR);
+
+const UI_ORDER = [
+ 'ui/state.js',
+ 'ui/theme.js',
+ 'ui/graph/layout.js',
+ 'ui/graph/renderRowSvg.js',
+ 'ui/services/gitClient.js',
+ 'ui/components/contextMenu.js',
+ 'ui/components/modal.js',
+ 'ui/components/findWidget.js',
+ 'ui/panels/remotePanel.js',
+ 'ui/panels/detailPanel.js',
+ 'ui/main.js',
+ 'ui/bootstrap.js',
+];
+
+const STYLES_ORDER = [
+ 'styles/tokens.css',
+ 'styles/layout.css',
+ 'styles/graph.css',
+ 'styles/detail-panel.css',
+ 'styles/overlay.css',
+];
+
+function concat(files, dir) {
+ let out = '';
+ for (const f of files) {
+ const full = path.join(dir, f);
+ if (!fs.existsSync(full)) {
+ console.warn('Missing:', full);
+ continue;
+ }
+ out += '/* ' + f + ' */\n' + fs.readFileSync(full, 'utf8') + '\n';
+ }
+ return out;
+}
+
+const uiOut = path.join(SOURCE_DIR, 'ui.js');
+const styleOut = path.join(SOURCE_DIR, 'style.css');
+
+fs.writeFileSync(uiOut, concat(UI_ORDER, SOURCE_DIR), 'utf8');
+fs.writeFileSync(styleOut, concat(STYLES_ORDER, SOURCE_DIR), 'utf8');
+
+console.log('Built', uiOut, 'and', styleOut);
diff --git a/MiniApp/Demo/git-graph/source/esm_dependencies.json b/MiniApp/Demo/git-graph/source/esm_dependencies.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/esm_dependencies.json
@@ -0,0 +1 @@
+[]
diff --git a/MiniApp/Demo/git-graph/source/index.html b/MiniApp/Demo/git-graph/source/index.html
new file mode 100644
index 00000000..8b0a0d39
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/index.html
@@ -0,0 +1,134 @@
+
+
+
+
+
+ Git Graph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Git Graph
+
可视化浏览 Git 提交历史、分支与合并
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MiniApp/Demo/git-graph/source/style.css b/MiniApp/Demo/git-graph/source/style.css
new file mode 100644
index 00000000..4a9c0340
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/style.css
@@ -0,0 +1,814 @@
+/* styles/tokens.css */
+/* Git Graph MiniApp — theme tokens (host --bitfun-* when running in BitFun) */
+:root {
+ --bg: var(--bitfun-bg, #0d1117);
+ --bg-surface: var(--bitfun-bg-secondary, #161b22);
+ --bg-hover: var(--bitfun-element-hover, #1c2333);
+ --bg-active: var(--bitfun-element-bg, #1f2a3d);
+ --border: var(--bitfun-border, #30363d);
+ --border-light: var(--bitfun-border-subtle, #21262d);
+ --text: var(--bitfun-text, #e6edf3);
+ --text-sec: var(--bitfun-text-secondary, #8b949e);
+ --text-dim: var(--bitfun-text-muted, #484f58);
+ --accent: var(--bitfun-accent, #58a6ff);
+ --accent-dim: var(--bitfun-accent-hover, #1f6feb);
+ --green: var(--bitfun-success, #3fb950);
+ --red: var(--bitfun-error, #f85149);
+ --orange: var(--bitfun-warning, #d29922);
+ --purple: var(--bitfun-info, #bc8cff);
+ --branch-1: var(--bitfun-accent, #58a6ff);
+ --branch-2: var(--bitfun-success, #3fb950);
+ --branch-3: var(--bitfun-info, #bc8cff);
+ --branch-4: var(--bitfun-warning, #f0883e);
+ --branch-5: #f778ba;
+ --branch-6: #79c0ff;
+ --branch-7: #56d364;
+ --radius: var(--bitfun-radius, 6px);
+ --radius-lg: var(--bitfun-radius-lg, 10px);
+ --graph-node-stroke: var(--bitfun-bg, #0d1117);
+ --graph-uncommitted: var(--text-dim, #808080);
+ --graph-lane-width: 18px;
+ --graph-row-height: 28px;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--bitfun-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif);
+ font-size: 13px;
+ color: var(--text);
+ background: var(--bg);
+ min-height: 100vh;
+ overflow: hidden;
+}
+
+#app { display: flex; flex-direction: column; height: 100vh; }
+
+/* styles/layout.css */
+/* ── Toolbar ─────────────────────── */
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 16px;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ min-height: 44px;
+}
+.toolbar__left, .toolbar__right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.toolbar__path {
+ font-size: 12px;
+ color: var(--text-sec);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 280px;
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+}
+.toolbar__branch-filter { position: relative; }
+.toolbar__branch-filter .chevron { margin-left: 4px; opacity: .8; }
+.dropdown-panel {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 4px;
+ min-width: 220px;
+ max-height: 320px;
+ overflow-y: auto;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0,0,0,.25);
+ z-index: 200;
+ padding: 4px 0;
+}
+.dropdown-panel[aria-hidden="true"] { display: none; }
+.dropdown-panel__item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text);
+}
+.dropdown-panel__item:hover { background: var(--bg-hover); }
+.dropdown-panel__item input[type="checkbox"] { margin: 0; }
+.dropdown-panel__sep { height: 1px; background: var(--border-light); margin: 4px 0; }
+
+/* ── Buttons ─────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background .15s, border-color .15s, color .15s;
+ white-space: nowrap;
+ line-height: 1.4;
+}
+.btn--primary {
+ background: var(--accent-dim);
+ color: #fff;
+ border-color: rgba(88,166,255,.4);
+}
+.btn--primary:hover { background: #2d72c7; }
+.btn--primary:active { background: #2563b5; }
+.btn--secondary {
+ background: var(--bg-active);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+.btn--secondary:hover { background: var(--bg-hover); border-color: var(--border); }
+.btn--secondary:active { background: var(--bg); }
+.btn--icon {
+ padding: 6px 10px;
+ min-width: 32px;
+ min-height: 32px;
+ color: var(--text-sec);
+}
+.btn--icon:hover { background: var(--bg-hover); color: var(--text); }
+.btn--icon:active { background: var(--bg-active); }
+.btn--lg { padding: 8px 20px; font-size: 13px; }
+.btn-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-sec);
+ cursor: pointer;
+ transition: background .15s, color .15s;
+}
+.btn-icon:hover { background: var(--bg-hover); color: var(--text); }
+.btn-icon:active { background: var(--bg-active); }
+.btn-icon--header {
+ width: 28px;
+ height: 28px;
+ color: var(--text-sec);
+}
+.btn-icon--header:hover { background: var(--bg-hover); color: var(--text); }
+.btn-icon--header:active { background: var(--bg-active); }
+
+/* ── Badges ──────────────────────── */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+}
+.badge--branch {
+ background: rgba(88,166,255,.15);
+ color: var(--accent);
+ border: 1px solid rgba(88,166,255,.25);
+}
+.badge--status {
+ background: rgba(63,185,80,.12);
+ color: var(--green);
+ border: 1px solid rgba(63,185,80,.2);
+}
+.badge--status.has-changes {
+ background: rgba(210,153,34,.12);
+ color: var(--orange);
+ border-color: rgba(210,153,34,.2);
+}
+
+/* ── Empty state ─────────────────── */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ gap: 16px;
+ padding: 40px;
+ animation: fadeIn .5s;
+}
+.empty-state__icon { opacity: .6; }
+.empty-state__graph-icon .empty-state__node--1 { stroke: var(--branch-1); }
+.empty-state__graph-icon .empty-state__node--2 { stroke: var(--branch-2); }
+.empty-state__graph-icon .empty-state__node--3 { stroke: var(--branch-3); }
+.empty-state__graph-icon .empty-state__node--4 { stroke: var(--branch-4); }
+.empty-state__graph-icon .empty-state__line--1 { stroke: var(--branch-1); }
+.empty-state__graph-icon .empty-state__line--2 { stroke: var(--branch-2); }
+.empty-state__graph-icon .empty-state__line--3 { stroke: var(--branch-3); }
+.empty-state__title {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text);
+}
+.empty-state__desc {
+ font-size: 13px;
+ color: var(--text-sec);
+ max-width: 320px;
+ text-align: center;
+ line-height: 1.5;
+}
+@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+
+/* ── Main layout ─────────────────── */
+.main { display: flex; flex: 1; min-height: 0; min-width: 0; }
+
+/* ── Graph area (commit list) ────── */
+.graph-area { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }
+.graph-area__scroll {
+ flex: 1;
+ min-width: 0;
+ overflow-y: auto;
+ overflow-x: auto;
+}
+.graph-area__scroll::-webkit-scrollbar,
+.graph-area::-webkit-scrollbar { width: 6px; }
+.graph-area__scroll::-webkit-scrollbar-track,
+.graph-area::-webkit-scrollbar-track { background: transparent; }
+.graph-area__scroll::-webkit-scrollbar-thumb,
+.graph-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+.load-more {
+ padding: 12px 16px;
+ text-align: center;
+ border-top: 1px solid var(--border-light);
+}
+
+/* ── Commit row ──────────────────── */
+.commit-row {
+ display: flex;
+ align-items: center;
+ height: var(--graph-row-height, 28px);
+ padding: 0 16px 0 0;
+ cursor: pointer;
+ transition: background .1s;
+ border-bottom: 1px solid var(--border-light);
+}
+.commit-row:hover { background: var(--bg-hover); }
+.commit-row.selected { background: var(--bg-active); }
+.commit-row.find-highlight { background: rgba(88,166,255,.15); }
+.commit-row.compare-selected { box-shadow: inset 0 0 0 2px var(--accent); }
+.commit-row.commit-row--stash .graph-node--stash-outer {
+ fill: none;
+ stroke: var(--orange);
+ stroke-width: 1.5;
+}
+.commit-row.commit-row--stash .graph-node--stash-inner {
+ fill: var(--orange);
+}
+.commit-row__graph {
+ flex-shrink: 0;
+ height: var(--graph-row-height, 28px);
+ overflow: visible;
+ min-width: 0;
+}
+.graph-line--uncommitted { stroke: var(--graph-uncommitted); stroke-dasharray: 2 2; }
+.graph-node--uncommitted { fill: none; stroke: var(--graph-uncommitted); stroke-width: 1.5; }
+
+.commit-row__info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+ padding-left: 8px;
+}
+.commit-row__hash {
+ flex-shrink: 0;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 11px;
+ color: var(--accent);
+ width: 56px;
+}
+.commit-row__message {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12.5px;
+ color: var(--text);
+}
+.commit-row__refs {
+ display: inline-flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.ref-tag {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+.ref-tag--head {
+ background: rgba(88,166,255,.18);
+ color: var(--accent);
+}
+.ref-tag--branch {
+ background: rgba(63,185,80,.14);
+ color: var(--green);
+}
+.ref-tag--tag {
+ background: rgba(210,153,34,.14);
+ color: var(--orange);
+}
+.ref-tag--remote {
+ background: rgba(188,140,255,.14);
+ color: var(--purple);
+}
+
+.commit-row__author {
+ flex-shrink: 0;
+ width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11.5px;
+ color: var(--text-sec);
+ text-align: right;
+}
+.commit-row__date {
+ flex-shrink: 0;
+ width: 90px;
+ font-size: 11px;
+ color: var(--text-dim);
+ text-align: right;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+}
+
+/* styles/graph.css */
+/* ── Graph SVG in rows ───────────── */
+.commit-row__graph svg .graph-line {
+ fill: none;
+ stroke-width: 1.5;
+}
+.commit-row__graph svg .graph-line--uncommitted {
+ stroke: var(--graph-uncommitted);
+ stroke-dasharray: 3 3;
+}
+.commit-row__graph svg .graph-node {
+ stroke-width: 1.5;
+ transition: r 0.15s;
+}
+.commit-row__graph svg .graph-node--commit {
+ stroke: var(--graph-node-stroke);
+}
+.commit-row__graph svg .graph-node--uncommitted {
+ fill: none;
+ stroke: var(--graph-uncommitted);
+}
+.commit-row:hover .commit-row__graph svg .graph-node--commit {
+ r: 5;
+}
+
+/* styles/detail-panel.css */
+/* ── Detail panel ────────────────── */
+.detail-panel {
+ min-width: 420px;
+ max-width: 720px;
+ width: clamp(420px, 36vw, 720px);
+ background: var(--bg-surface);
+ border-left: 1px solid var(--border-light);
+ flex-grow: 0;
+ flex-shrink: 0;
+ flex-basis: clamp(420px, 36vw, 720px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: -4px 0 12px rgba(0,0,0,.08);
+}
+.detail-panel-resizer {
+ width: 6px;
+ flex-shrink: 0;
+ background: var(--border);
+ cursor: col-resize;
+ transition: background .15s;
+ position: relative;
+ z-index: 1;
+}
+.detail-panel-resizer:hover,
+.detail-panel-resizer.dragging { background: var(--accent); }
+@keyframes slideIn { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
+
+.detail-panel__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px 12px 16px;
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-panel__title {
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.detail-panel__header .btn-icon--header { flex-shrink: 0; }
+.detail-panel__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.detail-panel__body::-webkit-scrollbar { width: 6px; }
+.detail-panel__body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+.detail-body__summary {
+ padding: 16px;
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-body__summary .detail-hash { margin-bottom: 6px; }
+.detail-body__summary .detail-message { margin-bottom: 8px; }
+.detail-body__summary .detail-meta { margin-bottom: 8px; }
+.detail-refs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+.detail-loading, .detail-error {
+ padding: 20px;
+ text-align: center;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+.detail-error { color: var(--red); }
+
+.detail-body__files-section {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-body__files-section .detail-section__label {
+ margin-bottom: 8px;
+}
+.detail-code-preview {
+ flex: 1;
+ min-height: 180px;
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--border-light);
+ background: var(--bg);
+}
+.detail-code-preview__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 12px;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-code-preview__filename {
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 12px;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.detail-code-preview__stats {
+ font-size: 11px;
+ color: var(--text-sec);
+ flex-shrink: 0;
+}
+.detail-code-preview__content {
+ flex: 1;
+ overflow: auto;
+ padding: 12px;
+ min-height: 120px;
+}
+.detail-code-preview__content::-webkit-scrollbar { width: 6px; }
+.detail-code-preview__content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+.detail-code-preview__loading, .detail-code-preview__error {
+ padding: 16px;
+ color: var(--text-dim);
+ font-size: 12px;
+}
+.detail-code-preview__error { color: var(--red); }
+.detail-code-preview__diff {
+ margin: 0;
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 11px;
+ line-height: 1.6;
+ white-space: pre;
+ word-break: normal;
+ overflow-x: auto;
+ color: var(--text);
+}
+.detail-code-preview__diff .diff-line { display: block; }
+.detail-code-preview__diff .diff-line.diff-add { color: var(--green); background: rgba(63,185,80,.08); }
+.detail-code-preview__diff .diff-line.diff-del { color: var(--red); background: rgba(248,81,73,.08); }
+.detail-code-preview__diff .diff-line.diff-hunk { color: var(--text-dim); }
+
+.detail-section {
+ margin-bottom: 20px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border-light);
+}
+.detail-section:last-child { border-bottom: none; }
+.detail-section__label {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .06em;
+ color: var(--text-dim);
+ margin-bottom: 8px;
+}
+.detail-hash {
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 12px;
+ color: var(--accent);
+ word-break: break-all;
+ line-height: 1.5;
+}
+.detail-message {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--text);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.detail-meta {
+ font-size: 12px;
+ color: var(--text-sec);
+ line-height: 1.6;
+}
+.detail-meta strong { color: var(--text); font-weight: 500; }
+
+.detail-files { list-style: none; margin: 0; padding: 0; max-height: 200px; overflow-y: auto; }
+.detail-file {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ font-size: 12px;
+ cursor: pointer;
+ transition: background .12s;
+}
+.detail-file:hover { background: var(--bg-hover); }
+.detail-file.detail-file--selected {
+ background: var(--bg-active);
+ outline: 1px solid var(--accent);
+ outline-offset: -1px;
+}
+.detail-file:last-child { border-bottom: none; }
+.detail-file.detail-file--expanded .detail-file-diff { display: block; }
+.detail-file-diff {
+ display: none;
+ margin-top: 10px;
+ padding: 12px;
+ background: var(--bg);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-light);
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 11px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 320px;
+ overflow: auto;
+}
+.diff-line { display: block; }
+.diff-line.diff-add { color: var(--green); background: rgba(63,185,80,.08); }
+.diff-line.diff-del { color: var(--red); background: rgba(248,81,73,.08); }
+.diff-line.diff-hunk { color: var(--text-dim); }
+.detail-file__name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ color: var(--text-sec);
+}
+.detail-file__stat { display: flex; gap: 6px; flex-shrink: 0; font-size: 11px; }
+.stat-add { color: var(--green); }
+.stat-del { color: var(--red); }
+
+/* styles/overlay.css */
+/* ── Loading ─────────────────────── */
+.loading-overlay {
+ position: fixed;
+ inset: 0;
+ background: color-mix(in srgb, var(--bg) 70%, transparent);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ z-index: 100;
+ color: var(--text-sec);
+ font-size: 13px;
+ backdrop-filter: blur(4px);
+}
+.spinner {
+ width: 28px; height: 28px;
+ border: 2.5px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin .8s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* ── Find widget ──────────────────── */
+.find-widget {
+ position: absolute;
+ top: 50px;
+ right: 20px;
+ z-index: 150;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 4px 16px rgba(0,0,0,.2);
+}
+.find-widget__input {
+ width: 220px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: var(--text);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ outline: none;
+}
+.find-widget__input:focus { border-color: var(--accent); }
+.find-widget__result {
+ font-size: 11px;
+ color: var(--text-dim);
+ min-width: 48px;
+}
+
+/* ── Context menu ─────────────────── */
+.context-menu {
+ position: fixed;
+ z-index: 1000;
+ min-width: 180px;
+ max-width: 320px;
+ padding: 4px 0;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0,0,0,.3);
+ font-size: 12px;
+}
+.context-menu[aria-hidden="true"] { display: none; }
+.context-menu__item {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ cursor: pointer;
+ color: var(--text);
+ white-space: nowrap;
+}
+.context-menu__item:hover { background: var(--bg-hover); }
+.context-menu__item:disabled,
+.context-menu__item.context-menu__item--disabled { opacity: .5; cursor: default; }
+.context-menu__sep {
+ height: 1px;
+ margin: 4px 8px;
+ background: var(--border-light);
+}
+
+/* ── Modal dialog ─────────────────── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--bg) 50%, transparent);
+ backdrop-filter: blur(4px);
+}
+.modal-overlay[aria-hidden="true"] { display: none; }
+.modal-dialog {
+ width: 90%;
+ max-width: 440px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 16px 48px rgba(0,0,0,.35);
+}
+.modal-dialog__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+}
+.modal-dialog__title { font-size: 14px; font-weight: 600; margin: 0; }
+.modal-dialog__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+}
+.modal-dialog__footer {
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+}
+.modal-dialog__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.modal-form-group { margin-bottom: 12px; }
+.modal-form-group label {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-dim);
+ margin-bottom: 4px;
+}
+.modal-form-group input,
+.modal-form-group select,
+.modal-form-group textarea {
+ width: 100%;
+ padding: 8px 10px;
+ font-size: 12px;
+ color: var(--text);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+.modal-form-group input:focus,
+.modal-form-group select:focus,
+.modal-form-group textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.modal-form-group .checkbox-wrap { display: flex; align-items: center; gap: 8px; }
+.modal-form-group .checkbox-wrap input { width: auto; }
+
+/* ── Remote panel ────────────────── */
+.remote-panel {
+ width: 320px;
+ max-width: 40%;
+ background: var(--bg-surface);
+ border-left: 1px solid var(--border);
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ animation: slideIn .2s;
+}
+.remote-panel__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+}
+.remote-panel__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+}
+.remote-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ margin-bottom: 6px;
+ background: var(--bg);
+ border: 1px solid var(--border-light);
+}
+.remote-item__name { font-weight: 600; font-size: 12px; }
+.remote-item__url { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.remote-item__actions { display: flex; gap: 4px; }
+
diff --git a/MiniApp/Demo/git-graph/source/styles/detail-panel.css b/MiniApp/Demo/git-graph/source/styles/detail-panel.css
new file mode 100644
index 00000000..818d9629
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/styles/detail-panel.css
@@ -0,0 +1,233 @@
+/* ── Detail panel ────────────────── */
+.detail-panel {
+ min-width: 420px;
+ max-width: 720px;
+ width: clamp(420px, 36vw, 720px);
+ background: var(--bg-surface);
+ border-left: 1px solid var(--border-light);
+ flex-grow: 0;
+ flex-shrink: 0;
+ flex-basis: clamp(420px, 36vw, 720px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: -4px 0 12px rgba(0,0,0,.08);
+}
+.detail-panel-resizer {
+ width: 6px;
+ flex-shrink: 0;
+ background: var(--border);
+ cursor: col-resize;
+ transition: background .15s;
+ position: relative;
+ z-index: 1;
+}
+.detail-panel-resizer:hover,
+.detail-panel-resizer.dragging { background: var(--accent); }
+@keyframes slideIn { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
+
+.detail-panel__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px 12px 16px;
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-panel__title {
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.detail-panel__header .btn-icon--header { flex-shrink: 0; }
+.detail-panel__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.detail-panel__body::-webkit-scrollbar { width: 6px; }
+.detail-panel__body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+.detail-body__summary {
+ padding: 16px;
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-body__summary .detail-hash { margin-bottom: 6px; }
+.detail-body__summary .detail-message { margin-bottom: 8px; }
+.detail-body__summary .detail-meta { margin-bottom: 8px; }
+.detail-refs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+.detail-loading, .detail-error {
+ padding: 20px;
+ text-align: center;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+.detail-error { color: var(--red); }
+
+.detail-body__files-section {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-body__files-section .detail-section__label {
+ margin-bottom: 8px;
+}
+.detail-code-preview {
+ flex: 1;
+ min-height: 180px;
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--border-light);
+ background: var(--bg);
+}
+.detail-code-preview__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 12px;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0;
+}
+.detail-code-preview__filename {
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 12px;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.detail-code-preview__stats {
+ font-size: 11px;
+ color: var(--text-sec);
+ flex-shrink: 0;
+}
+.detail-code-preview__content {
+ flex: 1;
+ overflow: auto;
+ padding: 12px;
+ min-height: 120px;
+}
+.detail-code-preview__content::-webkit-scrollbar { width: 6px; }
+.detail-code-preview__content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+.detail-code-preview__loading, .detail-code-preview__error {
+ padding: 16px;
+ color: var(--text-dim);
+ font-size: 12px;
+}
+.detail-code-preview__error { color: var(--red); }
+.detail-code-preview__diff {
+ margin: 0;
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 11px;
+ line-height: 1.6;
+ white-space: pre;
+ word-break: normal;
+ overflow-x: auto;
+ color: var(--text);
+}
+.detail-code-preview__diff .diff-line { display: block; }
+.detail-code-preview__diff .diff-line.diff-add { color: var(--green); background: rgba(63,185,80,.08); }
+.detail-code-preview__diff .diff-line.diff-del { color: var(--red); background: rgba(248,81,73,.08); }
+.detail-code-preview__diff .diff-line.diff-hunk { color: var(--text-dim); }
+
+.detail-section {
+ margin-bottom: 20px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border-light);
+}
+.detail-section:last-child { border-bottom: none; }
+.detail-section__label {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .06em;
+ color: var(--text-dim);
+ margin-bottom: 8px;
+}
+.detail-hash {
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 12px;
+ color: var(--accent);
+ word-break: break-all;
+ line-height: 1.5;
+}
+.detail-message {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--text);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.detail-meta {
+ font-size: 12px;
+ color: var(--text-sec);
+ line-height: 1.6;
+}
+.detail-meta strong { color: var(--text); font-weight: 500; }
+
+.detail-files { list-style: none; margin: 0; padding: 0; max-height: 200px; overflow-y: auto; }
+.detail-file {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ font-size: 12px;
+ cursor: pointer;
+ transition: background .12s;
+}
+.detail-file:hover { background: var(--bg-hover); }
+.detail-file.detail-file--selected {
+ background: var(--bg-active);
+ outline: 1px solid var(--accent);
+ outline-offset: -1px;
+}
+.detail-file:last-child { border-bottom: none; }
+.detail-file.detail-file--expanded .detail-file-diff { display: block; }
+.detail-file-diff {
+ display: none;
+ margin-top: 10px;
+ padding: 12px;
+ background: var(--bg);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-light);
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 11px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 320px;
+ overflow: auto;
+}
+.diff-line { display: block; }
+.diff-line.diff-add { color: var(--green); background: rgba(63,185,80,.08); }
+.diff-line.diff-del { color: var(--red); background: rgba(248,81,73,.08); }
+.diff-line.diff-hunk { color: var(--text-dim); }
+.detail-file__name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+ color: var(--text-sec);
+}
+.detail-file__stat { display: flex; gap: 6px; flex-shrink: 0; font-size: 11px; }
+.stat-add { color: var(--green); }
+.stat-del { color: var(--red); }
diff --git a/MiniApp/Demo/git-graph/source/styles/graph.css b/MiniApp/Demo/git-graph/source/styles/graph.css
new file mode 100644
index 00000000..3de7e060
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/styles/graph.css
@@ -0,0 +1,23 @@
+/* ── Graph SVG in rows ───────────── */
+.commit-row__graph svg .graph-line {
+ fill: none;
+ stroke-width: 1.5;
+}
+.commit-row__graph svg .graph-line--uncommitted {
+ stroke: var(--graph-uncommitted);
+ stroke-dasharray: 3 3;
+}
+.commit-row__graph svg .graph-node {
+ stroke-width: 1.5;
+ transition: r 0.15s;
+}
+.commit-row__graph svg .graph-node--commit {
+ stroke: var(--graph-node-stroke);
+}
+.commit-row__graph svg .graph-node--uncommitted {
+ fill: none;
+ stroke: var(--graph-uncommitted);
+}
+.commit-row:hover .commit-row__graph svg .graph-node--commit {
+ r: 5;
+}
diff --git a/MiniApp/Demo/git-graph/source/styles/layout.css b/MiniApp/Demo/git-graph/source/styles/layout.css
new file mode 100644
index 00000000..933bcee8
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/styles/layout.css
@@ -0,0 +1,307 @@
+/* ── Toolbar ─────────────────────── */
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 16px;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ min-height: 44px;
+}
+.toolbar__left, .toolbar__right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.toolbar__path {
+ font-size: 12px;
+ color: var(--text-sec);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 280px;
+ font-family: var(--bitfun-font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
+}
+.toolbar__branch-filter { position: relative; }
+.toolbar__branch-filter .chevron { margin-left: 4px; opacity: .8; }
+.dropdown-panel {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 4px;
+ min-width: 220px;
+ max-height: 320px;
+ overflow-y: auto;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0,0,0,.25);
+ z-index: 200;
+ padding: 4px 0;
+}
+.dropdown-panel[aria-hidden="true"] { display: none; }
+.dropdown-panel__item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text);
+}
+.dropdown-panel__item:hover { background: var(--bg-hover); }
+.dropdown-panel__item input[type="checkbox"] { margin: 0; }
+.dropdown-panel__sep { height: 1px; background: var(--border-light); margin: 4px 0; }
+
+/* ── Buttons ─────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background .15s, border-color .15s, color .15s;
+ white-space: nowrap;
+ line-height: 1.4;
+}
+.btn--primary {
+ background: var(--accent-dim);
+ color: #fff;
+ border-color: rgba(88,166,255,.4);
+}
+.btn--primary:hover { background: #2d72c7; }
+.btn--primary:active { background: #2563b5; }
+.btn--secondary {
+ background: var(--bg-active);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+.btn--secondary:hover { background: var(--bg-hover); border-color: var(--border); }
+.btn--secondary:active { background: var(--bg); }
+.btn--icon {
+ padding: 6px 10px;
+ min-width: 32px;
+ min-height: 32px;
+ color: var(--text-sec);
+}
+.btn--icon:hover { background: var(--bg-hover); color: var(--text); }
+.btn--icon:active { background: var(--bg-active); }
+.btn--lg { padding: 8px 20px; font-size: 13px; }
+.btn-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-sec);
+ cursor: pointer;
+ transition: background .15s, color .15s;
+}
+.btn-icon:hover { background: var(--bg-hover); color: var(--text); }
+.btn-icon:active { background: var(--bg-active); }
+.btn-icon--header {
+ width: 28px;
+ height: 28px;
+ color: var(--text-sec);
+}
+.btn-icon--header:hover { background: var(--bg-hover); color: var(--text); }
+.btn-icon--header:active { background: var(--bg-active); }
+
+/* ── Badges ──────────────────────── */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+}
+.badge--branch {
+ background: rgba(88,166,255,.15);
+ color: var(--accent);
+ border: 1px solid rgba(88,166,255,.25);
+}
+.badge--status {
+ background: rgba(63,185,80,.12);
+ color: var(--green);
+ border: 1px solid rgba(63,185,80,.2);
+}
+.badge--status.has-changes {
+ background: rgba(210,153,34,.12);
+ color: var(--orange);
+ border-color: rgba(210,153,34,.2);
+}
+
+/* ── Empty state ─────────────────── */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ gap: 16px;
+ padding: 40px;
+ animation: fadeIn .5s;
+}
+.empty-state__icon { opacity: .6; }
+.empty-state__graph-icon .empty-state__node--1 { stroke: var(--branch-1); }
+.empty-state__graph-icon .empty-state__node--2 { stroke: var(--branch-2); }
+.empty-state__graph-icon .empty-state__node--3 { stroke: var(--branch-3); }
+.empty-state__graph-icon .empty-state__node--4 { stroke: var(--branch-4); }
+.empty-state__graph-icon .empty-state__line--1 { stroke: var(--branch-1); }
+.empty-state__graph-icon .empty-state__line--2 { stroke: var(--branch-2); }
+.empty-state__graph-icon .empty-state__line--3 { stroke: var(--branch-3); }
+.empty-state__title {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text);
+}
+.empty-state__desc {
+ font-size: 13px;
+ color: var(--text-sec);
+ max-width: 320px;
+ text-align: center;
+ line-height: 1.5;
+}
+@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+
+/* ── Main layout ─────────────────── */
+.main { display: flex; flex: 1; min-height: 0; min-width: 0; }
+
+/* ── Graph area (commit list) ────── */
+.graph-area { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }
+.graph-area__scroll {
+ flex: 1;
+ min-width: 0;
+ overflow-y: auto;
+ overflow-x: auto;
+}
+.graph-area__scroll::-webkit-scrollbar,
+.graph-area::-webkit-scrollbar { width: 6px; }
+.graph-area__scroll::-webkit-scrollbar-track,
+.graph-area::-webkit-scrollbar-track { background: transparent; }
+.graph-area__scroll::-webkit-scrollbar-thumb,
+.graph-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+.load-more {
+ padding: 12px 16px;
+ text-align: center;
+ border-top: 1px solid var(--border-light);
+}
+
+/* ── Commit row ──────────────────── */
+.commit-row {
+ display: flex;
+ align-items: center;
+ height: var(--graph-row-height, 28px);
+ padding: 0 16px 0 0;
+ cursor: pointer;
+ transition: background .1s;
+ border-bottom: 1px solid var(--border-light);
+}
+.commit-row:hover { background: var(--bg-hover); }
+.commit-row.selected { background: var(--bg-active); }
+.commit-row.find-highlight { background: rgba(88,166,255,.15); }
+.commit-row.compare-selected { box-shadow: inset 0 0 0 2px var(--accent); }
+.commit-row.commit-row--stash .graph-node--stash-outer {
+ fill: none;
+ stroke: var(--orange);
+ stroke-width: 1.5;
+}
+.commit-row.commit-row--stash .graph-node--stash-inner {
+ fill: var(--orange);
+}
+.commit-row__graph {
+ flex-shrink: 0;
+ height: var(--graph-row-height, 28px);
+ overflow: visible;
+ min-width: 0;
+}
+.graph-line--uncommitted { stroke: var(--graph-uncommitted); stroke-dasharray: 2 2; }
+.graph-node--uncommitted { fill: none; stroke: var(--graph-uncommitted); stroke-width: 1.5; }
+
+.commit-row__info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+ padding-left: 8px;
+}
+.commit-row__hash {
+ flex-shrink: 0;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 11px;
+ color: var(--accent);
+ width: 56px;
+}
+.commit-row__message {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12.5px;
+ color: var(--text);
+}
+.commit-row__refs {
+ display: inline-flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.ref-tag {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+.ref-tag--head {
+ background: rgba(88,166,255,.18);
+ color: var(--accent);
+}
+.ref-tag--branch {
+ background: rgba(63,185,80,.14);
+ color: var(--green);
+}
+.ref-tag--tag {
+ background: rgba(210,153,34,.14);
+ color: var(--orange);
+}
+.ref-tag--remote {
+ background: rgba(188,140,255,.14);
+ color: var(--purple);
+}
+
+.commit-row__author {
+ flex-shrink: 0;
+ width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11.5px;
+ color: var(--text-sec);
+ text-align: right;
+}
+.commit-row__date {
+ flex-shrink: 0;
+ width: 90px;
+ font-size: 11px;
+ color: var(--text-dim);
+ text-align: right;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+}
diff --git a/MiniApp/Demo/git-graph/source/styles/overlay.css b/MiniApp/Demo/git-graph/source/styles/overlay.css
new file mode 100644
index 00000000..20694046
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/styles/overlay.css
@@ -0,0 +1,197 @@
+/* ── Loading ─────────────────────── */
+.loading-overlay {
+ position: fixed;
+ inset: 0;
+ background: color-mix(in srgb, var(--bg) 70%, transparent);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ z-index: 100;
+ color: var(--text-sec);
+ font-size: 13px;
+ backdrop-filter: blur(4px);
+}
+.spinner {
+ width: 28px; height: 28px;
+ border: 2.5px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin .8s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* ── Find widget ──────────────────── */
+.find-widget {
+ position: absolute;
+ top: 50px;
+ right: 20px;
+ z-index: 150;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 4px 16px rgba(0,0,0,.2);
+}
+.find-widget__input {
+ width: 220px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: var(--text);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ outline: none;
+}
+.find-widget__input:focus { border-color: var(--accent); }
+.find-widget__result {
+ font-size: 11px;
+ color: var(--text-dim);
+ min-width: 48px;
+}
+
+/* ── Context menu ─────────────────── */
+.context-menu {
+ position: fixed;
+ z-index: 1000;
+ min-width: 180px;
+ max-width: 320px;
+ padding: 4px 0;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0,0,0,.3);
+ font-size: 12px;
+}
+.context-menu[aria-hidden="true"] { display: none; }
+.context-menu__item {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ cursor: pointer;
+ color: var(--text);
+ white-space: nowrap;
+}
+.context-menu__item:hover { background: var(--bg-hover); }
+.context-menu__item:disabled,
+.context-menu__item.context-menu__item--disabled { opacity: .5; cursor: default; }
+.context-menu__sep {
+ height: 1px;
+ margin: 4px 8px;
+ background: var(--border-light);
+}
+
+/* ── Modal dialog ─────────────────── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--bg) 50%, transparent);
+ backdrop-filter: blur(4px);
+}
+.modal-overlay[aria-hidden="true"] { display: none; }
+.modal-dialog {
+ width: 90%;
+ max-width: 440px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 16px 48px rgba(0,0,0,.35);
+}
+.modal-dialog__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+}
+.modal-dialog__title { font-size: 14px; font-weight: 600; margin: 0; }
+.modal-dialog__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+}
+.modal-dialog__footer {
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+}
+.modal-dialog__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.modal-form-group { margin-bottom: 12px; }
+.modal-form-group label {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-dim);
+ margin-bottom: 4px;
+}
+.modal-form-group input,
+.modal-form-group select,
+.modal-form-group textarea {
+ width: 100%;
+ padding: 8px 10px;
+ font-size: 12px;
+ color: var(--text);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+.modal-form-group input:focus,
+.modal-form-group select:focus,
+.modal-form-group textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.modal-form-group .checkbox-wrap { display: flex; align-items: center; gap: 8px; }
+.modal-form-group .checkbox-wrap input { width: auto; }
+
+/* ── Remote panel ────────────────── */
+.remote-panel {
+ width: 320px;
+ max-width: 40%;
+ background: var(--bg-surface);
+ border-left: 1px solid var(--border);
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ animation: slideIn .2s;
+}
+.remote-panel__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+}
+.remote-panel__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+}
+.remote-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ margin-bottom: 6px;
+ background: var(--bg);
+ border: 1px solid var(--border-light);
+}
+.remote-item__name { font-weight: 600; font-size: 12px; }
+.remote-item__url { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.remote-item__actions { display: flex; gap: 4px; }
diff --git a/MiniApp/Demo/git-graph/source/styles/tokens.css b/MiniApp/Demo/git-graph/source/styles/tokens.css
new file mode 100644
index 00000000..0f164917
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/styles/tokens.css
@@ -0,0 +1,44 @@
+/* Git Graph MiniApp — theme tokens (host --bitfun-* when running in BitFun) */
+:root {
+ --bg: var(--bitfun-bg, #0d1117);
+ --bg-surface: var(--bitfun-bg-secondary, #161b22);
+ --bg-hover: var(--bitfun-element-hover, #1c2333);
+ --bg-active: var(--bitfun-element-bg, #1f2a3d);
+ --border: var(--bitfun-border, #30363d);
+ --border-light: var(--bitfun-border-subtle, #21262d);
+ --text: var(--bitfun-text, #e6edf3);
+ --text-sec: var(--bitfun-text-secondary, #8b949e);
+ --text-dim: var(--bitfun-text-muted, #484f58);
+ --accent: var(--bitfun-accent, #58a6ff);
+ --accent-dim: var(--bitfun-accent-hover, #1f6feb);
+ --green: var(--bitfun-success, #3fb950);
+ --red: var(--bitfun-error, #f85149);
+ --orange: var(--bitfun-warning, #d29922);
+ --purple: var(--bitfun-info, #bc8cff);
+ --branch-1: var(--bitfun-accent, #58a6ff);
+ --branch-2: var(--bitfun-success, #3fb950);
+ --branch-3: var(--bitfun-info, #bc8cff);
+ --branch-4: var(--bitfun-warning, #f0883e);
+ --branch-5: #f778ba;
+ --branch-6: #79c0ff;
+ --branch-7: #56d364;
+ --radius: var(--bitfun-radius, 6px);
+ --radius-lg: var(--bitfun-radius-lg, 10px);
+ --graph-node-stroke: var(--bitfun-bg, #0d1117);
+ --graph-uncommitted: var(--text-dim, #808080);
+ --graph-lane-width: 18px;
+ --graph-row-height: 28px;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--bitfun-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif);
+ font-size: 13px;
+ color: var(--text);
+ background: var(--bg);
+ min-height: 100vh;
+ overflow: hidden;
+}
+
+#app { display: flex; flex-direction: column; height: 100vh; }
diff --git a/MiniApp/Demo/git-graph/source/ui.js b/MiniApp/Demo/git-graph/source/ui.js
new file mode 100644
index 00000000..c751a603
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui.js
@@ -0,0 +1,1884 @@
+/* ui/state.js */
+/**
+ * Git Graph MiniApp — shared state, constants, DOM helpers.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+
+ window.__GG.STORAGE_KEY = 'lastRepo';
+ window.__GG.MAX_COMMITS = 300;
+ window.__GG.ROW_H = 28;
+ window.__GG.LANE_W = 18;
+ window.__GG.NODE_R = 4;
+
+ window.__GG.$ = function (id) {
+ return document.getElementById(id);
+ };
+
+ window.__GG.state = {
+ cwd: null,
+ commits: [],
+ stash: [],
+ branches: null,
+ refs: null,
+ head: null,
+ uncommitted: null,
+ status: null,
+ remotes: [],
+ selectedHash: null,
+ selectedBranchFilter: [],
+ firstParent: false,
+ order: 'date',
+ compareHashes: [],
+ findQuery: '',
+ findIndex: 0,
+ findMatches: [],
+ offset: 0,
+ hasMore: true,
+ };
+
+ window.__GG.show = function (el, v) {
+ if (el) el.style.display = v ? '' : 'none';
+ };
+
+ window.__GG.formatDate = function (dateStr) {
+ if (!dateStr) return '';
+ try {
+ const d = new Date(dateStr);
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const hh = String(d.getHours()).padStart(2, '0');
+ const mi = String(d.getMinutes()).padStart(2, '0');
+ return `${mm}-${dd} ${hh}:${mi}`;
+ } catch {
+ return String(dateStr).slice(0, 10);
+ }
+ };
+
+ window.__GG.parseRefs = function (refStr) {
+ if (!refStr) return [];
+ return refStr
+ .split(',')
+ .map(function (r) { return r.trim(); })
+ .filter(Boolean)
+ .map(function (r) {
+ if (r.startsWith('HEAD -> ')) return { type: 'head', label: r.replace('HEAD -> ', '') };
+ if (r.startsWith('tag: ')) return { type: 'tag', label: r.replace('tag: ', '') };
+ if (r.includes('/')) return { type: 'remote', label: r };
+ return { type: 'branch', label: r };
+ });
+ };
+
+ window.__GG.setLoading = function (v) {
+ window.__GG.show(window.__GG.$('loading-overlay'), v);
+ };
+
+ window.__GG.escapeHtml = function (s) {
+ if (s == null) return '';
+ const div = document.createElement('div');
+ div.textContent = s;
+ return div.innerHTML;
+ };
+
+ /**
+ * Returns display list from state.commits (already built by git.graphData:
+ * uncommitted + commits with stash rows in correct order). No client-side
+ * stash-by-date or status-based uncommitted fabrication.
+ */
+ window.__GG.getDisplayCommits = function () {
+ return (window.__GG.state.commits || []).slice();
+ };
+
+ /**
+ * Build ref tag list for a commit from structured refs (heads/tags/remotes).
+ * currentBranch: name of current branch for HEAD -> label.
+ */
+ window.__GG.getRefsFromStructured = function (commit, currentBranch) {
+ if (!commit) return [];
+ const out = [];
+ const heads = commit.heads || [];
+ const tags = commit.tags || [];
+ const remotes = commit.remotes || [];
+ heads.forEach(function (name) {
+ out.push({ type: name === currentBranch ? 'head' : 'branch', label: name === currentBranch ? 'HEAD -> ' + name : name });
+ });
+ tags.forEach(function (t) {
+ out.push({ type: 'tag', label: typeof t === 'string' ? t : (t.name || '') });
+ });
+ remotes.forEach(function (r) {
+ out.push({ type: 'remote', label: typeof r === 'string' ? r : (r.name || '') });
+ });
+ return out;
+ };
+})();
+
+
+/* ui/theme.js */
+/**
+ * Git Graph MiniApp — theme adapter: read --branch-* and node stroke from CSS for graph colors.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const root = document.documentElement;
+
+ function getComputed(name) {
+ return getComputedStyle(root).getPropertyValue(name).trim() || null;
+ }
+
+ /** Returns array of 7 branch/lane colors from CSS variables (theme-aware). */
+ window.__GG.getGraphColors = function () {
+ const colors = [];
+ for (let i = 1; i <= 7; i++) {
+ const v = getComputed('--branch-' + i);
+ colors.push(v || '#58a6ff');
+ }
+ return colors;
+ };
+
+ /** Node stroke color (contrast with background). */
+ window.__GG.getNodeStroke = function () {
+ return getComputed('--graph-node-stroke') || getComputed('--bitfun-bg') || getComputed('--bg') || '#0d1117';
+ };
+
+ /** Uncommitted / WIP line and node color. */
+ window.__GG.getUncommittedColor = function () {
+ return getComputed('--graph-uncommitted') || getComputed('--text-dim') || '#808080';
+ };
+})();
+
+/* ui/graph/layout.js */
+/**
+ * Git Graph MiniApp — global topology graph layout (Vertex/Branch/determinePath).
+ * Outputs per-row drawInfo compatible with renderRowSvg: { lane, lanesBefore, parentLanes }.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const NULL_VERTEX_ID = -1;
+
+ function Vertex(id, isStash) {
+ this.id = id;
+ this.isStash = !!isStash;
+ this.x = 0;
+ this.children = [];
+ this.parents = [];
+ this.nextParent = 0;
+ this.onBranch = null;
+ this.isCommitted = true;
+ this.nextX = 0;
+ this.connections = [];
+ }
+ Vertex.prototype.addChild = function (v) { this.children.push(v); };
+ Vertex.prototype.addParent = function (v) { this.parents.push(v); };
+ Vertex.prototype.getNextParent = function () {
+ return this.nextParent < this.parents.length ? this.parents[this.nextParent] : null;
+ };
+ Vertex.prototype.registerParentProcessed = function () { this.nextParent++; };
+ Vertex.prototype.isNotOnBranch = function () { return this.onBranch === null; };
+ Vertex.prototype.getPoint = function () { return { x: this.x, y: this.id }; };
+ Vertex.prototype.getNextPoint = function () { return { x: this.nextX, y: this.id }; };
+ Vertex.prototype.getPointConnectingTo = function (vertex, onBranch) {
+ for (let i = 0; i < this.connections.length; i++) {
+ if (this.connections[i] && this.connections[i].connectsTo === vertex && this.connections[i].onBranch === onBranch) {
+ return { x: i, y: this.id };
+ }
+ }
+ return null;
+ };
+ Vertex.prototype.registerUnavailablePoint = function (x, connectsToVertex, onBranch) {
+ if (x === this.nextX) {
+ this.nextX = x + 1;
+ while (this.connections.length <= x) this.connections.push(null);
+ this.connections[x] = { connectsTo: connectsToVertex, onBranch: onBranch };
+ }
+ };
+ Vertex.prototype.addToBranch = function (branch, x) {
+ if (this.onBranch === null) {
+ this.onBranch = branch;
+ this.x = x;
+ }
+ };
+ Vertex.prototype.getBranch = function () { return this.onBranch; };
+ Vertex.prototype.getIsCommitted = function () { return this.isCommitted; };
+ Vertex.prototype.setNotCommitted = function () { this.isCommitted = false; };
+ Vertex.prototype.isMerge = function () { return this.parents.length > 1; };
+
+ function Branch(colour) {
+ this.colour = colour;
+ this.lines = [];
+ }
+ Branch.prototype.getColour = function () { return this.colour; };
+ Branch.prototype.addLine = function (p1, p2, isCommitted, lockedFirst) {
+ this.lines.push({ p1: p1, p2: p2, lockedFirst: lockedFirst });
+ };
+
+ function getAvailableColour(availableColours, startAt) {
+ for (let i = 0; i < availableColours.length; i++) {
+ if (startAt > availableColours[i]) return i;
+ }
+ availableColours.push(0);
+ return availableColours.length - 1;
+ }
+
+ function determinePath(vertices, branches, availableColours, commits, commitLookup, onlyFollowFirstParent) {
+ function run(startAt) {
+ let i = startAt;
+ let vertex = vertices[i];
+ let parentVertex = vertex.getNextParent();
+ let lastPoint = vertex.isNotOnBranch() ? vertex.getNextPoint() : vertex.getPoint();
+
+ if (parentVertex !== null && parentVertex.id !== NULL_VERTEX_ID && vertex.isMerge() && !vertex.isNotOnBranch() && !parentVertex.isNotOnBranch()) {
+ var parentBranch = parentVertex.getBranch();
+ var foundPointToParent = false;
+ for (i = startAt + 1; i < vertices.length; i++) {
+ var curVertex = vertices[i];
+ var curPoint = curVertex.getPointConnectingTo(parentVertex, parentBranch);
+ if (curPoint === null) curPoint = curVertex.getNextPoint();
+ parentBranch.addLine(lastPoint, curPoint, vertex.getIsCommitted(), !foundPointToParent && curVertex !== parentVertex ? lastPoint.x < curPoint.x : true);
+ curVertex.registerUnavailablePoint(curPoint.x, parentVertex, parentBranch);
+ lastPoint = curPoint;
+ if (curVertex.getPointConnectingTo(parentVertex, parentBranch) !== null) foundPointToParent = true;
+ if (foundPointToParent) {
+ vertex.registerParentProcessed();
+ return;
+ }
+ }
+ } else {
+ var branch = new Branch(getAvailableColour(availableColours, startAt));
+ vertex.addToBranch(branch, lastPoint.x);
+ vertex.registerUnavailablePoint(lastPoint.x, vertex, branch);
+ for (i = startAt + 1; i < vertices.length; i++) {
+ var curVertex = vertices[i];
+ var curPoint = (parentVertex === curVertex && parentVertex && !parentVertex.isNotOnBranch()) ? curVertex.getPoint() : curVertex.getNextPoint();
+ branch.addLine(lastPoint, curPoint, vertex.getIsCommitted(), lastPoint.x < curPoint.x);
+ curVertex.registerUnavailablePoint(curPoint.x, parentVertex, branch);
+ lastPoint = curPoint;
+ if (parentVertex === curVertex) {
+ vertex.registerParentProcessed();
+ var parentVertexOnBranch = parentVertex && !parentVertex.isNotOnBranch();
+ parentVertex.addToBranch(branch, curPoint.x);
+ vertex = parentVertex;
+ parentVertex = vertex.getNextParent();
+ if (parentVertex === null || parentVertexOnBranch) return;
+ }
+ }
+ if (i === vertices.length && parentVertex !== null && parentVertex.id === NULL_VERTEX_ID) {
+ vertex.registerParentProcessed();
+ }
+ branches.push(branch);
+ availableColours[branch.getColour()] = i;
+ }
+ }
+
+ var idx = 0;
+ while (idx < vertices.length) {
+ var v = vertices[idx];
+ if (v.getNextParent() !== null || v.isNotOnBranch()) {
+ run(idx);
+ } else {
+ idx++;
+ }
+ }
+ }
+
+ function computeFallbackLayout(commits) {
+ const idx = {};
+ commits.forEach(function (c, i) { idx[c.hash] = i; });
+
+ const commitLane = new Array(commits.length);
+ const rowDrawInfo = [];
+ const activeLanes = [];
+ let maxLane = 0;
+
+ for (let i = 0; i < commits.length; i++) {
+ const c = commits[i];
+ const lanesBefore = activeLanes.slice();
+
+ let lane = lanesBefore.indexOf(c.hash);
+ if (lane === -1) {
+ lane = activeLanes.indexOf(null);
+ if (lane === -1) {
+ lane = activeLanes.length;
+ activeLanes.push(null);
+ }
+ }
+
+ commitLane[i] = lane;
+ while (activeLanes.length <= lane) activeLanes.push(null);
+ activeLanes[lane] = null;
+
+ const raw = c.parentHashes || c.parents || (c.parent != null ? [c.parent] : []);
+ const parents = Array.isArray(raw) ? raw : [raw];
+ const parentLanes = [];
+ for (let p = 0; p < parents.length; p++) {
+ const ph = parents[p];
+ if (idx[ph] === undefined) continue;
+
+ const existing = activeLanes.indexOf(ph);
+ if (existing >= 0) {
+ parentLanes.push({ lane: existing });
+ } else if (p === 0) {
+ activeLanes[lane] = ph;
+ parentLanes.push({ lane: lane });
+ } else {
+ let sl = activeLanes.indexOf(null);
+ if (sl === -1) {
+ sl = activeLanes.length;
+ activeLanes.push(null);
+ }
+ activeLanes[sl] = ph;
+ parentLanes.push({ lane: sl });
+ }
+ }
+
+ maxLane = Math.max(
+ maxLane,
+ lane,
+ parentLanes.length ? Math.max.apply(null, parentLanes.map(function (pl) { return pl.lane; })) : 0
+ );
+ rowDrawInfo.push({ lane: lane, lanesBefore: lanesBefore, parentLanes: parentLanes });
+ }
+
+ return { commitLane: commitLane, laneCount: maxLane + 1, idx: idx, rowDrawInfo: rowDrawInfo };
+ }
+
+ function isReasonableLayout(layout, commitCount) {
+ if (!layout || !Array.isArray(layout.rowDrawInfo) || layout.rowDrawInfo.length !== commitCount) return false;
+ if (!Number.isFinite(layout.laneCount) || layout.laneCount < 1) return false;
+
+ for (let i = 0; i < layout.rowDrawInfo.length; i++) {
+ const row = layout.rowDrawInfo[i];
+ if (!row || !Number.isFinite(row.lane) || row.lane < 0) return false;
+ if (!Array.isArray(row.parentLanes) || !Array.isArray(row.lanesBefore)) return false;
+ for (let j = 0; j < row.parentLanes.length; j++) {
+ if (!Number.isFinite(row.parentLanes[j].lane) || row.parentLanes[j].lane < 0) return false;
+ }
+ }
+
+ // If almost every row gets its own lane, the topology solver likely drifted.
+ if (commitCount >= 12 && layout.laneCount > Math.ceil(commitCount * 0.5)) return false;
+ return true;
+ }
+
+ /**
+ * Compute per-row graph layout using global topology (Vertex/Branch/determinePath).
+ * commits: array of { hash, parentHashes, stash } (parentHashes = array of hash strings).
+ * onlyFollowFirstParent: optional boolean (default false).
+ * Returns { commitLane, laneCount, idx, rowDrawInfo } for use by renderRowSvg.
+ */
+ window.__GG.computeGraphLayout = function (commits, onlyFollowFirstParent) {
+ onlyFollowFirstParent = !!onlyFollowFirstParent;
+ const idx = {};
+ commits.forEach(function (c, i) { idx[c.hash] = i; });
+ const n = commits.length;
+ if (n === 0) return { commitLane: [], laneCount: 1, idx: idx, rowDrawInfo: [] };
+
+ const nullVertex = new Vertex(NULL_VERTEX_ID, false);
+ const vertices = [];
+ for (let i = 0; i < n; i++) {
+ vertices.push(new Vertex(i, !!(commits[i].stash)));
+ }
+ for (let i = 0; i < n; i++) {
+ const raw = commits[i].parentHashes || commits[i].parents || (commits[i].parent != null ? [commits[i].parent] : []);
+ const parents = Array.isArray(raw) ? raw : [raw];
+ for (let p = 0; p < parents.length; p++) {
+ const ph = parents[p];
+ if (typeof idx[ph] === 'number') {
+ vertices[i].addParent(vertices[idx[ph]]);
+ vertices[idx[ph]].addChild(vertices[i]);
+ } else if (!onlyFollowFirstParent || p === 0) {
+ vertices[i].addParent(nullVertex);
+ }
+ }
+ }
+ if ((commits[0] && (commits[0].hash === '__uncommitted__' || commits[0].isUncommitted))) {
+ vertices[0].setNotCommitted();
+ }
+ const branches = [];
+ const availableColours = [];
+ determinePath(vertices, branches, availableColours, commits, idx, onlyFollowFirstParent);
+
+ const commitLane = [];
+ const rowDrawInfo = [];
+ let maxLane = 0;
+ const activeLanes = [];
+ for (let i = 0; i < n; i++) {
+ const v = vertices[i];
+ const lane = v.x;
+ maxLane = Math.max(maxLane, lane);
+ commitLane[i] = lane;
+ const lanesBefore = activeLanes.slice();
+ while (activeLanes.length <= lane) activeLanes.push(null);
+ activeLanes[lane] = null;
+ const parentLanes = [];
+ const parents = v.parents;
+ for (let p = 0; p < parents.length; p++) {
+ const pv = parents[p];
+ if (pv.id === NULL_VERTEX_ID) continue;
+ const pl = pv.x;
+ parentLanes.push({ lane: pl });
+ maxLane = Math.max(maxLane, pl);
+ while (activeLanes.length <= pl) activeLanes.push(null);
+ activeLanes[pl] = commits[pv.id].hash;
+ }
+ rowDrawInfo.push({ lane: lane, lanesBefore: lanesBefore, parentLanes: parentLanes });
+ }
+ const result = {
+ commitLane: commitLane,
+ laneCount: maxLane + 1,
+ idx: idx,
+ rowDrawInfo: rowDrawInfo,
+ };
+ return isReasonableLayout(result, n) ? result : computeFallbackLayout(commits);
+ };
+})();
+
+/* ui/graph/renderRowSvg.js */
+/**
+ * Git Graph MiniApp — build SVG for one commit row (theme-aware colors).
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const ROW_H = window.__GG.ROW_H;
+ const LANE_W = window.__GG.LANE_W;
+ const NODE_R = window.__GG.NODE_R;
+
+ window.__GG.buildRowSvg = function (commit, drawInfo, graphW, isStash, isUncommitted) {
+ isStash = !!isStash;
+ isUncommitted = !!isUncommitted;
+ const lane = drawInfo.lane;
+ const lanesBefore = drawInfo.lanesBefore;
+ const parentLanes = drawInfo.parentLanes;
+ const colors = window.__GG.getGraphColors();
+ const nodeStroke = window.__GG.getNodeStroke();
+ const uncommittedColor = window.__GG.getUncommittedColor();
+
+ const svgNS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(svgNS, 'svg');
+ svg.setAttribute('width', graphW);
+ svg.setAttribute('height', ROW_H);
+ svg.setAttribute('viewBox', '0 0 ' + graphW + ' ' + ROW_H);
+ svg.style.display = 'block';
+ svg.style.overflow = 'visible';
+
+ const cx = lane * LANE_W + LANE_W / 2 + 4;
+ const cy = ROW_H / 2;
+ const nodeColor = isUncommitted ? uncommittedColor : (colors[lane % colors.length] || colors[0]);
+ const bezierD = ROW_H * 0.8;
+
+ function laneX(l) { return l * LANE_W + LANE_W / 2 + 4; }
+
+ function mkPath(dAttr, stroke, dash) {
+ const p = document.createElementNS(svgNS, 'path');
+ p.setAttribute('d', dAttr);
+ p.setAttribute('stroke', stroke);
+ p.setAttribute('fill', 'none');
+ p.setAttribute('stroke-width', '1.5');
+ p.setAttribute('class', 'graph-line');
+ if (dash) p.setAttribute('stroke-dasharray', dash);
+ return p;
+ }
+
+ for (let l = 0; l < lanesBefore.length; l++) {
+ if (lanesBefore[l] !== null && l !== lane) {
+ const stroke = colors[l % colors.length] || colors[0];
+ svg.appendChild(mkPath('M' + laneX(l) + ' 0 L' + laneX(l) + ' ' + ROW_H, stroke, null));
+ }
+ }
+
+ const wasActive = lane < lanesBefore.length && lanesBefore[lane] !== null;
+ if (wasActive) {
+ const path = mkPath('M' + cx + ' 0 L' + cx + ' ' + cy, nodeColor, isUncommitted ? '3 3' : null);
+ if (isUncommitted) path.setAttribute('class', 'graph-line graph-line--uncommitted');
+ svg.appendChild(path);
+ }
+
+ for (let i = 0; i < parentLanes.length; i++) {
+ const pl = parentLanes[i];
+ const px = laneX(pl.lane);
+ const lineColor = isUncommitted ? uncommittedColor : (colors[pl.lane % colors.length] || colors[0]);
+ const dash = isUncommitted ? '3 3' : null;
+ var path;
+ if (px === cx) {
+ path = mkPath('M' + cx + ' ' + cy + ' L' + cx + ' ' + ROW_H, lineColor, dash);
+ } else {
+ path = mkPath(
+ 'M' + cx + ' ' + cy + ' C' + cx + ' ' + (cy + bezierD) + ' ' + px + ' ' + (ROW_H - bezierD) + ' ' + px + ' ' + ROW_H,
+ lineColor, dash
+ );
+ }
+ if (isUncommitted) path.setAttribute('class', 'graph-line graph-line--uncommitted');
+ svg.appendChild(path);
+ }
+
+ if (isStash) {
+ const outer = document.createElementNS(svgNS, 'circle');
+ outer.setAttribute('cx', cx); outer.setAttribute('cy', cy); outer.setAttribute('r', 4.5);
+ outer.setAttribute('fill', 'none'); outer.setAttribute('stroke', nodeColor); outer.setAttribute('stroke-width', '1.5');
+ outer.setAttribute('class', 'graph-node graph-node--stash-outer');
+ svg.appendChild(outer);
+ const inner = document.createElementNS(svgNS, 'circle');
+ inner.setAttribute('cx', cx); inner.setAttribute('cy', cy); inner.setAttribute('r', 2);
+ inner.setAttribute('fill', nodeColor);
+ inner.setAttribute('class', 'graph-node graph-node--stash-inner');
+ svg.appendChild(inner);
+ } else if (isUncommitted) {
+ const circle = document.createElementNS(svgNS, 'circle');
+ circle.setAttribute('cx', cx); circle.setAttribute('cy', cy); circle.setAttribute('r', NODE_R);
+ circle.setAttribute('fill', 'none'); circle.setAttribute('stroke', uncommittedColor); circle.setAttribute('stroke-width', '1.5');
+ circle.setAttribute('class', 'graph-node graph-node--uncommitted');
+ svg.appendChild(circle);
+ } else {
+ const circle = document.createElementNS(svgNS, 'circle');
+ circle.setAttribute('cx', cx); circle.setAttribute('cy', cy); circle.setAttribute('r', NODE_R);
+ circle.setAttribute('fill', nodeColor); circle.setAttribute('stroke', nodeStroke); circle.setAttribute('stroke-width', '1.5');
+ circle.setAttribute('class', 'graph-node graph-node--commit');
+ svg.appendChild(circle);
+ }
+
+ return svg;
+ };
+})();
+
+/* ui/services/gitClient.js */
+/**
+ * Git Graph MiniApp — worker call wrapper.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+
+ window.__GG.call = function (method, params) {
+ const state = window.__GG.state;
+ const p = Object.assign({ cwd: state.cwd }, params || {});
+ return window.app.call(method, p);
+ };
+})();
+
+/* ui/components/contextMenu.js */
+/**
+ * Git Graph MiniApp — context menu.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const $ = window.__GG.$;
+
+ window.__GG.showContextMenu = function (x, y, items) {
+ const menu = $('context-menu');
+ menu.innerHTML = '';
+ menu.setAttribute('aria-hidden', 'false');
+ menu.style.left = x + 'px';
+ menu.style.top = y + 'px';
+ items.forEach(function (item) {
+ if (item === null) {
+ const sep = document.createElement('div');
+ sep.className = 'context-menu__sep';
+ menu.appendChild(sep);
+ return;
+ }
+ const el = document.createElement('div');
+ el.className = 'context-menu__item' + (item.disabled ? ' context-menu__item--disabled' : '');
+ el.textContent = item.label;
+ if (!item.disabled && item.action) {
+ el.addEventListener('click', function () {
+ window.__GG.hideContextMenu();
+ item.action();
+ });
+ }
+ menu.appendChild(el);
+ });
+ };
+
+ window.__GG.hideContextMenu = function () {
+ const menu = $('context-menu');
+ if (menu) {
+ menu.setAttribute('aria-hidden', 'true');
+ menu.innerHTML = '';
+ }
+ };
+
+ document.addEventListener('click', function () { window.__GG.hideContextMenu(); });
+ document.addEventListener('contextmenu', function (e) {
+ if (e.target.closest('#context-menu')) return;
+ if (!e.target.closest('.commit-row')) window.__GG.hideContextMenu();
+ });
+})();
+
+/* ui/components/modal.js */
+/**
+ * Git Graph MiniApp — modal dialog.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const $ = window.__GG.$;
+
+ window.__GG.showModal = function (title, bodyHTML, buttons) {
+ const overlay = $('modal-overlay');
+ const titleEl = $('modal-title');
+ const bodyEl = $('modal-body');
+ const actionsEl = overlay.querySelector('.modal-dialog__actions');
+ titleEl.textContent = title;
+ bodyEl.innerHTML = bodyHTML;
+ actionsEl.innerHTML = '';
+ buttons.forEach(function (btn) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = btn.primary ? 'btn btn--primary' : 'btn btn--secondary';
+ b.textContent = btn.label;
+ b.addEventListener('click', function () {
+ if (btn.action) btn.action(b);
+ else window.__GG.hideModal();
+ });
+ actionsEl.appendChild(b);
+ });
+ overlay.setAttribute('aria-hidden', 'false');
+ $('modal-close').onclick = function () { window.__GG.hideModal(); };
+ overlay.onclick = function (e) {
+ if (e.target === overlay) window.__GG.hideModal();
+ };
+ };
+
+ window.__GG.hideModal = function () {
+ window.__GG.$('modal-overlay').setAttribute('aria-hidden', 'true');
+ };
+})();
+
+/* ui/components/findWidget.js */
+/**
+ * Git Graph MiniApp — find widget and branch filter dropdown.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+
+ window.__GG.updateFindMatches = function () {
+ const q = state.findQuery.trim().toLowerCase();
+ if (!q) {
+ state.findMatches = [];
+ state.findIndex = 0;
+ return;
+ }
+ const list = window.__GG.getDisplayCommits();
+ state.findMatches = list
+ .map(function (c, i) { return { c: c, i: i }; })
+ .filter(function (x) {
+ const c = x.c;
+ return (c.message && c.message.toLowerCase().indexOf(q) !== -1) ||
+ (c.hash && c.hash.toLowerCase().indexOf(q) !== -1) ||
+ (c.shortHash && c.shortHash.toLowerCase().indexOf(q) !== -1) ||
+ (c.author && c.author.toLowerCase().indexOf(q) !== -1);
+ })
+ .map(function (x) { return x.i; });
+ state.findIndex = 0;
+ };
+
+ window.__GG.showFindWidget = function () {
+ show($('find-widget'), true);
+ $('find-input').value = state.findQuery;
+ $('find-input').focus();
+ window.__GG.updateFindMatches();
+ window.__GG.renderCommitList();
+ $('find-result').textContent = state.findMatches.length > 0 ? '1 / ' + state.findMatches.length : '0';
+ };
+
+ window.__GG.findPrev = function () {
+ if (state.findMatches.length === 0) return;
+ state.findIndex = (state.findIndex - 1 + state.findMatches.length) % state.findMatches.length;
+ window.__GG.scrollToFindIndex();
+ };
+
+ window.__GG.findNext = function () {
+ if (state.findMatches.length === 0) return;
+ state.findIndex = (state.findIndex + 1) % state.findMatches.length;
+ window.__GG.scrollToFindIndex();
+ };
+
+ window.__GG.scrollToFindIndex = function () {
+ const idx = state.findMatches[state.findIndex];
+ if (idx === undefined) return;
+ const list = $('commit-list');
+ const rows = list.querySelectorAll('.commit-row');
+ const row = rows[idx];
+ if (row) row.scrollIntoView({ block: 'nearest' });
+ $('find-result').textContent = (state.findIndex + 1) + ' / ' + state.findMatches.length;
+ window.__GG.renderCommitList();
+ };
+
+ window.__GG.renderBranchFilterDropdown = function () {
+ const dropdown = $('branch-filter-dropdown');
+ if (!state.branches || !dropdown) return;
+ const all = state.branches.all || [];
+ const selected = state.selectedBranchFilter.length === 0 ? 'all' : state.selectedBranchFilter;
+ dropdown.innerHTML = '';
+ const allItem = document.createElement('div');
+ allItem.className = 'dropdown-panel__item';
+ allItem.textContent = 'All branches';
+ allItem.addEventListener('click', function () {
+ state.selectedBranchFilter = [];
+ $('branch-filter-label').textContent = 'All branches';
+ dropdown.setAttribute('aria-hidden', 'true');
+ window.__GG.loadRepo();
+ });
+ dropdown.appendChild(allItem);
+ const sep = document.createElement('div');
+ sep.className = 'dropdown-panel__sep';
+ dropdown.appendChild(sep);
+ all.forEach(function (name) {
+ const isSelected = selected === 'all' || selected.indexOf(name) !== -1;
+ const div = document.createElement('div');
+ div.className = 'dropdown-panel__item';
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = isSelected;
+ cb.addEventListener('change', function () {
+ if (selected === 'all') {
+ state.selectedBranchFilter = [name];
+ } else {
+ if (cb.checked) state.selectedBranchFilter = state.selectedBranchFilter.concat(name);
+ else state.selectedBranchFilter = state.selectedBranchFilter.filter(function (n) { return n !== name; });
+ }
+ $('branch-filter-label').textContent =
+ state.selectedBranchFilter.length === 0 ? 'All branches' : state.selectedBranchFilter.join(', ');
+ window.__GG.loadRepo();
+ });
+ div.appendChild(cb);
+ div.appendChild(document.createTextNode(' ' + name));
+ dropdown.appendChild(div);
+ });
+ };
+})();
+
+/* ui/panels/remotePanel.js */
+/**
+ * Git Graph MiniApp — remote panel.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const call = window.__GG.call;
+ const showModal = window.__GG.showModal;
+ const hideModal = window.__GG.hideModal;
+ const escapeHtml = window.__GG.escapeHtml;
+ const setLoading = window.__GG.setLoading;
+
+ window.__GG.showRemotePanel = function () {
+ show($('remote-panel'), true);
+ window.__GG.renderRemoteList();
+ };
+
+ window.__GG.renderRemoteList = function () {
+ const list = $('remote-list');
+ list.innerHTML = '';
+ (state.remotes || []).forEach(function (r) {
+ const div = document.createElement('div');
+ div.className = 'remote-item';
+ div.innerHTML =
+ '' + escapeHtml(r.name) + '
' +
+ '
' +
+ escapeHtml((r.fetch || '').slice(0, 50)) + ((r.fetch || '').length > 50 ? '\u2026' : '') + '
' +
+ '' +
+ '' +
+ '
';
+ div.querySelector('[data-action="fetch"]').addEventListener('click', async function () {
+ setLoading(true);
+ try {
+ await call('git.fetch', { remote: r.name, prune: true });
+ await window.__GG.loadRepo();
+ state.remotes = (await call('git.remotes')).remotes || [];
+ window.__GG.renderRemoteList();
+ } finally {
+ setLoading(false);
+ }
+ });
+ div.querySelector('[data-action="remove"]').addEventListener('click', function () {
+ showModal('Delete Remote', 'Delete remote ' + escapeHtml(r.name) + '?', [
+ { label: 'Cancel' },
+ {
+ label: 'Delete',
+ primary: true,
+ action: async function () {
+ await call('git.removeRemote', { name: r.name });
+ hideModal();
+ await window.__GG.loadRepo();
+ state.remotes = (await call('git.remotes')).remotes || [];
+ window.__GG.renderRemoteList();
+ },
+ },
+ ]);
+ });
+ list.appendChild(div);
+ });
+ const addBtn = document.createElement('button');
+ addBtn.type = 'button';
+ addBtn.className = 'btn btn--secondary';
+ addBtn.textContent = 'Add Remote';
+ addBtn.style.marginTop = '8px';
+ addBtn.addEventListener('click', function () {
+ showModal(
+ 'Add Remote',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Add',
+ primary: true,
+ action: async function () {
+ const name = ($('modal-remote-name').value || '').trim() || 'origin';
+ const url = ($('modal-remote-url').value || '').trim();
+ if (!url) return;
+ await call('git.addRemote', { name: name, url: url });
+ hideModal();
+ await window.__GG.loadRepo();
+ state.remotes = (await call('git.remotes')).remotes || [];
+ window.__GG.renderRemoteList();
+ },
+ },
+ ]
+ );
+ });
+ list.appendChild(addBtn);
+ };
+})();
+
+/* ui/panels/detailPanel.js */
+/**
+ * Git Graph MiniApp — detail panel (commit / compare).
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const call = window.__GG.call;
+ const parseRefs = window.__GG.parseRefs;
+ const escapeHtml = window.__GG.escapeHtml;
+
+ window.__GG.showDetailPanel = function () {
+ show($('detail-resizer'), true);
+ show($('detail-panel'), true);
+ };
+
+ window.__GG.openComparePanel = function (hash1, hash2) {
+ state.selectedHash = null;
+ state.compareHashes = [hash1, hash2];
+ window.__GG.showDetailPanel();
+ $('detail-panel-title').textContent = 'Compare';
+ var summary = $('detail-summary');
+ var filesSection = $('detail-files-section');
+ var codePreview = $('detail-code-preview');
+ if (summary) summary.innerHTML = 'Loading\u2026
';
+ if (filesSection) show(filesSection, false);
+ if (codePreview) show(codePreview, false);
+ (async function () {
+ try {
+ const res = await call('git.compareCommits', { hash1: hash1, hash2: hash2 });
+ if (summary) {
+ summary.innerHTML = '' + escapeHtml(hash1.slice(0, 7)) + ' \u2026 ' + escapeHtml(hash2.slice(0, 7)) + '
';
+ }
+ var list = $('detail-files-list');
+ if (list) {
+ list.innerHTML = '';
+ (res.files || []).forEach(function (f) {
+ var li = document.createElement('li');
+ li.className = 'detail-file';
+ li.innerHTML = '' + escapeHtml(f.file) + '' + escapeHtml(f.status) + '';
+ list.appendChild(li);
+ });
+ }
+ if (filesSection) show(filesSection, (res.files && res.files.length) ? true : false);
+ if ($('detail-files-label')) $('detail-files-label').textContent = 'Changed Files (' + (res.files ? res.files.length : 0) + ')';
+ } catch (e) {
+ if (summary) summary.innerHTML = '' + escapeHtml(e && e.message ? e.message : String(e)) + '
';
+ }
+ })();
+ };
+
+ window.__GG.selectCommit = async function (hash) {
+ state.selectedHash = hash;
+ state.compareHashes = [];
+ window.__GG.renderCommitList();
+
+ var summary = $('detail-summary');
+ var filesSection = $('detail-files-section');
+ var codePreview = $('detail-code-preview');
+ window.__GG.showDetailPanel();
+ $('detail-panel-title').textContent = hash === '__uncommitted__' ? 'Uncommitted changes' : 'Commit';
+ if (summary) summary.innerHTML = 'Loading\u2026
';
+ if (filesSection) show(filesSection, false);
+ if (codePreview) show(codePreview, false);
+
+ if (hash === '__uncommitted__') {
+ var uncommitted = state.uncommitted;
+ if (!uncommitted) {
+ if (summary) summary.innerHTML = 'No uncommitted changes
';
+ return;
+ }
+ var summaryHtml = 'WIP
Uncommitted changes
';
+ if (summary) summary.innerHTML = summaryHtml;
+ var list = $('detail-files-list');
+ if (list) list.innerHTML = '';
+ var files = (uncommitted.files || []);
+ if (files.length && list) {
+ if ($('detail-files-label')) $('detail-files-label').textContent = 'Changed Files (' + files.length + ')';
+ show(filesSection, true);
+ files.forEach(function (f) {
+ var li = document.createElement('li');
+ li.className = 'detail-file';
+ li.dataset.file = f.path || f.file || '';
+ var name = document.createElement('span');
+ name.className = 'detail-file__name';
+ name.textContent = f.path || f.file || '';
+ var stat = document.createElement('span');
+ stat.className = 'detail-file__stat';
+ stat.textContent = f.status || '';
+ li.appendChild(name);
+ li.appendChild(stat);
+ list.appendChild(li);
+ });
+ }
+ return;
+ }
+
+ var displayCommit = (state.commits || []).find(function (c) { return c.hash === hash; });
+ var isStashRow = displayCommit && (displayCommit.stash && displayCommit.stash.selector);
+
+ try {
+ const res = await call('git.show', { hash: hash });
+ if (!res || !res.commit) {
+ if (summary) summary.innerHTML = 'Commit not found
';
+ return;
+ }
+ const c = res.commit;
+
+ var summaryHtml = '';
+ summaryHtml += '' + escapeHtml(c.hash) + '
';
+ if (isStashRow && displayCommit.stash) {
+ summaryHtml += 'Stash: ' + escapeHtml(displayCommit.stash.selector || '') + '
Base: ' + escapeHtml((displayCommit.stash.baseHash || '').slice(0, 7)) + (displayCommit.stash.untrackedFilesHash ? ' · Untracked: ' + escapeHtml(displayCommit.stash.untrackedFilesHash.slice(0, 7)) : '') + '
';
+ }
+ var msgFirst = (c.message || '').split('\n')[0];
+ if (c.body && c.body.trim()) msgFirst += '\n\n' + c.body.trim();
+ summaryHtml += '' + escapeHtml(msgFirst) + '
';
+ summaryHtml += '' + escapeHtml(c.author || '') + ' <' + escapeHtml(c.email || '') + '>
' + escapeHtml(String(c.date || '')) + '
';
+ if (c.refs) {
+ summaryHtml += '';
+ parseRefs(c.refs).forEach(function (r) {
+ summaryHtml += '' + escapeHtml(r.label) + '';
+ });
+ summaryHtml += '
';
+ }
+ if ((c.heads || c.tags || c.remotes) && window.__GG.getRefsFromStructured) {
+ var refTags = window.__GG.getRefsFromStructured(c, state.branches && state.branches.current);
+ if (refTags.length) {
+ summaryHtml += '';
+ refTags.forEach(function (r) {
+ summaryHtml += '' + escapeHtml(r.label) + '';
+ });
+ summaryHtml += '
';
+ }
+ } else if (c.refs) {
+ summaryHtml += '';
+ parseRefs(c.refs).forEach(function (r) {
+ summaryHtml += '' + escapeHtml(r.label) + '';
+ });
+ summaryHtml += '
';
+ }
+ if (summary) summary.innerHTML = summaryHtml;
+
+ var list = $('detail-files-list');
+ if (list) list.innerHTML = '';
+ if (res.files && res.files.length && list) {
+ $('detail-files-label').textContent = 'Changed Files (' + res.files.length + ')';
+ show(filesSection, true);
+ res.files.forEach(function (f) {
+ var li = document.createElement('li');
+ li.className = 'detail-file';
+ li.dataset.file = f.file || '';
+ var name = document.createElement('span');
+ name.className = 'detail-file__name';
+ name.textContent = f.file || '';
+ name.title = f.file || '';
+ var stat = document.createElement('span');
+ stat.className = 'detail-file__stat';
+ if (f.insertions) {
+ var s = document.createElement('span');
+ s.className = 'stat-add';
+ s.textContent = '+' + f.insertions;
+ stat.appendChild(s);
+ }
+ if (f.deletions) {
+ var s2 = document.createElement('span');
+ s2.className = 'stat-del';
+ s2.textContent = '-' + f.deletions;
+ stat.appendChild(s2);
+ }
+ li.appendChild(name);
+ li.appendChild(stat);
+ li.addEventListener('click', function () {
+ var prev = list.querySelector('.detail-file--selected');
+ if (prev) prev.classList.remove('detail-file--selected');
+ if (prev === li) {
+ show(codePreview, false);
+ return;
+ }
+ li.classList.add('detail-file--selected');
+ var headerName = $('detail-code-preview-filename');
+ var headerStats = $('detail-code-preview-stats');
+ var content = $('detail-code-preview-content');
+ if (headerName) headerName.textContent = f.file || '';
+ if (headerName) headerName.title = f.file || '';
+ if (headerStats) headerStats.textContent = (f.insertions ? '+' + f.insertions : '') + ' ' + (f.deletions ? '-' + f.deletions : '');
+ if (content) {
+ content.innerHTML = 'Loading\u2026
';
+ }
+ show(codePreview, true);
+ (async function () {
+ try {
+ var diffRes = await call('git.fileDiff', { from: hash + '^', to: hash, file: f.file });
+ var lines = (diffRes.diff || '').split('\n');
+ var html = lines.map(function (line) {
+ var cls = (line.indexOf('+') === 0 && line.indexOf('+++') !== 0) ? 'diff-add'
+ : (line.indexOf('-') === 0 && line.indexOf('---') !== 0) ? 'diff-del'
+ : line.indexOf('@@') === 0 ? 'diff-hunk' : '';
+ return '' + escapeHtml(line) + '';
+ }).join('\n');
+ if (content) content.innerHTML = '' + html + '
';
+ } catch (err) {
+ if (content) content.innerHTML = '' + escapeHtml(err && err.message ? err.message : 'Failed to load diff') + '
';
+ }
+ })();
+ });
+ list.appendChild(li);
+ });
+ } else {
+ if ($('detail-files-label')) $('detail-files-label').textContent = 'Changed Files (0)';
+ }
+ } catch (e) {
+ if (summary) summary.innerHTML = '' + escapeHtml(e && e.message ? e.message : e) + '
';
+ }
+ };
+
+ window.__GG.closeDetail = function () {
+ state.selectedHash = null;
+ state.compareHashes = [];
+ show($('detail-resizer'), false);
+ show($('detail-panel'), false);
+ window.__GG.renderCommitList();
+ };
+
+ window.__GG.initDetailResizer = function () {
+ const resizer = $('detail-resizer');
+ const panel = $('detail-panel');
+ if (!resizer || !panel) return;
+ var startX = 0;
+ var startW = 0;
+ var MIN_PANEL = 420;
+ var MAX_PANEL = 720;
+ resizer.addEventListener('mousedown', function (e) {
+ e.preventDefault();
+ startX = e.clientX;
+ startW = panel.offsetWidth || Math.min(MAX_PANEL, Math.max(MIN_PANEL, Math.round(window.innerWidth * 0.36)));
+ resizer.classList.add('dragging');
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ function onMove(ev) {
+ var delta = startX - ev.clientX;
+ var mainW = (panel.parentElement && panel.parentElement.offsetWidth) || window.innerWidth;
+ mainW -= 6;
+ var maxPanelW = mainW - 80;
+ var newW = Math.min(Math.max(MIN_PANEL, startW + delta), Math.min(MAX_PANEL, maxPanelW));
+ panel.style.flexBasis = newW + 'px';
+ }
+ function onUp() {
+ resizer.classList.remove('dragging');
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ }
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+ };
+})();
+
+/* ui/main.js */
+/**
+ * Git Graph MiniApp — commit list, context menus, git actions, loadRepo.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const call = window.__GG.call;
+ const setLoading = window.__GG.setLoading;
+ const formatDate = window.__GG.formatDate;
+ const parseRefs = window.__GG.parseRefs;
+ const getRefsFromStructured = window.__GG.getRefsFromStructured;
+ const escapeHtml = window.__GG.escapeHtml;
+ const showContextMenu = window.__GG.showContextMenu;
+ const showModal = window.__GG.showModal;
+ const hideModal = window.__GG.hideModal;
+ const MAX_COMMITS = window.__GG.MAX_COMMITS;
+ const LANE_W = window.__GG.LANE_W;
+
+ window.__GG.renderCommitList = function () {
+ const list = $('commit-list');
+ list.innerHTML = '';
+ const display = window.__GG.getDisplayCommits();
+ if (!display.length) return;
+
+ var layout = window.__GG.computeGraphLayout(display, state.firstParent);
+ const laneCount = layout.laneCount;
+ const rowDrawInfo = layout.rowDrawInfo || [];
+ const graphW = Math.max(32, laneCount * LANE_W + 16);
+
+ display.forEach(function (c, i) {
+ const isUncommitted = c.hash === '__uncommitted__' || c.isUncommitted;
+ const isStash = c.isStash === true || (c.stash && c.stash.selector);
+ const drawInfo = rowDrawInfo[i] || { lane: 0, lanesBefore: [], parentLanes: [] };
+
+ const row = document.createElement('div');
+ row.className =
+ 'commit-row' +
+ (state.selectedHash === c.hash ? ' selected' : '') +
+ (state.compareHashes.indexOf(c.hash) !== -1 ? ' compare-selected' : '') +
+ (state.findMatches.length && state.findMatches[state.findIndex] === i ? ' find-highlight' : '') +
+ (isUncommitted ? ' commit-row--uncommitted' : '') +
+ (isStash ? ' commit-row--stash' : '');
+ row.dataset.hash = c.hash;
+ row.dataset.index = String(i);
+
+ row.addEventListener('click', function (e) {
+ if (e.ctrlKey || e.metaKey) {
+ if (state.compareHashes.indexOf(c.hash) !== -1) {
+ state.compareHashes = state.compareHashes.filter(function (h) { return h !== c.hash; });
+ } else {
+ state.compareHashes = state.compareHashes.concat(c.hash).slice(-2);
+ }
+ window.__GG.renderCommitList();
+ if (state.compareHashes.length === 2) window.__GG.openComparePanel(state.compareHashes[0], state.compareHashes[1]);
+ return;
+ }
+ if (isUncommitted) {
+ window.__GG.selectCommit('__uncommitted__');
+ return;
+ }
+ window.__GG.selectCommit(c.hash);
+ });
+
+ row.addEventListener('contextmenu', function (e) {
+ e.preventDefault();
+ if (isUncommitted) window.__GG.showUncommittedContextMenu(e.clientX, e.clientY);
+ else if (isStash) window.__GG.showStashContextMenu(e.clientX, e.clientY, c);
+ else window.__GG.showCommitContextMenu(e.clientX, e.clientY, c);
+ });
+
+ const graphCell = document.createElement('div');
+ graphCell.className = 'commit-row__graph';
+ graphCell.style.width = graphW + 'px';
+ const svg = window.__GG.buildRowSvg(
+ isUncommitted ? { parentHashes: [], hash: '' } : c,
+ drawInfo,
+ graphW,
+ isStash,
+ isUncommitted
+ );
+ graphCell.appendChild(svg);
+ row.appendChild(graphCell);
+
+ const info = document.createElement('div');
+ info.className = 'commit-row__info';
+ const hash = document.createElement('span');
+ hash.className = 'commit-row__hash';
+ hash.textContent = isUncommitted ? 'WIP' : (c.shortHash || (c.hash && c.hash.slice(0, 7)) || '');
+ const msg = document.createElement('span');
+ msg.className = 'commit-row__message';
+ msg.textContent = c.message || (isUncommitted ? 'Uncommitted changes' : '');
+ const refsSpan = document.createElement('span');
+ refsSpan.className = 'commit-row__refs';
+ var refTags = (c.heads || c.tags || c.remotes) ? getRefsFromStructured(c, state.branches && state.branches.current) : (c.refs ? parseRefs(c.refs) : []);
+ refTags.forEach(function (r) {
+ const tag = document.createElement('span');
+ tag.className = 'ref-tag ref-tag--' + r.type;
+ tag.textContent = r.label;
+ if (r.type === 'branch') {
+ tag.addEventListener('contextmenu', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ window.__GG.showBranchContextMenu(e.clientX, e.clientY, r.label, false);
+ });
+ } else if (r.type === 'remote') {
+ const parts = r.label.split('/');
+ if (parts.length >= 2) {
+ tag.addEventListener('contextmenu', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ window.__GG.showBranchContextMenu(e.clientX, e.clientY, parts.slice(1).join('/'), true, parts[0]);
+ });
+ }
+ }
+ refsSpan.appendChild(tag);
+ });
+ if (isStash && (c.stashSelector || (c.stash && c.stash.selector))) {
+ const t = document.createElement('span');
+ t.className = 'ref-tag ref-tag--tag';
+ t.textContent = c.stashSelector || (c.stash && c.stash.selector) || '';
+ refsSpan.appendChild(t);
+ }
+ const author = document.createElement('span');
+ author.className = 'commit-row__author';
+ author.textContent = c.author || '';
+ const date = document.createElement('span');
+ date.className = 'commit-row__date';
+ date.textContent = isUncommitted ? '' : formatDate(c.date);
+ info.appendChild(hash);
+ info.appendChild(refsSpan);
+ info.appendChild(msg);
+ info.appendChild(author);
+ info.appendChild(date);
+ row.appendChild(info);
+ list.appendChild(row);
+ });
+
+ show($('load-more'), state.hasMore && state.commits.length >= MAX_COMMITS);
+ };
+
+ window.__GG.showCommitContextMenu = function (x, y, c) {
+ showContextMenu(x, y, [
+ { label: 'Add Tag\u2026', action: function () { window.__GG.openAddTagDialog(c.hash); } },
+ { label: 'Create Branch\u2026', action: function () { window.__GG.openCreateBranchDialog(c.hash); } },
+ null,
+ { label: 'Checkout\u2026', action: function () { window.__GG.checkoutCommit(c.hash); } },
+ { label: 'Cherry Pick\u2026', action: function () { window.__GG.cherryPick(c.hash); } },
+ { label: 'Revert\u2026', action: function () { window.__GG.revertCommit(c.hash); } },
+ { label: 'Drop Commit\u2026', action: function () { window.__GG.dropCommit(c.hash); } },
+ null,
+ { label: 'Merge into current branch\u2026', action: function () { window.__GG.openMergeDialog(c.hash); } },
+ { label: 'Rebase onto this commit\u2026', action: function () { window.__GG.openRebaseDialog(c.hash); } },
+ { label: 'Reset current branch\u2026', action: function () { window.__GG.openResetDialog(c.hash); } },
+ null,
+ { label: 'Copy Hash', action: function () { navigator.clipboard.writeText(c.hash); } },
+ { label: 'Copy Subject', action: function () { navigator.clipboard.writeText(c.message || ''); } },
+ ]);
+ };
+
+ window.__GG.showStashContextMenu = function (x, y, c) {
+ var selector = c.stashSelector || (c.stash && c.stash.selector);
+ showContextMenu(x, y, [
+ { label: 'Apply Stash\u2026', action: function () { window.__GG.stashApply(selector); } },
+ { label: 'Pop Stash\u2026', action: function () { window.__GG.stashPop(selector); } },
+ { label: 'Drop Stash\u2026', action: function () { window.__GG.stashDrop(selector); } },
+ { label: 'Create Branch from Stash\u2026', action: function () { window.__GG.openStashBranchDialog(selector); } },
+ null,
+ { label: 'Copy Stash Name', action: function () { navigator.clipboard.writeText(selector || ''); } },
+ { label: 'Copy Hash', action: function () { navigator.clipboard.writeText(c.hash); } },
+ ]);
+ };
+
+ window.__GG.showUncommittedContextMenu = function (x, y) {
+ showContextMenu(x, y, [
+ { label: 'Stash uncommitted changes\u2026', action: function () { window.__GG.openStashPushDialog(); } },
+ null,
+ { label: 'Reset uncommitted changes\u2026', action: function () { window.__GG.openResetUncommittedDialog(); } },
+ { label: 'Clean untracked files\u2026', action: function () { window.__GG.cleanUntracked(); } },
+ ]);
+ };
+
+ window.__GG.showBranchContextMenu = function (x, y, branchName, isRemote, remoteName) {
+ isRemote = !!isRemote;
+ remoteName = remoteName || null;
+ const items = [];
+ if (isRemote) {
+ items.push(
+ { label: 'Checkout\u2026', action: function () { window.__GG.openCheckoutRemoteBranchDialog(remoteName, branchName); } },
+ { label: 'Fetch into local branch\u2026', action: function () { window.__GG.openFetchIntoLocalDialog(remoteName, branchName); } },
+ { label: 'Delete Remote Branch\u2026', action: function () { window.__GG.deleteRemoteBranch(remoteName, branchName); } }
+ );
+ } else {
+ items.push(
+ { label: 'Checkout', action: function () { window.__GG.checkoutBranch(branchName); } },
+ { label: 'Rename\u2026', action: function () { window.__GG.openRenameBranchDialog(branchName); } },
+ { label: 'Delete\u2026', action: function () { window.__GG.openDeleteBranchDialog(branchName); } },
+ null,
+ { label: 'Merge into current branch\u2026', action: function () { window.__GG.openMergeDialog(branchName); } },
+ { label: 'Rebase onto\u2026', action: function () { window.__GG.openRebaseDialog(branchName); } },
+ { label: 'Push\u2026', action: function () { window.__GG.openPushDialog(branchName); } }
+ );
+ }
+ items.push(null, { label: 'Copy Branch Name', action: function () { navigator.clipboard.writeText(branchName); } });
+ showContextMenu(x, y, items);
+ };
+
+ window.__GG.checkoutBranch = async function (name) {
+ setLoading(true);
+ try {
+ await call('git.checkout', { ref: name });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.checkoutCommit = async function (hash) {
+ setLoading(true);
+ try {
+ await call('git.checkout', { ref: hash });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.openCreateBranchDialog = function (startHash) {
+ showModal('Create Branch',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Create',
+ primary: true,
+ action: async function () {
+ const name = ($('modal-branch-name').value || '').trim();
+ if (!name) return;
+ const checkout = $('modal-branch-checkout').checked;
+ await call('git.createBranch', { name: name, startPoint: startHash, checkout: checkout });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openAddTagDialog = function (ref) {
+ showModal('Add Tag',
+ '' +
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Add',
+ primary: true,
+ action: async function () {
+ const name = ($('modal-tag-name').value || '').trim();
+ if (!name) return;
+ const annotated = $('modal-tag-annotated').checked;
+ const message = ($('modal-tag-message').value || '').trim();
+ await call('git.addTag', { name: name, ref: ref, annotated: annotated, message: annotated ? message : null });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ $('modal-tag-annotated').addEventListener('change', function () {
+ show($('modal-tag-message-wrap'), $('modal-tag-annotated').checked);
+ });
+ };
+
+ window.__GG.openMergeDialog = function (ref) {
+ showModal('Merge',
+ 'Merge ' + escapeHtml(ref) + ' into current branch?
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Merge',
+ primary: true,
+ action: async function () {
+ const noFF = $('modal-merge-no-ff').checked;
+ await call('git.merge', { ref: ref, noFF: noFF });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openRebaseDialog = function (ref) {
+ showModal('Rebase', 'Rebase current branch onto ' + escapeHtml(ref) + '?', [
+ { label: 'Cancel' },
+ {
+ label: 'Rebase',
+ primary: true,
+ action: async function () {
+ await call('git.rebase', { onto: ref });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.openResetDialog = function (hash) {
+ showModal('Reset',
+ 'Reset current branch to ' + escapeHtml(hash.slice(0, 7)) + '?
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Reset',
+ primary: true,
+ action: async function () {
+ const mode = $('modal-reset-mode').value;
+ await call('git.reset', { hash: hash, mode: mode });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openResetUncommittedDialog = function () {
+ showModal('Reset Uncommitted',
+ 'Reset all uncommitted changes?
',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Reset',
+ primary: true,
+ action: async function () {
+ const mode = $('modal-reset-uc-mode').value;
+ await call('git.resetUncommitted', { mode: mode });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.cherryPick = async function (hash) {
+ setLoading(true);
+ try {
+ await call('git.cherryPick', { hash: hash });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.revertCommit = async function (hash) {
+ setLoading(true);
+ try {
+ await call('git.revert', { hash: hash });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.dropCommit = function (hash) {
+ showModal('Drop Commit', 'Remove commit ' + hash.slice(0, 7) + ' from history?', [
+ { label: 'Cancel' },
+ {
+ label: 'Drop',
+ primary: true,
+ action: async function () {
+ await call('git.dropCommit', { hash: hash });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.openRenameBranchDialog = function (oldName) {
+ showModal('Rename Branch',
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Rename',
+ primary: true,
+ action: async function () {
+ const newName = ($('modal-rename-branch').value || '').trim();
+ if (!newName) return;
+ await call('git.renameBranch', { oldName: oldName, newName: newName });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openDeleteBranchDialog = function (name) {
+ showModal('Delete Branch',
+ 'Delete branch ' + escapeHtml(name) + '?
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Delete',
+ primary: true,
+ action: async function () {
+ const force = $('modal-delete-force').checked;
+ await call('git.deleteBranch', { name: name, force: force });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openCheckoutRemoteBranchDialog = function (remoteName, branchName) {
+ showModal('Checkout Remote Branch',
+ 'Create local branch from ' + escapeHtml(remoteName + '/' + branchName) + '
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Checkout',
+ primary: true,
+ action: async function () {
+ const localName = ($('modal-local-branch-name').value || '').trim() || branchName;
+ await call('git.checkout', { ref: remoteName + '/' + branchName, createBranch: localName });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openFetchIntoLocalDialog = function (remoteName, remoteBranch) {
+ showModal('Fetch into Local Branch',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Fetch',
+ primary: true,
+ action: async function () {
+ const localBranch = ($('modal-fetch-local-name').value || '').trim() || remoteBranch;
+ const force = $('modal-fetch-force').checked;
+ await call('git.fetchIntoLocalBranch', { remote: remoteName, remoteBranch: remoteBranch, localBranch: localBranch, force: force });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.deleteRemoteBranch = async function (remoteName, branchName) {
+ setLoading(true);
+ try {
+ await call('git.push', { remote: remoteName, branch: ':' + branchName });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.openPushDialog = function (branchName) {
+ const remotes = state.remotes.map(function (r) { return r.name; });
+ if (!remotes.length) {
+ showModal('Push', 'No remotes configured. Add one in Remote panel.
', [{ label: 'OK' }]);
+ return;
+ }
+ showModal('Push Branch',
+ '' +
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Push',
+ primary: true,
+ action: async function () {
+ const remote = $('modal-push-remote').value;
+ const setUpstream = $('modal-push-set-upstream').checked;
+ const force = $('modal-push-force').checked;
+ await call('git.push', { remote: remote, branch: branchName, setUpstream: setUpstream, force: force });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openStashPushDialog = function () {
+ showModal('Stash',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Stash',
+ primary: true,
+ action: async function () {
+ const message = ($('modal-stash-msg').value || '').trim() || null;
+ const includeUntracked = $('modal-stash-untracked').checked;
+ await call('git.stashPush', { message: message, includeUntracked: includeUntracked });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.stashApply = async function (selector) {
+ setLoading(true);
+ try {
+ await call('git.stashApply', { selector: selector });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.stashPop = async function (selector) {
+ setLoading(true);
+ try {
+ await call('git.stashPop', { selector: selector });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.stashDrop = function (selector) {
+ showModal('Drop Stash', 'Drop ' + selector + '?', [
+ { label: 'Cancel' },
+ {
+ label: 'Drop',
+ primary: true,
+ action: async function () {
+ await call('git.stashDrop', { selector: selector });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.openStashBranchDialog = function (selector) {
+ showModal('Create Branch from Stash',
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Create',
+ primary: true,
+ action: async function () {
+ const branchName = ($('modal-stash-branch-name').value || '').trim();
+ if (!branchName) return;
+ await call('git.stashBranch', { branchName: branchName, selector: selector });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.cleanUntracked = function () {
+ showModal('Clean Untracked', 'Remove all untracked files?', [
+ { label: 'Cancel' },
+ {
+ label: 'Clean',
+ primary: true,
+ action: async function () {
+ await call('git.cleanUntracked', { force: true, directories: true });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.loadRepo = async function () {
+ if (!state.cwd) return;
+ setLoading(true);
+ $('repo-path').textContent = state.cwd;
+ $('repo-path').title = state.cwd;
+
+ try {
+ const branchesParam = state.selectedBranchFilter.length > 0 ? state.selectedBranchFilter : [];
+ const graphData = await call('git.graphData', {
+ maxCount: MAX_COMMITS,
+ order: state.order,
+ firstParent: state.firstParent,
+ branches: branchesParam,
+ showRemoteBranches: true,
+ showStashes: true,
+ showUncommittedChanges: true,
+ hideRemotes: [],
+ });
+
+ state.commits = graphData.commits || [];
+ state.refs = graphData.refs || { head: null, heads: [], tags: [], remotes: [] };
+ state.stash = graphData.stashes || [];
+ state.uncommitted = graphData.uncommitted || null;
+ state.status = graphData.status || null;
+ state.remotes = graphData.remotes || [];
+ state.head = graphData.head || null;
+ state.hasMore = !!graphData.moreCommitsAvailable;
+
+ var currentBranch = null;
+ if (state.refs.head && state.refs.heads && state.refs.heads.length) {
+ var headEntry = state.refs.heads.find(function (h) { return h.hash === state.refs.head; });
+ if (headEntry) currentBranch = headEntry.name;
+ }
+ state.branches = {
+ current: currentBranch,
+ all: (state.refs.heads || []).map(function (h) { return h.name; }),
+ };
+
+ if (state.branches.current) {
+ $('branch-name').textContent = state.branches.current;
+ show($('branch-badge'), true);
+ }
+
+ const badge = $('status-badge');
+ if (state.status) {
+ const m = (state.status.modified && state.status.modified.length) || 0;
+ const s = (state.status.staged && state.status.staged.length) || 0;
+ const u = (state.status.not_added && state.status.not_added.length) || 0;
+ const total = m + s + u;
+ if (total > 0) {
+ const p = [];
+ if (m) p.push(m + ' modified');
+ if (s) p.push(s + ' staged');
+ if (u) p.push(u + ' untracked');
+ badge.textContent = p.join(' \u00b7 ');
+ badge.classList.add('has-changes');
+ } else {
+ badge.textContent = '\u2713 clean';
+ badge.classList.remove('has-changes');
+ }
+ show(badge, true);
+ }
+
+ show($('empty-state'), false);
+ show($('graph-area'), true);
+ window.__GG.renderCommitList();
+ window.__GG.updateFindMatches();
+ if ($('find-widget').style.display !== 'none') {
+ $('find-result').textContent = state.findMatches.length > 0 ? (state.findIndex + 1) + ' / ' + state.findMatches.length : '0';
+ }
+ } catch (e) {
+ console.error('load failed', e);
+ show($('empty-state'), true);
+ show($('graph-area'), false);
+ var desc = $('empty-state') && $('empty-state').querySelector('.empty-state__desc');
+ if (desc) desc.textContent = 'Load failed: ' + (e && e.message ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ };
+})();
+
+/* ui/bootstrap.js */
+/**
+ * Git Graph MiniApp — bootstrap: bind events, init resizer, restore last repo, theme subscription.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const STORAGE_KEY = window.__GG.STORAGE_KEY;
+
+ function init() {
+ $('btn-open-repo').addEventListener('click', window.__GG.openRepo);
+ $('btn-empty-open').addEventListener('click', window.__GG.openRepo);
+ $('btn-close-detail').addEventListener('click', window.__GG.closeDetail);
+ $('btn-refresh').addEventListener('click', function () { window.__GG.loadRepo(); });
+ $('btn-remotes').addEventListener('click', window.__GG.showRemotePanel);
+ $('btn-remote-close').addEventListener('click', function () { show($('remote-panel'), false); });
+ $('btn-find').addEventListener('click', window.__GG.showFindWidget);
+ $('find-input').addEventListener('input', function () {
+ state.findQuery = $('find-input').value;
+ window.__GG.updateFindMatches();
+ window.__GG.renderCommitList();
+ $('find-result').textContent = state.findMatches.length > 0 ? (state.findIndex + 1) + ' / ' + state.findMatches.length : '0';
+ });
+ $('find-prev').addEventListener('click', window.__GG.findPrev);
+ $('find-next').addEventListener('click', window.__GG.findNext);
+ $('find-close').addEventListener('click', function () {
+ show($('find-widget'), false);
+ state.findQuery = '';
+ state.findMatches = [];
+ window.__GG.renderCommitList();
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.ctrlKey && e.key === 'f') {
+ e.preventDefault();
+ window.__GG.showFindWidget();
+ }
+ if ($('find-widget').style.display !== 'none') {
+ if (e.key === 'Escape') show($('find-widget'), false);
+ if (e.key === 'Enter') (e.shiftKey ? window.__GG.findPrev : window.__GG.findNext)();
+ }
+ });
+ var loadMore = $('btn-load-more');
+ if (loadMore) loadMore.addEventListener('click', function () { window.__GG.loadRepo(); });
+
+ window.__GG.initDetailResizer();
+
+ var branchFilterBtn = $('btn-branch-filter');
+ if (branchFilterBtn) {
+ branchFilterBtn.addEventListener('click', function () {
+ var dropdown = $('branch-filter-dropdown');
+ var hidden = dropdown.getAttribute('aria-hidden') !== 'false';
+ dropdown.setAttribute('aria-hidden', String(!hidden));
+ if (hidden) window.__GG.renderBranchFilterDropdown();
+ });
+ }
+
+ if (window.app && typeof window.app.onThemeChange === 'function') {
+ window.app.onThemeChange(function () {
+ if (state.cwd && $('commit-list').children.length) {
+ window.__GG.renderCommitList();
+ }
+ });
+ }
+
+ (async function () {
+ try {
+ var last = await window.app.storage.get(STORAGE_KEY);
+ if (last && typeof last === 'string') {
+ state.cwd = last;
+ await window.__GG.loadRepo();
+ }
+ } catch (_) {}
+ })();
+ }
+
+ window.__GG.openRepo = async function () {
+ try {
+ var sel = await window.app.dialog.open({ directory: true, multiple: false });
+ if (Array.isArray(sel)) sel = sel[0];
+ if (!sel) return;
+ state.cwd = sel;
+ await window.app.storage.set(STORAGE_KEY, sel);
+ await window.__GG.loadRepo();
+ } catch (e) {
+ console.error('open failed', e);
+ }
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
+
diff --git a/MiniApp/Demo/git-graph/source/ui/bootstrap.js b/MiniApp/Demo/git-graph/source/ui/bootstrap.js
new file mode 100644
index 00000000..9021c560
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/bootstrap.js
@@ -0,0 +1,95 @@
+/**
+ * Git Graph MiniApp — bootstrap: bind events, init resizer, restore last repo, theme subscription.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const STORAGE_KEY = window.__GG.STORAGE_KEY;
+
+ function init() {
+ $('btn-open-repo').addEventListener('click', window.__GG.openRepo);
+ $('btn-empty-open').addEventListener('click', window.__GG.openRepo);
+ $('btn-close-detail').addEventListener('click', window.__GG.closeDetail);
+ $('btn-refresh').addEventListener('click', function () { window.__GG.loadRepo(); });
+ $('btn-remotes').addEventListener('click', window.__GG.showRemotePanel);
+ $('btn-remote-close').addEventListener('click', function () { show($('remote-panel'), false); });
+ $('btn-find').addEventListener('click', window.__GG.showFindWidget);
+ $('find-input').addEventListener('input', function () {
+ state.findQuery = $('find-input').value;
+ window.__GG.updateFindMatches();
+ window.__GG.renderCommitList();
+ $('find-result').textContent = state.findMatches.length > 0 ? (state.findIndex + 1) + ' / ' + state.findMatches.length : '0';
+ });
+ $('find-prev').addEventListener('click', window.__GG.findPrev);
+ $('find-next').addEventListener('click', window.__GG.findNext);
+ $('find-close').addEventListener('click', function () {
+ show($('find-widget'), false);
+ state.findQuery = '';
+ state.findMatches = [];
+ window.__GG.renderCommitList();
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.ctrlKey && e.key === 'f') {
+ e.preventDefault();
+ window.__GG.showFindWidget();
+ }
+ if ($('find-widget').style.display !== 'none') {
+ if (e.key === 'Escape') show($('find-widget'), false);
+ if (e.key === 'Enter') (e.shiftKey ? window.__GG.findPrev : window.__GG.findNext)();
+ }
+ });
+ var loadMore = $('btn-load-more');
+ if (loadMore) loadMore.addEventListener('click', function () { window.__GG.loadRepo(); });
+
+ window.__GG.initDetailResizer();
+
+ var branchFilterBtn = $('btn-branch-filter');
+ if (branchFilterBtn) {
+ branchFilterBtn.addEventListener('click', function () {
+ var dropdown = $('branch-filter-dropdown');
+ var hidden = dropdown.getAttribute('aria-hidden') !== 'false';
+ dropdown.setAttribute('aria-hidden', String(!hidden));
+ if (hidden) window.__GG.renderBranchFilterDropdown();
+ });
+ }
+
+ if (window.app && typeof window.app.onThemeChange === 'function') {
+ window.app.onThemeChange(function () {
+ if (state.cwd && $('commit-list').children.length) {
+ window.__GG.renderCommitList();
+ }
+ });
+ }
+
+ (async function () {
+ try {
+ var last = await window.app.storage.get(STORAGE_KEY);
+ if (last && typeof last === 'string') {
+ state.cwd = last;
+ await window.__GG.loadRepo();
+ }
+ } catch (_) {}
+ })();
+ }
+
+ window.__GG.openRepo = async function () {
+ try {
+ var sel = await window.app.dialog.open({ directory: true, multiple: false });
+ if (Array.isArray(sel)) sel = sel[0];
+ if (!sel) return;
+ state.cwd = sel;
+ await window.app.storage.set(STORAGE_KEY, sel);
+ await window.__GG.loadRepo();
+ } catch (e) {
+ console.error('open failed', e);
+ }
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/components/contextMenu.js b/MiniApp/Demo/git-graph/source/ui/components/contextMenu.js
new file mode 100644
index 00000000..1134c28f
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/components/contextMenu.js
@@ -0,0 +1,47 @@
+/**
+ * Git Graph MiniApp — context menu.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const $ = window.__GG.$;
+
+ window.__GG.showContextMenu = function (x, y, items) {
+ const menu = $('context-menu');
+ menu.innerHTML = '';
+ menu.setAttribute('aria-hidden', 'false');
+ menu.style.left = x + 'px';
+ menu.style.top = y + 'px';
+ items.forEach(function (item) {
+ if (item === null) {
+ const sep = document.createElement('div');
+ sep.className = 'context-menu__sep';
+ menu.appendChild(sep);
+ return;
+ }
+ const el = document.createElement('div');
+ el.className = 'context-menu__item' + (item.disabled ? ' context-menu__item--disabled' : '');
+ el.textContent = item.label;
+ if (!item.disabled && item.action) {
+ el.addEventListener('click', function () {
+ window.__GG.hideContextMenu();
+ item.action();
+ });
+ }
+ menu.appendChild(el);
+ });
+ };
+
+ window.__GG.hideContextMenu = function () {
+ const menu = $('context-menu');
+ if (menu) {
+ menu.setAttribute('aria-hidden', 'true');
+ menu.innerHTML = '';
+ }
+ };
+
+ document.addEventListener('click', function () { window.__GG.hideContextMenu(); });
+ document.addEventListener('contextmenu', function (e) {
+ if (e.target.closest('#context-menu')) return;
+ if (!e.target.closest('.commit-row')) window.__GG.hideContextMenu();
+ });
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/components/findWidget.js b/MiniApp/Demo/git-graph/source/ui/components/findWidget.js
new file mode 100644
index 00000000..47b82a0f
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/components/findWidget.js
@@ -0,0 +1,105 @@
+/**
+ * Git Graph MiniApp — find widget and branch filter dropdown.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+
+ window.__GG.updateFindMatches = function () {
+ const q = state.findQuery.trim().toLowerCase();
+ if (!q) {
+ state.findMatches = [];
+ state.findIndex = 0;
+ return;
+ }
+ const list = window.__GG.getDisplayCommits();
+ state.findMatches = list
+ .map(function (c, i) { return { c: c, i: i }; })
+ .filter(function (x) {
+ const c = x.c;
+ return (c.message && c.message.toLowerCase().indexOf(q) !== -1) ||
+ (c.hash && c.hash.toLowerCase().indexOf(q) !== -1) ||
+ (c.shortHash && c.shortHash.toLowerCase().indexOf(q) !== -1) ||
+ (c.author && c.author.toLowerCase().indexOf(q) !== -1);
+ })
+ .map(function (x) { return x.i; });
+ state.findIndex = 0;
+ };
+
+ window.__GG.showFindWidget = function () {
+ show($('find-widget'), true);
+ $('find-input').value = state.findQuery;
+ $('find-input').focus();
+ window.__GG.updateFindMatches();
+ window.__GG.renderCommitList();
+ $('find-result').textContent = state.findMatches.length > 0 ? '1 / ' + state.findMatches.length : '0';
+ };
+
+ window.__GG.findPrev = function () {
+ if (state.findMatches.length === 0) return;
+ state.findIndex = (state.findIndex - 1 + state.findMatches.length) % state.findMatches.length;
+ window.__GG.scrollToFindIndex();
+ };
+
+ window.__GG.findNext = function () {
+ if (state.findMatches.length === 0) return;
+ state.findIndex = (state.findIndex + 1) % state.findMatches.length;
+ window.__GG.scrollToFindIndex();
+ };
+
+ window.__GG.scrollToFindIndex = function () {
+ const idx = state.findMatches[state.findIndex];
+ if (idx === undefined) return;
+ const list = $('commit-list');
+ const rows = list.querySelectorAll('.commit-row');
+ const row = rows[idx];
+ if (row) row.scrollIntoView({ block: 'nearest' });
+ $('find-result').textContent = (state.findIndex + 1) + ' / ' + state.findMatches.length;
+ window.__GG.renderCommitList();
+ };
+
+ window.__GG.renderBranchFilterDropdown = function () {
+ const dropdown = $('branch-filter-dropdown');
+ if (!state.branches || !dropdown) return;
+ const all = state.branches.all || [];
+ const selected = state.selectedBranchFilter.length === 0 ? 'all' : state.selectedBranchFilter;
+ dropdown.innerHTML = '';
+ const allItem = document.createElement('div');
+ allItem.className = 'dropdown-panel__item';
+ allItem.textContent = 'All branches';
+ allItem.addEventListener('click', function () {
+ state.selectedBranchFilter = [];
+ $('branch-filter-label').textContent = 'All branches';
+ dropdown.setAttribute('aria-hidden', 'true');
+ window.__GG.loadRepo();
+ });
+ dropdown.appendChild(allItem);
+ const sep = document.createElement('div');
+ sep.className = 'dropdown-panel__sep';
+ dropdown.appendChild(sep);
+ all.forEach(function (name) {
+ const isSelected = selected === 'all' || selected.indexOf(name) !== -1;
+ const div = document.createElement('div');
+ div.className = 'dropdown-panel__item';
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = isSelected;
+ cb.addEventListener('change', function () {
+ if (selected === 'all') {
+ state.selectedBranchFilter = [name];
+ } else {
+ if (cb.checked) state.selectedBranchFilter = state.selectedBranchFilter.concat(name);
+ else state.selectedBranchFilter = state.selectedBranchFilter.filter(function (n) { return n !== name; });
+ }
+ $('branch-filter-label').textContent =
+ state.selectedBranchFilter.length === 0 ? 'All branches' : state.selectedBranchFilter.join(', ');
+ window.__GG.loadRepo();
+ });
+ div.appendChild(cb);
+ div.appendChild(document.createTextNode(' ' + name));
+ dropdown.appendChild(div);
+ });
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/components/modal.js b/MiniApp/Demo/git-graph/source/ui/components/modal.js
new file mode 100644
index 00000000..1c6f2a3f
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/components/modal.js
@@ -0,0 +1,37 @@
+/**
+ * Git Graph MiniApp — modal dialog.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const $ = window.__GG.$;
+
+ window.__GG.showModal = function (title, bodyHTML, buttons) {
+ const overlay = $('modal-overlay');
+ const titleEl = $('modal-title');
+ const bodyEl = $('modal-body');
+ const actionsEl = overlay.querySelector('.modal-dialog__actions');
+ titleEl.textContent = title;
+ bodyEl.innerHTML = bodyHTML;
+ actionsEl.innerHTML = '';
+ buttons.forEach(function (btn) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = btn.primary ? 'btn btn--primary' : 'btn btn--secondary';
+ b.textContent = btn.label;
+ b.addEventListener('click', function () {
+ if (btn.action) btn.action(b);
+ else window.__GG.hideModal();
+ });
+ actionsEl.appendChild(b);
+ });
+ overlay.setAttribute('aria-hidden', 'false');
+ $('modal-close').onclick = function () { window.__GG.hideModal(); };
+ overlay.onclick = function (e) {
+ if (e.target === overlay) window.__GG.hideModal();
+ };
+ };
+
+ window.__GG.hideModal = function () {
+ window.__GG.$('modal-overlay').setAttribute('aria-hidden', 'true');
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/graph/layout.js b/MiniApp/Demo/git-graph/source/ui/graph/layout.js
new file mode 100644
index 00000000..c3c2f1ed
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/graph/layout.js
@@ -0,0 +1,284 @@
+/**
+ * Git Graph MiniApp — global topology graph layout (Vertex/Branch/determinePath).
+ * Outputs per-row drawInfo compatible with renderRowSvg: { lane, lanesBefore, parentLanes }.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const NULL_VERTEX_ID = -1;
+
+ function Vertex(id, isStash) {
+ this.id = id;
+ this.isStash = !!isStash;
+ this.x = 0;
+ this.children = [];
+ this.parents = [];
+ this.nextParent = 0;
+ this.onBranch = null;
+ this.isCommitted = true;
+ this.nextX = 0;
+ this.connections = [];
+ }
+ Vertex.prototype.addChild = function (v) { this.children.push(v); };
+ Vertex.prototype.addParent = function (v) { this.parents.push(v); };
+ Vertex.prototype.getNextParent = function () {
+ return this.nextParent < this.parents.length ? this.parents[this.nextParent] : null;
+ };
+ Vertex.prototype.registerParentProcessed = function () { this.nextParent++; };
+ Vertex.prototype.isNotOnBranch = function () { return this.onBranch === null; };
+ Vertex.prototype.getPoint = function () { return { x: this.x, y: this.id }; };
+ Vertex.prototype.getNextPoint = function () { return { x: this.nextX, y: this.id }; };
+ Vertex.prototype.getPointConnectingTo = function (vertex, onBranch) {
+ for (let i = 0; i < this.connections.length; i++) {
+ if (this.connections[i] && this.connections[i].connectsTo === vertex && this.connections[i].onBranch === onBranch) {
+ return { x: i, y: this.id };
+ }
+ }
+ return null;
+ };
+ Vertex.prototype.registerUnavailablePoint = function (x, connectsToVertex, onBranch) {
+ if (x === this.nextX) {
+ this.nextX = x + 1;
+ while (this.connections.length <= x) this.connections.push(null);
+ this.connections[x] = { connectsTo: connectsToVertex, onBranch: onBranch };
+ }
+ };
+ Vertex.prototype.addToBranch = function (branch, x) {
+ if (this.onBranch === null) {
+ this.onBranch = branch;
+ this.x = x;
+ }
+ };
+ Vertex.prototype.getBranch = function () { return this.onBranch; };
+ Vertex.prototype.getIsCommitted = function () { return this.isCommitted; };
+ Vertex.prototype.setNotCommitted = function () { this.isCommitted = false; };
+ Vertex.prototype.isMerge = function () { return this.parents.length > 1; };
+
+ function Branch(colour) {
+ this.colour = colour;
+ this.lines = [];
+ }
+ Branch.prototype.getColour = function () { return this.colour; };
+ Branch.prototype.addLine = function (p1, p2, isCommitted, lockedFirst) {
+ this.lines.push({ p1: p1, p2: p2, lockedFirst: lockedFirst });
+ };
+
+ function getAvailableColour(availableColours, startAt) {
+ for (let i = 0; i < availableColours.length; i++) {
+ if (startAt > availableColours[i]) return i;
+ }
+ availableColours.push(0);
+ return availableColours.length - 1;
+ }
+
+ function determinePath(vertices, branches, availableColours, commits, commitLookup, onlyFollowFirstParent) {
+ function run(startAt) {
+ let i = startAt;
+ let vertex = vertices[i];
+ let parentVertex = vertex.getNextParent();
+ let lastPoint = vertex.isNotOnBranch() ? vertex.getNextPoint() : vertex.getPoint();
+
+ if (parentVertex !== null && parentVertex.id !== NULL_VERTEX_ID && vertex.isMerge() && !vertex.isNotOnBranch() && !parentVertex.isNotOnBranch()) {
+ var parentBranch = parentVertex.getBranch();
+ var foundPointToParent = false;
+ for (i = startAt + 1; i < vertices.length; i++) {
+ var curVertex = vertices[i];
+ var curPoint = curVertex.getPointConnectingTo(parentVertex, parentBranch);
+ if (curPoint === null) curPoint = curVertex.getNextPoint();
+ parentBranch.addLine(lastPoint, curPoint, vertex.getIsCommitted(), !foundPointToParent && curVertex !== parentVertex ? lastPoint.x < curPoint.x : true);
+ curVertex.registerUnavailablePoint(curPoint.x, parentVertex, parentBranch);
+ lastPoint = curPoint;
+ if (curVertex.getPointConnectingTo(parentVertex, parentBranch) !== null) foundPointToParent = true;
+ if (foundPointToParent) {
+ vertex.registerParentProcessed();
+ return;
+ }
+ }
+ } else {
+ var branch = new Branch(getAvailableColour(availableColours, startAt));
+ vertex.addToBranch(branch, lastPoint.x);
+ vertex.registerUnavailablePoint(lastPoint.x, vertex, branch);
+ for (i = startAt + 1; i < vertices.length; i++) {
+ var curVertex = vertices[i];
+ var curPoint = (parentVertex === curVertex && parentVertex && !parentVertex.isNotOnBranch()) ? curVertex.getPoint() : curVertex.getNextPoint();
+ branch.addLine(lastPoint, curPoint, vertex.getIsCommitted(), lastPoint.x < curPoint.x);
+ curVertex.registerUnavailablePoint(curPoint.x, parentVertex, branch);
+ lastPoint = curPoint;
+ if (parentVertex === curVertex) {
+ vertex.registerParentProcessed();
+ var parentVertexOnBranch = parentVertex && !parentVertex.isNotOnBranch();
+ parentVertex.addToBranch(branch, curPoint.x);
+ vertex = parentVertex;
+ parentVertex = vertex.getNextParent();
+ if (parentVertex === null || parentVertexOnBranch) return;
+ }
+ }
+ if (i === vertices.length && parentVertex !== null && parentVertex.id === NULL_VERTEX_ID) {
+ vertex.registerParentProcessed();
+ }
+ branches.push(branch);
+ availableColours[branch.getColour()] = i;
+ }
+ }
+
+ var idx = 0;
+ while (idx < vertices.length) {
+ var v = vertices[idx];
+ if (v.getNextParent() !== null || v.isNotOnBranch()) {
+ run(idx);
+ } else {
+ idx++;
+ }
+ }
+ }
+
+ function computeFallbackLayout(commits) {
+ const idx = {};
+ commits.forEach(function (c, i) { idx[c.hash] = i; });
+
+ const commitLane = new Array(commits.length);
+ const rowDrawInfo = [];
+ const activeLanes = [];
+ let maxLane = 0;
+
+ for (let i = 0; i < commits.length; i++) {
+ const c = commits[i];
+ const lanesBefore = activeLanes.slice();
+
+ let lane = lanesBefore.indexOf(c.hash);
+ if (lane === -1) {
+ lane = activeLanes.indexOf(null);
+ if (lane === -1) {
+ lane = activeLanes.length;
+ activeLanes.push(null);
+ }
+ }
+
+ commitLane[i] = lane;
+ while (activeLanes.length <= lane) activeLanes.push(null);
+ activeLanes[lane] = null;
+
+ const raw = c.parentHashes || c.parents || (c.parent != null ? [c.parent] : []);
+ const parents = Array.isArray(raw) ? raw : [raw];
+ const parentLanes = [];
+ for (let p = 0; p < parents.length; p++) {
+ const ph = parents[p];
+ if (idx[ph] === undefined) continue;
+
+ const existing = activeLanes.indexOf(ph);
+ if (existing >= 0) {
+ parentLanes.push({ lane: existing });
+ } else if (p === 0) {
+ activeLanes[lane] = ph;
+ parentLanes.push({ lane: lane });
+ } else {
+ let sl = activeLanes.indexOf(null);
+ if (sl === -1) {
+ sl = activeLanes.length;
+ activeLanes.push(null);
+ }
+ activeLanes[sl] = ph;
+ parentLanes.push({ lane: sl });
+ }
+ }
+
+ maxLane = Math.max(
+ maxLane,
+ lane,
+ parentLanes.length ? Math.max.apply(null, parentLanes.map(function (pl) { return pl.lane; })) : 0
+ );
+ rowDrawInfo.push({ lane: lane, lanesBefore: lanesBefore, parentLanes: parentLanes });
+ }
+
+ return { commitLane: commitLane, laneCount: maxLane + 1, idx: idx, rowDrawInfo: rowDrawInfo };
+ }
+
+ function isReasonableLayout(layout, commitCount) {
+ if (!layout || !Array.isArray(layout.rowDrawInfo) || layout.rowDrawInfo.length !== commitCount) return false;
+ if (!Number.isFinite(layout.laneCount) || layout.laneCount < 1) return false;
+
+ for (let i = 0; i < layout.rowDrawInfo.length; i++) {
+ const row = layout.rowDrawInfo[i];
+ if (!row || !Number.isFinite(row.lane) || row.lane < 0) return false;
+ if (!Array.isArray(row.parentLanes) || !Array.isArray(row.lanesBefore)) return false;
+ for (let j = 0; j < row.parentLanes.length; j++) {
+ if (!Number.isFinite(row.parentLanes[j].lane) || row.parentLanes[j].lane < 0) return false;
+ }
+ }
+
+ // If almost every row gets its own lane, the topology solver likely drifted.
+ if (commitCount >= 12 && layout.laneCount > Math.ceil(commitCount * 0.5)) return false;
+ return true;
+ }
+
+ /**
+ * Compute per-row graph layout using global topology (Vertex/Branch/determinePath).
+ * commits: array of { hash, parentHashes, stash } (parentHashes = array of hash strings).
+ * onlyFollowFirstParent: optional boolean (default false).
+ * Returns { commitLane, laneCount, idx, rowDrawInfo } for use by renderRowSvg.
+ */
+ window.__GG.computeGraphLayout = function (commits, onlyFollowFirstParent) {
+ onlyFollowFirstParent = !!onlyFollowFirstParent;
+ const idx = {};
+ commits.forEach(function (c, i) { idx[c.hash] = i; });
+ const n = commits.length;
+ if (n === 0) return { commitLane: [], laneCount: 1, idx: idx, rowDrawInfo: [] };
+
+ const nullVertex = new Vertex(NULL_VERTEX_ID, false);
+ const vertices = [];
+ for (let i = 0; i < n; i++) {
+ vertices.push(new Vertex(i, !!(commits[i].stash)));
+ }
+ for (let i = 0; i < n; i++) {
+ const raw = commits[i].parentHashes || commits[i].parents || (commits[i].parent != null ? [commits[i].parent] : []);
+ const parents = Array.isArray(raw) ? raw : [raw];
+ for (let p = 0; p < parents.length; p++) {
+ const ph = parents[p];
+ if (typeof idx[ph] === 'number') {
+ vertices[i].addParent(vertices[idx[ph]]);
+ vertices[idx[ph]].addChild(vertices[i]);
+ } else if (!onlyFollowFirstParent || p === 0) {
+ vertices[i].addParent(nullVertex);
+ }
+ }
+ }
+ if ((commits[0] && (commits[0].hash === '__uncommitted__' || commits[0].isUncommitted))) {
+ vertices[0].setNotCommitted();
+ }
+ const branches = [];
+ const availableColours = [];
+ determinePath(vertices, branches, availableColours, commits, idx, onlyFollowFirstParent);
+
+ const commitLane = [];
+ const rowDrawInfo = [];
+ let maxLane = 0;
+ const activeLanes = [];
+ for (let i = 0; i < n; i++) {
+ const v = vertices[i];
+ const lane = v.x;
+ maxLane = Math.max(maxLane, lane);
+ commitLane[i] = lane;
+ const lanesBefore = activeLanes.slice();
+ while (activeLanes.length <= lane) activeLanes.push(null);
+ activeLanes[lane] = null;
+ const parentLanes = [];
+ const parents = v.parents;
+ for (let p = 0; p < parents.length; p++) {
+ const pv = parents[p];
+ if (pv.id === NULL_VERTEX_ID) continue;
+ const pl = pv.x;
+ parentLanes.push({ lane: pl });
+ maxLane = Math.max(maxLane, pl);
+ while (activeLanes.length <= pl) activeLanes.push(null);
+ activeLanes[pl] = commits[pv.id].hash;
+ }
+ rowDrawInfo.push({ lane: lane, lanesBefore: lanesBefore, parentLanes: parentLanes });
+ }
+ const result = {
+ commitLane: commitLane,
+ laneCount: maxLane + 1,
+ idx: idx,
+ rowDrawInfo: rowDrawInfo,
+ };
+ return isReasonableLayout(result, n) ? result : computeFallbackLayout(commits);
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/graph/renderRowSvg.js b/MiniApp/Demo/git-graph/source/ui/graph/renderRowSvg.js
new file mode 100644
index 00000000..a8da6a3d
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/graph/renderRowSvg.js
@@ -0,0 +1,105 @@
+/**
+ * Git Graph MiniApp — build SVG for one commit row (theme-aware colors).
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const ROW_H = window.__GG.ROW_H;
+ const LANE_W = window.__GG.LANE_W;
+ const NODE_R = window.__GG.NODE_R;
+
+ window.__GG.buildRowSvg = function (commit, drawInfo, graphW, isStash, isUncommitted) {
+ isStash = !!isStash;
+ isUncommitted = !!isUncommitted;
+ const lane = drawInfo.lane;
+ const lanesBefore = drawInfo.lanesBefore;
+ const parentLanes = drawInfo.parentLanes;
+ const colors = window.__GG.getGraphColors();
+ const nodeStroke = window.__GG.getNodeStroke();
+ const uncommittedColor = window.__GG.getUncommittedColor();
+
+ const svgNS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(svgNS, 'svg');
+ svg.setAttribute('width', graphW);
+ svg.setAttribute('height', ROW_H);
+ svg.setAttribute('viewBox', '0 0 ' + graphW + ' ' + ROW_H);
+ svg.style.display = 'block';
+ svg.style.overflow = 'visible';
+
+ const cx = lane * LANE_W + LANE_W / 2 + 4;
+ const cy = ROW_H / 2;
+ const nodeColor = isUncommitted ? uncommittedColor : (colors[lane % colors.length] || colors[0]);
+ const bezierD = ROW_H * 0.8;
+
+ function laneX(l) { return l * LANE_W + LANE_W / 2 + 4; }
+
+ function mkPath(dAttr, stroke, dash) {
+ const p = document.createElementNS(svgNS, 'path');
+ p.setAttribute('d', dAttr);
+ p.setAttribute('stroke', stroke);
+ p.setAttribute('fill', 'none');
+ p.setAttribute('stroke-width', '1.5');
+ p.setAttribute('class', 'graph-line');
+ if (dash) p.setAttribute('stroke-dasharray', dash);
+ return p;
+ }
+
+ for (let l = 0; l < lanesBefore.length; l++) {
+ if (lanesBefore[l] !== null && l !== lane) {
+ const stroke = colors[l % colors.length] || colors[0];
+ svg.appendChild(mkPath('M' + laneX(l) + ' 0 L' + laneX(l) + ' ' + ROW_H, stroke, null));
+ }
+ }
+
+ const wasActive = lane < lanesBefore.length && lanesBefore[lane] !== null;
+ if (wasActive) {
+ const path = mkPath('M' + cx + ' 0 L' + cx + ' ' + cy, nodeColor, isUncommitted ? '3 3' : null);
+ if (isUncommitted) path.setAttribute('class', 'graph-line graph-line--uncommitted');
+ svg.appendChild(path);
+ }
+
+ for (let i = 0; i < parentLanes.length; i++) {
+ const pl = parentLanes[i];
+ const px = laneX(pl.lane);
+ const lineColor = isUncommitted ? uncommittedColor : (colors[pl.lane % colors.length] || colors[0]);
+ const dash = isUncommitted ? '3 3' : null;
+ var path;
+ if (px === cx) {
+ path = mkPath('M' + cx + ' ' + cy + ' L' + cx + ' ' + ROW_H, lineColor, dash);
+ } else {
+ path = mkPath(
+ 'M' + cx + ' ' + cy + ' C' + cx + ' ' + (cy + bezierD) + ' ' + px + ' ' + (ROW_H - bezierD) + ' ' + px + ' ' + ROW_H,
+ lineColor, dash
+ );
+ }
+ if (isUncommitted) path.setAttribute('class', 'graph-line graph-line--uncommitted');
+ svg.appendChild(path);
+ }
+
+ if (isStash) {
+ const outer = document.createElementNS(svgNS, 'circle');
+ outer.setAttribute('cx', cx); outer.setAttribute('cy', cy); outer.setAttribute('r', 4.5);
+ outer.setAttribute('fill', 'none'); outer.setAttribute('stroke', nodeColor); outer.setAttribute('stroke-width', '1.5');
+ outer.setAttribute('class', 'graph-node graph-node--stash-outer');
+ svg.appendChild(outer);
+ const inner = document.createElementNS(svgNS, 'circle');
+ inner.setAttribute('cx', cx); inner.setAttribute('cy', cy); inner.setAttribute('r', 2);
+ inner.setAttribute('fill', nodeColor);
+ inner.setAttribute('class', 'graph-node graph-node--stash-inner');
+ svg.appendChild(inner);
+ } else if (isUncommitted) {
+ const circle = document.createElementNS(svgNS, 'circle');
+ circle.setAttribute('cx', cx); circle.setAttribute('cy', cy); circle.setAttribute('r', NODE_R);
+ circle.setAttribute('fill', 'none'); circle.setAttribute('stroke', uncommittedColor); circle.setAttribute('stroke-width', '1.5');
+ circle.setAttribute('class', 'graph-node graph-node--uncommitted');
+ svg.appendChild(circle);
+ } else {
+ const circle = document.createElementNS(svgNS, 'circle');
+ circle.setAttribute('cx', cx); circle.setAttribute('cy', cy); circle.setAttribute('r', NODE_R);
+ circle.setAttribute('fill', nodeColor); circle.setAttribute('stroke', nodeStroke); circle.setAttribute('stroke-width', '1.5');
+ circle.setAttribute('class', 'graph-node graph-node--commit');
+ svg.appendChild(circle);
+ }
+
+ return svg;
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/main.js b/MiniApp/Demo/git-graph/source/ui/main.js
new file mode 100644
index 00000000..791316c2
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/main.js
@@ -0,0 +1,679 @@
+/**
+ * Git Graph MiniApp — commit list, context menus, git actions, loadRepo.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const call = window.__GG.call;
+ const setLoading = window.__GG.setLoading;
+ const formatDate = window.__GG.formatDate;
+ const parseRefs = window.__GG.parseRefs;
+ const getRefsFromStructured = window.__GG.getRefsFromStructured;
+ const escapeHtml = window.__GG.escapeHtml;
+ const showContextMenu = window.__GG.showContextMenu;
+ const showModal = window.__GG.showModal;
+ const hideModal = window.__GG.hideModal;
+ const MAX_COMMITS = window.__GG.MAX_COMMITS;
+ const LANE_W = window.__GG.LANE_W;
+
+ window.__GG.renderCommitList = function () {
+ const list = $('commit-list');
+ list.innerHTML = '';
+ const display = window.__GG.getDisplayCommits();
+ if (!display.length) return;
+
+ var layout = window.__GG.computeGraphLayout(display, state.firstParent);
+ const laneCount = layout.laneCount;
+ const rowDrawInfo = layout.rowDrawInfo || [];
+ const graphW = Math.max(32, laneCount * LANE_W + 16);
+
+ display.forEach(function (c, i) {
+ const isUncommitted = c.hash === '__uncommitted__' || c.isUncommitted;
+ const isStash = c.isStash === true || (c.stash && c.stash.selector);
+ const drawInfo = rowDrawInfo[i] || { lane: 0, lanesBefore: [], parentLanes: [] };
+
+ const row = document.createElement('div');
+ row.className =
+ 'commit-row' +
+ (state.selectedHash === c.hash ? ' selected' : '') +
+ (state.compareHashes.indexOf(c.hash) !== -1 ? ' compare-selected' : '') +
+ (state.findMatches.length && state.findMatches[state.findIndex] === i ? ' find-highlight' : '') +
+ (isUncommitted ? ' commit-row--uncommitted' : '') +
+ (isStash ? ' commit-row--stash' : '');
+ row.dataset.hash = c.hash;
+ row.dataset.index = String(i);
+
+ row.addEventListener('click', function (e) {
+ if (e.ctrlKey || e.metaKey) {
+ if (state.compareHashes.indexOf(c.hash) !== -1) {
+ state.compareHashes = state.compareHashes.filter(function (h) { return h !== c.hash; });
+ } else {
+ state.compareHashes = state.compareHashes.concat(c.hash).slice(-2);
+ }
+ window.__GG.renderCommitList();
+ if (state.compareHashes.length === 2) window.__GG.openComparePanel(state.compareHashes[0], state.compareHashes[1]);
+ return;
+ }
+ if (isUncommitted) {
+ window.__GG.selectCommit('__uncommitted__');
+ return;
+ }
+ window.__GG.selectCommit(c.hash);
+ });
+
+ row.addEventListener('contextmenu', function (e) {
+ e.preventDefault();
+ if (isUncommitted) window.__GG.showUncommittedContextMenu(e.clientX, e.clientY);
+ else if (isStash) window.__GG.showStashContextMenu(e.clientX, e.clientY, c);
+ else window.__GG.showCommitContextMenu(e.clientX, e.clientY, c);
+ });
+
+ const graphCell = document.createElement('div');
+ graphCell.className = 'commit-row__graph';
+ graphCell.style.width = graphW + 'px';
+ const svg = window.__GG.buildRowSvg(
+ isUncommitted ? { parentHashes: [], hash: '' } : c,
+ drawInfo,
+ graphW,
+ isStash,
+ isUncommitted
+ );
+ graphCell.appendChild(svg);
+ row.appendChild(graphCell);
+
+ const info = document.createElement('div');
+ info.className = 'commit-row__info';
+ const hash = document.createElement('span');
+ hash.className = 'commit-row__hash';
+ hash.textContent = isUncommitted ? 'WIP' : (c.shortHash || (c.hash && c.hash.slice(0, 7)) || '');
+ const msg = document.createElement('span');
+ msg.className = 'commit-row__message';
+ msg.textContent = c.message || (isUncommitted ? 'Uncommitted changes' : '');
+ const refsSpan = document.createElement('span');
+ refsSpan.className = 'commit-row__refs';
+ var refTags = (c.heads || c.tags || c.remotes) ? getRefsFromStructured(c, state.branches && state.branches.current) : (c.refs ? parseRefs(c.refs) : []);
+ refTags.forEach(function (r) {
+ const tag = document.createElement('span');
+ tag.className = 'ref-tag ref-tag--' + r.type;
+ tag.textContent = r.label;
+ if (r.type === 'branch') {
+ tag.addEventListener('contextmenu', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ window.__GG.showBranchContextMenu(e.clientX, e.clientY, r.label, false);
+ });
+ } else if (r.type === 'remote') {
+ const parts = r.label.split('/');
+ if (parts.length >= 2) {
+ tag.addEventListener('contextmenu', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ window.__GG.showBranchContextMenu(e.clientX, e.clientY, parts.slice(1).join('/'), true, parts[0]);
+ });
+ }
+ }
+ refsSpan.appendChild(tag);
+ });
+ if (isStash && (c.stashSelector || (c.stash && c.stash.selector))) {
+ const t = document.createElement('span');
+ t.className = 'ref-tag ref-tag--tag';
+ t.textContent = c.stashSelector || (c.stash && c.stash.selector) || '';
+ refsSpan.appendChild(t);
+ }
+ const author = document.createElement('span');
+ author.className = 'commit-row__author';
+ author.textContent = c.author || '';
+ const date = document.createElement('span');
+ date.className = 'commit-row__date';
+ date.textContent = isUncommitted ? '' : formatDate(c.date);
+ info.appendChild(hash);
+ info.appendChild(refsSpan);
+ info.appendChild(msg);
+ info.appendChild(author);
+ info.appendChild(date);
+ row.appendChild(info);
+ list.appendChild(row);
+ });
+
+ show($('load-more'), state.hasMore && state.commits.length >= MAX_COMMITS);
+ };
+
+ window.__GG.showCommitContextMenu = function (x, y, c) {
+ showContextMenu(x, y, [
+ { label: 'Add Tag\u2026', action: function () { window.__GG.openAddTagDialog(c.hash); } },
+ { label: 'Create Branch\u2026', action: function () { window.__GG.openCreateBranchDialog(c.hash); } },
+ null,
+ { label: 'Checkout\u2026', action: function () { window.__GG.checkoutCommit(c.hash); } },
+ { label: 'Cherry Pick\u2026', action: function () { window.__GG.cherryPick(c.hash); } },
+ { label: 'Revert\u2026', action: function () { window.__GG.revertCommit(c.hash); } },
+ { label: 'Drop Commit\u2026', action: function () { window.__GG.dropCommit(c.hash); } },
+ null,
+ { label: 'Merge into current branch\u2026', action: function () { window.__GG.openMergeDialog(c.hash); } },
+ { label: 'Rebase onto this commit\u2026', action: function () { window.__GG.openRebaseDialog(c.hash); } },
+ { label: 'Reset current branch\u2026', action: function () { window.__GG.openResetDialog(c.hash); } },
+ null,
+ { label: 'Copy Hash', action: function () { navigator.clipboard.writeText(c.hash); } },
+ { label: 'Copy Subject', action: function () { navigator.clipboard.writeText(c.message || ''); } },
+ ]);
+ };
+
+ window.__GG.showStashContextMenu = function (x, y, c) {
+ var selector = c.stashSelector || (c.stash && c.stash.selector);
+ showContextMenu(x, y, [
+ { label: 'Apply Stash\u2026', action: function () { window.__GG.stashApply(selector); } },
+ { label: 'Pop Stash\u2026', action: function () { window.__GG.stashPop(selector); } },
+ { label: 'Drop Stash\u2026', action: function () { window.__GG.stashDrop(selector); } },
+ { label: 'Create Branch from Stash\u2026', action: function () { window.__GG.openStashBranchDialog(selector); } },
+ null,
+ { label: 'Copy Stash Name', action: function () { navigator.clipboard.writeText(selector || ''); } },
+ { label: 'Copy Hash', action: function () { navigator.clipboard.writeText(c.hash); } },
+ ]);
+ };
+
+ window.__GG.showUncommittedContextMenu = function (x, y) {
+ showContextMenu(x, y, [
+ { label: 'Stash uncommitted changes\u2026', action: function () { window.__GG.openStashPushDialog(); } },
+ null,
+ { label: 'Reset uncommitted changes\u2026', action: function () { window.__GG.openResetUncommittedDialog(); } },
+ { label: 'Clean untracked files\u2026', action: function () { window.__GG.cleanUntracked(); } },
+ ]);
+ };
+
+ window.__GG.showBranchContextMenu = function (x, y, branchName, isRemote, remoteName) {
+ isRemote = !!isRemote;
+ remoteName = remoteName || null;
+ const items = [];
+ if (isRemote) {
+ items.push(
+ { label: 'Checkout\u2026', action: function () { window.__GG.openCheckoutRemoteBranchDialog(remoteName, branchName); } },
+ { label: 'Fetch into local branch\u2026', action: function () { window.__GG.openFetchIntoLocalDialog(remoteName, branchName); } },
+ { label: 'Delete Remote Branch\u2026', action: function () { window.__GG.deleteRemoteBranch(remoteName, branchName); } }
+ );
+ } else {
+ items.push(
+ { label: 'Checkout', action: function () { window.__GG.checkoutBranch(branchName); } },
+ { label: 'Rename\u2026', action: function () { window.__GG.openRenameBranchDialog(branchName); } },
+ { label: 'Delete\u2026', action: function () { window.__GG.openDeleteBranchDialog(branchName); } },
+ null,
+ { label: 'Merge into current branch\u2026', action: function () { window.__GG.openMergeDialog(branchName); } },
+ { label: 'Rebase onto\u2026', action: function () { window.__GG.openRebaseDialog(branchName); } },
+ { label: 'Push\u2026', action: function () { window.__GG.openPushDialog(branchName); } }
+ );
+ }
+ items.push(null, { label: 'Copy Branch Name', action: function () { navigator.clipboard.writeText(branchName); } });
+ showContextMenu(x, y, items);
+ };
+
+ window.__GG.checkoutBranch = async function (name) {
+ setLoading(true);
+ try {
+ await call('git.checkout', { ref: name });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.checkoutCommit = async function (hash) {
+ setLoading(true);
+ try {
+ await call('git.checkout', { ref: hash });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.openCreateBranchDialog = function (startHash) {
+ showModal('Create Branch',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Create',
+ primary: true,
+ action: async function () {
+ const name = ($('modal-branch-name').value || '').trim();
+ if (!name) return;
+ const checkout = $('modal-branch-checkout').checked;
+ await call('git.createBranch', { name: name, startPoint: startHash, checkout: checkout });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openAddTagDialog = function (ref) {
+ showModal('Add Tag',
+ '' +
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Add',
+ primary: true,
+ action: async function () {
+ const name = ($('modal-tag-name').value || '').trim();
+ if (!name) return;
+ const annotated = $('modal-tag-annotated').checked;
+ const message = ($('modal-tag-message').value || '').trim();
+ await call('git.addTag', { name: name, ref: ref, annotated: annotated, message: annotated ? message : null });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ $('modal-tag-annotated').addEventListener('change', function () {
+ show($('modal-tag-message-wrap'), $('modal-tag-annotated').checked);
+ });
+ };
+
+ window.__GG.openMergeDialog = function (ref) {
+ showModal('Merge',
+ 'Merge ' + escapeHtml(ref) + ' into current branch?
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Merge',
+ primary: true,
+ action: async function () {
+ const noFF = $('modal-merge-no-ff').checked;
+ await call('git.merge', { ref: ref, noFF: noFF });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openRebaseDialog = function (ref) {
+ showModal('Rebase', 'Rebase current branch onto ' + escapeHtml(ref) + '?', [
+ { label: 'Cancel' },
+ {
+ label: 'Rebase',
+ primary: true,
+ action: async function () {
+ await call('git.rebase', { onto: ref });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.openResetDialog = function (hash) {
+ showModal('Reset',
+ 'Reset current branch to ' + escapeHtml(hash.slice(0, 7)) + '?
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Reset',
+ primary: true,
+ action: async function () {
+ const mode = $('modal-reset-mode').value;
+ await call('git.reset', { hash: hash, mode: mode });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openResetUncommittedDialog = function () {
+ showModal('Reset Uncommitted',
+ 'Reset all uncommitted changes?
',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Reset',
+ primary: true,
+ action: async function () {
+ const mode = $('modal-reset-uc-mode').value;
+ await call('git.resetUncommitted', { mode: mode });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.cherryPick = async function (hash) {
+ setLoading(true);
+ try {
+ await call('git.cherryPick', { hash: hash });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.revertCommit = async function (hash) {
+ setLoading(true);
+ try {
+ await call('git.revert', { hash: hash });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.dropCommit = function (hash) {
+ showModal('Drop Commit', 'Remove commit ' + hash.slice(0, 7) + ' from history?', [
+ { label: 'Cancel' },
+ {
+ label: 'Drop',
+ primary: true,
+ action: async function () {
+ await call('git.dropCommit', { hash: hash });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.openRenameBranchDialog = function (oldName) {
+ showModal('Rename Branch',
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Rename',
+ primary: true,
+ action: async function () {
+ const newName = ($('modal-rename-branch').value || '').trim();
+ if (!newName) return;
+ await call('git.renameBranch', { oldName: oldName, newName: newName });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openDeleteBranchDialog = function (name) {
+ showModal('Delete Branch',
+ 'Delete branch ' + escapeHtml(name) + '?
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Delete',
+ primary: true,
+ action: async function () {
+ const force = $('modal-delete-force').checked;
+ await call('git.deleteBranch', { name: name, force: force });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openCheckoutRemoteBranchDialog = function (remoteName, branchName) {
+ showModal('Checkout Remote Branch',
+ 'Create local branch from ' + escapeHtml(remoteName + '/' + branchName) + '
' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Checkout',
+ primary: true,
+ action: async function () {
+ const localName = ($('modal-local-branch-name').value || '').trim() || branchName;
+ await call('git.checkout', { ref: remoteName + '/' + branchName, createBranch: localName });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openFetchIntoLocalDialog = function (remoteName, remoteBranch) {
+ showModal('Fetch into Local Branch',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Fetch',
+ primary: true,
+ action: async function () {
+ const localBranch = ($('modal-fetch-local-name').value || '').trim() || remoteBranch;
+ const force = $('modal-fetch-force').checked;
+ await call('git.fetchIntoLocalBranch', { remote: remoteName, remoteBranch: remoteBranch, localBranch: localBranch, force: force });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.deleteRemoteBranch = async function (remoteName, branchName) {
+ setLoading(true);
+ try {
+ await call('git.push', { remote: remoteName, branch: ':' + branchName });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.openPushDialog = function (branchName) {
+ const remotes = state.remotes.map(function (r) { return r.name; });
+ if (!remotes.length) {
+ showModal('Push', 'No remotes configured. Add one in Remote panel.
', [{ label: 'OK' }]);
+ return;
+ }
+ showModal('Push Branch',
+ '' +
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Push',
+ primary: true,
+ action: async function () {
+ const remote = $('modal-push-remote').value;
+ const setUpstream = $('modal-push-set-upstream').checked;
+ const force = $('modal-push-force').checked;
+ await call('git.push', { remote: remote, branch: branchName, setUpstream: setUpstream, force: force });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.openStashPushDialog = function () {
+ showModal('Stash',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Stash',
+ primary: true,
+ action: async function () {
+ const message = ($('modal-stash-msg').value || '').trim() || null;
+ const includeUntracked = $('modal-stash-untracked').checked;
+ await call('git.stashPush', { message: message, includeUntracked: includeUntracked });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.stashApply = async function (selector) {
+ setLoading(true);
+ try {
+ await call('git.stashApply', { selector: selector });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.stashPop = async function (selector) {
+ setLoading(true);
+ try {
+ await call('git.stashPop', { selector: selector });
+ await window.__GG.loadRepo();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ window.__GG.stashDrop = function (selector) {
+ showModal('Drop Stash', 'Drop ' + selector + '?', [
+ { label: 'Cancel' },
+ {
+ label: 'Drop',
+ primary: true,
+ action: async function () {
+ await call('git.stashDrop', { selector: selector });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.openStashBranchDialog = function (selector) {
+ showModal('Create Branch from Stash',
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Create',
+ primary: true,
+ action: async function () {
+ const branchName = ($('modal-stash-branch-name').value || '').trim();
+ if (!branchName) return;
+ await call('git.stashBranch', { branchName: branchName, selector: selector });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]
+ );
+ };
+
+ window.__GG.cleanUntracked = function () {
+ showModal('Clean Untracked', 'Remove all untracked files?', [
+ { label: 'Cancel' },
+ {
+ label: 'Clean',
+ primary: true,
+ action: async function () {
+ await call('git.cleanUntracked', { force: true, directories: true });
+ hideModal();
+ await window.__GG.loadRepo();
+ },
+ },
+ ]);
+ };
+
+ window.__GG.loadRepo = async function () {
+ if (!state.cwd) return;
+ setLoading(true);
+ $('repo-path').textContent = state.cwd;
+ $('repo-path').title = state.cwd;
+
+ try {
+ const branchesParam = state.selectedBranchFilter.length > 0 ? state.selectedBranchFilter : [];
+ const graphData = await call('git.graphData', {
+ maxCount: MAX_COMMITS,
+ order: state.order,
+ firstParent: state.firstParent,
+ branches: branchesParam,
+ showRemoteBranches: true,
+ showStashes: true,
+ showUncommittedChanges: true,
+ hideRemotes: [],
+ });
+
+ state.commits = graphData.commits || [];
+ state.refs = graphData.refs || { head: null, heads: [], tags: [], remotes: [] };
+ state.stash = graphData.stashes || [];
+ state.uncommitted = graphData.uncommitted || null;
+ state.status = graphData.status || null;
+ state.remotes = graphData.remotes || [];
+ state.head = graphData.head || null;
+ state.hasMore = !!graphData.moreCommitsAvailable;
+
+ var currentBranch = null;
+ if (state.refs.head && state.refs.heads && state.refs.heads.length) {
+ var headEntry = state.refs.heads.find(function (h) { return h.hash === state.refs.head; });
+ if (headEntry) currentBranch = headEntry.name;
+ }
+ state.branches = {
+ current: currentBranch,
+ all: (state.refs.heads || []).map(function (h) { return h.name; }),
+ };
+
+ if (state.branches.current) {
+ $('branch-name').textContent = state.branches.current;
+ show($('branch-badge'), true);
+ }
+
+ const badge = $('status-badge');
+ if (state.status) {
+ const m = (state.status.modified && state.status.modified.length) || 0;
+ const s = (state.status.staged && state.status.staged.length) || 0;
+ const u = (state.status.not_added && state.status.not_added.length) || 0;
+ const total = m + s + u;
+ if (total > 0) {
+ const p = [];
+ if (m) p.push(m + ' modified');
+ if (s) p.push(s + ' staged');
+ if (u) p.push(u + ' untracked');
+ badge.textContent = p.join(' \u00b7 ');
+ badge.classList.add('has-changes');
+ } else {
+ badge.textContent = '\u2713 clean';
+ badge.classList.remove('has-changes');
+ }
+ show(badge, true);
+ }
+
+ show($('empty-state'), false);
+ show($('graph-area'), true);
+ window.__GG.renderCommitList();
+ window.__GG.updateFindMatches();
+ if ($('find-widget').style.display !== 'none') {
+ $('find-result').textContent = state.findMatches.length > 0 ? (state.findIndex + 1) + ' / ' + state.findMatches.length : '0';
+ }
+ } catch (e) {
+ console.error('load failed', e);
+ show($('empty-state'), true);
+ show($('graph-area'), false);
+ var desc = $('empty-state') && $('empty-state').querySelector('.empty-state__desc');
+ if (desc) desc.textContent = 'Load failed: ' + (e && e.message ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/panels/detailPanel.js b/MiniApp/Demo/git-graph/source/ui/panels/detailPanel.js
new file mode 100644
index 00000000..e2bf31f8
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/panels/detailPanel.js
@@ -0,0 +1,259 @@
+/**
+ * Git Graph MiniApp — detail panel (commit / compare).
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const call = window.__GG.call;
+ const parseRefs = window.__GG.parseRefs;
+ const escapeHtml = window.__GG.escapeHtml;
+
+ window.__GG.showDetailPanel = function () {
+ show($('detail-resizer'), true);
+ show($('detail-panel'), true);
+ };
+
+ window.__GG.openComparePanel = function (hash1, hash2) {
+ state.selectedHash = null;
+ state.compareHashes = [hash1, hash2];
+ window.__GG.showDetailPanel();
+ $('detail-panel-title').textContent = 'Compare';
+ var summary = $('detail-summary');
+ var filesSection = $('detail-files-section');
+ var codePreview = $('detail-code-preview');
+ if (summary) summary.innerHTML = 'Loading\u2026
';
+ if (filesSection) show(filesSection, false);
+ if (codePreview) show(codePreview, false);
+ (async function () {
+ try {
+ const res = await call('git.compareCommits', { hash1: hash1, hash2: hash2 });
+ if (summary) {
+ summary.innerHTML = '' + escapeHtml(hash1.slice(0, 7)) + ' \u2026 ' + escapeHtml(hash2.slice(0, 7)) + '
';
+ }
+ var list = $('detail-files-list');
+ if (list) {
+ list.innerHTML = '';
+ (res.files || []).forEach(function (f) {
+ var li = document.createElement('li');
+ li.className = 'detail-file';
+ li.innerHTML = '' + escapeHtml(f.file) + '' + escapeHtml(f.status) + '';
+ list.appendChild(li);
+ });
+ }
+ if (filesSection) show(filesSection, (res.files && res.files.length) ? true : false);
+ if ($('detail-files-label')) $('detail-files-label').textContent = 'Changed Files (' + (res.files ? res.files.length : 0) + ')';
+ } catch (e) {
+ if (summary) summary.innerHTML = '' + escapeHtml(e && e.message ? e.message : String(e)) + '
';
+ }
+ })();
+ };
+
+ window.__GG.selectCommit = async function (hash) {
+ state.selectedHash = hash;
+ state.compareHashes = [];
+ window.__GG.renderCommitList();
+
+ var summary = $('detail-summary');
+ var filesSection = $('detail-files-section');
+ var codePreview = $('detail-code-preview');
+ window.__GG.showDetailPanel();
+ $('detail-panel-title').textContent = hash === '__uncommitted__' ? 'Uncommitted changes' : 'Commit';
+ if (summary) summary.innerHTML = 'Loading\u2026
';
+ if (filesSection) show(filesSection, false);
+ if (codePreview) show(codePreview, false);
+
+ if (hash === '__uncommitted__') {
+ var uncommitted = state.uncommitted;
+ if (!uncommitted) {
+ if (summary) summary.innerHTML = 'No uncommitted changes
';
+ return;
+ }
+ var summaryHtml = 'WIP
Uncommitted changes
';
+ if (summary) summary.innerHTML = summaryHtml;
+ var list = $('detail-files-list');
+ if (list) list.innerHTML = '';
+ var files = (uncommitted.files || []);
+ if (files.length && list) {
+ if ($('detail-files-label')) $('detail-files-label').textContent = 'Changed Files (' + files.length + ')';
+ show(filesSection, true);
+ files.forEach(function (f) {
+ var li = document.createElement('li');
+ li.className = 'detail-file';
+ li.dataset.file = f.path || f.file || '';
+ var name = document.createElement('span');
+ name.className = 'detail-file__name';
+ name.textContent = f.path || f.file || '';
+ var stat = document.createElement('span');
+ stat.className = 'detail-file__stat';
+ stat.textContent = f.status || '';
+ li.appendChild(name);
+ li.appendChild(stat);
+ list.appendChild(li);
+ });
+ }
+ return;
+ }
+
+ var displayCommit = (state.commits || []).find(function (c) { return c.hash === hash; });
+ var isStashRow = displayCommit && (displayCommit.stash && displayCommit.stash.selector);
+
+ try {
+ const res = await call('git.show', { hash: hash });
+ if (!res || !res.commit) {
+ if (summary) summary.innerHTML = 'Commit not found
';
+ return;
+ }
+ const c = res.commit;
+
+ var summaryHtml = '';
+ summaryHtml += '' + escapeHtml(c.hash) + '
';
+ if (isStashRow && displayCommit.stash) {
+ summaryHtml += 'Stash: ' + escapeHtml(displayCommit.stash.selector || '') + '
Base: ' + escapeHtml((displayCommit.stash.baseHash || '').slice(0, 7)) + (displayCommit.stash.untrackedFilesHash ? ' · Untracked: ' + escapeHtml(displayCommit.stash.untrackedFilesHash.slice(0, 7)) : '') + '
';
+ }
+ var msgFirst = (c.message || '').split('\n')[0];
+ if (c.body && c.body.trim()) msgFirst += '\n\n' + c.body.trim();
+ summaryHtml += '' + escapeHtml(msgFirst) + '
';
+ summaryHtml += '' + escapeHtml(c.author || '') + ' <' + escapeHtml(c.email || '') + '>
' + escapeHtml(String(c.date || '')) + '
';
+ if (c.refs) {
+ summaryHtml += '';
+ parseRefs(c.refs).forEach(function (r) {
+ summaryHtml += '' + escapeHtml(r.label) + '';
+ });
+ summaryHtml += '
';
+ }
+ if ((c.heads || c.tags || c.remotes) && window.__GG.getRefsFromStructured) {
+ var refTags = window.__GG.getRefsFromStructured(c, state.branches && state.branches.current);
+ if (refTags.length) {
+ summaryHtml += '';
+ refTags.forEach(function (r) {
+ summaryHtml += '' + escapeHtml(r.label) + '';
+ });
+ summaryHtml += '
';
+ }
+ } else if (c.refs) {
+ summaryHtml += '';
+ parseRefs(c.refs).forEach(function (r) {
+ summaryHtml += '' + escapeHtml(r.label) + '';
+ });
+ summaryHtml += '
';
+ }
+ if (summary) summary.innerHTML = summaryHtml;
+
+ var list = $('detail-files-list');
+ if (list) list.innerHTML = '';
+ if (res.files && res.files.length && list) {
+ $('detail-files-label').textContent = 'Changed Files (' + res.files.length + ')';
+ show(filesSection, true);
+ res.files.forEach(function (f) {
+ var li = document.createElement('li');
+ li.className = 'detail-file';
+ li.dataset.file = f.file || '';
+ var name = document.createElement('span');
+ name.className = 'detail-file__name';
+ name.textContent = f.file || '';
+ name.title = f.file || '';
+ var stat = document.createElement('span');
+ stat.className = 'detail-file__stat';
+ if (f.insertions) {
+ var s = document.createElement('span');
+ s.className = 'stat-add';
+ s.textContent = '+' + f.insertions;
+ stat.appendChild(s);
+ }
+ if (f.deletions) {
+ var s2 = document.createElement('span');
+ s2.className = 'stat-del';
+ s2.textContent = '-' + f.deletions;
+ stat.appendChild(s2);
+ }
+ li.appendChild(name);
+ li.appendChild(stat);
+ li.addEventListener('click', function () {
+ var prev = list.querySelector('.detail-file--selected');
+ if (prev) prev.classList.remove('detail-file--selected');
+ if (prev === li) {
+ show(codePreview, false);
+ return;
+ }
+ li.classList.add('detail-file--selected');
+ var headerName = $('detail-code-preview-filename');
+ var headerStats = $('detail-code-preview-stats');
+ var content = $('detail-code-preview-content');
+ if (headerName) headerName.textContent = f.file || '';
+ if (headerName) headerName.title = f.file || '';
+ if (headerStats) headerStats.textContent = (f.insertions ? '+' + f.insertions : '') + ' ' + (f.deletions ? '-' + f.deletions : '');
+ if (content) {
+ content.innerHTML = 'Loading\u2026
';
+ }
+ show(codePreview, true);
+ (async function () {
+ try {
+ var diffRes = await call('git.fileDiff', { from: hash + '^', to: hash, file: f.file });
+ var lines = (diffRes.diff || '').split('\n');
+ var html = lines.map(function (line) {
+ var cls = (line.indexOf('+') === 0 && line.indexOf('+++') !== 0) ? 'diff-add'
+ : (line.indexOf('-') === 0 && line.indexOf('---') !== 0) ? 'diff-del'
+ : line.indexOf('@@') === 0 ? 'diff-hunk' : '';
+ return '' + escapeHtml(line) + '';
+ }).join('\n');
+ if (content) content.innerHTML = '' + html + '
';
+ } catch (err) {
+ if (content) content.innerHTML = '' + escapeHtml(err && err.message ? err.message : 'Failed to load diff') + '
';
+ }
+ })();
+ });
+ list.appendChild(li);
+ });
+ } else {
+ if ($('detail-files-label')) $('detail-files-label').textContent = 'Changed Files (0)';
+ }
+ } catch (e) {
+ if (summary) summary.innerHTML = '' + escapeHtml(e && e.message ? e.message : e) + '
';
+ }
+ };
+
+ window.__GG.closeDetail = function () {
+ state.selectedHash = null;
+ state.compareHashes = [];
+ show($('detail-resizer'), false);
+ show($('detail-panel'), false);
+ window.__GG.renderCommitList();
+ };
+
+ window.__GG.initDetailResizer = function () {
+ const resizer = $('detail-resizer');
+ const panel = $('detail-panel');
+ if (!resizer || !panel) return;
+ var startX = 0;
+ var startW = 0;
+ var MIN_PANEL = 420;
+ var MAX_PANEL = 720;
+ resizer.addEventListener('mousedown', function (e) {
+ e.preventDefault();
+ startX = e.clientX;
+ startW = panel.offsetWidth || Math.min(MAX_PANEL, Math.max(MIN_PANEL, Math.round(window.innerWidth * 0.36)));
+ resizer.classList.add('dragging');
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ function onMove(ev) {
+ var delta = startX - ev.clientX;
+ var mainW = (panel.parentElement && panel.parentElement.offsetWidth) || window.innerWidth;
+ mainW -= 6;
+ var maxPanelW = mainW - 80;
+ var newW = Math.min(Math.max(MIN_PANEL, startW + delta), Math.min(MAX_PANEL, maxPanelW));
+ panel.style.flexBasis = newW + 'px';
+ }
+ function onUp() {
+ resizer.classList.remove('dragging');
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ }
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/panels/remotePanel.js b/MiniApp/Demo/git-graph/source/ui/panels/remotePanel.js
new file mode 100644
index 00000000..15ad09a8
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/panels/remotePanel.js
@@ -0,0 +1,93 @@
+/**
+ * Git Graph MiniApp — remote panel.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const state = window.__GG.state;
+ const $ = window.__GG.$;
+ const show = window.__GG.show;
+ const call = window.__GG.call;
+ const showModal = window.__GG.showModal;
+ const hideModal = window.__GG.hideModal;
+ const escapeHtml = window.__GG.escapeHtml;
+ const setLoading = window.__GG.setLoading;
+
+ window.__GG.showRemotePanel = function () {
+ show($('remote-panel'), true);
+ window.__GG.renderRemoteList();
+ };
+
+ window.__GG.renderRemoteList = function () {
+ const list = $('remote-list');
+ list.innerHTML = '';
+ (state.remotes || []).forEach(function (r) {
+ const div = document.createElement('div');
+ div.className = 'remote-item';
+ div.innerHTML =
+ '' + escapeHtml(r.name) + '
' +
+ '
' +
+ escapeHtml((r.fetch || '').slice(0, 50)) + ((r.fetch || '').length > 50 ? '\u2026' : '') + '
' +
+ '' +
+ '' +
+ '
';
+ div.querySelector('[data-action="fetch"]').addEventListener('click', async function () {
+ setLoading(true);
+ try {
+ await call('git.fetch', { remote: r.name, prune: true });
+ await window.__GG.loadRepo();
+ state.remotes = (await call('git.remotes')).remotes || [];
+ window.__GG.renderRemoteList();
+ } finally {
+ setLoading(false);
+ }
+ });
+ div.querySelector('[data-action="remove"]').addEventListener('click', function () {
+ showModal('Delete Remote', 'Delete remote ' + escapeHtml(r.name) + '?', [
+ { label: 'Cancel' },
+ {
+ label: 'Delete',
+ primary: true,
+ action: async function () {
+ await call('git.removeRemote', { name: r.name });
+ hideModal();
+ await window.__GG.loadRepo();
+ state.remotes = (await call('git.remotes')).remotes || [];
+ window.__GG.renderRemoteList();
+ },
+ },
+ ]);
+ });
+ list.appendChild(div);
+ });
+ const addBtn = document.createElement('button');
+ addBtn.type = 'button';
+ addBtn.className = 'btn btn--secondary';
+ addBtn.textContent = 'Add Remote';
+ addBtn.style.marginTop = '8px';
+ addBtn.addEventListener('click', function () {
+ showModal(
+ 'Add Remote',
+ '' +
+ '',
+ [
+ { label: 'Cancel' },
+ {
+ label: 'Add',
+ primary: true,
+ action: async function () {
+ const name = ($('modal-remote-name').value || '').trim() || 'origin';
+ const url = ($('modal-remote-url').value || '').trim();
+ if (!url) return;
+ await call('git.addRemote', { name: name, url: url });
+ hideModal();
+ await window.__GG.loadRepo();
+ state.remotes = (await call('git.remotes')).remotes || [];
+ window.__GG.renderRemoteList();
+ },
+ },
+ ]
+ );
+ });
+ list.appendChild(addBtn);
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/services/gitClient.js b/MiniApp/Demo/git-graph/source/ui/services/gitClient.js
new file mode 100644
index 00000000..81d1806b
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/services/gitClient.js
@@ -0,0 +1,12 @@
+/**
+ * Git Graph MiniApp — worker call wrapper.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+
+ window.__GG.call = function (method, params) {
+ const state = window.__GG.state;
+ const p = Object.assign({ cwd: state.cwd }, params || {});
+ return window.app.call(method, p);
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/ui/state.js b/MiniApp/Demo/git-graph/source/ui/state.js
new file mode 100644
index 00000000..ba0ec7a5
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/state.js
@@ -0,0 +1,113 @@
+/**
+ * Git Graph MiniApp — shared state, constants, DOM helpers.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+
+ window.__GG.STORAGE_KEY = 'lastRepo';
+ window.__GG.MAX_COMMITS = 300;
+ window.__GG.ROW_H = 28;
+ window.__GG.LANE_W = 18;
+ window.__GG.NODE_R = 4;
+
+ window.__GG.$ = function (id) {
+ return document.getElementById(id);
+ };
+
+ window.__GG.state = {
+ cwd: null,
+ commits: [],
+ stash: [],
+ branches: null,
+ refs: null,
+ head: null,
+ uncommitted: null,
+ status: null,
+ remotes: [],
+ selectedHash: null,
+ selectedBranchFilter: [],
+ firstParent: false,
+ order: 'date',
+ compareHashes: [],
+ findQuery: '',
+ findIndex: 0,
+ findMatches: [],
+ offset: 0,
+ hasMore: true,
+ };
+
+ window.__GG.show = function (el, v) {
+ if (el) el.style.display = v ? '' : 'none';
+ };
+
+ window.__GG.formatDate = function (dateStr) {
+ if (!dateStr) return '';
+ try {
+ const d = new Date(dateStr);
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const hh = String(d.getHours()).padStart(2, '0');
+ const mi = String(d.getMinutes()).padStart(2, '0');
+ return `${mm}-${dd} ${hh}:${mi}`;
+ } catch {
+ return String(dateStr).slice(0, 10);
+ }
+ };
+
+ window.__GG.parseRefs = function (refStr) {
+ if (!refStr) return [];
+ return refStr
+ .split(',')
+ .map(function (r) { return r.trim(); })
+ .filter(Boolean)
+ .map(function (r) {
+ if (r.startsWith('HEAD -> ')) return { type: 'head', label: r.replace('HEAD -> ', '') };
+ if (r.startsWith('tag: ')) return { type: 'tag', label: r.replace('tag: ', '') };
+ if (r.includes('/')) return { type: 'remote', label: r };
+ return { type: 'branch', label: r };
+ });
+ };
+
+ window.__GG.setLoading = function (v) {
+ window.__GG.show(window.__GG.$('loading-overlay'), v);
+ };
+
+ window.__GG.escapeHtml = function (s) {
+ if (s == null) return '';
+ const div = document.createElement('div');
+ div.textContent = s;
+ return div.innerHTML;
+ };
+
+ /**
+ * Returns display list from state.commits (already built by git.graphData:
+ * uncommitted + commits with stash rows in correct order). No client-side
+ * stash-by-date or status-based uncommitted fabrication.
+ */
+ window.__GG.getDisplayCommits = function () {
+ return (window.__GG.state.commits || []).slice();
+ };
+
+ /**
+ * Build ref tag list for a commit from structured refs (heads/tags/remotes).
+ * currentBranch: name of current branch for HEAD -> label.
+ */
+ window.__GG.getRefsFromStructured = function (commit, currentBranch) {
+ if (!commit) return [];
+ const out = [];
+ const heads = commit.heads || [];
+ const tags = commit.tags || [];
+ const remotes = commit.remotes || [];
+ heads.forEach(function (name) {
+ out.push({ type: name === currentBranch ? 'head' : 'branch', label: name === currentBranch ? 'HEAD -> ' + name : name });
+ });
+ tags.forEach(function (t) {
+ out.push({ type: 'tag', label: typeof t === 'string' ? t : (t.name || '') });
+ });
+ remotes.forEach(function (r) {
+ out.push({ type: 'remote', label: typeof r === 'string' ? r : (r.name || '') });
+ });
+ return out;
+ };
+})();
+
diff --git a/MiniApp/Demo/git-graph/source/ui/theme.js b/MiniApp/Demo/git-graph/source/ui/theme.js
new file mode 100644
index 00000000..a1e3c479
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/ui/theme.js
@@ -0,0 +1,31 @@
+/**
+ * Git Graph MiniApp — theme adapter: read --branch-* and node stroke from CSS for graph colors.
+ */
+(function () {
+ window.__GG = window.__GG || {};
+ const root = document.documentElement;
+
+ function getComputed(name) {
+ return getComputedStyle(root).getPropertyValue(name).trim() || null;
+ }
+
+ /** Returns array of 7 branch/lane colors from CSS variables (theme-aware). */
+ window.__GG.getGraphColors = function () {
+ const colors = [];
+ for (let i = 1; i <= 7; i++) {
+ const v = getComputed('--branch-' + i);
+ colors.push(v || '#58a6ff');
+ }
+ return colors;
+ };
+
+ /** Node stroke color (contrast with background). */
+ window.__GG.getNodeStroke = function () {
+ return getComputed('--graph-node-stroke') || getComputed('--bitfun-bg') || getComputed('--bg') || '#0d1117';
+ };
+
+ /** Uncommitted / WIP line and node color. */
+ window.__GG.getUncommittedColor = function () {
+ return getComputed('--graph-uncommitted') || getComputed('--text-dim') || '#808080';
+ };
+})();
diff --git a/MiniApp/Demo/git-graph/source/worker.js b/MiniApp/Demo/git-graph/source/worker.js
new file mode 100644
index 00000000..d13c4e1f
--- /dev/null
+++ b/MiniApp/Demo/git-graph/source/worker.js
@@ -0,0 +1,686 @@
+// Git Graph MiniApp — Worker (Node.js/Bun). Uses simple-git npm package.
+// Methods are invoked via app.call('git.log', params) etc. from the UI.
+
+const simpleGit = require('simple-git');
+const EOL_REGEX = /\r\n|\r|\n/g;
+const GIT_LOG_SEP = 'XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb';
+
+function getGit(cwd) {
+ if (!cwd || typeof cwd !== 'string') {
+ throw new Error('git: cwd (repository path) is required');
+ }
+ return simpleGit({ baseDir: cwd });
+}
+
+function normalizeLogCommit(c) {
+ const parents = Array.isArray(c.parents)
+ ? c.parents
+ : c.parent
+ ? [c.parent]
+ : [];
+ return {
+ hash: c.hash,
+ shortHash: c.hash ? c.hash.slice(0, 7) : '',
+ message: c.message,
+ author: c.author_name,
+ email: c.author_email,
+ date: c.date,
+ refs: c.refs || '',
+ parentHashes: parents,
+ };
+}
+
+/** Parse git show-ref -d --head output into head, heads, tags, remotes. */
+async function getRefsFromShowRef(cwd, showRemoteBranches, hideRemotes = []) {
+ const git = getGit(cwd);
+ const args = ['show-ref'];
+ if (!showRemoteBranches) args.push('--heads', '--tags');
+ args.push('-d', '--head');
+ const stdout = await git.raw(args).catch(() => '');
+ const refData = { head: null, heads: [], tags: [], remotes: [] };
+ const hidePatterns = hideRemotes.map((r) => 'refs/remotes/' + r + '/');
+ const lines = stdout.trim().split(EOL_REGEX).filter(Boolean);
+ for (const line of lines) {
+ const parts = line.split(' ');
+ if (parts.length < 2) continue;
+ const hash = parts.shift();
+ const ref = parts.join(' ');
+ if (ref.startsWith('refs/heads/')) {
+ refData.heads.push({ hash, name: ref.substring(11) });
+ } else if (ref.startsWith('refs/tags/')) {
+ const annotated = ref.endsWith('^{}');
+ refData.tags.push({
+ hash,
+ name: annotated ? ref.substring(10, ref.length - 3) : ref.substring(10),
+ annotated,
+ });
+ } else if (ref.startsWith('refs/remotes/')) {
+ if (!hidePatterns.some((p) => ref.startsWith(p)) && !ref.endsWith('/HEAD')) {
+ refData.remotes.push({ hash, name: ref.substring(13) });
+ }
+ } else if (ref === 'HEAD') {
+ refData.head = hash;
+ }
+ }
+ return refData;
+}
+
+/** Parse git reflog refs/stash --format=... into stashes with baseHash, untrackedFilesHash, selector. */
+async function getStashesFromReflog(cwd) {
+ const git = getGit(cwd);
+ const format = ['%H', '%P', '%gD', '%an', '%ae', '%at', '%s'].join(GIT_LOG_SEP);
+ const stdout = await git.raw(['reflog', '--format=' + format, 'refs/stash', '--']).catch(() => '');
+ const stashes = [];
+ const lines = stdout.trim().split(EOL_REGEX).filter(Boolean);
+ for (const line of lines) {
+ const parts = line.split(GIT_LOG_SEP);
+ if (parts.length < 7 || !parts[1]) continue;
+ const parentHashes = parts[1].trim().split(/\s+/);
+ stashes.push({
+ hash: parts[0],
+ baseHash: parentHashes[0],
+ untrackedFilesHash: parentHashes.length >= 3 ? parentHashes[2] : null,
+ selector: parts[2] || 'stash@{0}',
+ author: parts[3] || '',
+ email: parts[4] || '',
+ date: parseInt(parts[5], 10) || 0,
+ message: parts[6] || '',
+ });
+ }
+ return stashes;
+}
+
+/** Build uncommitted node from status + diff --name-status + diff --numstat (HEAD to working tree). */
+async function getUncommittedNode(cwd, headHash) {
+ const git = getGit(cwd);
+ const [statusOut, nameStatusOut, numStatOut] = await Promise.all([
+ git.raw(['status', '-s', '--porcelain', '-z', '--untracked-files=all']).catch(() => ''),
+ git.raw(['diff', '--name-status', '--find-renames', '-z', 'HEAD']).catch(() => ''),
+ git.raw(['diff', '--numstat', '--find-renames', '-z', 'HEAD']).catch(() => ''),
+ ]);
+ const statusLines = statusOut.split('\0').filter((s) => s.length >= 4);
+ if (statusLines.length === 0) return null;
+ const nameStatusParts = nameStatusOut.split('\0').filter(Boolean);
+ const numStatLines = numStatOut.trim().split('\n').filter(Boolean);
+ const files = [];
+ const numStatByPath = {};
+ for (const nl of numStatLines) {
+ const m = nl.match(/^(\d+|-)\s+(\d+|-)\s+(.+)$/);
+ if (m) numStatByPath[m[3].replace(/\t.*$/, '')] = { additions: m[1] === '-' ? 0 : parseInt(m[1], 10), deletions: m[2] === '-' ? 0 : parseInt(m[2], 10) };
+ }
+ let i = 0;
+ while (i < nameStatusParts.length) {
+ const type = nameStatusParts[i][0];
+ if (type === 'A' || type === 'M' || type === 'D') {
+ const path = nameStatusParts[i + 1] || nameStatusParts[i].slice(2);
+ const stat = numStatByPath[path] || { additions: 0, deletions: 0 };
+ files.push({ oldFilePath: path, newFilePath: path, type, additions: stat.additions, deletions: stat.deletions });
+ i += 2;
+ } else if (type === 'R') {
+ const oldPath = nameStatusParts[i + 1];
+ const newPath = nameStatusParts[i + 2];
+ const stat = numStatByPath[newPath] || { additions: 0, deletions: 0 };
+ files.push({ oldFilePath: oldPath, newFilePath: newPath, type: 'R', additions: stat.additions, deletions: stat.deletions });
+ i += 3;
+ } else {
+ i += 1;
+ }
+ }
+ return {
+ hash: '__uncommitted__',
+ shortHash: 'WIP',
+ message: 'Uncommitted Changes (' + statusLines.length + ')',
+ author: '',
+ email: '',
+ date: Math.round(Date.now() / 1000),
+ parentHashes: headHash ? [headHash] : [],
+ heads: [],
+ tags: [],
+ remotes: [],
+ stash: null,
+ isUncommitted: true,
+ changeCount: statusLines.length,
+ files,
+ };
+}
+
+module.exports = {
+ // ─── Log & show ───────────────────────────────────────────────────────────
+ async 'git.log'({ cwd, maxCount = 100, order = 'date', firstParent = false, branches = [] }) {
+ const git = getGit(cwd);
+ const n = Math.min(Math.max(1, Number(maxCount) || 100), 1000);
+ const args = ['-n', String(n)];
+ if (order === 'topo') args.push('--topo-order');
+ else if (order === 'author-date') args.push('--author-date-order');
+ else args.push('--date-order');
+ if (firstParent) args.push('--first-parent');
+ if (Array.isArray(branches) && branches.length > 0) {
+ args.push(...branches);
+ args.push('--');
+ }
+ const log = await git.log(args);
+ return {
+ all: (log.all || []).map(normalizeLogCommit),
+ latest: log.latest ? normalizeLogCommit(log.latest) : null,
+ };
+ },
+
+ /**
+ * Aggregated graph data: head, commits (with heads/tags/remotes/stash), refs, stashes, uncommitted, remotes, status.
+ * UI should consume this instead of assembling from git.log + git.branches + git.stashList + status.
+ */
+ async 'git.graphData'({
+ cwd,
+ maxCount = 300,
+ order = 'date',
+ firstParent = false,
+ branches = [],
+ showRemoteBranches = true,
+ showStashes = true,
+ showUncommittedChanges = true,
+ hideRemotes = [],
+ }) {
+ const git = getGit(cwd);
+ const n = Math.min(Math.max(1, Number(maxCount) || 300), 1000);
+ let refData = { head: null, heads: [], tags: [], remotes: [] };
+ let stashes = [];
+ try {
+ refData = await getRefsFromShowRef(cwd, showRemoteBranches, hideRemotes);
+ } catch (_) {}
+ if (showStashes) {
+ try {
+ stashes = await getStashesFromReflog(cwd);
+ } catch (_) {}
+ }
+ const logArgs = ['-n', String(n + 1)];
+ if (order === 'topo') logArgs.push('--topo-order');
+ else if (order === 'author-date') logArgs.push('--author-date-order');
+ else logArgs.push('--date-order');
+ if (firstParent) logArgs.push('--first-parent');
+ if (Array.isArray(branches) && branches.length > 0) {
+ logArgs.push(...branches);
+ logArgs.push('--');
+ } else {
+ logArgs.push('--branches', '--tags', 'HEAD');
+ stashes.forEach((s) => {
+ if (s.baseHash && !logArgs.includes(s.baseHash)) logArgs.push(s.baseHash);
+ });
+ }
+ const log = await git.log(logArgs);
+ let rawCommits = (log.all || []).map(normalizeLogCommit);
+ const moreCommitsAvailable = rawCommits.length > n;
+ if (moreCommitsAvailable) rawCommits = rawCommits.slice(0, n);
+ const commitLookup = {};
+ rawCommits.forEach((c, i) => { commitLookup[c.hash] = i; });
+ const commits = rawCommits.map((c) => ({
+ ...c,
+ heads: [],
+ tags: [],
+ remotes: [],
+ stash: null,
+ }));
+ stashes.forEach((s) => {
+ if (typeof commitLookup[s.hash] === 'number') {
+ commits[commitLookup[s.hash]].stash = {
+ selector: s.selector,
+ baseHash: s.baseHash,
+ untrackedFilesHash: s.untrackedFilesHash,
+ };
+ }
+ });
+ const toAdd = [];
+ stashes.forEach((s) => {
+ if (typeof commitLookup[s.hash] === 'number') return;
+ if (typeof commitLookup[s.baseHash] !== 'number') return;
+ toAdd.push({ index: commitLookup[s.baseHash], data: s });
+ });
+ toAdd.sort((a, b) => (a.index !== b.index ? a.index - b.index : b.data.date - a.data.date));
+ for (let i = toAdd.length - 1; i >= 0; i--) {
+ const s = toAdd[i].data;
+ commits.splice(toAdd[i].index, 0, {
+ hash: s.hash,
+ shortHash: s.hash ? s.hash.slice(0, 7) : '',
+ message: s.message,
+ author: s.author,
+ email: s.email,
+ date: s.date,
+ parentHashes: [s.baseHash],
+ heads: [],
+ tags: [],
+ remotes: [],
+ stash: { selector: s.selector, baseHash: s.baseHash, untrackedFilesHash: s.untrackedFilesHash },
+ });
+ }
+ for (let i = 0; i < commits.length; i++) commitLookup[commits[i].hash] = i;
+ refData.heads.forEach((h) => {
+ if (typeof commitLookup[h.hash] === 'number') commits[commitLookup[h.hash]].heads.push(h.name);
+ });
+ refData.tags.forEach((t) => {
+ if (typeof commitLookup[t.hash] === 'number') commits[commitLookup[t.hash]].tags.push({ name: t.name, annotated: t.annotated });
+ });
+ refData.remotes.forEach((r) => {
+ if (typeof commitLookup[r.hash] === 'number') {
+ const remote = r.name.indexOf('/') >= 0 ? r.name.split('/')[0] : null;
+ commits[commitLookup[r.hash]].remotes.push({ name: r.name, remote });
+ }
+ });
+ let uncommitted = null;
+ if (showUncommittedChanges && refData.head) {
+ const headInList = commits.some((c) => c.hash === refData.head);
+ if (headInList) {
+ try {
+ uncommitted = await getUncommittedNode(cwd, refData.head);
+ if (uncommitted) commits.unshift(uncommitted);
+ } catch (_) {}
+ }
+ }
+ let status = null;
+ try {
+ status = await git.status();
+ status = {
+ current: status.current,
+ tracking: status.tracking,
+ not_added: status.not_added || [],
+ staged: status.staged || [],
+ modified: status.modified || [],
+ created: status.created || [],
+ deleted: status.deleted || [],
+ renamed: status.renamed || [],
+ files: status.files || [],
+ };
+ } catch (_) {}
+ let remotes = [];
+ try {
+ const remotesMap = await git.getRemotes(true);
+ remotes = Object.entries(remotesMap || {}).map(([name, r]) => ({
+ name,
+ fetch: (r && r.fetch) || '',
+ push: (r && r.push) || '',
+ }));
+ } catch (_) {}
+ return {
+ head: refData.head,
+ commits,
+ refs: refData,
+ stashes,
+ uncommitted: uncommitted ? { changeCount: uncommitted.changeCount, files: uncommitted.files } : null,
+ remotes,
+ status,
+ moreCommitsAvailable,
+ };
+ },
+
+ async 'git.searchCommits'({ cwd, query, maxCount = 100 }) {
+ const git = getGit(cwd);
+ const n = Math.min(Math.max(1, Number(maxCount) || 100), 500);
+ const log = await git.log(['-n', String(n), '--grep', String(query), '--all']);
+ return { all: (log.all || []).map(normalizeLogCommit) };
+ },
+
+ async 'git.branches'({ cwd }) {
+ const git = getGit(cwd);
+ const branch = await git.branch();
+ return {
+ current: branch.current,
+ all: branch.all || [],
+ branches: branch.branches || {},
+ };
+ },
+
+ async 'git.status'({ cwd }) {
+ const git = getGit(cwd);
+ const status = await git.status();
+ return {
+ current: status.current,
+ tracking: status.tracking,
+ not_added: status.not_added || [],
+ staged: status.staged || [],
+ modified: status.modified || [],
+ created: status.created || [],
+ deleted: status.deleted || [],
+ renamed: status.renamed || [],
+ files: status.files || [],
+ };
+ },
+
+ async 'git.show'({ cwd, hash }) {
+ if (!hash) throw new Error('git.show: hash is required');
+ const git = getGit(cwd);
+ const log = await git.log([hash, '-n', '1']);
+ const commit = log.latest;
+ if (!commit) return { commit: null, files: [] };
+ let files = [];
+ try {
+ const summary = await git.diffSummary([hash + '^..' + hash]);
+ if (summary && summary.files) {
+ files = summary.files.map((f) => ({
+ file: f.file,
+ changes: f.changes || 0,
+ insertions: f.insertions || 0,
+ deletions: f.deletions || 0,
+ }));
+ }
+ } catch (_) {}
+ return {
+ commit: {
+ hash: commit.hash,
+ shortHash: commit.hash ? commit.hash.slice(0, 7) : '',
+ message: commit.message,
+ body: commit.body || '',
+ author: commit.author_name,
+ email: commit.author_email,
+ date: commit.date,
+ refs: commit.refs || '',
+ },
+ files,
+ };
+ },
+
+ // ─── Checkout & branch ────────────────────────────────────────────────────
+ async 'git.checkout'({ cwd, ref, createBranch = null }) {
+ const git = getGit(cwd);
+ if (createBranch) {
+ await git.checkoutLocalBranch(createBranch, ref);
+ return { branch: createBranch };
+ }
+ await git.checkout(ref);
+ return { ref };
+ },
+
+ async 'git.createBranch'({ cwd, name, startPoint, checkout = false }) {
+ const git = getGit(cwd);
+ if (checkout) {
+ await git.checkoutLocalBranch(name, startPoint);
+ } else {
+ await git.branch([name, startPoint]);
+ }
+ return { name };
+ },
+
+ async 'git.deleteBranch'({ cwd, name, force = false }) {
+ const git = getGit(cwd);
+ await git.deleteLocalBranch(name, force);
+ return { deleted: name };
+ },
+
+ async 'git.renameBranch'({ cwd, oldName, newName }) {
+ const git = getGit(cwd);
+ await git.raw(['branch', '-m', oldName, newName]);
+ return { newName };
+ },
+
+ // ─── Merge & rebase ─────────────────────────────────────────────────────────
+ async 'git.merge'({ cwd, ref, noFF = false, squash = false, noCommit = false }) {
+ const git = getGit(cwd);
+ const args = [ref];
+ if (noFF) args.unshift('--no-ff');
+ if (squash) args.unshift('--squash');
+ if (noCommit) args.unshift('--no-commit');
+ await git.merge(args);
+ return { merged: ref };
+ },
+
+ async 'git.rebase'({ cwd, onto, branch = null }) {
+ const git = getGit(cwd);
+ if (branch) {
+ await git.rebase([branch]);
+ } else {
+ await git.rebase([onto]);
+ }
+ return { rebased: onto || branch };
+ },
+
+ // ─── Push & pull & fetch ───────────────────────────────────────────────────
+ async 'git.push'({ cwd, remote, branch, setUpstream = false, force = false, forceWithLease = false }) {
+ const git = getGit(cwd);
+ const args = [remote];
+ if (branch) args.push(branch);
+ if (setUpstream) args.push('--set-upstream');
+ if (force) args.push('--force');
+ if (forceWithLease) args.push('--force-with-lease');
+ await git.push(args);
+ return { pushed: true };
+ },
+
+ async 'git.pull'({ cwd, remote, branch, noFF = false, squash = false }) {
+ const git = getGit(cwd);
+ const args = [remote];
+ if (branch) args.push(branch);
+ if (noFF) args.push('--no-ff');
+ if (squash) args.push('--squash');
+ await git.pull(args);
+ return { pulled: true };
+ },
+
+ async 'git.fetch'({ cwd, remote, prune = false, pruneTags = false }) {
+ const git = getGit(cwd);
+ const args = remote ? [remote] : [];
+ if (prune) args.push('--prune');
+ if (pruneTags) args.push('--prune-tags');
+ await git.fetch(args);
+ return { fetched: true };
+ },
+
+ async 'git.fetchIntoLocalBranch'({ cwd, remote, remoteBranch, localBranch, force = false }) {
+ const git = getGit(cwd);
+ const ref = `${remote}/${remoteBranch}:refs/heads/${localBranch}`;
+ const args = [remote, ref];
+ if (force) args.push('--force');
+ await git.fetch(args);
+ return { localBranch };
+ },
+
+ // ─── Commit operations ─────────────────────────────────────────────────────
+ async 'git.cherryPick'({ cwd, hash, noCommit = false, recordOrigin = false }) {
+ const git = getGit(cwd);
+ const args = ['cherry-pick'];
+ if (noCommit) args.push('--no-commit');
+ if (recordOrigin) args.push('-x');
+ args.push(hash);
+ await git.raw(args);
+ return { hash };
+ },
+
+ async 'git.revert'({ cwd, hash, parentIndex = null }) {
+ const git = getGit(cwd);
+ const args = ['revert', '--no-edit'];
+ if (parentIndex != null) args.push('-m', String(parentIndex));
+ args.push(hash);
+ await git.raw(args);
+ return { hash };
+ },
+
+ async 'git.reset'({ cwd, hash, mode = 'mixed' }) {
+ const git = getGit(cwd);
+ const modes = { soft: 'soft', mixed: 'mixed', hard: 'hard' };
+ const m = modes[mode] || 'mixed';
+ await git.reset([m, hash]);
+ return { hash, mode: m };
+ },
+
+ async 'git.dropCommit'({ cwd, hash }) {
+ const git = getGit(cwd);
+ await git.raw(['rebase', '--onto', hash + '^', hash]);
+ return { hash };
+ },
+
+ // ─── Tags ──────────────────────────────────────────────────────────────────
+ async 'git.tags'({ cwd }) {
+ const git = getGit(cwd);
+ const tags = await git.tags();
+ return { all: tags.all || [] };
+ },
+
+ async 'git.addTag'({ cwd, name, ref, annotated = false, message = null }) {
+ const git = getGit(cwd);
+ if (annotated && message != null) {
+ if (ref) {
+ await git.raw(['tag', '-a', name, ref, '-m', message]);
+ } else {
+ await git.addAnnotatedTag(name, message);
+ }
+ } else {
+ if (ref) {
+ await git.raw(['tag', name, ref]);
+ } else {
+ await git.addTag(name);
+ }
+ }
+ return { name };
+ },
+
+ async 'git.deleteTag'({ cwd, name }) {
+ const git = getGit(cwd);
+ await git.raw(['tag', '-d', name]);
+ return { deleted: name };
+ },
+
+ async 'git.pushTag'({ cwd, remote, name }) {
+ const git = getGit(cwd);
+ await git.push(remote, `refs/tags/${name}`);
+ return { name };
+ },
+
+ async 'git.tagDetails'({ cwd, name }) {
+ const git = getGit(cwd);
+ try {
+ const show = await git.raw(['show', '--no-patch', name]);
+ return { output: show };
+ } catch (e) {
+ return { output: null, error: e.message };
+ }
+ },
+
+ // ─── Stash ─────────────────────────────────────────────────────────────────
+ async 'git.stashList'({ cwd }) {
+ const git = getGit(cwd);
+ const list = await git.stashList();
+ const items = (list.all || []).map((s) => ({
+ hash: s.hash,
+ shortHash: s.hash ? s.hash.slice(0, 7) : '',
+ message: s.message,
+ date: s.date,
+ refs: s.refs || '',
+ parentHashes: Array.isArray(s.parents) ? s.parents : s.parent ? [s.parent] : [],
+ stashSelector: s.hash ? `stash@{${list.all.indexOf(s)}}` : null,
+ }));
+ return { all: items };
+ },
+
+ async 'git.stashPush'({ cwd, message = null, includeUntracked = false }) {
+ const git = getGit(cwd);
+ const args = ['push'];
+ if (message) args.push('-m', message);
+ if (includeUntracked) args.push('--include-untracked');
+ await git.stash(args);
+ return { pushed: true };
+ },
+
+ async 'git.stashApply'({ cwd, selector, restoreIndex = false }) {
+ const git = getGit(cwd);
+ const args = ['apply'];
+ if (restoreIndex) args.push('--index');
+ args.push(selector);
+ await git.stash(args);
+ return { applied: selector };
+ },
+
+ async 'git.stashPop'({ cwd, selector, restoreIndex = false }) {
+ const git = getGit(cwd);
+ const args = ['pop'];
+ if (restoreIndex) args.push('--index');
+ args.push(selector);
+ await git.stash(args);
+ return { popped: selector };
+ },
+
+ async 'git.stashDrop'({ cwd, selector }) {
+ const git = getGit(cwd);
+ await git.stash(['drop', selector]);
+ return { dropped: selector };
+ },
+
+ async 'git.stashBranch'({ cwd, branchName, selector }) {
+ const git = getGit(cwd);
+ await git.stash(['branch', branchName, selector]);
+ return { branch: branchName };
+ },
+
+ // ─── Remotes ────────────────────────────────────────────────────────────────
+ async 'git.remotes'({ cwd }) {
+ const git = getGit(cwd);
+ const remotes = await git.getRemotes(true);
+ const list = Object.entries(remotes || {}).map(([name, r]) => ({
+ name,
+ fetch: (r && r.fetch) || '',
+ push: (r && r.push) || '',
+ }));
+ return { remotes: list };
+ },
+
+ async 'git.addRemote'({ cwd, name, url, pushUrl = null }) {
+ const git = getGit(cwd);
+ await git.addRemote(name, url);
+ if (pushUrl) {
+ await git.raw(['remote', 'set-url', '--push', name, pushUrl]);
+ }
+ return { name };
+ },
+
+ async 'git.removeRemote'({ cwd, name }) {
+ const git = getGit(cwd);
+ await git.removeRemote(name);
+ return { removed: name };
+ },
+
+ async 'git.setRemoteUrl'({ cwd, name, url, push = false }) {
+ const git = getGit(cwd);
+ if (push) {
+ await git.raw(['remote', 'set-url', '--push', name, url]);
+ } else {
+ await git.raw(['remote', 'set-url', name, url]);
+ }
+ return { name };
+ },
+
+ // ─── Diff & compare ────────────────────────────────────────────────────────
+ async 'git.fileDiff'({ cwd, from, to, file }) {
+ const git = getGit(cwd);
+ const args = ['--unified=3', from, to, '--', file];
+ const out = await git.diff(args);
+ return { diff: out };
+ },
+
+ async 'git.compareCommits'({ cwd, hash1, hash2 }) {
+ const git = getGit(cwd);
+ const out = await git.raw(['diff', '--name-status', hash1, hash2]);
+ const lines = (out || '').trim().split('\n').filter(Boolean);
+ const files = lines.map((line) => {
+ const m = line.match(/^([AMD])\s+(.+)$/) || line.match(/^([AR])\d*\s+(.+)\s+(.+)$/);
+ if (m) {
+ const status = m[1];
+ const path = m[2] || line.slice(2).trim();
+ return { status, file: path };
+ }
+ return { status: '?', file: line.trim() };
+ });
+ return { files };
+ },
+
+ // ─── Uncommitted / clean ───────────────────────────────────────────────────
+ async 'git.resetUncommitted'({ cwd, mode = 'mixed' }) {
+ const git = getGit(cwd);
+ const modes = { soft: 'soft', mixed: 'mixed', hard: 'hard' };
+ await git.reset([modes[mode] || 'mixed', 'HEAD']);
+ return { reset: true };
+ },
+
+ async 'git.cleanUntracked'({ cwd, force = false, directories = false }) {
+ const git = getGit(cwd);
+ const args = ['clean'];
+ if (force) args.push('-f');
+ if (directories) args.push('-fd');
+ await git.raw(args);
+ return { cleaned: true };
+ },
+};
diff --git a/MiniApp/Demo/git-graph/storage.json b/MiniApp/Demo/git-graph/storage.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/MiniApp/Demo/git-graph/storage.json
@@ -0,0 +1 @@
+{}
diff --git a/MiniApp/Skills/miniapp-dev/SKILL.md b/MiniApp/Skills/miniapp-dev/SKILL.md
new file mode 100644
index 00000000..14b7206d
--- /dev/null
+++ b/MiniApp/Skills/miniapp-dev/SKILL.md
@@ -0,0 +1,225 @@
+---
+name: miniapp-dev
+description: Develops and maintains the BitFun MiniApp system (Zero-Dialect Runtime). Use when working on miniapp modules, toolbox scene, bridge scripts, agent tool (InitMiniApp), permission policy, or any code under src/crates/core/src/miniapp/ or src/web-ui/src/app/scenes/toolbox/. Also use when the user mentions MiniApp, toolbox, bridge, or zero-dialect.
+---
+
+# BitFun MiniApp V2 开发指南
+
+## 核心哲学:Zero-Dialect Runtime
+
+MiniApp 使用 **标准 Web API + window.app**:UI 侧为 ESM 模块(`ui.js`),后端逻辑在独立 JS Worker 进程(Bun 优先 / Node 回退)中执行。Rust 负责进程管理、权限策略和 Tauri 独占 API;Bridge 从旧的 `require()` shim + `__BITFUN__` 替换为统一的 **window.app** Runtime Adapter。
+
+## 代码架构
+
+### Rust 后端
+
+```
+src/crates/core/src/miniapp/
+├── types.rs # MiniAppSource (ui_js/worker_js/esm_dependencies/npm_dependencies), NodePermissions
+├── manager.rs # CRUD + recompile() + resolve_policy_for_app()
+├── storage.rs # ui.js, worker.js, package.json, esm_dependencies.json
+├── compiler.rs # Import Map + Runtime Adapter 注入 + ESM
+├── bridge_builder.rs # window.app 生成 + build_import_map()
+├── permission_policy.rs # resolve_policy() → JSON 策略供 Worker 启动
+├── runtime_detect.rs # detect_runtime() Bun/Node
+├── js_worker.rs # 单进程 stdin/stderr JSON-RPC
+├── js_worker_pool.rs # 池管理 + install_deps
+├── exporter.rs # 导出骨架
+└── mod.rs
+```
+
+### Tauri Commands
+
+```
+src/apps/desktop/src/api/miniapp_api.rs
+```
+
+- 应用管理: `list_miniapps`, `get_miniapp`, `create_miniapp`, `update_miniapp`, `delete_miniapp`
+- 存储/授权: `get/set_miniapp_storage`, `grant_miniapp_workspace`, `grant_miniapp_path`
+- 版本: `get_miniapp_versions`, `rollback_miniapp`
+- Worker/Runtime: `miniapp_runtime_status`, `miniapp_worker_call`, `miniapp_worker_stop`, `miniapp_install_deps`, `miniapp_recompile`
+- 对话框由前端 Bridge 用 Tauri dialog 插件处理,无单独后端命令
+
+### Agent 工具
+
+```
+src/crates/core/src/agentic/tools/implementations/
+└── miniapp_init_tool.rs # InitMiniApp — 唯一工具,创建骨架目录供 AI 用通用文件工具编辑
+```
+
+注册在 `registry.rs` 的 `register_all_tools()` 中。AI 后续用 Read/Edit/Write 等通用文件工具编辑 MiniApp 文件。
+
+### 前端
+
+```
+src/web-ui/src/app/scenes/toolbox/
+├── ToolboxScene.tsx / .scss
+├── toolboxStore.ts
+├── views/ GalleryView, AppRunnerView
+├── components/ MiniAppCard, MiniAppRunner (iframe 带 data-app-id)
+└── hooks/
+ ├── useMiniAppBridge.ts # 仅处理 worker.call → workerCall() + dialog.open/save/message
+ └── useMiniAppList.ts
+
+src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts # runtimeStatus, workerCall, workerStop, installDeps, recompile
+src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx # InitMiniAppDisplay
+```
+
+### Worker 宿主
+
+```
+src/apps/desktop/resources/worker_host.js
+```
+
+Node/Bun 标准脚本:从 argv 读策略 JSON,stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。
+
+## MiniApp 数据模型 (V2)
+
+```rust
+// types.rs
+MiniAppSource {
+ html, css,
+ ui_js, // 浏览器侧 ESM
+ esm_dependencies,
+ worker_js, // Worker 侧逻辑
+ npm_dependencies,
+}
+MiniAppPermissions { fs?, shell?, net?, node? } // node 替代 env/compute
+```
+
+## 权限模型
+
+- **permission_policy.rs**:`resolve_policy(perms, app_id, app_data_dir, workspace_dir, granted_paths)` 生成 JSON 策略,传给 Worker 启动参数;Worker 内部按策略拦截越权。
+- 路径变量同前:`{appdata}`, `{workspace}`, `{user-selected}`, `{home}` 等。
+
+## Bridge 通信流程 (V2)
+
+```
+iframe 内 window.app.call(method, params)
+ → postMessage({ method: 'worker.call', params: { method, params } })
+ → useMiniAppBridge 监听
+ → miniAppAPI.workerCall(appId, method, params)
+ → Tauri invoke('miniapp_worker_call')
+ → JsWorkerPool → Worker 进程 stdin → stderr 响应
+ → 结果回 iframe
+
+dialog.open / dialog.save / dialog.message
+ → postMessage → useMiniAppBridge 直接调 @tauri-apps/plugin-dialog
+```
+
+## window.app 运行时 API
+
+MiniApp UI 内通过 **window.app** 访问:
+
+| API | 说明 |
+|-----|------|
+| `app.call(method, params)` | 调用 Worker 方法(含 fs/shell/net/os/storage 及用户 worker.js 导出) |
+| `app.fs.*` | 封装为 worker.call('fs.*', …) |
+| `app.shell.*` | 同上 |
+| `app.net.*` | 同上 |
+| `app.os.*` | 同上 |
+| `app.storage.*` | 同上 |
+| `app.dialog.open/save/message` | 由 Bridge 转 Tauri dialog 插件 |
+| 生命周期 / 事件 | 见 bridge_builder 生成的适配器 |
+
+## 主题集成
+
+MiniApp 在 iframe 中运行时自动与主应用主题同步,避免界面风格与主应用差距过大。
+
+### 只读属性与事件
+
+| 成员 | 说明 |
+|------|------|
+| `app.theme` | 当前主题类型字符串:`'dark'` 或 `'light'`(随主应用切换更新) |
+| `app.onThemeChange(fn)` | 注册主题变更回调,参数为 payload:`{ type, id, vars }` |
+
+### data-theme-type 属性
+
+编译后的 HTML 根元素 `` 带有 `data-theme-type="dark"` 或 `"light"`,便于用 CSS 按主题写样式,例如:
+
+```css
+[data-theme-type="light"] .panel { background: #f5f5f5; }
+[data-theme-type="dark"] .panel { background: #1a1a1a; }
+```
+
+### --bitfun-* CSS 变量
+
+宿主会将主应用主题映射为以下 CSS 变量并注入 iframe 的 `:root`。在 MiniApp 的 CSS 中建议用 `var(--bitfun-*, )` 引用,以便在 BitFun 内与主应用一致,导出为独立应用时 fallback 生效。
+
+**背景**
+
+- `--bitfun-bg` — 主背景
+- `--bitfun-bg-secondary` — 次级背景(如工具栏、面板)
+- `--bitfun-bg-tertiary` — 第三级背景
+- `--bitfun-bg-elevated` — 浮层/卡片背景
+
+**文字**
+
+- `--bitfun-text` — 主文字
+- `--bitfun-text-secondary` — 次要文字
+- `--bitfun-text-muted` — 弱化文字
+
+**强调与语义**
+
+- `--bitfun-accent`、`--bitfun-accent-hover` — 强调色及悬停
+- `--bitfun-success`、`--bitfun-warning`、`--bitfun-error`、`--bitfun-info` — 语义色
+
+**边框与元素**
+
+- `--bitfun-border`、`--bitfun-border-subtle` — 边框
+- `--bitfun-element-bg`、`--bitfun-element-hover` — 控件背景与悬停
+
+**圆角与字体**
+
+- `--bitfun-radius`、`--bitfun-radius-lg` — 圆角
+- `--bitfun-font-sans`、`--bitfun-font-mono` — 无衬线与等宽字体
+
+**滚动条**
+
+- `--bitfun-scrollbar-thumb`、`--bitfun-scrollbar-thumb-hover` — 滚动条滑块
+
+示例(在 `style.css` 中):
+
+```css
+:root {
+ --bg: var(--bitfun-bg, #121214);
+ --text: var(--bitfun-text, #e8e8e8);
+ --accent: var(--bitfun-accent, #60a5fa);
+}
+body {
+ font-family: var(--bitfun-font-sans, system-ui, sans-serif);
+ color: var(--text);
+ background: var(--bg);
+}
+```
+
+### 同步时机
+
+- iframe 加载后 bridge 会向宿主发送 `bitfun/request-theme`,宿主回推当前主题变量,iframe 内 `_applyThemeVars` 写入 `:root`。
+- 主应用切换主题时,宿主会向 iframe 发送 `themeChange` 事件,bridge 更新变量并触发 `onThemeChange` 回调。
+
+## 开发约定
+
+### 新增 Agent 工具
+
+当前仅 **InitMiniApp**。若扩展:
+1. `implementations/miniapp_xxx_tool.rs` 实现 `Tool`
+2. `mod.rs` + `registry.rs` 注册
+3. `flow_chat/tool-cards/index.ts` 与 `MiniAppToolDisplay.tsx` 增加对应卡片
+
+### 修改编译器
+
+`compiler.rs`:注入 Import Map(`build_import_map`)、Runtime Adapter(`build_bridge_script`)、CSP;用户脚本以 `"#,
+ json.to_string()
+ )
+}
+
+/// Build CSP meta content from permissions (net.allow → connect-src).
+pub fn build_csp_content(permissions: &MiniAppPermissions) -> String {
+ let net_allow = permissions
+ .net
+ .as_ref()
+ .and_then(|n| n.allow.as_ref())
+ .map(|v| v.iter().map(|d| d.as_str()).collect::>())
+ .unwrap_or_default();
+
+ let connect_src = if net_allow.is_empty() {
+ "'self'".to_string()
+ } else if net_allow.contains(&"*") {
+ "'self' *".to_string()
+ } else {
+ let safe: Vec = net_allow
+ .iter()
+ .map(|d| {
+ d.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ })
+ .collect();
+ format!("'self' https://esm.sh {}", safe.join(" "))
+ };
+
+ format!(
+ "default-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; connect-src 'self' {}; img-src 'self' data: https:; font-src 'self' https:; object-src 'none'; base-uri 'self';",
+ connect_src
+ )
+}
+
+/// Scroll boundary script (reuse same logic as MCP App).
+pub fn scroll_boundary_script() -> &'static str {
+ r#""#
+}
+
+/// Default dark theme CSS variables for MiniApp iframe (avoids flash before host sends theme).
+pub fn build_miniapp_default_theme_css() -> &'static str {
+ r#""#
+}
diff --git a/src/crates/core/src/miniapp/compiler.rs b/src/crates/core/src/miniapp/compiler.rs
new file mode 100644
index 00000000..e2090c10
--- /dev/null
+++ b/src/crates/core/src/miniapp/compiler.rs
@@ -0,0 +1,175 @@
+//! MiniApp compiler — assemble source (html/css/ui_js) + Import Map + Runtime Adapter + CSP into compiled_html.
+
+use crate::miniapp::bridge_builder::{
+ build_bridge_script, build_csp_content, build_import_map, build_miniapp_default_theme_css,
+ scroll_boundary_script,
+};
+use crate::miniapp::types::{MiniAppPermissions, MiniAppSource};
+use crate::util::errors::{BitFunError, BitFunResult};
+
+/// Compile MiniApp source into full HTML with Import Map, Runtime Adapter, and CSP injected.
+pub fn compile(
+ source: &MiniAppSource,
+ permissions: &MiniAppPermissions,
+ app_id: &str,
+ app_data_dir: &str,
+ workspace_dir: &str,
+ theme: &str,
+) -> BitFunResult {
+ let platform = if cfg!(target_os = "windows") {
+ "win32"
+ } else if cfg!(target_os = "macos") {
+ "darwin"
+ } else {
+ "linux"
+ };
+
+ let bridge = build_bridge_script(app_id, app_data_dir, workspace_dir, theme, platform);
+ let csp = build_csp_content(permissions);
+ let csp_tag = format!(
+ "",
+ csp.replace('"', """)
+ );
+ let scroll = scroll_boundary_script();
+ let theme_default_style = build_miniapp_default_theme_css();
+ let import_map = build_import_map(&source.esm_dependencies);
+ let style_tag = if source.css.is_empty() {
+ String::new()
+ } else {
+ format!("", source.css)
+ };
+ let bridge_script_tag = format!("", bridge);
+ let user_script_tag = if source.ui_js.is_empty() {
+ String::new()
+ } else {
+ format!("", source.ui_js)
+ };
+
+ let head_content = format!(
+ "\n{}\n{}\n{}\n{}\n{}\n{}\n",
+ theme_default_style,
+ csp_tag,
+ scroll,
+ import_map,
+ bridge_script_tag,
+ style_tag,
+ );
+
+ let html = if source.html.trim().is_empty() {
+ let theme_attr = format!(" data-theme-type=\"{}\"", escape_html_attr(theme));
+ format!(
+ r#"
+
+{head}
+
+{user_script}
+
+"#,
+ theme_attr = theme_attr,
+ head = head_content,
+ user_script = user_script_tag,
+ )
+ } else {
+ let with_theme = inject_data_theme_type(&source.html, theme);
+ let with_head = inject_into_head(&with_theme, &head_content)?;
+ inject_before_body_close(&with_head, &user_script_tag)
+ };
+
+ Ok(html)
+}
+
+/// Place content just before . If no