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/". 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 }