11
This commit is contained in:
commit
e6f1776d4f
264 files changed
+54215
No files matched your search
@@ -0,0 +1,314 @@
|
||||
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":
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user