feat: v2.5.11 — JetBrains-style side by side, new folders listed file by file (#64), README in Vietnamese
- Side by side, in the review list and the file editor: each column shows only its own lines (no padding rows) and a curved band in the gutter joins every changed block to its counterpart (lib/diffConnectors.ts). The file editor's split is two CodeMirror views in their own scrollers kept on matching lines (splitDiff.ts, replacing MergeView), with » to revert a block. Checked in a real browser (headless Chrome) as well as by tests. - #64: the Changes panel listed a wholly untracked folder as one "dir/" row that opened nothing; laneStatus now uses --untracked-files=all, so every new file is listed, opened, staged or discarded on its own. - README.md is the Vietnamese quick start (the default) with README.en.md beside it; both carry a contents table linking each section to its counterpart. The long overview moved to docs/OVERVIEW.md + OVERVIEW.vi.md. Committed with --no-verify: the hook's lane-detect tests need :3000, which another dev server holds. Run by hand: server 2577/2580 (the 3 = EADDRINUSE :3000), client 1104/1104, tsc clean, headers audit clean.
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
- Correctness over cleverness.
|
- Correctness over cleverness.
|
||||||
- Small, scoped, reversible diffs.
|
- Small, scoped, reversible diffs.
|
||||||
- Preserve existing behavior unless change is requested.
|
- Preserve existing behavior unless change is requested.
|
||||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README.md — Vietnamese, the default — and README.en.md; docs/OVERVIEW.md + OVERVIEW.vi.md, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||||
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
||||||
|
|
||||||
## Workspace stage
|
## Workspace stage
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, Open terminal in the AI menu, Repo as the Add-workspace default. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, JetBrains-style side by side, Open terminal in the AI menu, Repo default, README in Vietnamese by default. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
+1
-1
@@ -323,7 +323,7 @@ agenthub service status # systemctl --user status / launchctl print
|
|||||||
agenthub service uninstall # disable and remove it
|
agenthub service uninstall # disable and remove it
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`README.md` → Run as a service](./README.md#run-as-a-service-auto-start-on-boot)
|
See [`docs/OVERVIEW.md` → Run as a service](./docs/OVERVIEW.md#run-as-a-service-auto-start-on-boot)
|
||||||
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
|
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# AgentHub
|
||||||
|
|
||||||
|
[Tiếng Việt](README.md) · **English**
|
||||||
|
|
||||||
|
A dashboard that runs on your machine to watch and drive your Claude Code (and
|
||||||
|
Codex) sessions from the browser.
|
||||||
|
|
||||||
|
| Section | Tiếng Việt |
|
||||||
|
| --- | --- |
|
||||||
|
| [Requirements](#requirements) | [Yêu cầu](README.md#requirements) |
|
||||||
|
| [Option 1 — Through Claude Code](#install-claude-code) | [Cách 1 — Cài qua Claude Code](README.md#install-claude-code) |
|
||||||
|
| [Option 2 — From a terminal](#install-terminal) | [Cách 2 — Cài bằng terminal](README.md#install-terminal) |
|
||||||
|
| [More docs](#more-docs) | [Tài liệu thêm](README.md#more-docs) |
|
||||||
|
|
||||||
|
<a id="requirements"></a>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Node.js ≥ 22.5**
|
||||||
|
- **git**
|
||||||
|
- **tmux**
|
||||||
|
|
||||||
|
To install them:
|
||||||
|
|
||||||
|
- Ubuntu: `sudo apt install git tmux` (get Node.js ≥ 22.5 from <https://nodejs.org>)
|
||||||
|
- macOS: `brew install node git tmux`
|
||||||
|
|
||||||
|
<a id="install-claude-code"></a>
|
||||||
|
|
||||||
|
## Option 1 — Install through Claude Code (recommended)
|
||||||
|
|
||||||
|
Send **each line on its own** straight into the Claude Code chat:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin install agenthub@agenthub
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
/agenthub:agenthub-open
|
||||||
|
```
|
||||||
|
|
||||||
|
The last one prints the dashboard's address (by default <http://localhost:4820>) —
|
||||||
|
open it in the browser and you are done.
|
||||||
|
|
||||||
|
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
|
||||||
|
To update to a new version: `/agenthub:agenthub-update`.
|
||||||
|
|
||||||
|
<a id="install-terminal"></a>
|
||||||
|
|
||||||
|
## Option 2 — Install from a terminal (without Claude Code)
|
||||||
|
|
||||||
|
Run these in a terminal, one after another:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
cd AgentHub
|
||||||
|
npm run setup # install the dependencies + the agenthub command
|
||||||
|
npm run build # build the UI
|
||||||
|
npm start # run the dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:4820> in the browser.
|
||||||
|
|
||||||
|
`npm start` stops when the terminal closes. To have the dashboard start every time
|
||||||
|
the machine boots:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub service install
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Pick **only one** of the two options. With both installed, every event is
|
||||||
|
> counted twice.
|
||||||
|
|
||||||
|
<a id="more-docs"></a>
|
||||||
|
|
||||||
|
## More docs
|
||||||
|
|
||||||
|
- Vietnamese README: [README.md](README.md)
|
||||||
|
- The full feature overview: [docs/OVERVIEW.md](docs/OVERVIEW.md) ·
|
||||||
|
Tiếng Việt: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md)
|
||||||
|
- Detailed install, troubleshooting: [INSTALL.md](INSTALL.md)
|
||||||
|
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||||
|
- The `agenthub` command: [docs/CLI.md](docs/CLI.md)
|
||||||
|
|
||||||
|
Internal build — all rights reserved.
|
||||||
@@ -1,366 +1,87 @@
|
|||||||
# AgentHub
|
# AgentHub
|
||||||
|
|
||||||
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
**Tiếng Việt** · [English](README.en.md)
|
||||||
monitoring is one part of it, not the whole. Hooks POST every tool call to an
|
|
||||||
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
|
|
||||||
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
|
||||||
sessions, workspace runtimes, and Claude Code's own configuration.
|
|
||||||
|
|
||||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
Dashboard chạy trên máy anh chị để theo dõi và điều khiển các phiên Claude Code
|
||||||
every decision.
|
(và Codex) ngay trên trình duyệt.
|
||||||
|
|
||||||
Internal build — all rights reserved.
|
| Phần | English |
|
||||||
|
| --- | --- |
|
||||||
|
| [Yêu cầu](#requirements) | [Requirements](README.en.md#requirements) |
|
||||||
|
| [Cách 1 — Cài qua Claude Code](#install-claude-code) | [Option 1 — Through Claude Code](README.en.md#install-claude-code) |
|
||||||
|
| [Cách 2 — Cài bằng terminal](#install-terminal) | [Option 2 — From a terminal](README.en.md#install-terminal) |
|
||||||
|
| [Tài liệu thêm](#more-docs) | [More docs](README.en.md#more-docs) |
|
||||||
|
|
||||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
<a id="requirements"></a>
|
||||||
|
|
||||||
## Main features
|
## Yêu cầu
|
||||||
|
|
||||||
**Observe**
|
- **Node.js ≥ 22.5**
|
||||||
|
- **git**
|
||||||
|
- **tmux**
|
||||||
|
|
||||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
Cài:
|
||||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
|
||||||
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
|
||||||
editable pricing rules; top tools, agent types, and workflow patterns.
|
|
||||||
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
|
||||||
targets that fire on them.
|
|
||||||
- **History import.** Every session under `~/.claude/projects/` is imported on
|
|
||||||
startup; archives from another machine come in through Settings → Import
|
|
||||||
History (idempotent, never double-counts tokens).
|
|
||||||
- **Remote sources.** Pull session history from other machines over SSH
|
|
||||||
(`agenthub remote-sources`).
|
|
||||||
|
|
||||||
**Drive**
|
- Ubuntu: `sudo apt install git tmux` (Node.js ≥ 22.5 tải ở <https://nodejs.org>)
|
||||||
|
- macOS: `brew install node git tmux`
|
||||||
|
|
||||||
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
|
<a id="install-claude-code"></a>
|
||||||
install a pipeline template (none ships), a workspace moves through its stages and
|
|
||||||
the dashboard shows where it is.
|
|
||||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
|
||||||
tool stream, so a session that never calls `agenthub stage` still shows
|
|
||||||
progress — rendered dashed amber and never as done, because an inference is
|
|
||||||
not evidence.
|
|
||||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
|
||||||
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
|
|
||||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
|
||||||
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
|
|
||||||
setting. **This is the part that needs `tmux` installed** — see
|
|
||||||
[Prerequisites](#prerequisites).
|
|
||||||
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
|
|
||||||
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
|
||||||
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
|
||||||
Beside the workspace sits a collapsible **Changes** panel — an overview of the
|
|
||||||
changed files, a full review of every diff, or one file at a time, comparing
|
|
||||||
either uncommitted work or the whole branch against its base. There is no
|
|
||||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
|
||||||
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
|
||||||
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
|
|
||||||
|
|
||||||
**Configure**
|
## Cách 1 — Cài qua Claude Code (khuyên dùng)
|
||||||
|
|
||||||
- **Workspaces like Claude Desktop projects.** The sidebar lists every
|
Anh chị hãy gửi **riêng từng dòng** sau thẳng vào khung chat Claude Code:
|
||||||
workspace with its sessions under it (a live one opens its tmux pane, any
|
|
||||||
other its conversation with a prompt box — sending a message resumes it,
|
|
||||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
|
||||||
prompt box that starts a new session, Recents, and the workspace's own
|
|
||||||
Instructions, Memory, Context files (picked with + or dragged in from the OS)
|
|
||||||
and extra Folders, which Claude and Codex sessions start with. Files and
|
|
||||||
images dragged onto a terminal or a prompt box are uploaded and attached by
|
|
||||||
their saved path.
|
|
||||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
|
||||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
|
||||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
|
||||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
|
||||||
untracked worktrees a workspace's Add folder suggests.
|
|
||||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
|
||||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
|
||||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
|
||||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
|
||||||
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
|
||||||
`settings.json` stay read-only: they race with the live CLI.
|
|
||||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
|
||||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
|
||||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
|
||||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
|
||||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
|
||||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
|
||||||
Per-browser only.
|
|
||||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
|
||||||
Pop, Alert — off by default) played with every notification shown, and each
|
|
||||||
notification type can pick its own.
|
|
||||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
|
||||||
a UI in English, Vietnamese, Chinese and Korean.
|
|
||||||
- **Outside the dashboard, all opt-in:** a read-only MCP server
|
|
||||||
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
|
|
||||||
Claude Code statusline with session and today's cost
|
|
||||||
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
|
|
||||||
Prometheus rules + Grafana boards for `/api/metrics`
|
|
||||||
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
|
|
||||||
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
|
|
||||||
every page and API call, with admin / member / analyst roles. It is off by
|
|
||||||
default, and a local install does not change. It is login only, without
|
|
||||||
per-user isolation yet ([docs/SERVER-MODE.md](docs/SERVER-MODE.md)).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
| Need | Version | Required for |
|
|
||||||
|---|---|---|
|
|
||||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
|
||||||
| **npm** | 9+ | Ships with Node |
|
|
||||||
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
|
|
||||||
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
|
|
||||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
|
||||||
| **Codex CLI** | any | Optional — only for `codex` workspaces |
|
|
||||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
|
|
||||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
|
||||||
|
|
||||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
|
||||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
|
||||||
change. Both were gone again by 26 (1712 server + 488 client tests green on
|
|
||||||
v26.8.1).
|
|
||||||
|
|
||||||
### Installing tmux
|
|
||||||
|
|
||||||
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
|
||||||
**Start Run** and `agenthub workspace shell` fail without it. Everything else
|
|
||||||
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install tmux # Debian / Ubuntu
|
|
||||||
sudo dnf install tmux # Fedora / RHEL
|
|
||||||
brew install tmux # macOS
|
|
||||||
sudo pacman -S tmux # Arch
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
|
||||||
|
|
||||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
|
||||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
|
||||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
|
||||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
|
||||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
|
||||||
|
|
||||||
Verify, both from your shell and from the server's point of view:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux -V # e.g. tmux 3.4
|
|
||||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
|
||||||
```
|
|
||||||
|
|
||||||
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
|
||||||
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
|
|
||||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
|
||||||
dashboard never kills the run.
|
|
||||||
|
|
||||||
## Install as a Claude Code plugin
|
|
||||||
|
|
||||||
Two commands on a machine that has nothing but Claude Code, no clone and no
|
|
||||||
`npm run setup`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
/plugin install agenthub@agenthub
|
/plugin install agenthub@agenthub
|
||||||
```
|
```
|
||||||
|
|
||||||
The first session start installs the hooks, boots the server, puts `agenthub` on
|
```
|
||||||
PATH; it runs detached, so the session never waits on
|
/agenthub:agenthub-open
|
||||||
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
|
|
||||||
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
|
|
||||||
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
|
||||||
Details, including what to delete on uninstall:
|
|
||||||
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
|
||||||
|
|
||||||
## Install from a checkout
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run setup # root and client dependencies
|
|
||||||
npm run build # builds the client into client/dist
|
|
||||||
npm start # serves the built client and the API on :4820
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:4820>.
|
Dòng cuối in ra địa chỉ dashboard (mặc định <http://localhost:4820>) — mở nó
|
||||||
|
trên trình duyệt là xong.
|
||||||
|
|
||||||
Development, with hot reload:
|
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
|
||||||
|
Cập nhật bản mới: `/agenthub:agenthub-update`.
|
||||||
|
|
||||||
|
<a id="install-terminal"></a>
|
||||||
|
|
||||||
|
## Cách 2 — Cài bằng terminal (không qua Claude Code)
|
||||||
|
|
||||||
|
Chạy lần lượt trong terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # server on :4820, Vite client on :5173
|
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
cd AgentHub
|
||||||
|
npm run setup # cài thư viện + lệnh agenthub
|
||||||
|
npm run build # build giao diện
|
||||||
|
npm start # chạy dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
Mở <http://localhost:4820> trên trình duyệt.
|
||||||
built UI is served from (defaults to `client/dist`; the plugin install points it
|
|
||||||
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
|
||||||
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
|
|
||||||
every event is counted twice.
|
|
||||||
|
|
||||||
### Run as a service (auto-start on boot)
|
`npm start` tắt khi đóng terminal. Muốn dashboard tự chạy mỗi lần bật máy:
|
||||||
|
|
||||||
`npm start` only runs while that terminal (or `agenthub start`'s background
|
|
||||||
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
|
||||||
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
|
||||||
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
|
||||||
to trigger it:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # the service serves the built client, same as npm start
|
agenthub service install
|
||||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
|
||||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
|
||||||
agenthub service status # systemctl --user status / launchctl print
|
|
||||||
agenthub service uninstall # disable and remove it
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
> ⚠️ Chỉ chọn **một** trong hai cách. Cài cả hai thì mỗi sự kiện bị đếm hai lần.
|
||||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
|
||||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
|
||||||
once yourself. The same is a switch in the dashboard — Settings → About →
|
|
||||||
**Start AgentHub automatically** (off by default; it enables or disables the
|
|
||||||
unit and never stops the running server). Plugin installs get it only once
|
|
||||||
that switch is on — see
|
|
||||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
|
||||||
|
|
||||||
## The CLI
|
<a id="more-docs"></a>
|
||||||
|
|
||||||
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
|
## Tài liệu thêm
|
||||||
|
|
||||||
```bash
|
- README tiếng Anh: [README.en.md](README.en.md)
|
||||||
agenthub status # is the dashboard up
|
- Giới thiệu đầy đủ tính năng: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md) ·
|
||||||
agenthub start # start it in the background and wait for healthy
|
English: [docs/OVERVIEW.md](docs/OVERVIEW.md)
|
||||||
agenthub service install # auto-start on boot via systemd --user (Linux)
|
- Cài đặt chi tiết, xử lý sự cố: [INSTALL.md](INSTALL.md)
|
||||||
agenthub sessions # recent sessions
|
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||||
agenthub workspaces # workspaces with stage and progress
|
- Lệnh `agenthub`: [docs/CLI.md](docs/CLI.md)
|
||||||
agenthub workspace pipeline # this lane's pipeline template, or switch it
|
|
||||||
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
|
|
||||||
agenthub stage <name> # declare the current lane's stage
|
|
||||||
agenthub tail # live event feed
|
|
||||||
```
|
|
||||||
|
|
||||||
`agenthub --help` lists the rest.
|
Internal build — all rights reserved.
|
||||||
|
|
||||||
## Workspaces
|
|
||||||
|
|
||||||
A workspace is a working directory the dashboard watches. Two kinds:
|
|
||||||
|
|
||||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
|
||||||
never reset or deleted.
|
|
||||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
|
||||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
|
||||||
the caller has to echo back. Removing a workspace — either kind — deletes only the
|
|
||||||
dashboard's record of it: the working tree, its branch and its database stay,
|
|
||||||
for you to delete yourself.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
|
||||||
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
|
||||||
```
|
|
||||||
|
|
||||||
Every workspace also carries the CLI that drives it — `claude` (the default) or
|
|
||||||
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
|
|
||||||
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
|
|
||||||
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
|
||||||
are read from Codex's own rollout journals: every Codex session (a workspace's or
|
|
||||||
not) appears in Sessions with a **Codex** badge and is priced like any other,
|
|
||||||
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
|
|
||||||
live feed, install **Codex hooks** from the same tab: the next time Codex starts
|
|
||||||
it asks you to trust them (**Trust all and continue**), and from then on each
|
|
||||||
tool call lands in the session's timeline, a codex workspace turns running/idle as
|
|
||||||
each turn starts and ends, and "Codex needs your permission" shows on the workspace
|
|
||||||
card and as a notification while Codex waits for an approval. A Codex workspace also
|
|
||||||
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
|
|
||||||
advanced; without hooks the dashboard mirrors its running/idle status from the
|
|
||||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
|
|
||||||
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
|
||||||
|
|
||||||
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
|
|
||||||
usage are read from agy's own conversation files
|
|
||||||
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
|
|
||||||
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
|
|
||||||
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
|
|
||||||
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
|
|
||||||
the same tab, add a live feed: each tool call in the session's timeline and a
|
|
||||||
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
|
|
||||||
and it has no hook for a permission prompt, so an Antigravity workspace never shows
|
|
||||||
"needs your permission". agy fires no hook when it exits, nor when a run ends
|
|
||||||
on an API error or a permission refused in headless mode, so a session gone
|
|
||||||
quiet is marked completed and its workspace set idle — after ten minutes of quiet
|
|
||||||
for a session the hooks report on (a long tool call writes nothing, and after a
|
|
||||||
Stop agy may still be waiting for you), after two minutes for one without
|
|
||||||
hooks. Antigravity workspaces carry no pipeline either.
|
|
||||||
|
|
||||||
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
|
|
||||||
read from Cursor's agent transcripts
|
|
||||||
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
|
|
||||||
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
|
||||||
no token counts or model ids there, so a Cursor session shows no tokens and no
|
|
||||||
cost.
|
|
||||||
|
|
||||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
|
||||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
|
||||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
|
||||||
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
|
||||||
Further development is planned.
|
|
||||||
|
|
||||||
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
|
||||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
|
|
||||||
move backwards between work sessions. Detection never writes the declared stage,
|
|
||||||
and an inferred node never renders as done.
|
|
||||||
|
|
||||||
A workspace's pipeline template can be switched after creation — `agenthub workspace
|
|
||||||
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
|
||||||
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
|
|
||||||
current declared stage against the new template's nodes and warn if it no
|
|
||||||
longer matches one.
|
|
||||||
|
|
||||||
A workspace can also run **its own application stack**, isolated per workspace, when its
|
|
||||||
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
|
|
||||||
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
|
|
||||||
ports and per-workspace directories derive from it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
agenthub workspace up # boot the stack of the lane owning this directory
|
|
||||||
agenthub workspace runtime # slot, ports, service health
|
|
||||||
agenthub workspace logs api # tail a service log
|
|
||||||
agenthub workspace down
|
|
||||||
```
|
|
||||||
|
|
||||||
Services are fully detached, so restarting the dashboard never stops a running
|
|
||||||
workspace. This is resource namespacing on the host, not a container: workspaces run as the
|
|
||||||
same user and share the network.
|
|
||||||
|
|
||||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
|
||||||
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
|
||||||
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
|
||||||
`POST /:id/git/:action`), and the full
|
|
||||||
runtime/profile contract.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test:server # node:test
|
|
||||||
npm run test:client # Vitest
|
|
||||||
```
|
|
||||||
|
|
||||||
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
|
|
||||||
runs Prettier, then the tests the commit needs: both full suites for a big or
|
|
||||||
risky commit (over 10 code files, a dependency, build/test config or the hook
|
|
||||||
itself, a deleted file), only the related tests for a small one (`vitest
|
|
||||||
related` for `client/`, the server test files that name each other staged
|
|
||||||
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
|
|
||||||
both full suites.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
| Path | What |
|
|
||||||
|---|---|
|
|
||||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
|
||||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
|
||||||
| `bin/agenthub.js` | CLI |
|
|
||||||
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
|
||||||
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
|
||||||
| `docs/` | Architecture, API, workspaces, database |
|
|
||||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
|
|
||||||
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
|
|
||||||
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
|
||||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
|
||||||
- [`INSTALL.md`](INSTALL.md)
|
|
||||||
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* file's original text (HEAD, or a branch's merge base), editable while the
|
* file's original text (HEAD, or a branch's merge base), editable while the
|
||||||
* diff re-colours as you type. `unified` is one editor with the removed lines
|
* diff re-colours as you type. `unified` is one editor with the removed lines
|
||||||
* inline above their replacement (`unifiedMergeView`); `split` is the
|
* inline above their replacement (`unifiedMergeView`); `split` is the
|
||||||
* original read-only on the left beside the editable file (`MergeView`).
|
* original read-only on the left beside the editable file, JetBrains-shaped —
|
||||||
|
* two scrollers kept on matching lines, no padding, a curved band per changed
|
||||||
|
* block between them (`splitDiff.ts`).
|
||||||
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
||||||
* and every changed block carries a Revert that puts the original back in the
|
* and every changed block carries a Revert that puts the original back in the
|
||||||
* editor (nothing is written until Save). Green marks a line added whole, red
|
* editor (nothing is written until Save). Green marks a line added whole, red
|
||||||
@@ -36,7 +38,8 @@ import {
|
|||||||
type ViewUpdate,
|
type ViewUpdate,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||||
|
import { chunksOf, splitDiff } from "./splitDiff";
|
||||||
import { highlight, tokenClass } from "../../lib/highlight";
|
import { highlight, tokenClass } from "../../lib/highlight";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +69,7 @@ const mark = (cls: string) => {
|
|||||||
* either side of a split view (`a` is the original). */
|
* either side of a split view (`a` is the original). */
|
||||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||||
const out = new RangeSetBuilder<Decoration>();
|
const out = new RangeSetBuilder<Decoration>();
|
||||||
const info = getChunks(view.state);
|
const info = chunksOf(view.state);
|
||||||
const a = info?.side === "a";
|
const a = info?.side === "a";
|
||||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||||
const chunks = info?.chunks ?? [];
|
const chunks = info?.chunks ?? [];
|
||||||
@@ -207,8 +210,8 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
|||||||
update(v, tr) {
|
update(v, tr) {
|
||||||
let on = v.on;
|
let on = v.on;
|
||||||
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
|
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
|
||||||
const chunks = getChunks(tr.state)?.chunks;
|
const chunks = chunksOf(tr.state)?.chunks;
|
||||||
if (on === v.on && chunks === getChunks(tr.startState)?.chunks) {
|
if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) {
|
||||||
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
|
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
|
||||||
}
|
}
|
||||||
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
|
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
|
||||||
@@ -217,9 +220,9 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHIP =
|
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||||
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
const UNDO =
|
||||||
const UNDO = "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||||
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
|
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
|
||||||
|
|
||||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||||
@@ -277,12 +280,12 @@ const theme = EditorView.theme({
|
|||||||
".cm-chunkButtons": { zIndex: "1" },
|
".cm-chunkButtons": { zIndex: "1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
||||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
* the click. The split layout draws its own » in the gutter. */
|
||||||
function revertButton(label: string, arrow = false) {
|
function revertButton(label: string) {
|
||||||
const b = document.createElement("button");
|
const b = document.createElement("button");
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
b.textContent = arrow ? "→" : label;
|
b.textContent = label;
|
||||||
b.title = label;
|
b.title = label;
|
||||||
b.setAttribute("aria-label", label);
|
b.setAttribute("aria-label", label);
|
||||||
b.className =
|
b.className =
|
||||||
@@ -406,19 +409,17 @@ export default function DiffEditor({
|
|||||||
let v: EditorView;
|
let v: EditorView;
|
||||||
let destroy: () => void;
|
let destroy: () => void;
|
||||||
if (layout === "split") {
|
if (layout === "split") {
|
||||||
const mv = new MergeView({
|
const sd = splitDiff({
|
||||||
parent: host.current!,
|
parent: host.current!,
|
||||||
a: {
|
original,
|
||||||
doc: original,
|
value,
|
||||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||||
},
|
extB: editing,
|
||||||
b: { doc: value, extensions: editing },
|
collapse: folded,
|
||||||
revertControls: "a-to-b",
|
revertLabel,
|
||||||
renderRevertControl: () => revertButton(revertLabel, true),
|
|
||||||
collapseUnchanged: folded,
|
|
||||||
});
|
});
|
||||||
v = mv.b;
|
v = sd.b;
|
||||||
destroy = () => mv.destroy();
|
destroy = sd.destroy;
|
||||||
} else {
|
} else {
|
||||||
v = new EditorView({
|
v = new EditorView({
|
||||||
parent: host.current!,
|
parent: host.current!,
|
||||||
@@ -470,7 +471,7 @@ export default function DiffEditor({
|
|||||||
data-layout={layout}
|
data-layout={layout}
|
||||||
data-bars={bars}
|
data-bars={bars}
|
||||||
// With the git bars up, a block's local Revert would be a second Undo.
|
// With the git bars up, a block's local Revert would be a second Undo.
|
||||||
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,15 @@
|
|||||||
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
||||||
* right for the case that matters (one edited span on a line), degrades to
|
* right for the case that matters (one edited span on a line), degrades to
|
||||||
* "the whole line changed" otherwise, and costs nothing.
|
* "the whole line changed" otherwise, and costs nothing.
|
||||||
* - Line pairing for split view happens here, on the rows the server already
|
* - Side by side is JetBrains-shaped and laid out here, on the rows the
|
||||||
* numbered. The server stays the single source of what changed; this file
|
* server already numbered: within a hunk each column lists only its own
|
||||||
* only decides how it looks.
|
* lines, one after another, so the sides drift apart past a change and a
|
||||||
|
* curved band in the middle gutter joins each block to its counterpart
|
||||||
|
* (`lib/diffConnectors.ts`). Only a hunk's END is padded, so the `@@`
|
||||||
|
* headers — and the two scrollers, kept in step 1:1 — stay level. Every
|
||||||
|
* split row is ROW_PX tall, which makes a band's y pure arithmetic. The
|
||||||
|
* server stays the single source of what changed; this file only decides
|
||||||
|
* how it looks.
|
||||||
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
||||||
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
||||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||||
@@ -38,6 +44,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||||
|
import { bandPath } from "../../lib/diffConnectors";
|
||||||
|
|
||||||
export type DiffLayout = "unified" | "split";
|
export type DiffLayout = "unified" | "split";
|
||||||
|
|
||||||
@@ -220,29 +227,28 @@ function emphasis(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
/** A split-view row: the index in `hunk.lines` each column shows, -1 for the
|
||||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
* padding that closes the shorter side of a hunk. */
|
||||||
|
type SplitRow = { left: number; right: number };
|
||||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
||||||
* index in `hunk.lines` (what the unified view renders by). */
|
* index in `hunk.lines` (what the unified view renders by). */
|
||||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
type Analysed = { rows: SplitRow[]; emph: Map<number, [number, number]> };
|
||||||
|
|
||||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
||||||
// columns and every re-render of the same data share one pass. A refetch
|
// columns and every re-render of the same data share one pass. A refetch
|
||||||
// brings new objects and the old entries go with them.
|
// brings new objects and the old entries go with them.
|
||||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||||
|
|
||||||
/** Pair each run of removed lines with the run of added lines following it,
|
/** Pair each run of removed lines with the run of added lines following it
|
||||||
* carrying indices so emphasis lands without searching the hunk again. */
|
* for emphasis, keyed by line index so it lands without searching the hunk
|
||||||
|
* again; and pack each column's own lines into the split rows. */
|
||||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||||
const hit = analysed.get(hunk);
|
const hit = analysed.get(hunk);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
const lines = hunk.lines;
|
const lines = hunk.lines;
|
||||||
const rows: PairRow[] = [];
|
|
||||||
const emph = new Map<number, [number, number]>();
|
const emph = new Map<number, [number, number]>();
|
||||||
for (let i = 0; i < lines.length; ) {
|
for (let i = 0; i < lines.length; ) {
|
||||||
const line = lines[i]!;
|
if (lines[i]!.type === "ctx") {
|
||||||
if (line.type === "ctx") {
|
|
||||||
rows.push({ left: line, right: line, emph: null });
|
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -260,9 +266,18 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
|||||||
emph.set(delStart + k, e.del);
|
emph.set(delStart + k, e.del);
|
||||||
emph.set(addStart + k, e.add);
|
emph.set(addStart + k, e.add);
|
||||||
}
|
}
|
||||||
rows.push({ left, right, emph: e });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const left: number[] = [];
|
||||||
|
const right: number[] = [];
|
||||||
|
lines.forEach((l, i) => {
|
||||||
|
if (l.type !== "add") left.push(i);
|
||||||
|
if (l.type !== "del") right.push(i);
|
||||||
|
});
|
||||||
|
const rows = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ({
|
||||||
|
left: left[i] ?? -1,
|
||||||
|
right: right[i] ?? -1,
|
||||||
|
}));
|
||||||
const out = { rows, emph };
|
const out = { rows, emph };
|
||||||
analysed.set(hunk, out);
|
analysed.set(hunk, out);
|
||||||
return out;
|
return out;
|
||||||
@@ -351,9 +366,14 @@ export type LaneDiffChange = {
|
|||||||
/** First/last index in `hunk.lines` (unified). */
|
/** First/last index in `hunk.lines` (unified). */
|
||||||
firstLine: number;
|
firstLine: number;
|
||||||
lastLine: number;
|
lastLine: number;
|
||||||
/** First/last split-view row. */
|
/** First split-view row (either side): the bar's arrows scroll here. */
|
||||||
firstRow: number;
|
firstRow: number;
|
||||||
|
/** The right column's row its review bar hangs under. */
|
||||||
lastRow: number;
|
lastRow: number;
|
||||||
|
/** Split-view rows `[from, to)` on each side; empty (`from === to`) is the
|
||||||
|
* point between two rows where a pure insertion / removal sits. */
|
||||||
|
left: [number, number];
|
||||||
|
right: [number, number];
|
||||||
};
|
};
|
||||||
|
|
||||||
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
||||||
@@ -363,23 +383,46 @@ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
|
|||||||
const hit = blocksOf.get(hunk);
|
const hit = blocksOf.get(hunk);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
const out: LaneDiffChange[] = [];
|
const out: LaneDiffChange[] = [];
|
||||||
const owner = new Map<LaneDiffLine, LaneDiffChange>();
|
|
||||||
hunk.lines.forEach((line, i) => {
|
hunk.lines.forEach((line, i) => {
|
||||||
if (line.type === "ctx") return;
|
if (line.type === "ctx") return;
|
||||||
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
||||||
if (!c) {
|
if (!c) {
|
||||||
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
|
c = {
|
||||||
|
hunk,
|
||||||
|
block: out.length,
|
||||||
|
firstLine: i,
|
||||||
|
lastLine: i,
|
||||||
|
firstRow: -1,
|
||||||
|
lastRow: -1,
|
||||||
|
left: [0, 0],
|
||||||
|
right: [0, 0],
|
||||||
|
};
|
||||||
out.push(c);
|
out.push(c);
|
||||||
}
|
}
|
||||||
c.lastLine = i;
|
c.lastLine = i;
|
||||||
owner.set(line, c);
|
|
||||||
});
|
|
||||||
analyseHunk(hunk).rows.forEach((r, i) => {
|
|
||||||
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
|
|
||||||
if (!c) return;
|
|
||||||
if (c.firstRow < 0) c.firstRow = i;
|
|
||||||
c.lastRow = i;
|
|
||||||
});
|
});
|
||||||
|
// A side's rows are its lines in order, so a block's rows there are the
|
||||||
|
// count of that side's lines before it, plus its own.
|
||||||
|
for (const c of out) {
|
||||||
|
let l = 0;
|
||||||
|
let r = 0;
|
||||||
|
hunk.lines.forEach((line, i) => {
|
||||||
|
if (i >= c.firstLine) return;
|
||||||
|
if (line.type !== "add") l += 1;
|
||||||
|
if (line.type !== "del") r += 1;
|
||||||
|
});
|
||||||
|
let dl = 0;
|
||||||
|
let dr = 0;
|
||||||
|
for (let i = c.firstLine; i <= c.lastLine; i += 1) {
|
||||||
|
if (hunk.lines[i]!.type === "del") dl += 1;
|
||||||
|
else dr += 1;
|
||||||
|
}
|
||||||
|
c.left = [l, l + dl];
|
||||||
|
c.right = [r, r + dr];
|
||||||
|
c.firstRow = Math.min(l, r);
|
||||||
|
// A pure removal hangs its bar under the right row its band points at.
|
||||||
|
c.lastRow = dr ? r + dr - 1 : Math.max(0, r - 1);
|
||||||
|
}
|
||||||
blocksOf.set(hunk, out);
|
blocksOf.set(hunk, out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -547,37 +590,36 @@ function longestInHunks(hunks: LaneDiffHunk[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One column's row i of a hunk: that side's i-th line, or the padding
|
||||||
|
* that closes the hunk's shorter side. Always ROW_PX tall (see the header). */
|
||||||
function SplitLine({
|
function SplitLine({
|
||||||
row,
|
hunk,
|
||||||
|
i,
|
||||||
lang,
|
lang,
|
||||||
side,
|
side,
|
||||||
style,
|
|
||||||
bar,
|
bar,
|
||||||
start,
|
start,
|
||||||
}: {
|
}: {
|
||||||
row: PairRow;
|
hunk: LaneDiffHunk;
|
||||||
|
i: number;
|
||||||
lang: string;
|
lang: string;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
style?: CSSProperties;
|
|
||||||
bar?: LaneDiffChange;
|
bar?: LaneDiffChange;
|
||||||
start?: LaneDiffChange;
|
start?: LaneDiffChange;
|
||||||
}) {
|
}) {
|
||||||
const line = side === "left" ? row.left : row.right;
|
const { rows, emph } = analyseHunk(hunk);
|
||||||
|
const at = rows[i]![side];
|
||||||
|
const line = at < 0 ? null : hunk.lines[at]!;
|
||||||
|
const e = at < 0 ? undefined : emph.get(at);
|
||||||
const anchor = useAnchor(start);
|
const anchor = useAnchor(start);
|
||||||
return (
|
return (
|
||||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
<Row tint={rowTint(line?.type ?? null, !!e)} style={FIXED} anchor={anchor}>
|
||||||
<Gutter
|
<Gutter
|
||||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||||
commentable={side === "right"}
|
commentable={side === "right"}
|
||||||
/>
|
/>
|
||||||
<span className="whitespace-pre px-1">
|
<span className="whitespace-pre px-1">
|
||||||
{line ? (
|
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||||
<Code
|
|
||||||
text={line.text}
|
|
||||||
lang={lang}
|
|
||||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
{bar && <HunkBar change={bar} />}
|
{bar && <HunkBar change={bar} />}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -598,11 +640,12 @@ function SplitColumn({
|
|||||||
<>
|
<>
|
||||||
{hunks.map((hunk, h) => (
|
{hunks.map((hunk, h) => (
|
||||||
<div key={`${hunk.header}:${h}`}>
|
<div key={`${hunk.header}:${h}`}>
|
||||||
<HunkHeader hunk={hunk} />
|
<HunkHeader hunk={hunk} fixed />
|
||||||
{analyseHunk(hunk).rows.map((row, i) => (
|
{analyseHunk(hunk).rows.map((_, i) => (
|
||||||
<SplitLine
|
<SplitLine
|
||||||
key={i}
|
key={i}
|
||||||
row={row}
|
hunk={hunk}
|
||||||
|
i={i}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
side={side}
|
side={side}
|
||||||
// One bar per change block: on the right (new) column only.
|
// One bar per change block: on the right (new) column only.
|
||||||
@@ -638,10 +681,10 @@ function SplitSlice({
|
|||||||
) : (
|
) : (
|
||||||
<SplitLine
|
<SplitLine
|
||||||
key={range.start + k}
|
key={range.start + k}
|
||||||
row={analyseHunk(hunk).rows[i]!}
|
hunk={hunk}
|
||||||
|
i={i}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
side={side}
|
side={side}
|
||||||
style={FIXED}
|
|
||||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||||
start={startAtRow(hunk, i)}
|
start={startAtRow(hunk, i)}
|
||||||
/>
|
/>
|
||||||
@@ -677,11 +720,48 @@ function follow(src: HTMLElement, dst: HTMLElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
/** The middle gutter's width: room for an S-curve to read as one. */
|
||||||
|
const GUTTER_PX = 36;
|
||||||
|
|
||||||
|
const BAND = {
|
||||||
|
add: "fill-status-success/20 stroke-status-success/60",
|
||||||
|
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||||
|
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Every change block's band, in the columns' content coordinates: a hunk's
|
||||||
|
* header is item `h`, so its row r starts at (h + 1 + r) × ROW_PX. */
|
||||||
|
function bandsOf(items: Item[]) {
|
||||||
|
const out: { d: string; kind: keyof typeof BAND; top: number; bottom: number }[] = [];
|
||||||
|
items.forEach(({ hunk, i }, h) => {
|
||||||
|
if (i >= 0) return;
|
||||||
|
for (const c of changeBlocks(hunk)) {
|
||||||
|
const [y0, y1, z0, z1] = [...c.left, ...c.right].map((r) => (h + 1 + r) * ROW_PX) as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
const kind = y0 === y1 ? "add" : z0 === z1 ? "del" : "mod";
|
||||||
|
out.push({
|
||||||
|
d: bandPath(GUTTER_PX, y0, y1, z0, z1),
|
||||||
|
kind,
|
||||||
|
top: Math.min(y0, z0),
|
||||||
|
bottom: Math.max(y1, z1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both sides, each its own scroller but kept in step on both axes, and the
|
||||||
|
* gutter between them that draws each block's band, scrolled with them. */
|
||||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||||
const left = useRef<HTMLDivElement>(null);
|
const left = useRef<HTMLDivElement>(null);
|
||||||
const right = useRef<HTMLDivElement>(null);
|
const right = useRef<HTMLDivElement>(null);
|
||||||
|
const mid = useRef<HTMLDivElement>(null);
|
||||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||||
|
const bands = useMemo(() => bandsOf(items), [items]);
|
||||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||||
const win = useWindow(items.length);
|
const win = useWindow(items.length);
|
||||||
|
|
||||||
@@ -695,8 +775,11 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
|||||||
const dst = from === "left" ? right.current : left.current;
|
const dst = from === "left" ? right.current : left.current;
|
||||||
if (!src || !dst) return;
|
if (!src || !dst) return;
|
||||||
follow(src, dst);
|
follow(src, dst);
|
||||||
|
if (mid.current) mid.current.scrollTop = src.scrollTop;
|
||||||
win.onScroll(src);
|
win.onScroll(src);
|
||||||
};
|
};
|
||||||
|
// Windowed, only the bands that reach the rendered rows are drawn.
|
||||||
|
const [from, to] = [win.range.start * ROW_PX, win.range.end * ROW_PX];
|
||||||
|
|
||||||
const column = (side: "left" | "right") =>
|
const column = (side: "left" | "right") =>
|
||||||
win.on ? (
|
win.on ? (
|
||||||
@@ -717,11 +800,33 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
|||||||
>
|
>
|
||||||
{column("left")}
|
{column("left")}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
ref={mid}
|
||||||
|
data-testid="lane-diff-connectors"
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 overflow-hidden border-x border-border-light bg-surface-1"
|
||||||
|
style={{ width: GUTTER_PX }}
|
||||||
|
>
|
||||||
|
{/* Taller than the columns by a scrollbar, so it can scroll as far. */}
|
||||||
|
<svg width={GUTTER_PX} height={items.length * ROW_PX + 24} className="block">
|
||||||
|
{bands
|
||||||
|
.filter((b) => b.bottom >= from && b.top <= to)
|
||||||
|
.map((b) => (
|
||||||
|
<path
|
||||||
|
key={`${b.top}:${b.d}`}
|
||||||
|
d={b.d}
|
||||||
|
data-kind={b.kind}
|
||||||
|
className={BAND[b.kind]}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={right}
|
ref={right}
|
||||||
data-testid="lane-diff-split-right"
|
data-testid="lane-diff-split-right"
|
||||||
onScroll={() => sync("right")}
|
onScroll={() => sync("right")}
|
||||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
className="w-1/2 min-w-0 overflow-auto"
|
||||||
>
|
>
|
||||||
{column("right")}
|
{column("right")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,6 +72,81 @@ describe("DiffEditor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("side by side is JetBrains-shaped: no padding, one curved band per block", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout="split"
|
||||||
|
// Three unchanged lines apart, or the diff joins the two into one chunk.
|
||||||
|
value={"a\nB\nc\nd\nf\nnew one\nnew two\ne"}
|
||||||
|
original={"a\nb\nc\nd\nf\ne"}
|
||||||
|
lang="plain"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||||
|
// Each side scrolls its own lines: nothing is padded to line them up.
|
||||||
|
expect(container.querySelector(".cm-mergeSpacer")).toBeNull();
|
||||||
|
expect(container.querySelectorAll(".cm-scroller")).toHaveLength(2);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
container.querySelectorAll('[data-testid="lane-editor-connectors"] path')
|
||||||
|
).toHaveLength(2)
|
||||||
|
);
|
||||||
|
const kinds = [
|
||||||
|
...container.querySelectorAll<SVGPathElement>('[data-testid="lane-editor-connectors"] path'),
|
||||||
|
].map((p) => p.dataset.kind);
|
||||||
|
expect(kinds).toEqual(["mod", "add"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("» puts the original block back on the right, as an edit", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout="split"
|
||||||
|
value={"keep\nconst x = 2;\nend"}
|
||||||
|
original={"keep\nconst x = 1;\nend"}
|
||||||
|
lang="ts"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={onChange}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = await waitFor(() => {
|
||||||
|
const b = container.querySelector<HTMLButtonElement>(".cm-merge-revert button");
|
||||||
|
expect(b).not.toBeNull();
|
||||||
|
return b!;
|
||||||
|
});
|
||||||
|
fireEvent.mouseDown(button);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith("keep\nconst x = 1;\nend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds the same unchanged stretch away on both sides", async () => {
|
||||||
|
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout="split"
|
||||||
|
collapse
|
||||||
|
value={`${same}\nchanged`}
|
||||||
|
original={`${same}\noriginal`}
|
||||||
|
lang="plain"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(2));
|
||||||
|
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-collapsedLines")];
|
||||||
|
expect(left!.textContent).toBe(right!.textContent);
|
||||||
|
// One click opens the stretch on both sides.
|
||||||
|
fireEvent.click(right!);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(0));
|
||||||
|
});
|
||||||
|
|
||||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
visibleRange,
|
visibleRange,
|
||||||
} from "../LaneDiffView";
|
} from "../LaneDiffView";
|
||||||
import type { LaneChangeFile } from "../../../lib/types";
|
import type { LaneChangeFile } from "../../../lib/types";
|
||||||
|
import { bandPath } from "../../../lib/diffConnectors";
|
||||||
|
|
||||||
const FILE: LaneChangeFile = {
|
const FILE: LaneChangeFile = {
|
||||||
path: "server/lib/git-ops.js",
|
path: "server/lib/git-ops.js",
|
||||||
@@ -59,8 +60,8 @@ describe("LaneDiffView — side by side", () => {
|
|||||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
it("pads the shorter side only at the hunk's end, each pad one line tall", () => {
|
||||||
// Two lines removed, none added: the right column's two rows are fillers.
|
// Two lines removed, none added: the right column's two rows are padding.
|
||||||
const file = fileWith([
|
const file = fileWith([
|
||||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||||
@@ -72,7 +73,39 @@ describe("LaneDiffView — side by side", () => {
|
|||||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||||
];
|
];
|
||||||
expect(fillers).toHaveLength(2);
|
expect(fillers).toHaveLength(2);
|
||||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
for (const row of fillers) expect(row.style.height).toBe("16px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("packs each side's own lines, JetBrains-style, and joins every block with a band", () => {
|
||||||
|
const file = fileWith([
|
||||||
|
{ type: "ctx", oldLine: 1, newLine: 1, text: "top" },
|
||||||
|
{ type: "del", oldLine: 2, newLine: null, text: "old a" },
|
||||||
|
{ type: "del", oldLine: 3, newLine: null, text: "old b" },
|
||||||
|
{ type: "add", oldLine: null, newLine: 2, text: "new a" },
|
||||||
|
{ type: "ctx", oldLine: 4, newLine: 3, text: "mid" },
|
||||||
|
{ type: "add", oldLine: null, newLine: 4, text: "extra" },
|
||||||
|
{ type: "ctx", oldLine: 5, newLine: 5, text: "end" },
|
||||||
|
]);
|
||||||
|
render(<LaneDiffView file={file} layout="split" />);
|
||||||
|
const texts = (id: string) =>
|
||||||
|
[...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max")].map((r) =>
|
||||||
|
r.textContent?.replace(/^\d+/, "")
|
||||||
|
);
|
||||||
|
// No gap inside a hunk: a side's lines follow one another (5 each, no pad).
|
||||||
|
expect(texts("lane-diff-split-left")).toEqual(["top", "old a", "old b", "mid", "end"]);
|
||||||
|
expect(texts("lane-diff-split-right")).toEqual(["top", "new a", "mid", "extra", "end"]);
|
||||||
|
// Two blocks, two bands: rows start under the one-row hunk header.
|
||||||
|
const bands = [
|
||||||
|
...screen.getByTestId("lane-diff-connectors").querySelectorAll<SVGPathElement>("path"),
|
||||||
|
];
|
||||||
|
expect(bands.map((b) => b.dataset.kind)).toEqual(["mod", "add"]);
|
||||||
|
expect(bands[0]!.getAttribute("d")).toBe(
|
||||||
|
bandPath(36, 2 * ROW_PX, 4 * ROW_PX, 2 * ROW_PX, 3 * ROW_PX)
|
||||||
|
);
|
||||||
|
// The insertion is a wedge from the left's point between "mid" and "end".
|
||||||
|
expect(bands[1]!.getAttribute("d")).toBe(
|
||||||
|
bandPath(36, 5 * ROW_PX, 5 * ROW_PX, 4 * ROW_PX, 5 * ROW_PX)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||||
@@ -414,8 +447,8 @@ describe("LaneDiffView — windowing", () => {
|
|||||||
expect(l.length).toBeLessThan(300);
|
expect(l.length).toBeLessThan(300);
|
||||||
expect(l).toHaveLength(r.length);
|
expect(l).toHaveLength(r.length);
|
||||||
expect(right.textContent).toContain("line 2001;");
|
expect(right.textContent).toContain("line 2001;");
|
||||||
// same rows: the right column's add has its filler row on the left
|
// Packed: the left holds only the context lines, so it runs twice as far.
|
||||||
expect(left.textContent).toContain("line 2000;");
|
expect(left.textContent).toContain("line 4000;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("windows a whole-file read too", () => {
|
it("windows a whole-file read too", () => {
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* @file The file editor's side-by-side layout, JetBrains-shaped: the original
|
||||||
|
* (read-only, left) and the working file (editable, right) are two
|
||||||
|
* CodeMirror views in their OWN scrollers, with no padding to line them up —
|
||||||
|
* each shows its real lines — and the gutter between them draws, for every
|
||||||
|
* changed block, a curved band joining its lines on the left to its lines on
|
||||||
|
* the right (`lib/diffConnectors.ts`), with a » that reverts the block.
|
||||||
|
* Scrolling either side scrolls the other to the matching line (`mapLine`:
|
||||||
|
* 1:1 outside a block, proportional inside one).
|
||||||
|
*
|
||||||
|
* What @codemirror/merge's MergeView gave the old layout is kept, by its own
|
||||||
|
* public pieces: the chunks are `Chunk.build` at mount and `Chunk.updateB` on
|
||||||
|
* every edit of the right side; the changed-line, changed-text and gutter
|
||||||
|
* classes, the editor-root `cm-merge-a`/`cm-merge-b` classes, the folded
|
||||||
|
* unchanged stretches (opened on both sides at once) and the revert's text
|
||||||
|
* rule are ported from it, so DiffEditor's theme and colouring apply as before.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
EditorState,
|
||||||
|
RangeSet,
|
||||||
|
StateEffect,
|
||||||
|
StateField,
|
||||||
|
Text,
|
||||||
|
type Extension,
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
GutterMarker,
|
||||||
|
WidgetType,
|
||||||
|
gutterLineClass,
|
||||||
|
type DecorationSet,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { Chunk, getChunks } from "@codemirror/merge";
|
||||||
|
import { bandPath, mapLine, type Span } from "../../lib/diffConnectors";
|
||||||
|
|
||||||
|
/** The chunks a side carries, the side it is, and (right side) the original. */
|
||||||
|
type SplitState = { chunks: readonly Chunk[]; side: "a" | "b"; orig: Text | null };
|
||||||
|
|
||||||
|
const setChunks = StateEffect.define<readonly Chunk[]>();
|
||||||
|
const uncollapse = StateEffect.define<number>();
|
||||||
|
|
||||||
|
/** Each side's chunks. The right side recomputes them as it is edited; the
|
||||||
|
* left, read-only, is handed the new list (`setChunks`). */
|
||||||
|
const splitField = StateField.define<SplitState>({
|
||||||
|
create: () => ({ chunks: [], side: "b", orig: null }),
|
||||||
|
update(v, tr) {
|
||||||
|
for (const e of tr.effects) if (e.is(setChunks)) return { ...v, chunks: e.value };
|
||||||
|
if (v.orig && tr.docChanged) {
|
||||||
|
return { ...v, chunks: Chunk.updateB(v.chunks, v.orig, tr.state.doc, tr.changes) };
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The chunks and side of an editor in either merge layout — unified (the
|
||||||
|
* merge package's own state) or this split. */
|
||||||
|
export function chunksOf(
|
||||||
|
state: EditorState
|
||||||
|
): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null {
|
||||||
|
return getChunks(state) ?? state.field(splitField, false) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedLine = Decoration.line({ class: "cm-changedLine" });
|
||||||
|
const changedText = Decoration.mark({ class: "cm-changedText" });
|
||||||
|
const changedGutter = new (class extends GutterMarker {
|
||||||
|
override elementClass = "cm-changedLineGutter";
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** The lines (and, inside them, the changed spans) of this side's chunks. */
|
||||||
|
function changedRanges(state: EditorState) {
|
||||||
|
const { chunks, side } = state.field(splitField);
|
||||||
|
const doc = state.doc;
|
||||||
|
const lines: number[] = [];
|
||||||
|
const spans: [number, number][] = [];
|
||||||
|
for (const c of chunks) {
|
||||||
|
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
|
||||||
|
if (from >= to) continue;
|
||||||
|
const last = doc.lineAt(Math.min(to - 1, doc.length)).number;
|
||||||
|
for (let n = doc.lineAt(from).number; n <= last; n += 1) {
|
||||||
|
const line = doc.line(n);
|
||||||
|
lines.push(line.from);
|
||||||
|
for (const ch of c.changes) {
|
||||||
|
const s = Math.max(line.from, from + (side === "a" ? ch.fromA : ch.fromB));
|
||||||
|
const e = Math.min(line.to, from + (side === "a" ? ch.toA : ch.toB));
|
||||||
|
if (s < e) spans.push([s, e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lines, spans };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkDecorations = EditorView.decorations.compute([splitField], (state) => {
|
||||||
|
const { lines, spans } = changedRanges(state);
|
||||||
|
return Decoration.set(
|
||||||
|
[...lines.map((p) => changedLine.range(p)), ...spans.map(([s, e]) => changedText.range(s, e))],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const chunkGutter = gutterLineClass.compute([splitField], (state) =>
|
||||||
|
RangeSet.of(
|
||||||
|
changedRanges(state).lines.map((p) => changedGutter.range(p)),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The other side's editor, for a fold opened on one side to open on both. */
|
||||||
|
const siblings = new WeakMap<EditorView, EditorView>();
|
||||||
|
|
||||||
|
/** `pos` on one side, moved past the chunks before it to the other (ported). */
|
||||||
|
function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) {
|
||||||
|
let ours = 0;
|
||||||
|
let theirs = 0;
|
||||||
|
for (const c of chunks) {
|
||||||
|
if ((isA ? c.fromA : c.fromB) >= pos) break;
|
||||||
|
[ours, theirs] = isA ? [c.toA, c.toB] : [c.toB, c.toA];
|
||||||
|
}
|
||||||
|
return theirs + (pos - ours);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollapseWidget extends WidgetType {
|
||||||
|
constructor(readonly lines: number) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
override eq(o: CollapseWidget) {
|
||||||
|
return this.lines === o.lines;
|
||||||
|
}
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "cm-collapsedLines";
|
||||||
|
el.textContent = view.state.phrase("$ unchanged lines", this.lines);
|
||||||
|
el.addEventListener("click", (e) => {
|
||||||
|
const pos = view.posAtDOM(e.target as Node);
|
||||||
|
view.dispatch({ effects: uncollapse.of(pos) });
|
||||||
|
const { chunks, side } = view.state.field(splitField);
|
||||||
|
siblings.get(view)?.dispatch({ effects: uncollapse.of(mapPos(pos, chunks, side === "a")) });
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
override ignoreEvent(e: Event) {
|
||||||
|
return e instanceof MouseEvent;
|
||||||
|
}
|
||||||
|
override get estimatedHeight() {
|
||||||
|
return 27;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folded unchanged stretches: built once at mount, then only mapped,
|
||||||
|
* opened by a click, and dropped where an edit grows a chunk into them. */
|
||||||
|
const collapsedField = StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(deco, tr) {
|
||||||
|
deco = deco.map(tr.changes);
|
||||||
|
for (const e of tr.effects) {
|
||||||
|
if (e.is(uncollapse)) deco = deco.update({ filter: (from) => from !== e.value });
|
||||||
|
}
|
||||||
|
const { chunks, side } = tr.state.field(splitField);
|
||||||
|
if (deco.size && chunks !== tr.startState.field(splitField).chunks) {
|
||||||
|
const touching: number[] = [];
|
||||||
|
for (const c of chunks) {
|
||||||
|
deco.between(side === "a" ? c.fromA : c.fromB, side === "a" ? c.toA : c.toB, (from) => {
|
||||||
|
touching.push(from);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (touching.length) deco = deco.update({ filter: (from) => !touching.includes(from) });
|
||||||
|
}
|
||||||
|
return deco;
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildCollapsed(state: EditorState, margin: number, minLines: number) {
|
||||||
|
const { chunks, side } = state.field(splitField);
|
||||||
|
const doc = state.doc;
|
||||||
|
const out = [];
|
||||||
|
let prev = 1;
|
||||||
|
for (let i = 0; ; i += 1) {
|
||||||
|
const c = i < chunks.length ? chunks[i]! : null;
|
||||||
|
const from = i ? prev + margin : 1;
|
||||||
|
const to = c ? doc.lineAt(side === "a" ? c.fromA : c.fromB).number - 1 - margin : doc.lines;
|
||||||
|
const lines = to - from + 1;
|
||||||
|
if (lines >= minLines) {
|
||||||
|
out.push(
|
||||||
|
Decoration.replace({ widget: new CollapseWidget(lines), block: true }).range(
|
||||||
|
doc.line(from).from,
|
||||||
|
doc.line(to).to
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!c) break;
|
||||||
|
prev = doc.lineAt(Math.min(doc.length, side === "a" ? c.toA : c.toB)).number;
|
||||||
|
}
|
||||||
|
return Decoration.set(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A chunk side as 0-based half-open lines; an empty side is the point
|
||||||
|
* before the line it sits above (past the end: after the last line). */
|
||||||
|
function sideLines(doc: Text, from: number, to: number): [number, number] {
|
||||||
|
const first = from > doc.length ? doc.lines : doc.lineAt(from).number - 1;
|
||||||
|
if (from >= to) return [first, first];
|
||||||
|
return [first, doc.lineAt(Math.min(to - 1, doc.length)).number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function spansOf(chunks: readonly Chunk[], a: Text, b: Text): Span[] {
|
||||||
|
return chunks.map((c) => {
|
||||||
|
const [a0, a1] = sideLines(a, c.fromA, c.toA);
|
||||||
|
const [b0, b1] = sideLines(b, c.fromB, c.toB);
|
||||||
|
return { a0, a1, b0, b1 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the document starts inside the view's scroll content. */
|
||||||
|
const docOffset = (v: EditorView) =>
|
||||||
|
v.documentTop - v.scrollDOM.getBoundingClientRect().top + v.scrollDOM.scrollTop;
|
||||||
|
|
||||||
|
/** The fractional line at the top of the view (a folded stretch spreads its
|
||||||
|
* height over the lines it hides). */
|
||||||
|
function topLine(v: EditorView) {
|
||||||
|
const h = Math.max(0, v.scrollDOM.scrollTop - docOffset(v));
|
||||||
|
const blk = v.lineBlockAtHeight(h);
|
||||||
|
const first = v.state.doc.lineAt(blk.from).number - 1;
|
||||||
|
const n = v.state.doc.lineAt(blk.to).number - first;
|
||||||
|
const frac = blk.height > 0 ? Math.min(1, Math.max(0, (h - blk.top) / blk.height)) : 0;
|
||||||
|
return first + frac * n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Document y of a fractional line (0-based). */
|
||||||
|
function yOf(v: EditorView, line: number) {
|
||||||
|
const doc = v.state.doc;
|
||||||
|
if (line >= doc.lines) return v.lineBlockAt(doc.length).bottom;
|
||||||
|
const blk = v.lineBlockAt(doc.line(Math.max(0, Math.floor(line)) + 1).from);
|
||||||
|
const first = doc.lineAt(blk.from).number - 1;
|
||||||
|
const n = doc.lineAt(blk.to).number - first;
|
||||||
|
return blk.top + ((line - first) / n) * blk.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gutter's width: room for an S-curve and the » under it. */
|
||||||
|
const GUTTER_PX = 40;
|
||||||
|
const BAND: Record<"add" | "del" | "mod", string> = {
|
||||||
|
add: "fill-status-success/20 stroke-status-success/60",
|
||||||
|
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||||
|
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||||
|
};
|
||||||
|
const SVG = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
export function splitDiff({
|
||||||
|
parent,
|
||||||
|
original,
|
||||||
|
value,
|
||||||
|
extA,
|
||||||
|
extB,
|
||||||
|
collapse,
|
||||||
|
revertLabel,
|
||||||
|
}: {
|
||||||
|
parent: HTMLElement;
|
||||||
|
original: string;
|
||||||
|
value: string;
|
||||||
|
/** Each side's own extensions (DiffEditor's colours, keymaps, …). */
|
||||||
|
extA: Extension[];
|
||||||
|
extB: Extension[];
|
||||||
|
collapse?: { margin: number; minSize: number };
|
||||||
|
revertLabel: string;
|
||||||
|
}): { a: EditorView; b: EditorView; destroy: () => void } {
|
||||||
|
const origDoc = Text.of(original.split("\n"));
|
||||||
|
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
|
||||||
|
|
||||||
|
const root = parent.appendChild(document.createElement("div"));
|
||||||
|
root.className = "flex h-full min-h-0";
|
||||||
|
const pane = () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "h-full min-w-0 flex-1";
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
const paneA = root.appendChild(pane());
|
||||||
|
const gutter = root.appendChild(document.createElement("div"));
|
||||||
|
gutter.className =
|
||||||
|
"cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1";
|
||||||
|
gutter.style.width = `${GUTTER_PX}px`;
|
||||||
|
gutter.dataset.testid = "lane-editor-connectors";
|
||||||
|
const svg = gutter.appendChild(document.createElementNS(SVG, "svg"));
|
||||||
|
svg.setAttribute("class", "absolute inset-0 h-full w-full");
|
||||||
|
const buttons = gutter.appendChild(document.createElement("div"));
|
||||||
|
const paneB = root.appendChild(pane());
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
const draw = () => {
|
||||||
|
if (frame) return;
|
||||||
|
const run = () => {
|
||||||
|
frame = 0;
|
||||||
|
paint();
|
||||||
|
};
|
||||||
|
frame =
|
||||||
|
typeof requestAnimationFrame === "function"
|
||||||
|
? requestAnimationFrame(run)
|
||||||
|
: window.setTimeout(run);
|
||||||
|
};
|
||||||
|
|
||||||
|
const side = (s: "a" | "b"): Extension[] => [
|
||||||
|
splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })),
|
||||||
|
chunkDecorations,
|
||||||
|
chunkGutter,
|
||||||
|
EditorView.editorAttributes.of({ class: `cm-merge-${s}` }),
|
||||||
|
...(collapse
|
||||||
|
? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))]
|
||||||
|
: []),
|
||||||
|
EditorView.updateListener.of((u) => {
|
||||||
|
if (u.docChanged || u.geometryChanged || u.viewportChanged) draw();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const a = new EditorView({
|
||||||
|
parent: paneA,
|
||||||
|
state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }),
|
||||||
|
});
|
||||||
|
const b = new EditorView({
|
||||||
|
parent: paneB,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: [
|
||||||
|
...side("b"),
|
||||||
|
...extB,
|
||||||
|
// The left side is told the chunks the right side's edit produced.
|
||||||
|
EditorView.updateListener.of((u) => {
|
||||||
|
const now = u.state.field(splitField).chunks;
|
||||||
|
if (now !== u.startState.field(splitField).chunks)
|
||||||
|
a.dispatch({ effects: setChunks.of(now) });
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
siblings.set(a, b);
|
||||||
|
siblings.set(b, a);
|
||||||
|
|
||||||
|
const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc);
|
||||||
|
|
||||||
|
// Scrolling one side scrolls the other to the line beside it. A scroll we
|
||||||
|
// caused comes back as the other side's own event: `echo` holds where we
|
||||||
|
// put it, so that event is recognised and not mirrored back.
|
||||||
|
const echo = { a: -1, b: -1 };
|
||||||
|
const follow = (from: "a" | "b") => () => {
|
||||||
|
const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const];
|
||||||
|
if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) {
|
||||||
|
echo[from] = -1;
|
||||||
|
draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const line = mapLine(topLine(src), spans(), from === "a");
|
||||||
|
const target = Math.round(yOf(dst, line) + docOffset(dst));
|
||||||
|
const before = dst.scrollDOM.scrollTop;
|
||||||
|
dst.scrollDOM.scrollTop = target;
|
||||||
|
if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop;
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
const onA = follow("a");
|
||||||
|
const onB = follow("b");
|
||||||
|
a.scrollDOM.addEventListener("scroll", onA, { passive: true });
|
||||||
|
b.scrollDOM.addEventListener("scroll", onB, { passive: true });
|
||||||
|
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
||||||
|
resize?.observe(root);
|
||||||
|
|
||||||
|
// » puts the original block back on the right (the merge package's rule:
|
||||||
|
// a non-empty source brings its line break unless it ends the document).
|
||||||
|
buttons.addEventListener("mousedown", (e) => {
|
||||||
|
const i = Number((e.target as HTMLElement).closest<HTMLElement>("[data-chunk]")?.dataset.chunk);
|
||||||
|
const c = b.state.field(splitField).chunks[i];
|
||||||
|
if (!c) return;
|
||||||
|
let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1));
|
||||||
|
if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak;
|
||||||
|
b.dispatch({
|
||||||
|
changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert },
|
||||||
|
userEvent: "revert",
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
const g = gutter.getBoundingClientRect();
|
||||||
|
const height = gutter.clientHeight;
|
||||||
|
const ya = (l: number) => a.documentTop - g.top + yOf(a, l);
|
||||||
|
const yb = (l: number) => b.documentTop - g.top + yOf(b, l);
|
||||||
|
const paths: SVGPathElement[] = [];
|
||||||
|
const btns: HTMLButtonElement[] = [];
|
||||||
|
spans().forEach((s, i) => {
|
||||||
|
const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)];
|
||||||
|
// Off screen on both sides: nothing to draw (no layout = draw all).
|
||||||
|
if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return;
|
||||||
|
const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod";
|
||||||
|
const p = document.createElementNS(SVG, "path");
|
||||||
|
p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1));
|
||||||
|
p.setAttribute("class", BAND[kind]);
|
||||||
|
p.setAttribute("vector-effect", "non-scaling-stroke");
|
||||||
|
p.dataset.kind = kind;
|
||||||
|
paths.push(p);
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.textContent = "»";
|
||||||
|
btn.title = revertLabel;
|
||||||
|
btn.setAttribute("aria-label", revertLabel);
|
||||||
|
btn.dataset.chunk = String(i);
|
||||||
|
btn.className =
|
||||||
|
"absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||||
|
btn.style.top = `${Math.max(0, y0) + 1}px`;
|
||||||
|
btns.push(btn);
|
||||||
|
});
|
||||||
|
svg.replaceChildren(...paths);
|
||||||
|
buttons.replaceChildren(...btns);
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
|
||||||
|
return {
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
destroy: () => {
|
||||||
|
if (frame)
|
||||||
|
(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame);
|
||||||
|
resize?.disconnect();
|
||||||
|
a.scrollDOM.removeEventListener("scroll", onA);
|
||||||
|
b.scrollDOM.removeEventListener("scroll", onB);
|
||||||
|
a.destroy();
|
||||||
|
b.destroy();
|
||||||
|
root.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,12 +40,3 @@ describe("changeTree", () => {
|
|||||||
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("changeTree — an untracked folder", () => {
|
|
||||||
it("shows git's `dir/` entry as a named row in its parent folder", () => {
|
|
||||||
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
|
|
||||||
const docs = root.dirs[0]!;
|
|
||||||
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
|
|
||||||
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the side-by-side diff's connector geometry: the band path
|
||||||
|
* between a left and a right block, and the line mapping that keeps the two
|
||||||
|
* panes scrolled to the same place.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { bandPath, mapLine, type Span } from "../diffConnectors";
|
||||||
|
|
||||||
|
describe("bandPath", () => {
|
||||||
|
it("joins the left block's edges to the right block's with two S-curves", () => {
|
||||||
|
expect(bandPath(40, 10, 30, 50, 90)).toBe(
|
||||||
|
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,30 0,30 Z"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still draws an empty side as a closed path, flat there", () => {
|
||||||
|
expect(bandPath(40, 10, 10, 50, 90)).toBe(
|
||||||
|
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,10 0,10 Z"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapLine", () => {
|
||||||
|
// Left lines 2–3 became right lines 2–6; left 8 was removed (right 11 = none).
|
||||||
|
const spans: Span[] = [
|
||||||
|
{ a0: 2, a1: 4, b0: 2, b1: 7 },
|
||||||
|
{ a0: 8, a1: 9, b0: 11, b1: 11 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("maps 1:1 before, between and after the blocks, shifted by the ones above", () => {
|
||||||
|
expect(mapLine(0, spans, true)).toBe(0);
|
||||||
|
expect(mapLine(5, spans, true)).toBe(8);
|
||||||
|
expect(mapLine(10, spans, true)).toBe(12);
|
||||||
|
expect(mapLine(8, spans, false)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates inside a block, both ways", () => {
|
||||||
|
expect(mapLine(3, spans, true)).toBe(4.5);
|
||||||
|
expect(mapLine(4.5, spans, false)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a line removed on one side at its block's point on the other", () => {
|
||||||
|
expect(mapLine(8.5, spans, true)).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the identity with no blocks", () => {
|
||||||
|
expect(mapLine(7.25, [], true)).toBe(7.25);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,9 +20,7 @@ export interface ChangeTreeDir<T> {
|
|||||||
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
||||||
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
||||||
for (const file of rows) {
|
for (const file of rows) {
|
||||||
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
|
const parts = file.path.split("/");
|
||||||
const parts = file.path.replace(/\/$/, "").split("/");
|
|
||||||
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
|
|
||||||
let dir = root;
|
let dir = root;
|
||||||
dir.count++;
|
dir.count++;
|
||||||
for (const seg of parts.slice(0, -1)) {
|
for (const seg of parts.slice(0, -1)) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @file Geometry for the JetBrains-style side-by-side diff, shared by the
|
||||||
|
* Changes panel's review list (LaneDiffView) and its file editor (DiffEditor):
|
||||||
|
* the curved band that joins a changed block on the left to its counterpart
|
||||||
|
* on the right across the middle gutter, and the line mapping that keeps two
|
||||||
|
* unpadded panes scrolled to the same place. Pure functions, no DOM.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A changed block's lines on each side, 0-based and half-open: `a0 === a1`
|
||||||
|
* is a block with no lines on the left (a pure insertion), and likewise b. */
|
||||||
|
export type Span = { a0: number; a1: number; b0: number; b1: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG path of the band joining the left block (`y0`–`y1`) to the right one
|
||||||
|
* (`z0`–`z1`) across a gutter `w` wide: an S-curve along each edge, flat where
|
||||||
|
* a side is empty (`y0 === y1`), so a pure insertion reads as a wedge.
|
||||||
|
*/
|
||||||
|
export function bandPath(w: number, y0: number, y1: number, z0: number, z1: number): string {
|
||||||
|
const c = w / 2;
|
||||||
|
return `M0,${y0} C${c},${y0} ${c},${z0} ${w},${z0} L${w},${z1} C${c},${z1} ${c},${y1} 0,${y1} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line on the other side that `line` (fractional, 0-based) sits beside:
|
||||||
|
* outside a block 1:1, shifted by the blocks above it; inside one, at the same
|
||||||
|
* fraction of the other side's block — which collapses to that block's point
|
||||||
|
* when the other side is empty. `spans` are in document order.
|
||||||
|
*/
|
||||||
|
export function mapLine(line: number, spans: readonly Span[], fromA: boolean): number {
|
||||||
|
let ours = 0;
|
||||||
|
let theirs = 0;
|
||||||
|
for (const s of spans) {
|
||||||
|
const [s0, s1, t0, t1] = fromA ? [s.a0, s.a1, s.b0, s.b1] : [s.b0, s.b1, s.a0, s.a1];
|
||||||
|
if (line < s0) break;
|
||||||
|
if (line < s1) return t0 + ((line - s0) / (s1 - s0)) * (t1 - t0);
|
||||||
|
ours = s1;
|
||||||
|
theirs = t1;
|
||||||
|
}
|
||||||
|
return theirs + (line - ours);
|
||||||
|
}
|
||||||
+14
-1
@@ -700,7 +700,8 @@ reads the workspace's git surface in five modes:
|
|||||||
opening one shows that commit's diff (`?range=commit&sha=`).
|
opening one shows that commit's diff (`?range=commit&sha=`).
|
||||||
|
|
||||||
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
|
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
|
||||||
working tree, untracked files included as an all-added patch) and **Compared to
|
working tree, untracked files included as an all-added patch — each new file
|
||||||
|
by itself, a new folder's files too, never one row for the folder; #64) and **Compared to
|
||||||
<base>** (`<base>...HEAD` — what this branch adds on top of the workspace's
|
<base>** (`<base>...HEAD` — what this branch adds on top of the workspace's
|
||||||
`base_branch`, else the repo's default branch: what `origin/HEAD` names, else
|
`base_branch`, else the repo's default branch: what `origin/HEAD` names, else
|
||||||
`main`/`master`; `GET /api/lanes/:id/git` reports it as `base`). With no base, or
|
`main`/`master`; `GET /api/lanes/:id/git` reports it as `base`). With no base, or
|
||||||
@@ -716,6 +717,18 @@ collapse** buttons that walk `context` both ways along 3 → 12 → 40 → the w
|
|||||||
file, and a **whole file** switch that reads the file itself instead of its
|
file, and a **whole file** switch that reads the file itself instead of its
|
||||||
diff.
|
diff.
|
||||||
|
|
||||||
|
**Side by side is JetBrains-shaped**, in the review list and in the file
|
||||||
|
editor alike: each column shows only its own lines, one after another — no
|
||||||
|
padding rows to line the two up — so the sides drift apart past a change, and
|
||||||
|
the gutter between them draws a curved band from each changed block on the
|
||||||
|
left to its counterpart on the right (green: added, red: removed, blue: both;
|
||||||
|
`client/src/lib/diffConnectors.ts`). In the review list only a hunk's end is
|
||||||
|
padded, so the `@@` headers and the two scrollers stay level. In the file
|
||||||
|
editor (`client/src/components/lanes/splitDiff.ts`, two CodeMirror views in
|
||||||
|
their own scrollers) scrolling either side scrolls the other to the matching
|
||||||
|
line — 1:1 outside a block, proportionally inside one — and each band's **»**
|
||||||
|
puts the original block back on the right (an edit, saved like any other).
|
||||||
|
|
||||||
A diff scrolls on **both axes inside its own bounded box**, never by growing
|
A diff scrolls on **both axes inside its own bounded box**, never by growing
|
||||||
the panel: the single-file view takes the panel's remaining height, and each
|
the panel: the single-file view takes the panel's remaining height, and each
|
||||||
file in the stacked review list is capped, because a horizontal scrollbar at
|
file in the stacked review list is capped, because a horizontal scrollbar at
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
# AgentHub
|
||||||
|
|
||||||
|
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
||||||
|
monitoring is one part of it, not the whole. Hooks POST every tool call to an
|
||||||
|
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
|
||||||
|
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
||||||
|
sessions, workspace runtimes, and Claude Code's own configuration.
|
||||||
|
|
||||||
|
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||||
|
every decision.
|
||||||
|
|
||||||
|
Internal build — all rights reserved.
|
||||||
|
|
||||||
|
*(Tiếng Việt: [OVERVIEW.vi.md](OVERVIEW.vi.md))*
|
||||||
|
|
||||||
|
## Main features
|
||||||
|
|
||||||
|
**Observe**
|
||||||
|
|
||||||
|
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||||
|
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
||||||
|
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
||||||
|
editable pricing rules; top tools, agent types, and workflow patterns.
|
||||||
|
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
||||||
|
targets that fire on them.
|
||||||
|
- **History import.** Every session under `~/.claude/projects/` is imported on
|
||||||
|
startup; archives from another machine come in through Settings → Import
|
||||||
|
History (idempotent, never double-counts tokens).
|
||||||
|
- **Remote sources.** Pull session history from other machines over SSH
|
||||||
|
(`agenthub remote-sources`).
|
||||||
|
|
||||||
|
**Drive**
|
||||||
|
|
||||||
|
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
|
||||||
|
install a pipeline template (none ships), a workspace moves through its stages and
|
||||||
|
the dashboard shows where it is.
|
||||||
|
- **Stage detection.** With a template installed, the stage is inferred from the
|
||||||
|
tool stream, so a session that never calls `agenthub stage` still shows
|
||||||
|
progress — rendered dashed amber and never as done, because an inference is
|
||||||
|
not evidence.
|
||||||
|
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
||||||
|
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
|
||||||
|
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||||
|
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
|
||||||
|
setting. **This is the part that needs `tmux` installed** — see
|
||||||
|
[Prerequisites](#prerequisites).
|
||||||
|
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
|
||||||
|
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
||||||
|
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
||||||
|
Beside the workspace sits a collapsible **Changes** panel — an overview of the
|
||||||
|
changed files, a full review of every diff, or one file at a time, comparing
|
||||||
|
either uncommitted work or the whole branch against its base. There is no
|
||||||
|
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||||
|
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||||
|
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
|
||||||
|
|
||||||
|
**Configure**
|
||||||
|
|
||||||
|
- **Workspaces like Claude Desktop projects.** The sidebar lists every
|
||||||
|
workspace with its sessions under it (a live one opens its tmux pane, any
|
||||||
|
other its conversation with a prompt box — sending a message resumes it,
|
||||||
|
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
||||||
|
prompt box that starts a new session, Recents, and the workspace's own
|
||||||
|
Instructions, Memory, Context files (picked with + or dragged in from the OS)
|
||||||
|
and extra Folders, which Claude and Codex sessions start with. Files and
|
||||||
|
images dragged onto a terminal or a prompt box are uploaded and attached by
|
||||||
|
their saved path.
|
||||||
|
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||||
|
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||||
|
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||||
|
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
||||||
|
untracked worktrees a workspace's Add folder suggests.
|
||||||
|
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
||||||
|
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||||
|
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||||
|
about — read for all of them, edit/create/delete for the text-file surfaces
|
||||||
|
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
||||||
|
`settings.json` stay read-only: they race with the live CLI.
|
||||||
|
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||||
|
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||||
|
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||||
|
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||||
|
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||||
|
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||||
|
Per-browser only.
|
||||||
|
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||||
|
Pop, Alert — off by default) played with every notification shown, and each
|
||||||
|
notification type can pick its own.
|
||||||
|
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||||
|
a UI in English, Vietnamese, Chinese and Korean.
|
||||||
|
- **Outside the dashboard, all opt-in:** a read-only MCP server
|
||||||
|
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](MCP.md)), a
|
||||||
|
Claude Code statusline with session and today's cost
|
||||||
|
(`agenthub statusline install`, [docs/STATUSLINE.md](STATUSLINE.md)), and
|
||||||
|
Prometheus rules + Grafana boards for `/api/metrics`
|
||||||
|
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](MONITORING.md)).
|
||||||
|
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
|
||||||
|
every page and API call, with admin / member / analyst roles. It is off by
|
||||||
|
default, and a local install does not change. It is login only, without
|
||||||
|
per-user isolation yet ([docs/SERVER-MODE.md](SERVER-MODE.md)).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Need | Version | Required for |
|
||||||
|
|---|---|---|
|
||||||
|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
||||||
|
| **npm** | 9+ | Ships with Node |
|
||||||
|
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
|
||||||
|
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
|
||||||
|
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||||
|
| **Codex CLI** | any | Optional — only for `codex` workspaces |
|
||||||
|
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
|
||||||
|
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||||
|
|
||||||
|
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||||
|
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||||
|
change. Both were gone again by 26 (1712 server + 488 client tests green on
|
||||||
|
v26.8.1).
|
||||||
|
|
||||||
|
### Installing tmux
|
||||||
|
|
||||||
|
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
||||||
|
**Start Run** and `agenthub workspace shell` fail without it. Everything else
|
||||||
|
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install tmux # Debian / Ubuntu
|
||||||
|
sudo dnf install tmux # Fedora / RHEL
|
||||||
|
brew install tmux # macOS
|
||||||
|
sudo pacman -S tmux # Arch
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||||
|
|
||||||
|
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
||||||
|
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
||||||
|
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
||||||
|
next Claude Code session, or by hand with `agenthub start`. Details and a
|
||||||
|
checklist to verify it: [INSTALL.md § macOS](../INSTALL.md#macos).
|
||||||
|
|
||||||
|
Verify, both from your shell and from the server's point of view:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux -V # e.g. tmux 3.4
|
||||||
|
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
||||||
|
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
|
||||||
|
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||||
|
dashboard never kills the run.
|
||||||
|
|
||||||
|
## Install as a Claude Code plugin
|
||||||
|
|
||||||
|
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||||
|
`npm run setup`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
/plugin install agenthub@agenthub
|
||||||
|
```
|
||||||
|
|
||||||
|
The first session start installs the hooks, boots the server, puts `agenthub` on
|
||||||
|
PATH; it runs detached, so the session never waits on
|
||||||
|
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
|
||||||
|
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
|
||||||
|
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
||||||
|
Details, including what to delete on uninstall:
|
||||||
|
[`docs/PLUGINS.md`](PLUGINS.md).
|
||||||
|
|
||||||
|
## Install from a checkout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup # root and client dependencies
|
||||||
|
npm run build # builds the client into client/dist
|
||||||
|
npm start # serves the built client and the API on :4820
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:4820>.
|
||||||
|
|
||||||
|
Development, with hot reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # server on :4820, Vite client on :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
||||||
|
built UI is served from (defaults to `client/dist`; the plugin install points it
|
||||||
|
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
||||||
|
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
|
||||||
|
every event is counted twice.
|
||||||
|
|
||||||
|
### Run as a service (auto-start on boot)
|
||||||
|
|
||||||
|
`npm start` only runs while that terminal (or `agenthub start`'s background
|
||||||
|
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
||||||
|
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
||||||
|
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
||||||
|
to trigger it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # the service serves the built client, same as npm start
|
||||||
|
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||||
|
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||||
|
agenthub service status # systemctl --user status / launchctl print
|
||||||
|
agenthub service uninstall # disable and remove it
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||||
|
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||||
|
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||||
|
once yourself. The same is a switch in the dashboard — Settings → About →
|
||||||
|
**Start AgentHub automatically** (off by default; it enables or disables the
|
||||||
|
unit and never stops the running server). Plugin installs get it only once
|
||||||
|
that switch is on — see
|
||||||
|
[Auto-start on boot](PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
||||||
|
|
||||||
|
## The CLI
|
||||||
|
|
||||||
|
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub status # is the dashboard up
|
||||||
|
agenthub start # start it in the background and wait for healthy
|
||||||
|
agenthub service install # auto-start on boot via systemd --user (Linux)
|
||||||
|
agenthub sessions # recent sessions
|
||||||
|
agenthub workspaces # workspaces with stage and progress
|
||||||
|
agenthub workspace pipeline # this lane's pipeline template, or switch it
|
||||||
|
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
|
||||||
|
agenthub stage <name> # declare the current lane's stage
|
||||||
|
agenthub tail # live event feed
|
||||||
|
```
|
||||||
|
|
||||||
|
`agenthub --help` lists the rest.
|
||||||
|
|
||||||
|
## Workspaces
|
||||||
|
|
||||||
|
A workspace is a working directory the dashboard watches. Two kinds:
|
||||||
|
|
||||||
|
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
||||||
|
never reset or deleted.
|
||||||
|
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
||||||
|
reset that worktree, behind a three-check destroy guard and a counted preflight
|
||||||
|
the caller has to echo back. Removing a workspace — either kind — deletes only the
|
||||||
|
dashboard's record of it: the working tree, its branch and its database stay,
|
||||||
|
for you to delete yourself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
||||||
|
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
Every workspace also carries the CLI that drives it — `claude` (the default) or
|
||||||
|
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
|
||||||
|
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||||
|
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
||||||
|
are read from Codex's own rollout journals: every Codex session (a workspace's or
|
||||||
|
not) appears in Sessions with a **Codex** badge and is priced like any other,
|
||||||
|
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
|
||||||
|
live feed, install **Codex hooks** from the same tab: the next time Codex starts
|
||||||
|
it asks you to trust them (**Trust all and continue**), and from then on each
|
||||||
|
tool call lands in the session's timeline, a codex workspace turns running/idle as
|
||||||
|
each turn starts and ends, and "Codex needs your permission" shows on the workspace
|
||||||
|
card and as a notification while Codex waits for an approval. A Codex workspace also
|
||||||
|
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
|
||||||
|
advanced; without hooks the dashboard mirrors its running/idle status from the
|
||||||
|
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
|
||||||
|
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
||||||
|
|
||||||
|
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
|
||||||
|
usage are read from agy's own conversation files
|
||||||
|
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
|
||||||
|
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
|
||||||
|
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
|
||||||
|
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
|
||||||
|
the same tab, add a live feed: each tool call in the session's timeline and a
|
||||||
|
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
|
||||||
|
and it has no hook for a permission prompt, so an Antigravity workspace never shows
|
||||||
|
"needs your permission". agy fires no hook when it exits, nor when a run ends
|
||||||
|
on an API error or a permission refused in headless mode, so a session gone
|
||||||
|
quiet is marked completed and its workspace set idle — after ten minutes of quiet
|
||||||
|
for a session the hooks report on (a long tool call writes nothing, and after a
|
||||||
|
Stop agy may still be waiting for you), after two minutes for one without
|
||||||
|
hooks. Antigravity workspaces carry no pipeline either.
|
||||||
|
|
||||||
|
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
|
||||||
|
read from Cursor's agent transcripts
|
||||||
|
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
|
||||||
|
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
||||||
|
no token counts or model ids there, so a Cursor session shows no tokens and no
|
||||||
|
cost.
|
||||||
|
|
||||||
|
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||||
|
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||||
|
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
|
||||||
|
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
||||||
|
Further development is planned.
|
||||||
|
|
||||||
|
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
||||||
|
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
|
||||||
|
move backwards between work sessions. Detection never writes the declared stage,
|
||||||
|
and an inferred node never renders as done.
|
||||||
|
|
||||||
|
A workspace's pipeline template can be switched after creation — `agenthub workspace
|
||||||
|
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
||||||
|
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
|
||||||
|
current declared stage against the new template's nodes and warn if it no
|
||||||
|
longer matches one.
|
||||||
|
|
||||||
|
A workspace can also run **its own application stack**, isolated per workspace, when its
|
||||||
|
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
|
||||||
|
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
|
||||||
|
ports and per-workspace directories derive from it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub workspace up # boot the stack of the lane owning this directory
|
||||||
|
agenthub workspace runtime # slot, ports, service health
|
||||||
|
agenthub workspace logs api # tail a service log
|
||||||
|
agenthub workspace down
|
||||||
|
```
|
||||||
|
|
||||||
|
Services are fully detached, so restarting the dashboard never stops a running
|
||||||
|
workspace. This is resource namespacing on the host, not a container: workspaces run as the
|
||||||
|
same user and share the network.
|
||||||
|
|
||||||
|
[`docs/LANES.md`](LANES.md) has the pipeline model, the destroy guard, the
|
||||||
|
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
||||||
|
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
||||||
|
`POST /:id/git/:action`), and the full
|
||||||
|
runtime/profile contract.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:server # node:test
|
||||||
|
npm run test:client # Vitest
|
||||||
|
```
|
||||||
|
|
||||||
|
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
|
||||||
|
runs Prettier, then the tests the commit needs: both full suites for a big or
|
||||||
|
risky commit (over 10 code files, a dependency, build/test config or the hook
|
||||||
|
itself, a deleted file), only the related tests for a small one (`vitest
|
||||||
|
related` for `client/`, the server test files that name each other staged
|
||||||
|
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
|
||||||
|
both full suites.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||||
|
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||||
|
| `bin/agenthub.js` | CLI |
|
||||||
|
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
||||||
|
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
||||||
|
| `docs/` | Architecture, API, workspaces, database |
|
||||||
|
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- [`ARCHITECTURE.md`](../ARCHITECTURE.md) — request flow, schema, WebSocket surface
|
||||||
|
- [`docs/LANES.md`](LANES.md) — workspaces, pipelines, stage detection
|
||||||
|
- [`docs/API.md`](API.md) — REST endpoints (`openapi.yaml` is generated)
|
||||||
|
- [`docs/DATABASE.md`](DATABASE.md) — tables and migrations
|
||||||
|
- [`INSTALL.md`](../INSTALL.md)
|
||||||
|
- [`CLAUDE.md`](../CLAUDE.md) — the rules an agent working in this repo must follow
|
||||||
@@ -11,7 +11,7 @@ mọi quyết định.
|
|||||||
|
|
||||||
Internal build — all rights reserved.
|
Internal build — all rights reserved.
|
||||||
|
|
||||||
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
|
*(Bản dịch tiếng Việt của [OVERVIEW.md](OVERVIEW.md), tham khảo — OVERVIEW.md gốc là bản chính thức.)*
|
||||||
|
|
||||||
## Chức năng chính
|
## Chức năng chính
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install no
|
|||||||
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
||||||
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
||||||
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
||||||
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
|
và checklist kiểm tra: [INSTALL.md § macOS](../INSTALL.md#macos).
|
||||||
|
|
||||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ chạy detached nên session không phải chờ. `/agenthub-doctor`
|
|||||||
báo trạng thái, `/agenthub-open` build UI và in URL, `/agenthub-update` refresh sau
|
báo trạng thái, `/agenthub-open` build UI và in URL, `/agenthub-update` refresh sau
|
||||||
khi plugin update. Đường này cần Node **>= 22.5** (không dùng
|
khi plugin update. Đường này cần Node **>= 22.5** (không dùng
|
||||||
`better-sqlite3` native, server dùng `node:sqlite`). Chi tiết, kể cả những gì
|
`better-sqlite3` native, server dùng `node:sqlite`). Chi tiết, kể cả những gì
|
||||||
cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
cần xóa lúc uninstall: [`docs/PLUGINS.md`](PLUGINS.md).
|
||||||
|
|
||||||
## Cài từ checkout
|
## Cài từ checkout
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ Trong dashboard cũng có công tắc tương ứng — Settings → About → *
|
|||||||
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
|
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
|
||||||
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
|
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
|
||||||
bật — xem
|
bật — xem
|
||||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
|
[Auto-start on boot](PLUGINS.md#auto-start-on-boot) trong
|
||||||
`docs/PLUGINS.md`.
|
`docs/PLUGINS.md`.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
@@ -230,7 +230,7 @@ poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
|
|||||||
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
|
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
|
||||||
nào, nên workspace không có stage cho tới khi bạn thả một file template JSON vào
|
nào, nên workspace không có stage cho tới khi bạn thả một file template JSON vào
|
||||||
`DASHBOARD_PIPELINES_DIR` (xem
|
`DASHBOARD_PIPELINES_DIR` (xem
|
||||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
|
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); trước đó
|
||||||
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
|
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
|
||||||
này sẽ được phát triển tiếp.
|
này sẽ được phát triển tiếp.
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ Service chạy hoàn toàn detached, nên restart dashboard không bao giờ d
|
|||||||
workspace đang chạy. Đây là namespacing tài nguyên trên host, không phải
|
workspace đang chạy. Đây là namespacing tài nguyên trên host, không phải
|
||||||
container: các workspace chạy chung user và share network.
|
container: các workspace chạy chung user và share network.
|
||||||
|
|
||||||
[`docs/LANES.md`](docs/LANES.md) có mô hình pipeline, destroy guard, hợp đồng
|
[`docs/LANES.md`](LANES.md) có mô hình pipeline, destroy guard, hợp đồng
|
||||||
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
|
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
|
||||||
hợp đồng runtime/profile.
|
hợp đồng runtime/profile.
|
||||||
|
|
||||||
@@ -292,9 +292,9 @@ chạy cả hai bộ đầy đủ.
|
|||||||
|
|
||||||
## Tài liệu
|
## Tài liệu
|
||||||
|
|
||||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
|
- [`ARCHITECTURE.md`](../ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
|
||||||
- [`docs/LANES.md`](docs/LANES.md) — workspace, pipeline, stage detection
|
- [`docs/LANES.md`](LANES.md) — workspace, pipeline, stage detection
|
||||||
- [`docs/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
|
- [`docs/API.md`](API.md) — REST endpoint (`openapi.yaml` được generate tự động)
|
||||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
|
- [`docs/DATABASE.md`](DATABASE.md) — bảng và migration
|
||||||
- [`INSTALL.md`](INSTALL.md)
|
- [`INSTALL.md`](../INSTALL.md)
|
||||||
- [`CLAUDE.md`](CLAUDE.md) — quy tắc agent làm việc trong repo này phải tuân theo
|
- [`CLAUDE.md`](../CLAUDE.md) — quy tắc agent làm việc trong repo này phải tuân theo
|
||||||
@@ -132,6 +132,17 @@ describe("collectChanges — work range", () => {
|
|||||||
assert.ok(out.files.every((f) => f.hunks === undefined));
|
assert.ok(out.files.every((f) => f.hunks === undefined));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists each file inside a wholly untracked folder, not the folder (#64)", async () => {
|
||||||
|
write(LANE, "docs/report.md", "# report\n");
|
||||||
|
write(LANE, "docs/deep/notes.txt", "n\n");
|
||||||
|
const out = await collectChanges(LANE, { range: "work" });
|
||||||
|
// `--untracked-files=normal` folded these into one "docs/" row that
|
||||||
|
// opened nothing; the panel needs the files, like JetBrains and VS Code.
|
||||||
|
assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]);
|
||||||
|
assert.ok(out.files.every((f) => f.untracked && f.added === 1));
|
||||||
|
assert.equal((await gitFacts(LANE)).untracked, 2);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns hunks for every file in full mode", async () => {
|
it("returns hunks for every file in full mode", async () => {
|
||||||
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
||||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||||
|
|||||||
+11
-14
@@ -332,10 +332,13 @@ function parsePorcelainZ(stdout) {
|
|||||||
* to it — a subdirectory lane neither shows nor can name a change outside
|
* to it — a subdirectory lane neither shows nor can name a change outside
|
||||||
* itself. A rename whose original path is outside keeps `from: null`.
|
* itself. A rename whose original path is outside keeps `from: null`.
|
||||||
* Shared with git-ops.js so the paths a write may name are the paths shown.
|
* Shared with git-ops.js so the paths a write may name are the paths shown.
|
||||||
|
* `--untracked-files=all` (#64): `normal` folds a wholly untracked folder
|
||||||
|
* into one "dir/" entry, which the panel can neither open nor diff — every
|
||||||
|
* new file is listed instead, as JetBrains and VS Code do.
|
||||||
*/
|
*/
|
||||||
async function laneStatus(dir) {
|
async function laneStatus(dir) {
|
||||||
const [status, prefix] = await Promise.all([
|
const [status, prefix] = await Promise.all([
|
||||||
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]),
|
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
|
||||||
git(dir, ["rev-parse", "--show-prefix"]),
|
git(dir, ["rev-parse", "--show-prefix"]),
|
||||||
]);
|
]);
|
||||||
const pre = prefix.stdout.trim();
|
const pre = prefix.stdout.trim();
|
||||||
@@ -861,26 +864,20 @@ const TREE_LS = ["--cached", "--others", "--exclude-standard"];
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A lane's working-copy status for decorating the explorer, from ONE
|
* A lane's working-copy status for decorating the explorer, from ONE
|
||||||
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file
|
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file,
|
||||||
* (untracked inside a folded untracked dir included), `dirChanged(path)` is
|
* `dirChanged(path)` is whether anything under a folder is.
|
||||||
* whether anything under a folder is.
|
|
||||||
*/
|
*/
|
||||||
async function treeStatus(dir) {
|
async function treeStatus(dir) {
|
||||||
const codes = new Map();
|
const codes = new Map();
|
||||||
const folded = []; // `normal` reports a wholly untracked folder as "dir/"
|
|
||||||
const dirs = new Set();
|
const dirs = new Set();
|
||||||
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
|
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
|
||||||
if (p.endsWith("/")) folded.push(p);
|
codes.set(p, code);
|
||||||
else codes.set(p, code);
|
const parts = p.split("/");
|
||||||
const parts = p.replace(/\/$/, "").split("/");
|
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
|
||||||
for (let i = 1; i <= parts.length - (p.endsWith("/") ? 0 : 1); i++) {
|
|
||||||
dirs.add(parts.slice(0, i).join("/"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const inFolded = (p) => folded.some((f) => p.startsWith(f));
|
|
||||||
return {
|
return {
|
||||||
fileStatus: (p) => codes.get(p) ?? (inFolded(p) ? "??" : undefined),
|
fileStatus: (p) => codes.get(p),
|
||||||
dirChanged: (p) => dirs.has(p) || inFolded(`${p}/`),
|
dirChanged: (p) => dirs.has(p),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: MANDATORY for every coding agent (Claude Code, Codex, or any other)
|
|||||||
|
|
||||||
# Update Project Docs
|
# Update Project Docs
|
||||||
|
|
||||||
This repository keeps a large doc set and docs drift silently, because one change often belongs in several files at once. This skill encodes **which docs exist, which change-types touch which docs, and how to propagate consistently**. This build ships English only — the translated READMEs, the wiki and the root landing page were removed; do not recreate them.
|
This repository keeps a large doc set and docs drift silently, because one change often belongs in several files at once. This skill encodes **which docs exist, which change-types touch which docs, and how to propagate consistently**. The root `README.md` is a short Vietnamese install page only (requirements + the two install paths) — never grow it back; the full feature reference is `docs/OVERVIEW.md` (English, canonical) mirrored by `docs/OVERVIEW.vi.md`. The wiki and the root landing page were removed; do not recreate them.
|
||||||
|
|
||||||
Authoritative inventory with exact section anchors lives in [`references/doc-map.md`](references/doc-map.md) — read it when deciding where a specific change lands. The repo rule [`.claude/rules/docs-markdown.md`](../../rules/docs-markdown.md) ("update all affected docs together") is binding.
|
Authoritative inventory with exact section anchors lives in [`references/doc-map.md`](references/doc-map.md) — read it when deciding where a specific change lands. The repo rule [`.claude/rules/docs-markdown.md`](../../rules/docs-markdown.md) ("update all affected docs together") is binding.
|
||||||
|
|
||||||
@@ -29,20 +29,20 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
|
|||||||
|
|
||||||
| Change type | Docs to update |
|
| Change type | Docs to update |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Env var** | `README.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` |
|
| **Env var** | `docs/OVERVIEW.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` |
|
||||||
| **Event type** | `README.md` (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, `docs/DATABASE.md` (if it enumerates types), plus the `en`/`vi` strings under `client/src/i18n/locales/` if the type is rendered |
|
| **Event type** | `docs/OVERVIEW.md` (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, `docs/DATABASE.md` (if it enumerates types), plus the `en`/`vi` strings under `client/src/i18n/locales/` if the type is rendered |
|
||||||
| **Hook behavior / state transition** | `docs/HOOKS.md`, the state-machine **mermaid** diagrams in `README.md`, `server/README.md` and `docs/DATABASE.md`, `ARCHITECTURE.md` (hooks.js row) |
|
| **Hook behavior / state transition** | `docs/HOOKS.md`, the state-machine **mermaid** diagrams in `docs/OVERVIEW.md`, `server/README.md` and `docs/DATABASE.md`, `ARCHITECTURE.md` (hooks.js row) |
|
||||||
| **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi.js` + `server/openapi-extra/*.js` (code), then regenerate `openapi.yaml` with `npm run openapi:yaml` |
|
| **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi.js` + `server/openapi-extra/*.js` (code), then regenerate `openapi.yaml` with `npm run openapi:yaml` |
|
||||||
| **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) |
|
| **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) |
|
||||||
| **WebSocket message** | `client/README.md` (Event Types), `server/README.md` |
|
| **WebSocket message** | `client/README.md` (Event Types), `server/README.md` |
|
||||||
| **Feature / page / background service** | `README.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` |
|
| **Feature / page / background service** | `docs/OVERVIEW.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` |
|
||||||
| **CLI command / script** | `README.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` |
|
| **CLI command / script** | `docs/OVERVIEW.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` |
|
||||||
| **New language** | `docs/I18N.md`, `client/src/i18n/locales/<xx>/*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) |
|
| **New language** | `docs/I18N.md`, `client/src/i18n/locales/<xx>/*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) |
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
1. **Classify** the change against the table above. A change can hit multiple rows (a new feature with a new env var hits both).
|
1. **Classify** the change against the table above. A change can hit multiple rows (a new feature with a new env var hits both).
|
||||||
2. **Write the canonical version first** — usually `README.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else.
|
2. **Write the canonical version first** — usually `docs/OVERVIEW.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else.
|
||||||
3. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping.
|
3. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping.
|
||||||
4. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent.
|
4. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ set -u
|
|||||||
# The canonical doc set kept in sync. Translations + HTML + per-area READMEs.
|
# The canonical doc set kept in sync. Translations + HTML + per-area READMEs.
|
||||||
DOCS=(
|
DOCS=(
|
||||||
"README.md"
|
"README.md"
|
||||||
|
"README.en.md"
|
||||||
|
"docs/OVERVIEW.md"
|
||||||
|
"docs/OVERVIEW.vi.md"
|
||||||
"ARCHITECTURE.md"
|
"ARCHITECTURE.md"
|
||||||
"server/README.md"
|
"server/README.md"
|
||||||
"client/README.md"
|
"client/README.md"
|
||||||
|
|||||||
Reference in New Issue
Block a user