Files
superwork/tui/internal/tui/open_actions.go
T
2026-06-23 05:02:15 +08:00

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
}