320 lines
9.0 KiB
Go
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
|
|
}
|