Files
superwork/tui/internal/tui/prcommits.go
T
2026-06-24 02:32:22 +08:00

320 lines
9.0 KiB
Go

package tui
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"superwork-tui/internal/auth"
"superwork-tui/internal/config"
"superwork-tui/internal/git"
"superwork-tui/internal/gitea"
"superwork-tui/internal/issue"
"superwork-tui/internal/store"
)
// prCommitsLoadedMsg carries the result of loading a PR's commits and files
// plus the persisted confirmation state.
type prCommitsLoadedMsg struct {
commits []gitea.PullRequestCommit
files []gitea.PullRequestFile
confirmedPaths []string
confirmedSHAs []string
baseRef string
err error
}
// prReviewWriteMsg is the no-op result of persisting confirmation state.
type prReviewWriteMsg struct{ err error }
// headSHA returns the SHA of the last commit, which the file-level
// confirmations are keyed against. Empty when there are no commits.
func headSHA(commits []gitea.PullRequestCommit) string {
if len(commits) == 0 {
return ""
}
return commits[len(commits)-1].SHA
}
// firstLine returns the first line of a (possibly multi-line) commit message.
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// allFilesConfirmed reports whether every file in the PR is confirmed. An empty
// file set is never considered fully confirmed.
func allFilesConfirmed(files []gitea.PullRequestFile, confirmed map[string]bool) bool {
if len(files) == 0 {
return false
}
for _, f := range files {
if !confirmed[f.Filename] {
return false
}
}
return true
}
// loadPRCommitsCmd fetches the PR commits, files, and persisted confirmations.
func loadPRCommitsCmd(iss issue.Issue) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := config.WorkspaceRoot()
if err != nil {
return prCommitsLoadedMsg{err: fmt.Errorf("workspace root: %w", err)}
}
remote, err := git.DetectRepo(root)
if err != nil {
return prCommitsLoadedMsg{err: fmt.Errorf("detect repo: %w", err)}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return prCommitsLoadedMsg{err: fmt.Errorf("resolve token: %w", err)}
}
prNum, err := strconv.Atoi(iss.PR)
if err != nil || prNum <= 0 {
return prCommitsLoadedMsg{err: fmt.Errorf("invalid PR number %q", iss.PR)}
}
client := gitea.New(remote.Host, token)
pr, err := client.GetPullRequest(ctx, remote.Owner, remote.Repo, prNum)
if err != nil {
return prCommitsLoadedMsg{err: fmt.Errorf("get pr: %w", err)}
}
commits, err := client.ListPullRequestCommits(ctx, remote.Owner, remote.Repo, prNum)
if err != nil {
return prCommitsLoadedMsg{err: fmt.Errorf("list pr commits: %w", err)}
}
files, err := client.ListPullRequestFiles(ctx, remote.Owner, remote.Repo, prNum)
if err != nil {
return prCommitsLoadedMsg{err: fmt.Errorf("list pr files: %w", err)}
}
confirmedPaths, _ := store.ReadConfirmed(root, iss.Number, headSHA(commits))
confirmedSHAs, _ := store.ReadConfirmedCommits(root, iss.Number)
return prCommitsLoadedMsg{
commits: commits,
files: files,
confirmedPaths: confirmedPaths,
confirmedSHAs: confirmedSHAs,
baseRef: pr.Base.Ref,
}
}
}
// writeConfirmedFilesCmd persists the confirmed file paths for the PR head SHA.
func writeConfirmedFilesCmd(issueNumber int, sha string, confirmed map[string]bool) tea.Cmd {
paths := sortedTrue(confirmed)
return func() tea.Msg {
root, err := config.WorkspaceRoot()
if err != nil {
return prReviewWriteMsg{err: err}
}
return prReviewWriteMsg{err: store.WriteConfirmed(root, issueNumber, sha, paths)}
}
}
// writeConfirmedCommitsCmd persists the confirmed commit SHAs for the issue.
func writeConfirmedCommitsCmd(issueNumber int, confirmed map[string]bool) tea.Cmd {
shas := sortedTrue(confirmed)
return func() tea.Msg {
root, err := config.WorkspaceRoot()
if err != nil {
return prReviewWriteMsg{err: err}
}
return prReviewWriteMsg{err: store.WriteConfirmedCommits(root, issueNumber, shas)}
}
}
// sortedTrue collects the keys whose value is true.
func sortedTrue(m map[string]bool) []string {
var out []string
for k, v := range m {
if v {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
// diffBaseRef returns the ref to diff against, "origin/<devBranch>".
func diffBaseRef(prBaseRef string) string {
if prBaseRef != "" {
return "origin/" + prBaseRef
}
branch := "main"
if settings, err := config.Load(); err == nil && settings.DevBranch != "" {
branch = settings.DevBranch
}
return "origin/" + branch
}
// openDiffCmd suspends the TUI to show a file's diff via git's own pager. When
// the PR worktree is missing it returns nil and reports through statusMsg
// (handled by the caller) instead of suspending.
func openDiffCmd(worktreeAbs, base, headSHA, file string) tea.Cmd {
c := exec.Command("git", "-C", worktreeAbs, "diff", base+"..."+headSHA, "--", file)
return tea.ExecProcess(c, func(err error) tea.Msg {
return prReviewWriteMsg{err: err}
})
}
// prRowCount is the total number of selectable rows: every commit followed by
// every file. The cursor ranges over [0, prRowCount).
func (m Model) prRowCount() int {
return len(m.prCommits) + len(m.prFiles)
}
// cursorOnFile reports whether the cursor is on a file row and, if so, its index
// into prFiles.
func (m Model) cursorOnFile() (int, bool) {
if m.prCursor >= len(m.prCommits) && m.prCursor < m.prRowCount() {
return m.prCursor - len(m.prCommits), true
}
return 0, false
}
// handlePRCommitsKey processes key input while in the PR commits panel. The
// cursor spans the commits list then the files list; space toggles the
// confirmation of whichever row it rests on, enter opens a file's diff.
func (m Model) handlePRCommitsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
if m.prCommitsReturn != stateLoading {
m.state = m.prCommitsReturn
m.prCommitsReturn = stateLoading
return m, nil
}
m.state = stateDetail
return m, nil
case "up", "k":
if m.prCursor > 0 {
m.prCursor--
}
return m, nil
case "down", "j":
if m.prCursor < m.prRowCount()-1 {
m.prCursor++
}
return m, nil
case "space":
if fileIdx, ok := m.cursorOnFile(); ok {
path := m.prFiles[fileIdx].Filename
if m.prConfirmed == nil {
m.prConfirmed = map[string]bool{}
}
m.prConfirmed[path] = !m.prConfirmed[path]
return m, writeConfirmedFilesCmd(m.prCommitsIss.Number, headSHA(m.prCommits), m.prConfirmed)
}
if m.prCursor >= 0 && m.prCursor < len(m.prCommits) {
sha := m.prCommits[m.prCursor].SHA
if m.prConfirmedSHAs == nil {
m.prConfirmedSHAs = map[string]bool{}
}
m.prConfirmedSHAs[sha] = !m.prConfirmedSHAs[sha]
return m, writeConfirmedCommitsCmd(m.prCommitsIss.Number, m.prConfirmedSHAs)
}
return m, nil
case "enter":
return m.openSelectedDiff()
}
return m, nil
}
// openSelectedDiff opens the diff for the file under the cursor, or reports a
// missing worktree through statusMsg without suspending the TUI. A no-op when
// the cursor is on a commit row.
func (m Model) openSelectedDiff() (tea.Model, tea.Cmd) {
fileIdx, ok := m.cursorOnFile()
if !ok {
return m, nil
}
root, err := config.WorkspaceRoot()
if err != nil {
m.statusMsg = fmt.Sprintf("workspace root: %v", err)
return m, nil
}
worktreeAbs := filepath.Join(root, m.prCommitsIss.WorktreePath)
if _, statErr := os.Stat(worktreeAbs); statErr != nil {
m.statusMsg = fmt.Sprintf("worktree 不存在: %s", worktreeAbs)
return m, nil
}
file := m.prFiles[fileIdx].Filename
return m, openDiffCmd(worktreeAbs, diffBaseRef(m.prBaseRef), headSHA(m.prCommits), file)
}
// prCommitsView renders the PR commits review panel.
func (m Model) prCommitsView() string {
prNum := ""
if m.prCommitsIss != nil {
prNum = m.prCommitsIss.PR
}
var b strings.Builder
fmt.Fprintf(&b, "PR #%s commits review\n\n", prNum)
if m.prCommitsErr != nil {
b.WriteString(errorStyle.Render("error: " + m.prCommitsErr.Error()))
b.WriteString("\n\n" + helpStyle.Render("esc 返回"))
return b.String()
}
if !m.prCommitsLoaded {
return b.String() + m.spinner.View() + " 加载中…\n\n" + helpStyle.Render("esc 返回")
}
b.WriteString("Commits:\n")
for i, c := range m.prCommits {
mark := " "
if m.prConfirmedSHAs[c.SHA] {
mark = "x"
}
cursor := " "
if i == m.prCursor {
cursor = ">"
}
fmt.Fprintf(&b, "%s [%s] %s %s\n", cursor, mark, shortSHA(c.SHA), firstLine(c.Commit.Message))
}
b.WriteString("\nFiles:\n")
for i, f := range m.prFiles {
mark := " "
if m.prConfirmed[f.Filename] {
mark = "x"
}
cursor := " "
if len(m.prCommits)+i == m.prCursor {
cursor = ">"
}
fmt.Fprintf(&b, "%s [%s] %s (%s)\n", cursor, mark, f.Filename, f.Status)
}
if allFilesConfirmed(m.prFiles, m.prConfirmed) {
b.WriteString("\n" + confirmStyle.Render("所有文件已确认"))
}
b.WriteString("\n\n" + helpStyle.Render("↑↓/jk 选择 space 确认 enter 看 diff esc 返回"))
return b.String()
}
// shortSHA truncates a SHA to its conventional 7-character prefix.
func shortSHA(sha string) string {
if len(sha) > 7 {
return sha[:7]
}
return sha
}