215 lines
7.4 KiB
Go
215 lines
7.4 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"superwork-tui/internal/auth"
|
|
"superwork-tui/internal/git"
|
|
"superwork-tui/internal/gitea"
|
|
"superwork-tui/internal/issue"
|
|
)
|
|
|
|
// ── injectable seams ─────────────────────────────────────────────────────────
|
|
|
|
// editorExecFn wraps tea.ExecProcess so tests can capture the command without
|
|
// suspending a real terminal.
|
|
var editorExecFn = func(c *exec.Cmd, fn func(error) tea.Msg) tea.Cmd {
|
|
return tea.ExecProcess(c, fn)
|
|
}
|
|
|
|
// xdgOpenFn starts xdg-open detached (fire-and-forget). Indirected so tests
|
|
// can record the URL without launching a browser.
|
|
var xdgOpenFn = func(url string) error {
|
|
return exec.Command("xdg-open", url).Start()
|
|
}
|
|
|
|
// resolvePRURLFn resolves the browser URL for a PR. Indirected so tests can
|
|
// skip the Gitea network call.
|
|
var resolvePRURLFn = defaultResolvePRURL
|
|
|
|
// worktreeWindowFn opens a new tmux window cd'd to path. Indirected so tests
|
|
// can assert the window name and path without a live tmux session.
|
|
var worktreeWindowFn = func(sm *SessionManager, winName, path string) error {
|
|
return sm.RunInWindow(context.Background(), winName, path, "")
|
|
}
|
|
|
|
// ── openFileMsg ──────────────────────────────────────────────────────────────
|
|
|
|
type openFileMsg struct{ err error }
|
|
|
|
// ── openEditorCmd ────────────────────────────────────────────────────────────
|
|
|
|
// openEditorCmd suspends the TUI and opens absPath in $EDITOR (fallback: vi).
|
|
func openEditorCmd(absPath string) tea.Cmd {
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = "vi"
|
|
}
|
|
c := exec.Command(editor, absPath)
|
|
return editorExecFn(c, func(err error) tea.Msg {
|
|
return openFileMsg{err: err}
|
|
})
|
|
}
|
|
|
|
// defaultResolvePRURL fetches the PR from Gitea and returns its html_url,
|
|
// falling back to a constructed URL if the field is empty.
|
|
func defaultResolvePRURL(iss issue.Issue) (string, error) {
|
|
prNum, err := prNumberFor(iss)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
root, err := workspaceRootFn()
|
|
if err != nil {
|
|
return "", fmt.Errorf("workspace root: %w", err)
|
|
}
|
|
remote, err := git.DetectRepo(root)
|
|
if err != nil {
|
|
return "", fmt.Errorf("detect repo: %w", err)
|
|
}
|
|
token, err := auth.ResolveGiteaToken(remote.Host)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve token: %w", err)
|
|
}
|
|
client := gitea.New(remote.Host, token)
|
|
pr, err := client.GetPullRequest(context.Background(), remote.Owner, remote.Repo, prNum)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get PR: %w", err)
|
|
}
|
|
if pr.HtmlURL != "" {
|
|
return pr.HtmlURL, nil
|
|
}
|
|
return fmt.Sprintf("https://%s/%s/%s/pulls/%d", remote.Host, remote.Owner, remote.Repo, prNum), nil
|
|
}
|
|
|
|
// ── openPRMsg ────────────────────────────────────────────────────────────────
|
|
|
|
type openPRMsg struct{ err error }
|
|
|
|
// ── openPRCmd ────────────────────────────────────────────────────────────────
|
|
|
|
// openPRCmd resolves the PR's html_url then fires xdg-open detached.
|
|
// The TUI is not suspended. resolvePRURLFn is injectable for tests.
|
|
func openPRCmd(iss issue.Issue) tea.Cmd {
|
|
return func() tea.Msg {
|
|
url, err := resolvePRURLFn(iss)
|
|
if err != nil {
|
|
return openPRMsg{err: err}
|
|
}
|
|
return openPRMsg{err: xdgOpenFn(url)}
|
|
}
|
|
}
|
|
|
|
// prNumberFor parses the PR number from iss.PR. Returns an error if absent or
|
|
// invalid.
|
|
func prNumberFor(iss issue.Issue) (int, error) {
|
|
if iss.PR == "" {
|
|
return 0, fmt.Errorf("issue #%d 无关联 PR", iss.Number)
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(iss.PR, "%d", &n); err != nil || n <= 0 {
|
|
return 0, fmt.Errorf("issue #%d PR 号无效: %q", iss.Number, iss.PR)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ── openWorktreeMsg ──────────────────────────────────────────────────────────
|
|
|
|
type openWorktreeMsg struct{ err error }
|
|
|
|
// ── openWorktreeCmd ──────────────────────────────────────────────────────────
|
|
|
|
// openWorktreeCmd opens a new tmux window cd'd to the issue's worktree
|
|
// directory. This is the TUI equivalent of VS Code's vscode.openFolder (new
|
|
// window) from worktree.ts handleOpenWorktree.
|
|
func openWorktreeCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if iss.WorktreePath == "" {
|
|
return openWorktreeMsg{err: fmt.Errorf("issue #%d 无 worktree 路径", iss.Number)}
|
|
}
|
|
root, err := workspaceRootFn()
|
|
if err != nil {
|
|
return openWorktreeMsg{err: fmt.Errorf("workspace root: %w", err)}
|
|
}
|
|
absPath := iss.WorktreePath
|
|
if !filepath.IsAbs(absPath) {
|
|
absPath = filepath.Join(root, iss.WorktreePath)
|
|
}
|
|
if _, statErr := os.Stat(absPath); statErr != nil {
|
|
return openWorktreeMsg{err: fmt.Errorf("worktree 不存在: %s", absPath)}
|
|
}
|
|
winName := fmt.Sprintf("%d-wt", iss.Number)
|
|
return openWorktreeMsg{err: worktreeWindowFn(sm, winName, absPath)}
|
|
}
|
|
}
|
|
|
|
// ── editorFilePicker ─────────────────────────────────────────────────────────
|
|
|
|
// handleFilePickerKey processes keystrokes while stateFilePicker is active.
|
|
func (m Model) handleFilePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.state = m.filePickerPrevState
|
|
case "up", "k":
|
|
if m.filePickerCursor > 0 {
|
|
m.filePickerCursor--
|
|
}
|
|
case "down", "j":
|
|
if m.filePickerCursor < len(m.filePickerFiles)-1 {
|
|
m.filePickerCursor++
|
|
}
|
|
case "enter":
|
|
if len(m.filePickerFiles) == 0 {
|
|
m.state = m.filePickerPrevState
|
|
return m, nil
|
|
}
|
|
chosen := m.filePickerFiles[m.filePickerCursor]
|
|
m.state = m.filePickerPrevState
|
|
return m, openEditorCmd(chosen)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// filePickerView renders the transient file-picker overlay.
|
|
func (m Model) filePickerView() string {
|
|
var sb strings.Builder
|
|
sb.WriteString(helpStyle.Render("打开文件") + "\n")
|
|
sb.WriteString(strings.Repeat("─", 50) + "\n")
|
|
for i, p := range m.filePickerFiles {
|
|
line := fmt.Sprintf(" %s", filepath.Base(p))
|
|
if i == m.filePickerCursor {
|
|
sb.WriteString(selectedCardStyle.Render(line) + "\n")
|
|
} else {
|
|
sb.WriteString(normalCardStyle.Render(line) + "\n")
|
|
}
|
|
}
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpStyle.Render("↑↓/jk select enter open esc cancel"))
|
|
return sb.String()
|
|
}
|
|
|
|
// candidateFiles returns the workspace-relative file paths set on iss that
|
|
// exist on disk, preserving spec → plan → prDiff order.
|
|
func candidateFiles(iss issue.Issue, root string) []string {
|
|
var out []string
|
|
for _, rel := range []string{iss.SpecFile, iss.PlanFile, iss.PrDiffFile} {
|
|
if rel == "" {
|
|
continue
|
|
}
|
|
abs := rel
|
|
if !filepath.IsAbs(rel) {
|
|
abs = filepath.Join(root, rel)
|
|
}
|
|
if _, err := os.Stat(abs); err == nil {
|
|
out = append(out, abs)
|
|
}
|
|
}
|
|
return out
|
|
}
|