From 2cc492bb88ddc1523d1618299f6c9aff76cd3b9e Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 2 Nov 2026 11:52:40 +0700 Subject: [PATCH] =?UTF-8?q?feat(git):=20Branches=20&=20tags=20panel=20?= =?UTF-8?q?=E2=80=94=20rename,=20branch=20from=20any=20ref,=20upstream,=20?= =?UTF-8?q?tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /git/refs lists local branches (upstream, ahead/behind, last commit, merged, held by a worktree, protected), remote branches grouped by remote and tags (target, annotated message/tagger/date). New writes in server/lib/git-refs.js, dispatched from git-ops.js: branch-rename, branch-upstream (set/unset), tag-create (light/annotated), tag-delete (confirm), tag-push (one tag to origin, no --tags, no force), and branch-create with a looked-up from + switch on a clean tree. Every ref is looked up, never passed through; repo-wide writes are repo-root only (ESUBDIR) and refused during any operation (EMERGING). Client: git/BranchManager + TagManager dialog opened from the git bar's tags icon, en+vi (gitRefs namespace), keyboard navigable. # Conflicts: # CLAUDE.md # client/src/components/lanes/LaneGitBar.tsx # docs/API.md # server/__tests__/lanes-api.test.js # server/routes/lanes.js --- CLAUDE.md | 2 +- client/src/components/lanes/LaneGitBar.tsx | 15 + .../lanes/__tests__/BranchManager.test.tsx | 296 ++++++++ .../components/lanes/git/BranchManager.tsx | 641 ++++++++++++++++++ .../components/lanes/git/BranchesButton.tsx | 71 ++ .../src/components/lanes/git/TagManager.tsx | 271 ++++++++ client/src/components/lanes/git/refUi.ts | 41 ++ client/src/i18n/index.ts | 1 + client/src/i18n/locales/en/gitRefs.json | 76 +++ client/src/i18n/locales/vi/gitRefs.json | 76 +++ client/src/lib/api.ts | 88 ++- docs/API.md | 46 +- docs/LANES.md | 26 +- server/__tests__/git-refs.test.js | 293 ++++++++ server/__tests__/lanes-api.test.js | 25 + server/lib/git-ops.js | 7 + server/lib/git-refs.js | 524 ++++++++++++++ server/openapi-extra.js | 7 +- server/openapi-extra/git-refs.js | 50 ++ server/routes/lanes.js | 33 +- 20 files changed, 2580 insertions(+), 9 deletions(-) create mode 100644 client/src/components/lanes/__tests__/BranchManager.test.tsx create mode 100644 client/src/components/lanes/git/BranchManager.tsx create mode 100644 client/src/components/lanes/git/BranchesButton.tsx create mode 100644 client/src/components/lanes/git/TagManager.tsx create mode 100644 client/src/components/lanes/git/refUi.ts create mode 100644 client/src/i18n/locales/en/gitRefs.json create mode 100644 client/src/i18n/locales/vi/gitRefs.json create mode 100644 server/__tests__/git-refs.test.js create mode 100644 server/lib/git-refs.js create mode 100644 server/openapi-extra/git-refs.js diff --git a/CLAUDE.md b/CLAUDE.md index d8a7e716..62de2e84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. -- **The dashboard's git writes are these twenty-five, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort` and `shelf-drop` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add rebase, a hard/mixed reset or any other stash use here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. +- **The dashboard's git writes are these thirty, and the guards are IN `git-ops.js` (and the `git-refs.js` it dispatches to), not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop`, and the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort` and `shelf-drop` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add rebase, a hard/mixed reset or any other stash use here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`, `/git/refs`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. - **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. diff --git a/client/src/components/lanes/LaneGitBar.tsx b/client/src/components/lanes/LaneGitBar.tsx index ce25be83..b19cd47a 100644 --- a/client/src/components/lanes/LaneGitBar.tsx +++ b/client/src/components/lanes/LaneGitBar.tsx @@ -32,6 +32,7 @@ * resent with the typed credential — the token is never rendered back. * Fetch, pull, push and publish say so in a toast when they succeed; a merge, * a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel. + * The tags icon opens the Branches panel (`git/BranchManager`: every branch and tag, rename, upstream, tags). * @author Nguyễn Ngọc Trí Vĩ */ @@ -55,6 +56,7 @@ import { confirmDialog } from "../../lib/confirm"; import { isSubmitEnter } from "../../lib/keys"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; import { restoreShelf } from "./LaneShelves"; +import { BranchesButton } from "./git/BranchesButton"; type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish"; @@ -505,6 +507,19 @@ export function LaneGitBar({ } onClick={openSwitch} /> + { + // A rename, delete or new branch makes the switcher's list stale. + if (branches !== null) void refreshBranches(); + onChanged(); + }} + /> )} diff --git a/client/src/components/lanes/__tests__/BranchManager.test.tsx b/client/src/components/lanes/__tests__/BranchManager.test.tsx new file mode 100644 index 00000000..930a7bcc --- /dev/null +++ b/client/src/components/lanes/__tests__/BranchManager.test.tsx @@ -0,0 +1,296 @@ +/** + * @file BranchManager.test.tsx + * @description Pins the git bar's Branches panel: local branches with the + * current one marked and ahead/behind shown, remote branches grouped by + * remote, the search box, arrow-key walking, and each action's request — + * rename, new branch from a remote branch (switch only offered on a clean + * tree), set/unset upstream, merge into current, delete refused with its + * reason — plus the Tags tab: annotated create, push one tag, delete with + * `confirm: true` after the themed confirm, and git's error shown verbatim. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BranchManager } from "../git/BranchManager"; +import { api, type GitRefs } from "../../../lib/api"; +import { confirmDialog } from "../../../lib/confirm"; + +vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) })); +vi.mock("../../../lib/toast", () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); +vi.mock("../../../lib/api", () => ({ + gitTarget: (lane: number) => lane, + api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } }, +})); + +const REFS: GitRefs = { + available: true, + current: "main", + head: "aaaaaaa", + truncated: false, + local: [ + { + name: "main", + sha: "a1", + current: true, + upstream: "origin/main", + ahead: 2, + behind: 1, + gone: false, + subject: "latest work", + date: null, + merged: true, + worktree: null, + protected: true, + }, + { + name: "feature/very-long-branch-name", + sha: "b2", + current: false, + upstream: null, + ahead: null, + behind: null, + gone: false, + subject: "feature commit", + date: null, + merged: false, + worktree: null, + protected: false, + }, + { + name: "held", + sha: "c3", + current: false, + upstream: null, + ahead: null, + behind: null, + gone: false, + subject: "x", + date: null, + merged: true, + worktree: "/tmp/other", + protected: false, + }, + ], + remotes: [ + { + remote: "origin", + branches: [ + { name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null }, + { name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null }, + ], + }, + ], + tags: [ + { + name: "v1", + sha: "a1b2c3d4", + annotated: true, + message: "release one", + tagger: "T", + date: null, + }, + ], +}; + +function setup(over: { dirty?: number } = {}) { + const onChanged = vi.fn(); + render( + + ); + return { onChanged }; +} + +beforeEach(() => { + vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS); + vi.mocked(api.lanes.gitRefAction) + .mockReset() + .mockResolvedValue({ action: "x", ok: true, output: "" }); + vi.mocked(confirmDialog).mockClear(); +}); + +const row = (name: RegExp) => screen.getByRole("button", { name }); + +describe("BranchManager", () => { + it("lists local branches with the current marked and remote ones grouped", async () => { + setup(); + await screen.findByText("feature/very-long-branch-name"); + const main = row(/^main/); + expect(within(main).getByText("current")).toBeInTheDocument(); + expect(main).toHaveTextContent("2"); + expect(main).toHaveTextContent("1"); + expect(screen.getByText("Remote · origin")).toBeInTheDocument(); + expect(screen.getByText("origin/remote-only")).toBeInTheDocument(); + // A long name truncates with its full text as the tooltip. + expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute( + "title", + "feature/very-long-branch-name" + ); + }); + + it("filters by the search box and walks rows with the arrow keys", async () => { + setup(); + await screen.findByText("held"); + await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote"); + expect(screen.queryByText("held")).toBeNull(); + expect(screen.getByText("origin/remote-only")).toBeInTheDocument(); + await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" })); + await userEvent.keyboard("{ArrowDown}"); + expect(row(/^main/)).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}"); + expect(row(/^feature\/very-long/)).toHaveFocus(); + }); + + it("renames a branch and re-reads the list", async () => { + const { onChanged } = setup(); + await userEvent.click(await screen.findByText("feature/very-long-branch-name")); + await userEvent.click(screen.getByRole("button", { name: /^Rename$/ })); + const input = screen.getByRole("textbox", { name: "New name" }); + await userEvent.clear(input); + await userEvent.type(input, "feature/short{Enter}"); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", { + branch: "feature/very-long-branch-name", + name: "feature/short", + }); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2); + }); + + it("refuses rename and delete of a protected current branch, with the reason", async () => { + setup(); + await userEvent.click(await screen.findByText("main")); + expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled(); + expect(screen.getByText(/You are on this branch/)).toBeInTheDocument(); + }); + + it("explains a branch another worktree holds", async () => { + setup(); + await userEvent.click(await screen.findByText("held")); + expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled(); + expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument(); + }); + + it("deletes an unmerged branch only after the confirm warns", async () => { + setup(); + await userEvent.click(await screen.findByText("feature/very-long-branch-name")); + await userEvent.click(screen.getByRole("button", { name: /^Delete$/ })); + expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", { + branch: "feature/very-long-branch-name", + }); + }); + + it("creates a branch from a remote branch, switch offered only on a clean tree", async () => { + setup({ dirty: 2 }); + await userEvent.click(await screen.findByText("origin/remote-only")); + await userEvent.click(screen.getByRole("button", { name: /New branch from here/ })); + expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled(); + const input = screen.getByRole("textbox", { name: "New branch name" }); + expect(input).toHaveValue("remote-only"); + await userEvent.type(input, "{Enter}"); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", { + branch: "remote-only", + from: "origin/remote-only", + }); + }); + + it("sets and unsets an upstream, and merges into the current branch", async () => { + setup(); + await userEvent.click(await screen.findByText("feature/very-long-branch-name")); + await userEvent.click(screen.getByRole("button", { name: /Set upstream/ })); + await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only"); + await userEvent.click(screen.getByRole("button", { name: /Apply/ })); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { + branch: "feature/very-long-branch-name", + upstream: "origin/remote-only", + }); + await userEvent.click(screen.getByRole("button", { name: /Merge into main/ })); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", { + branch: "feature/very-long-branch-name", + }); + await userEvent.click(screen.getByText("main")); + await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ })); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" }); + }); + + it("shows git's own words when a write is refused", async () => { + vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce( + Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" }) + ); + setup(); + await userEvent.click(await screen.findByText("feature/very-long-branch-name")); + await userEvent.click(screen.getByRole("button", { name: /^Rename$/ })); + await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}"); + expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent( + "branch x already exists" + ); + }); +}); + +describe("TagManager (Tags tab)", () => { + const openTags = async () => { + setup(); + await screen.findByText("main"); + await userEvent.click(screen.getByRole("tab", { name: /Tags/ })); + }; + + it("lists tags with their message and creates an annotated one", async () => { + await openTags(); + expect(screen.getByText("v1")).toBeInTheDocument(); + expect(screen.getByText(/release one/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /New tag/ })); + await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2"); + await userEvent.type( + screen.getByRole("textbox", { name: /^On / }), + "feature/very-long-branch-name" + ); + await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes"); + await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" })); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", { + tag: "v2", + message: "notes", + from: "feature/very-long-branch-name", + }); + }); + + it("pushes one tag and deletes one only with confirm: true", async () => { + await openTags(); + await userEvent.click(screen.getByText("v1")); + await userEvent.click(screen.getByRole("button", { name: /Push to origin/ })); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" }); + await userEvent.click(screen.getByRole("button", { name: /Delete tag/ })); + expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true); + expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", { + tag: "v1", + confirm: true, + }); + }); + + it("opens from the git bar's button and closes on Escape", async () => { + const { BranchesButton } = await import("../git/BranchesButton"); + render(); + await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" })); + expect(await screen.findByTestId("branch-manager")).toBeInTheDocument(); + await screen.findByText("main"); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByTestId("branch-manager")).toBeNull(); + }); + + it("does not delete when the confirm is cancelled", async () => { + vi.mocked(confirmDialog).mockResolvedValueOnce(false); + await openTags(); + await userEvent.click(screen.getByText("v1")); + await userEvent.click(screen.getByRole("button", { name: /Delete tag/ })); + expect(api.lanes.gitRefAction).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/lanes/git/BranchManager.tsx b/client/src/components/lanes/git/BranchManager.tsx new file mode 100644 index 00000000..20265ffe --- /dev/null +++ b/client/src/components/lanes/git/BranchManager.tsx @@ -0,0 +1,641 @@ +/** + * @file The git bar's Branches panel (JetBrains' branch popup, GitLens' + * Branches view): a dialog over the workspace listing every local branch + * (current marked, upstream with ahead/behind, last commit, why a delete would + * be refused) and the fetched remote branches grouped by remote, filtered by a + * search box and walked with the arrow keys; picking a row opens its actions — + * new branch from it (optionally switching to it on a clean tree), rename, + * set/unset upstream, merge into the current branch, delete. Its Tags tab is + * `TagManager`. Everything it may do is decided by the server + * (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled + * button here is a courtesy, and git's own text is shown when it refuses. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ArrowDown, + ArrowUp, + Check, + GitBranch, + GitMerge, + Link2, + Link2Off, + Pencil, + Plus, + Search, + Tag, + Trash2, + X, +} from "lucide-react"; +import { + api, + gitTarget, + type GitLocalBranch, + type GitRefs, + type GitRemoteBranch, + type GitSource, +} from "../../../lib/api"; +import { useDialog } from "../../../hooks/useDialog"; +import { confirmDialog } from "../../../lib/confirm"; +import { toast } from "../../../lib/toast"; +import { isSubmitEnter } from "../../../lib/keys"; +import { timeAgo } from "../../../lib/format"; +import { Checkbox } from "../../Checkbox"; +import { TagManager } from "./TagManager"; +import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi"; + +type Mode = "new" | "rename" | "upstream" | null; + +export function BranchManager({ + laneId, + session, + source, + dirty, + merging, + onClose, + onChanged, +}: { + laneId: number; + session?: string; + source?: GitSource; + /** Uncommitted files: a "switch to it" is refused on a dirty tree. */ + dirty: number; + /** A merge, cherry-pick or revert is open: every write here is refused. */ + merging: boolean; + onClose: () => void; + /** Called after every write, so the git bar and the Changes panel re-read. */ + onChanged: () => void; +}) { + const { t } = useTranslation(["gitRefs"]); + const titleId = useId(); + const panelRef = useRef(null); + const searchRef = useRef(null); + useDialog(true, panelRef, onClose, searchRef); + + const target = gitTarget(laneId, session, source); + const [refs, setRefs] = useState(null); + const [tab, setTab] = useState<"branches" | "tags">("branches"); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [mode, setMode] = useState(null); + const [text, setText] = useState(""); + const [switchTo, setSwitchTo] = useState(false); + const [upstream, setUpstream] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const inFlight = useRef(false); + + const load = useCallback(async () => { + try { + setRefs(await api.lanes.gitRefs(target)); + } catch { + setRefs({ + available: false, + current: null, + head: null, + local: [], + remotes: [], + tags: [], + truncated: false, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [laneId, session, source]); + useEffect(() => { + void load(); + }, [load]); + + const run: RunRefAction = async (action, body, done) => { + if (inFlight.current) return false; + inFlight.current = true; + setBusy(true); + setError(null); + try { + await api.lanes.gitRefAction(target, action, body); + if (done) toast.success(done); + setMode(null); + setText(""); + await load(); + onChanged(); + return true; + } catch (err) { + const code = (err as { code?: string })?.code; + setError( + code === "EGITAUTH" + ? t("errLogin") + : code === "ESUBDIR" + ? t("errSubdir") + : err instanceof Error + ? err.message + : String(err) + ); + return false; + } finally { + inFlight.current = false; + setBusy(false); + } + }; + + const q = query.trim().toLowerCase(); + const match = (s: string) => !q || s.toLowerCase().includes(q); + const local = useMemo( + () => (refs?.local || []).filter((b) => match(b.name)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [refs, q] + ); + const remoteGroups = useMemo( + () => + (refs?.remotes || []) + .map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) })) + .filter((g) => g.branches.length), + // eslint-disable-next-line react-hooks/exhaustive-deps + [refs, q] + ); + const allRemote = useMemo( + () => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)), + [refs] + ); + const current = refs?.current ?? null; + + const pick = (key: string) => { + setSelected((s) => (s === key ? null : key)); + setMode(null); + setText(""); + setError(null); + }; + const open = (m: Mode, initial = "") => { + setMode((v) => (v === m ? null : m)); + setText(initial); + setSwitchTo(false); + setError(null); + }; + + const merge = async (branch: string) => { + const ok = await confirmDialog({ + title: t("merge"), + message: t("mergeConfirm", { branch, current: current ?? "HEAD" }), + confirmLabel: t("merge"), + destructive: false, + }); + if (ok) await run("merge", { branch }, t("done.merge", { branch })); + }; + const remove = async (b: GitLocalBranch) => { + const ok = await confirmDialog({ + title: t("delete"), + message: b.merged + ? t("deleteConfirm", { branch: b.name }) + : t("deleteUnmerged", { branch: b.name }), + confirmLabel: t("delete"), + destructive: true, + }); + if (ok) { + if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name }))) + setSelected(null); + } + }; + + /** Why `git branch -d` would refuse this branch, or null. */ + const deleteBlock = (b: GitLocalBranch) => + b.current + ? t("why.current") + : b.protected + ? t("why.protected") + : b.worktree + ? t("why.worktree", { path: b.worktree }) + : null; + + const newBranchForm = (from: string) => ( +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (isSubmitEnter(e) && text.trim()) + void run( + "branch-create", + { branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) }, + t("done.create", { branch: text.trim() }) + ); + }} + placeholder={t("newBranchName")} + aria-label={t("newBranchName")} + className={smallInput} + /> + 0 || merging} + label={t("switchToIt")} + /> + + {dirty > 0 && {t("switchDirty")}} +
+ ); + + const localActions = (b: GitLocalBranch) => { + const block = deleteBlock(b); + return ( +
+
+ + + + {b.upstream && ( + + )} + {!b.current && ( + + )} + +
+ {block &&

{block}

} + {!block && !b.merged && ( +

{t("why.unmerged")}

+ )} + {mode === "new" && newBranchForm(b.name)} + {mode === "rename" && ( +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name) + void run( + "branch-rename", + { branch: b.name, name: text.trim() }, + t("done.rename", { branch: text.trim() }) + ); + }} + aria-label={t("renameTo")} + placeholder={t("renameTo")} + className={smallInput} + /> + +
+ )} + {mode === "upstream" && ( +
+ + +
+ )} +
+ ); + }; + + const remoteActions = (r: GitRemoteBranch) => ( +
+
+ + +
+ {mode === "new" && newBranchForm(r.name)} +
+ ); + + const row = ( + key: string, + name: string, + sub: React.ReactNode, + badges: React.ReactNode, + actions: () => React.ReactNode + ) => ( +
  • + + {selected === key && actions()} +
  • + ); + + const badge = (label: string, tone = "text-fg-muted border-border-light") => ( + {label} + ); + + const localRow = (b: GitLocalBranch) => + row( + `l:${b.name}`, + b.name, + <> + {b.subject} + {b.date ? ` · ${timeAgo(b.date)}` : ""} + , + <> + {b.current && badge(t("current"), "text-accent border-accent/40")} + {b.protected && badge(t("protected"))} + {b.worktree && ( + + {badge(t("worktree"))} + + )} + {b.upstream && ( + + {b.gone ? ( + {t("gone")} + ) : ( + <> + {(b.ahead ?? 0) > 0 && ( + + + {b.ahead} + + )} + {(b.behind ?? 0) > 0 && ( + + + {b.behind} + + )} + {!b.ahead && !b.behind && } + + )} + + )} + , + () => localActions(b) + ); + + const remoteRow = (r: GitRemoteBranch) => + row( + `r:${r.name}`, + r.name, + <> + {r.subject} + {r.date ? ` · ${timeAgo(r.date)}` : ""} + , + null, + () => remoteActions(r) + ); + + const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => ( + + ); + + return ( +
    +
    e.stopPropagation()} + className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40" + > +
    +

    + {t("title")} +

    +
    + {tabBtn("branches", t("branches"), )} + {tabBtn("tags", t("tags"), )} +
    + +
    +
    + + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + panelRef.current?.querySelector("[data-ref-row]")?.focus(); + } + }} + placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")} + aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")} + className={`${smallInput} !text-xs`} + /> +
    + {merging &&

    {t("merging")}

    } + {error && ( +
    +            {error}
    +          
    + )} +
    + {!refs ? ( +

    {t("loading")}

    + ) : !refs.available ? ( +

    {t("unavailable")}

    + ) : tab === "tags" ? ( + match(x.name))} + busy={busy} + merging={merging} + dirty={dirty} + run={run} + /> + ) : ( + <> +

    + {t("local")} +

    + {local.length ? ( +
      {local.map(localRow)}
    + ) : ( +

    {t("noMatch")}

    + )} + {remoteGroups.map((g) => ( +
    +

    + {t("remote", { remote: g.remote })} +

    +
      {g.branches.map(remoteRow)}
    +
    + ))} + {refs.truncated && ( +

    {t("truncated")}

    + )} + + )} +
    +
    +
    + ); +} + +export default BranchManager; diff --git a/client/src/components/lanes/git/BranchesButton.tsx b/client/src/components/lanes/git/BranchesButton.tsx new file mode 100644 index 00000000..ee02df62 --- /dev/null +++ b/client/src/components/lanes/git/BranchesButton.tsx @@ -0,0 +1,71 @@ +/** + * @file The git bar's "Branches & tags" icon button, which owns the Branches + * panel (`BranchManager`) it opens — portalled to `document.body`, so no + * scrolling or transformed ancestor of the Changes panel can clip it. The + * panel closes when the bar starts showing another workspace. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { Tags } from "lucide-react"; +import type { GitSource } from "../../../lib/api"; +import { BranchManager } from "./BranchManager"; + +export function BranchesButton({ + laneId, + session, + source, + dirty, + merging, + disabled, + onChanged, +}: { + laneId: number; + session?: string; + source?: GitSource; + dirty: number; + merging: boolean; + disabled?: boolean; + onChanged: () => void; +}) { + const { t } = useTranslation(["gitRefs"]); + const [open, setOpen] = useState(false); + const [shown, setShown] = useState(`${laneId}|${session ?? ""}|${source ?? ""}`); + const key = `${laneId}|${session ?? ""}|${source ?? ""}`; + if (shown !== key) { + setShown(key); + setOpen(false); + } + return ( + <> + + {open && + createPortal( + setOpen(false)} + onChanged={onChanged} + />, + document.body + )} + + ); +} + +export default BranchesButton; diff --git a/client/src/components/lanes/git/TagManager.tsx b/client/src/components/lanes/git/TagManager.tsx new file mode 100644 index 00000000..2e40aefc --- /dev/null +++ b/client/src/components/lanes/git/TagManager.tsx @@ -0,0 +1,271 @@ +/** + * @file The Tags tab of the git bar's Branches panel (`BranchManager`): the + * repo's tags (name, target commit, and for an annotated tag its message, + * tagger and date), a form that creates one — lightweight, or annotated when a + * message is typed — on HEAD or on a branch, tag or commit sha, and per tag: + * new branch from it, push it to origin (one tag, never `--tags`, never + * forced) and delete it locally, which is irreversible and so asks first. The + * server looks every name up (server/lib/git-refs.js); nothing here decides. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Plus, Tag, Trash2, Upload } from "lucide-react"; +import type { GitTag } from "../../../lib/api"; +import { confirmDialog } from "../../../lib/confirm"; +import { isSubmitEnter } from "../../../lib/keys"; +import { timeAgo } from "../../../lib/format"; +import { Checkbox } from "../../Checkbox"; +import { smallBtn, smallInput, type RunRefAction } from "./refUi"; + +export function TagManager({ + tags, + busy, + merging, + dirty, + run, +}: { + /** Already filtered by the panel's search box. */ + tags: GitTag[]; + busy: boolean; + merging: boolean; + dirty: number; + run: RunRefAction; +}) { + const { t } = useTranslation(["gitRefs"]); + const [creating, setCreating] = useState(false); + const [name, setName] = useState(""); + const [message, setMessage] = useState(""); + const [from, setFrom] = useState(""); + const [selected, setSelected] = useState(null); + const [branchName, setBranchName] = useState(null); + const [switchTo, setSwitchTo] = useState(false); + + const create = async () => { + const tag = name.trim(); + if (!tag) return; + const ok = await run( + "tag-create", + { + tag, + ...(message.trim() ? { message: message.trim() } : {}), + ...(from.trim() ? { from: from.trim() } : {}), + }, + t("done.tagCreate", { tag }) + ); + if (ok) { + setName(""); + setMessage(""); + setFrom(""); + setCreating(false); + } + }; + const push = async (tag: string) => { + const ok = await confirmDialog({ + title: t("tagPush"), + message: t("tagPushConfirm", { tag }), + confirmLabel: t("tagPush"), + destructive: false, + }); + if (ok) await run("tag-push", { tag }, t("done.tagPush", { tag })); + }; + const remove = async (tag: string) => { + const ok = await confirmDialog({ + title: t("tagDelete"), + message: t("tagDeleteConfirm", { tag }), + confirmLabel: t("tagDelete"), + destructive: true, + }); + if (ok && (await run("tag-delete", { tag, confirm: true }, t("done.tagDelete", { tag })))) + setSelected(null); + }; + const branchFrom = (tag: string) => { + const branch = (branchName ?? "").trim(); + if (!branch) return; + void run( + "branch-create", + { branch, from: `refs/tags/${tag}`, ...(switchTo ? { switch: true } : {}) }, + t("done.create", { branch }) + ).then((ok) => ok && setBranchName(null)); + }; + + return ( +
    +
    + {creating ? ( +
    + setName(e.target.value)} + onKeyDown={(e) => { + if (isSubmitEnter(e)) void create(); + }} + placeholder={t("tagName")} + aria-label={t("tagName")} + className={`${smallInput} w-full`} + /> + setFrom(e.target.value)} + placeholder={t("tagFrom")} + aria-label={t("tagFrom")} + className={`${smallInput} w-full`} + /> +