Compare commits

..

6 Commits

Author SHA1 Message Date
TBNilles 69d38484e8 Slice 13: repo search + filtering (+ no-cache dev assets)
<repo-list> gains a search box (name/path) and Dirty/Ahead-behind filter chips with an N-of-M count; filtering is client-side and the state persists in localStorage (gitmanager.repolist.filters, §4). Refresh/selection re-apply filters. cmd/server sends Cache-Control: no-cache for /components and /static so hot-reloaded asset edits show on reload. Server-verified (curl); live click-through pending an unresponsive browser pane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 18:02:30 -04:00
TBNilles 34ed127653 Slice 12: inline command-result toasts
New <toast-host> overlay: components post 'toast' CustomEvents ({message, kind: success|error|info}) and it shows brief, auto-dismissing, bottom-right toasts (errors linger). <repo-menu> (git + coordination actions) and <pr-list> (create/merge) now post friendly success/error toasts instead of alert(); the activity feed still logs everything. Also adds the slice 11 CHANGELOG entry. Verified live: 'Checked for updates' toast shown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:50:32 -04:00
TBNilles 68b6c3a3f8 Slice 11: branch-picker submenu in the right-click menu
Replace the Switch branch text prompt with a flyout submenu populated from GET /api/repo (the repo's branches; current one disabled); clicking a branch checks it out. Flips leftward near the viewport edge. New branch still prompts. Verified live: submenu lists branches and switches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:44:55 -04:00
TBNilles ad00654487 Slice 10: create_pr (open a pull request)
forge CreatePullRequest (Gitea; empty base resolves to the repo default branch). Service CreatePR records a pr-created event. HTTP POST /api/repo/pr/create; MCP tool create_pr; <pr-list> New pull request button (head = selected repo current branch, base = default). Live-tested end-to-end: app create_pr opened a real PR (base auto-resolved), listed, then cleaned up; main untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:34:13 -04:00
TBNilles e1999bcf21 Slice 9: git_checkout and create_branch (menu + MCP)
git boundary Checkout/CreateBranch; service GitCheckout/GitCreateBranch (activity detail names the branch; gitAction takes an ok-detail). HTTP /api/repo/git ops checkout + create-branch (branch field). MCP tools git_checkout and create_branch. <repo-menu> gains Switch branch… and New branch… (prompt for name). Service test covers create+switch and existing-branch failure. Checkout is not destructive - git refuses if it would overwrite changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:27:58 -04:00
TBNilles e59d5bbd29 Slice 8: configure git credentials + identity in the container
On startup the app runs git config --global to set a commit identity (GIT_USER_NAME/GIT_USER_EMAIL), safe.directory=* for host-owned mounts, and http.<GITEA_URL>.extraheader with the Gitea token so push/fetch/pull authenticate over HTTPS without an SSH key. New git.CLI.SetGlobalConfig and config fields. Verified: fetch via the app works and the identity/header are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:22:34 -04:00
21 changed files with 733 additions and 82 deletions
+8
View File
@@ -55,6 +55,14 @@ SCAN_FETCH_ENABLED=false
# "dev" uses a readable console handler; anything else uses structured JSON. # "dev" uses a readable console handler; anything else uses structured JSON.
APP_ENV=dev APP_ENV=dev
# Commit identity for git actions the app runs (commit/etc.). Without these,
# commits inside the container fail with "empty ident". Set to your name/email.
GIT_USER_NAME=
GIT_USER_EMAIL=
# Note: when GITEA_URL + GITEA_TOKEN are set, the app also configures git to
# authenticate to that host over HTTPS (an http.extraheader), so push/fetch/pull
# work from the container without a separate SSH key or credential helper.
# Optional: also append structured logs to this file. Leave empty to disable. # Optional: also append structured logs to this file. Leave empty to disable.
LOG_FILE= LOG_FILE=
+9 -5
View File
@@ -365,9 +365,9 @@ obeys the safety rules (§1.4).
the tool handlers (§1.7). Expected tools (grow as features land): the tool handlers (§1.7). Expected tools (grow as features land):
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`, - Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
`get_pending_switch`, `list_prs`. `get_pending_switch`, `list_prs`.
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, - Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
`git_discard_changes`, `merge_and_cleanup_pr`, `set_active_project`, `create_branch`, `git_discard_changes`, `create_pr`, `merge_and_cleanup_pr`,
`ack_switch`. (More — `git_checkout`, `create_branch`, `create_pr` — as they land.) `set_active_project`, `ack_switch`.
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The - **A tool's result type must be a struct, never a bare slice/map/scalar.** The
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
structured output must be a JSON **object** (`type: "object"`). A handler that structured output must be a JSON **object** (`type: "object"`). A handler that
@@ -548,8 +548,12 @@ silently guess.)*
branch delete) and how it is provisioned; document in `.env.example`. branch delete) and how it is provisioned; document in `.env.example`.
- **Listen address / exposure:** localhostonly by default (covers `/mcp` too). - **Listen address / exposure:** localhostonly by default (covers `/mcp` too).
Confirm before binding to a nonlocal interface — there is no auth (Section 0). Confirm before binding to a nonlocal interface — there is no auth (Section 0).
- **Credential path from the container:** SSH agent socket vs mounted keys vs - ✅ **RESOLVED 2026-09-20:** **Container git auth = the Gitea token over HTTPS.**
credential helper, for pushing/fetching from inside Docker. On startup the app runs `git config --global` to set a commit identity
(`GIT_USER_NAME`/`GIT_USER_EMAIL`), `safe.directory=*` (host-owned mounts), and
`http.<GITEA_URL>.extraheader: Authorization: token …` so push/fetch/pull work
without SSH keys. The token lands in the container's gitconfig (ephemeral,
localhost). An SSH-key path stays possible later for non-Gitea remotes.
--- ---
+72
View File
@@ -201,3 +201,75 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
- **Affects:** `internal/mcp` (+test), `AGENT.md` (§8.1). - **Affects:** `internal/mcp` (+test), `AGENT.md` (§8.1).
- **Note:** the new tools appear in Claude Desktop only after its next restart - **Note:** the new tools appear in Claude Desktop only after its next restart
(tool list cached per connection); network ops still need container git creds. (tool list cached per connection); network ops still need container git creds.
## 2026-09-20 — Slice 8: git credentials + identity in the container (§11)
- **What:** On startup the app configures the container's git (`git config
--global`): a commit identity (`GIT_USER_NAME`/`GIT_USER_EMAIL`),
`safe.directory=*` for host-owned mounts, and — when `GITEA_URL`+`GITEA_TOKEN`
are set — `http.<url>.extraheader: Authorization: token …` so push/fetch/pull
authenticate over HTTPS with no SSH key. New `git.CLI.SetGlobalConfig`; new
config `GIT_USER_NAME`/`GIT_USER_EMAIL`; `.env.example` documents them.
- **Why:** Make the network git commands (menu + MCP) actually work from Docker,
and let commits have an author.
- **Affects:** `internal/config`, `internal/git`, `cmd/server/main.go`,
`.env.example`, `AGENT.md` (§11).
- **Verified:** startup logs "git remote auth configured"; container git identity
set; `http.extraheader` present; `git_fetch` via the app returned ok.
- **Security note:** the token is written to the container's ephemeral gitconfig
and passed in a `git config` argv — acceptable for a localhost dev container.
## 2026-09-20 — Slice 9: git_checkout + create_branch
- **What:** git boundary `Checkout` (switch existing branch) and `CreateBranch`
(git checkout -b). Service `GitCheckout`/`GitCreateBranch` (feed detail names
the branch; `gitAction` now takes an ok-detail). HTTP `/api/repo/git` gained
ops `checkout` and `create-branch` (+`branch` field). MCP tools `git_checkout`
and `create_branch`. `<repo-menu>` gained "Switch branch…" and "New branch…"
(prompt for the name). Service test covers create+switch+existing-branch-fails.
- **Why:** Round out the everyday git commands in both front doors (§1.7).
- **Affects:** `internal/git`, `internal/service` (+test), `internal/mcp`,
`cmd/server/main.go`, `components/repo-menu`, `web/templates/help.html`,
`AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would
overwrite uncommitted changes.
## 2026-09-20 — Slice 10: create_pr
- **What:** forge `CreatePullRequest` (Gitea; empty base → repo default branch,
via GetRepo). Service `CreatePR` (records `pr-created`). HTTP
`POST /api/repo/pr/create`. MCP tool `create_pr`. `<pr-list>` gained a
"New pull request…" button (head = selected repo's current branch, base =
default). AGENT.md §8.1 lists `create_pr` in Act.
- **Why:** Open PRs from the app or Claude — the front half of the PR workflow
whose back half is "Merge & clean up".
- **Affects:** `internal/forge`, `internal/service`, `internal/mcp`,
`cmd/server/main.go`, `components/pr-list`, `web/templates/help.html`,
`AGENT.md`.
- **Note:** the head branch must already exist on the remote (push first).
## 2026-09-20 — Slice 11: branch-picker submenu
- **What:** `<repo-menu>` "Switch branch" is now a flyout submenu populated from
`GET /api/repo` (the repo's branches; current one disabled), flipping leftward
near the viewport edge; clicking a branch checks it out. "New branch…" still
prompts. No backend change.
- **Affects:** `components/repo-menu`, `web/templates/help.html`.
## 2026-09-20 — Slice 12: inline command-result toasts
- **What:** New `<toast-host>` overlay — components post `toast` CustomEvents
(`{message, kind}`; success/error/info) and it shows brief, auto-dismissing,
bottom-right toasts. `<repo-menu>` (git ops + coordination actions) and
`<pr-list>` (create/merge) now post success/error toasts with friendly labels
instead of `alert()`. Activity feed still logs everything.
- **Why:** Immediate, legible feedback for the non-expert audience (§6 polish).
- **Affects:** `components/toast-host` (new), `components/repo-menu`,
`components/pr-list`, `web/templates/index.html`.
## 2026-09-20 — Slice 13: repo search + filtering
- **What:** `<repo-list>` gained a search box (name/path, case-insensitive) and
"Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is
client-side over the fetched list; state persists per-viewer in
`localStorage["gitmanager.repolist.filters"]` (§4). Refresh/selection re-apply
the active filters.
- **Why:** Keep the dashboard usable as the number of repos grows.
- **Affects:** `components/repo-list`, `web/templates/help.html`.
- **Dev-server tweak:** `cmd/server` now sends `Cache-Control: no-cache` for
`/components` and `/static` so browsers revalidate assets on reload (cached ES
modules were defeating hot reload). Server serves the new component (curl-
verified); live click-through pending (browser pane was unresponsive).
+61
View File
@@ -6,9 +6,11 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
@@ -26,6 +28,30 @@ import (
"gitmanager/internal/service" "gitmanager/internal/service"
) )
// configureGit prepares the container's git for operating on the mounted repos:
// a commit identity (so commits don't fail with "empty ident"), permission to
// work on host-owned mounts, and — when a Gitea token is set — an auth header so
// pushes/fetches over HTTPS succeed. The token is written to the container's
// gitconfig (ephemeral, localhost); see AGENT.md §11.
func configureGit(ctx context.Context, g *git.CLI, cfg config.Config, log *slog.Logger) {
set := func(key, value string) {
if err := g.SetGlobalConfig(ctx, key, value); err != nil {
log.Warn("git config failed", "key", key, "err", err)
}
}
set("safe.directory", "*") // mounted repos are host-owned
if cfg.GitUserName != "" {
set("user.name", cfg.GitUserName)
}
if cfg.GitUserEmail != "" {
set("user.email", cfg.GitUserEmail)
}
if cfg.GiteaURL != "" && cfg.GiteaToken != "" {
set("http."+cfg.GiteaURL+".extraheader", "Authorization: token "+cfg.GiteaToken)
log.Info("git remote auth configured", "host", cfg.GiteaURL)
}
}
func main() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -46,6 +72,7 @@ func main() {
} else { } else {
log.Info("git detected", "version", v) log.Info("git detected", "version", v)
} }
configureGit(context.Background(), g, cfg, log)
// Start the read-only scanner in the background. // Start the read-only scanner in the background.
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled) scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
@@ -83,6 +110,18 @@ func main() {
e.Use(middleware.Recover()) e.Use(middleware.Recover())
e.Use(middleware.RequestID()) e.Use(middleware.RequestID())
// Ask browsers to revalidate component/static assets so edits show up on
// reload (the dev server hot-reloads; cached ES modules would defeat that).
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
p := c.Request().URL.Path
if strings.HasPrefix(p, "/components/") || strings.HasPrefix(p, "/static/") {
c.Response().Header().Set("Cache-Control", "no-cache")
}
return next(c)
}
})
// Static assets and component sources. // Static assets and component sources.
e.Static("/static", "web/static") e.Static("/static", "web/static")
e.Static("/components", "components") e.Static("/components", "components")
@@ -169,6 +208,7 @@ func main() {
Path string `json:"path"` Path string `json:"path"`
Op string `json:"op"` Op string `json:"op"`
Message string `json:"message"` Message string `json:"message"`
Branch string `json:"branch"`
} }
if err := c.Bind(&body); err != nil { if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
@@ -187,6 +227,10 @@ func main() {
out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message) out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message)
case "discard": case "discard":
out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path) out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path)
case "checkout":
out, err = svc.GitCheckout(ctx, activity.ActorUser, body.Path, body.Branch)
case "create-branch":
out, err = svc.GitCreateBranch(ctx, activity.ActorUser, body.Path, body.Branch)
default: default:
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op})
} }
@@ -209,6 +253,23 @@ func main() {
} }
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs}) return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs})
}) })
e.POST("/api/repo/pr/create", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Head string `json:"head"`
Base string `json:"base"`
Title string `json:"title"`
Body string `json:"body"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
pr, err := svc.CreatePR(c.Request().Context(), activity.ActorUser, body.Path, body.Head, body.Base, body.Title, body.Body)
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, pr)
})
e.POST("/api/repo/pr/merge", func(c echo.Context) error { e.POST("/api/repo/pr/merge", func(c echo.Context) error {
var body struct { var body struct {
Path string `json:"path"` Path string `json:"path"`
+39 -4
View File
@@ -10,6 +10,7 @@ class PRList extends HTMLElement {
#controller = null; #controller = null;
#onSelect = null; #onSelect = null;
#path = ''; #path = '';
#repo = null;
constructor() { constructor() {
super(); super();
@@ -18,7 +19,7 @@ class PRList extends HTMLElement {
connectedCallback() { connectedCallback() {
this.#renderShell(); this.#renderShell();
this.#onSelect = (e) => this.#load(e.detail?.path); this.#onSelect = (e) => { this.#repo = e.detail; this.#load(e.detail?.path); };
document.addEventListener('repo:select', this.#onSelect); document.addEventListener('repo:select', this.#onSelect);
} }
@@ -58,9 +59,34 @@ class PRList extends HTMLElement {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#toast(`Merged & cleaned up PR #${pr.number}`, 'success');
this.#load(this.#path); // refresh the list this.#load(this.#path); // refresh the list
} catch (err) { } catch (err) {
this.#error(`Merge failed: ${err.message}`); this.#toast(`Merge failed: ${err.message}`, 'error');
}
}
#toast(message, kind) {
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
}
async #create() {
const branch = this.#repo?.branch;
if (!branch) return;
const title = window.prompt(`Open a pull request from "${branch}" (into the default branch).\nTitle:`, branch);
if (title === null) return;
try {
const res = await fetch('/api/repo/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#path, head: branch, base: '', title: title.trim() || branch }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#toast(`Opened PR #${data.number}`, 'success');
this.#load(this.#path); // refresh so the new PR appears
} catch (err) {
this.#toast(`Create PR failed: ${err.message}`, 'error');
} }
} }
@@ -102,8 +128,13 @@ class PRList extends HTMLElement {
:host { display: block; } :host { display: block; }
.box { background: var(--surface-1); border: 1px solid var(--border); .box { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; } border-radius: var(--radius); padding: 14px 16px; }
.hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 0 0 8px; } color: var(--color-fg-muted); margin: 0; }
#new { margin-left: auto; font: inherit; cursor: pointer; padding: 3px 10px;
border-radius: var(--radius-sm); border: 1px solid var(--fill-accent);
color: var(--fill-accent); background: transparent; }
#new:hover { background: var(--surface-2); }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
li { border-top: 1px solid var(--border); padding-top: 8px; } li { border-top: 1px solid var(--border); padding-top: 8px; }
li:first-child { border-top: none; padding-top: 0; } li:first-child { border-top: none; padding-top: 0; }
@@ -124,9 +155,13 @@ class PRList extends HTMLElement {
.error { color: var(--color-danger); } .error { color: var(--color-danger); }
</style> </style>
<div class="box"> <div class="box">
<h3>Pull requests</h3> <div class="hd">
<h3>Pull requests</h3>
<button id="new" title="Open a PR from the current branch">New pull request…</button>
</div>
<div id="body"><p class="muted">Select a repository.</p></div> <div id="body"><p class="muted">Select a repository.</p></div>
</div>`; </div>`;
this.shadowRoot.getElementById('new').addEventListener('click', () => this.#create());
} }
#esc(s) { #esc(s) {
+5 -1
View File
@@ -13,10 +13,14 @@ naming the PR, base, and branch to be deleted.
configured or the repo isn't on the forge host. configured or the repo isn't on the forge host.
- **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`. - **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`.
- **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden). - **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden).
- **Writes:** `POST /api/repo/pr/merge {path, number}` after a `confirm()`. - **Writes:** `POST /api/repo/pr/merge {path, number}` after a `confirm()`;
`POST /api/repo/pr/create {path, head, base, title}` via the "New pull request…"
button (head = the selected repo's current branch, base = the repo default).
## History ## History
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up". - 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up".
- 2026-09-20: added "New pull request…" (create_pr) — opens a PR from the
selected repo's current branch into the default branch (slice 10).
## Notes / gotchas ## Notes / gotchas
- Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component - Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component
+130 -41
View File
@@ -2,8 +2,12 @@
// //
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a // A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a
// shadow root, fetches its own data from /api/repos on connect, renders itself, // shadow root, fetches its own data from /api/repos on connect, renders itself,
// and cleans up on disconnect. It talks to the rest of the app only via a // and cleans up on disconnect. It talks to the rest of the app only via
// bubbling/composed `repo:select` CustomEvent — no shared globals. // bubbling/composed CustomEvents (`repo:select`, `repo:contextmenu`) — no shared
// globals. Search + filter state is per-viewer view state kept in localStorage
// (§4); filtering is client-side over the already-fetched list.
const FILTER_KEY = 'gitmanager.repolist.filters';
class RepoList extends HTMLElement { class RepoList extends HTMLElement {
#refreshMs = 15000; #refreshMs = 15000;
@@ -11,6 +15,7 @@ class RepoList extends HTMLElement {
#controller = null; #controller = null;
#repos = []; #repos = [];
#selected = null; #selected = null;
#filters = { q: '', dirty: false, aheadBehind: false };
constructor() { constructor() {
super(); super();
@@ -18,6 +23,7 @@ class RepoList extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
this.#loadFilters();
this.#renderShell(); this.#renderShell();
this.#load(); this.#load();
this.#timer = setInterval(() => this.#load(), this.#refreshMs); this.#timer = setInterval(() => this.#load(), this.#refreshMs);
@@ -34,7 +40,8 @@ class RepoList extends HTMLElement {
try { try {
const res = await fetch('/api/repos', { signal: this.#controller.signal }); const res = await fetch('/api/repos', { signal: this.#controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderRepos(await res.json()); this.#repos = (await res.json()) || [];
this.#apply();
} catch (err) { } catch (err) {
if (err.name !== 'AbortError') this.#renderError(err); if (err.name !== 'AbortError') this.#renderError(err);
} }
@@ -42,7 +49,7 @@ class RepoList extends HTMLElement {
#select(repo) { #select(repo) {
this.#selected = repo.path; this.#selected = repo.path;
this.#renderRepos(this.#repos); // reflect selection highlight this.#apply(); // reflect selection highlight
// Cross-component communication is via events only (AGENT.md §1.1). // Cross-component communication is via events only (AGENT.md §1.1).
this.dispatchEvent(new CustomEvent('repo:select', { this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true, detail: repo, bubbles: true, composed: true,
@@ -56,53 +63,46 @@ class RepoList extends HTMLElement {
}).catch(() => {}); }).catch(() => {});
} }
#renderShell() { #loadFilters() {
this.shadowRoot.innerHTML = ` try {
<style> const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
:host { display: block; } this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved };
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } } catch { /* ignore — use defaults */ }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div id="body"><p class="empty">Loading repositories…</p></div>
`;
} }
#renderError(err) { #saveFilters() {
this.shadowRoot.getElementById('body').innerHTML = try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ }
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
} }
#renderRepos(repos) { // #apply computes the filtered set and renders the body + count.
this.#repos = repos || []; #apply() {
const body = this.shadowRoot.getElementById('body'); const body = this.shadowRoot.getElementById('body');
const count = this.shadowRoot.getElementById('count');
const { q, dirty, aheadBehind } = this.#filters;
const ql = q.trim().toLowerCase();
const filtered = this.#repos.filter((r) => {
if (ql && !(String(r.name).toLowerCase().includes(ql) || String(r.path).toLowerCase().includes(ql))) return false;
if (dirty && !r.dirty) return false;
if (aheadBehind && !((r.ahead || 0) > 0 || (r.behind || 0) > 0)) return false;
return true;
});
count.textContent = this.#repos.length ? `${filtered.length} of ${this.#repos.length}` : '';
if (this.#repos.length === 0) { if (this.#repos.length === 0) {
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`; body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
return; return;
} }
if (filtered.length === 0) {
body.innerHTML = `<p class="empty">No repositories match your search.</p>`;
return;
}
this.#renderList(filtered, body);
}
#renderList(repos, body) {
const ul = document.createElement('ul'); const ul = document.createElement('ul');
for (const r of this.#repos) { for (const r of repos) {
const li = document.createElement('li'); const li = document.createElement('li');
if (r.path === this.#selected) li.classList.add('selected'); if (r.path === this.#selected) li.classList.add('selected');
// Right-click opens the command menu (§6) for this repo — via an event, // Right-click opens the command menu (§6) for this repo — via an event,
@@ -128,6 +128,95 @@ class RepoList extends HTMLElement {
body.replaceChildren(ul); body.replaceChildren(ul);
} }
#renderError(err) {
this.shadowRoot.getElementById('body').innerHTML =
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.controls { display: grid; gap: 8px; margin-bottom: 10px; }
#search {
width: 100%; box-sizing: border-box; font: inherit;
background: var(--surface-1); color: var(--color-fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px;
}
#search:focus { outline: none; border-color: var(--fill-accent); }
.chips { display: flex; align-items: center; gap: 6px; }
.chip {
font: inherit; font-size: 12px; cursor: pointer; padding: 3px 10px;
border-radius: 999px; border: 1px solid var(--border-strong);
background: transparent; color: var(--color-fg-muted);
}
.chip.active { border-color: var(--fill-accent); color: var(--fill-accent);
background: var(--surface-2); }
.count { margin-left: auto; color: var(--color-fg-muted); font-size: 12px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div class="controls">
<input id="search" type="search" placeholder="Search repositories…" autocomplete="off" />
<div class="chips">
<button id="f-dirty" class="chip" type="button">Dirty</button>
<button id="f-ab" class="chip" type="button">Ahead/behind</button>
<span id="count" class="count"></span>
</div>
</div>
<div id="body"><p class="empty">Loading repositories…</p></div>
`;
const search = this.shadowRoot.getElementById('search');
const dirtyBtn = this.shadowRoot.getElementById('f-dirty');
const abBtn = this.shadowRoot.getElementById('f-ab');
// Reflect persisted state.
search.value = this.#filters.q;
dirtyBtn.classList.toggle('active', this.#filters.dirty);
abBtn.classList.toggle('active', this.#filters.aheadBehind);
search.addEventListener('input', () => {
this.#filters.q = search.value;
this.#saveFilters();
this.#apply();
});
dirtyBtn.addEventListener('click', () => {
this.#filters.dirty = !this.#filters.dirty;
dirtyBtn.classList.toggle('active', this.#filters.dirty);
this.#saveFilters();
this.#apply();
});
abBtn.addEventListener('click', () => {
this.#filters.aheadBehind = !this.#filters.aheadBehind;
abBtn.classList.toggle('active', this.#filters.aheadBehind);
this.#saveFilters();
this.#apply();
});
}
#esc(s) { #esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ( return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
+7 -1
View File
@@ -9,7 +9,11 @@ component pattern the rest of the UI follows.
## Public surface ## Public surface
- **Tag:** `<repo-list>` - **Tag:** `<repo-list>`
- **Attributes/properties:** none yet. - **Attributes/properties:** none.
- **Search + filter:** a search box (matches name/path, case-insensitive) plus
"Dirty" and "Ahead/behind" toggle chips, with a "N of M" count. Filtering is
client-side over the fetched list; the state persists per-viewer in
`localStorage["gitmanager.repolist.filters"]` (§4).
- **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is - **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is
aborted on refresh and on disconnect). aborted on refresh and on disconnect).
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail` - **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
@@ -26,6 +30,8 @@ component pattern the rest of the UI follows.
readable by Claude via `get_active_project`. Fire-and-forget. readable by Claude via `get_active_project`. Fire-and-forget.
- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for - 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for
`<repo-menu>` (§6). Right-click does not change the selection/active project. `<repo-menu>` (§6). Right-click does not change the selection/active project.
- 2026-09-20: added search + "Dirty"/"Ahead-behind" filter chips with a count,
persisted in localStorage; filtering is client-side (slice 13).
## Notes / gotchas ## Notes / gotchas
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
+105 -19
View File
@@ -4,7 +4,8 @@
// `repo:contextmenu` event from <repo-list>, and shows a positioned menu of // `repo:contextmenu` event from <repo-list>, and shows a positioned menu of
// PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run // PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run
// on click; the destructive one ("Discard all changes") confirms first (§1.4). // on click; the destructive one ("Discard all changes") confirms first (§1.4).
// It calls the same endpoints Claude uses via MCP (one service layer, §1.7); // "Switch branch" is a flyout submenu populated from the repo's branches. It
// calls the same endpoints Claude uses via MCP (one service layer, §1.7);
// results show up live in <activity-feed>. // results show up live in <activity-feed>.
const ITEMS = [ const ITEMS = [
@@ -12,6 +13,8 @@ const ITEMS = [
{ cmd: 'push', label: 'Publish', hint: 'push' }, { cmd: 'push', label: 'Publish', hint: 'push' },
{ cmd: 'fetch', label: 'Check for updates', hint: 'fetch' }, { cmd: 'fetch', label: 'Check for updates', hint: 'fetch' },
{ cmd: 'commit', label: 'Save my work…', hint: 'commit' }, { cmd: 'commit', label: 'Save my work…', hint: 'commit' },
{ sub: 'branches', label: 'Switch branch', hint: 'checkout' },
{ cmd: 'newbranch', label: 'New branch…', hint: 'branch' },
{ sep: true }, { sep: true },
{ cmd: 'active', label: 'Set as active project' }, { cmd: 'active', label: 'Set as active project' },
{ cmd: 'handoff', label: 'Ask Claude to switch here' }, { cmd: 'handoff', label: 'Ask Claude to switch here' },
@@ -25,6 +28,7 @@ class RepoMenu extends HTMLElement {
#onContext = null; #onContext = null;
#onDocClick = null; #onDocClick = null;
#onKey = null; #onKey = null;
#branchController = null;
constructor() { constructor() {
super(); super();
@@ -39,6 +43,7 @@ class RepoMenu extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener('repo:contextmenu', this.#onContext); document.removeEventListener('repo:contextmenu', this.#onContext);
this.#branchController?.abort();
this.#teardownDismiss(); this.#teardownDismiss();
} }
@@ -48,25 +53,25 @@ class RepoMenu extends HTMLElement {
const menu = this.shadowRoot.getElementById('menu'); const menu = this.shadowRoot.getElementById('menu');
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path); this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
menu.hidden = false; menu.hidden = false;
// Position, clamped to the viewport.
// Clamp to viewport; flip submenus leftward when near the right edge.
const rect = menu.getBoundingClientRect(); const rect = menu.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8); const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8); const top = Math.min(y, window.innerHeight - rect.height - 8);
menu.style.left = Math.max(8, left) + 'px'; menu.style.left = Math.max(8, left) + 'px';
menu.style.top = Math.max(8, top) + 'px'; menu.style.top = Math.max(8, top) + 'px';
menu.classList.toggle('flip', left + rect.width + 200 > window.innerWidth);
this.#loadBranches(repo);
// Dismiss on next outside click, Esc, or scroll.
this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); }; this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); };
this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); }; this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); };
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', this.#onDocClick, { once: true }); document.addEventListener('click', this.#onDocClick, { once: true });
document.addEventListener('keydown', this.#onKey); document.addEventListener('keydown', this.#onKey);
window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true });
}, 0); }, 0);
} }
#hideBound() { return () => this.#hide(); }
#hide() { #hide() {
this.shadowRoot.getElementById('menu').hidden = true; this.shadowRoot.getElementById('menu').hidden = true;
this.#teardownDismiss(); this.#teardownDismiss();
@@ -78,6 +83,30 @@ class RepoMenu extends HTMLElement {
this.#onDocClick = this.#onKey = null; this.#onDocClick = this.#onKey = null;
} }
async #loadBranches(repo) {
const sub = this.shadowRoot.getElementById('branches');
sub.innerHTML = `<div class="note">Loading…</div>`;
this.#branchController?.abort();
this.#branchController = new AbortController();
try {
const res = await fetch(`/api/repo?path=${encodeURIComponent(repo.path)}`, { signal: this.#branchController.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const detail = await res.json();
const branches = detail.branches || [];
if (branches.length === 0) { sub.innerHTML = `<div class="note">No branches.</div>`; return; }
sub.replaceChildren(...branches.map((b) => {
const btn = document.createElement('button');
btn.className = 'branch' + (b.current ? ' cur' : '');
btn.textContent = (b.current ? '● ' : '') + b.name;
if (b.current) { btn.disabled = true; btn.title = 'Current branch'; }
else { btn.dataset.branch = b.name; }
return btn;
}));
} catch (err) {
if (err.name !== 'AbortError') sub.innerHTML = `<div class="note err">Couldn't load branches.</div>`;
}
}
async #dispatch(cmd) { async #dispatch(cmd) {
const repo = this.#repo; const repo = this.#repo;
const name = this.#base(repo.path); const name = this.#base(repo.path);
@@ -91,6 +120,11 @@ class RepoMenu extends HTMLElement {
if (msg && msg.trim()) await this.#git('commit', { message: msg }); if (msg && msg.trim()) await this.#git('commit', { message: msg });
break; break;
} }
case 'newbranch': {
const b = window.prompt(`New branch name in ${name}:`);
if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() });
break;
}
case 'discard': { case 'discard': {
const ok = window.confirm( const ok = window.confirm(
`Discard ALL uncommitted changes in ${name}?\n\n` + `Discard ALL uncommitted changes in ${name}?\n\n` +
@@ -101,16 +135,37 @@ class RepoMenu extends HTMLElement {
} }
case 'active': case 'active':
await this.#post('/api/active-project', { path: repo.path }); await this.#post('/api/active-project', { path: repo.path });
this.#toast(`${name} is now the active project`, 'info');
break; break;
case 'handoff': case 'handoff':
await this.#post('/api/switch', { target: repo.path }); await this.#post('/api/switch', { target: repo.path });
this.#toast(`Asked Claude to switch to ${name}`, 'info');
break; break;
case 'copy': case 'copy':
try { await navigator.clipboard.writeText(repo.path); } catch { /* ignore */ } try { await navigator.clipboard.writeText(repo.path); this.#toast('Path copied', 'info'); }
catch { this.#toast('Could not copy path', 'error'); }
break; break;
} }
} }
async #checkoutBranch(branch) {
this.#hide();
await this.#git('checkout', { branch });
}
#okLabel(op, extra) {
switch (op) {
case 'pull': return 'Got the latest';
case 'push': return 'Published';
case 'fetch': return 'Checked for updates';
case 'commit': return 'Saved your work';
case 'checkout': return `Switched to ${extra.branch}`;
case 'create-branch': return `Created branch ${extra.branch}`;
case 'discard': return 'Discarded changes';
default: return 'Done';
}
}
async #git(op, extra = {}) { async #git(op, extra = {}) {
try { try {
const res = await fetch('/api/repo/git', { const res = await fetch('/api/repo/git', {
@@ -120,11 +175,16 @@ class RepoMenu extends HTMLElement {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#toast(this.#okLabel(op, extra), 'success');
} catch (err) { } catch (err) {
window.alert(`${op} failed: ${err.message}`); this.#toast(`${this.#okLabel(op, extra)} failed: ${err.message}`, 'error');
} }
} }
#toast(message, kind) {
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
}
async #post(url, body) { async #post(url, body) {
try { try {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
@@ -132,11 +192,19 @@ class RepoMenu extends HTMLElement {
} }
#renderShell() { #renderShell() {
const rows = ITEMS.map((it) => it.sep const rows = ITEMS.map((it) => {
? '<hr>' if (it.sep) return '<hr>';
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}"> if (it.sub) {
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''} return `<div class="item has-sub" tabindex="0">
</button>`).join(''); <span>${it.label}</span><span class="arrow">▸</span>
<div class="submenu" id="${it.sub}"></div>
</div>`;
}
return `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
</button>`;
}).join('');
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
#menu { #menu {
@@ -148,25 +216,43 @@ class RepoMenu extends HTMLElement {
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px; .hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
border-bottom: 1px solid var(--border); margin-bottom: 4px; border-bottom: 1px solid var(--border); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button { display: flex; align-items: center; gap: 10px; width: 100%; button, .item { display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; color: var(--color-fg); font: inherit; box-sizing: border-box; background: none; border: none; color: var(--color-fg);
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm); font: inherit; text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
cursor: pointer; } cursor: pointer; }
button:hover { background: var(--fill-accent); color: #071019; } button:hover, .item:hover, .item:focus { background: var(--fill-accent); color: #071019; }
button code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); } button code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
button:hover code { color: #071019; } button:hover code { color: #071019; }
button.danger { color: var(--color-danger); } button.danger { color: var(--color-danger); }
button.danger:hover { background: var(--color-danger); color: #fff; } button.danger:hover { background: var(--color-danger); color: #fff; }
button.danger:hover code { color: #fff; }
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; } hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
.has-sub { position: relative; }
.has-sub .arrow { margin-left: auto; color: var(--color-fg-muted); }
.has-sub:hover .arrow, .has-sub:focus .arrow, .has-sub:focus-within .arrow { color: #071019; }
.submenu {
position: absolute; left: 100%; top: -5px; display: none;
min-width: 180px; max-height: 260px; overflow-y: auto;
background: var(--surface-2); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 4px; box-shadow: 0 8px 28px rgba(0,0,0,.45);
}
#menu.flip .submenu { left: auto; right: 100%; }
.has-sub:hover .submenu, .has-sub:focus-within .submenu { display: block; }
.submenu .branch { color: var(--color-fg); }
.submenu .branch.cur { color: var(--color-fg-muted); cursor: default; }
.submenu .branch:disabled { background: none; color: var(--color-fg-muted); }
.submenu .note { padding: 6px 10px; color: var(--color-fg-muted); font-size: 12px; }
.submenu .note.err { color: var(--color-danger); }
</style> </style>
<div id="menu" hidden> <div id="menu" hidden>
<div class="hdr" id="hdr"></div> <div class="hdr" id="hdr"></div>
${rows} ${rows}
</div>`; </div>`;
this.shadowRoot.getElementById('menu').addEventListener('click', (e) => { this.shadowRoot.getElementById('menu').addEventListener('click', (e) => {
const btn = e.target.closest('button'); const btn = e.target.closest('button');
if (btn) this.#dispatch(btn.dataset.cmd); if (!btn) return;
if (btn.dataset.branch !== undefined) { this.#checkoutBranch(btn.dataset.branch); return; }
if (btn.dataset.cmd) this.#dispatch(btn.dataset.cmd);
}); });
} }
+10 -3
View File
@@ -11,10 +11,13 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
- **Listens:** `repo:contextmenu` on `document``detail: { repo, x, y }` - **Listens:** `repo:contextmenu` on `document``detail: { repo, x, y }`
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y). (dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
- **Commands → endpoints:** - **Commands → endpoints:**
- Get latest / Publish / Check for updates / Save my work… / Discard all - Get latest / Publish / Check for updates / Save my work… / New branch… /
changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/ Discard all changes… → `POST /api/repo/git {path, op, message?, branch?}`
commit/discard). "Save my work…" prompts for a message; "Discard all (op: pull/push/fetch/commit/create-branch/checkout/discard). "Save my work…"
prompts for a message; "New branch…" prompts for a name; "Discard all
changes…" confirms (destructive). changes…" confirms (destructive).
- **Switch branch ▸** — a flyout submenu populated from `GET /api/repo?path=`
(the repo's branches; current one disabled). Clicking a branch → checkout.
- Set as active project → `POST /api/active-project`. - Set as active project → `POST /api/active-project`.
- Ask Claude to switch here → `POST /api/switch` (the handoff request). - Ask Claude to switch here → `POST /api/switch` (the handoff request).
- Copy path → clipboard. - Copy path → clipboard.
@@ -23,6 +26,10 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
## History ## History
- 2026-09-20: created — slice 6; plain-language git commands + coordination - 2026-09-20: created — slice 6; plain-language git commands + coordination
actions, backed by the shared service layer (same ops Claude gets via MCP). actions, backed by the shared service layer (same ops Claude gets via MCP).
- 2026-09-20: added "Switch branch…" (checkout) and "New branch…" (create-branch),
both prompting for the branch name (slice 9).
- 2026-09-20: "Switch branch" is now a flyout submenu listing the repo's branches
(fetched from /api/repo), not a text prompt (slice 11). "New branch…" still prompts.
## Notes / gotchas ## Notes / gotchas
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a - Results aren't shown inline; they appear in `<activity-feed>` (each op records a
+67
View File
@@ -0,0 +1,67 @@
// <toast-host> — a singleton overlay that shows brief command-result toasts.
//
// A self-contained control (AGENT.md §1.1): shadow DOM, no data of its own. Any
// component posts a toast by dispatching a `toast` CustomEvent on document:
// document.dispatchEvent(new CustomEvent('toast',
// { detail: { message: 'Published', kind: 'success' } }));
// kind ∈ success | error | info. Toasts stack bottom-right, auto-dismiss (errors
// linger longer), and dismiss on click.
class ToastHost extends HTMLElement {
#onToast = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#render();
this.#onToast = (e) => this.#show(e.detail || {});
document.addEventListener('toast', this.#onToast);
}
disconnectedCallback() {
document.removeEventListener('toast', this.#onToast);
}
#show({ message, kind = 'info', timeout }) {
if (!message) return;
const t = document.createElement('div');
t.className = 'toast ' + (['success', 'error', 'info'].includes(kind) ? kind : 'info');
t.textContent = String(message);
t.addEventListener('click', () => t.remove());
this.shadowRoot.getElementById('stack').appendChild(t);
requestAnimationFrame(() => t.classList.add('in'));
const ms = timeout || (kind === 'error' ? 6000 : 3500);
setTimeout(() => {
t.classList.remove('in');
setTimeout(() => t.remove(), 200);
}, ms);
}
#render() {
this.shadowRoot.innerHTML = `
<style>
#stack {
position: fixed; right: 16px; bottom: 16px; z-index: 1100;
display: flex; flex-direction: column-reverse; gap: 8px;
max-width: min(360px, 90vw);
}
.toast {
background: var(--surface-2); color: var(--color-fg);
border: 1px solid var(--border-strong); border-left-width: 3px;
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
box-shadow: 0 8px 28px rgba(0,0,0,.45); cursor: pointer;
opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s;
}
.toast.in { opacity: 1; transform: none; }
.toast.success { border-left-color: var(--color-success); }
.toast.error { border-left-color: var(--color-danger); }
.toast.info { border-left-color: var(--fill-accent); }
</style>
<div id="stack"></div>`;
}
}
customElements.define('toast-host', ToastHost);
+22
View File
@@ -0,0 +1,22 @@
# toast-host
## Intent
A singleton overlay for brief command-result feedback (AGENT.md §6 polish). It
gives immediate, legible confirmation of what a UI action did — "Published",
"Merged & cleaned up PR #3", or an error — instead of only the activity feed or a
browser `alert()`. Any component can post to it without a reference to it.
## Public surface
- **Tag:** `<toast-host>` (place once, near the end of the page).
- **Listens:** `toast` on `document``detail: { message, kind?, timeout? }`,
where `kind``success | error | info` (default `info`).
- **Behavior:** toasts stack bottom-right, animate in, auto-dismiss (errors last
longer — 6s vs 3.5s), and dismiss on click. Renders nothing until posted to.
## History
- 2026-09-20: created — slice 12; inline toasts for menu/PR command results.
## Notes / gotchas
- Posters build the `CustomEvent` themselves (components are standalone, no shared
module); keep the detail shape in sync with this contract.
- Messages are set via `textContent` (no HTML injection).
+5
View File
@@ -34,6 +34,9 @@ type Config struct {
Dev bool // readable console logging vs structured JSON Dev bool // readable console logging vs structured JSON
LogFile string // optional file to also append logs to LogFile string // optional file to also append logs to
GitUserName string // commit identity for git actions run by the app
GitUserEmail string // commit identity for git actions run by the app
GiteaURL string // Gitea/Forgejo base URL (e.g. https://git.nilles.net) GiteaURL string // Gitea/Forgejo base URL (e.g. https://git.nilles.net)
GiteaToken string // Gitea token (read + PR write + branch delete) — §8.4 GiteaToken string // Gitea token (read + PR write + branch delete) — §8.4
GitHubToken string // optional forge token (later provider) GitHubToken string // optional forge token (later provider)
@@ -57,6 +60,8 @@ func Load() (Config, error) {
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false), ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"), Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
LogFile: env("LOG_FILE", ""), LogFile: env("LOG_FILE", ""),
GitUserName: env("GIT_USER_NAME", ""),
GitUserEmail: env("GIT_USER_EMAIL", ""),
GiteaURL: env("GITEA_URL", ""), GiteaURL: env("GITEA_URL", ""),
GiteaToken: env("GITEA_TOKEN", ""), GiteaToken: env("GITEA_TOKEN", ""),
GitHubToken: env("GITHUB_TOKEN", ""), GitHubToken: env("GITHUB_TOKEN", ""),
+10
View File
@@ -48,11 +48,21 @@ type MergeResult struct {
BranchDeleted bool `json:"branchDeleted"` BranchDeleted bool `json:"branchDeleted"`
} }
// NewPR describes a pull request to open. An empty Base means "the repo's
// default branch".
type NewPR struct {
Head string
Base string
Title string
Body string
}
// Provider talks to one hosting provider. // Provider talks to one hosting provider.
type Provider interface { type Provider interface {
// Handles reports whether this provider serves the given remote host. // Handles reports whether this provider serves the given remote host.
Handles(host string) bool Handles(host string) bool
ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error) ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error)
CreatePullRequest(ctx context.Context, owner, repo string, pr NewPR) (PullRequest, error)
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error) MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
} }
+30
View File
@@ -54,6 +54,36 @@ func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullR
return out, nil return out, nil
} }
// CreatePullRequest opens a PR. An empty Base resolves to the repo's default
// branch. Requires the head branch to already exist on the remote.
func (g *Gitea) CreatePullRequest(_ context.Context, owner, repo string, pr NewPR) (PullRequest, error) {
if strings.TrimSpace(pr.Head) == "" {
return PullRequest{}, fmt.Errorf("a head branch is required")
}
base := strings.TrimSpace(pr.Base)
if base == "" {
r, _, err := g.client.GetRepo(owner, repo)
if err != nil {
return PullRequest{}, err
}
base = r.DefaultBranch
}
title := pr.Title
if strings.TrimSpace(title) == "" {
title = pr.Head
}
created, _, err := g.client.CreatePullRequest(owner, repo, gitea.CreatePullRequestOption{
Head: pr.Head,
Base: base,
Title: title,
Body: pr.Body,
})
if err != nil {
return PullRequest{}, err
}
return toPR(created), nil
}
// MergeAndCleanup merges the PR and deletes its head branch (when the head is in // MergeAndCleanup merges the PR and deletes its head branch (when the head is in
// the same repo — never a fork's branch). Callers MUST have confirmed with the // the same repo — never a fork's branch). Callers MUST have confirmed with the
// user first (§1.4). // user first (§1.4).
+19
View File
@@ -69,6 +69,13 @@ func (c *CLI) Version(ctx context.Context) (string, error) {
return c.run(ctx, "", "version") return c.run(ctx, "", "version")
} }
// SetGlobalConfig sets a global git config value (git config --global key value).
// Used at startup to give the container git a commit identity and remote auth.
func (c *CLI) SetGlobalConfig(ctx context.Context, key, value string) error {
_, err := c.run(ctx, "", "config", "--global", key, value)
return err
}
// CurrentBranch returns the checked-out branch, or "HEAD" when detached. // CurrentBranch returns the checked-out branch, or "HEAD" when detached.
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) { func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
@@ -148,6 +155,18 @@ func (c *CLI) DiscardAll(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "reset", "--hard", "HEAD") return c.run(ctx, dir, "reset", "--hard", "HEAD")
} }
// Checkout switches to an existing branch. Git refuses if uncommitted changes
// would be overwritten, so this is not destructive — the error is surfaced.
func (c *CLI) Checkout(ctx context.Context, dir, branch string) (string, error) {
return c.run(ctx, dir, "checkout", branch)
}
// CreateBranch creates a new branch from the current HEAD and switches to it
// (git checkout -b). Fails if the branch already exists.
func (c *CLI) CreateBranch(ctx context.Context, dir, name string) (string, error) {
return c.run(ctx, dir, "checkout", "-b", name)
}
// Branch is a local branch and its upstream, if any. // Branch is a local branch and its upstream, if any.
type Branch struct { type Branch struct {
Name string `json:"name"` Name string `json:"name"`
+55
View File
@@ -81,6 +81,15 @@ type mergePRInput struct {
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"` Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"`
} }
// createPRInput describes a pull request to open.
type createPRInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Head string `json:"head" jsonschema:"the branch to merge from (must already exist on the remote)"`
Base string `json:"base" jsonschema:"the branch to merge into; leave empty for the repo's default branch"`
Title string `json:"title" jsonschema:"the pull request title"`
Body string `json:"body" jsonschema:"the pull request description (optional)"`
}
// gitCommitInput is the argument schema for git_commit. // gitCommitInput is the argument schema for git_commit.
type gitCommitInput struct { type gitCommitInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"` Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
@@ -92,6 +101,18 @@ type gitActionOutput struct {
Output string `json:"output" jsonschema:"the git command output (may be empty)"` Output string `json:"output" jsonschema:"the git command output (may be empty)"`
} }
// gitCheckoutInput selects a branch to switch to.
type gitCheckoutInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Branch string `json:"branch" jsonschema:"the existing branch to switch to"`
}
// createBranchInput names a new branch to create.
type createBranchInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Name string `json:"name" jsonschema:"the new branch name to create and switch to"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools. // NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server { func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{ s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -184,6 +205,18 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, prListOutput{PRs: prs}, nil return nil, prListOutput{PRs: prs}, nil
}) })
// create_pr — open a pull request from a branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "create_pr",
Description: "Open a pull request from head into base (leave base empty for the repo's default branch). The head branch must already exist on the remote — push it first. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRInput) (*mcpsdk.CallToolResult, forge.PullRequest, error) {
pr, err := svc.CreatePR(ctx, activity.ActorClaude, in.Path, in.Head, in.Base, in.Title, in.Body)
if err != nil {
return nil, forge.PullRequest{}, err
}
return nil, pr, nil
})
// merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch. // merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{ mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "merge_and_cleanup_pr", Name: "merge_and_cleanup_pr",
@@ -253,6 +286,28 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, gitActionOutput{Output: out}, nil return nil, gitActionOutput{Output: out}, nil
}) })
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_checkout",
Description: "Switch a repository to an existing branch. Git refuses if uncommitted changes would be overwritten (the error is returned). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCheckoutInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitCheckout(ctx, activity.ActorClaude, in.Path, in.Branch)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "create_branch",
Description: "Create a new branch from the current HEAD and switch to it (git checkout -b). Fails if the branch already exists. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createBranchInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitCreateBranch(ctx, activity.ActorClaude, in.Path, in.Name)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
return s return s
} }
+46 -7
View File
@@ -137,6 +137,22 @@ func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRe
return s.forge.ListPullRequests(ctx, owner, repo) return s.forge.ListPullRequests(ctx, owner, repo)
} }
// CreatePR opens a pull request from head into base (empty base = the repo's
// default branch) and records the action. The head branch must already exist on
// the remote (push it first).
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.PullRequest{}, err
}
pr, err := s.forge.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
if err != nil {
return forge.PullRequest{}, err
}
s.feed.Record(actor, "pr-created", filepath.Clean(repoPath), fmt.Sprintf("PR #%d %s → %s", pr.Number, pr.Head, pr.Base))
return pr, nil
}
// MergeAndCleanup merges a PR and deletes its branch, then records the action. // MergeAndCleanup merges a PR and deletes its branch, then records the action.
// The caller is responsible for confirming with the user first (§1.4); actor // The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude). // distinguishes a UI action (user) from an MCP one (claude).
@@ -162,7 +178,7 @@ func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, rep
// gitAction runs one mutating git op through the boundary, records the outcome // gitAction runs one mutating git op through the boundary, records the outcome
// on the activity feed, and refreshes the repo in the index on success. Callers // on the activity feed, and refreshes the repo in the index on success. Callers
// are responsible for §1.4 confirmation of destructive ops (e.g. discard). // are responsible for §1.4 confirmation of destructive ops (e.g. discard).
func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind string, run func(dir string) (string, error)) (string, error) { func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind, okDetail string, run func(dir string) (string, error)) (string, error) {
base, ok := s.index.Get(filepath.Clean(repoPath)) base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok { if !ok {
return "", fmt.Errorf("unknown repository %q", repoPath) return "", fmt.Errorf("unknown repository %q", repoPath)
@@ -172,7 +188,10 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath,
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error()) s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
return "", err return "", err
} }
s.feed.Record(actor, kind, base.Path, "ok") if okDetail == "" {
okDetail = "ok"
}
s.feed.Record(actor, kind, base.Path, okDetail)
if s.refresh != nil { if s.refresh != nil {
s.refresh(ctx, base.Path) s.refresh(ctx, base.Path)
} }
@@ -182,19 +201,19 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath,
// GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands // GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands
// the right-click menu (and, later, MCP) invoke. // the right-click menu (and, later, MCP) invoke.
func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-fetch", func(d string) (string, error) { return s.gitAction(ctx, actor, repoPath, "git-fetch", "ok", func(d string) (string, error) {
return "", s.git.Fetch(ctx, d) return "", s.git.Fetch(ctx, d)
}) })
} }
func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-pull", func(d string) (string, error) { return s.gitAction(ctx, actor, repoPath, "git-pull", "ok", func(d string) (string, error) {
return s.git.Pull(ctx, d) return s.git.Pull(ctx, d)
}) })
} }
func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-push", func(d string) (string, error) { return s.gitAction(ctx, actor, repoPath, "git-push", "ok", func(d string) (string, error) {
return s.git.Push(ctx, d) return s.git.Push(ctx, d)
}) })
} }
@@ -203,18 +222,38 @@ func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath,
if strings.TrimSpace(message) == "" { if strings.TrimSpace(message) == "" {
return "", fmt.Errorf("a commit message is required") return "", fmt.Errorf("a commit message is required")
} }
return s.gitAction(ctx, actor, repoPath, "git-commit", func(d string) (string, error) { return s.gitAction(ctx, actor, repoPath, "git-commit", "ok", func(d string) (string, error) {
return s.git.Commit(ctx, d, message) return s.git.Commit(ctx, d, message)
}) })
} }
// GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first. // GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first.
func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-discard", func(d string) (string, error) { return s.gitAction(ctx, actor, repoPath, "git-discard", "ok", func(d string) (string, error) {
return s.git.DiscardAll(ctx, d) return s.git.DiscardAll(ctx, d)
}) })
} }
// GitCheckout switches to an existing branch.
func (s *Service) GitCheckout(ctx context.Context, actor activity.Actor, repoPath, branch string) (string, error) {
if strings.TrimSpace(branch) == "" {
return "", fmt.Errorf("a branch name is required")
}
return s.gitAction(ctx, actor, repoPath, "git-checkout", "switched to "+branch, func(d string) (string, error) {
return s.git.Checkout(ctx, d, branch)
})
}
// GitCreateBranch creates a new branch from HEAD and switches to it.
func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, repoPath, name string) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("a branch name is required")
}
return s.gitAction(ctx, actor, repoPath, "git-create-branch", "created "+name, func(d string) (string, error) {
return s.git.CreateBranch(ctx, d, name)
})
}
// resolveForge maps a repo path to (owner, repo) on the configured forge host via // resolveForge maps a repo path to (owner, repo) on the configured forge host via
// its git remotes, preferring "origin". // its git remotes, preferring "origin".
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) { func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
+18
View File
@@ -65,6 +65,24 @@ func TestGitActions(t *testing.T) {
t.Fatalf("a.txt = %q, want restored to \"one\"", got) t.Fatalf("a.txt = %q, want restored to \"one\"", got)
} }
// Create a branch (switches to it), then switch back to main.
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err != nil {
t.Fatalf("GitCreateBranch: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Branch != "feature-x" {
t.Fatalf("branch = %q, want feature-x", st.Branch)
}
if _, err := svc.GitCheckout(ctx, activity.ActorUser, repoPath, "main"); err != nil {
t.Fatalf("GitCheckout: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Branch != "main" {
t.Fatalf("branch = %q, want main", st.Branch)
}
// Creating an existing branch fails.
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err == nil {
t.Fatalf("expected error creating an existing branch")
}
// The feed recorded the successful actions. // The feed recorded the successful actions.
kinds := map[string]bool{} kinds := map[string]bool{}
for _, e := range feed.Events(0) { for _, e := range feed.Events(0) {
+13 -1
View File
@@ -38,6 +38,14 @@
<li>Run <code>docker compose up</code> and open the dashboard.</li> <li>Run <code>docker compose up</code> and open the dashboard.</li>
</ol> </ol>
<h2>Finding a repository</h2>
<p>
Use the search box above the list to filter by name or path, and the
<strong>Dirty</strong> and <strong>Ahead/behind</strong> chips to show only
repos with uncommitted changes or commits to sync. The count shows how many
match. Your search and filters are remembered on this device.
</p>
<h2>Reading the dashboard</h2> <h2>Reading the dashboard</h2>
<ul> <ul>
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li> <li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
@@ -55,6 +63,8 @@
<li><strong>Publish</strong> — push your commits.</li> <li><strong>Publish</strong> — push your commits.</li>
<li><strong>Check for updates</strong> — fetch without changing your files.</li> <li><strong>Check for updates</strong> — fetch without changing your files.</li>
<li><strong>Save my work…</strong> — commit everything (asks for a message).</li> <li><strong>Save my work…</strong> — commit everything (asks for a message).</li>
<li><strong>Switch branch ▸</strong> — hover to pick from the repo's branches.</li>
<li><strong>New branch…</strong> — create a branch and switch to it.</li>
<li><strong>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li> <li><strong>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
<li><strong>Copy path</strong>.</li> <li><strong>Copy path</strong>.</li>
<li><strong>Discard all changes…</strong> — throw away uncommitted edits <li><strong>Discard all changes…</strong> — throw away uncommitted edits
@@ -86,7 +96,9 @@
<h2>Pull requests: Merge &amp; clean up</h2> <h2>Pull requests: Merge &amp; clean up</h2>
<p> <p>
When a repository is hosted on your Gitea server, its open pull requests When a repository is hosted on your Gitea server, its open pull requests
appear under the details panel. Each has a <strong>Merge &amp; clean up</strong> appear under the details panel. Use <strong>New pull request…</strong> to
open one from the selected repo's current branch (push the branch first).
Each open PR has a <strong>Merge &amp; clean up</strong>
button: it squash-merges the pull request and <strong>deletes its button: it squash-merges the pull request and <strong>deletes its
branch</strong> in one step, so finished work doesn't leave branches lying branch</strong> in one step, so finished work doesn't leave branches lying
around. You'll be asked to confirm — it names the pull request and the branch around. You'll be asked to confirm — it names the pull request and the branch
+2
View File
@@ -13,6 +13,7 @@
<script type="module" src="/components/handoff-bar/handoff-bar.js"></script> <script type="module" src="/components/handoff-bar/handoff-bar.js"></script>
<script type="module" src="/components/pr-list/pr-list.js"></script> <script type="module" src="/components/pr-list/pr-list.js"></script>
<script type="module" src="/components/repo-menu/repo-menu.js"></script> <script type="module" src="/components/repo-menu/repo-menu.js"></script>
<script type="module" src="/components/toast-host/toast-host.js"></script>
<style> <style>
header { header {
display: flex; display: flex;
@@ -55,5 +56,6 @@
<activity-feed></activity-feed> <activity-feed></activity-feed>
</main> </main>
<repo-menu></repo-menu> <repo-menu></repo-menu>
<toast-host></toast-host>
</body> </body>
</html> </html>