diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..f4ddf84 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,228 @@ +// Package git provides thin, read-mostly helpers around the local git CLI for +// the chat composer's branch indicator and PR button. All commands run with an +// explicit working directory and fixed argument lists (no shell), so session +// cwd values can never inject extra commands. +package git + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + "time" +) + +var ( + // ErrNotRepo is returned when dir is not inside a git work tree. + ErrNotRepo = errors.New("not a git repository") + // ErrInvalidBranchName is returned when a requested branch name is empty + // or contains characters git would reject. + ErrInvalidBranchName = errors.New("invalid branch name") + // ErrNoRemote is returned when no GitHub-style origin remote is configured. + ErrNoRemote = errors.New("no github remote configured") + // ErrDefaultBranch is returned when a rename targets the repository's + // default branch, which we refuse to rename. + ErrDefaultBranch = errors.New("refusing to rename the default branch") +) + +// branchNamePattern is intentionally stricter than git's own check-ref-format: +// it covers the names humans actually type and rejects anything exotic. +var branchNamePattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) + +// Info describes the git state surfaced in the composer footer. +type Info struct { + IsRepo bool `json:"isRepo"` + Branch string `json:"branch"` + // IsDefault marks the repository's default branch (no rename / no PR). + IsDefault bool `json:"isDefault"` + // HasChanges is true when the working tree is dirty or there are local + // commits not yet pushed to the upstream — i.e. there's something to push. + HasChanges bool `json:"hasChanges"` + // PRCreateURL is the GitHub "open a pull request" URL for this branch. + PRCreateURL string `json:"prCreateUrl"` + // PRURL is set when an OPEN pull request already exists for this branch, + // in which case the UI offers "View PR" instead of "Create PR". + PRURL string `json:"prUrl"` +} + +func run(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// CurrentBranch returns the checked-out branch name for dir. +func CurrentBranch(dir string) (string, error) { + if dir == "" { + return "", ErrNotRepo + } + branch, err := run(dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", ErrNotRepo + } + if branch == "" || branch == "HEAD" { + // Detached HEAD or empty repo: no editable branch. + return "", ErrNotRepo + } + return branch, nil +} + +// Describe gathers the branch and a best-effort GitHub PR URL for dir. A +// non-repo directory yields Info{IsRepo: false} with a nil error so callers can +// simply hide the footer. +func Describe(dir string) (Info, error) { + branch, err := CurrentBranch(dir) + if err != nil { + return Info{IsRepo: false}, nil + } + info := Info{IsRepo: true, Branch: branch, HasChanges: HasLocalChanges(dir)} + if def := DefaultBranch(dir); def != "" && def == branch { + info.IsDefault = true + } + if url, err := pullRequestURL(dir, branch); err == nil { + info.PRCreateURL = url + } + // Only feature branches can have a PR against the default branch. + if !info.IsDefault { + info.PRURL = existingOpenPRURL(dir) + } + return info, nil +} + +// HasLocalChanges reports whether there is something to commit or push: either +// a dirty working tree, or local commits ahead of the upstream branch. +func HasLocalChanges(dir string) bool { + if out, err := run(dir, "status", "--porcelain"); err == nil && out != "" { + return true + } + if out, err := run(dir, "rev-list", "--count", "@{upstream}..HEAD"); err == nil { + if out != "" && out != "0" { + return true + } + } + return false +} + +// existingOpenPRURL returns the URL of an OPEN pull request for the current +// branch, using the gh CLI when available. It is best-effort: a missing/ +// unauthenticated gh, no PR, or a closed/merged PR all yield "". +func existingOpenPRURL(dir string) string { + gh, err := exec.LookPath("gh") + if err != nil { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, gh, "pr", "view", "--json", "url,state") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + var pr struct { + URL string `json:"url"` + State string `json:"state"` + } + if err := json.Unmarshal(out, &pr); err != nil { + return "" + } + if strings.EqualFold(pr.State, "OPEN") { + return pr.URL + } + return "" +} + +// DefaultBranch reports the repository's default branch. It prefers the +// remote's published HEAD (origin/HEAD) and falls back to a local main/master +// when that isn't configured. Returns "" when it can't be determined. +func DefaultBranch(dir string) string { + if out, err := run(dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"); err == nil && out != "" { + return strings.TrimPrefix(out, "origin/") + } + for _, candidate := range []string{"main", "master"} { + if _, err := run(dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+candidate); err == nil { + return candidate + } + } + return "" +} + +// RenameBranch renames the currently checked-out branch to name via +// `git branch -m`, validating the name first. +func RenameBranch(dir, name string) (string, error) { + name = strings.TrimSpace(name) + if !ValidBranchName(name) { + return "", ErrInvalidBranchName + } + branch, err := CurrentBranch(dir) + if err != nil { + return "", err + } + if def := DefaultBranch(dir); def != "" && def == branch { + return "", ErrDefaultBranch + } + cmd := exec.Command("git", "branch", "-m", name) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("%s", msg) + } + return name, nil +} + +// ValidBranchName reports whether name is safe to pass to git branch -m. +func ValidBranchName(name string) bool { + if name == "" || len(name) > 255 { + return false + } + if strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") { + return false + } + if strings.Contains(name, "..") || strings.Contains(name, "//") { + return false + } + return branchNamePattern.MatchString(name) +} + +// pullRequestURL turns the origin remote + branch into a GitHub "open a pull +// request" URL. Supports both SSH (git@github.com:owner/repo.git) and HTTPS +// remotes. Returns ErrNoRemote for non-GitHub or missing remotes. +func pullRequestURL(dir, branch string) (string, error) { + remote, err := run(dir, "remote", "get-url", "origin") + if err != nil || remote == "" { + return "", ErrNoRemote + } + slug, ok := githubSlug(remote) + if !ok { + return "", ErrNoRemote + } + return fmt.Sprintf("https://github.com/%s/pull/new/%s", slug, branch), nil +} + +// githubSlug extracts "owner/repo" from a github remote URL, or returns false. +func githubSlug(remote string) (string, bool) { + remote = strings.TrimSpace(remote) + remote = strings.TrimSuffix(remote, ".git") + + switch { + case strings.HasPrefix(remote, "git@github.com:"): + return strings.TrimPrefix(remote, "git@github.com:"), true + case strings.HasPrefix(remote, "ssh://git@github.com/"): + return strings.TrimPrefix(remote, "ssh://git@github.com/"), true + case strings.HasPrefix(remote, "https://github.com/"): + return strings.TrimPrefix(remote, "https://github.com/"), true + case strings.HasPrefix(remote, "http://github.com/"): + return strings.TrimPrefix(remote, "http://github.com/"), true + } + return "", false +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..421cfd8 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,137 @@ +package git + +import ( + "os/exec" + "path/filepath" + "testing" +) + +func initTestRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + mustGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, out) + } + } + mustGit("init") + mustGit("config", "user.email", "test@example.com") + mustGit("config", "user.name", "Test") + mustGit("commit", "--allow-empty", "-m", "init") + mustGit("branch", "-M", "main") + return dir +} + +func TestDescribeDefaultBranch(t *testing.T) { + dir := initTestRepo(t) + + info, err := Describe(dir) + if err != nil { + t.Fatalf("Describe: %v", err) + } + if !info.IsRepo || info.Branch != "main" { + t.Fatalf("got %+v, want repo on main", info) + } + if !info.IsDefault { + t.Fatalf("main should be reported as the default branch") + } + + // The default branch must not be renamable, even via the API directly. + if _, err := RenameBranch(dir, "renamed-main"); err != ErrDefaultBranch { + t.Fatalf("renaming default branch: got %v, want ErrDefaultBranch", err) + } + if info, _ := Describe(dir); info.Branch != "main" { + t.Fatalf("default branch was renamed to %q despite guard", info.Branch) + } + + // Create and switch to a feature branch so the rename below is allowed. + cmd := exec.Command("git", "checkout", "-b", "feature/tmp") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("checkout feature branch: %v (%s)", err, out) + } + + if _, err := RenameBranch(dir, "feature/x"); err != nil { + t.Fatalf("RenameBranch: %v", err) + } + info, _ = Describe(dir) + if info.Branch != "feature/x" { + t.Fatalf("got branch %q, want feature/x", info.Branch) + } + if info.IsDefault { + t.Fatalf("feature/x should not be the default branch") + } +} + +func TestDescribeNonRepo(t *testing.T) { + info, err := Describe(filepath.Join(t.TempDir(), "nope")) + if err != nil { + t.Fatalf("Describe non-repo returned error: %v", err) + } + if info.IsRepo { + t.Fatalf("expected IsRepo false for non-repo dir") + } +} + +func TestValidBranchName(t *testing.T) { + valid := []string{ + "main", + "feature/pr-button", + "fix_123", + "release-2.1.0", + "a", + } + for _, name := range valid { + if !ValidBranchName(name) { + t.Errorf("expected %q to be valid", name) + } + } + + invalid := []string{ + "", + "-leading-dash", + "/leading-slash", + "trailing-slash/", + "has space", + "double..dot", + "double//slash", + "semicolon;rm", + "tilde~name", + "caret^name", + "colon:name", + "quote\"name", + } + for _, name := range invalid { + if ValidBranchName(name) { + t.Errorf("expected %q to be invalid", name) + } + } +} + +func TestGithubSlug(t *testing.T) { + cases := []struct { + remote string + want string + ok bool + }{ + {"git@github.com:owner/repo.git", "owner/repo", true}, + {"git@github.com:owner/repo", "owner/repo", true}, + {"https://github.com/owner/repo.git", "owner/repo", true}, + {"https://github.com/owner/repo", "owner/repo", true}, + {"ssh://git@github.com/owner/repo.git", "owner/repo", true}, + {"git@gitlab.com:owner/repo.git", "", false}, + {"https://example.com/owner/repo.git", "", false}, + {"", "", false}, + } + for _, c := range cases { + got, ok := githubSlug(c.remote) + if ok != c.ok || got != c.want { + t.Errorf("githubSlug(%q) = (%q, %v), want (%q, %v)", c.remote, got, ok, c.want, c.ok) + } + } +} diff --git a/internal/server/git.go b/internal/server/git.go new file mode 100644 index 0000000..bb8ff6d --- /dev/null +++ b/internal/server/git.go @@ -0,0 +1,89 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "pi-web/internal/git" + "pi-web/internal/sessions" +) + +// resolveSessionCwd resolves a session id to its working directory (the cwd +// recorded in the session header). +func (s *Server) resolveSessionCwd(id string) (sessions.ResolvedSession, string, error) { + var resolved sessions.ResolvedSession + var err error + if s.cache != nil { + resolved, err = s.cache.Resolve(s.sessionsDir, id) + } else { + resolved, err = sessions.ResolveByID(s.sessionsDir, id) + } + if err != nil { + return resolved, "", err + } + cwd, _ := resolved.Session.Header["cwd"].(string) + return resolved, cwd, nil +} + +func writeSessionLookupError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, sessions.ErrInvalidSessionID): + writeJSONError(w, http.StatusBadRequest, "invalid session id") + case errors.Is(err, sessions.ErrSessionNotFound): + writeJSONError(w, http.StatusNotFound, "not found") + default: + writeJSONError(w, http.StatusInternalServerError, err.Error()) + } +} + +// handleGitInfo returns the current branch and a GitHub PR URL for the +// session's working directory. Non-repo cwds return {isRepo:false}. +func (s *Server) handleGitInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + _, cwd, err := s.resolveSessionCwd(r.URL.Query().Get("id")) + if err != nil { + writeSessionLookupError(w, err) + return + } + info, _ := git.Describe(cwd) + writeJSON(w, 0, info) +} + +// handleGitRenameBranch renames the checked-out branch in the session's cwd. +func (s *Server) handleGitRenameBranch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + _, cwd, err := s.resolveSessionCwd(r.URL.Query().Get("id")) + if err != nil { + writeSessionLookupError(w, err) + return + } + branch, err := git.RenameBranch(cwd, body.Name) + if err != nil { + switch { + case errors.Is(err, git.ErrInvalidBranchName): + writeJSONError(w, http.StatusBadRequest, "invalid branch name") + case errors.Is(err, git.ErrDefaultBranch): + writeJSONError(w, http.StatusBadRequest, "refusing to rename the default branch") + case errors.Is(err, git.ErrNotRepo): + writeJSONError(w, http.StatusBadRequest, "not a git repository") + default: + writeJSONError(w, http.StatusBadRequest, err.Error()) + } + return + } + writeJSON(w, 0, map[string]any{"ok": true, "branch": branch}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 5dd1c3d..1938869 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -171,6 +171,8 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/clone-session", s.auth.Wrap(s.handleApiCloneSession)) mux.HandleFunc("/api/rename-session", s.auth.Wrap(s.handleRenameSession)) mux.HandleFunc("/api/recent-locations", s.auth.Wrap(s.handleRecentLocations)) + mux.HandleFunc("/api/git/info", s.auth.Wrap(s.handleGitInfo)) + mux.HandleFunc("/api/git/rename-branch", s.auth.Wrap(s.handleGitRenameBranch)) mux.HandleFunc("/custom-themes.css", s.auth.Wrap(s.handleCustomThemes)) mux.HandleFunc("/api/scratchpad", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { diff --git a/internal/ui/live_templates/chat_composer.html b/internal/ui/live_templates/chat_composer.html index c384178..8116066 100644 --- a/internal/ui/live_templates/chat_composer.html +++ b/internal/ui/live_templates/chat_composer.html @@ -229,4 +229,79 @@ +
diff --git a/internal/ui/live_templates/styles/index.css b/internal/ui/live_templates/styles/index.css index 6346dc1..b324203 100644 --- a/internal/ui/live_templates/styles/index.css +++ b/internal/ui/live_templates/styles/index.css @@ -34,7 +34,7 @@ a { color: inherit; } position: sticky; top: 0; z-index: 20; - background: color-mix(in srgb, var(--body-bg) 92%, transparent); + background: color-mix(in srgb, var(--chrome-bg) 92%, transparent); border-bottom: 1px solid var(--dim); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index f4d1a65..0edb834 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -454,6 +454,10 @@ align-items: stretch; height: calc(100vh - 52px); margin-top: 52px; + /* Chrome shell behind the resizer gutters so no body-bg slivers show + beside the darker composer / sidebars. The content column re-lights + itself with --body-bg below. */ + background: var(--chrome-bg); } /* Sidebar — height comes from #app's fixed height */ @@ -461,7 +465,7 @@ width: var(--sidebar-width); min-width: var(--sidebar-width); max-width: var(--sidebar-width); - background: var(--body-bg); + background: var(--chrome-bg); flex-shrink: 0; display: flex; flex-direction: column; @@ -510,7 +514,7 @@ width: var(--right-sidebar-width); min-width: var(--right-sidebar-width); max-width: var(--right-sidebar-width); - background: var(--body-bg); + background: var(--chrome-bg); flex-shrink: 0; display: flex; flex-direction: column; @@ -547,7 +551,7 @@ .right-sidebar-header { padding: 8px 12px; flex-shrink: 0; - background: var(--body-bg); + background: var(--chrome-bg); border-bottom: 1px solid var(--dim); display: flex; align-items: center; @@ -615,7 +619,7 @@ } .scratchpad-textarea::placeholder { - color: var(--dim); + color: var(--muted); } .right-sidebar-footer { @@ -672,7 +676,7 @@ .sidebar-header { padding: 8px 12px; flex-shrink: 0; - background: var(--body-bg); + background: var(--chrome-bg); border-bottom: 1px solid var(--dim); } @@ -689,7 +693,7 @@ padding: 4px 8px; font-size: 11px; font-family: inherit; - background: var(--body-bg); + background: var(--input-bg); color: var(--text); border: 1px solid var(--dim); border-radius: 3px; @@ -851,6 +855,8 @@ display: flex; flex-direction: column; align-items: center; + /* The lit reading column on top of the chrome #app shell. */ + background: var(--body-bg); } #content > * { @@ -1038,7 +1044,7 @@ z-index: 101; height: 52px; padding: 0 18px; - background: color-mix(in srgb, var(--body-bg) 92%, transparent); + background: color-mix(in srgb, var(--chrome-bg) 92%, transparent); border-bottom: 1px solid var(--pi-menu-border); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); @@ -2187,10 +2193,167 @@ width: 100%; box-sizing: border-box; display: block; - padding: 10px 14px calc(12px + env(safe-area-inset-bottom)); - background: var(--body-bg); - border-top: 1px solid color-mix(in srgb, var(--dim) 55%, transparent); + padding: 18px 14px calc(14px + env(safe-area-inset-bottom)); + background: var(--chrome-bg); + } + + /* Git branch + Create PR row beneath the input box. */ + .pi-git-bar { + width: 100%; + max-width: 760px; + margin: 8px auto 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 2px; + font-size: 11px; } + .pi-git-bar[hidden] { display: none; } + + .pi-git-branch { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--muted); + } + .pi-git-icon { + width: 13px; + height: 13px; + fill: currentColor; + flex-shrink: 0; + } + .pi-git-branch-name { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-soft); + font-family: inherit; + } + .pi-git-edit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + flex-shrink: 0; + background: transparent; + color: var(--muted); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, color 0.15s, background 0.15s, border-color 0.15s; + } + .pi-git-edit:hover { + opacity: 1; + color: var(--text); + background: var(--hover); + border-color: var(--borderMuted); + } + .pi-git-edit svg { width: 12px; height: 12px; fill: currentColor; } + + .pi-git-branch-input { + min-width: 0; + flex: 1 1 220px; + max-width: 320px; + padding: 2px 7px; + font-size: 11px; + font-family: inherit; + background: var(--input-bg); + color: var(--text); + border: 1px solid color-mix(in srgb, var(--accent, #8abeb7) 60%, var(--dim)); + border-radius: 4px; + outline: none; + } + .pi-git-branch-input[hidden] { display: none; } + + .pi-git-actions { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } + .pi-git-actions > [hidden] { display: none; } + .pi-git-pr { + position: relative; + flex-shrink: 0; + } + .pi-git-pr-button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 11px; + font-size: 11px; + font-family: inherit; + background: transparent; + color: var(--text-soft); + border: 1px solid var(--dim); + border-radius: 5px; + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; + } + .pi-git-pr-button:hover, + .pi-git-pr-button[aria-expanded="true"] { + color: var(--text); + background: var(--hover); + border-color: var(--borderMuted); + } + + .pi-git-pr-menu { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + min-width: 180px; + background: var(--container-bg, var(--surface)); + border: 1px solid var(--dim); + border-radius: 6px; + box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.28); + padding: 4px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 2px; + } + .pi-git-pr-menu[hidden] { display: none; } + .pi-git-pr-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 9px; + font-size: 11px; + font-family: inherit; + background: transparent; + color: var(--text-soft); + border: 0; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + } + .pi-git-pr-item:hover { + background: var(--hover); + color: var(--text); + } + /* The class above sets display:block, which would otherwise defeat the + [hidden] attribute — restore hiding for toggled menu items. */ + .pi-git-pr-item[hidden] { display: none; } + + /* Split button: primary action + caret share a pill, divided by a hairline. */ + .pi-git-primary { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .pi-git-caret { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + padding-left: 7px; + padding-right: 7px; + } + .pi-git-caret[hidden] { display: none; } .pi-chat-shell { width: 100%; @@ -2199,7 +2362,7 @@ min-width: 0; box-sizing: border-box; overflow: visible; - background: var(--body-bg); + background: var(--input-bg); border: 1px solid var(--dim); border-radius: 6px; position: relative; diff --git a/internal/ui/live_templates/styles/theme.css b/internal/ui/live_templates/styles/theme.css index 8f9af0e..72a09ef 100644 --- a/internal/ui/live_templates/styles/theme.css +++ b/internal/ui/live_templates/styles/theme.css @@ -5,6 +5,15 @@ --palette-surface: var(--surface); --palette-surface-2: var(--surface-2); --palette-text-soft: var(--text-soft); + + /* + * Chrome surfaces (top bar + sidebars + composer bar) sit a touch darker + * than the body so the middle reading column reads as "lit up". The chat + * input box sits a touch lighter than the body. Both derive from --body-bg + * so every theme, including the runtime "custom" theme, gets them for free. + */ + --chrome-bg: color-mix(in srgb, var(--body-bg) 90%, #000); + --input-bg: color-mix(in srgb, var(--body-bg) 86%, #fff); } :root, diff --git a/internal/ui/session_page.go b/internal/ui/session_page.go index b652047..9ccf6e9 100644 --- a/internal/ui/session_page.go +++ b/internal/ui/session_page.go @@ -8,6 +8,7 @@ import ( "html/template" "strings" + "pi-web/internal/git" "pi-web/internal/sessions" ) @@ -169,18 +170,39 @@ func chatComposerHtmlForSession(session sessions.Session) string { modelLabel = modelLabel + " @ " + session.ModelProvider } } + // Pre-render the branch + the correct action control from fast, local git + // calls so the footer doesn't pop in after the async /api/git/info fetch. + // PR detection (which can hit the network via gh) stays in that async call. + gitIsRepo, gitBranch, gitIsDefault, gitHasChanges := false, "", false, false + if cwd != "" { + if b, err := git.CurrentBranch(cwd); err == nil { + gitIsRepo, gitBranch = true, b + if def := git.DefaultBranch(cwd); def != "" && def == b { + gitIsDefault = true + } + gitHasChanges = git.HasLocalChanges(cwd) + } + } data := struct { SessionID string ChatAvailable bool ChatDisabledReason string Cwd string ModelLabel string + GitIsRepo bool + GitBranch string + GitIsDefault bool + GitHasChanges bool }{ SessionID: session.ID, ChatAvailable: chatAvailable, ChatDisabledReason: session.ChatDisabledReason, Cwd: cwd, ModelLabel: modelLabel, + GitIsRepo: gitIsRepo, + GitBranch: gitBranch, + GitIsDefault: gitIsDefault, + GitHasChanges: gitHasChanges, } if !data.ChatAvailable && data.ChatDisabledReason == "" { data.ChatDisabledReason = "This session can be viewed, but chat is disabled because its working directory no longer exists." diff --git a/web/src/session/chat/git-api.js b/web/src/session/chat/git-api.js new file mode 100644 index 0000000..d16d834 --- /dev/null +++ b/web/src/session/chat/git-api.js @@ -0,0 +1,9 @@ +import { getJSON, postJSON } from '../../shared/api.js'; + +export function getGitInfo(sessionId, { getImpl = getJSON } = {}) { + return getImpl(`/api/git/info?id=${encodeURIComponent(sessionId)}`); +} + +export function renameBranch(sessionId, name, { postImpl = postJSON } = {}) { + return postImpl(`/api/git/rename-branch?id=${encodeURIComponent(sessionId)}`, { name }); +} diff --git a/web/src/session/chat/git-footer.js b/web/src/session/chat/git-footer.js new file mode 100644 index 0000000..3ad2ad3 --- /dev/null +++ b/web/src/session/chat/git-footer.js @@ -0,0 +1,251 @@ +// Wires the branch indicator + the smart git action control beneath the chat +// composer. The bar stays hidden unless the session cwd is a git repo. +// +// The right-hand control is a split button: a primary action chosen for the +// current state, plus a caret (▾) revealing the remaining relevant actions. + +export const DRAFT_PR_PROMPT = + 'Commit any uncommitted changes on this branch with a clear message, push ' + + 'the branch to the remote, then open a draft pull request with ' + + '`gh pr create --draft`. Review the diff first (`git status`, `git diff`, ' + + '`git log`) and write a clear PR title, a description summarizing what ' + + 'changed and why, and a short test plan.'; + +export const COMMIT_PUSH_PROMPT = + 'Review the current changes (run `git status` and `git diff`), then stage ' + + 'and commit them with a clear message summarizing what changed and why, and ' + + 'push to the remote.'; + +export const MERGE_PR_PROMPT = + 'Merge the pull request for this branch once its checks are green: run ' + + '`gh pr merge` (use a squash merge unless the project prefers otherwise) and ' + + 'delete the branch after merging.'; + +export function setupGitFooter({ + documentImpl = document, + windowImpl = window, + sessionId = '', + gitApi +} = {}) { + const bar = documentImpl.getElementById('pi-git-bar'); + if (!bar || !gitApi) return; + + const nameEl = documentImpl.getElementById('pi-git-branch-name'); + const editBtn = documentImpl.getElementById('pi-git-branch-edit'); + const input = documentImpl.getElementById('pi-git-branch-input'); + const prWrap = documentImpl.getElementById('pi-git-pr'); + const primaryBtn = documentImpl.getElementById('pi-git-primary'); + const primaryLabel = documentImpl.getElementById('pi-git-primary-label'); + const caretBtn = documentImpl.getElementById('pi-git-caret'); + const prMenu = documentImpl.getElementById('pi-git-pr-menu'); + const items = { + view: documentImpl.getElementById('pi-git-pr-view'), + draft: documentImpl.getElementById('pi-git-pr-draft'), + manual: documentImpl.getElementById('pi-git-pr-manual'), + merge: documentImpl.getElementById('pi-git-pr-merge'), + commit: documentImpl.getElementById('pi-git-pr-commit') + }; + + let currentBranch = ''; + let prCreateUrl = ''; + let existingPrUrl = ''; + let primaryAction = () => {}; + + function show(el, visible) { + if (el) el.hidden = !visible; + } + + function insertPrompt(text) { + const textarea = documentImpl.getElementById('pi-chat-message'); + if (!textarea) return; + textarea.value = text; + const EventCtor = windowImpl.Event || (typeof Event !== 'undefined' ? Event : null); + if (EventCtor) textarea.dispatchEvent(new EventCtor('input', { bubbles: true })); + if (typeof textarea.focus === 'function') textarea.focus(); + } + function openUrl(url) { + if (url && typeof windowImpl.open === 'function') windowImpl.open(url, '_blank', 'noopener'); + } + + // Each action is { id, label, run }. The plan picks one primary plus a list + // of secondary actions shown under the caret. + const ACTIONS = { + draft: { label: 'Create PR', run: () => insertPrompt(DRAFT_PR_PROMPT) }, + manual: { label: 'Create PR manually ↗', run: () => openUrl(prCreateUrl) }, + view: { label: 'View PR ↗', run: () => openUrl(existingPrUrl) }, + merge: { label: 'Merge PR', run: () => insertPrompt(MERGE_PR_PROMPT) }, + commit: { label: 'Commit & push', run: () => insertPrompt(COMMIT_PUSH_PROMPT) } + }; + + // Decide the primary action + secondary list from the current git state. + // "Commit & push" only appears when there is actually something to push. + function planActions({ isDefault, hasPr, hasChanges }) { + if (isDefault) { + // The only thing to do on the default branch is push pending changes. + return { primary: hasChanges ? 'commit' : null, secondary: [] }; + } + if (!hasPr) { + // "Create PR" already commits + pushes, so the only alternative offered + // is doing it by hand on GitHub. + return { primary: 'draft', secondary: ['manual'] }; + } + // An open PR already exists for this feature branch. + if (hasChanges) return { primary: 'commit', secondary: ['view', 'merge'] }; + return { primary: 'view', secondary: ['merge'] }; + } + + function applyInfo(info) { + if (!info || !info.isRepo || !info.branch) { + bar.hidden = true; + return; + } + currentBranch = info.branch; + prCreateUrl = info.prCreateUrl || ''; + existingPrUrl = info.prUrl || ''; + if (nameEl) nameEl.textContent = info.branch; + if (items.manual) items.manual.title = prCreateUrl ? prCreateUrl : 'No GitHub remote configured'; + + const isDefault = !!info.isDefault; + const hasPr = !isDefault && !!existingPrUrl; + const hasChanges = !!info.hasChanges; + + show(editBtn, !isDefault); + + const plan = planActions({ isDefault, hasPr, hasChanges }); + const primary = plan.primary ? ACTIONS[plan.primary] : null; + if (primary) { + if (primaryLabel) primaryLabel.textContent = primary.label; + primaryAction = primary.run; + } else { + primaryAction = () => {}; + } + show(primaryBtn, !!primary); + + const secondary = new Set(plan.secondary); + Object.keys(items).forEach((id) => show(items[id], secondary.has(id))); + show(caretBtn, plan.secondary.length > 0); + if (plan.secondary.length === 0) setMenuOpen(false); + + // Hide the whole action cluster when there is nothing to do. + show(prWrap, !!primary || plan.secondary.length > 0); + + bar.hidden = false; + } + + // Synchronous first paint from the server-stamped data attributes (branch, + // default, has-changes) so the control is correct immediately. The async + // refresh() then fills in PR state (which can require a network gh call). + function infoFromDataset() { + if (bar.dataset.gitRepo !== 'true') return null; + return { + isRepo: true, + branch: bar.dataset.gitBranch || '', + isDefault: bar.dataset.gitDefault === 'true', + hasChanges: bar.dataset.gitHasChanges === 'true', + prUrl: '', + prCreateUrl: '' + }; + } + + function refresh() { + return gitApi + .getGitInfo(sessionId) + .then(applyInfo) + .catch(() => {}); + } + + // ── Branch rename ── + function openEditor() { + if (!input) return; + input.value = currentBranch; + input.hidden = false; + if (nameEl) nameEl.hidden = true; + if (editBtn) editBtn.hidden = true; + input.focus(); + input.select(); + } + function closeEditor() { + if (!input) return; + input.hidden = true; + if (nameEl) nameEl.hidden = false; + if (editBtn) editBtn.hidden = false; + } + function commitRename() { + const next = (input ? input.value : '').trim(); + if (!next || next === currentBranch) { + closeEditor(); + return; + } + gitApi + .renameBranch(sessionId, next) + .then(() => { + closeEditor(); + return refresh(); + }) + .catch((err) => { + if (input) { + input.title = (err && err.message) || 'Rename failed'; + input.focus(); + input.select(); + } + }); + } + + if (editBtn) { + editBtn.addEventListener('click', (e) => { + e.preventDefault(); + openEditor(); + }); + } + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closeEditor(); + } + }); + input.addEventListener('blur', () => closeEditor()); + } + + // ── Split button ── + function setMenuOpen(open) { + if (!prMenu || !caretBtn) return; + prMenu.hidden = !open; + caretBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + if (primaryBtn) { + primaryBtn.addEventListener('click', (e) => { + e.preventDefault(); + setMenuOpen(false); + primaryAction(); + }); + } + if (caretBtn) { + caretBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(prMenu ? prMenu.hidden : false); + }); + } + documentImpl.addEventListener('click', (e) => { + if (prMenu && !prMenu.hidden && prWrap && !prWrap.contains(e.target)) setMenuOpen(false); + }); + + Object.keys(items).forEach((id) => { + const el = items[id]; + if (!el) return; + el.addEventListener('click', (e) => { + e.preventDefault(); + setMenuOpen(false); + ACTIONS[id].run(); + }); + }); + + const initial = infoFromDataset(); + if (initial) applyInfo(initial); + refresh(); + return { refresh }; +} diff --git a/web/src/session/chat/git-footer.test.js b/web/src/session/chat/git-footer.test.js new file mode 100644 index 0000000..ef06315 --- /dev/null +++ b/web/src/session/chat/git-footer.test.js @@ -0,0 +1,157 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { setupGitFooter, DRAFT_PR_PROMPT, COMMIT_PUSH_PROMPT, MERGE_PR_PROMPT } from './git-footer.js'; + +function createDom(dataset = {}) { + const div = document.createElement('div'); + div.innerHTML = ` + + + `; + document.body.appendChild(div); + const bar = div.querySelector('#pi-git-bar'); + Object.entries(dataset).forEach(([k, v]) => { bar.dataset[k] = v; }); + return div; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); +const id = (x) => document.getElementById(x); + +let dom; +afterEach(() => { + if (dom) dom.remove(); + dom = undefined; +}); + +describe('setupGitFooter', () => { + it('stays hidden when the cwd is not a git repo', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: false }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-bar').hidden).toBe(true); + }); + + it('renders synchronously from data attributes before the async fetch resolves', () => { + dom = createDom({ gitRepo: 'true', gitBranch: 'feature/x', gitDefault: 'false', gitHasChanges: 'false' }); + // getGitInfo never resolves during this synchronous assertion. + const gitApi = { getGitInfo: vi.fn(() => new Promise(() => {})) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + expect(id('pi-git-bar').hidden).toBe(false); + expect(id('pi-git-branch-name').textContent).toBe('feature/x'); + expect(id('pi-git-primary-label').textContent).toBe('Create PR'); + }); + + it('feature branch, no PR -> primary Create PR (commit+push+create), only manual under the caret', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: true, prUrl: '' }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('Create PR'); + expect(id('pi-git-branch-edit').hidden).toBe(false); + expect(id('pi-git-caret').hidden).toBe(false); + expect(id('pi-git-pr-manual').hidden).toBe(false); + // No separate Commit & push — Create PR already commits and pushes. + expect(id('pi-git-pr-commit').hidden).toBe(true); + expect(id('pi-git-pr-view').hidden).toBe(true); + expect(id('pi-git-pr-merge').hidden).toBe(true); + id('pi-git-primary').click(); + expect(id('pi-chat-message').value).toBe(DRAFT_PR_PROMPT); + }); + + it('feature branch, open PR + local changes -> primary Commit & push, secondary view + merge', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: true, prUrl: 'https://github.com/o/r/pull/42' }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('Commit & push'); + expect(id('pi-git-pr-view').hidden).toBe(false); + expect(id('pi-git-pr-merge').hidden).toBe(false); + expect(id('pi-git-pr-draft').hidden).toBe(true); + expect(id('pi-git-pr-manual').hidden).toBe(true); + id('pi-git-primary').click(); + expect(id('pi-chat-message').value).toBe(COMMIT_PUSH_PROMPT); + }); + + it('feature branch, open PR + no changes -> primary View PR, secondary merge only (no commit)', async () => { + const open = vi.fn(); + dom = createDom(); + const win = { ...window, open, Event: window.Event }; + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: false, prUrl: 'https://github.com/o/r/pull/42' }) }; + setupGitFooter({ documentImpl: document, windowImpl: win, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('View PR ↗'); + expect(id('pi-git-pr-merge').hidden).toBe(false); + expect(id('pi-git-pr-commit').hidden).toBe(true); + id('pi-git-primary').click(); + expect(open).toHaveBeenCalledWith('https://github.com/o/r/pull/42', '_blank', 'noopener'); + }); + + it('default branch + changes -> primary Commit & push, no caret, no edit pencil', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'main', isDefault: true, hasChanges: true }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('Commit & push'); + expect(id('pi-git-caret').hidden).toBe(true); + expect(id('pi-git-branch-edit').hidden).toBe(true); + id('pi-git-primary').click(); + expect(id('pi-chat-message').value).toBe(COMMIT_PUSH_PROMPT); + }); + + it('default branch + no changes -> action control hidden, only the branch shows', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'main', isDefault: true, hasChanges: false }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-bar').hidden).toBe(false); + expect(id('pi-git-pr').hidden).toBe(true); + expect(id('pi-git-primary').hidden).toBe(true); + }); + + it('menu items run their actions (Merge PR injects merge prompt)', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: true, prUrl: 'https://github.com/o/r/pull/42' }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + id('pi-git-pr-merge').click(); + expect(id('pi-chat-message').value).toBe(MERGE_PR_PROMPT); + }); + + it('renames the branch and refreshes', async () => { + dom = createDom(); + const renameBranch = vi.fn().mockResolvedValue({ ok: true, branch: 'renamed' }); + const getGitInfo = vi + .fn() + .mockResolvedValueOnce({ isRepo: true, branch: 'old', isDefault: false, prUrl: '' }) + .mockResolvedValueOnce({ isRepo: true, branch: 'renamed', isDefault: false, prUrl: '' }); + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi: { getGitInfo, renameBranch } }); + await flush(); + + id('pi-git-branch-edit').click(); + const input = id('pi-git-branch-input'); + expect(input.hidden).toBe(false); + input.value = 'renamed'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await flush(); + + expect(renameBranch).toHaveBeenCalledWith('s', 'renamed'); + expect(id('pi-git-branch-name').textContent).toBe('renamed'); + }); +}); diff --git a/web/src/session/session.js b/web/src/session/session.js index 5309fc8..82e14f8 100644 --- a/web/src/session/session.js +++ b/web/src/session/session.js @@ -16,6 +16,8 @@ import { setupSessionUi } from './ui/session-ui-runner.js'; import * as chatComposerRunner from './chat/chat-composer-runner.js'; import * as doneNotifier from './chat/done-notifier.js'; import * as chatApi from './chat/chat-api.js'; +import * as gitApi from './chat/git-api.js'; +import { setupGitFooter } from './chat/git-footer.js'; import * as chatSelectors from './chat/chat-selectors.js'; import * as thinkingSelector from './chat/thinking-selector.js'; import * as modelSelector from './chat/model-selector.js'; @@ -442,6 +444,13 @@ export function runSessionApp({ target = window } = {}) { setIntervalImpl: target.setInterval.bind(target) }); + setupGitFooter({ + documentImpl, + windowImpl: target, + sessionId: getSessionSearchParams({ documentImpl, windowImpl: target }).get('id') || '', + gitApi + }); + // Handle Visual Viewport changes to prevent mobile browsers from shifting // the top fixed header out of view when the virtual keyboard is open. if (target.visualViewport) {