11
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user