From 0eb5a167e254d339056f67c092eb404f6a6d3a2b Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:13:24 +0800 Subject: [PATCH 1/7] Add git-tree: local Git commit graph viewer MiniApp that inspects a local Git repository's commit history: - Swim-lane commit graph (single SVG canvas, ported from zai-org/ZCode) - Branch / tag / working-tree status from parallel git calls - Inline (collapsible) and right-pane (always-on) commit detail - Filter state, theme, and refresh cadence persisted to /prefs.json - Portability fallback: on a fresh install, scans home dev directories and every mounted drive root on Windows to discover repos without any repos.json configuration Tested environment: MiniMax Code desktop 3.0.73.166 on Windows 10.0.26200 (x64). macOS and Linux not verified. --- README.md | 10 + README.zh-CN.md | 10 + .../git-tree/.minimax-plugin/plugin.json | 18 + plugins/microbiosis/git-tree/LICENSE | 197 ++ plugins/microbiosis/git-tree/README.md | 73 + plugins/microbiosis/git-tree/README.zh-CN.md | 73 + plugins/microbiosis/git-tree/docs/preview.jpg | Bin 0 -> 131267 bytes plugins/microbiosis/git-tree/icon.png | Bin 0 -> 1574 bytes .../git-tree/miniapp/client/index.html | 1857 +++++++++++++++++ .../microbiosis/git-tree/miniapp/miniapp.json | 20 + .../git-tree/miniapp/node/git-graph.mjs | 433 ++++ .../git-tree/miniapp/node/repos.json | 5 + .../git-tree/miniapp/node/server.mjs | 1063 ++++++++++ plugins/microbiosis/git-tree/package.json | 6 + 14 files changed, 3765 insertions(+) create mode 100644 plugins/microbiosis/git-tree/.minimax-plugin/plugin.json create mode 100644 plugins/microbiosis/git-tree/LICENSE create mode 100644 plugins/microbiosis/git-tree/README.md create mode 100644 plugins/microbiosis/git-tree/README.zh-CN.md create mode 100644 plugins/microbiosis/git-tree/docs/preview.jpg create mode 100644 plugins/microbiosis/git-tree/icon.png create mode 100644 plugins/microbiosis/git-tree/miniapp/client/index.html create mode 100644 plugins/microbiosis/git-tree/miniapp/miniapp.json create mode 100644 plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs create mode 100644 plugins/microbiosis/git-tree/miniapp/node/repos.json create mode 100644 plugins/microbiosis/git-tree/miniapp/node/server.mjs create mode 100644 plugins/microbiosis/git-tree/package.json diff --git a/README.md b/README.md index 4d8d32c..0b465d8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Token Usage Board](plugins/yanhy2000/mcode-usage-monitor/) | Watch local Token usage, output speed, and cache hit rate in near real time; filter by time range, model, and session | [yanhy2000](https://github.com/yanhy2000) | | [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) | | [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | **Official plugin** for planning driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) | +| [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [redmingwei](https://github.com/redmingwei) |
Preview: Token Usage Board @@ -82,6 +83,15 @@ The preview uses synthetic data. The app interface is currently in Chinese. See
+
+Preview: Git Commit Tree + +![Git Commit Tree showing a swim-lane commit graph with synthetic data](plugins/microbiosis/git-tree/docs/preview.jpg) + +The preview uses synthetic data. The app interface is currently in Chinese. See its [README](plugins/microbiosis/git-tree/README.md) for repository discovery, layout algorithm attribution, and compatibility notes. + +
+ ## Getting started ### Requirements diff --git a/README.zh-CN.md b/README.zh-CN.md index aed133e..d2680b7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,6 +64,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Token 用量看板](plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md) | 近实时查看本机 Token 用量、输出速度与缓存命中率,可按时间范围、模型和会话筛选 | [yanhy2000](https://github.com/yanhy2000) | | [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) | | [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) | +| [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [redmingwei](https://github.com/redmingwei) |
预览:Token 用量看板 @@ -83,6 +84,15 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求
+
+预览:Git 提交树 + +![Git 提交树,使用合成数据展示泳道提交图](plugins/microbiosis/git-tree/docs/preview.jpg) + +预览使用合成数据,应用界面目前为中文。仓库发现规则、泳道算法来源与兼容性说明见作品 [README](plugins/microbiosis/git-tree/README.zh-CN.md)。 + +
+ ## 开始使用 ### 使用要求 diff --git a/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json b/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json new file mode 100644 index 0000000..1b1b7b4 --- /dev/null +++ b/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "name": "git-tree", + "displayName": "Git 提交树", + "version": "1.1.0", + "description": "查看本机 git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计。", + "author": "redmingwei", + "icon": "icon.png", + "category": "Other", + "exampleQueries": [ + "看一下这个仓库的提交树", + "显示最近的 git 提交历史", + "查看提交详情和文件改动" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/microbiosis/git-tree/LICENSE b/plugins/microbiosis/git-tree/LICENSE new file mode 100644 index 0000000..a41c15c --- /dev/null +++ b/plugins/microbiosis/git-tree/LICENSE @@ -0,0 +1,197 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensed or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of tracking or otherwise improving the Work, + but excludes communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work or + a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Microbiosis + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + The git-graph lane-assignment algorithm and pixel layout in + miniapp/node/git-graph.mjs is a JavaScript port of: + packages/ui/src/git-graph/layoutAlgorithm.ts + packages/ui/src/git-graph/layout.ts + from the upstream repository https://github.com/zai-org/ZCode (Apache-2.0). + Original authors: Z.ai / zai-org contributors. diff --git a/plugins/microbiosis/git-tree/README.md b/plugins/microbiosis/git-tree/README.md new file mode 100644 index 0000000..bad0d8e --- /dev/null +++ b/plugins/microbiosis/git-tree/README.md @@ -0,0 +1,73 @@ +# Git Commit Tree + +[简体中文](README.zh-CN.md) | English + +Inspect a local Git repository's commit history from MiniMax Code: swim-lane commit graph, branches and tags, commit detail and per-file change stats, with optional auto-refresh and persisted filter preferences. + +Author: Microbiosis · Version: 1.1.0 + +![Git commit tree preview](docs/preview.jpg) + +## Install & use + +Copy this directory into the active MiniMax Code data directory under `plugins/`. `` defaults to `~/.minimax`, so the default install path is: + +```text +/plugins/git-tree/ +``` + +Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code with Mini App support, confirm the plugin is recognized, then open "Git 提交树" — either from the plugin list or by asking the assistant. + +The page loads the first 200 commits of the active repo (configurable via `repos.json`). Use the ref dropdown to filter by branch or tag, the search box to match commit subjects, the author box to match authors, and the "Auto refresh" dropdown to keep the view in sync with new pushes. + +## What it shows + +| Area | Source | Detail | +|---|---|---| +| Stats grid | `git rev-list --all --count`, `for-each-ref`, `tag --list`, `status --porcelain` | Total commits, branches, tags, working-tree changes | +| Worktree chips | `git status --porcelain` | Current branch + the first 6 changed paths | +| Commit graph | `git log --topo-order` + custom lane algorithm (ported from `zai-org/ZCode`) | Single SVG canvas: paths, dots, selection ring, hover highlight | +| Commit rows | `git log` + `git tag --contains` | 4-column grid (refs + subject / date / author / hash); refs chip with `is-head` and `is-tag` styling | +| Inline detail (toggle) | server-side `/api/commit` | Subject, refs, file changes table, body — appears below the list and can be collapsed | +| Right detail (always on) | server-side `/api/commit` | Same payload as inline; selection and inline are independent | + +## Data sources & caching + +- **Repos**: `repos.json` ships with the plugin empty by design. Each install must declare its own local paths — either by listing them in the `repos` array or by setting `scanRoots` to one or more directories whose direct children contain `.git`. The Node entry walks up from the plugin root and process cwd, then one directory level of every `scanRoots` entry, and validates each candidate by checking for a `.git` directory. **Portability fallback**: when neither `repos.json` nor the walk-up produced any scan base, the registry also looks under `~/Code`, `~/Projects`, `~/repos`, `~/workspace`, `~/src`, `~/source`, `~/dev`, `~/work`, `~/Documents`, `~/git` (case-insensitive on Windows/macOS). On Windows it additionally walks every mounted drive root (`A:\` … `Z:\`) because developers routinely keep projects on a non-OS drive. Windows system hives (`Program Files`, `Windows`, `Users`, `ProgramData`, `$Recycle.Bin`, …) and macOS resource dirs (`Library`, `Applications`, `System`) are filtered out so widening to drive roots cannot recurse into `%ProgramFiles%`. The fallback only kicks in when there is no other discovery source, so users with an explicit `repos.json` are unaffected. +- **Stats**: 7 parallel `git` commands (`log -n 1`, `rev-list --all --count`, two `for-each-ref`, `tag --list`, `status`, `log -n 1 --format=%D`), cached for **30 s** per repo. +- **Graph**: `git log --topo-order` with optional `--grep` and `--author` filters, capped at **400** rows per call. Layout result is server-validated; the client renders it as a single SVG. +- **Commit detail**: `git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`, cached **60 s** per `(repo, sha)`. The client mirrors this cache for 60 s as well, so re-clicking a row is free. +- **Tags for graph**: `git log --all --simplify-by-decoration --format=%H %D`, cached 60 s per repo. +- **Compression**: API responses ≥ 256 bytes are gzipped when the client advertises `Accept-Encoding: gzip`. +- **Timeouts**: quick reads (for-each-ref, tag --list, contains checks) 10 s; medium reads (log -n 1, status, git show) 15 s; heavy reads (log --topo-order --all, rev-list --all --count) 30 s. Client `api()` caps at 35 s with `AbortController`. + +## Preferences + +Filter state and theme are written to `/prefs.json` and restored on the next open: + +| Key | Type | Default | Notes | +|---|---|---|---| +| `theme` | `auto` \| `light` \| `dark` | `auto` | `auto` follows `prefers-color-scheme` | +| `repo` | absolute path | first repo | Falls back to the registry default if the saved path is no longer present | +| `ref` | string | `all` | `all`, branch name, or tag name | +| `q` | string | `""` | substring search on commit subject | +| `author` | string | `""` | matches author name or email | +| `interval` | `0` \| `5` \| `10` \| `30` \| `60` | `0` | Auto-refresh interval in seconds | + +The `/api/prefs` endpoint is GET for read, POST for merge-write (never wipes sibling fields). Writes are debounced 400 ms on the client side. + +## Auto refresh + +Set "Auto refresh" to 5 / 10 / 30 / 60 seconds. The page re-fetches overview and graph in place, keeping the user's selected commit, scroll position, and inline-detail state. A countdown chip next to the refresh button shows the time until the next tick; manual refresh is still available at any time. + +## Source & verification + +The Client is at `miniapp/client/index.html` — single HTML file, single SVG canvas, inline script. The Node entry is at `miniapp/node/server.mjs`. The lane-assignment algorithm is at `miniapp/node/git-graph.mjs`. + +No build step. Verified on MiniMax Code desktop `3.0.73.166`, Windows (10.0.26200, x64). macOS and Linux not verified. + +## License + +[Apache-2.0](LICENSE). + +The git-graph layout algorithm in `miniapp/node/git-graph.mjs` is a JavaScript port of [`zai-org/ZCode`](https://github.com/zai-org/ZCode)'s `packages/ui/src/git-graph/layoutAlgorithm.ts` and `packages/ui/src/git-graph/layout.ts`, which are licensed under Apache-2.0 by Z.ai / zai-org contributors. diff --git a/plugins/microbiosis/git-tree/README.zh-CN.md b/plugins/microbiosis/git-tree/README.zh-CN.md new file mode 100644 index 0000000..363260e --- /dev/null +++ b/plugins/microbiosis/git-tree/README.zh-CN.md @@ -0,0 +1,73 @@ +# Git 提交树 + +[English](README.md) | 简体中文 + +在 MiniMax Code 里查看本机 Git 仓库的提交历史:泳道提交图、分支与标签、提交详情和文件改动统计,支持自动刷新与筛选偏好持久化。 + +作者:Microbiosis · 版本:`1.1.0` + +![Git 提交树预览](docs/preview.jpg) + +## 安装与使用 + +将本目录完整复制到 MiniMax Code 当前数据目录下。`` 默认为用户主目录下的 `.minimax`,即 `~/.minimax`,默认安装路径: + +```text +/plugins/git-tree/ +``` + +保留 `.minimax-plugin/` 隐藏目录。重新启动支持 Mini App 的 MiniMax Code,确认插件已被识别并启用,然后打开「Git 提交树」,或在对话中请求打开它。 + +页面默认加载当前仓库最近 200 条提交(可在 `repos.json` 中配置)。通过引用下拉过滤分支或标签,用搜索框匹配提交信息,用作者框匹配作者,用「自动刷新」下拉保持视图与新推送同步。 + +## 页面分区 + +| 区域 | 数据来源 | 说明 | +|---|---|---| +| 统计卡片 | `git rev-list --all --count`、`for-each-ref`、`tag --list`、`status --porcelain` | 提交总数、分支数、标签数、工作区改动数 | +| 工作区条 | `git status --porcelain` | 当前分支 + 前 6 个改动路径 chip | +| 提交图 | `git log --topo-order` + 自定义泳道算法(移植自 `zai-org/ZCode`) | 单 SVG canvas:path、dot、选中环、悬停高亮 | +| 提交行 | `git log` + `git tag --contains` | 4 列网格(refs+subject / date / author / hash);refs chip 用 `is-head`、`is-tag` 区分边框色 | +| 行内详情(可折叠) | 服务端 `/api/commit` | 标题、refs、文件改动表、body — 出现在列表下方,可折叠 | +| 右侧详情(常驻) | 服务端 `/api/commit` | 同上行内;选中态与行内互相独立 | + +## 数据源与缓存 + +- **仓库列表**:`repos.json` 与插件同发,默认空(刻意保留)。每次安装需自行声明本地路径:在 `repos` 数组里列出,或把 `scanRoots` 设为包含 `.git` 的一层目录。Node 入口从插件根和工作目录向上找 `.git`,再扫一次 `scanRoots` 的一层目录验证。**可移植性回退**:如果 `repos.json` 与向上找都没产出任何 scanBase,自动再扫 `~/Code`、`~/Projects`、`~/repos`、`~/workspace`、`~/src`、`~/source`、`~/dev`、`~/work`、`~/Documents`、`~/git`(Windows/macOS 下大小写不敏感)。Windows 上额外扫描每个挂载的盘符根(`A:\` 到 `Z:\`),因为开发者常把项目放在非系统盘的 `D:\` / `E:\` 上。Windows 系统目录(`Program Files`、`Windows`、`Users`、`ProgramData`、`$Recycle.Bin` 等)和 macOS 资源目录(`Library`、`Applications`、`System`)会被过滤掉,所以扫描盘符根不会进入 `%ProgramFiles%`。回退只在没有其他发现源时才启用,有 `repos.json` 的用户完全不受影响。 +- **统计**:7 个并行 `git` 命令(`log -n 1`、`rev-list --all --count`、两次 `for-each-ref`、`tag --list`、`status`、`log -n 1 --format=%D`),按仓库缓存 **30 秒**。 +- **提交图**:`git log --topo-order` + 可选 `--grep` / `--author`,每次请求上限 **400** 行。布局结果由服务端校验,客户端用单 SVG 渲染。 +- **提交详情**:`git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`,按 `(repo, sha)` 缓存 **60 秒**。客户端镜像同一缓存,重复点同一条提交零成本。 +- **图的标签**:`git log --all --simplify-by-decoration --format=%H %D`,按仓库缓存 60 秒。 +- **响应压缩**:API 响应 ≥ 256 字节且客户端带 `Accept-Encoding: gzip` 时自动 gzip。 +- **超时分级**:快速读(`for-each-ref`、`tag --list`、`--contains`)10 秒;中速读(`log -n 1`、`status`、`git show`)15 秒;重量级读(`log --topo-order --all`、`rev-list --all --count`)30 秒。客户端 `api()` 用 `AbortController` 兜底 35 秒。 + +## 偏好持久化 + +筛选条件和主题写入 `/prefs.json`,下次打开自动恢复: + +| 键 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `theme` | `auto` \| `light` \| `dark` | `auto` | `auto` 跟随系统 `prefers-color-scheme` | +| `repo` | 绝对路径 | 第一个仓库 | 若保存的路径已不在仓库列表,回退到默认 | +| `ref` | 字符串 | `all` | `all`、分支名或标签名 | +| `q` | 字符串 | `""` | 提交信息子串搜索 | +| `author` | 字符串 | `""` | 匹配作者名或邮箱 | +| `interval` | `0` \| `5` \| `10` \| `30` \| `60` | `0` | 自动刷新间隔(秒) | + +`/api/prefs` 用 GET 读、POST 合并写(不会清空其他字段)。客户端写 400 ms 防抖。 + +## 自动刷新 + +「自动刷新」设为 5 / 10 / 30 / 60 秒后,页面原地重新拉取 overview 和 graph,选中行、滚动位置、行内详情全部保留。刷新按钮旁的倒计时 chip 显示距离下次自动刷新的秒数;手动刷新按钮任意时刻可用。 + +## 源码与验证 + +页面位于 `miniapp/client/index.html`(单文件、单 SVG canvas、行内脚本),Node 入口位于 `miniapp/node/server.mjs`,泳道算法位于 `miniapp/node/git-graph.mjs`。 + +无需构建。已验证环境:MiniMax Code 桌面端 `3.0.73.166`,Windows(10.0.26200,x64)。macOS 与 Linux 未验证。 + +## 许可证 + +[Apache-2.0](LICENSE)。 + +`miniapp/node/git-graph.mjs` 中的泳道布局算法是 [`zai-org/ZCode`](https://github.com/zai-org/ZCode) 的 `packages/ui/src/git-graph/layoutAlgorithm.ts` 与 `packages/ui/src/git-graph/layout.ts` 的 JavaScript 移植,上游以 Apache-2.0 许可,原作者 Z.ai / zai-org 贡献者。 diff --git a/plugins/microbiosis/git-tree/docs/preview.jpg b/plugins/microbiosis/git-tree/docs/preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..868c1b100149db9432e3c642eab833774386bd26 GIT binary patch literal 131267 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 z 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
  • LoDjdI60Yl2fEyW=*WdUHaUAUeSytO+o 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 literal 0 HcmV?d00001 diff --git a/plugins/microbiosis/git-tree/miniapp/client/index.html b/plugins/microbiosis/git-tree/miniapp/client/index.html new file mode 100644 index 0000000..8b00342 --- /dev/null +++ b/plugins/microbiosis/git-tree/miniapp/client/index.html @@ -0,0 +1,1857 @@ + + + + + + Git 提交树 + + + +
    +
    +
    +

    Git 提交树

    +

    查看本机 Git 仓库的提交历史、分支与标签结构

    +
    +
    + + + + + + + +
    +
    + +
    +
    +
    +
    + +
    + + + + + + +
    + + +
    +
    + +
    +
    +
    +

    提交历史

    + +
    + +
    +
    + +
      + +
      +
      + + +
      + + +
      +
      + + + + diff --git a/plugins/microbiosis/git-tree/miniapp/miniapp.json b/plugins/microbiosis/git-tree/miniapp/miniapp.json new file mode 100644 index 0000000..cae762b --- /dev/null +++ b/plugins/microbiosis/git-tree/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/git-tree" + }, + "mcpEndpoints": [] +} diff --git a/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs b/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs new file mode 100644 index 0000000..532ef8c --- /dev/null +++ b/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// This file is a JavaScript port of: +// packages/ui/src/git-graph/layoutAlgorithm.ts +// packages/ui/src/git-graph/layout.ts +// from the upstream repository https://github.com/zai-org/ZCode (Apache-2.0). +// Original authors: Z.ai / zai-org contributors. +// Modifications: TypeScript → ES module JavaScript; pixel layout emits a list +// of per-row primitives (line / curve) so the browser can render the graph as a +// column of small SVGs, one per commit row, instead of a single large SVG. + +/** + * @typedef {object} GraphCommit + * @property {string} hash + * @property {string[]} parents + * + * @typedef {object} GraphPoint + * @property {number} laneIndex + * @property {number} rowIndex + * + * @typedef {object} BranchLineSeed + * @property {GraphPoint} from + * @property {GraphPoint} to + * @property {number} laneIndex + * @property {string} sourceHash + * @property {string} targetHash + * @property {boolean} lockedFirst + * + * @typedef {object} LayoutOptions + * @property {number} [rowHeight] + * @property {number} [laneGap] + * @property {number} [lanePadding] + * @property {number} [topPadding] + * @property {number} [bottomPadding] + */ + +const MISSING_PARENT_ID = -1; +const DEFAULT_ROW_HEIGHT = 26; +const DEFAULT_LANE_GAP = 14; +const DEFAULT_LANE_PADDING = 12; +const DEFAULT_TOP_PADDING = 0; +const DEFAULT_BOTTOM_PADDING = 0; + +class LayoutBranch { + /** @type {Property} */ colourIndex; + /** @type {BranchLineSeed[]} */ lines = []; + endRowIndex = 0; + constructor(colourIndex) { + this.colourIndex = colourIndex; + } + addLine(from, to, sourceHash, targetHash, lockedFirst) { + this.lines.push({ from, to, laneIndex: /** @type {number} */ (this.colourIndex), sourceHash, targetHash, lockedFirst }); + } +} + +class LayoutVertex { + /** @type {number} */ id; + /** @type {string} */ hash; + /** @type {LayoutVertex[]} */ parents = []; + nextParentIndex = 0; + laneIndex = /** @type {number | null} */ (null); + /** @type {LayoutBranch | null} */ branch = null; + nextLaneIndex = 0; + /** @type {Array<{ target: LayoutVertex, branch: LayoutBranch } | undefined>} */ connections = []; + constructor(id, hash) { + this.id = id; + this.hash = hash; + } + addParent(vertex) { this.parents.push(vertex); } + getNextParent() { + return this.nextParentIndex < this.parents.length ? /** @type {LayoutVertex} */ (this.parents[this.nextParentIndex]) : null; + } + registerParentProcessed() { this.nextParentIndex += 1; } + isMerge() { return this.parents.length > 1; } + isNotOnBranch() { return this.branch === null || this.laneIndex === null; } + addToBranch(branch, laneIndex) { + if (this.branch === null) { + this.branch = branch; + this.laneIndex = laneIndex; + } + } + getBranch() { return this.branch; } + getLaneIndex() { return this.laneIndex ?? 0; } + getPoint() { return { laneIndex: this.getLaneIndex(), rowIndex: this.id }; } + getNextPoint() { return { laneIndex: this.nextLaneIndex, rowIndex: this.id }; } + getPointConnectingTo(target, branch) { + const idx = this.connections.findIndex((c) => c && c.target === target && c.branch === branch); + return idx >= 0 ? { laneIndex: idx, rowIndex: this.id } : null; + } + reservePoint(laneIndex, target, branch) { + if (laneIndex === this.nextLaneIndex) { + this.connections[laneIndex] = { target, branch }; + this.nextLaneIndex = laneIndex + 1; + } + } + getWidthLaneIndex() { return this.nextLaneIndex; } +} + +function createVertices(commits) { + const missingParent = new LayoutVertex(MISSING_PARENT_ID, '__zcode_missing_parent__'); + const vertices = commits.map((c, i) => new LayoutVertex(i, c.hash)); + const vertexByHash = new Map(vertices.map((v) => [v.hash, v])); + commits.forEach((commit, index) => { + const vertex = vertices[index]; + for (const parentHash of commit.parents) { + const parent = vertexByHash.get(parentHash) ?? missingParent; + vertex.addParent(parent); + } + }); + return { missingParent, vertices, vertexByHash }; +} + +function getAvailableColour(startAt, availableColours) { + const reusable = availableColours.findIndex((endAt) => startAt > endAt); + if (reusable >= 0) return reusable; + availableColours.push(0); + return availableColours.length - 1; +} + +function determineMergePath(startAt, vertices, vertex, parentVertex) { + const parentBranch = /** @type {LayoutBranch} */ (parentVertex.getBranch()); + let lastPoint = vertex.getPoint(); + let foundConnectionToParent = false; + for (let rowIndex = startAt + 1; rowIndex < vertices.length; rowIndex += 1) { + const currentVertex = vertices[rowIndex]; + const existingPoint = currentVertex.getPointConnectingTo(parentVertex, parentBranch); + const currentPoint = existingPoint ?? currentVertex.getNextPoint(); + foundConnectionToParent = existingPoint !== null; + parentBranch.addLine( + lastPoint, + currentPoint, + vertex.hash, + parentVertex.hash, + !foundConnectionToParent && currentVertex !== parentVertex + ? lastPoint.laneIndex < currentPoint.laneIndex + : true, + ); + currentVertex.reservePoint(currentPoint.laneIndex, parentVertex, parentBranch); + lastPoint = currentPoint; + if (foundConnectionToParent) { + vertex.registerParentProcessed(); + break; + } + } +} + +function determineNormalPath(params) { + const { startAt, vertices, branches, availableColours, missingParent } = params; + let rowIndex = startAt; + let vertex = vertices[rowIndex]; + let parentVertex = vertex.getNextParent(); + let lastPoint = vertex.isNotOnBranch() ? vertex.getNextPoint() : vertex.getPoint(); + const branch = new LayoutBranch(getAvailableColour(startAt, availableColours)); + vertex.addToBranch(branch, lastPoint.laneIndex); + vertex.reservePoint(lastPoint.laneIndex, vertex, branch); + for (rowIndex = startAt + 1; rowIndex < vertices.length; rowIndex += 1) { + if (parentVertex === null || parentVertex === missingParent) break; + const currentVertex = vertices[rowIndex]; + const currentPoint = + parentVertex === currentVertex && !parentVertex.isNotOnBranch() + ? currentVertex.getPoint() + : currentVertex.getNextPoint(); + branch.addLine( + lastPoint, + currentPoint, + vertex.hash, + parentVertex.hash, + lastPoint.laneIndex < currentPoint.laneIndex, + ); + currentVertex.reservePoint(currentPoint.laneIndex, parentVertex, branch); + lastPoint = currentPoint; + if (parentVertex === currentVertex) { + vertex.registerParentProcessed(); + const parentWasAlreadyOnBranch = !parentVertex.isNotOnBranch(); + parentVertex.addToBranch(branch, currentPoint.laneIndex); + vertex = parentVertex; + parentVertex = vertex.getNextParent(); + if (parentVertex === missingParent) { + vertex.registerParentProcessed(); + break; + } + if (parentVertex === null || parentWasAlreadyOnBranch) break; + } + } + branch.endRowIndex = rowIndex; + branches.push(branch); + availableColours[branch.colourIndex] = rowIndex; +} + +function determinePath(params) { + const vertex = params.vertices[params.startAt]; + const parentVertex = vertex.getNextParent(); + if (parentVertex === params.missingParent) { + vertex.registerParentProcessed(); + return; + } + if ( + parentVertex !== null && + vertex.isMerge() && + !vertex.isNotOnBranch() && + !parentVertex.isNotOnBranch() + ) { + determineMergePath(params.startAt, params.vertices, vertex, parentVertex); + return; + } + determineNormalPath(params); +} + +function createGitGraphLayoutModel(commits) { + const { missingParent, vertices, vertexByHash } = createVertices(commits); + const branches = []; + const availableColours = []; + let index = 0; + while (index < vertices.length) { + const vertex = vertices[index]; + if (vertex.getNextParent() !== null || vertex.isNotOnBranch()) { + determinePath({ startAt: index, vertices, branches, availableColours, missingParent }); + } else { + index += 1; + } + } + const laneIndices = vertices.map((v) => v.getLaneIndex()); + const laneByHash = new Map(); + vertices.forEach((v, i) => laneByHash.set(v.hash, laneIndices[i])); + return { + vertices, + vertexByHash, + laneIndices, + branchLines: branches.flatMap((b) => b.lines), + laneByHash, + }; +} + +function buildEdgePath({ fromX, fromY, toX, toY, lockedFirst }) { + if (fromX === toX) { + return `M ${fromX} ${fromY} L ${toX} ${toY}`; + } + const curveOffset = Math.max(8, Math.abs(toY - fromY) * 0.5); + if (lockedFirst === false) { + return `M ${fromX} ${fromY} C ${fromX} ${toY - curveOffset}, ${toX} ${toY - curveOffset}, ${toX} ${toY}`; + } + return `M ${fromX} ${fromY} C ${fromX} ${fromY + curveOffset}, ${toX} ${fromY + curveOffset}, ${toX} ${toY}`; +} + +function pointToPixels(point, opts) { + return { + x: opts.lanePadding + point.laneIndex * opts.laneGap, + y: opts.topPadding + point.rowIndex * opts.rowHeight, + }; +} + +/** + * @typedef {object} DetailedRow + * @property {string} hash + * @property {number} rowIndex + * @property {number} laneIndex + * @property {number} x + * @property {number} y + * + * @typedef {object} DetailedEdge + * @property {string} id + * @property {string} fromHash + * @property {string} toHash + * @property {number} fromLaneIndex + * @property {number} toLaneIndex + * @property {string} path + * @property {boolean} truncated + * + * @typedef {object} DetailedPath + * @property {string} id + * @property {number} laneIndex + * @property {string} path + * @property {string[]} relatedHashes + * + * @typedef {object} DetailedLaneSegment + * @property {string} id + * @property {string} hash + * @property {number} laneIndex + * @property {string} path + * + * @typedef {object} DetailedGraphLayout + * @property {DetailedRow[]} rows + * @property {DetailedEdge[]} edges + * @property {DetailedPath[]} paths + * @property {DetailedLaneSegment[]} laneSegments + * @property {number} laneCount + * @property {number} width + * @property {number} height + * @property {number} rowHeight + * @property {number} laneGap + */ + +/** + * ZCode-aligned wrapper around the algorithm. Returns the rich structure the + * client needs to render the graph as a single SVG canvas: rows with x/y + * pixel coordinates, full SVG path strings per edge, the complete set of + * branch paths for hover highlighting, and vertical-only lane segments. This + * replaces the per-row-primitive layout that the client used to assemble + * N inline SVGs. + * @param {GraphCommit[]} commits + * @param {LayoutOptions} [options] + * @returns {DetailedGraphLayout} + */ +function layoutGitGraphDetailed(commits, options = {}) { + const rowHeight = options.rowHeight ?? DEFAULT_ROW_HEIGHT; + const laneGap = options.laneGap ?? DEFAULT_LANE_GAP; + const lanePadding = options.lanePadding ?? DEFAULT_LANE_PADDING; + const topPadding = options.topPadding ?? DEFAULT_TOP_PADDING; + const bottomPadding = options.bottomPadding ?? DEFAULT_BOTTOM_PADDING; + + const { vertices, vertexByHash, branchLines } = createGitGraphLayoutModel(commits); + + const rows = vertices.map((vertex, rowIndex) => { + const laneIndex = vertex.getLaneIndex(); + return { + hash: commits[rowIndex].hash, + rowIndex, + laneIndex, + x: lanePadding + laneIndex * laneGap, + y: topPadding + rowIndex * rowHeight, + }; + }); + + const rowByHash = new Map(rows.map((row) => [row.hash, row])); + const pixelOptions = { lanePadding, laneGap, topPadding, rowHeight }; + + const maxRowLaneIndex = rows.reduce((max, r) => Math.max(max, r.laneIndex), 0); + const maxWidthLaneIndex = vertices.reduce( + (max, vertex) => Math.max(max, vertex.getWidthLaneIndex() - 1), + 0, + ); + const maxLineLaneIndex = branchLines.reduce( + (max, line) => Math.max(max, line.from.laneIndex, line.to.laneIndex), + 0, + ); + const laneCount = Math.max(1, maxRowLaneIndex + 1, maxWidthLaneIndex + 1, maxLineLaneIndex + 1); + const width = lanePadding * 2 + (laneCount - 1) * laneGap; + const height = topPadding + Math.max(0, commits.length - 1) * rowHeight + bottomPadding; + + /** @type {DetailedEdge[]} */ + const edges = []; + for (let rowIndex = 0; rowIndex < commits.length; rowIndex += 1) { + const commit = commits[rowIndex]; + const fromVertex = vertexByHash.get(commit.hash); + if (!fromVertex) continue; + const fromRow = rows[rowIndex]; + for (let parentIndex = 0; parentIndex < commit.parents.length; parentIndex += 1) { + const parentHash = commit.parents[parentIndex]; + const parentVertex = vertexByHash.get(parentHash); + const fromLaneIndex = fromVertex.getLaneIndex(); + const toLaneIndex = parentVertex ? parentVertex.getLaneIndex() : fromLaneIndex + parentIndex; + const toRow = parentHash ? rowByHash.get(parentHash) : null; + const fromX = lanePadding + fromLaneIndex * laneGap; + const toX = lanePadding + toLaneIndex * laneGap; + edges.push({ + id: `${commit.hash}:${parentHash}:${parentIndex}`, + fromHash: commit.hash, + toHash: parentHash, + fromLaneIndex, + toLaneIndex, + path: buildEdgePath({ + fromX, + fromY: fromRow.y, + toX, + toY: toRow ? toRow.y : fromRow.y, + lockedFirst: fromLaneIndex < toLaneIndex, + }), + truncated: !toRow, + }); + } + } + + /** @type {DetailedPath[]} */ + const paths = branchLines.map((line, index) => { + const from = pointToPixels(line.from, pixelOptions); + const to = pointToPixels(line.to, pixelOptions); + return { + id: `${line.sourceHash}:${line.targetHash}:${line.from.rowIndex}:${line.to.rowIndex}:${index}:path`, + laneIndex: line.laneIndex, + path: buildEdgePath({ + fromX: from.x, + fromY: from.y, + toX: to.x, + toY: to.y, + lockedFirst: line.lockedFirst, + }), + relatedHashes: + line.sourceHash === line.targetHash + ? [line.sourceHash] + : [line.sourceHash, line.targetHash], + }; + }); + + /** @type {DetailedLaneSegment[]} */ + const laneSegments = branchLines + .filter((line) => line.from.laneIndex === line.to.laneIndex) + .map((line, index) => { + const from = pointToPixels(line.from, pixelOptions); + const to = pointToPixels(line.to, pixelOptions); + return { + id: `${line.sourceHash}:${line.targetHash}:${line.from.rowIndex}:${line.to.rowIndex}:${index}:segment`, + hash: line.targetHash, + laneIndex: line.to.laneIndex, + path: buildEdgePath({ + fromX: from.x, + fromY: from.y, + toX: to.x, + toY: to.y, + lockedFirst: line.lockedFirst, + }), + }; + }); + + return { + rows, + edges, + paths, + laneSegments, + laneCount, + width, + height, + rowHeight, + laneGap, + }; +} + +export { + createGitGraphLayoutModel, + layoutGitGraphDetailed, + DEFAULT_ROW_HEIGHT, + DEFAULT_LANE_GAP, + DEFAULT_LANE_PADDING, +}; \ No newline at end of file diff --git a/plugins/microbiosis/git-tree/miniapp/node/repos.json b/plugins/microbiosis/git-tree/miniapp/node/repos.json new file mode 100644 index 0000000..acaf8fb --- /dev/null +++ b/plugins/microbiosis/git-tree/miniapp/node/repos.json @@ -0,0 +1,5 @@ +{ + "_comment": "List of Git repositories the page shows by default. Empty by design — every install should declare its own local paths. Set `scanRoots` to one or more directories whose direct children will be auto-discovered (each must contain a `.git` directory).", + "repos": [], + "scanRoots": [] +} diff --git a/plugins/microbiosis/git-tree/miniapp/node/server.mjs b/plugins/microbiosis/git-tree/miniapp/node/server.mjs new file mode 100644 index 0000000..e6501e6 --- /dev/null +++ b/plugins/microbiosis/git-tree/miniapp/node/server.mjs @@ -0,0 +1,1063 @@ +// @ts-check + +import { execFile } from 'node:child_process'; +import { readFile, readdir, stat, writeFile, mkdir } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import { + layoutGitGraphDetailed, + DEFAULT_LANE_GAP, + DEFAULT_LANE_PADDING, + DEFAULT_ROW_HEIGHT, +} from './git-graph.mjs'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const SURFACE_PATH = '/git-tree'; +const API_ROOT = '/api/git-tree'; +const ROW_HEIGHT = DEFAULT_ROW_HEIGHT; +const LANE_SPACING = DEFAULT_LANE_GAP; +const GRAPH_PADDING = DEFAULT_LANE_PADDING; +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 400; +// Per-command git timeout budgets. Tight, quick reads should fail fast so a +// hung background process does not stall the whole overview response. +const GIT_TIMEOUT_QUICK_MS = 10_000; // for-each-ref, tag --list, contains checks +const GIT_TIMEOUT_MEDIUM_MS = 15_000; // log -n 1, status --porcelain, git show (single commit) +const GIT_TIMEOUT_HEAVY_MS = 30_000; // log --topo-order --all, rev-list --all --count +const MAX_BUFFER = 64 * 1024 * 1024; +const DISCOVERY_TTL_MS = 60_000; +const OVERVIEW_TTL_MS = 30_000; +const COMMIT_TTL_MS = 60_000; +const GZIP_MIN_BYTES = 256; +const REF_PATTERN = /^(?!-)[A-Za-z0-9._/+^-]{1,200}$/; +const SHA_PATTERN = /^[0-9a-fA-F]{4,64}$/; + +/** + * @typedef {object} GitResult + * @property {boolean} ok + * @property {string} stdout + * @property {string} stderr + * @property {number} code + */ + +/** + * @param {string} cwd + * @param {string[]} args + * @param {number} [timeoutMs] + * @returns {Promise} + */ +function runGit(cwd, args, timeoutMs = GIT_TIMEOUT_HEAVY_MS) { + return new Promise((resolvePromise) => { + execFile( + 'git', + args, + { cwd, windowsHide: true, maxBuffer: MAX_BUFFER, timeout: timeoutMs, encoding: 'utf8' }, + (error, stdout, stderr) => { + const code = typeof error?.code === 'number' ? error.code : error ? 1 : 0; + resolvePromise({ + ok: !error, + stdout: typeof stdout === 'string' ? stdout : '', + stderr: typeof stderr === 'string' ? stderr : '', + code, + }); + }, + ); + }); +} + +/** @param {string} target */ +async function pathExists(target) { + try { + await stat(target); + return true; + } catch { + return false; + } +} + +/** + * Walk up from `start` until a directory containing `.git` is found. + * @param {string} start + * @returns {Promise} + */ +async function findRepoRoot(start) { + let current = resolve(start); + for (let depth = 0; depth < 5; depth += 1) { + if (await pathExists(join(current, '.git'))) return current; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +/** + * @typedef {object} RepoEntry + * @property {string} path + * @property {string} label + * @property {boolean} isDefault + */ + +/** + * @typedef {object} Registry + * @property {string | null} defaultRepo + * @property {RepoEntry[]} repos + */ + +/** + * @typedef {object} RepoConfig + * @property {string[]} repos + * @property {string[]} scanRoots + */ + +/** + * Load the authored repository allowlist shipped beside the Node entry. The + * published Host install directory sits outside the workspace, so this file is + * the only discovery source that survives publication. + * @param {string} pluginRoot + * @returns {Promise} + */ +async function loadRepoConfig(pluginRoot) { + /** @type {RepoConfig} */ + const empty = { repos: [], scanRoots: [] }; + try { + const raw = await readFile(join(pluginRoot, 'miniapp/node/repos.json'), 'utf8'); + const parsed = JSON.parse(raw); + const pick = (/** @type {unknown} */ value) => + Array.isArray(value) ? value.filter((entry) => typeof entry === 'string' && isAbsolute(resolve(entry))) : []; + return { repos: pick(parsed?.repos), scanRoots: pick(parsed?.scanRoots) }; + } catch { + return empty; + } +} + +/** + * Subdirectories we never want to walk into even when they appear at a scan + * base, because they are either inaccessible, contain OS state, or are simply + * not where a developer keeps code. Most hidden dirs (`.Trash`, `.cache`, …) + * are caught by the `startsWith('.')` rule in the filter; this set covers + * unhidden-but-systemic names. + */ +const SKIP_DIR_NAMES = new Set([ + // macOS system / resource / metadata directories + 'Library', 'Applications', 'System', + // Windows system hives — even when read access works, recursing here is + // never what a developer wants. + 'Program Files', 'Program Files (x86)', 'ProgramData', 'Windows', 'Users', + '$Recycle.Bin', 'System Volume Information', '$WinREAgent', 'Recovery', + 'PerfLogs', 'Boot', 'Config.Msi', 'OneDriveTemp', +]); + +/** @param {string} name */ +function isSkippableDirectoryName(name) { + if (!name) return true; + // Hidden dirs: .Trash, .cache, .npm, .next, .pnpm-store, .git, ... + if (name.startsWith('.')) return true; + // macOS resource forks / metadata and AppleDouble double-dot prefixes + if (name.startsWith('_')) return true; + return SKIP_DIR_NAMES.has(name); +} + +/** + * Portability fallback for `repos.json` empty installs: derive a small, bounded + * set of plausible dev directories. On macOS/Linux we stay under or one level + * above `$HOME`; on Windows we also walk every mounted drive root because + * developers routinely keep projects on a non-OS drive (`D:\GitHub\...`, + * `E:\Work\...`) that is never under `$HOME`. + * + * The candidate set is only added when nothing else surfaced a scan base, so + * users with an explicit `repos.json` are unaffected. Per-entry filtering + * below drops Windows system hives (`Program Files`, `Users`, `Windows`, …), + * so widening to drive roots cannot accidentally recurse into `%ProgramFiles%`. + * + * Names with case duplicates (Code/code, Repos/repos) are intentional because + * Windows and macOS default to case-insensitive filesystems and developers + * frequently mix capitalizations. The set itself is small (≈ 16 + drives) + * and the per-base scan is one-level deep with a 60-entry cap. + * @returns {string[]} + */ +function discoverDefaultScanBases() { + const home = homedir(); + if (!home) return []; + const bases = []; + for (const name of [ + 'Code', 'code', + 'Projects', 'projects', + 'repos', 'Repos', + 'workspace', 'Workspace', + 'src', 'SRC', 'source', 'Source', + 'dev', 'Dev', 'work', 'Work', + 'Documents', 'git', 'Git', + ]) { + bases.push(join(home, name)); + } + if (process.platform === 'win32') { + // Each Windows drive root is one readdir() away from common + // "all my projects live here" parents (D:\GitHub\\..., + // D:\Projects\..., D:\Work\...). Per-entry filter below keeps + // Windows system hives out of the scan. + for (let i = 65; i <= 90; i += 1) { + bases.push(`${String.fromCharCode(i)}:\\`); + } + } else { + // On macOS/Linux the home parent (e.g. /home, /Users) is a + // sibling-user scan that costs one extra readdir and frequently + // catches other developer accounts on the same host. + bases.push(resolve(home, '..')); + } + return bases; +} + +/** + * Build a bounded repository registry from an explicit allowlist plus bounded + * discovery: walk-ups from the plugin root and the process cwd, then one + * directory level of every scan root, each validated by a `.git` entry. + * @param {string} pluginRoot + * @returns {Promise} + */ +async function buildRegistry(pluginRoot) { + /** @type {RepoEntry[]} */ + const repos = []; + /** @type {Set} */ + const seen = new Set(); + + const add = (/** @type {string} */ candidate) => { + const normalized = resolve(candidate); + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + repos.push({ path: normalized, label: normalized.split(/[\\/]/).filter(Boolean).pop() ?? normalized, isDefault: false }); + }; + + const config = await loadRepoConfig(pluginRoot); + const walkRoots = [await findRepoRoot(pluginRoot), await findRepoRoot(process.cwd())]; + + /** @type {string[]} */ + const preferred = []; + for (const candidate of [...config.repos, ...walkRoots]) { + if (candidate && (await pathExists(join(candidate, '.git')))) preferred.push(candidate); + } + for (const candidate of preferred) add(candidate); + + /** @type {Set} */ + const scanBases = new Set(); + for (const base of config.scanRoots) scanBases.add(resolve(base)); + for (const root of walkRoots) if (root) scanBases.add(dirname(root)); + + // Portability fallback: when walk-up produced nothing (plugin installed + // outside any git tree) AND the user has not authored `repos.json`, derive + // a small set of `$HOME` dev directories plus, on Windows, every mounted + // drive root. This lets a fresh install on a brand-new host surface + // projects without any manual configuration. + // + // On Windows the per-entry filter below drops system hives (`Program + // Files`, `Users`, `Windows`, …) so widening to drive roots cannot + // accidentally recurse into %ProgramFiles% or %SystemRoot%. + if (scanBases.size === 0) { + for (const base of discoverDefaultScanBases()) { + scanBases.add(resolve(base)); + } + } + + for (const scanBase of scanBases) { + try { + const entries = await readdir(scanBase, { withFileTypes: true }); + const directories = entries + .filter((entry) => entry.isDirectory() && !isSkippableDirectoryName(entry.name)) + .slice(0, 60); + for (const entry of directories) { + const candidate = join(scanBase, entry.name); + if (await pathExists(join(candidate, '.git'))) add(candidate); + } + } catch { + /* scan base unreadable: keep whatever was already found */ + } + } + + if (preferred.length > 0 && repos.length > 0) repos[0].isDefault = true; + return { defaultRepo: preferred[0] ?? null, repos }; +} + +/** @param {string} value */ +function normalizeRepo(value) { + if (typeof value !== 'string' || value.length === 0) return null; + if (value.includes('\0')) return null; + const normalized = resolve(value); + if (!isAbsolute(normalized)) return null; + return normalized; +} + +/** + * Resolve a requested repository against the discovered registry. + * @param {Registry} registry + * @param {string | null} requested + * @returns {string | null} + */ +function resolveRepo(registry, requested) { + const target = requested ? normalizeRepo(requested) : registry.defaultRepo; + if (!target) return null; + const key = target.toLowerCase(); + const match = registry.repos.find((repo) => repo.path.toLowerCase() === key); + return match ? match.path : null; +} + +/** + * @typedef {object} CommitRow + * @property {string} sha + * @property {string} short + * @property {string[]} parents + * @property {string} authorName + * @property {string} authorEmail + * @property {string} date + * @property {string} subject + * @property {number} lane + * @property {boolean} merge + * @property {string[]} tags + */ + +/** + * @typedef {object} GraphEdge + * @property {number} from + * @property {number} to + * @property {number} fromLane + * @property {number} toLane + */ + +/** + * Compute the swim-lane layout, full SVG path strings, and per-row pixel + * coordinates the client renders as a single canvas SVG. Also mutates each + * commit with its assigned lane index and merge flag. + * @param {CommitRow[]} commits + */ +function buildGraph(commits) { + const graphCommits = commits.map((c) => ({ hash: c.sha, parents: c.parents })); + const layout = layoutGitGraphDetailed(graphCommits, { + rowHeight: ROW_HEIGHT, + laneGap: LANE_SPACING, + lanePadding: GRAPH_PADDING, + }); + + for (const row of layout.rows) { + const commit = commits[row.rowIndex]; + if (commit) { + commit.lane = row.laneIndex; + commit.merge = commit.parents.length > 1; + } + } + + return layout; +} + +/** @param {string} stdout */ +function splitLines(stdout) { + return stdout + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.length > 0); +} + +/** @param {string} stdout */ +function splitFields(stdout, separator) { + return splitLines(stdout).map((line) => line.split(separator)); +} + +/** + * @param {string} repo + * @returns {Promise>} + */ +async function loadTagMap(repo) { + const result = await runGit(repo, ['log', '--all', '--simplify-by-decoration', '--format=%H %D']); + /** @type {Map} */ + const tags = new Map(); + if (!result.ok) return tags; + for (const line of splitLines(result.stdout)) { + const separator = line.indexOf(' '); + if (separator <= 0) continue; + const sha = line.slice(0, separator); + const decoration = line.slice(separator + 1); + const names = []; + for (const part of decoration.split(',')) { + const trimmed = part.trim(); + if (trimmed.startsWith('tag:')) names.push(trimmed.slice(4).trim()); + } + if (names.length > 0) tags.set(sha, names); + } + return tags; +} + +/** + * @typedef {object} CacheEntry + * @property {number} at + * @property {Map} tags + */ + +/** + * @param {MiniAppContext['logger']} logger + */ +function createTagCache(logger) { + /** @type {Map} */ + const cache = new Map(); + return { + /** + * @param {string} repo + * @returns {Promise>} + */ + async get(repo) { + const key = repo.toLowerCase(); + const hit = cache.get(key); + const now = Date.now(); + if (hit && now - hit.at < DISCOVERY_TTL_MS) return hit.tags; + const tags = await loadTagMap(repo); + cache.set(key, { at: now, tags }); + logger.debug('git-tree.tags.loaded', { entries: tags.size }); + return tags; + }, + }; +} + +/** @param {string} value */ +function clampText(value, limit) { + const trimmed = value.trim(); + return trimmed.length > limit ? `${trimmed.slice(0, limit - 1)}…` : trimmed; +} + +/** + * @template T + * @param {number} ttlMs + * @param {string} label + * @param {MiniAppContext['logger']} logger + */ +function createTTLCache(ttlMs, label, logger) { + /** @type {Map} */ + const store = new Map(); + return { + /** + * @param {string} key + * @returns {T | null} + */ + get(key) { + const hit = store.get(key); + if (!hit) return null; + if (Date.now() - hit.at >= ttlMs) { + store.delete(key); + return null; + } + return hit.value; + }, + /** + * @param {string} key + * @param {T} value + */ + set(key, value) { + store.set(key, { at: Date.now(), value }); + }, + /** @param {string} key */ + delete(key) { store.delete(key); }, + clear() { store.clear(); }, + get size() { return store.size; }, + sweep() { + const now = Date.now(); + let dropped = 0; + for (const [key, hit] of store) { + if (now - hit.at >= ttlMs) { + store.delete(key); + dropped += 1; + } + } + if (dropped > 0) logger.debug(`git-tree.${label}.sweep`, { dropped }); + }, + }; +} + +/** + * @param {MiniAppContext['logger']} logger + */ +function createOverviewCache(logger) { + const cache = createTTLCache(OVERVIEW_TTL_MS, 'overview', logger); + return { + /** + * @param {string} repo + * @param {() => Promise} loader + */ + async get(repo, loader) { + const key = repo.toLowerCase(); + const hit = cache.get(key); + if (hit) return hit; + const value = await loader(); + cache.set(key, value); + return value; + }, + /** @param {string} repo */ + invalidate(repo) { cache.delete(repo.toLowerCase()); }, + }; +} + +/** + * @param {MiniAppContext['logger']} logger + */ +function createCommitCache(logger) { + const cache = createTTLCache(COMMIT_TTL_MS, 'commit', logger); + return { + /** + * @param {string} repo + * @param {string} sha + */ + get(repo, sha) { + const key = `${repo.toLowerCase()}::${sha}`; + return cache.get(key); + }, + /** + * @param {string} repo + * @param {string} sha + * @param {unknown} value + */ + set(repo, sha, value) { + const key = `${repo.toLowerCase()}::${sha}`; + cache.set(key, value); + }, + }; +} + +/* ------------------------------------------------------------------ */ +/* Preferences persistence */ +/* ------------------------------------------------------------------ */ + +const PREF_KEYS = /** @type {const} */ (['theme', 'repo', 'ref', 'q', 'author', 'interval']); +const PREF_INTERVALS = [0, 5, 10, 30, 60]; +const PREF_THEMES = ['auto', 'light', 'dark']; + +/** + * Whitelist and type-coerce an arbitrary patch so a stray or malformed + * payload cannot poison the prefs.json on disk. + * @param {unknown} input + */ +function sanitizePrefs(input) { + /** @type {Record} */ + const out = {}; + if (!input || typeof input !== 'object') return out; + const obj = /** @type {Record} */ (input); + for (const key of PREF_KEYS) { + if (!(key in obj)) continue; + const value = obj[key]; + if (key === 'interval') { + if (typeof value === 'number' && PREF_INTERVALS.indexOf(value) >= 0) out[key] = value; + } else if (key === 'theme') { + if (typeof value === 'string' && PREF_THEMES.indexOf(value) >= 0) out[key] = value; + } else { + // repo / ref / q / author: bounded string + if (typeof value === 'string' && value.length > 0 && value.length <= 240) out[key] = value; + } + } + return out; +} + +/** + * Build a prefs module bound to the Host-injected plugin data directory. + * Reads happen lazily; writes merge with whatever is already on disk so a + * partial POST never wipes sibling fields. + * @param {string} dataDir + */ +function createPrefsStore(dataDir) { + /** @type {string | null} */ + let prefsPath = null; + /** @type {Promise | null} */ + let resolving = null; + + function path() { + if (prefsPath) return Promise.resolve(prefsPath); + if (!resolving) { + resolving = mkdir(dataDir, { recursive: true }) + .then(() => { + prefsPath = join(dataDir, 'prefs.json'); + return /** @type {string} */ (prefsPath); + }) + .catch((error) => { + resolving = null; + throw error; + }); + } + return resolving; + } + + return { + async read() { + try { + const p = await path(); + const raw = await readFile(p, 'utf8'); + const obj = JSON.parse(raw); + return obj && typeof obj === 'object' ? sanitizePrefs(obj) : {}; + } catch (error) { + const code = /** @type {NodeJS.ErrnoException} */ (error).code; + if (code !== 'ENOENT') { + // Corrupt file: don't crash the whole prefs surface, just start fresh. + return {}; + } + return {}; + } + }, + /** + * @param {Record} patch + */ + async merge(patch) { + const sanitized = sanitizePrefs(patch); + const current = await this.read(); + const next = { ...current, ...sanitized }; + const p = await path(); + // tmp-write + rename for crash-safety. Avoid clobbering the file with + // a half-written JSON if we crash mid-write. + const tmp = `${p}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(next), 'utf8'); + const fs = await import('node:fs/promises'); + await fs.rename(tmp, p); + return next; + }, + }; +} + +/** + * Decide whether the response is worth gzipping. The cost of compressing very + * small payloads is not worth the extra headers, so we require a minimum body + * size before turning on compression. + * @param {import('node:http').IncomingMessage} request + * @param {string} payload + */ +function acceptsGzip(request, payload) { + if (payload.length < GZIP_MIN_BYTES) return false; + const acceptEncoding = String(request.headers['accept-encoding'] ?? ''); + return acceptEncoding.split(',').some((token) => token.trim().toLowerCase().startsWith('gzip')); +} + +/** + * Send a JSON body, transparently gzipping it when the client accepts it and + * the payload is large enough to benefit. Stream-based to avoid buffering the + * compressed bytes twice. + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + * @param {number} status + * @param {unknown} body + */ +async function sendJson(request, response, status, body) { + const payload = JSON.stringify(body); + const baseHeaders = { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }; + if (!acceptsGzip(request, payload)) { + response.writeHead(status, baseHeaders); + response.end(payload); + return; + } + response.writeHead(status, { ...baseHeaders, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }); + const gzip = createGzip(); + pipeline(gzip, response).catch(() => undefined); + gzip.end(payload); +} + +/** + * @typedef {object} RequestContext + * @property {Registry} registry + * @property {{ get: (repo: string) => Promise> }} tags + * @property {{ get: (repo: string, loader: () => Promise) => Promise, invalidate: (repo: string) => void }} overview + * @property {{ get: (repo: string, sha: string) => unknown, set: (repo: string, sha: string, value: unknown) => void }} commits + * @property {MiniAppContext['logger']} logger + */ + +/** + * @param {RequestContext} context + * @param {URL} url + */ +async function handleRepos(context, url) { + return { defaultRepo: context.registry.defaultRepo, repos: context.registry.repos }; +} + +/** + * @param {RequestContext} context + * @param {URL} url + */ +async function handleOverview(context, url) { + const repo = resolveRepo(context.registry, url.searchParams.get('repo')); + if (!repo) return { status: 400, body: { code: 'unknown_repo', message: '仓库不在可用列表内。' } }; + + const body = await context.overview.get(repo, async () => { + const [headResult, countResult, branchesResult, remotesResult, tagsResult, statusResult, decorateResult] = + await Promise.all([ + runGit(repo, ['log', '-n', '1', '--date=iso-strict', '--format=%H%x09%h%x09%an%x09%ad%x09%s'], GIT_TIMEOUT_MEDIUM_MS), + runGit(repo, ['rev-list', '--all', '--count'], GIT_TIMEOUT_HEAVY_MS), + runGit(repo, ['for-each-ref', '--format=%(refname:short) %(objectname:short) %(upstream:short) %(HEAD)', 'refs/heads'], GIT_TIMEOUT_QUICK_MS), + runGit(repo, ['for-each-ref', '--format=%(refname:short) %(objectname:short)', 'refs/remotes'], GIT_TIMEOUT_QUICK_MS), + runGit(repo, ['tag', '--list'], GIT_TIMEOUT_QUICK_MS), + runGit(repo, ['status', '--porcelain=v1', '-b'], GIT_TIMEOUT_MEDIUM_MS), + runGit(repo, ['log', '-n', '1', '--format=%D'], GIT_TIMEOUT_QUICK_MS), + ]); + + if (!headResult.ok) { + return { code: 'git_failed', message: '无法读取仓库历史。' }; + } + + const headFields = headResult.stdout.split('\t'); + const decorate = decorateResult.ok ? decorateResult.stdout.trim() : ''; + const branchFromStatus = statusResult.ok + ? (statusResult.stdout.split('\n')[0] ?? '').replace(/^## /, '').split('...')[0].trim() + : ''; + + const branches = splitFields(branchesResult.ok ? branchesResult.stdout : '', ' ').map((fields) => ({ + name: fields[0] ?? '', + short: fields[1] ?? '', + upstream: fields[2] ?? '', + current: (fields[3] ?? '').includes('*'), + })); + const remotes = splitFields(remotesResult.ok ? remotesResult.stdout : '', ' ').map((fields) => ({ + name: fields[0] ?? '', + short: fields[1] ?? '', + })); + + const statusLines = splitLines(statusResult.ok ? statusResult.stdout : ''); + const statusEntries = statusLines + .slice(1) + .map((line) => ({ code: line.slice(0, 2), path: line.slice(3) })) + .filter((entry) => entry.path.length > 0) + .slice(0, 60); + + const tags = splitLines(tagsResult.ok ? tagsResult.stdout : ''); + const total = Number.parseInt(countResult.ok ? countResult.stdout.trim() : '0', 10); + + return { + repo, + head: { + sha: headFields[0] ?? '', + short: headFields[1] ?? '', + authorName: headFields[2] ?? '', + date: headFields[3] ?? '', + subject: (headFields[4] ?? '').replace(/[\r\n]+$/, ''), + decoration: decorate, + branch: branchFromStatus, + }, + counts: { + commits: Number.isFinite(total) ? total : 0, + branches: branches.length, + remotes: remotes.length, + tags: tags.length, + changed: statusEntries.length, + }, + branches, + remotes, + tags, + status: { + branch: branchFromStatus, + clean: statusEntries.length === 0, + entries: statusEntries, + }, + }; + }); + + // The loader returns either a domain body or a { code, message } error. + if (body && typeof body === 'object' && 'code' in body && !('repo' in body)) { + return { status: 502, body }; + } + return { status: 200, body }; +} + +/** + * @param {RequestContext} context + * @param {URL} url + */ +async function handleGraph(context, url) { + const repo = resolveRepo(context.registry, url.searchParams.get('repo')); + if (!repo) return { status: 400, body: { code: 'unknown_repo', message: '仓库不在可用列表内。' } }; + + const rawRef = url.searchParams.get('ref') ?? 'all'; + const ref = rawRef === 'all' ? '--all' : rawRef; + if (ref !== '--all' && !REF_PATTERN.test(ref)) { + return { status: 400, body: { code: 'invalid_ref', message: '引用名称不合法。' } }; + } + if (ref !== '--all' && ref.includes('..')) { + return { status: 400, body: { code: 'invalid_ref', message: '引用名称不合法。' } }; + } + + const rawLimit = Number.parseInt(url.searchParams.get('limit') ?? '', 10); + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_LIMIT) : DEFAULT_LIMIT; + const rawOffset = Number.parseInt(url.searchParams.get('offset') ?? '0', 10); + const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? rawOffset : 0; + + const query = (url.searchParams.get('q') ?? '').slice(0, 120); + const author = (url.searchParams.get('author') ?? '').slice(0, 120); + + /** @type {string[]} */ + const args = ['log', ref, '--topo-order', '--date=iso-strict', '--format=%H%x09%h%x09%P%x09%an%x09%ae%x09%ad%x09%s']; + if (query) args.push('-F', '-i', `--grep=${query}`); + if (author) args.push(`--author=${author}`); + // Fetch one extra row so `hasMore` can be derived from real evidence + // instead of being permanently false. + args.push('-n', String(offset + limit + 1)); + + const [logResult, tagMap] = await Promise.all([runGit(repo, args), context.tags.get(repo)]); + if (!logResult.ok) { + context.logger.warn('git-tree.log.failed', { reason: 'git_log' }); + return { status: 502, body: { code: 'git_failed', message: '无法读取提交图。' } }; + } + + const rows = splitFields(logResult.stdout, '\t'); + const hasMore = rows.length > offset + limit; + const commits = rows.slice(offset, offset + limit).map((/** @type {string[]} */ fields) => ({ + sha: fields[0] ?? '', + short: fields[1] ?? '', + parents: (fields[2] ?? '').split(' ').filter(Boolean), + authorName: fields[3] ?? '', + authorEmail: fields[4] ?? '', + date: fields[5] ?? '', + subject: fields[6] ?? '', + lane: 0, + merge: false, + tags: tagMap.get(fields[0] ?? '') ?? [], + })); + + const layout = buildGraph(commits); + + return { + status: 200, + body: { + repo, + ref: rawRef, + commits, + rows: layout.rows, + edges: layout.edges, + paths: layout.paths, + laneSegments: layout.laneSegments, + laneCount: layout.laneCount, + laneSpacing: LANE_SPACING, + rowHeight: layout.rowHeight, + graphPadding: GRAPH_PADDING, + graphWidth: layout.width, + graphHeight: layout.height, + hasMore, + loaded: commits.length, + }, + }; +} + +/** + * @param {RequestContext} context + * @param {URL} url + */ +async function handleCommit(context, url) { + const repo = resolveRepo(context.registry, url.searchParams.get('repo')); + if (!repo) return { status: 400, body: { code: 'unknown_repo', message: '仓库不在可用列表内。' } }; + + const sha = url.searchParams.get('sha') ?? ''; + if (!SHA_PATTERN.test(sha)) { + return { status: 400, body: { code: 'invalid_sha', message: '提交哈希不合法。' } }; + } + + const cached = context.commits.get(repo, sha); + if (cached) return { status: 200, body: cached }; + + const [metaResult, statResult, branchesResult, tagsResult] = await Promise.all([ + runGit(repo, ['show', '-s', '--date=iso-strict', sha, '--format=%H%x09%h%x09%P%x09%an%x09%ae%x09%ad%x09%cn%x09%ce%x09%cd%x09%s%x09%B'], GIT_TIMEOUT_MEDIUM_MS), + runGit(repo, ['show', '--numstat', '--format=', sha], GIT_TIMEOUT_MEDIUM_MS), + runGit(repo, ['branch', '--contains', sha, '--format=%(refname:short)'], GIT_TIMEOUT_QUICK_MS), + runGit(repo, ['tag', '--contains', sha], GIT_TIMEOUT_QUICK_MS), + ]); + + if (!metaResult.ok) { + return { status: 404, body: { code: 'commit_not_found', message: '找不到该提交。' } }; + } + + // %B is the 11th field (index 10); it may itself contain tabs, so re-join. + // Its first line repeats the subject, which the panel already renders. + // A trailing tab/empty body produces an extra empty entry after split; trim + // and drop that empty tail explicitly so we never render a phantom newline. + const fields = metaResult.stdout.split('\t'); + const subject = fields[9] ?? ''; + const rawBody = fields.slice(10).join('\t'); + const bodyLines = rawBody.split('\n'); + while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === '') bodyLines.pop(); + if (bodyLines.length > 0 && bodyLines[0].trim() === subject.trim()) bodyLines.shift(); + const body = bodyLines.join('\n').trim(); + const files = statResult.ok + ? splitLines(statResult.stdout).map((line) => { + const parts = line.split('\t'); + return { + added: parts[0] === '-' ? null : Number.parseInt(parts[0] ?? '0', 10), + deleted: parts[1] === '-' ? null : Number.parseInt(parts[1] ?? '0', 10), + path: parts.slice(2).join('\t'), + binary: parts[0] === '-', + }; + }) + : []; + + const insertions = files.reduce((sum, file) => sum + (file.added ?? 0), 0); + const deletions = files.reduce((sum, file) => sum + (file.deleted ?? 0), 0); + + const result = { + sha: fields[0] ?? '', + short: fields[1] ?? '', + parents: (fields[2] ?? '').split(' ').filter(Boolean), + authorName: fields[3] ?? '', + authorEmail: fields[4] ?? '', + authorDate: fields[5] ?? '', + committerName: fields[6] ?? '', + committerDate: fields[8] ?? '', + subject, + body, + files, + stats: { files: files.length, insertions, deletions }, + branches: splitLines(branchesResult.ok ? branchesResult.stdout : ''), + tags: splitLines(tagsResult.ok ? tagsResult.stdout : ''), + }; + + context.commits.set(repo, sha, result); + return { status: 200, body: result }; +} + +/** + * @param {MiniAppContext} context + * @returns {Promise} + */ + +export async function start(context) { + const clientEntry = await readFile(join(context.pluginRoot, 'miniapp/client/index.html')); + const registry = await buildRegistry(context.pluginRoot); + const tagCache = createTagCache(context.logger); + const overviewCache = createOverviewCache(context.logger); + const commitCache = createCommitCache(context.logger); + + if (!registry.defaultRepo) { + context.logger.warn('git-tree.registry.empty'); + } else { + context.logger.info('git-tree.registry.ready', { repos: registry.repos.length }); + } + + const prefsStore = createPrefsStore(context.dataDir); + + /** @type {RequestContext} */ + const requestContext = { + registry, + tags: tagCache, + overview: overviewCache, + commits: commitCache, + logger: context.logger, + }; + + /** + * Read the request body as utf-8 text, capped at `limit` bytes so a runaway + * POST cannot exhaust memory. + * @param {import('node:http').IncomingMessage} req + * @param {number} [limit] + */ + function readRequestBody(req, limit = 8192) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk) => { + data += chunk.toString('utf-8'); + if (data.length > limit) { + req.destroy(); + reject(new Error('body too large')); + } + }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); + } + + /** + * @param {string} method + * @param {string} route + * @param {URL} url + * @param {import('node:http').IncomingMessage} request + * @returns {Promise<{ status: number, body: unknown }>} + */ + async function dispatch(method, route, url, request) { + if (route === '/prefs') { + if (method === 'GET') { + return { status: 200, body: await prefsStore.read() }; + } + if (method === 'POST') { + let patch = {}; + try { + const text = await readRequestBody(request); + patch = text ? JSON.parse(text) : {}; + } catch (error) { + return { status: 400, body: { code: 'bad_body', message: '无法解析 prefs 请求体。' } }; + } + try { + const merged = await prefsStore.merge(patch); + return { status: 200, body: merged }; + } catch (error) { + context.logger.warn('git-tree.prefs.write_failed', { + message: /** @type {Error} */ (error).message, + }); + return { status: 500, body: { code: 'prefs_write_failed', message: '写入偏好失败。' } }; + } + } + return { status: 405, body: { code: 'method_not_allowed', message: 'prefs 仅支持 GET / POST。' } }; + } + if (method !== 'GET') { + return { status: 405, body: { code: 'method_not_allowed', message: '该接口仅支持 GET。' } }; + } + if (route === '/repos') return { status: 200, body: await handleRepos(requestContext, url) }; + if (route === '/overview') return handleOverview(requestContext, url); + if (route === '/graph') return handleGraph(requestContext, url); + if (route === '/commit') return handleCommit(requestContext, url); + return { status: 404, body: { code: 'not_found', message: '接口不存在。' } }; + } + + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + if (request.method === 'GET' && url.pathname === SURFACE_PATH) { + const html = clientEntry.toString('utf8'); + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + response.end(html); + return; + } + + if (url.pathname.startsWith(`${API_ROOT}/`)) { + const route = url.pathname.slice(API_ROOT.length); + const method = String(request.method ?? 'GET').toUpperCase(); + try { + const outcome = await dispatch(method, route, url, request); + await sendJson(request, response, outcome.status, outcome.body); + } catch (error) { + context.logger.warn('git-tree.request.failed', { route, method }); + await sendJson(request, response, 500, { code: 'internal', message: '处理请求时出错。' }); + } + return; + } + + await sendJson(request, response, 404, { code: 'not_found', message: '接口不存在。' }); + })(); + }); + + await new Promise((resolvePromise, reject) => { + const onError = (/** @type {Error} */ error) => reject(error); + server.once('error', onError); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', onError); + resolvePromise(undefined); + }); + }); + context.logger.info('miniapp.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await new Promise((resolvePromise) => { + server.close(() => resolvePromise(undefined)); + server.closeIdleConnections?.(); + }); + }; + const onAbort = () => { + void dispose().catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} diff --git a/plugins/microbiosis/git-tree/package.json b/plugins/microbiosis/git-tree/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/microbiosis/git-tree/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} From db3d6cca38f687bf557c0452913937f92fad9af3 Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:27:58 +0800 Subject: [PATCH 2/7] Surface repos nested two levels under a Windows drive root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the portability fallback kicks in (no `repos.json`, no walk-up match), the registry now recurses one level into first-level entries that look like a dev parent (`github`, `code`, `projects`, `workspace`, `src`, `dev`, `work`, `git`, `repos`, plus CJK variants `代码` / `项目` / `工程` / `源码`). This catches the common layout `D:\Github\\` where the user's projects live two levels deep under the drive root, which the previous single-level scan missed. On the maintainer's Windows machine this lifts the discovery count from 14 to 27 (the 13 new entries are all under `D:\Github\`). Cost is bounded: one extra `readdir` per matching dev-parent entry, and we only recurse when the immediate child does not already carry `.git`. The dev-parent name set is intentionally narrow (no fuzzy heuristics like `play` / `stuff`) so the recursion cannot walk into arbitrary folders. - Update `miniapp/node/server.mjs`: add `DEV_PARENT_HINTS` set and `isLikelyDevParentName`, recurse one level inside `buildRegistry`'s scan loop when the entry matches. - Update `README.md` and `README.zh-CN.md` Data sources section to describe the recursive step. --- plugins/microbiosis/git-tree/README.md | 2 +- plugins/microbiosis/git-tree/README.zh-CN.md | 2 +- .../git-tree/miniapp/node/server.mjs | 55 ++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/plugins/microbiosis/git-tree/README.md b/plugins/microbiosis/git-tree/README.md index bad0d8e..610e8d4 100644 --- a/plugins/microbiosis/git-tree/README.md +++ b/plugins/microbiosis/git-tree/README.md @@ -33,7 +33,7 @@ The page loads the first 200 commits of the active repo (configurable via `repos ## Data sources & caching -- **Repos**: `repos.json` ships with the plugin empty by design. Each install must declare its own local paths — either by listing them in the `repos` array or by setting `scanRoots` to one or more directories whose direct children contain `.git`. The Node entry walks up from the plugin root and process cwd, then one directory level of every `scanRoots` entry, and validates each candidate by checking for a `.git` directory. **Portability fallback**: when neither `repos.json` nor the walk-up produced any scan base, the registry also looks under `~/Code`, `~/Projects`, `~/repos`, `~/workspace`, `~/src`, `~/source`, `~/dev`, `~/work`, `~/Documents`, `~/git` (case-insensitive on Windows/macOS). On Windows it additionally walks every mounted drive root (`A:\` … `Z:\`) because developers routinely keep projects on a non-OS drive. Windows system hives (`Program Files`, `Windows`, `Users`, `ProgramData`, `$Recycle.Bin`, …) and macOS resource dirs (`Library`, `Applications`, `System`) are filtered out so widening to drive roots cannot recurse into `%ProgramFiles%`. The fallback only kicks in when there is no other discovery source, so users with an explicit `repos.json` are unaffected. +- **Repos**: `repos.json` ships with the plugin empty by design. Each install must declare its own local paths — either by listing them in the `repos` array or by setting `scanRoots` to one or more directories whose direct children contain `.git`. The Node entry walks up from the plugin root and process cwd, then one directory level of every `scanRoots` entry, and validates each candidate by checking for a `.git` directory. **Portability fallback**: when neither `repos.json` nor the walk-up produced any scan base, the registry also looks under `~/Code`, `~/Projects`, `~/repos`, `~/workspace`, `~/src`, `~/source`, `~/dev`, `~/work`, `~/Documents`, `~/git` (case-insensitive on Windows/macOS). On Windows it additionally walks every mounted drive root (`A:\` … `Z:\`) because developers routinely keep projects on a non-OS drive. Windows system hives (`Program Files`, `Windows`, `Users`, `ProgramData`, `$Recycle.Bin`, …) and macOS resource dirs (`Library`, `Applications`, `System`) are filtered out so widening to drive roots cannot recurse into `%ProgramFiles%`. When a first-level entry under any scan base looks like a dev parent (`github`, `code`, `projects`, `workspace`, `src`, `dev`, `work`, `git`, `repos`, …) but does not itself contain `.git`, the scanner recurses one level into it — this surfaces layouts like `D:\Github\\` where projects live two levels under the drive root. The fallback only kicks in when there is no other discovery source, so users with an explicit `repos.json` are unaffected. - **Stats**: 7 parallel `git` commands (`log -n 1`, `rev-list --all --count`, two `for-each-ref`, `tag --list`, `status`, `log -n 1 --format=%D`), cached for **30 s** per repo. - **Graph**: `git log --topo-order` with optional `--grep` and `--author` filters, capped at **400** rows per call. Layout result is server-validated; the client renders it as a single SVG. - **Commit detail**: `git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`, cached **60 s** per `(repo, sha)`. The client mirrors this cache for 60 s as well, so re-clicking a row is free. diff --git a/plugins/microbiosis/git-tree/README.zh-CN.md b/plugins/microbiosis/git-tree/README.zh-CN.md index 363260e..c386cd2 100644 --- a/plugins/microbiosis/git-tree/README.zh-CN.md +++ b/plugins/microbiosis/git-tree/README.zh-CN.md @@ -33,7 +33,7 @@ ## 数据源与缓存 -- **仓库列表**:`repos.json` 与插件同发,默认空(刻意保留)。每次安装需自行声明本地路径:在 `repos` 数组里列出,或把 `scanRoots` 设为包含 `.git` 的一层目录。Node 入口从插件根和工作目录向上找 `.git`,再扫一次 `scanRoots` 的一层目录验证。**可移植性回退**:如果 `repos.json` 与向上找都没产出任何 scanBase,自动再扫 `~/Code`、`~/Projects`、`~/repos`、`~/workspace`、`~/src`、`~/source`、`~/dev`、`~/work`、`~/Documents`、`~/git`(Windows/macOS 下大小写不敏感)。Windows 上额外扫描每个挂载的盘符根(`A:\` 到 `Z:\`),因为开发者常把项目放在非系统盘的 `D:\` / `E:\` 上。Windows 系统目录(`Program Files`、`Windows`、`Users`、`ProgramData`、`$Recycle.Bin` 等)和 macOS 资源目录(`Library`、`Applications`、`System`)会被过滤掉,所以扫描盘符根不会进入 `%ProgramFiles%`。回退只在没有其他发现源时才启用,有 `repos.json` 的用户完全不受影响。 +- **仓库列表**:`repos.json` 与插件同发,默认空(刻意保留)。每次安装需自行声明本地路径:在 `repos` 数组里列出,或把 `scanRoots` 设为包含 `.git` 的一层目录。Node 入口从插件根和工作目录向上找 `.git`,再扫一次 `scanRoots` 的一层目录验证。**可移植性回退**:如果 `repos.json` 与向上找都没产出任何 scanBase,自动再扫 `~/Code`、`~/Projects`、`~/repos`、`~/workspace`、`~/src`、`~/source`、`~/dev`、`~/work`、`~/Documents`、`~/git`(Windows/macOS 下大小写不敏感)。Windows 上额外扫描每个挂载的盘符根(`A:\` 到 `Z:\`),因为开发者常把项目放在非系统盘的 `D:\` / `E:\` 上。Windows 系统目录(`Program Files`、`Windows`、`Users`、`ProgramData`、`$Recycle.Bin` 等)和 macOS 资源目录(`Library`、`Applications`、`System`)会被过滤掉,所以扫描盘符根不会进入 `%ProgramFiles%`。如果 scanBase 下的第一层目录名字命中开发父目录模式(`github`、`code`、`projects`、`workspace`、`src`、`dev`、`work`、`git`、`repos` 等)但自身没有 `.git`,扫描器会再下一层 — 这样 `D:\Github\\` 这种把项目放在盘符根下两层的布局也能被发现。回退只在没有其他发现源时才启用,有 `repos.json` 的用户完全不受影响。 - **统计**:7 个并行 `git` 命令(`log -n 1`、`rev-list --all --count`、两次 `for-each-ref`、`tag --list`、`status`、`log -n 1 --format=%D`),按仓库缓存 **30 秒**。 - **提交图**:`git log --topo-order` + 可选 `--grep` / `--author`,每次请求上限 **400** 行。布局结果由服务端校验,客户端用单 SVG 渲染。 - **提交详情**:`git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`,按 `(repo, sha)` 缓存 **60 秒**。客户端镜像同一缓存,重复点同一条提交零成本。 diff --git a/plugins/microbiosis/git-tree/miniapp/node/server.mjs b/plugins/microbiosis/git-tree/miniapp/node/server.mjs index e6501e6..2977bc4 100644 --- a/plugins/microbiosis/git-tree/miniapp/node/server.mjs +++ b/plugins/microbiosis/git-tree/miniapp/node/server.mjs @@ -163,6 +163,39 @@ function isSkippableDirectoryName(name) { return SKIP_DIR_NAMES.has(name); } +/** + * Subdirectory names under a drive root that suggest "projects live here". + * When such a directory exists one level under a scan base but does not + * itself contain `.git`, we recurse one level into it to catch layouts + * like `D:\Github\\` where the actual repositories are nested + * two levels deep under the drive root. + * + * Match is case-insensitive. The list covers English conventions + * (`github`, `code`, `projects`, …) and CJK variants seen on shared + * developer machines (`代码`, `项目`, …). Heuristic-only names that + * could collide with unrelated folders (e.g. `play`, `stuff`) are + * intentionally omitted to keep the recursion cheap and predictable. + */ +const DEV_PARENT_HINTS = new Set([ + 'github', 'code', 'codes', 'projects', 'project', + 'workspace', 'workspaces', + 'src', 'source', 'sources', + 'dev', 'devel', 'development', + 'work', 'works', + 'git', 'gits', 'repos', 'repos', 'repositories', 'repository', + 'gitlab', 'bitbucket', 'coding', 'playground', 'sandbox', + 'opensources', 'oss', 'personal', 'private', 'public', + 'practices', 'experiments', + // CJK variants occasionally seen on shared dev machines + '代码', '项目', '工程', '源码', +]); + +/** @param {string} name */ +function isLikelyDevParentName(name) { + if (!name) return false; + return DEV_PARENT_HINTS.has(name.toLowerCase()); +} + /** * Portability fallback for `repos.json` empty installs: derive a small, bounded * set of plausible dev directories. On macOS/Linux we stay under or one level @@ -272,7 +305,27 @@ async function buildRegistry(pluginRoot) { .slice(0, 60); for (const entry of directories) { const candidate = join(scanBase, entry.name); - if (await pathExists(join(candidate, '.git'))) add(candidate); + if (await pathExists(join(candidate, '.git'))) { + add(candidate); + continue; + } + // One-level recursion for entries that look like a dev parent. This + // surfaces layouts like `D:\Github\\` where the user's repos + // live two levels under a Windows drive root. Cost is bounded: one + // extra readdir per matching dev-parent entry, and we only recurse + // when the immediate child does not already carry `.git`. + if (isLikelyDevParentName(entry.name)) { + try { + const subEntries = await readdir(candidate, { withFileTypes: true }); + for (const sub of subEntries) { + if (!sub.isDirectory() || isSkippableDirectoryName(sub.name)) continue; + const subCandidate = join(candidate, sub.name); + if (await pathExists(join(subCandidate, '.git'))) add(subCandidate); + } + } catch { + /* subdir unreadable: skip */ + } + } } } catch { /* scan base unreadable: keep whatever was already found */ From 8c228548fc39955cab8dad481a8983fdfb4f88fd Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:33:31 +0800 Subject: [PATCH 3/7] Remove silent 60-entry cap on direct repo discovery The previous `slice(0, 60)` was applied to *every* scan-base entry before the `.git` check, which silently dropped legitimate repos that happened to land past index 60 in the alphabetical listing. Concrete failure on the maintainer's Windows host: `D:\` has ~110 non-skipped entries; `D:\synthetic-git-repo` sat at index 81 and never reached the `.git` stat. After this change, all 27 repos are discovered (arcreel-connect, synthetic-git-repo, plus the 13 `D:\Github\\` projects and 12 on `E:\`). - Direct `.git` check is now uncapped. One stat per non-skipped entry on a local SSD is cheap enough that the cap was never justified. Stat calls run in parallel via `Promise.all` to keep wall time flat. - The dev-parent recursion (which costs an extra `readdir` per matching entry) keeps the 60-entry cap to bound worst-case fan-out. - Set `repos[0].isDefault = true` and `defaultRepo = repos[0].path` in the fallback path so the client dropdown opens with a selection instead of forcing the user to pick one manually when no walk-up produced a preferred repo. Verified end-to-end against `D:\synthetic-git-repo`: 5 commits, 2 lanes, merge commit correctly identified at depth 1 with parent edges on both lanes. --- .../git-tree/miniapp/node/server.mjs | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/plugins/microbiosis/git-tree/miniapp/node/server.mjs b/plugins/microbiosis/git-tree/miniapp/node/server.mjs index 2977bc4..90847df 100644 --- a/plugins/microbiosis/git-tree/miniapp/node/server.mjs +++ b/plugins/microbiosis/git-tree/miniapp/node/server.mjs @@ -300,31 +300,41 @@ async function buildRegistry(pluginRoot) { for (const scanBase of scanBases) { try { const entries = await readdir(scanBase, { withFileTypes: true }); - const directories = entries - .filter((entry) => entry.isDirectory() && !isSkippableDirectoryName(entry.name)) + const directories = entries.filter( + (entry) => entry.isDirectory() && !isSkippableDirectoryName(entry.name), + ); + // Direct `.git` check is uncapped: every non-skipped entry is probed + // with one stat. The earlier `slice(0, 60)` was a silent correctness + // bug — on a Windows drive root with 100+ entries (D:\ here has ~110) + // anything past index 60 was dropped before the `.git` check, so + // legitimate repos like `D:\synthetic-git-repo` could never appear. + // Stat-per-entry is cheap enough on a local SSD that the cap was + // never justified in the first place. + const directChecks = directories.map(async (entry) => { + const candidate = join(scanBase, entry.name); + if (await pathExists(join(candidate, '.git'))) add(candidate); + return entry; + }); + const directEntries = (await Promise.all(directChecks)); + // Dev-parent recursion stays capped. Only entries that match + // `DEV_PARENT_HINTS` recurse one level, and we cap at 60 to bound + // the worst-case second-level readdir fan-out. Sorting dev-parent + // entries first keeps the most likely productive subtrees inside + // the cap even when the parent scan base has hundreds of entries. + const devParentEntries = directEntries + .filter((entry) => isLikelyDevParentName(entry.name)) .slice(0, 60); - for (const entry of directories) { + for (const entry of devParentEntries) { const candidate = join(scanBase, entry.name); - if (await pathExists(join(candidate, '.git'))) { - add(candidate); - continue; - } - // One-level recursion for entries that look like a dev parent. This - // surfaces layouts like `D:\Github\\` where the user's repos - // live two levels under a Windows drive root. Cost is bounded: one - // extra readdir per matching dev-parent entry, and we only recurse - // when the immediate child does not already carry `.git`. - if (isLikelyDevParentName(entry.name)) { - try { - const subEntries = await readdir(candidate, { withFileTypes: true }); - for (const sub of subEntries) { - if (!sub.isDirectory() || isSkippableDirectoryName(sub.name)) continue; - const subCandidate = join(candidate, sub.name); - if (await pathExists(join(subCandidate, '.git'))) add(subCandidate); - } - } catch { - /* subdir unreadable: skip */ + try { + const subEntries = await readdir(candidate, { withFileTypes: true }); + for (const sub of subEntries) { + if (!sub.isDirectory() || isSkippableDirectoryName(sub.name)) continue; + const subCandidate = join(candidate, sub.name); + if (await pathExists(join(subCandidate, '.git'))) add(subCandidate); } + } catch { + /* subdir unreadable: skip */ } } } catch { @@ -333,7 +343,13 @@ async function buildRegistry(pluginRoot) { } if (preferred.length > 0 && repos.length > 0) repos[0].isDefault = true; - return { defaultRepo: preferred[0] ?? null, repos }; + // Fallback path: when no walk-up produced a preferred repo, the registry + // still has entries but no `defaultRepo`. Mark the first entry as the + // default so the client dropdown opens with something selected instead + // of forcing the user to pick manually. The client also has its own + // `state.repos[0]` fallback, so this is a defense-in-depth nicety. + if (repos.length > 0 && !preferred.length) repos[0].isDefault = true; + return { defaultRepo: preferred[0] ?? repos[0]?.path ?? null, repos }; } /** @param {string} value */ From 97392433be17ee0452651a763c132806cb3c190d Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:43:52 +0800 Subject: [PATCH 4/7] Align buildEdgePath curve constants with upstream ZCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port had two deviations from upstream's `packages/ui/src/git-graph/layout.ts`: - `curveOffset = Math.max(8, Math.abs(toY - fromY) * 0.5)` → `Math.max(14, Math.abs(toY - fromY) * 0.38)`. The minimum offset of 14 and the 0.38 factor produce gentler Bezier curves at short row distances and match what ZCode renders. The previous values were tighter curves that diverged from the upstream visual. - File header comment claimed the port emits per-row primitives (stale from an earlier draft that the client has since replaced with a single SVG canvas). Updated to describe the actual divergence (default pixel sizes reduced for the Mini App surface) and to claim byte-equivalence for the algorithm and curve constants. Also dropped two unused fields (`laneIndices`, `laneByHash`) that were computed by `createGitGraphLayoutModel` but never read by any consumer. The returned shape now matches upstream exactly. Verified end-to-end against a synthetic merge graph (HEAD at index 0, oldest at the tail — the order `git log --topo-order` produces). Output paths include both straight-line edges on the same lane and Bezier curves on cross-lane connections, with `curveOffset = max(14, |dy| * 0.38)` exactly matching the upstream formula. --- .../git-tree/miniapp/node/git-graph.mjs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs b/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs index 532ef8c..48eef2d 100644 --- a/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs +++ b/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs @@ -5,9 +5,12 @@ // packages/ui/src/git-graph/layout.ts // from the upstream repository https://github.com/zai-org/ZCode (Apache-2.0). // Original authors: Z.ai / zai-org contributors. -// Modifications: TypeScript → ES module JavaScript; pixel layout emits a list -// of per-row primitives (line / curve) so the browser can render the graph as a -// column of small SVGs, one per commit row, instead of a single large SVG. +// Modifications: TypeScript → ES module JavaScript; default rowHeight / +// laneGap / lanePadding / topPadding / bottomPadding reduced so the layout +// fits a single-screen commit table on a Mini App surface (upstream uses +// 42 / 18 / 16 / 20 / 18). Curve constants in `buildEdgePath` and every +// algorithm detail (vertex state machine, branch colour reuse, merge-path +// detection, lockedFirst semantics) are kept in lock-step with upstream. /** * @typedef {object} GraphCommit @@ -219,15 +222,10 @@ function createGitGraphLayoutModel(commits) { index += 1; } } - const laneIndices = vertices.map((v) => v.getLaneIndex()); - const laneByHash = new Map(); - vertices.forEach((v, i) => laneByHash.set(v.hash, laneIndices[i])); return { vertices, vertexByHash, - laneIndices, - branchLines: branches.flatMap((b) => b.lines), - laneByHash, + branchLines: branches.flatMap((branch) => branch.lines), }; } @@ -235,7 +233,7 @@ function buildEdgePath({ fromX, fromY, toX, toY, lockedFirst }) { if (fromX === toX) { return `M ${fromX} ${fromY} L ${toX} ${toY}`; } - const curveOffset = Math.max(8, Math.abs(toY - fromY) * 0.5); + const curveOffset = Math.max(14, Math.abs(toY - fromY) * 0.38); if (lockedFirst === false) { return `M ${fromX} ${fromY} C ${fromX} ${toY - curveOffset}, ${toX} ${toY - curveOffset}, ${toX} ${toY}`; } @@ -294,9 +292,10 @@ function pointToPixels(point, opts) { * ZCode-aligned wrapper around the algorithm. Returns the rich structure the * client needs to render the graph as a single SVG canvas: rows with x/y * pixel coordinates, full SVG path strings per edge, the complete set of - * branch paths for hover highlighting, and vertical-only lane segments. This - * replaces the per-row-primitive layout that the client used to assemble - * N inline SVGs. + * branch paths for hover highlighting, and vertical-only lane segments. + * + * Equivalent to upstream `layoutGitGraph`; renamed so the existing + * `createGitGraphLayoutModel` export below keeps its original name. * @param {GraphCommit[]} commits * @param {LayoutOptions} [options] * @returns {DetailedGraphLayout} From d786d1ba4f99daaf4bc24e4f9ebc57557fc59a9d Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:43:31 +0800 Subject: [PATCH 5/7] Fix five e2e bugs found by real browser + HTTP testing Every fix below was reproduced against a running server and a real browser before being written; none were visible to a syntax or import smoke test. 1. Paginated graph layout was per-page, not per-window. buildGraph() is stateful across rows (lane colour reuse, merge-path detection, lockedFirst), so laying out page N in isolation disagreed with the pages around it: a branch opened on page 1 lost its lane on page 2, and merges stopped resolving once scrolled past the first page. Lay out the whole accumulated window instead - git log already fetched those rows, so this costs no extra call. Only the current page commits go over the wire. 2. The client concatenated the graph geometry arrays on each page. They are absolute-indexed and describe the whole accumulated list, so concat duplicated every page rows and left the canvas sized to the first page only. Replace them, then re-run applyGraphSize() + renderCanvas() so rows past page 1 get lanes and dots. 3. A cleared filter could not be persisted. sanitizePrefs() dropped empty strings, so an empty ref - how the client says the filter was cleared - was silently discarded and the merge resurrected the old value on reload. Accept the empty string as a meaningful value; for the ref key it normalises to the default all scope. 4. An oversized prefs body reset the connection instead of answering. req.destroy() tore the socket down before sendJson could write the response, so the client saw a bare reset rather than the designed error. Stop accumulating, let the stream drain, and reject with a sentinel that becomes a real 413 body_too_large response. 5. Two path/config normalisation bugs. repos.json written by a Windows editor carries a UTF-8 BOM that JSON.parse rejects, which made an explicitly configured allowlist fail silently and fall back to discovery - parse it with the BOM stripped. And buildRegistry() pushed raw D:/foo paths while defaultRepo handed the client resolve()d ones, so the repo dropdown could not match its own option list and rendered as nothing selected. --- .../git-tree/miniapp/client/index.html | 20 ++-- .../git-tree/miniapp/node/server.mjs | 98 ++++++++++++++++--- 2 files changed, 99 insertions(+), 19 deletions(-) diff --git a/plugins/microbiosis/git-tree/miniapp/client/index.html b/plugins/microbiosis/git-tree/miniapp/client/index.html index 8b00342..d608cae 100644 --- a/plugins/microbiosis/git-tree/miniapp/client/index.html +++ b/plugins/microbiosis/git-tree/miniapp/client/index.html @@ -1639,18 +1639,26 @@

      提交历史

      api('/graph', graphParams(base), { timeoutMs: LONG_TIMEOUT_MS }) .then(function (data) { var commits = data.commits || []; - var rows = data.rows || []; - var paths = data.paths || []; state.commits = state.commits.concat(commits); - state.rows = state.rows.concat(rows); - state.paths = state.paths.concat(paths); - state.edges = state.edges.concat(data.edges || []); + // Graph geometry arrays are absolute-indexed and belong to the + // whole accumulated list, so they replace rather than append. + // Concatenating them duplicated every page's rows and left the + // canvas sized to the first page only. + state.rows = data.rows || []; + state.edges = data.edges || []; + state.paths = data.paths || []; state.laneCount = Math.max(state.laneCount, data.laneCount || 1); state.graphHeight = data.graphHeight || state.graphHeight; + state.graphWidth = data.graphWidth || state.graphWidth; state.hasMore = !!data.hasMore; state.loadingMore = false; - // Append-only: avoid rebuilding the N rows already on screen. + // Append-only for the DOM rows: avoid rebuilding the N rows + // already on screen. appendRows(base, state.commits.length); + // ...but the canvas must be re-laid-out for the new rows, or the + // commits past the first page render with no lanes and no dots. + applyGraphSize(); + renderCanvas(); renderListMeta(); button.disabled = false; button.textContent = '加载更多'; diff --git a/plugins/microbiosis/git-tree/miniapp/node/server.mjs b/plugins/microbiosis/git-tree/miniapp/node/server.mjs index 90847df..4f2cbb7 100644 --- a/plugins/microbiosis/git-tree/miniapp/node/server.mjs +++ b/plugins/microbiosis/git-tree/miniapp/node/server.mjs @@ -115,6 +115,18 @@ async function findRepoRoot(start) { * @property {string[]} scanRoots */ +/** + * Parse JSON text that may carry a UTF-8 BOM. Windows editors (notepad.exe in + * particular) write one by default, and `JSON.parse` rejects a leading + * `\uFEFF` — which used to make an explicitly configured `repos.json` fail + * silently and fall back to discovery with nothing surfaced to the user. + * @param {string} text + * @returns {unknown} + */ +function parseJsonText(text) { + return JSON.parse(text.replace(/^\uFEFF/, '')); +} + /** * Load the authored repository allowlist shipped beside the Node entry. The * published Host install directory sits outside the workspace, so this file is @@ -127,7 +139,7 @@ async function loadRepoConfig(pluginRoot) { const empty = { repos: [], scanRoots: [] }; try { const raw = await readFile(join(pluginRoot, 'miniapp/node/repos.json'), 'utf8'); - const parsed = JSON.parse(raw); + const parsed = parseJsonText(raw); const pick = (/** @type {unknown} */ value) => Array.isArray(value) ? value.filter((entry) => typeof entry === 'string' && isAbsolute(resolve(entry))) : []; return { repos: pick(parsed?.repos), scanRoots: pick(parsed?.scanRoots) }; @@ -273,7 +285,11 @@ async function buildRegistry(pluginRoot) { /** @type {string[]} */ const preferred = []; for (const candidate of [...config.repos, ...walkRoots]) { - if (candidate && (await pathExists(join(candidate, '.git')))) preferred.push(candidate); + // Normalize on the way in: `repos[].path` entries are `resolve()`d below, + // and `defaultRepo` is handed to the client verbatim. Keeping the raw + // `D:/foo` (forward slashes) here would make the repo dropdown fail to + // match its own option list and render as "nothing selected". + if (candidate && (await pathExists(join(candidate, '.git')))) preferred.push(resolve(candidate)); } for (const candidate of preferred) add(candidate); @@ -603,6 +619,11 @@ const PREF_THEMES = ['auto', 'light', 'dark']; /** * Whitelist and type-coerce an arbitrary patch so a stray or malformed * payload cannot poison the prefs.json on disk. + * + * The empty string is a meaningful value, not a missing one: it is how the + * client says "this filter was cleared". Dropping it here meant a clear + * silently failed to persist, and the merge below resurrected the old value + * on the next reload. * @param {unknown} input */ function sanitizePrefs(input) { @@ -618,8 +639,10 @@ function sanitizePrefs(input) { } else if (key === 'theme') { if (typeof value === 'string' && PREF_THEMES.indexOf(value) >= 0) out[key] = value; } else { - // repo / ref / q / author: bounded string - if (typeof value === 'string' && value.length > 0 && value.length <= 240) out[key] = value; + // repo / ref / q / author: bounded string. `ref` additionally accepts + // '' as a synonym for the default 'all' scope so a cleared ref does + // not get resurrected either. + if (typeof value === 'string' && value.length <= 240) out[key] = value === '' && key === 'ref' ? 'all' : value; } } return out; @@ -872,7 +895,13 @@ async function handleGraph(context, url) { const rows = splitFields(logResult.stdout, '\t'); const hasMore = rows.length > offset + limit; - const commits = rows.slice(offset, offset + limit).map((/** @type {string[]} */ fields) => ({ + // Lay out the whole accumulated window, not just this page. The lane + // algorithm is stateful across rows (branch colour reuse, merge-path + // detection, lockedFirst), so a page laid out in isolation disagrees with + // the pages around it: a branch opened on page 1 keeps its lane on page 2 + // only if page 2 is laid out with page 1's rows still in scope. `git log -n + // offset+limit+1` already fetched them, so this costs no extra git call. + const window = rows.slice(0, offset + limit).map((/** @type {string[]} */ fields) => ({ sha: fields[0] ?? '', short: fields[1] ?? '', parents: (fields[2] ?? '').split(' ').filter(Boolean), @@ -884,8 +913,11 @@ async function handleGraph(context, url) { merge: false, tags: tagMap.get(fields[0] ?? '') ?? [], })); + // Only this page's commits go over the wire; the client already holds the + // earlier ones and appends these. + const commits = window.slice(offset); - const layout = buildGraph(commits); + const layout = buildGraph(window); return { status: 200, @@ -904,7 +936,7 @@ async function handleGraph(context, url) { graphWidth: layout.width, graphHeight: layout.height, hasMore, - loaded: commits.length, + loaded: window.length, }, }; } @@ -1015,21 +1047,57 @@ export async function start(context) { /** * Read the request body as utf-8 text, capped at `limit` bytes so a runaway * POST cannot exhaust memory. + * + * On overflow we must NOT `req.destroy()`: that tears the socket down before + * `sendJson` can write the 400, so the client sees a bare connection reset + * instead of the designed error body. Instead we stop accumulating, let the + * stream drain (cheap — the bytes are discarded), and reject with a sentinel + * the caller turns into a proper HTTP response. * @param {import('node:http').IncomingMessage} req * @param {number} [limit] */ function readRequestBody(req, limit = 8192) { return new Promise((resolve, reject) => { + /** @type {string} */ let data = ''; - req.on('data', (chunk) => { + /** @type {boolean} */ + let overflowed = false; + /** @type {(() => void) | null} */ + let onData = null; + /** @type {(() => void) | null} */ + let onEnd = null; + /** @type {((error: Error) => void) | null} */ + let onError = null; + + const detach = () => { + if (onData) req.off('data', onData); + if (onEnd) req.off('end', onEnd); + if (onError) req.off('error', onError); + }; + + onData = (/** @type {Buffer} */ chunk) => { + if (overflowed) return; // draining: discard the remainder data += chunk.toString('utf-8'); if (data.length > limit) { - req.destroy(); - reject(new Error('body too large')); + overflowed = true; + data = ''; + // Keep the listeners attached so the request finishes cleanly; the + // `end` handler below resolves the drain and the caller responds. } - }); - req.on('end', () => resolve(data)); - req.on('error', reject); + }; + onEnd = () => { + detach(); + if (overflowed) reject(Object.assign(new Error('body too large'), { status: 413 })); + else resolve(data); + }; + onError = (/** @type {Error} */ error) => { + detach(); + reject(error); + }; + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); }); } @@ -1051,6 +1119,10 @@ export async function start(context) { const text = await readRequestBody(request); patch = text ? JSON.parse(text) : {}; } catch (error) { + const status = /** @type {{ status?: number }} */ (error).status; + if (status === 413) { + return { status: 413, body: { code: 'body_too_large', message: '请求体过大。' } }; + } return { status: 400, body: { code: 'bad_body', message: '无法解析 prefs 请求体。' } }; } try { From 7b2cc62042127275b494b16509fd312cd27d73e9 Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:52:41 +0800 Subject: [PATCH 6/7] Add required Data & access and Tested environment sections The repo contract added by upstream now expects these headings in every plugin README. Fill them in with the real facts: local git reads only, no network egress, and the exact tested desktop version + Windows build. --- plugins/microbiosis/git-tree/README.md | 10 +++++++++- plugins/microbiosis/git-tree/README.zh-CN.md | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/plugins/microbiosis/git-tree/README.md b/plugins/microbiosis/git-tree/README.md index 610e8d4..13825d0 100644 --- a/plugins/microbiosis/git-tree/README.md +++ b/plugins/microbiosis/git-tree/README.md @@ -64,7 +64,15 @@ Set "Auto refresh" to 5 / 10 / 30 / 60 seconds. The page re-fetches overview and The Client is at `miniapp/client/index.html` — single HTML file, single SVG canvas, inline script. The Node entry is at `miniapp/node/server.mjs`. The lane-assignment algorithm is at `miniapp/node/git-graph.mjs`. -No build step. Verified on MiniMax Code desktop `3.0.73.166`, Windows (10.0.26200, x64). macOS and Linux not verified. +No build step. + +## Data & access + +Every read is a local `git` invocation against a repository you declared in `repos.json` or one the scanner found on this machine. Nothing is uploaded, and the plugin makes no outbound network requests. The only write is `/prefs.json`, which stores just the filter and theme keys listed under Preferences. + +## Tested environment + +MiniMax Code desktop `3.0.73.166`, Windows 10.0.26200 (x64). macOS and Linux not verified. ## License diff --git a/plugins/microbiosis/git-tree/README.zh-CN.md b/plugins/microbiosis/git-tree/README.zh-CN.md index c386cd2..99beaa5 100644 --- a/plugins/microbiosis/git-tree/README.zh-CN.md +++ b/plugins/microbiosis/git-tree/README.zh-CN.md @@ -64,7 +64,15 @@ 页面位于 `miniapp/client/index.html`(单文件、单 SVG canvas、行内脚本),Node 入口位于 `miniapp/node/server.mjs`,泳道算法位于 `miniapp/node/git-graph.mjs`。 -无需构建。已验证环境:MiniMax Code 桌面端 `3.0.73.166`,Windows(10.0.26200,x64)。macOS 与 Linux 未验证。 +无需构建。 + +## 数据与访问 + +所有读取都是对本机仓库的本地 `git` 命令调用,仓库来自你在 `repos.json` 中声明的路径或扫描器在本机发现的目录。不上传任何数据,插件不发起任何对外网络请求。唯一的写入是 `/prefs.json`,只保存「偏好持久化」一节列出的筛选与主题字段。 + +## 测试环境 + +MiniMax Code 桌面端 `3.0.73.166`,Windows 10.0.26200(x64)。macOS 与 Linux 未验证。 ## 许可证 From ba9457fb2b1d7b95a0c04c26324abc3d2730eb0a Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:08:51 +0800 Subject: [PATCH 7/7] Address PR review: author attribution, refs chips, scan gate - Attribute the plugin to Microbiosis (gh api users/redmingwei returns 404: redmingwei is the GitHub profile display name, not a login, so the README link github.com/redmingwei was dead). Unified across both root indexes, plugin.json and both plugin READMEs. - Drop the unused refs chip row: the server never sends a per-commit refs field, so refsHtml() always returned an empty string and .row-refs / .ref-chip.is-head were dead code. Branches/tags still render in the detail panels via .ref-chip.is-tag. - Gate the whole-machine scan fallback on config.repos.length === 0 as well, so users who declared repos explicitly never trigger a drive-root scan. - Correct docs: 200 commits is a fixed client page size, not a repos.json setting; client timeout is 35s default and 60s for graph requests; expand Data & access into read / discovery / write / network sections. --- README.md | 2 +- README.zh-CN.md | 2 +- .../git-tree/.minimax-plugin/plugin.json | 2 +- plugins/microbiosis/git-tree/README.md | 24 +++++++++++---- plugins/microbiosis/git-tree/README.zh-CN.md | 24 +++++++++++---- .../git-tree/miniapp/client/index.html | 29 ------------------- .../git-tree/miniapp/node/server.mjs | 6 +++- 7 files changed, 44 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 0b465d8..20eaf54 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Token Usage Board](plugins/yanhy2000/mcode-usage-monitor/) | Watch local Token usage, output speed, and cache hit rate in near real time; filter by time range, model, and session | [yanhy2000](https://github.com/yanhy2000) | | [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) | | [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | **Official plugin** for planning driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) | -| [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [redmingwei](https://github.com/redmingwei) | +| [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [Microbiosis](https://github.com/Microbiosis) |
      Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index d2680b7..e18ec61 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,7 +64,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Token 用量看板](plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md) | 近实时查看本机 Token 用量、输出速度与缓存命中率,可按时间范围、模型和会话筛选 | [yanhy2000](https://github.com/yanhy2000) | | [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) | | [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) | -| [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [redmingwei](https://github.com/redmingwei) | +| [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [Microbiosis](https://github.com/Microbiosis) |
      预览:Token 用量看板 diff --git a/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json b/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json index 1b1b7b4..e891b95 100644 --- a/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json +++ b/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json @@ -4,7 +4,7 @@ "displayName": "Git 提交树", "version": "1.1.0", "description": "查看本机 git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计。", - "author": "redmingwei", + "author": "Microbiosis", "icon": "icon.png", "category": "Other", "exampleQueries": [ diff --git a/plugins/microbiosis/git-tree/README.md b/plugins/microbiosis/git-tree/README.md index 13825d0..813555f 100644 --- a/plugins/microbiosis/git-tree/README.md +++ b/plugins/microbiosis/git-tree/README.md @@ -4,7 +4,7 @@ Inspect a local Git repository's commit history from MiniMax Code: swim-lane commit graph, branches and tags, commit detail and per-file change stats, with optional auto-refresh and persisted filter preferences. -Author: Microbiosis · Version: 1.1.0 +Author: [Microbiosis](https://github.com/Microbiosis) · Version: `1.1.0` ![Git commit tree preview](docs/preview.jpg) @@ -18,7 +18,7 @@ Copy this directory into the active MiniMax Code data directory under `plugins/` Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code with Mini App support, confirm the plugin is recognized, then open "Git 提交树" — either from the plugin list or by asking the assistant. -The page loads the first 200 commits of the active repo (configurable via `repos.json`). Use the ref dropdown to filter by branch or tag, the search box to match commit subjects, the author box to match authors, and the "Auto refresh" dropdown to keep the view in sync with new pushes. +The page loads the first 200 commits of the active repo. 200 is a fixed page size on the client, not a setting in `repos.json`; narrow the result set with the ref dropdown (branch or tag), the search box (commit subject), and the author box, or use "Auto refresh" to keep the view in sync with new pushes. ## What it shows @@ -27,8 +27,8 @@ The page loads the first 200 commits of the active repo (configurable via `repos | Stats grid | `git rev-list --all --count`, `for-each-ref`, `tag --list`, `status --porcelain` | Total commits, branches, tags, working-tree changes | | Worktree chips | `git status --porcelain` | Current branch + the first 6 changed paths | | Commit graph | `git log --topo-order` + custom lane algorithm (ported from `zai-org/ZCode`) | Single SVG canvas: paths, dots, selection ring, hover highlight | -| Commit rows | `git log` + `git tag --contains` | 4-column grid (refs + subject / date / author / hash); refs chip with `is-head` and `is-tag` styling | -| Inline detail (toggle) | server-side `/api/commit` | Subject, refs, file changes table, body — appears below the list and can be collapsed | +| Commit rows | `git log` + `git tag --contains` | 4-column grid (subject / date / author / hash) | +| Inline detail (toggle) | server-side `/api/commit` | Subject, branches/tags chips, file changes table, body — appears below the list and can be collapsed | | Right detail (always on) | server-side `/api/commit` | Same payload as inline; selection and inline are independent | ## Data sources & caching @@ -39,7 +39,7 @@ The page loads the first 200 commits of the active repo (configurable via `repos - **Commit detail**: `git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`, cached **60 s** per `(repo, sha)`. The client mirrors this cache for 60 s as well, so re-clicking a row is free. - **Tags for graph**: `git log --all --simplify-by-decoration --format=%H %D`, cached 60 s per repo. - **Compression**: API responses ≥ 256 bytes are gzipped when the client advertises `Accept-Encoding: gzip`. -- **Timeouts**: quick reads (for-each-ref, tag --list, contains checks) 10 s; medium reads (log -n 1, status, git show) 15 s; heavy reads (log --topo-order --all, rev-list --all --count) 30 s. Client `api()` caps at 35 s with `AbortController`. +- **Timeouts**: quick reads (for-each-ref, tag --list, contains checks) 10 s; medium reads (log -n 1, status, git show) 15 s; heavy reads (log --topo-order --all, rev-list --all --count) 30 s. Client `api()` caps at 35 s by default and 60 s for graph requests, using `AbortController`. ## Preferences @@ -68,7 +68,19 @@ No build step. ## Data & access -Every read is a local `git` invocation against a repository you declared in `repos.json` or one the scanner found on this machine. Nothing is uploaded, and the plugin makes no outbound network requests. The only write is `/prefs.json`, which stores just the filter and theme keys listed under Preferences. +**What the plugin reads.** Every read is a local `git` invocation against a repository you declared in `repos.json` or one the scanner found on this machine. Git reads `.git` internals and working-tree metadata (for example `git status`) inside those repositories only. + +**How repositories are discovered.** Discovery enumerates *directory names* and checks each candidate for a `.git` entry; it never reads the contents of files it is not already pointed at. The scan surface is wider than `repos.json`: + +- Your home development directories (`~/Code`, `~/Projects`, `~/repos`, `~/workspace`, `~/src`, `~/source`, `~/dev`, `~/work`, `~/Documents`, `~/git`), plus the plugin root and process cwd walk-up. +- On Windows, the root of every mounted drive letter (`A:\` … `Z:\`), which includes mapped network drives. System directories are filtered out. +- On macOS and Linux, the parent of your home directory (`/Users`, `/home`), which lists the *names* of other local user accounts' home directories. + +This only runs when neither `repos.json` nor the walk-up produced a scan base. + +**What the plugin writes.** The only write is `/prefs.json`, which stores just the filter and theme keys listed under Preferences. + +**Network.** Nothing is uploaded, and the plugin makes no outbound network requests. ## Tested environment diff --git a/plugins/microbiosis/git-tree/README.zh-CN.md b/plugins/microbiosis/git-tree/README.zh-CN.md index 99beaa5..7fe4060 100644 --- a/plugins/microbiosis/git-tree/README.zh-CN.md +++ b/plugins/microbiosis/git-tree/README.zh-CN.md @@ -4,7 +4,7 @@ 在 MiniMax Code 里查看本机 Git 仓库的提交历史:泳道提交图、分支与标签、提交详情和文件改动统计,支持自动刷新与筛选偏好持久化。 -作者:Microbiosis · 版本:`1.1.0` +作者:[Microbiosis](https://github.com/Microbiosis) · 版本:`1.1.0` ![Git 提交树预览](docs/preview.jpg) @@ -18,7 +18,7 @@ 保留 `.minimax-plugin/` 隐藏目录。重新启动支持 Mini App 的 MiniMax Code,确认插件已被识别并启用,然后打开「Git 提交树」,或在对话中请求打开它。 -页面默认加载当前仓库最近 200 条提交(可在 `repos.json` 中配置)。通过引用下拉过滤分支或标签,用搜索框匹配提交信息,用作者框匹配作者,用「自动刷新」下拉保持视图与新推送同步。 +页面默认加载当前仓库最近 200 条提交。200 是客户端固定分页大小,不是 `repos.json` 里的配置项;可用引用下拉(分支或标签)、搜索框(提交信息)和作者框收窄结果,或用「自动刷新」下拉保持视图与新推送同步。 ## 页面分区 @@ -27,8 +27,8 @@ | 统计卡片 | `git rev-list --all --count`、`for-each-ref`、`tag --list`、`status --porcelain` | 提交总数、分支数、标签数、工作区改动数 | | 工作区条 | `git status --porcelain` | 当前分支 + 前 6 个改动路径 chip | | 提交图 | `git log --topo-order` + 自定义泳道算法(移植自 `zai-org/ZCode`) | 单 SVG canvas:path、dot、选中环、悬停高亮 | -| 提交行 | `git log` + `git tag --contains` | 4 列网格(refs+subject / date / author / hash);refs chip 用 `is-head`、`is-tag` 区分边框色 | -| 行内详情(可折叠) | 服务端 `/api/commit` | 标题、refs、文件改动表、body — 出现在列表下方,可折叠 | +| 提交行 | `git log` + `git tag --contains` | 4 列网格(subject / date / author / hash) | +| 行内详情(可折叠) | 服务端 `/api/commit` | 标题、分支/标签 chip、文件改动表、body — 出现在列表下方,可折叠 | | 右侧详情(常驻) | 服务端 `/api/commit` | 同上行内;选中态与行内互相独立 | ## 数据源与缓存 @@ -39,7 +39,7 @@ - **提交详情**:`git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`,按 `(repo, sha)` 缓存 **60 秒**。客户端镜像同一缓存,重复点同一条提交零成本。 - **图的标签**:`git log --all --simplify-by-decoration --format=%H %D`,按仓库缓存 60 秒。 - **响应压缩**:API 响应 ≥ 256 字节且客户端带 `Accept-Encoding: gzip` 时自动 gzip。 -- **超时分级**:快速读(`for-each-ref`、`tag --list`、`--contains`)10 秒;中速读(`log -n 1`、`status`、`git show`)15 秒;重量级读(`log --topo-order --all`、`rev-list --all --count`)30 秒。客户端 `api()` 用 `AbortController` 兜底 35 秒。 +- **超时分级**:快速读(`for-each-ref`、`tag --list`、`--contains`)10 秒;中速读(`log -n 1`、`status`、`git show`)15 秒;重量级读(`log --topo-order --all`、`rev-list --all --count`)30 秒。客户端 `api()` 默认用 `AbortController` 兜底 35 秒,提交图请求放宽到 60 秒。 ## 偏好持久化 @@ -68,7 +68,19 @@ ## 数据与访问 -所有读取都是对本机仓库的本地 `git` 命令调用,仓库来自你在 `repos.json` 中声明的路径或扫描器在本机发现的目录。不上传任何数据,插件不发起任何对外网络请求。唯一的写入是 `/prefs.json`,只保存「偏好持久化」一节列出的筛选与主题字段。 +**读取范围**。所有读取都是对本机仓库的本地 `git` 命令调用,仓库来自你在 `repos.json` 中声明的路径或扫描器在本机发现的目录。`git` 只读取这些仓库内部的 `.git` 数据和工作区元数据(例如 `git status`)。 + +**仓库发现方式**。发现逻辑只枚举*目录名*并检查候选目录里是否存在 `.git` 条目,不会读取无关文件的内容。扫描面比 `repos.json` 更宽: + +- 你的主目录下的开发目录(`~/Code`、`~/Projects`、`~/repos`、`~/workspace`、`~/src`、`~/source`、`~/dev`、`~/work`、`~/Documents`、`~/git`),以及从插件根和工作目录向上查找。 +- Windows 上每个已挂载盘符的根目录(`A:\` 到 `Z:\`),其中包含映射的网络驱动器;系统目录会被过滤掉。 +- macOS 与 Linux 上主目录的父目录(`/Users`、`/home`),这会列出本机其他用户账户的*目录名*。 + +只有在 `repos.json` 与向上查找都没有产出任何扫描基址时,才会执行上述扫描。 + +**写入范围**。唯一的写入是 `/prefs.json`,只保存「偏好持久化」一节列出的筛选与主题字段。 + +**网络**。不上传任何数据,插件不发起任何对外网络请求。 ## 测试环境 diff --git a/plugins/microbiosis/git-tree/miniapp/client/index.html b/plugins/microbiosis/git-tree/miniapp/client/index.html index d608cae..5d21060 100644 --- a/plugins/microbiosis/git-tree/miniapp/client/index.html +++ b/plugins/microbiosis/git-tree/miniapp/client/index.html @@ -422,13 +422,6 @@ padding-right: 8px; } - .row-refs { - display: inline-flex; - align-items: center; - gap: 4px; - flex: none; - } - .ref-chip { display: inline-flex; align-items: center; @@ -447,12 +440,6 @@ white-space: nowrap; } - .ref-chip.is-head { - border-color: var(--git-lane-0); - background: var(--mcode-surface-hover); - color: var(--mcode-text); - } - .ref-chip.is-tag { border-color: var(--git-lane-2); color: var(--git-lane-2); @@ -1205,24 +1192,9 @@

      提交历史

      return 'lane-color-' + (((laneIndex % 4) + 4) % 4); } - function refsHtml(commit) { - if (!commit || !commit.refs || commit.refs.length === 0) return ''; - return commit.refs.slice(0, 4).map(function (ref) { - var kindClass = ref.kind === 'head' ? ' is-head' : ref.kind === 'tag' ? ' is-tag' : ''; - var icon = - ref.kind === 'tag' - ? '' - : ref.kind === 'head' - ? '' - : ''; - return '' + icon + esc(ref.name) + ''; - }).join(''); - } - function rowHtml(commit, index) { var selected = state.selectedSha === commit.sha; var hoverClass = state.hoveredSha && state.hoveredSha !== commit.sha ? ' is-dimmed' : ''; - var refs = refsHtml(commit); var mergeIcon = commit.merge ? '' : ''; @@ -1232,7 +1204,6 @@

      提交历史

      '" data-sha="' + esc(commit.sha) + '" data-index="' + index + '">' + '' + '' + - (refs ? '' + refs + '' : '') + '' + esc(commit.subject || '(无提交信息)') + '' + mergeIcon + '' + diff --git a/plugins/microbiosis/git-tree/miniapp/node/server.mjs b/plugins/microbiosis/git-tree/miniapp/node/server.mjs index 4f2cbb7..c8adbf2 100644 --- a/plugins/microbiosis/git-tree/miniapp/node/server.mjs +++ b/plugins/microbiosis/git-tree/miniapp/node/server.mjs @@ -304,10 +304,14 @@ async function buildRegistry(pluginRoot) { // drive root. This lets a fresh install on a brand-new host surface // projects without any manual configuration. // + // The `config.repos.length === 0` half of the gate matters: without it a + // user who listed their repos explicitly would still get a whole-disk + // scan, which is far more file access than they asked for. + // // On Windows the per-entry filter below drops system hives (`Program // Files`, `Users`, `Windows`, …) so widening to drive roots cannot // accidentally recurse into %ProgramFiles% or %SystemRoot%. - if (scanBases.size === 0) { + if (scanBases.size === 0 && config.repos.length === 0) { for (const base of discoverDefaultScanBases()) { scanBases.add(resolve(base)); }