From a303cd67dbc7c6a2a98a80a38d9d55fc88b066d7 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sat, 7 Mar 2026 21:18:22 +0800 Subject: [PATCH] Add Mini App 1. Add MiniApp Runtime 2. Add MiniApp init tool 3. Add MiniApp dev Skill & Demo --- MiniApp/Demo/git-graph/README.md | 181 ++ MiniApp/Demo/git-graph/meta.json | 25 + MiniApp/Demo/git-graph/package.json | 10 + MiniApp/Demo/git-graph/source/build.js | 53 + .../git-graph/source/esm_dependencies.json | 1 + MiniApp/Demo/git-graph/source/index.html | 134 ++ MiniApp/Demo/git-graph/source/style.css | 814 +++++++ .../git-graph/source/styles/detail-panel.css | 233 ++ .../Demo/git-graph/source/styles/graph.css | 23 + .../Demo/git-graph/source/styles/layout.css | 307 +++ .../Demo/git-graph/source/styles/overlay.css | 197 ++ .../Demo/git-graph/source/styles/tokens.css | 44 + MiniApp/Demo/git-graph/source/ui.js | 1884 +++++++++++++++++ MiniApp/Demo/git-graph/source/ui/bootstrap.js | 95 + .../source/ui/components/contextMenu.js | 47 + .../source/ui/components/findWidget.js | 105 + .../git-graph/source/ui/components/modal.js | 37 + .../Demo/git-graph/source/ui/graph/layout.js | 284 +++ .../git-graph/source/ui/graph/renderRowSvg.js | 105 + MiniApp/Demo/git-graph/source/ui/main.js | 679 ++++++ .../git-graph/source/ui/panels/detailPanel.js | 259 +++ .../git-graph/source/ui/panels/remotePanel.js | 93 + .../git-graph/source/ui/services/gitClient.js | 12 + MiniApp/Demo/git-graph/source/ui/state.js | 113 + MiniApp/Demo/git-graph/source/ui/theme.js | 31 + MiniApp/Demo/git-graph/source/worker.js | 686 ++++++ MiniApp/Demo/git-graph/storage.json | 1 + MiniApp/Skills/miniapp-dev/SKILL.md | 225 ++ MiniApp/Skills/miniapp-dev/api-reference.md | 185 ++ MiniApp/Skills/miniapp-dev/architecture.md | 163 ++ src/apps/desktop/resources/worker_host.js | 187 ++ src/apps/desktop/src/api/app_state.rs | 19 + src/apps/desktop/src/api/commands.rs | 9 +- src/apps/desktop/src/api/miniapp_api.rs | 576 +++++ src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/lib.rs | 21 + .../implementations/miniapp_init_tool.rs | 202 ++ .../src/agentic/tools/implementations/mod.rs | 2 + src/crates/core/src/agentic/tools/registry.rs | 3 + .../infrastructure/filesystem/path_manager.rs | 11 + src/crates/core/src/lib.rs | 1 + src/crates/core/src/miniapp/bridge_builder.rs | 203 ++ src/crates/core/src/miniapp/compiler.rs | 175 ++ src/crates/core/src/miniapp/exporter.rs | 88 + src/crates/core/src/miniapp/js_worker.rs | 156 ++ src/crates/core/src/miniapp/js_worker_pool.rs | 285 +++ src/crates/core/src/miniapp/manager.rs | 568 +++++ src/crates/core/src/miniapp/mod.rs | 23 + .../core/src/miniapp/permission_policy.rs | 107 + src/crates/core/src/miniapp/runtime_detect.rs | 55 + src/crates/core/src/miniapp/storage.rs | 377 ++++ src/crates/core/src/miniapp/types.rs | 204 ++ .../src/app/components/NavPanel/MainNav.tsx | 2 + .../src/app/components/NavPanel/config.ts | 10 + .../sections/toolbox/ToolboxSection.scss | 27 + .../sections/toolbox/ToolboxSection.tsx | 189 ++ .../src/app/components/SceneBar/types.ts | 12 +- src/web-ui/src/app/hooks/useSceneManager.ts | 14 +- src/web-ui/src/app/scenes/SceneViewport.tsx | 10 +- src/web-ui/src/app/scenes/registry.ts | 26 +- .../src/app/scenes/toolbox/MiniAppScene.scss | 99 + .../src/app/scenes/toolbox/MiniAppScene.tsx | 166 ++ .../src/app/scenes/toolbox/ToolboxScene.scss | 7 + .../src/app/scenes/toolbox/ToolboxScene.tsx | 49 + .../toolbox/components/MiniAppCard.scss | 236 +++ .../scenes/toolbox/components/MiniAppCard.tsx | 110 + .../toolbox/components/MiniAppRunner.tsx | 30 + .../scenes/toolbox/hooks/useMiniAppBridge.ts | 101 + .../scenes/toolbox/hooks/useMiniAppList.ts | 53 + .../src/app/scenes/toolbox/toolboxStore.ts | 58 + .../toolbox/utils/buildMiniAppThemeVars.ts | 67 + .../app/scenes/toolbox/views/GalleryView.scss | 493 +++++ .../app/scenes/toolbox/views/GalleryView.tsx | 363 ++++ src/web-ui/src/app/stores/sceneStore.ts | 66 +- src/web-ui/src/app/types/index.ts | 2 +- .../tool-cards/MiniAppToolDisplay.scss | 84 + .../tool-cards/MiniAppToolDisplay.tsx | 54 + src/web-ui/src/flow_chat/tool-cards/index.ts | 20 +- .../api/service-api/MiniAppAPI.ts | 232 ++ src/web-ui/src/locales/en-US/common.json | 17 +- src/web-ui/src/locales/zh-CN/common.json | 17 +- 81 files changed, 12876 insertions(+), 37 deletions(-) create mode 100644 MiniApp/Demo/git-graph/README.md create mode 100644 MiniApp/Demo/git-graph/meta.json create mode 100644 MiniApp/Demo/git-graph/package.json create mode 100644 MiniApp/Demo/git-graph/source/build.js create mode 100644 MiniApp/Demo/git-graph/source/esm_dependencies.json create mode 100644 MiniApp/Demo/git-graph/source/index.html create mode 100644 MiniApp/Demo/git-graph/source/style.css create mode 100644 MiniApp/Demo/git-graph/source/styles/detail-panel.css create mode 100644 MiniApp/Demo/git-graph/source/styles/graph.css create mode 100644 MiniApp/Demo/git-graph/source/styles/layout.css create mode 100644 MiniApp/Demo/git-graph/source/styles/overlay.css create mode 100644 MiniApp/Demo/git-graph/source/styles/tokens.css create mode 100644 MiniApp/Demo/git-graph/source/ui.js create mode 100644 MiniApp/Demo/git-graph/source/ui/bootstrap.js create mode 100644 MiniApp/Demo/git-graph/source/ui/components/contextMenu.js create mode 100644 MiniApp/Demo/git-graph/source/ui/components/findWidget.js create mode 100644 MiniApp/Demo/git-graph/source/ui/components/modal.js create mode 100644 MiniApp/Demo/git-graph/source/ui/graph/layout.js create mode 100644 MiniApp/Demo/git-graph/source/ui/graph/renderRowSvg.js create mode 100644 MiniApp/Demo/git-graph/source/ui/main.js create mode 100644 MiniApp/Demo/git-graph/source/ui/panels/detailPanel.js create mode 100644 MiniApp/Demo/git-graph/source/ui/panels/remotePanel.js create mode 100644 MiniApp/Demo/git-graph/source/ui/services/gitClient.js create mode 100644 MiniApp/Demo/git-graph/source/ui/state.js create mode 100644 MiniApp/Demo/git-graph/source/ui/theme.js create mode 100644 MiniApp/Demo/git-graph/source/worker.js create mode 100644 MiniApp/Demo/git-graph/storage.json create mode 100644 MiniApp/Skills/miniapp-dev/SKILL.md create mode 100644 MiniApp/Skills/miniapp-dev/api-reference.md create mode 100644 MiniApp/Skills/miniapp-dev/architecture.md create mode 100644 src/apps/desktop/resources/worker_host.js create mode 100644 src/apps/desktop/src/api/miniapp_api.rs create mode 100644 src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs create mode 100644 src/crates/core/src/miniapp/bridge_builder.rs create mode 100644 src/crates/core/src/miniapp/compiler.rs create mode 100644 src/crates/core/src/miniapp/exporter.rs create mode 100644 src/crates/core/src/miniapp/js_worker.rs create mode 100644 src/crates/core/src/miniapp/js_worker_pool.rs create mode 100644 src/crates/core/src/miniapp/manager.rs create mode 100644 src/crates/core/src/miniapp/mod.rs create mode 100644 src/crates/core/src/miniapp/permission_policy.rs create mode 100644 src/crates/core/src/miniapp/runtime_detect.rs create mode 100644 src/crates/core/src/miniapp/storage.rs create mode 100644 src/crates/core/src/miniapp/types.rs create mode 100644 src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.scss create mode 100644 src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.tsx create mode 100644 src/web-ui/src/app/scenes/toolbox/MiniAppScene.scss create mode 100644 src/web-ui/src/app/scenes/toolbox/MiniAppScene.tsx create mode 100644 src/web-ui/src/app/scenes/toolbox/ToolboxScene.scss create mode 100644 src/web-ui/src/app/scenes/toolbox/ToolboxScene.tsx create mode 100644 src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss create mode 100644 src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx create mode 100644 src/web-ui/src/app/scenes/toolbox/components/MiniAppRunner.tsx create mode 100644 src/web-ui/src/app/scenes/toolbox/hooks/useMiniAppBridge.ts create mode 100644 src/web-ui/src/app/scenes/toolbox/hooks/useMiniAppList.ts create mode 100644 src/web-ui/src/app/scenes/toolbox/toolboxStore.ts create mode 100644 src/web-ui/src/app/scenes/toolbox/utils/buildMiniAppThemeVars.ts create mode 100644 src/web-ui/src/app/scenes/toolbox/views/GalleryView.scss create mode 100644 src/web-ui/src/app/scenes/toolbox/views/GalleryView.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss create mode 100644 src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx create mode 100644 src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts 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 = '
'; + } + 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 = '
'; + } + 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 found, append before or at end. +fn inject_before_body_close(html: &str, content: &str) -> String { + if content.is_empty() { + return html.to_string(); + } + if let Some(pos) = html.rfind("") { + let (before, after) = html.split_at(pos); + return format!("{}\n{}\n{}", before, content, after); + } + if let Some(pos) = html.rfind("") { + let (before, after) = html.split_at(pos); + return format!("{}\n{}\n{}", before, content, after); + } + format!("{}\n{}", html, content) +} + +fn escape_html_attr(s: &str) -> String { + s.replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +/// Inject or replace data-theme-type on the first tag. +fn inject_data_theme_type(html: &str, theme: &str) -> String { + let safe = escape_html_attr(theme); + if let Some(idx) = html.find("') { + let insert = format!(" data-theme-type=\"{}\"", safe); + return format!( + "{}{}>{}", + &html[..after_html + close], + insert, + &html[after_html + close + 1..] + ); + } + } + html.to_string() +} + +fn inject_into_head(html: &str, content: &str) -> BitFunResult { + if let Some(head_start) = html.find("') { + head_start + close_bracket + 1 + } else { + return Err(BitFunError::validation( + "Invalid HTML: not properly opened".to_string(), + )); + }; + let before = &html[..after_head_open]; + let after = &html[after_head_open..]; + return Ok(format!("{}{}{}", before, content, after)); + } + + if let Some(html_open) = html.find("') { + html_open + close_bracket + 1 + } else { + return Err(BitFunError::validation( + "Invalid HTML: not properly opened".to_string(), + )); + }; + let before = &html[..after_html_open]; + let after = &html[after_html_open..]; + return Ok(format!("{}\n{}{}", before, content, after)); + } + + Ok(format!( + r#" + +{} + +{} + +"#, + content, html + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::miniapp::types::MiniAppSource; + + #[test] + fn test_inject_into_head() { + let html = r#"x"#; + let content = ""; + let out = inject_into_head(html, content).unwrap(); + assert!(out.contains("")); + assert!(out.contains(", + pub icon_path: Option, + pub include_storage: bool, + pub platforms: Vec, + pub sign: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportCheckResult { + pub ready: bool, + pub runtime: Option, + pub missing: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportResult { + pub success: bool, + pub output_path: Option, + pub size_mb: Option, + pub duration_ms: Option, +} + +/// Export engine: check prerequisites and export MiniApp to standalone app. +pub struct MiniAppExporter { + #[allow(dead_code)] + path_manager: Arc, + #[allow(dead_code)] + templates_dir: PathBuf, +} + +impl MiniAppExporter { + pub fn new( + path_manager: Arc, + templates_dir: PathBuf, + ) -> Self { + Self { + path_manager, + templates_dir, + } + } + + /// Check if export is possible (runtime, electron-builder, etc.). + pub async fn check(&self, _app_id: &str) -> BitFunResult { + let runtime = crate::miniapp::runtime_detect::detect_runtime(); + let runtime_str = runtime.as_ref().map(|r| { + match r.kind { + crate::miniapp::runtime_detect::RuntimeKind::Bun => "bun", + crate::miniapp::runtime_detect::RuntimeKind::Node => "node", + } + .to_string() + }); + let mut missing = Vec::new(); + if runtime.is_none() { + missing.push("No JS runtime (install Bun or Node.js)".to_string()); + } + Ok(ExportCheckResult { + ready: missing.is_empty(), + runtime: runtime_str, + missing, + warnings: Vec::new(), + }) + } + + /// Export the MiniApp to a standalone application. + pub async fn export(&self, _app_id: &str, _options: ExportOptions) -> BitFunResult { + Err(BitFunError::validation( + "Export not yet implemented (skeleton)".to_string(), + )) + } +} diff --git a/src/crates/core/src/miniapp/js_worker.rs b/src/crates/core/src/miniapp/js_worker.rs new file mode 100644 index 00000000..397a736a --- /dev/null +++ b/src/crates/core/src/miniapp/js_worker.rs @@ -0,0 +1,156 @@ +//! JS Worker — single child process (Bun/Node) with stdin/stderr JSON-RPC. + +use crate::miniapp::runtime_detect::DetectedRuntime; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{oneshot, Mutex}; + +/// Single JS Worker process: stdin for requests, stderr for RPC responses, stdout for user logs. +pub struct JsWorker { + _child: Child, + stdin: Mutex>, + pending: Arc>>>>, + last_activity: Arc, +} + +impl JsWorker { + /// Spawn Worker process: `runtime_path worker_host_path ''` with cwd = app_dir. + pub async fn spawn( + runtime: &DetectedRuntime, + worker_host_path: &Path, + app_dir: &Path, + policy_json: &str, + ) -> Result { + let exe = runtime.path.to_string_lossy(); + let host = worker_host_path.to_string_lossy(); + let mut child = Command::new(&*exe) + .arg(&*host) + .arg(policy_json) + .current_dir(app_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("Failed to spawn JS Worker: {}", e))?; + + let stdin_handle = child.stdin.take().ok_or("No stdin")?; + let stderr = child.stderr.take().ok_or("No stderr")?; + let _stdout = child.stdout.take(); + + let pending = Arc::new(Mutex::new(HashMap::>>::new())); + let last_activity = Arc::new(AtomicI64::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + )); + + let pending_clone = pending.clone(); + let last_activity_clone = last_activity.clone(); + tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.is_empty() { + continue; + } + let _ = last_activity_clone.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |_| { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + ) + }); + let msg: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + let id = msg.get("id").and_then(Value::as_str).map(String::from); + if let Some(id) = id { + let result = if let Some(err) = msg.get("error") { + let msg = err.get("message").and_then(Value::as_str).unwrap_or("RPC error"); + Err(msg.to_string()) + } else { + msg.get("result").cloned().ok_or_else(|| "Missing result".to_string()) + }; + let mut guard = pending_clone.lock().await; + if let Some(tx) = guard.remove(&id) { + let _ = tx.send(result); + } + } + } + }); + + Ok(Self { + _child: child, + stdin: Mutex::new(Some(stdin_handle)), + pending, + last_activity, + }) + } + + /// Send a JSON-RPC request and wait for the response (with timeout). + pub async fn call(&self, method: &str, params: Value, timeout_ms: u64) -> Result { + let id = format!("rpc-{}", uuid::Uuid::new_v4()); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + let line = serde_json::to_string(&request).map_err(|e| e.to_string())? + "\n"; + + let (tx, rx) = oneshot::channel(); + { + let mut guard = self.pending.lock().await; + guard.insert(id.clone(), tx); + } + self.last_activity.store( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + Ordering::SeqCst, + ); + + let mut stdin_guard = self.stdin.lock().await; + let stdin = stdin_guard.as_mut().ok_or("Worker stdin closed")?; + use tokio::io::AsyncWriteExt; + stdin.write_all(line.as_bytes()).await.map_err(|e| e.to_string())?; + stdin.flush().await.map_err(|e| e.to_string())?; + drop(stdin_guard); + + let timeout = Duration::from_millis(timeout_ms); + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(Ok(v))) => Ok(v), + Ok(Ok(Err(e))) => Err(e), + Ok(Err(_)) => { + let _ = self.pending.lock().await.remove(&id); + Err("Worker dropped response".to_string()) + } + Err(_) => { + let _ = self.pending.lock().await.remove(&id); + Err(format!("Worker call timeout ({}ms)", timeout_ms)) + } + } + } + + /// Last activity timestamp (millis since epoch). + pub fn last_activity_ms(&self) -> i64 { + self.last_activity.load(Ordering::SeqCst) + } + + /// Kill the worker process. + pub async fn kill(&mut self) { + let _ = self._child.start_kill(); + let _ = tokio::time::timeout(Duration::from_secs(2), self._child.wait()).await; + } +} diff --git a/src/crates/core/src/miniapp/js_worker_pool.rs b/src/crates/core/src/miniapp/js_worker_pool.rs new file mode 100644 index 00000000..9fb7eaae --- /dev/null +++ b/src/crates/core/src/miniapp/js_worker_pool.rs @@ -0,0 +1,285 @@ +//! JS Worker pool — LRU pool, get_or_spawn, call, stop_all, install_deps. + +use crate::miniapp::js_worker::JsWorker; +use crate::miniapp::runtime_detect::{detect_runtime, DetectedRuntime}; +use crate::miniapp::types::{NpmDep, NodePermissions}; +use crate::util::errors::{BitFunError, BitFunResult}; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::process::Command; +use tokio::sync::Mutex; + +const MAX_WORKERS: usize = 5; +const IDLE_TIMEOUT_MS: i64 = 3 * 60 * 1000; // 3 minutes + +/// Result of npm/bun install. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct InstallResult { + pub success: bool, + pub stdout: String, + pub stderr: String, +} + +struct WorkerEntry { + revision: String, + worker: Arc>, +} + +pub struct JsWorkerPool { + workers: Arc>>, + runtime: DetectedRuntime, + worker_host_path: PathBuf, + path_manager: Arc, +} + +impl JsWorkerPool { + pub fn new( + path_manager: Arc, + worker_host_path: PathBuf, + ) -> BitFunResult { + let runtime = detect_runtime() + .ok_or_else(|| BitFunError::validation("No JS runtime found (install Bun or Node.js)".to_string()))?; + let workers = Arc::new(Mutex::new(std::collections::HashMap::::new())); + + // Background task: evict idle workers every 60s without waiting for a new spawn. + let workers_bg = Arc::clone(&workers); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + interval.tick().await; // skip first immediate tick + loop { + interval.tick().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let mut guard = workers_bg.lock().await; + let to_remove: Vec = guard + .iter() + .filter(|(_, entry)| { + if let Ok(worker) = entry.worker.try_lock() { + now - worker.last_activity_ms() > IDLE_TIMEOUT_MS + } else { + false + } + }) + .map(|(k, _)| k.clone()) + .collect(); + for id in to_remove { + if let Some(entry) = guard.remove(&id) { + let mut w = entry.worker.lock().await; + w.kill().await; + } + } + } + }); + + Ok(Self { + workers, + runtime, + worker_host_path, + path_manager, + }) + } + + pub fn runtime_info(&self) -> &DetectedRuntime { + &self.runtime + } + + /// Get or spawn a Worker for the app. policy_json is the resolved permission policy JSON string. + pub async fn get_or_spawn( + &self, + app_id: &str, + worker_revision: &str, + policy_json: &str, + node_perms: Option<&NodePermissions>, + ) -> BitFunResult>> { + let mut guard = self.workers.lock().await; + self.evict_idle(&mut guard).await; + + if let Some(entry) = guard.remove(app_id) { + if entry.revision == worker_revision { + let worker = Arc::clone(&entry.worker); + guard.insert(app_id.to_string(), entry); + return Ok(worker); + } + let mut stale = entry.worker.lock().await; + stale.kill().await; + } + + if guard.len() >= MAX_WORKERS { + self.evict_lru(&mut guard).await; + } + + let app_dir = self.path_manager.miniapp_dir(app_id); + if !app_dir.exists() { + return Err(BitFunError::NotFound(format!("MiniApp dir not found: {}", app_id))); + } + + let worker = JsWorker::spawn( + &self.runtime, + &self.worker_host_path, + &app_dir, + policy_json, + ) + .await + .map_err(|e| BitFunError::validation(e))?; + + let _timeout_ms = node_perms + .and_then(|n| n.timeout_ms) + .unwrap_or(30_000); + let worker = Arc::new(Mutex::new(worker)); + guard.insert( + app_id.to_string(), + WorkerEntry { + revision: worker_revision.to_string(), + worker: Arc::clone(&worker), + }, + ); + Ok(worker) + } + + async fn evict_idle( + &self, + guard: &mut std::collections::HashMap, + ) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let to_remove: Vec = guard + .iter() + .filter(|(_, entry)| { + let w = entry.worker.try_lock(); + if let Ok(worker) = w { + now - worker.last_activity_ms() > IDLE_TIMEOUT_MS + } else { + false + } + }) + .map(|(k, _)| k.clone()) + .collect(); + for id in to_remove { + if let Some(entry) = guard.remove(&id) { + let mut w = entry.worker.lock().await; + w.kill().await; + } + } + } + + async fn evict_lru( + &self, + guard: &mut std::collections::HashMap, + ) { + let (oldest_id, _) = guard + .iter() + .map(|(id, entry)| { + let activity = entry + .worker + .try_lock() + .map(|worker| worker.last_activity_ms()) + .unwrap_or(0); + (id.clone(), activity) + }) + .min_by_key(|(_, a)| *a) + .unwrap_or((String::new(), 0)); + if !oldest_id.is_empty() { + if let Some(entry) = guard.remove(&oldest_id) { + let mut w = entry.worker.lock().await; + w.kill().await; + } + } + } + + /// Call a method on the app's Worker. Spawns the worker if needed; caller must provide policy_json. + pub async fn call( + &self, + app_id: &str, + worker_revision: &str, + policy_json: &str, + permissions: Option<&NodePermissions>, + method: &str, + params: Value, + ) -> BitFunResult { + let worker = self + .get_or_spawn(app_id, worker_revision, policy_json, permissions) + .await?; + let timeout_ms = permissions + .and_then(|n| n.timeout_ms) + .unwrap_or(30_000); + let guard = worker.lock().await; + guard.call(method, params, timeout_ms).await.map_err(BitFunError::validation) + } + + /// Stop and remove the Worker for the app. + pub async fn stop(&self, app_id: &str) { + let mut guard = self.workers.lock().await; + if let Some(entry) = guard.remove(app_id) { + let mut w = entry.worker.lock().await; + w.kill().await; + } + } + + /// Return app IDs of currently running Workers. + pub async fn list_running(&self) -> Vec { + let guard = self.workers.lock().await; + guard.keys().cloned().collect() + } + + pub async fn is_running(&self, app_id: &str) -> bool { + let guard = self.workers.lock().await; + guard.contains_key(app_id) + } + + /// Stop all Workers. + pub async fn stop_all(&self) { + let mut guard = self.workers.lock().await; + for (_, entry) in guard.drain() { + let mut w = entry.worker.lock().await; + w.kill().await; + } + } + + pub fn has_installed_deps(&self, app_id: &str) -> bool { + self.path_manager.miniapp_dir(app_id).join("node_modules").exists() + } + + /// Install npm dependencies for the app (bun install or npm/pnpm install). + pub async fn install_deps(&self, app_id: &str, _deps: &[NpmDep]) -> BitFunResult { + let app_dir = self.path_manager.miniapp_dir(app_id); + let package_json = app_dir.join("package.json"); + if !package_json.exists() { + return Ok(InstallResult { + success: true, + stdout: String::new(), + stderr: String::new(), + }); + } + + let (cmd, args): (&str, &[&str]) = match self.runtime.kind { + crate::miniapp::runtime_detect::RuntimeKind::Bun => { + ("bun", &["install", "--production"][..]) + } + crate::miniapp::runtime_detect::RuntimeKind::Node => { + if which::which("pnpm").is_ok() { + ("pnpm", &["install", "--prod"][..]) + } else { + ("npm", &["install", "--production"][..]) + } + } + }; + + let output = Command::new(cmd) + .args(args) + .current_dir(&app_dir) + .output() + .await + .map_err(|e| BitFunError::io(format!("install_deps failed: {}", e)))?; + + Ok(InstallResult { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs new file mode 100644 index 00000000..e9929335 --- /dev/null +++ b/src/crates/core/src/miniapp/manager.rs @@ -0,0 +1,568 @@ +//! MiniApp manager — CRUD, version management, compile on save (V2: no permission guard, policy for Worker). + +use crate::miniapp::compiler::compile; +use crate::miniapp::permission_policy::resolve_policy; +use crate::miniapp::storage::MiniAppStorage; +use crate::miniapp::types::{ + MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, +}; +use crate::util::errors::BitFunResult; +use chrono::Utc; +use once_cell::sync::OnceCell; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +static GLOBAL_MINIAPP_MANAGER: OnceCell> = OnceCell::new(); + +/// Initialize the global MiniAppManager (called once at startup from Tauri app_state). +pub fn initialize_global_miniapp_manager(manager: Arc) { + let _ = GLOBAL_MINIAPP_MANAGER.set(manager); +} + +/// Get the global MiniAppManager, returning None if not initialized. +pub fn try_get_global_miniapp_manager() -> Option> { + GLOBAL_MINIAPP_MANAGER.get().cloned() +} + +/// MiniApp manager: create, read, update, delete, list, compile, rollback. +pub struct MiniAppManager { + storage: MiniAppStorage, + path_manager: Arc, + /// Current workspace root (for permission policy resolution). + workspace_path: RwLock>, + /// User-granted paths per app (for resolve_policy). + granted_paths: RwLock>>, +} + +impl MiniAppManager { + pub fn new(path_manager: Arc) -> Self { + let storage = MiniAppStorage::new(path_manager.clone()); + Self { + storage, + path_manager, + workspace_path: RwLock::new(None), + granted_paths: RwLock::new(HashMap::new()), + } + } + + fn build_source_revision(version: u32, updated_at: i64) -> String { + format!("src:{version}:{updated_at}") + } + + fn build_deps_revision(source: &MiniAppSource) -> String { + let mut deps: Vec = source + .npm_dependencies + .iter() + .map(|dep| format!("{}@{}", dep.name, dep.version)) + .collect(); + deps.sort(); + deps.join("|") + } + + fn build_runtime_state( + version: u32, + updated_at: i64, + source: &MiniAppSource, + deps_dirty: bool, + worker_restart_required: bool, + ) -> MiniAppRuntimeState { + MiniAppRuntimeState { + source_revision: Self::build_source_revision(version, updated_at), + deps_revision: Self::build_deps_revision(source), + deps_dirty, + worker_restart_required, + ui_recompile_required: false, + } + } + + fn ensure_runtime_state(app: &mut MiniApp) -> bool { + let mut changed = false; + if app.runtime.source_revision.is_empty() { + app.runtime.source_revision = Self::build_source_revision(app.version, app.updated_at); + changed = true; + } + let deps_revision = Self::build_deps_revision(&app.source); + if app.runtime.deps_revision != deps_revision { + app.runtime.deps_revision = deps_revision; + changed = true; + } + changed + } + + pub fn build_worker_revision(&self, app: &MiniApp, policy_json: &str) -> String { + format!( + "{}::{}::{}", + app.runtime.source_revision, app.runtime.deps_revision, policy_json + ) + } + + /// Set current workspace path (for permission policy resolution). + pub async fn set_workspace_path(&self, path: Option) { + let mut guard = self.workspace_path.write().await; + *guard = path; + } + + /// List all MiniApp metadata. + pub async fn list(&self) -> BitFunResult> { + let ids = self.storage.list_app_ids().await?; + let mut metas = Vec::with_capacity(ids.len()); + for id in ids { + if let Ok(meta) = self.storage.load_meta(&id).await { + metas.push(meta); + } + } + metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(metas) + } + + /// Get full MiniApp by id. + pub async fn get(&self, app_id: &str) -> BitFunResult { + let mut app = self.storage.load(app_id).await?; + if Self::ensure_runtime_state(&mut app) { + self.storage.save(&app).await?; + } + Ok(app) + } + + /// Create a new MiniApp (generates id, sets created_at/updated_at, compiles). + pub async fn create( + &self, + name: String, + description: String, + icon: String, + category: String, + tags: Vec, + source: MiniAppSource, + permissions: MiniAppPermissions, + ai_context: Option, + ) -> BitFunResult { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp_millis(); + + let app_data_dir = self.path_manager.miniapp_dir(&id); + let app_data_dir_str = app_data_dir.to_string_lossy().to_string(); + let workspace_dir = self + .workspace_path + .read() + .await + .clone() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| String::new()); + + let compiled_html = compile( + &source, + &permissions, + &id, + &app_data_dir_str, + &workspace_dir, + "dark", + )?; + let runtime = Self::build_runtime_state( + 1, + now, + &source, + !source.npm_dependencies.is_empty(), + true, + ); + + let app = MiniApp { + id: id.clone(), + name, + description, + icon, + category, + tags, + version: 1, + created_at: now, + updated_at: now, + source, + compiled_html, + permissions, + ai_context, + runtime, + }; + + self.storage.save(&app).await?; + Ok(app) + } + + /// Update existing MiniApp (increment version, recompile, save). + pub async fn update( + &self, + app_id: &str, + name: Option, + description: Option, + icon: Option, + category: Option, + tags: Option>, + source: Option, + permissions: Option, + ai_context: Option, + ) -> BitFunResult { + let mut app = self.storage.load(app_id).await?; + let previous_app = app.clone(); + let source_changed = source.is_some(); + let permissions_changed = permissions.is_some(); + + if let Some(n) = name { + app.name = n; + } + if let Some(d) = description { + app.description = d; + } + if let Some(i) = icon { + app.icon = i; + } + if let Some(c) = category { + app.category = c; + } + if let Some(t) = tags { + app.tags = t; + } + if let Some(s) = source { + app.source = s; + } + if let Some(p) = permissions { + app.permissions = p; + } + if let Some(a) = ai_context { + app.ai_context = Some(a); + } + + app.version += 1; + app.updated_at = Utc::now().timestamp_millis(); + + let app_data_dir = self.path_manager.miniapp_dir(app_id); + let app_data_dir_str = app_data_dir.to_string_lossy().to_string(); + let workspace_dir = self + .workspace_path + .read() + .await + .clone() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| String::new()); + + app.compiled_html = compile( + &app.source, + &app.permissions, + app_id, + &app_data_dir_str, + &workspace_dir, + "dark", + )?; + let deps_changed = previous_app.source.npm_dependencies != app.source.npm_dependencies; + if source_changed || permissions_changed { + app.runtime.source_revision = Self::build_source_revision(app.version, app.updated_at); + app.runtime.worker_restart_required = true; + } + if deps_changed { + app.runtime.deps_revision = Self::build_deps_revision(&app.source); + app.runtime.deps_dirty = !app.source.npm_dependencies.is_empty(); + app.runtime.worker_restart_required = true; + } + app.runtime.ui_recompile_required = false; + Self::ensure_runtime_state(&mut app); + + self.storage + .save_version(app_id, previous_app.version, &previous_app) + .await?; + self.storage.save(&app).await?; + Ok(app) + } + + /// Delete MiniApp and its directory. + pub async fn delete(&self, app_id: &str) -> BitFunResult<()> { + self.granted_paths.write().await.remove(app_id); + self.storage.delete(app_id).await + } + + /// Get the path manager (for external callers that need paths like miniapp_dir). + pub fn path_manager(&self) -> &Arc { + &self.path_manager + } + + /// Resolve permission policy for the given app (for JS Worker startup). + pub async fn resolve_policy_for_app(&self, app_id: &str, permissions: &MiniAppPermissions) -> serde_json::Value { + let app_data_dir = self.path_manager.miniapp_dir(app_id); + let wp = self.workspace_path.read().await; + let workspace_dir = wp.as_deref(); + let gp = self.granted_paths.read().await; + let granted = gp.get(app_id).map(|v| v.as_slice()).unwrap_or(&[]); + resolve_policy(permissions, app_id, &app_data_dir, workspace_dir, granted) + } + + /// Grant workspace access for an app (no-op; workspace is set by host). + pub async fn grant_workspace(&self, _app_id: &str) {} + + /// Grant path (user-selected) for an app. + pub async fn grant_path(&self, app_id: &str, path: PathBuf) { + let mut guard = self.granted_paths.write().await; + let list = guard.entry(app_id.to_string()).or_default(); + if !list.contains(&path) { + list.push(path); + } + } + + /// Get app storage (KV) value. + pub async fn get_storage(&self, app_id: &str, key: &str) -> BitFunResult { + let storage = self.storage.load_app_storage(app_id).await?; + Ok(storage + .get(key) + .cloned() + .unwrap_or(serde_json::Value::Null)) + } + + /// Set app storage (KV) value. + pub async fn set_storage( + &self, + app_id: &str, + key: &str, + value: serde_json::Value, + ) -> BitFunResult<()> { + self.storage.save_app_storage(app_id, key, value).await + } + + pub async fn mark_deps_installed(&self, app_id: &str) -> BitFunResult { + let mut app = self.storage.load(app_id).await?; + Self::ensure_runtime_state(&mut app); + app.runtime.deps_dirty = false; + app.runtime.worker_restart_required = true; + self.storage.save(&app).await?; + Ok(app) + } + + pub async fn clear_worker_restart_required(&self, app_id: &str) -> BitFunResult { + let mut app = self.storage.load(app_id).await?; + Self::ensure_runtime_state(&mut app); + if app.runtime.worker_restart_required { + app.runtime.worker_restart_required = false; + self.storage.save(&app).await?; + } + Ok(app) + } + + /// List version numbers for an app. + pub async fn list_versions(&self, app_id: &str) -> BitFunResult> { + self.storage.list_versions(app_id).await + } + + /// Rollback app to a previous version (loads version snapshot, saves as current). + pub async fn rollback(&self, app_id: &str, version: u32) -> BitFunResult { + let current = self.storage.load(app_id).await?; + let mut app = self.storage.load_version(app_id, version).await?; + let now = Utc::now().timestamp_millis(); + app.version = current.version + 1; + app.updated_at = now; + app.runtime = Self::build_runtime_state( + app.version, + app.updated_at, + &app.source, + !app.source.npm_dependencies.is_empty(), + true, + ); + self.storage + .save_version(app_id, current.version, ¤t) + .await?; + self.storage.save(&app).await?; + Ok(app) + } + + /// Recompile app (e.g. after workspace or theme change). Updates compiled_html and saves. + pub async fn recompile(&self, app_id: &str, theme: &str) -> BitFunResult { + let mut app = self.storage.load(app_id).await?; + let app_data_dir = self.path_manager.miniapp_dir(app_id); + let app_data_dir_str = app_data_dir.to_string_lossy().to_string(); + let workspace_dir = self + .workspace_path + .read() + .await + .clone() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| String::new()); + + app.compiled_html = compile( + &app.source, + &app.permissions, + app_id, + &app_data_dir_str, + &workspace_dir, + theme, + )?; + app.updated_at = Utc::now().timestamp_millis(); + Self::ensure_runtime_state(&mut app); + app.runtime.ui_recompile_required = false; + self.storage.save(&app).await?; + Ok(app) + } + + pub async fn sync_from_fs(&self, app_id: &str, theme: &str) -> BitFunResult { + let previous_app = self.storage.load(app_id).await?; + let mut app = previous_app.clone(); + app.source = self.storage.load_source_only(app_id).await?; + app.version += 1; + app.updated_at = Utc::now().timestamp_millis(); + + let app_data_dir = self.path_manager.miniapp_dir(app_id); + let app_data_dir_str = app_data_dir.to_string_lossy().to_string(); + let workspace_dir = self + .workspace_path + .read() + .await + .clone() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(String::new); + + app.compiled_html = compile( + &app.source, + &app.permissions, + app_id, + &app_data_dir_str, + &workspace_dir, + theme, + )?; + app.runtime = Self::build_runtime_state( + app.version, + app.updated_at, + &app.source, + !app.source.npm_dependencies.is_empty(), + true, + ); + self.storage + .save_version(app_id, previous_app.version, &previous_app) + .await?; + self.storage.save(&app).await?; + Ok(app) + } + + /// Import a MiniApp from a directory (e.g. miniapps/git-graph). Copies meta, source, package.json, storage into a new app id and recompiles. + pub async fn import_from_path(&self, source_path: PathBuf) -> BitFunResult { + use crate::util::errors::BitFunError; + + let src = source_path.as_path(); + if !src.is_dir() { + return Err(BitFunError::validation(format!( + "Not a directory: {}", + src.display() + ))); + } + + let meta_path = src.join("meta.json"); + let source_dir = src.join("source"); + if !meta_path.exists() { + return Err(BitFunError::validation(format!( + "Missing meta.json in {}", + src.display() + ))); + } + if !source_dir.is_dir() { + return Err(BitFunError::validation(format!( + "Missing source/ directory in {}", + src.display() + ))); + } + for required in &["index.html", "style.css", "ui.js", "worker.js"] { + if !source_dir.join(required).exists() { + return Err(BitFunError::validation(format!( + "Missing source/{} in {}", + required, + src.display() + ))); + } + } + + let meta_content = tokio::fs::read_to_string(&meta_path) + .await + .map_err(|e| BitFunError::io(format!("Failed to read meta.json: {}", e)))?; + let mut meta: MiniAppMeta = serde_json::from_str(&meta_content) + .map_err(|e| BitFunError::parse(format!("Invalid meta.json: {}", e)))?; + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp_millis(); + meta.id = id.clone(); + meta.created_at = now; + meta.updated_at = now; + + let dest_dir = self.path_manager.miniapp_dir(&id); + let dest_source = dest_dir.join("source"); + tokio::fs::create_dir_all(&dest_source) + .await + .map_err(|e| BitFunError::io(format!("Failed to create app dir: {}", e)))?; + + let meta_json = serde_json::to_string_pretty(&meta).map_err(BitFunError::from)?; + tokio::fs::write(dest_dir.join("meta.json"), meta_json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write meta.json: {}", e)))?; + + for name in &["index.html", "style.css", "ui.js", "worker.js"] { + let from = source_dir.join(name); + let to = dest_source.join(name); + if from.exists() { + tokio::fs::copy(&from, &to) + .await + .map_err(|e| BitFunError::io(format!("Failed to copy {}: {}", name, e)))?; + } + } + let esm_path = source_dir.join("esm_dependencies.json"); + if esm_path.exists() { + tokio::fs::copy(&esm_path, dest_source.join("esm_dependencies.json")) + .await + .map_err(|e| BitFunError::io(format!("Failed to copy esm_dependencies.json: {}", e)))?; + } else { + tokio::fs::write( + dest_source.join("esm_dependencies.json"), + "[]", + ) + .await + .map_err(|_e| BitFunError::io("Failed to write esm_dependencies.json"))?; + } + + let pkg_src = src.join("package.json"); + if pkg_src.exists() { + tokio::fs::copy(&pkg_src, dest_dir.join("package.json")) + .await + .map_err(|e| BitFunError::io(format!("Failed to copy package.json: {}", e)))?; + } else { + let pkg = serde_json::json!({ + "name": format!("miniapp-{}", id), + "private": true, + "dependencies": {} + }); + tokio::fs::write( + dest_dir.join("package.json"), + serde_json::to_string_pretty(&pkg).map_err(BitFunError::from)?, + ) + .await + .map_err(|_e| BitFunError::io("Failed to write package.json"))?; + } + + let storage_src = src.join("storage.json"); + if storage_src.exists() { + tokio::fs::copy(&storage_src, dest_dir.join("storage.json")) + .await + .map_err(|e| BitFunError::io(format!("Failed to copy storage.json: {}", e)))?; + } else { + tokio::fs::write(dest_dir.join("storage.json"), "{}") + .await + .map_err(|_e| BitFunError::io("Failed to write storage.json"))?; + } + + let placeholder_html = "Loading..."; + tokio::fs::write(dest_dir.join("compiled.html"), placeholder_html) + .await + .map_err(|_e| BitFunError::io("Failed to write placeholder compiled.html"))?; + + let mut app = self.recompile(&id, "dark").await?; + app.runtime = Self::build_runtime_state( + app.version, + app.updated_at, + &app.source, + !app.source.npm_dependencies.is_empty(), + true, + ); + self.storage.save(&app).await?; + Ok(app) + } +} diff --git a/src/crates/core/src/miniapp/mod.rs b/src/crates/core/src/miniapp/mod.rs new file mode 100644 index 00000000..74a1d1f5 --- /dev/null +++ b/src/crates/core/src/miniapp/mod.rs @@ -0,0 +1,23 @@ +//! MiniApp module — V2: ESM UI + Node Worker, Runtime Adapter, permission policy. + +pub mod bridge_builder; +pub mod compiler; +pub mod exporter; +pub mod js_worker; +pub mod js_worker_pool; +pub mod manager; +pub mod permission_policy; +pub mod runtime_detect; +pub mod storage; +pub mod types; + +pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, MiniAppExporter}; +pub use js_worker_pool::{InstallResult, JsWorkerPool}; +pub use manager::{MiniAppManager, initialize_global_miniapp_manager, try_get_global_miniapp_manager}; +pub use permission_policy::resolve_policy; +pub use runtime_detect::{DetectedRuntime, RuntimeKind}; +pub use storage::MiniAppStorage; +pub use types::{ + EsmDep, FsPermissions, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, + NpmDep, NodePermissions, NetPermissions, PathScope, ShellPermissions, +}; diff --git a/src/crates/core/src/miniapp/permission_policy.rs b/src/crates/core/src/miniapp/permission_policy.rs new file mode 100644 index 00000000..2487e60e --- /dev/null +++ b/src/crates/core/src/miniapp/permission_policy.rs @@ -0,0 +1,107 @@ +//! Permission policy — resolve manifest permissions to JSON policy for JS Worker. + +use crate::miniapp::types::{MiniAppPermissions, PathScope}; +use serde_json::{Map, Value}; +use std::path::Path; + +/// Resolve permission manifest to a JSON policy object passed to the Worker as startup argument. +/// Path variables {appdata}, {workspace}, {home} are resolved to absolute paths. +/// `granted_paths` are user-granted paths (e.g. from grant_path) to include in read+write. +pub fn resolve_policy( + perms: &MiniAppPermissions, + app_id: &str, + app_data_dir: &Path, + workspace_dir: Option<&Path>, + granted_paths: &[std::path::PathBuf], +) -> Value { + let mut policy = Map::new(); + + if let Some(ref fs) = perms.fs { + let read = resolve_fs_scopes( + fs.read.as_deref().unwrap_or(&[]), + app_id, + app_data_dir, + workspace_dir, + ); + let write = resolve_fs_scopes( + fs.write.as_deref().unwrap_or(&[]), + app_id, + app_data_dir, + workspace_dir, + ); + let mut read_paths: Vec = read.into_iter().collect(); + let mut write_paths: Vec = write.into_iter().collect(); + for gp in granted_paths { + if let Some(s) = gp.to_str() { + read_paths.push(s.to_string()); + write_paths.push(s.to_string()); + } + } + if !read_paths.is_empty() || !write_paths.is_empty() { + let mut fs_map = Map::new(); + fs_map.insert( + "read".to_string(), + Value::Array(read_paths.into_iter().map(Value::String).collect()), + ); + fs_map.insert( + "write".to_string(), + Value::Array(write_paths.into_iter().map(Value::String).collect()), + ); + policy.insert("fs".to_string(), Value::Object(fs_map)); + } + } + + if let Some(ref shell) = perms.shell { + let allow = shell + .allow + .as_ref() + .map(|v| { + Value::Array(v.iter().map(|s| Value::String(s.clone())).collect()) + }) + .unwrap_or_else(|| Value::Array(Vec::new())); + policy.insert("shell".to_string(), serde_json::json!({ "allow": allow })); + } + + if let Some(ref net) = perms.net { + let allow = net + .allow + .as_ref() + .map(|v| { + Value::Array(v.iter().map(|s| Value::String(s.clone())).collect()) + }) + .unwrap_or_else(|| Value::Array(Vec::new())); + policy.insert("net".to_string(), serde_json::json!({ "allow": allow })); + } + + Value::Object(policy) +} + +fn resolve_fs_scopes( + scopes: &[String], + _app_id: &str, + app_data_dir: &Path, + workspace_dir: Option<&Path>, +) -> Vec { + let mut result = Vec::with_capacity(scopes.len()); + for s in scopes { + let scope = PathScope::from_manifest_value(s); + let paths = match &scope { + PathScope::AppData => vec![app_data_dir.to_path_buf()], + PathScope::Workspace => workspace_dir.map(|p| p.to_path_buf()).into_iter().collect(), + PathScope::UserSelected | PathScope::Home => { + if let PathScope::Home = scope { + dirs::home_dir().into_iter().collect() + } else { + Vec::new() + } + } + PathScope::Custom(paths) => paths.clone(), + }; + for p in paths { + if let Some(s) = p.to_str() { + result.push(s.to_string()); + } + } + } + result +} diff --git a/src/crates/core/src/miniapp/runtime_detect.rs b/src/crates/core/src/miniapp/runtime_detect.rs new file mode 100644 index 00000000..4595294f --- /dev/null +++ b/src/crates/core/src/miniapp/runtime_detect.rs @@ -0,0 +1,55 @@ +//! Runtime detection — Bun first, Node.js fallback for JS Worker. + +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuntimeKind { + Bun, + Node, +} + +#[derive(Debug, Clone)] +pub struct DetectedRuntime { + pub kind: RuntimeKind, + pub path: PathBuf, + pub version: String, +} + +/// Detect available JS runtime: Bun first, then Node.js. Returns None if neither is available. +pub fn detect_runtime() -> Option { + if let Ok(bun_path) = which::which("bun") { + if let Ok(version) = get_version(&bun_path) { + return Some(DetectedRuntime { + kind: RuntimeKind::Bun, + path: bun_path, + version, + }); + } + } + if let Ok(node_path) = which::which("node") { + if let Ok(version) = get_version(&node_path) { + return Some(DetectedRuntime { + kind: RuntimeKind::Node, + path: node_path, + version, + }); + } + } + None +} + +fn get_version(executable: &std::path::Path) -> Result { + let out = Command::new(executable) + .arg("--version") + .output()?; + if out.status.success() { + let v = String::from_utf8_lossy(&out.stdout); + Ok(v.trim().to_string()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "version check failed", + )) + } +} diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs new file mode 100644 index 00000000..9bdf1857 --- /dev/null +++ b/src/crates/core/src/miniapp/storage.rs @@ -0,0 +1,377 @@ +//! MiniApp storage — persist and load MiniApp data under user data dir (V2: ui.js, worker.js, package.json). + +use crate::miniapp::types::{MiniApp, MiniAppMeta, MiniAppSource, NpmDep}; +use crate::util::errors::{BitFunError, BitFunResult}; +use serde_json; +use std::path::PathBuf; +use std::sync::Arc; + +const META_JSON: &str = "meta.json"; +const SOURCE_DIR: &str = "source"; +const INDEX_HTML: &str = "index.html"; +const STYLE_CSS: &str = "style.css"; +const UI_JS: &str = "ui.js"; +const WORKER_JS: &str = "worker.js"; +const PACKAGE_JSON: &str = "package.json"; +const ESM_DEPS_JSON: &str = "esm_dependencies.json"; +const COMPILED_HTML: &str = "compiled.html"; +const STORAGE_JSON: &str = "storage.json"; +const VERSIONS_DIR: &str = "versions"; + +/// MiniApp storage service (file-based under path_manager.miniapps_dir). +pub struct MiniAppStorage { + path_manager: Arc, +} + +impl MiniAppStorage { + pub fn new(path_manager: Arc) -> Self { + Self { path_manager } + } + + fn app_dir(&self, app_id: &str) -> PathBuf { + self.path_manager.miniapp_dir(app_id) + } + + fn meta_path(&self, app_id: &str) -> PathBuf { + self.app_dir(app_id).join(META_JSON) + } + + fn source_dir(&self, app_id: &str) -> PathBuf { + self.app_dir(app_id).join(SOURCE_DIR) + } + + fn compiled_path(&self, app_id: &str) -> PathBuf { + self.app_dir(app_id).join(COMPILED_HTML) + } + + fn storage_path(&self, app_id: &str) -> PathBuf { + self.app_dir(app_id).join(STORAGE_JSON) + } + + fn version_path(&self, app_id: &str, version: u32) -> PathBuf { + self.app_dir(app_id) + .join(VERSIONS_DIR) + .join(format!("v{}.json", version)) + } + + /// Ensure app directory and source subdir exist. + pub async fn ensure_app_dir(&self, app_id: &str) -> BitFunResult<()> { + let dir = self.app_dir(app_id); + let source = self.source_dir(app_id); + tokio::fs::create_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!("Failed to create miniapp dir {}: {}", dir.display(), e)) + })?; + tokio::fs::create_dir_all(&source).await.map_err(|e| { + BitFunError::io(format!("Failed to create source dir {}: {}", source.display(), e)) + })?; + Ok(()) + } + + /// List all app IDs (directories under miniapps_dir). + pub async fn list_app_ids(&self) -> BitFunResult> { + let root = self.path_manager.miniapps_dir(); + if !root.exists() { + return Ok(Vec::new()); + } + let mut ids = Vec::new(); + let mut read_dir = tokio::fs::read_dir(&root).await.map_err(|e| { + BitFunError::io(format!("Failed to read miniapps dir: {}", e)) + })?; + while let Some(entry) = read_dir.next_entry().await.map_err(|e| { + BitFunError::io(format!("Failed to read miniapps entry: {}", e)) + })? { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if !name.starts_with('.') { + ids.push(name.to_string()); + } + } + } + } + Ok(ids) + } + + /// Load full MiniApp by id (meta + source + compiled_html). + pub async fn load(&self, app_id: &str) -> BitFunResult { + let meta_path = self.meta_path(app_id); + let meta_content = tokio::fs::read_to_string(&meta_path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + BitFunError::NotFound(format!("MiniApp not found: {}", app_id)) + } else { + BitFunError::io(format!("Failed to read meta: {}", e)) + } + })?; + let meta: MiniAppMeta = serde_json::from_str(&meta_content) + .map_err(|e| BitFunError::parse(format!("Invalid meta.json: {}", e)))?; + + let source = self.load_source(app_id).await?; + let compiled_html = self.load_compiled_html(app_id).await?; + + Ok(MiniApp { + id: meta.id, + name: meta.name, + description: meta.description, + icon: meta.icon, + category: meta.category, + tags: meta.tags, + version: meta.version, + created_at: meta.created_at, + updated_at: meta.updated_at, + source, + compiled_html, + permissions: meta.permissions, + ai_context: meta.ai_context, + runtime: meta.runtime, + }) + } + + /// Load only metadata (for list views). + pub async fn load_meta(&self, app_id: &str) -> BitFunResult { + let meta_path = self.meta_path(app_id); + let content = tokio::fs::read_to_string(&meta_path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + BitFunError::NotFound(format!("MiniApp not found: {}", app_id)) + } else { + BitFunError::io(format!("Failed to read meta: {}", e)) + } + })?; + serde_json::from_str(&content).map_err(|e| { + BitFunError::parse(format!("Invalid meta.json: {}", e)) + }) + } + + async fn load_source(&self, app_id: &str) -> BitFunResult { + let sd = self.source_dir(app_id); + let html = tokio::fs::read_to_string(sd.join(INDEX_HTML)) + .await + .unwrap_or_default(); + let css = tokio::fs::read_to_string(sd.join(STYLE_CSS)) + .await + .unwrap_or_default(); + let ui_js = tokio::fs::read_to_string(sd.join(UI_JS)) + .await + .unwrap_or_default(); + let worker_js = tokio::fs::read_to_string(sd.join(WORKER_JS)) + .await + .unwrap_or_default(); + + let esm_dependencies = if sd.join(ESM_DEPS_JSON).exists() { + let c = tokio::fs::read_to_string(sd.join(ESM_DEPS_JSON)) + .await + .unwrap_or_default(); + serde_json::from_str(&c).unwrap_or_default() + } else { + Vec::new() + }; + + let npm_dependencies = self.load_npm_dependencies(app_id).await?; + + Ok(MiniAppSource { + html, + css, + ui_js, + esm_dependencies, + worker_js, + npm_dependencies, + }) + } + + /// Load only source files and package dependencies from disk. + pub async fn load_source_only(&self, app_id: &str) -> BitFunResult { + self.load_source(app_id).await + } + + async fn load_npm_dependencies(&self, app_id: &str) -> BitFunResult> { + let p = self.app_dir(app_id).join(PACKAGE_JSON); + if !p.exists() { + return Ok(Vec::new()); + } + let c = tokio::fs::read_to_string(&p).await.map_err(|e| { + BitFunError::io(format!("Failed to read package.json: {}", e)) + })?; + let pkg: serde_json::Value = serde_json::from_str(&c) + .map_err(|e| BitFunError::parse(format!("Invalid package.json: {}", e)))?; + let empty = serde_json::Map::new(); + let deps = pkg + .get("dependencies") + .and_then(|d| d.as_object()) + .unwrap_or(&empty); + let npm_dependencies: Vec = deps + .iter() + .map(|(name, v)| NpmDep { + name: name.clone(), + version: v.as_str().unwrap_or("*").to_string(), + }) + .collect(); + Ok(npm_dependencies) + } + + async fn load_compiled_html(&self, app_id: &str) -> BitFunResult { + let p = self.compiled_path(app_id); + tokio::fs::read_to_string(&p).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + BitFunError::NotFound(format!("Compiled HTML not found: {}", app_id)) + } else { + BitFunError::io(format!("Failed to read compiled.html: {}", e)) + } + }) + } + + /// Save full MiniApp (meta, source files, compiled.html). + pub async fn save(&self, app: &MiniApp) -> BitFunResult<()> { + self.ensure_app_dir(&app.id).await?; + + let meta = MiniAppMeta::from(app); + let meta_path = self.meta_path(&app.id); + let meta_json = serde_json::to_string_pretty(&meta).map_err(BitFunError::from)?; + tokio::fs::write(&meta_path, meta_json).await.map_err(|e| { + BitFunError::io(format!("Failed to write meta: {}", e)) + })?; + + let sd = self.source_dir(&app.id); + tokio::fs::write(sd.join(INDEX_HTML), &app.source.html).await.map_err(|e| { + BitFunError::io(format!("Failed to write index.html: {}", e)) + })?; + tokio::fs::write(sd.join(STYLE_CSS), &app.source.css).await.map_err(|e| { + BitFunError::io(format!("Failed to write style.css: {}", e)) + })?; + tokio::fs::write(sd.join(UI_JS), &app.source.ui_js).await.map_err(|e| { + BitFunError::io(format!("Failed to write ui.js: {}", e)) + })?; + tokio::fs::write(sd.join(WORKER_JS), &app.source.worker_js).await.map_err(|e| { + BitFunError::io(format!("Failed to write worker.js: {}", e)) + })?; + + let esm_json = + serde_json::to_string_pretty(&app.source.esm_dependencies).map_err(BitFunError::from)?; + tokio::fs::write(sd.join(ESM_DEPS_JSON), esm_json).await.map_err(|e| { + BitFunError::io(format!("Failed to write esm_dependencies.json: {}", e)) + })?; + + self.write_package_json(&app.id, &app.source.npm_dependencies) + .await?; + + tokio::fs::write(self.compiled_path(&app.id), &app.compiled_html) + .await + .map_err(|e| BitFunError::io(format!("Failed to write compiled.html: {}", e)))?; + + Ok(()) + } + + async fn write_package_json(&self, app_id: &str, deps: &[NpmDep]) -> BitFunResult<()> { + let mut dependencies = serde_json::Map::new(); + for d in deps { + dependencies.insert(d.name.clone(), serde_json::Value::String(d.version.clone())); + } + let pkg = serde_json::json!({ + "name": format!("miniapp-{}", app_id), + "private": true, + "dependencies": dependencies + }); + let p = self.app_dir(app_id).join(PACKAGE_JSON); + let json = serde_json::to_string_pretty(&pkg).map_err(BitFunError::from)?; + tokio::fs::write(&p, json).await.map_err(|e| { + BitFunError::io(format!("Failed to write package.json: {}", e)) + })?; + Ok(()) + } + + /// Save a version snapshot (for rollback). + pub async fn save_version(&self, app_id: &str, version: u32, app: &MiniApp) -> BitFunResult<()> { + let versions_dir = self.app_dir(app_id).join(VERSIONS_DIR); + tokio::fs::create_dir_all(&versions_dir).await.map_err(|e| { + BitFunError::io(format!("Failed to create versions dir: {}", e)) + })?; + let path = self.version_path(app_id, version); + let json = serde_json::to_string_pretty(app).map_err(BitFunError::from)?; + tokio::fs::write(&path, json).await.map_err(|e| { + BitFunError::io(format!("Failed to write version file: {}", e)) + })?; + Ok(()) + } + + /// Load app storage (KV JSON). Returns empty object if missing. + pub async fn load_app_storage(&self, app_id: &str) -> BitFunResult { + let p = self.storage_path(app_id); + if !p.exists() { + return Ok(serde_json::json!({})); + } + let c = tokio::fs::read_to_string(&p).await.map_err(|e| { + BitFunError::io(format!("Failed to read storage: {}", e)) + })?; + Ok(serde_json::from_str(&c).unwrap_or_else(|_| serde_json::json!({}))) + } + + /// Save app storage (merge with existing or replace). + pub async fn save_app_storage( + &self, + app_id: &str, + key: &str, + value: serde_json::Value, + ) -> BitFunResult<()> { + self.ensure_app_dir(app_id).await?; + let mut current = self.load_app_storage(app_id).await?; + let obj = current.as_object_mut().ok_or_else(|| { + BitFunError::validation("App storage is not an object".to_string()) + })?; + obj.insert(key.to_string(), value); + let p = self.storage_path(app_id); + let json = serde_json::to_string_pretty(¤t).map_err(BitFunError::from)?; + tokio::fs::write(&p, json).await.map_err(|e| { + BitFunError::io(format!("Failed to write storage: {}", e)) + })?; + Ok(()) + } + + /// Delete MiniApp directory entirely. + pub async fn delete(&self, app_id: &str) -> BitFunResult<()> { + let dir = self.app_dir(app_id); + if dir.exists() { + tokio::fs::remove_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!("Failed to delete miniapp dir: {}", e)) + })?; + } + Ok(()) + } + + /// List version numbers that have snapshots. + pub async fn list_versions(&self, app_id: &str) -> BitFunResult> { + let vdir = self.app_dir(app_id).join(VERSIONS_DIR); + if !vdir.exists() { + return Ok(Vec::new()); + } + let mut versions = Vec::new(); + let mut read_dir = tokio::fs::read_dir(&vdir).await.map_err(|e| { + BitFunError::io(format!("Failed to read versions dir: {}", e)) + })?; + while let Some(entry) = read_dir.next_entry().await.map_err(|e| { + BitFunError::io(format!("Failed to read versions entry: {}", e)) + })? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with('v') && name.ends_with(".json") { + if let Ok(n) = name[1..name.len() - 5].parse::() { + versions.push(n); + } + } + } + versions.sort(); + Ok(versions) + } + + /// Load a specific version snapshot. + pub async fn load_version(&self, app_id: &str, version: u32) -> BitFunResult { + let p = self.version_path(app_id, version); + let c = tokio::fs::read_to_string(&p).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + BitFunError::NotFound(format!("Version v{} not found", version)) + } else { + BitFunError::io(format!("Failed to read version: {}", e)) + } + })?; + serde_json::from_str(&c).map_err(|e| { + BitFunError::parse(format!("Invalid version file: {}", e)) + }) + } +} diff --git a/src/crates/core/src/miniapp/types.rs b/src/crates/core/src/miniapp/types.rs new file mode 100644 index 00000000..0c27a223 --- /dev/null +++ b/src/crates/core/src/miniapp/types.rs @@ -0,0 +1,204 @@ +//! MiniApp types — data model and permissions (V2: ESM UI + Node Worker). + +use serde::{Deserialize, Serialize}; + +/// ESM dependency for Import Map (browser UI). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EsmDep { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// NPM dependency for Worker (package.json). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpmDep { + pub name: String, + pub version: String, +} + +/// MiniApp source: UI layer (browser) + Worker layer (Node.js). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MiniAppSource { + pub html: String, + pub css: String, + /// ESM module code running in the browser. + #[serde(rename = "ui_js")] + pub ui_js: String, + #[serde(default, rename = "esm_dependencies")] + pub esm_dependencies: Vec, + /// Node.js Worker logic (source/worker.js). + #[serde(rename = "worker_js")] + pub worker_js: String, + #[serde(default, rename = "npm_dependencies")] + pub npm_dependencies: Vec, +} + +/// Permissions manifest (resolved to policy for JS Worker). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MiniAppPermissions { + #[serde(skip_serializing_if = "Option::is_none")] + pub fs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub net: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub node: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FsPermissions { + /// Path scopes: "{appdata}", "{workspace}", "{home}", or absolute paths. + #[serde(skip_serializing_if = "Option::is_none")] + pub read: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub write: Option>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ShellPermissions { + /// Command allowlist (e.g. ["git", "ffmpeg"]). Empty = all forbidden. + #[serde(skip_serializing_if = "Option::is_none")] + pub allow: Option>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NetPermissions { + /// Domain allowlist. "*" = all. + #[serde(skip_serializing_if = "Option::is_none")] + pub allow: Option>, +} + +/// Node.js Worker permissions (memory, timeout). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NodePermissions { + #[serde(default = "default_node_enabled")] + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_memory_mb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +fn default_node_enabled() -> bool { + true +} + +/// AI context for iteration (stored in meta, not in compiled HTML). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MiniAppAiContext { + pub original_prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(default)] + pub iteration_history: Vec, +} + +/// Runtime lifecycle state persisted in meta.json. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct MiniAppRuntimeState { + /// Revision used for UI / source lifecycle changes. + pub source_revision: String, + /// Revision derived from npm dependencies. + pub deps_revision: String, + /// Dependencies changed and need install before reliable worker startup. + pub deps_dirty: bool, + /// Worker should be restarted on next runtime use. + pub worker_restart_required: bool, + /// UI assets should be recompiled before next render. + pub ui_recompile_required: bool, +} + +/// Full MiniApp entity (in-memory / API). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MiniApp { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + pub category: String, + #[serde(default)] + pub tags: Vec, + pub version: u32, + pub created_at: i64, + pub updated_at: i64, + + pub source: MiniAppSource, + /// Assembled HTML with Import Map + Runtime Adapter (generated by compiler). + pub compiled_html: String, + + #[serde(default)] + pub permissions: MiniAppPermissions, + + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_context: Option, + + #[serde(default)] + pub runtime: MiniAppRuntimeState, +} + +/// MiniApp metadata only (for list views; no source/compiled_html). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MiniAppMeta { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + pub category: String, + #[serde(default)] + pub tags: Vec, + pub version: u32, + pub created_at: i64, + pub updated_at: i64, + #[serde(default)] + pub permissions: MiniAppPermissions, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_context: Option, + #[serde(default)] + pub runtime: MiniAppRuntimeState, +} + +impl From<&MiniApp> for MiniAppMeta { + fn from(app: &MiniApp) -> Self { + Self { + id: app.id.clone(), + name: app.name.clone(), + description: app.description.clone(), + icon: app.icon.clone(), + category: app.category.clone(), + tags: app.tags.clone(), + version: app.version, + created_at: app.created_at, + updated_at: app.updated_at, + permissions: app.permissions.clone(), + ai_context: app.ai_context.clone(), + runtime: app.runtime.clone(), + } + } +} + +/// Path scope for permission policy resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathScope { + AppData, + Workspace, + UserSelected, + Home, + Custom(Vec), +} + +impl PathScope { + pub fn from_manifest_value(s: &str) -> Self { + match s { + "{appdata}" => PathScope::AppData, + "{workspace}" => PathScope::Workspace, + "{user-selected}" => PathScope::UserSelected, + "{home}" => PathScope::Home, + _ => PathScope::Custom(vec![std::path::PathBuf::from(s)]), + } + } +} diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 0cc04397..71cd29ae 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -30,6 +30,7 @@ import ShellHubSection from './sections/shell-hub/ShellHubSection'; import GitSection from './sections/git/GitSection'; import TeamSection from './sections/team/TeamSection'; import SkillsSection from './sections/skills/SkillsSection'; +import ToolboxSection from './sections/toolbox/ToolboxSection'; import WorkspaceHeader from './components/WorkspaceHeader'; import { useSceneStore } from '../../stores/sceneStore'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; @@ -48,6 +49,7 @@ const INLINE_SECTIONS: Partial> = { git: GitSection, team: TeamSection, skills: SkillsSection, + toolbox: ToolboxSection, }; type DepartDir = 'up' | 'anchor' | 'down' | null; diff --git a/src/web-ui/src/app/components/NavPanel/config.ts b/src/web-ui/src/app/components/NavPanel/config.ts index 13dc4172..c9d5c474 100644 --- a/src/web-ui/src/app/components/NavPanel/config.ts +++ b/src/web-ui/src/app/components/NavPanel/config.ts @@ -19,6 +19,7 @@ import { Users, SquareTerminal, Puzzle, + Sparkles, } from 'lucide-react'; import type { NavSection } from './types'; @@ -84,6 +85,15 @@ export const NAV_SECTIONS: NavSection[] = [ collapsible: true, defaultExpanded: false, items: [ + { + tab: 'toolbox', + labelKey: 'nav.items.miniApps', + tooltipKey: 'nav.tooltips.toolbox', + Icon: Sparkles, + behavior: 'scene', + sceneId: 'toolbox', + inlineExpandable: true, + }, { tab: 'git', labelKey: 'nav.items.git', diff --git a/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.scss b/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.scss new file mode 100644 index 00000000..52ddaf25 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.scss @@ -0,0 +1,27 @@ +@use '../../../../../component-library/styles/tokens.scss' as *; + +.bitfun-nav-panel__inline-item-action-btn--toolbox { + border: none; + background: transparent; + color: var(--color-text-muted); + box-shadow: none; + transition: color $motion-fast $easing-standard, + opacity $motion-fast $easing-standard; + + &:hover, + &:active { + background: transparent; + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 1px solid var(--color-accent-500); + outline-offset: 1px; + } +} + +@media (prefers-reduced-motion: reduce) { + .bitfun-nav-panel__inline-item-action-btn--toolbox { + transition: none; + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.tsx new file mode 100644 index 00000000..d986702a --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.tsx @@ -0,0 +1,189 @@ +/** + * ToolboxSection — inline list under the Mini App nav item. + * + * Prioritises three things: + * - open the Mini App gallery + * - quick access to running apps + * - visibility for recently opened apps that still have tabs + */ +import React, { useCallback, useEffect } from 'react'; +import { FolderPlus, Puzzle, Sparkles, Circle, Square } from 'lucide-react'; +import { useSceneManager } from '@/app/hooks/useSceneManager'; +import { useToolboxStore } from '@/app/scenes/toolbox/toolboxStore'; +import { useMiniAppList } from '@/app/scenes/toolbox/hooks/useMiniAppList'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { Tooltip } from '@/component-library'; +import type { SceneTabId } from '@/app/components/SceneBar/types'; +import './ToolboxSection.scss'; + +const ToolboxSection: React.FC = () => { + const { t } = useI18n('common'); + const { openScene, activateScene, closeScene, openTabs } = useSceneManager(); + useMiniAppList(); + const openedAppIds = useToolboxStore((s) => s.openedAppIds); + const runningWorkerIds = useToolboxStore((s) => s.runningWorkerIds); + const apps = useToolboxStore((s) => s.apps); + const markWorkerStopped = useToolboxStore((s) => s.markWorkerStopped); + const visibleAppIds = Array.from(new Set([...runningWorkerIds, ...openedAppIds])); + + useEffect(() => { + miniAppAPI.workerListRunning().then((ids) => { + useToolboxStore.getState().setRunningWorkerIds(ids); + }).catch(() => {}); + }, []); + + const openTabIds = new Set(openTabs.map((tab) => tab.id)); + + const handleOpenGallery = useCallback(() => { + openScene('toolbox'); + }, [openScene]); + + const runningApps = visibleAppIds.filter((appId) => runningWorkerIds.includes(appId)); + const openedOnlyApps = visibleAppIds.filter((appId) => !runningWorkerIds.includes(appId)); + + const handleRowClick = useCallback( + (appId: string) => { + const tabId: SceneTabId = `miniapp:${appId}`; + if (openTabIds.has(tabId)) { + activateScene(tabId); + } else { + openScene(tabId); + } + }, + [openTabIds, openScene, activateScene] + ); + + const handleStop = useCallback( + async (appId: string, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await miniAppAPI.workerStop(appId); + markWorkerStopped(appId); + const tabId: SceneTabId = `miniapp:${appId}`; + if (openTabIds.has(tabId)) { + closeScene(tabId); + } + } catch { + markWorkerStopped(appId); + const tabId: SceneTabId = `miniapp:${appId}`; + if (openTabIds.has(tabId)) { + closeScene(tabId); + } + } + }, + [markWorkerStopped, closeScene, openTabIds] + ); + + return ( +
+
+ + +
+ + {visibleAppIds.length === 0 ? ( +
+ {t('nav.toolbox.noApps')} +
+ ) : ( + <> + {runningApps.length > 0 && ( + <> +
+ {t('nav.toolbox.runningApps')} +
+ {runningApps.map((appId) => { + const app = apps.find((a) => a.id === appId); + const name = app?.name ?? appId; + return ( +
handleRowClick(appId)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(appId); + } + }} + title={name} + > + + {name} + +
+ + + +
+
+ ); + })} + + )} + + {openedOnlyApps.length > 0 && ( + <> +
+ {t('nav.toolbox.myApps')} +
+ {openedOnlyApps.map((appId) => { + const app = apps.find((a) => a.id === appId); + const name = app?.name ?? appId; + return ( +
handleRowClick(appId)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(appId); + } + }} + title={name} + > + + {name} +
+ ); + })} + + )} + + )} +
+ ); +}; + +export default ToolboxSection; diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 2f57981d..9ef86683 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -5,7 +5,7 @@ import type { LucideIcon } from 'lucide-react'; /** Scene tab identifier — max 3 open at a time */ -export type SceneTabId = 'welcome' | 'session' | 'terminal' | 'git' | 'settings' | 'file-viewer' | 'profile' | 'team' | 'skills'; +export type SceneTabId = 'welcome' | 'session' | 'terminal' | 'git' | 'settings' | 'file-viewer' | 'profile' | 'team' | 'skills' | 'toolbox' | `miniapp:${string}`; /** Static definition (from registry) for a scene tab type */ export interface SceneTabDef { @@ -14,8 +14,12 @@ export interface SceneTabDef { /** i18n key under common.scenes — when provided, SceneBar will translate instead of using label */ labelKey?: string; Icon?: LucideIcon; - /** Pinned tabs are always open and cannot be closed */ + /** @deprecated Prefer fixed + closable. Pinned tabs cannot be closed and were protected from eviction. */ pinned: boolean; + /** If true, tab is always kept and never evicted by capacity policy (e.g. agent/session). */ + fixed?: boolean; + /** If false, user cannot close the tab. Default true for non-fixed scenes. */ + closable?: boolean; /** Only one instance allowed */ singleton: boolean; /** Open on app start */ @@ -25,6 +29,8 @@ export interface SceneTabDef { /** Runtime instance of an open scene tab */ export interface SceneTab { id: SceneTabId; - /** Last-used timestamp for LRU eviction */ + /** First-open timestamp for FIFO eviction (oldest replaceable tab is evicted). */ + openedAt: number; + /** Last-used timestamp for activate/close fallback (e.g. which tab to activate after close). */ lastUsed: number; } diff --git a/src/web-ui/src/app/hooks/useSceneManager.ts b/src/web-ui/src/app/hooks/useSceneManager.ts index 7e7cfaa4..09c86f1c 100644 --- a/src/web-ui/src/app/hooks/useSceneManager.ts +++ b/src/web-ui/src/app/hooks/useSceneManager.ts @@ -5,9 +5,10 @@ * write to the same Zustand store, so state is always in sync. */ -import { SCENE_TAB_REGISTRY } from '../scenes/registry'; +import { SCENE_TAB_REGISTRY, getMiniAppSceneDef } from '../scenes/registry'; import type { SceneTabDef } from '../components/SceneBar/types'; import { useSceneStore } from '../stores/sceneStore'; +import { useToolboxStore } from '../scenes/toolbox/toolboxStore'; export interface UseSceneManagerReturn { openTabs: ReturnType['openTabs']; @@ -20,11 +21,20 @@ export interface UseSceneManagerReturn { export function useSceneManager(): UseSceneManagerReturn { const { openTabs, activeTabId, activateScene, openScene, closeScene } = useSceneStore(); + const apps = useToolboxStore((s) => s.apps); + + const miniAppDefs: SceneTabDef[] = openTabs + .filter((t) => typeof t.id === 'string' && t.id.startsWith('miniapp:')) + .map((t) => { + const appId = (t.id as string).slice('miniapp:'.length); + const app = apps.find((a) => a.id === appId); + return getMiniAppSceneDef(appId, app?.name); + }); return { openTabs, activeTabId, - tabDefs: SCENE_TAB_REGISTRY, + tabDefs: [...SCENE_TAB_REGISTRY, ...miniAppDefs], activateScene, openScene, closeScene, diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index 0a81ade9..beb1119a 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -9,7 +9,7 @@ */ import React, { Suspense, lazy } from 'react'; -import { MessageSquare, Terminal, GitBranch, Settings, FileCode2, CircleUserRound, Puzzle } from 'lucide-react'; +import { MessageSquare, Terminal, GitBranch, Settings, FileCode2, CircleUserRound, Puzzle, Wrench } from 'lucide-react'; import type { SceneTabId } from '../components/SceneBar/types'; import { useSceneManager } from '../hooks/useSceneManager'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; @@ -23,7 +23,9 @@ const FileViewerScene = lazy(() => import('./file-viewer/FileViewerScene')); const ProfileScene = lazy(() => import('./profile/ProfileScene')); const TeamScene = lazy(() => import('./team/TeamScene')); const SkillsScene = lazy(() => import('./skills/SkillsScene')); +const ToolboxScene = lazy(() => import('./toolbox/ToolboxScene')); const WelcomeScene = lazy(() => import('./welcome/WelcomeScene')); +const MiniAppScene = lazy(() => import('./toolbox/MiniAppScene')); interface SceneViewportProps { workspacePath?: string; @@ -49,6 +51,7 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering { id: 'file-viewer' as SceneTabId, Icon: FileCode2, labelKey: 'scenes.fileViewer' }, { id: 'profile' as SceneTabId, Icon: CircleUserRound, labelKey: 'scenes.projectContext' }, { id: 'skills' as SceneTabId, Icon: Puzzle, labelKey: 'scenes.skills' }, + { id: 'toolbox' as SceneTabId, Icon: Wrench, labelKey: 'scenes.toolbox' }, ].map(({ id, Icon, labelKey }) => { const label = t(labelKey); return ( @@ -108,7 +111,12 @@ function renderScene(id: SceneTabId, workspacePath?: string, isEntering?: boolea return ; case 'skills': return ; + case 'toolbox': + return ; default: + if (typeof id === 'string' && id.startsWith('miniapp:')) { + return ; + } return null; } } diff --git a/src/web-ui/src/app/scenes/registry.ts b/src/web-ui/src/app/scenes/registry.ts index 545b65d4..65836ec3 100644 --- a/src/web-ui/src/app/scenes/registry.ts +++ b/src/web-ui/src/app/scenes/registry.ts @@ -7,7 +7,7 @@ * - pinned = false: can be auto-evicted and manually closed. */ -import { MessageSquare, Terminal, GitBranch, Settings, FileCode2, CircleUserRound, Blocks, Users, Puzzle } from 'lucide-react'; +import { MessageSquare, Terminal, GitBranch, Settings, FileCode2, CircleUserRound, Blocks, Users, Puzzle, Wrench } from 'lucide-react'; import type { SceneTabDef, SceneTabId } from '../components/SceneBar/types'; export const MAX_OPEN_SCENES = 3; @@ -96,8 +96,32 @@ export const SCENE_TAB_REGISTRY: SceneTabDef[] = [ singleton: true, defaultOpen: false, }, + { + id: 'toolbox' as SceneTabId, + label: 'Toolbox', + labelKey: 'scenes.toolbox', + Icon: Wrench, + pinned: false, + singleton: true, + defaultOpen: false, + }, ]; export function getSceneDef(id: SceneTabId): SceneTabDef | undefined { return SCENE_TAB_REGISTRY.find(d => d.id === id); } + +/** Dynamic scene def for a MiniApp tab (used by SceneBar and useSceneManager). */ +export function getMiniAppSceneDef(appId: string, appName?: string): SceneTabDef { + const id: SceneTabId = `miniapp:${appId}`; + return { + id, + label: appName ?? appId, + Icon: Puzzle, + pinned: false, + fixed: false, + closable: true, + singleton: false, + defaultOpen: false, + }; +} diff --git a/src/web-ui/src/app/scenes/toolbox/MiniAppScene.scss b/src/web-ui/src/app/scenes/toolbox/MiniAppScene.scss new file mode 100644 index 00000000..2a7092b4 --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/MiniAppScene.scss @@ -0,0 +1,99 @@ +/** + * MiniAppScene styles + */ +@use '../../../component-library/styles/tokens' as *; + +.miniapp-scene { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-width: 0; + height: 100%; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + min-height: 40px; + } + + &__header-center { + flex: 1; + min-width: 0; + } + + &__title { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + + &--loading { + color: var(--color-text-muted); + font-weight: $font-weight-normal; + } + } + + &__header-actions { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-shrink: 0; + } + + &__content { + flex: 1; + overflow: hidden; + position: relative; + } + + &__loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: $size-gap-3; + color: var(--color-text-muted); + font-size: $font-size-sm; + } + + &__error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: $size-gap-3; + color: var(--color-text-muted); + font-size: $font-size-sm; + text-align: center; + padding: $size-gap-6; + + > svg { + color: var(--color-warning); + } + + p { + margin: 0; + max-width: 320px; + line-height: $line-height-relaxed; + } + } + + &__spinning { + animation: miniapp-scene-spin 0.8s linear infinite; + } +} + +@keyframes miniapp-scene-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/web-ui/src/app/scenes/toolbox/MiniAppScene.tsx b/src/web-ui/src/app/scenes/toolbox/MiniAppScene.tsx new file mode 100644 index 00000000..97078388 --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/MiniAppScene.tsx @@ -0,0 +1,166 @@ +/** + * MiniAppScene — standalone scene tab for a single MiniApp. + * Mounts MiniAppRunner; close via SceneBar × (does not stop worker). + */ +import React, { useEffect, useState } from 'react'; +import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react'; +import { useToolboxStore } from './toolboxStore'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; +import { createLogger } from '@/shared/utils/logger'; +import { IconButton, Button } from '@/component-library'; +import { useSceneManager } from '@/app/hooks/useSceneManager'; +import './MiniAppScene.scss'; + +const log = createLogger('MiniAppScene'); + +const MiniAppRunner = React.lazy(() => import('./components/MiniAppRunner')); + +interface MiniAppSceneProps { + appId: string; +} + +const MiniAppScene: React.FC = ({ appId }) => { + const openApp = useToolboxStore((s) => s.openApp); + const closeApp = useToolboxStore((s) => s.closeApp); + const { themeType } = useTheme(); + const { closeScene } = useSceneManager(); + + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [key, setKey] = useState(0); + + useEffect(() => { + openApp(appId); + return () => { + closeApp(appId); + }; + }, [appId, openApp, closeApp]); + + const load = async (id: string) => { + setLoading(true); + setError(null); + try { + const theme = themeType ?? 'dark'; + const loaded = await miniAppAPI.getMiniApp(id, theme); + setApp(loaded); + } catch (err) { + log.error('Failed to load app', err); + setError(String(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (appId) { + load(appId); + } + }, [appId, themeType]); + + useEffect(() => { + const tabId = `miniapp:${appId}`; + const shouldHandle = (payload?: { id?: string }) => payload?.id === appId; + + const unlistenUpdated = api.listen<{ id?: string }>('miniapp-updated', (payload) => { + if (shouldHandle(payload)) { + setKey((k) => k + 1); + void load(appId); + } + }); + const unlistenRecompiled = api.listen<{ id?: string }>('miniapp-recompiled', (payload) => { + if (shouldHandle(payload)) { + setKey((k) => k + 1); + void load(appId); + } + }); + const unlistenRolledBack = api.listen<{ id?: string }>('miniapp-rolled-back', (payload) => { + if (shouldHandle(payload)) { + setKey((k) => k + 1); + void load(appId); + } + }); + const unlistenRestarted = api.listen<{ id?: string }>('miniapp-worker-restarted', (payload) => { + if (shouldHandle(payload)) { + setKey((k) => k + 1); + void load(appId); + } + }); + const unlistenDeleted = api.listen<{ id?: string }>('miniapp-deleted', (payload) => { + if (shouldHandle(payload)) { + closeScene(tabId); + } + }); + + return () => { + unlistenUpdated(); + unlistenRecompiled(); + unlistenRolledBack(); + unlistenRestarted(); + unlistenDeleted(); + }; + }, [appId, closeScene]); + + const handleReload = () => { + if (appId) { + setKey((k) => k + 1); + load(appId); + } + }; + + return ( +
+
+
+ {app ? ( + {app.name} + ) : ( + MiniApp + )} +
+
+ + {loading ? ( + + ) : ( + + )} + +
+
+
+ {loading && !app && ( +
+ + 加载中… +
+ )} + {error && ( +
+ +

加载失败:{error}

+ +
+ )} + {app && !loading && ( + + + + )} +
+
+ ); +}; + +export default MiniAppScene; diff --git a/src/web-ui/src/app/scenes/toolbox/ToolboxScene.scss b/src/web-ui/src/app/scenes/toolbox/ToolboxScene.scss new file mode 100644 index 00000000..b4882806 --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/ToolboxScene.scss @@ -0,0 +1,7 @@ +.toolbox-scene { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + overflow: hidden; +} diff --git a/src/web-ui/src/app/scenes/toolbox/ToolboxScene.tsx b/src/web-ui/src/app/scenes/toolbox/ToolboxScene.tsx new file mode 100644 index 00000000..d8ea4e6f --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/ToolboxScene.tsx @@ -0,0 +1,49 @@ +/** + * ToolboxScene — Toolbox tab showing the MiniApp gallery. + * Opening an app opens a separate scene tab (miniapp:id). + */ +import React, { Suspense, lazy, useEffect } from 'react'; +import { useMiniAppList } from './hooks/useMiniAppList'; +import { useToolboxStore } from './toolboxStore'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; +import './ToolboxScene.scss'; + +const GalleryView = lazy(() => import('./views/GalleryView')); + +const ToolboxScene: React.FC = () => { + useMiniAppList(); + const setRunningWorkerIds = useToolboxStore((s) => s.setRunningWorkerIds); + const markWorkerRunning = useToolboxStore((s) => s.markWorkerRunning); + const markWorkerStopped = useToolboxStore((s) => s.markWorkerStopped); + + useEffect(() => { + miniAppAPI.workerListRunning().then(setRunningWorkerIds).catch(() => {}); + + const unlistenRestarted = api.listen<{ id?: string }>('miniapp-worker-restarted', (payload) => { + if (payload?.id) markWorkerRunning(payload.id); + }); + const unlistenStopped = api.listen<{ id?: string }>('miniapp-worker-stopped', (payload) => { + if (payload?.id) markWorkerStopped(payload.id); + }); + const unlistenDeleted = api.listen<{ id?: string }>('miniapp-deleted', (payload) => { + if (payload?.id) markWorkerStopped(payload.id); + }); + + return () => { + unlistenRestarted(); + unlistenStopped(); + unlistenDeleted(); + }; + }, [setRunningWorkerIds, markWorkerRunning, markWorkerStopped]); + + return ( +
+ + + +
+ ); +}; + +export default ToolboxScene; diff --git a/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss new file mode 100644 index 00000000..9193a702 --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss @@ -0,0 +1,236 @@ +@use '../../../../component-library/styles/tokens' as *; + +.miniapp-card { + display: flex; + flex-direction: column; + border-radius: $size-radius-lg; + background: var(--element-bg-soft); + border: none; + cursor: pointer; + position: relative; + overflow: hidden; + animation: miniapp-card-in 0.28s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 40ms); + transition: + background $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + box-shadow: + 0 6px 20px rgba(0, 0, 0, 0.2), + 0 2px 6px rgba(59, 130, 246, 0.07); + transform: translateY(-2px); + + .miniapp-card__overlay { + opacity: 1; + } + } + + &:active { + transform: translateY(0); + transition-duration: $motion-instant; + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 2px; + } + + &--running { + background: color-mix(in srgb, #34d399 7%, var(--element-bg-soft)); + box-shadow: + 0 10px 28px rgba(0, 0, 0, 0.22), + 0 0 0 1px rgba(52, 211, 153, 0.22); + + &:hover { + background: color-mix(in srgb, #34d399 10%, var(--element-bg-medium)); + } + } + + &__icon-area { + position: relative; + height: 90px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + ellipse at 25% 15%, + rgba(255, 255, 255, 0.1) 0%, + transparent 65% + ); + pointer-events: none; + z-index: 1; + } + } + + &__icon { + color: rgba(255, 255, 255, 0.88); + position: relative; + z-index: 2; + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.35)); + } + + &__running-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 3; + height: 20px; + padding: 0 8px; + border-radius: $size-radius-full; + font-size: 10px; + font-weight: $font-weight-semibold; + color: #0e7490; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(52, 211, 153, 0.36); + display: inline-flex; + align-items: center; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 90px; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + display: flex; + align-items: center; + justify-content: center; + gap: $size-gap-2; + opacity: 0; + transition: opacity $motion-fast $easing-standard; + z-index: 4; + } + + &__overlay-btn { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: $size-radius-full; + border: none; + cursor: pointer; + transition: + background $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + + &--primary { + background: $color-accent-500; + color: #fff; + + &:hover { + background: $color-accent-600; + transform: scale(1.1); + } + } + + &--danger { + background: rgba($color-error, 0.82); + color: #fff; + border: none; + + &:hover { + background: $color-error; + transform: scale(1.1); + } + } + } + + &__info { + flex: 1; + padding: $size-gap-2 $size-gap-3; + min-width: 0; + border-top: none; + padding-bottom: $size-gap-4; + } + + &__name { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $line-height-tight; + margin-bottom: 3px; + } + + &__desc { + font-size: $font-size-xs; + color: var(--color-text-muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: $line-height-base; + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: $size-gap-1; + margin-top: $size-gap-1; + } + + &__version { + position: absolute; + bottom: $size-gap-2; + right: $size-gap-2; + font-size: 10px; + padding: 1px 5px; + border-radius: $size-radius-sm; + background: var(--element-bg-medium); + color: var(--color-text-muted); + font-family: $font-family-mono; + pointer-events: none; + letter-spacing: 0.02em; + line-height: 1.4; + } +} + +@keyframes miniapp-card-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +:root[data-theme-type='light'] { + .miniapp-card { + &__version { + background: var(--element-bg-base); + } + + &__running-badge { + color: #065f46; + background: rgba(255, 255, 255, 0.95); + border-color: rgba(16, 185, 129, 0.34); + } + } +} + +@media (prefers-reduced-motion: reduce) { + .miniapp-card { + animation: none; + transition: none; + } +} diff --git a/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx new file mode 100644 index 00000000..2a5ce3e5 --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { Play, Trash2 } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { Tag } from '@/component-library'; +import './MiniAppCard.scss'; + +interface MiniAppCardProps { + app: MiniAppMeta; + index?: number; + isRunning?: boolean; + onOpen: (id: string) => void; + onDelete: (id: string) => void; +} + +function getIcon(name: string): React.ReactNode { + const key = name + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') as keyof typeof LucideIcons; + const Icon = LucideIcons[key] as React.ElementType | undefined; + return Icon ? : ; +} + +function getIconGradient(icon: string): string { + const gradients = [ + 'linear-gradient(135deg, rgba(59,130,246,0.35) 0%, rgba(139,92,246,0.25) 100%)', + 'linear-gradient(135deg, rgba(16,185,129,0.3) 0%, rgba(59,130,246,0.25) 100%)', + 'linear-gradient(135deg, rgba(245,158,11,0.3) 0%, rgba(239,68,68,0.2) 100%)', + 'linear-gradient(135deg, rgba(139,92,246,0.35) 0%, rgba(236,72,153,0.2) 100%)', + 'linear-gradient(135deg, rgba(6,182,212,0.3) 0%, rgba(59,130,246,0.25) 100%)', + 'linear-gradient(135deg, rgba(239,68,68,0.25) 0%, rgba(245,158,11,0.2) 100%)', + ]; + const idx = (icon.charCodeAt(0) || 0) % gradients.length; + return gradients[idx]; +} + +const MiniAppCard: React.FC = ({ + app, + index = 0, + isRunning = false, + onOpen, + onDelete, +}) => { + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(app.id); + }; + + const handleOpenClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onOpen(app.id); + }; + + return ( +
onOpen(app.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && onOpen(app.id)} + aria-label={`Open ${app.name}`} + > +
+
{getIcon(app.icon || 'box')}
+ {isRunning && 运行中} +
+ +
+ + +
+ +
+
{app.name}
+ {app.description &&
{app.description}
} + {app.tags.length > 0 && ( +
+ {app.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} +
+ )} +
+ +
v{app.version}
+
+ ); +}; + +export default MiniAppCard; + diff --git a/src/web-ui/src/app/scenes/toolbox/components/MiniAppRunner.tsx b/src/web-ui/src/app/scenes/toolbox/components/MiniAppRunner.tsx new file mode 100644 index 00000000..b8bf9b1f --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/components/MiniAppRunner.tsx @@ -0,0 +1,30 @@ +/** + * MiniAppRunner — sandboxed iframe that runs a compiled MiniApp. + * Injects the bridge script (already in compiledHtml from Rust compiler) + * and handles all postMessage RPC via useMiniAppBridge. + */ +import React, { useRef } from 'react'; +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useMiniAppBridge } from '../hooks/useMiniAppBridge'; + +interface MiniAppRunnerProps { + app: MiniApp; +} + +const MiniAppRunner: React.FC = ({ app }) => { + const iframeRef = useRef(null); + useMiniAppBridge(iframeRef, app); + + return ( +