Files
superwork/tui/internal/tui/model.go
T
2026-06-24 02:32:22 +08:00

2290 lines
60 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"image/color"
"os"
"path/filepath"
"strconv"
"strings"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"superwork-tui/internal/auth"
"superwork-tui/internal/cc"
"superwork-tui/internal/config"
"superwork-tui/internal/git"
"superwork-tui/internal/gitea"
"superwork-tui/internal/issue"
"superwork-tui/internal/logging"
"superwork-tui/internal/store"
"superwork-tui/internal/webhook"
)
// ── internal message types ──────────────────────────────────────────────────
type issuesLoadedMsg struct{ issues []issue.Issue }
type errMsg struct{ err error }
type createResultMsg struct {
err error
}
// imagePastedMsg carries the result of a clipboard image paste in the create modal.
type imagePastedMsg struct {
path string
index int
err error
}
func (e errMsg) Error() string { return e.err.Error() }
type moveResultMsg struct {
issueNumber int
newCol issue.Column
origCol issue.Column
err error
}
type closeResultMsg struct {
issueNumber int
err error
}
type deleteResultMsg struct {
issueNumber int
err error
}
type setDepResultMsg struct {
issueNumber int
prerequisite int
oldPrereq int
err error
}
type clearDepResultMsg struct {
issueNumber int
oldPrereq int
err error
}
// ── confirm types ────────────────────────────────────────────────────────────
type confirmAction int
const (
confirmClose confirmAction = iota
confirmDelete
confirmBranchSync
confirmCommitRun
)
type confirmPending struct {
action confirmAction
issue issue.Issue
}
// ── bottom panel tabs ────────────────────────────────────────────────────────
type bottomTab int
const (
bottomTabIssue bottomTab = iota
bottomTabProfile
bottomTabSessions
bottomTabCommits
)
type focusPane int
const (
focusBoard focusPane = iota
focusPanel
)
type panelFocus int
const (
panelFocusTabs panelFocus = iota
panelFocusActions
)
// ── state enum ──────────────────────────────────────────────────────────────
type boardState int
const (
stateLoading boardState = iota
stateLoaded
stateError
stateInput // textinput active for dependency entry
stateCreate // textinput active for new issue request
stateDetail // detail panel for a single issue
statePRCommits // PR commits review panel
stateSettings // settings panel
stateProfilePicker // f/F in detail: pick a Claude profile
stateProfileGrid // G: profile grid editor
stateManagedSessions // m: managed sessions panel
stateLogs // ctrl+l: log viewer modal
stateFilePicker // o in detail: pick spec/plan/prDiff when >1 exists
)
// ── styles ──────────────────────────────────────────────────────────────────
var (
colHeaderStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1)
colBorderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
selectedCardStyle = lipgloss.NewStyle().
Bold(true).
Reverse(true).
Padding(0, 1)
normalCardStyle = lipgloss.NewStyle().Padding(0, 1)
badgeStyle = lipgloss.NewStyle().Faint(true)
helpStyle = lipgloss.NewStyle().Faint(true)
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
confirmStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3"))
blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Faint(true)
)
// ── Model ────────────────────────────────────────────────────────────────────
// Model is the root Bubble Tea model for the kanban TUI.
type Model struct {
state boardState
spinner spinner.Model
issues []issue.Issue
buckets [4][]issue.Issue
// cursor: colIdx ∈ [0,3], rowIdx ∈ [0, len(col)-1]
colIdx int
rowIdx int
width int
height int
loadErr error
statusMsg string
confirmPend *confirmPending
envLocked bool
branchSyncPending *git.BranchSyncStatus
// input state for setting a dependency prerequisite
inputPend int // issue number being edited; 0 = not in input mode
depInput textinput.Model
// input state for creating a new issue (multi-line request + profile pick)
createArea textarea.Model
createProfileIdx int // index into profilePickerProfiles; -1 = 默认
createImages []string // pasted image paths in the inbox dir
createInboxDir string // /tmp/spx-inbox/<nonce> for this create session
// detail panel state
detailIss *issue.Issue
sessionMgr *SessionManager
bottomTab bottomTab
focusPane focusPane
panelFocus panelFocus
actionIdx int
hoverActionIdx int
hoverActionOK bool
// PR commits review panel state
prCommitsIss *issue.Issue
prBaseRef string
prCommits []gitea.PullRequestCommit
prFiles []gitea.PullRequestFile
prCommitsLoaded bool
prCommitsErr error
prCursor int // cursor over commit rows then file rows
prConfirmed map[string]bool // file path → confirmed
prConfirmedSHAs map[string]bool // commit sha → confirmed
prCommitsReturn boardState
// webhook server lifecycle
webhookCtx context.Context
webhookCh chan webhook.Event
webhookCancel context.CancelFunc
// settings panel
settings *config.Settings
settingsState *settingsPanel
// profile picker (f / F in detail)
profilePickerProfiles []cc.ClaudeProfile
profilePickerCursor int
profilePickerForTest bool
profilePickerPrevState boardState
// profile grid (G)
profileGridData store.ProfilesData
profileGridRow int
profileGridCol int
profileGridEdit textinput.Model
profileGridEditing bool
profileGridNewKey textinput.Model
profileGridAddingKey bool
// managed sessions (m)
managedSessions store.ManagedSessionsData
managedSessionsCursor int
managedSessionsInput textinput.Model
managedSessionsInputActive bool
managedSessionsInputMode managedSessionsMode
managedSessionsDeleteID string
// working tree status
hasChanges bool
// commitRunning guards against concurrent commit/run invocations.
commitRunning bool
// logs modal (ctrl+l)
logEntries []logging.Entry
// file picker (o in detail, >1 candidate)
filePickerFiles []string
filePickerCursor int
filePickerPrevState boardState
}
// New creates an initial Model; loading starts immediately via Init.
func New() Model {
sp := spinner.New()
sp.Spinner = spinner.Dot
ch := make(chan webhook.Event, 16)
ctx, cancel := context.WithCancel(context.Background())
settings, err := config.Load()
if err != nil || settings == nil {
settings = config.DefaultSettings()
}
return Model{
state: stateLoading,
spinner: sp,
sessionMgr: NewSessionManager(defaultTmuxRunner),
webhookCtx: ctx,
webhookCh: ch,
webhookCancel: cancel,
settings: settings,
}
}
// ── Init ────────────────────────────────────────────────────────────────────
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
loadCmd(),
tabAliveCmd(resolveSessionName()),
startWebhookServer(m.webhookCh, m.webhookCtx),
waitForWebhookCmd(m.webhookCtx, m.webhookCh),
hasCheckCmd(),
)
}
// loadCmd builds and executes the full data-load pipeline inside a tea.Cmd.
func loadCmd() tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := config.WorkspaceRoot()
if err != nil {
return errMsg{fmt.Errorf("workspace root: %w", err)}
}
remote, err := git.DetectRepo(root)
if err != nil {
return errMsg{fmt.Errorf("detect repo: %w", err)}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return errMsg{fmt.Errorf("resolve token: %w", err)}
}
client := gitea.New(remote.Host, token)
issues, err := issue.LoadIssues(ctx, client, remote.Owner, remote.Repo, root)
if err != nil {
return errMsg{fmt.Errorf("load issues: %w", err)}
}
return issuesLoadedMsg{issues: issues}
}
}
func moveCmd(issueNumber int, newCol, origCol issue.Column) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := config.WorkspaceRoot()
if err != nil {
return moveResultMsg{issueNumber: issueNumber, newCol: newCol, origCol: origCol, err: err}
}
remote, err := git.DetectRepo(root)
if err != nil {
return moveResultMsg{issueNumber: issueNumber, newCol: newCol, origCol: origCol, err: err}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return moveResultMsg{issueNumber: issueNumber, newCol: newCol, origCol: origCol, err: err}
}
client := gitea.New(remote.Host, token)
err = issue.MergeStateJSON(ctx, client, remote.Owner, remote.Repo, issueNumber, map[string]any{"column": string(newCol)})
return moveResultMsg{issueNumber: issueNumber, newCol: newCol, origCol: origCol, err: err}
}
}
func closeCmd(issueNumber int) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := config.WorkspaceRoot()
if err != nil {
return closeResultMsg{issueNumber: issueNumber, err: err}
}
remote, err := git.DetectRepo(root)
if err != nil {
return closeResultMsg{issueNumber: issueNumber, err: err}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return closeResultMsg{issueNumber: issueNumber, err: err}
}
client := gitea.New(remote.Host, token)
err = client.CloseIssue(ctx, remote.Owner, remote.Repo, issueNumber)
return closeResultMsg{issueNumber: issueNumber, err: err}
}
}
func deleteCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := workspaceRootFn()
if err != nil {
return deleteResultMsg{issueNumber: iss.Number, err: err}
}
// 1. Close all session windows; fire impl-tab-post-close hook after impl tab.
session := resolveSessionName()
settings, sErrEarly := config.Load()
for _, phase := range []Phase{PhaseBrain, PhaseImpl, PhaseReview, PhaseTest} {
win := windowName(iss.Number, phase)
exists, _ := sm.windowExists(session, win)
if exists {
_ = sm.Close(ctx, phase, iss, session)
if phase == PhaseImpl && iss.WorktreePath != "" {
worktreePath := filepath.Join(root, iss.WorktreePath)
mainBranch := "main"
if sErrEarly == nil && settings.DevBranch != "" {
mainBranch = settings.DevBranch
}
postHctx := git.HookContext{
WorkspaceRoot: root,
WorktreePath: worktreePath,
Branch: iss.Branch,
IssueNumber: iss.Number,
MainBranch: mainBranch,
}
if sErrEarly == nil {
postHctx.CustomScriptPath = settings.ImplTabPostCloseScript
}
runImplTabPostCloseHookFn(ctx, postHctx)
}
}
}
// 2+3. Pre-remove hook and worktree removal (only if worktree exists on disk).
if iss.WorktreePath != "" {
worktreePath := filepath.Join(root, iss.WorktreePath)
if _, statErr := os.Stat(worktreePath); statErr == nil {
settings, sErr := config.Load()
mainBranch := "main"
if sErr == nil && settings.DevBranch != "" {
mainBranch = settings.DevBranch
}
hctx := git.HookContext{
WorkspaceRoot: root,
WorktreePath: worktreePath,
Branch: iss.Branch,
IssueNumber: iss.Number,
MainBranch: mainBranch,
}
_ = git.RunPreRemoveHook(ctx, hctx)
if rmErr := git.RemoveWorktree(ctx, git.RemoveWorktreeOpts{
WorkspaceRoot: root,
WorktreePath: worktreePath,
Force: true,
}); rmErr != nil {
return deleteResultMsg{issueNumber: iss.Number, err: fmt.Errorf("remove worktree: %w", rmErr)}
}
}
}
// 4+5+6. Close PR → delete branch → delete issue on Gitea.
if err := deleteGiteaResourcesFn(ctx, root, iss); err != nil {
return deleteResultMsg{issueNumber: iss.Number, err: err}
}
return deleteResultMsg{issueNumber: iss.Number}
}
}
// runImplTabPostCloseHookFn is indirected for tests.
var runImplTabPostCloseHookFn = func(ctx context.Context, hctx git.HookContext) git.HookResult {
return git.RunImplTabPostCloseHook(ctx, hctx)
}
// deleteGiteaResourcesFn closes the PR, deletes the remote branch, and hard-deletes
// the issue, in that order, stopping at the first failure. Indirected through a
// package var so tests can assert the cleanup order without a live Gitea host.
var deleteGiteaResourcesFn = deleteGiteaResources
func deleteGiteaResources(ctx context.Context, root string, iss issue.Issue) error {
remote, err := git.DetectRepo(root)
if err != nil {
return err
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return err
}
client := gitea.New(remote.Host, token)
if iss.PR != "" {
prNum, _ := strconv.Atoi(iss.PR)
if prNum > 0 {
if closeErr := client.ClosePullRequest(ctx, remote.Owner, remote.Repo, prNum); closeErr != nil {
return fmt.Errorf("close PR: %w", closeErr)
}
}
}
if iss.Branch != "" {
if delBrErr := client.DeleteBranch(ctx, remote.Owner, remote.Repo, iss.Branch); delBrErr != nil {
return fmt.Errorf("delete branch: %w", delBrErr)
}
}
if err := client.DeleteIssue(ctx, remote.Owner, remote.Repo, iss.Number); err != nil {
return fmt.Errorf("delete issue: %w", err)
}
return nil
}
func setDepCmd(issueNumber, prereqNumber, oldPrereq int) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := config.WorkspaceRoot()
if err != nil {
return setDepResultMsg{issueNumber: issueNumber, prerequisite: prereqNumber, oldPrereq: oldPrereq, err: err}
}
remote, err := git.DetectRepo(root)
if err != nil {
return setDepResultMsg{issueNumber: issueNumber, prerequisite: prereqNumber, oldPrereq: oldPrereq, err: err}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return setDepResultMsg{issueNumber: issueNumber, prerequisite: prereqNumber, oldPrereq: oldPrereq, err: err}
}
client := gitea.New(remote.Host, token)
err = client.AddDependency(ctx, remote.Owner, remote.Repo, issueNumber, prereqNumber)
return setDepResultMsg{issueNumber: issueNumber, prerequisite: prereqNumber, oldPrereq: oldPrereq, err: err}
}
}
func clearDepCmd(issueNumber, oldPrereq int) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := config.WorkspaceRoot()
if err != nil {
return clearDepResultMsg{issueNumber: issueNumber, oldPrereq: oldPrereq, err: err}
}
remote, err := git.DetectRepo(root)
if err != nil {
return clearDepResultMsg{issueNumber: issueNumber, oldPrereq: oldPrereq, err: err}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return clearDepResultMsg{issueNumber: issueNumber, oldPrereq: oldPrereq, err: err}
}
client := gitea.New(remote.Host, token)
err = client.RemoveDependency(ctx, remote.Owner, remote.Repo, issueNumber, oldPrereq)
return clearDepResultMsg{issueNumber: issueNumber, oldPrereq: oldPrereq, err: err}
}
}
func createIssueCmd(userRequest, profilePath string, images []string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := config.WorkspaceRoot()
if err != nil {
return createResultMsg{err: fmt.Errorf("workspace root: %w", err)}
}
settings, err := config.Load()
if err != nil {
settings = config.DefaultSettings()
}
nonce, err := generateNonce()
if err != nil {
return createResultMsg{err: fmt.Errorf("generate nonce: %w", err)}
}
prompt := cc.BrainstormPrompt(settings, struct{ UserRequest, Nonce string }{
UserRequest: userRequest,
Nonce: nonce,
})
if len(images) > 0 {
var b strings.Builder
b.WriteString(prompt)
b.WriteString("\n\n参考图片(请用 Read 工具查看):\n")
for _, p := range images {
b.WriteString("- " + p + "\n")
}
prompt = b.String()
}
_, err = cc.SpawnClaude(ctx, cc.Opts{
Prompt: prompt,
Cwd: root,
ProfilePath: profilePath,
})
if err != nil {
return createResultMsg{err: err}
}
return createResultMsg{}
}
}
func generateNonce() (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// removeIssue returns a new slice with the issue matching number removed.
func removeIssue(issues []issue.Issue, number int) []issue.Issue {
out := make([]issue.Issue, 0, len(issues))
for _, iss := range issues {
if iss.Number != number {
out = append(out, iss)
}
}
return out
}
// ── Update ──────────────────────────────────────────────────────────────────
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.MouseClickMsg:
return m.handleMouseClick(msg)
case tea.MouseMotionMsg:
return m.handleMouseMotion(msg)
case spinner.TickMsg:
if m.state == stateLoading || (m.state == statePRCommits && !m.prCommitsLoaded) {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case issuesLoadedMsg:
m.state = stateLoaded
m.issues = msg.issues
m.buckets = bucketize(msg.issues)
m.colIdx = 0
m.rowIdx = 0
m.clampCursor()
case errMsg:
m.state = stateError
m.loadErr = msg.err
case tabAliveMsg:
m.issues = reconcileTabs(m.issues, msg.windows)
m.buckets = bucketize(m.issues)
return m, tabAliveCmd(resolveSessionName())
case sessionActionResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("会话操作失败: %v", msg.err)
}
case moveResultMsg:
if msg.err != nil {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].Column = msg.origCol
break
}
}
m.buckets = bucketize(m.issues)
m.clampCursor()
m.statusMsg = fmt.Sprintf("移列失败 #%d: %v", msg.issueNumber, msg.err)
} else {
m.statusMsg = fmt.Sprintf("#%d 已移到 %s", msg.issueNumber, msg.newCol)
}
case closeResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("关闭失败 #%d: %v", msg.issueNumber, msg.err)
} else {
m.issues = removeIssue(m.issues, msg.issueNumber)
m.buckets = bucketize(m.issues)
m.clampCursor()
m.statusMsg = fmt.Sprintf("已关闭 #%d", msg.issueNumber)
}
case deleteResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("删除失败 #%d: %v", msg.issueNumber, msg.err)
} else {
m.issues = removeIssue(m.issues, msg.issueNumber)
m.buckets = bucketize(m.issues)
m.clampCursor()
m.statusMsg = fmt.Sprintf("已删除 #%d", msg.issueNumber)
}
case setDepResultMsg:
if msg.err != nil {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].Prerequisite = msg.oldPrereq
break
}
}
m.buckets = bucketize(m.issues)
m.statusMsg = fmt.Sprintf("设置依赖失败 #%d: %v", msg.issueNumber, msg.err)
} else {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].Prerequisite = msg.prerequisite
break
}
}
m.buckets = bucketize(m.issues)
m.statusMsg = fmt.Sprintf("#%d 前置已设为 #%d", msg.issueNumber, msg.prerequisite)
}
case clearDepResultMsg:
if msg.err != nil {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].Prerequisite = msg.oldPrereq
break
}
}
m.buckets = bucketize(m.issues)
m.statusMsg = fmt.Sprintf("清除依赖失败 #%d: %v", msg.issueNumber, msg.err)
} else {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].Prerequisite = 0
break
}
}
m.buckets = bucketize(m.issues)
m.statusMsg = fmt.Sprintf("#%d 前置已清除", msg.issueNumber)
}
case branchSyncStatusMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("分支同步检查失败: %v", msg.err)
break
}
if msg.status.Unavailable {
m.statusMsg = fmt.Sprintf("分支同步不可用: %s", msg.status.Reason)
break
}
m.branchSyncPending = &msg.status
m.confirmPend = &confirmPending{action: confirmBranchSync}
m.statusMsg = fmt.Sprintf("分支落后 %d 个提交,确认同步? (y/n)", msg.status.Behind)
case branchSyncDoneMsg:
m.branchSyncPending = nil
if msg.err != nil {
m.statusMsg = fmt.Sprintf("分支同步失败: %v", msg.err)
} else {
m.statusMsg = fmt.Sprintf("已将 %s 同步至 %s", msg.devBranch, msg.autoBuildBranch)
}
case envLockDoneMsg:
m.envLocked = msg.locked
if msg.err != nil {
m.statusMsg = fmt.Sprintf("env 文件操作失败: %v", msg.err)
} else {
action := "已解锁"
if msg.locked {
action = "已锁定"
}
m.statusMsg = fmt.Sprintf("env %s (%d 个文件)", action, msg.result.Total)
}
case prDiffResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("PR diff 摘要失败: %v", msg.err)
} else {
if m.detailIss != nil && m.detailIss.Number == msg.issueNumber {
m.detailIss.PrDiffFile = msg.prDiffFile
}
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].PrDiffFile = msg.prDiffFile
break
}
}
m.statusMsg = fmt.Sprintf("PR diff 摘要已写入 %s", msg.prDiffFile)
}
case implementResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("实施失败 #%d: %v", msg.issueNumber, msg.err)
} else {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].Column = issue.ColumnInProgress
m.issues[i].Branch = msg.branch
m.issues[i].WorktreePath = msg.relWorktreePath
m.issues[i].ImplementStatus = "running"
m.issues[i].WorktreeExists = true
break
}
}
if m.detailIss != nil && m.detailIss.Number == msg.issueNumber {
m.detailIss.Column = issue.ColumnInProgress
m.detailIss.Branch = msg.branch
m.detailIss.WorktreePath = msg.relWorktreePath
m.detailIss.ImplementStatus = "running"
m.detailIss.WorktreeExists = true
}
m.buckets = bucketize(m.issues)
warnSuffix := ""
if len(msg.hookWarnings) > 0 {
warnSuffix = " [警告: " + strings.Join(msg.hookWarnings, "; ") + "]"
}
m.statusMsg = fmt.Sprintf("已开始实施 #%d%s", msg.issueNumber, warnSuffix)
return m, watchSessionCmd(msg.issueNumber, msg.worktreePath)
}
case implementSessionCapturedMsg:
if msg.sessionID != "" {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].ImplementSessionID = msg.sessionID
break
}
}
if m.detailIss != nil && m.detailIss.Number == msg.issueNumber {
m.detailIss.ImplementSessionID = msg.sessionID
}
m.statusMsg = fmt.Sprintf("#%d 实施会话已捕获: %s", msg.issueNumber, msg.sessionID)
return m, writeImplementSessionCmd(msg.issueNumber, msg.sessionID)
} else {
m.statusMsg = fmt.Sprintf("#%d 实施会话监听超时", msg.issueNumber)
}
case reviewResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("审查会话失败: %v", msg.err)
} else {
if m.detailIss != nil {
m.detailIss.ReviewTabOpen = true
}
}
case testResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("测试会话失败: %v", msg.err)
} else {
if m.detailIss != nil {
m.detailIss.TestTabOpen = true
issNum := m.detailIss.Number
cwd, _ := workspaceRootFn()
return m, watchTestSessionCmd(issNum, cwd)
}
}
case testSessionCapturedMsg:
if msg.sessionID != "" {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].TestSessionID = msg.sessionID
break
}
}
if m.detailIss != nil && m.detailIss.Number == msg.issueNumber {
m.detailIss.TestSessionID = msg.sessionID
}
m.statusMsg = fmt.Sprintf("#%d 测试会话已捕获: %s", msg.issueNumber, msg.sessionID)
return m, writeTestSessionCmd(msg.issueNumber, msg.sessionID)
} else {
m.statusMsg = fmt.Sprintf("#%d 测试会话监听超时", msg.issueNumber)
}
case brainstormStartedMsg:
return m, watchBrainstormSessionCmd(msg.issueNumber, msg.workspaceRoot)
case brainstormSessionCapturedMsg:
if msg.sessionID != "" {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].SessionID = msg.sessionID
break
}
}
if m.detailIss != nil && m.detailIss.Number == msg.issueNumber {
m.detailIss.SessionID = msg.sessionID
}
m.statusMsg = fmt.Sprintf("#%d 头脑风暴会话已捕获: %s", msg.issueNumber, msg.sessionID)
return m, writeBrainstormSessionCmd(msg.issueNumber, msg.sessionID)
} else {
m.statusMsg = fmt.Sprintf("#%d 头脑风暴会话监听超时", msg.issueNumber)
}
case createResultMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("创建失败: %v", msg.err)
} else {
m.statusMsg = "创建成功,刷新看板…"
m.state = stateLoading
return m, tea.Batch(m.spinner.Tick, loadCmd())
}
case imagePastedMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("粘贴图片: %v", msg.err)
return m, nil
}
m.createImages = append(m.createImages, msg.path)
v := m.createArea.Value()
if v != "" && !strings.HasSuffix(v, " ") && !strings.HasSuffix(v, "\n") {
v += " "
}
m.createArea.SetValue(v + fmt.Sprintf("[Image #%d]", len(m.createImages)))
m.statusMsg = fmt.Sprintf("已粘贴图片 #%d", len(m.createImages))
case prCommitsLoadedMsg:
m.prCommitsLoaded = true
if msg.err != nil {
m.prCommitsErr = msg.err
return m, nil
}
m.prBaseRef = msg.baseRef
m.prCommits = msg.commits
m.prFiles = msg.files
m.prConfirmed = make(map[string]bool, len(msg.confirmedPaths))
for _, p := range msg.confirmedPaths {
m.prConfirmed[p] = true
}
m.prConfirmedSHAs = make(map[string]bool, len(msg.confirmedSHAs))
for _, s := range msg.confirmedSHAs {
m.prConfirmedSHAs[s] = true
}
case prReviewWriteMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("写入确认状态失败: %v", msg.err)
}
case webhookEventMsg:
if msg.ev == nil {
// ctx cancelled (quit path) — do not re-issue the listen loop.
return m, nil
}
return dispatchWebhookEvent(m, msg.ev)
case webhookServerErrMsg:
m.statusMsg = fmt.Sprintf("webhook:%v", msg.err)
case autoReviewCapturedMsg:
if msg.sessionID != "" {
for i, iss := range m.issues {
if iss.Number == msg.issueNumber {
m.issues[i].ReviewSessionID = msg.sessionID
break
}
}
if m.detailIss != nil && m.detailIss.Number == msg.issueNumber {
m.detailIss.ReviewSessionID = msg.sessionID
}
m.statusMsg = fmt.Sprintf("#%d 审查会话已捕获", msg.issueNumber)
return m, autoReviewPersistCmd(msg.issueNumber, msg.sessionID)
}
case autoReviewPersistMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("写入 reviewSessionId 失败 #%d: %v", msg.issueNumber, msg.err)
}
case profilePickerLoadedMsg:
if m.state == stateCreate {
// Create modal: profiles are an optional radio list; absence is fine.
if msg.err == nil {
m.profilePickerProfiles = msg.profiles
m.createProfileIdx = defaultProfileIdx(msg.profiles)
}
} else if msg.err != nil {
m.statusMsg = fmt.Sprintf("加载 profiles 失败: %v", msg.err)
m.state = m.profilePickerPrevState
} else if len(msg.profiles) == 0 {
m.statusMsg = "无可用 profile"
m.state = m.profilePickerPrevState
} else {
m.profilePickerProfiles = msg.profiles
}
case profilePathSavedMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("保存 profile 失败: %v", msg.err)
}
case profileGridLoadedMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("加载 profile grid 失败: %v", msg.err)
} else {
m.profileGridData = msg.data
}
case profileGridSavedMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("保存 profile grid 失败: %v", msg.err)
} else {
m.statusMsg = "profile grid 已保存"
}
case managedSessionsLoadedMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("加载会话列表失败: %v", msg.err)
} else {
m.managedSessions = msg.data
}
case managedSessionsSavedMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("保存会话列表失败: %v", msg.err)
}
case managedSessionCapturedMsg:
if msg.sessionID == "" {
m.statusMsg = "managed 会话监听超时 (120s)"
break
}
sess := store.ManagedSession{
ID: msg.sessionID,
Name: msg.name,
ProfilePath: msg.profilePath,
CreatedAt: msg.createdAt,
}
newData := store.AddSession(m.managedSessions, sess)
m.managedSessions = newData
m.managedSessionsCursor = len(newData.Sessions) - 1
m.statusMsg = fmt.Sprintf("已捕获会话 %q (%s)", msg.name, msg.sessionID)
return m, saveManagedSessionsCmd(newData)
case openFileMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("打开文件失败: %v", msg.err)
}
case openPRMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("打开 PR 失败: %v", msg.err)
} else {
m.statusMsg = "已在浏览器中打开 PR"
}
case openWorktreeMsg:
if msg.err != nil {
m.statusMsg = fmt.Sprintf("打开 worktree 失败: %v", msg.err)
} else {
m.statusMsg = "已在新 tmux 窗口中打开 worktree"
}
case hasChangesMsg:
m.hasChanges = msg.hasChanges
return m, hasChangesTickCmd()
case commitRunMsg:
m.commitRunning = false
if msg.err != nil {
m.statusMsg = fmt.Sprintf("提交失败: %v", msg.err)
} else {
m.statusMsg = "提交完成"
}
case mergePreviewResultMsg:
if msg.err != nil {
if len(msg.result.Conflicts) > 0 {
m.statusMsg = fmt.Sprintf("#%d 合并冲突 (%d 个): %s — 执行 git merge --abort 撤销",
msg.issueNumber, len(msg.result.Conflicts), strings.Join(msg.result.Conflicts, "; "))
} else {
m.statusMsg = fmt.Sprintf("#%d 预合并失败: %v", msg.issueNumber, msg.err)
}
} else {
m.statusMsg = fmt.Sprintf("#%d 可干净合并", msg.issueNumber)
}
case logsSnapshotMsg:
m.logEntries = msg.entries
case logsTickMsg:
if m.state == stateLogs {
m.logEntries = logging.Default.Snapshot()
return m, logsTickCmd()
}
case tea.KeyPressMsg:
// Settings panel mode.
if m.state == stateSettings {
return m.handleSettingsKey(msg)
}
// Detail panel mode.
if m.state == stateDetail {
switch msg.String() {
case "esc":
m.state = stateLoaded
m.detailIss = nil
case "b":
if m.detailIss == nil {
break
}
if m.detailIss.SessionID == "" {
// Start new brainstorm session.
iss := *m.detailIss
return m, brainstormStartCmd(iss, m.sessionMgr)
}
iss := *m.detailIss
cmd := claudeResumeCmd(iss.SessionID, iss.ProfilePath, m.settings.SystemPromptCommand)
cwd, _ := config.WorkspaceRoot()
mgr := m.sessionMgr
// Optimistically mark the brainstorm tab open; the ~2s poll
// reconciles if OpenOrFocus ultimately fails.
m.detailIss.BrainstormTabOpen = true
for i, candidate := range m.issues {
if candidate.Number == iss.Number {
m.issues[i].BrainstormTabOpen = true
break
}
}
m.buckets = bucketize(m.issues)
return m, func() tea.Msg {
return sessionActionResultMsg{err: mgr.OpenOrFocus(context.Background(), PhaseBrain, iss, cwd, cmd)}
}
case "v":
if m.detailIss == nil {
break
}
if m.detailIss.ReviewSessionID == "" {
m.statusMsg = "无审查会话"
break
}
iss := *m.detailIss
return m, reviewCmd(iss, m.sessionMgr)
case "t":
if m.detailIss == nil {
break
}
iss := *m.detailIss
return m, testCmd(iss, m.sessionMgr)
case "i":
if m.detailIss == nil {
break
}
if m.detailIss.PlanFile == "" {
m.statusMsg = "无计划文件,无法实施"
break
}
iss := *m.detailIss
// Optimistic column update.
m.detailIss.Column = issue.ColumnInProgress
for i, candidate := range m.issues {
if candidate.Number == iss.Number {
m.issues[i].Column = issue.ColumnInProgress
break
}
}
m.buckets = bucketize(m.issues)
m.statusMsg = fmt.Sprintf("正在启动实施 #%d…", iss.Number)
return m, implementCmd(iss, m.sessionMgr)
case "p", "R":
if m.detailIss == nil {
break
}
if m.detailIss.PR == "" {
m.statusMsg = "无关联 PR"
break
}
iss := *m.detailIss
m.prCommitsIss = &iss
m.prBaseRef = ""
m.prCommitsReturn = stateDetail
m.state = statePRCommits
m.prCommitsLoaded = false
m.prCommitsErr = nil
m.prCommits = nil
m.prFiles = nil
m.prCursor = 0
m.prConfirmed = map[string]bool{}
m.prConfirmedSHAs = map[string]bool{}
return m, tea.Batch(m.spinner.Tick, loadPRCommitsCmd(iss))
case "g":
if m.detailIss == nil {
break
}
if m.detailIss.PR == "" || m.detailIss.Branch == "" {
m.statusMsg = "无 PR 或分支信息,无法生成摘要"
break
}
iss := *m.detailIss
m.statusMsg = fmt.Sprintf("正在生成 #%d PR diff 摘要…", iss.Number)
return m, prDiffSummaryCmd(iss)
case "f":
return m.openProfilePicker(false)
case "F":
return m.openProfilePicker(true)
case "o":
if m.detailIss == nil {
break
}
root, rootErr := workspaceRootFn()
if rootErr != nil {
m.statusMsg = fmt.Sprintf("workspace root: %v", rootErr)
break
}
candidates := candidateFiles(*m.detailIss, root)
switch len(candidates) {
case 0:
m.statusMsg = "无可打开的文件 (spec/plan/prDiff 均未设置或不存在)"
case 1:
return m, openEditorCmd(candidates[0])
default:
m.filePickerFiles = candidates
m.filePickerCursor = 0
m.filePickerPrevState = m.state
m.state = stateFilePicker
}
case "O":
if m.detailIss == nil {
break
}
if m.detailIss.PR == "" {
m.statusMsg = "无关联 PR"
break
}
iss := *m.detailIss
return m, openPRCmd(iss)
case "w":
if m.detailIss == nil {
break
}
if m.detailIss.WorktreePath == "" {
m.statusMsg = "无 worktree 路径"
break
}
iss := *m.detailIss
return m, openWorktreeCmd(iss, m.sessionMgr)
case "M":
if m.detailIss == nil {
break
}
if m.detailIss.Branch == "" {
m.statusMsg = "该工单没有分支,无法预合并"
break
}
iss := *m.detailIss
devBranch := ""
if m.settings != nil {
devBranch = m.settings.DevBranch
}
m.statusMsg = fmt.Sprintf("正在预合并 %s…", iss.Branch)
return m, mergePreviewCmd(iss, devBranch)
}
return m, nil
}
// Profile picker mode.
if m.state == stateProfilePicker {
return m.handleProfilePickerKey(msg)
}
// File picker mode (o with >1 candidate).
if m.state == stateFilePicker {
return m.handleFilePickerKey(msg)
}
// Profile grid mode.
if m.state == stateProfileGrid {
return m.handleProfileGridKey(msg)
}
// Managed sessions mode.
if m.state == stateManagedSessions {
return m.handleManagedSessionsKey(msg)
}
// Logs modal mode.
if m.state == stateLogs {
return m.handleLogsKey(msg)
}
// PR commits review mode.
if m.state == statePRCommits {
return m.handlePRCommitsKey(msg)
}
// Create mode: capture text input for new issue request.
if m.state == stateCreate {
switch msg.String() {
case "ctrl+s":
raw := strings.TrimSpace(m.createArea.Value())
m.state = stateLoaded
if raw == "" {
return m, nil
}
profilePath := ""
if m.createProfileIdx >= 0 && m.createProfileIdx < len(m.profilePickerProfiles) {
profilePath = m.profilePickerProfiles[m.createProfileIdx].Path
}
m.statusMsg = "创建中…"
return m, createIssueCmd(raw, profilePath, m.createImages)
case "esc":
m.state = stateLoaded
return m, nil
case "ctrl+v":
return m, pasteImageCmd(m.createInboxDir, len(m.createImages)+1)
case "tab":
if n := len(m.profilePickerProfiles); n > 0 {
m.createProfileIdx++
if m.createProfileIdx >= n {
m.createProfileIdx = -1
}
}
return m, nil
default:
var cmd tea.Cmd
m.createArea, cmd = m.createArea.Update(msg)
return m, cmd
}
}
// Input mode: only textinput, enter, and esc are active.
if m.state == stateInput {
switch msg.String() {
case "enter":
raw := strings.TrimSpace(m.depInput.Value())
issNum := m.inputPend
m.inputPend = 0
m.state = stateLoaded
if raw == "" {
// empty input = cancel
return m, nil
}
prereq, err := strconv.Atoi(raw)
if err != nil || prereq <= 0 {
m.statusMsg = fmt.Sprintf("无效工单号: %q", raw)
return m, nil
}
// Optimistic update.
var oldPrereq int
for i, iss := range m.issues {
if iss.Number == issNum {
oldPrereq = iss.Prerequisite
m.issues[i].Prerequisite = prereq
break
}
}
m.buckets = bucketize(m.issues)
return m, setDepCmd(issNum, prereq, oldPrereq)
case "esc":
m.inputPend = 0
m.state = stateLoaded
return m, nil
default:
var cmd tea.Cmd
m.depInput, cmd = m.depInput.Update(msg)
return m, cmd
}
}
// Confirm mode: only y/n/esc are active.
if m.confirmPend != nil {
switch msg.String() {
case "y":
pend := m.confirmPend
m.confirmPend = nil
switch pend.action {
case confirmClose:
return m, closeCmd(pend.issue.Number)
case confirmDelete:
return m, deleteCmd(pend.issue, m.sessionMgr)
case confirmBranchSync:
if m.branchSyncPending != nil {
dev := m.branchSyncPending.DevBranch
auto := m.branchSyncPending.AutoBuildBranch
m.branchSyncPending = nil
return m, branchSyncRunCmd(dev, auto)
}
case confirmCommitRun:
m.commitRunning = true
return m, commitRunCmd()
}
case "n", "esc":
m.confirmPend = nil
}
return m, nil
}
if m.state == stateLoaded {
if handledModel, cmd, handled := m.handleFocusKey(msg); handled {
return handledModel, cmd
}
}
if m.state == stateLoaded && isInlineDetailKey(msg.String()) {
return m.handleInlineDetailKey(msg)
}
switch msg.String() {
case "q", "ctrl+c":
if m.webhookCancel != nil {
m.webhookCancel()
}
return m, tea.Quit
case "1":
if m.state == stateLoaded {
return m.switchBottomTab(bottomTabIssue)
}
case "2":
if m.state == stateLoaded {
return m.switchBottomTab(bottomTabProfile)
}
case "3":
if m.state == stateLoaded {
return m.switchBottomTab(bottomTabSessions)
}
case "4":
if m.state == stateLoaded {
return m.switchBottomTab(bottomTabCommits)
}
case "r":
m.state = stateLoading
m.loadErr = nil
m.statusMsg = ""
return m, tea.Batch(m.spinner.Tick, loadCmd())
case "up", "k":
if m.state == stateLoaded {
m.rowIdx--
m.clampCursor()
}
case "down", "j":
if m.state == stateLoaded {
m.rowIdx++
m.clampCursor()
}
case "left", "h":
if m.state == stateLoaded {
m.colIdx = prevNonEmpty(m.buckets, m.colIdx)
m.clampCursor()
}
case "right", "l":
if m.state == stateLoaded {
m.colIdx = nextNonEmpty(m.buckets, m.colIdx)
m.clampCursor()
}
case "H":
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
return m, m.moveCard(-1)
}
case "L":
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
return m, m.moveCard(+1)
}
case "c":
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
iss := m.buckets[m.colIdx][m.rowIdx]
m.confirmPend = &confirmPending{action: confirmClose, issue: iss}
}
case "d":
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
iss := m.buckets[m.colIdx][m.rowIdx]
m.confirmPend = &confirmPending{action: confirmDelete, issue: iss}
}
case "p":
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
iss := m.buckets[m.colIdx][m.rowIdx]
ti := textinput.New()
ti.Placeholder = "前置工单号"
ti.Focus()
m.depInput = ti
m.inputPend = iss.Number
m.state = stateInput
}
case "P":
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
iss := m.buckets[m.colIdx][m.rowIdx]
if iss.Prerequisite != 0 {
oldPrereq := iss.Prerequisite
// Optimistic clear.
for i, candidate := range m.issues {
if candidate.Number == iss.Number {
m.issues[i].Prerequisite = 0
break
}
}
m.buckets = bucketize(m.issues)
return m, clearDepCmd(iss.Number, oldPrereq)
}
}
case "n":
if m.state == stateLoaded {
ta := textarea.New()
ta.Placeholder = "用 Markdown 描述你的需求…(可 Ctrl+V 粘贴截图)"
ta.SetWidth(createModalWidth(m.width) - 4)
ta.SetHeight(8)
focusCmd := ta.Focus()
m.createArea = ta
m.createProfileIdx = -1
m.createImages = nil
nonce, _ := generateNonce()
m.createInboxDir = filepath.Join(os.TempDir(), "spx-inbox", nonce)
m.state = stateCreate
cmds := []tea.Cmd{focusCmd}
if m.settings != nil && m.settings.ProfilesDir != "" {
cmds = append(cmds, loadProfilePickerCmd(m.settings.ProfilesDir))
}
return m, tea.Batch(cmds...)
}
case "C":
if m.state == stateLoaded && !m.commitRunning {
m.confirmPend = &confirmPending{action: confirmCommitRun}
}
case "S":
if m.state == stateLoaded {
return m, branchSyncCheckCmd()
}
case "E":
if m.state == stateLoaded {
return m, envLockToggleCmd(m.envLocked)
}
case ",":
if m.state == stateLoaded {
return m.openSettings()
}
case "G":
if m.state == stateLoaded {
return m.openProfileGrid()
}
case "m":
if m.state == stateLoaded {
return m.openManagedSessions()
}
case "ctrl+l":
if m.state == stateLoaded {
return m.openLogs()
}
}
}
return m, nil
}
// moveCard moves the selected card left (delta=-1) or right (delta=+1).
// Returns nil when at boundary (no-op). On a valid move, applies optimistic
// update and returns the async persist Cmd.
func (m *Model) moveCard(delta int) tea.Cmd {
newColIdx := m.colIdx + delta
if newColIdx < 0 || newColIdx > 3 {
return nil
}
iss := m.buckets[m.colIdx][m.rowIdx]
origCol := iss.Column
newCol := columnOrder[newColIdx]
// Optimistic update in m.issues.
for i, candidate := range m.issues {
if candidate.Number == iss.Number {
m.issues[i].Column = newCol
break
}
}
m.buckets = bucketize(m.issues)
// Cursor follows the moved card into the target column.
m.colIdx = newColIdx
m.rowIdx = 0
for ri, candidate := range m.buckets[newColIdx] {
if candidate.Number == iss.Number {
m.rowIdx = ri
break
}
}
m.clampCursor()
return moveCmd(iss.Number, newCol, origCol)
}
// clampCursor keeps rowIdx within the valid range of the current column.
// If the column is empty, rowIdx is set to 0.
func (m *Model) clampCursor() {
col := m.buckets[m.colIdx]
if len(col) == 0 {
m.rowIdx = 0
return
}
if m.rowIdx < 0 {
m.rowIdx = 0
}
if m.rowIdx >= len(col) {
m.rowIdx = len(col) - 1
}
}
// nextNonEmpty returns the next column index that has issues, wrapping around.
// Returns current if no other column has issues.
func nextNonEmpty(buckets [4][]issue.Issue, cur int) int {
for step := 1; step < 4; step++ {
next := (cur + step) % 4
if len(buckets[next]) > 0 {
return next
}
}
return cur
}
// prevNonEmpty returns the previous column index that has issues, wrapping around.
// Returns current if no other column has issues.
func prevNonEmpty(buckets [4][]issue.Issue, cur int) int {
for step := 1; step < 4; step++ {
prev := ((cur-step)%4 + 4) % 4
if len(buckets[prev]) > 0 {
return prev
}
}
return cur
}
// ── View ────────────────────────────────────────────────────────────────────
func (m Model) View() tea.View {
v := tea.NewView(m.viewString())
v.AltScreen = true
v.MouseMode = tea.MouseModeAllMotion
return v
}
func (m Model) viewString() string {
switch m.state {
case stateLoading:
return m.spinner.View() + " 加载工单中...\n\n" +
helpStyle.Render("q 退出")
case stateError:
return errorStyle.Render("错误: "+m.loadErr.Error()) + "\n\n" +
helpStyle.Render("r 重试 q 退出")
case stateCreate:
return m.createView()
case stateDetail:
if m.detailIss != nil {
return m.detailView()
}
return m.boardView()
case statePRCommits:
return m.prCommitsView()
case stateSettings:
return m.settingsView()
case stateProfilePicker:
return m.profilePickerView()
case stateProfileGrid:
return m.profileGridView()
case stateManagedSessions:
return m.managedSessionsView()
case stateLogs:
return m.logsView()
case stateFilePicker:
return m.filePickerView()
default:
return m.boardView()
}
}
func (m Model) boardView() string {
if iss, ok := m.selectedIssue(); ok {
return m.boardWithDetailView(iss)
}
boxH := m.height - 2
if m.height == 0 {
boxH = 20
}
if boxH < 5 {
boxH = 5
}
board := m.boardOnlyView(boxH)
help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出")
indicator := ""
if m.hasChanges {
indicator = " " + confirmStyle.Render("● 未提交")
}
status := m.statusBar()
if status != "" {
return board + "\n" + status + indicator + "\n" + help
}
if indicator != "" {
return board + "\n" + indicator + "\n" + help
}
return board + "\n" + help
}
func (m Model) selectedIssue() (issue.Issue, bool) {
if m.colIdx < 0 || m.colIdx >= len(m.buckets) {
return issue.Issue{}, false
}
if m.rowIdx < 0 || m.rowIdx >= len(m.buckets[m.colIdx]) {
return issue.Issue{}, false
}
return m.buckets[m.colIdx][m.rowIdx], true
}
func (m Model) currentDetailIssue() (issue.Issue, bool) {
if m.state == stateDetail && m.detailIss != nil {
return *m.detailIss, true
}
return m.selectedIssue()
}
func (m Model) switchBottomTab(tab bottomTab) (Model, tea.Cmd) {
m.bottomTab = tab
switch tab {
case bottomTabProfile:
return m, loadProfileGridCmd()
case bottomTabSessions:
return m, loadManagedSessionsCmd()
case bottomTabCommits:
iss, ok := m.selectedIssue()
if !ok {
m.statusMsg = "无选中工单"
return m, nil
}
if iss.PR == "" {
m.statusMsg = "当前工单无关联 PR"
m.prCommitsIss = &iss
m.prCommitsLoaded = true
m.prCommitsErr = nil
m.prCommits = nil
m.prFiles = nil
return m, nil
}
m.prCommitsIss = &iss
m.prBaseRef = ""
m.prCommitsReturn = stateLoaded
m.prCommitsLoaded = false
m.prCommitsErr = nil
m.prCommits = nil
m.prFiles = nil
m.prCursor = 0
m.prConfirmed = map[string]bool{}
m.prConfirmedSHAs = map[string]bool{}
return m, tea.Batch(m.spinner.Tick, loadPRCommitsCmd(iss))
default:
return m, nil
}
}
type panelAction struct {
key string
label string
}
var panelActions = []panelAction{
{"b", "头脑风暴"},
{"i", "实施"},
{"v", "审查"},
{"t", "测试"},
{"R", "PR提交"},
{"g", "PR摘要"},
{"o", "打开文件"},
{"r", "刷新"},
{"q", "退出"},
}
func (m Model) actionBarView() string {
var parts []string
for i, action := range panelActions {
text := fmt.Sprintf("[%s] %s", action.key, action.label)
focused := m.focusPane == focusPanel && m.panelFocus == panelFocusActions && m.actionIdx == i
hovered := m.hoverActionOK && m.hoverActionIdx == i
if focused || hovered {
parts = append(parts, selectedCardStyle.Render(text))
} else {
parts = append(parts, helpStyle.Render(text))
}
}
return strings.Join(parts, " ")
}
func (m Model) handleFocusKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) {
switch msg.String() {
case "tab":
if m.focusPane == focusPanel {
m.focusPane = focusBoard
} else {
m.focusPane = focusPanel
}
return m, nil, true
case "shift+tab":
if m.focusPane == focusPanel {
m.focusPane = focusBoard
} else {
m.focusPane = focusPanel
}
return m, nil, true
}
if m.focusPane != focusPanel {
return m, nil, false
}
if m.bottomTab == bottomTabIssue && m.panelFocus == panelFocusActions {
switch msg.String() {
case "left", "h":
if m.actionIdx > 0 {
m.actionIdx--
}
return m, nil, true
case "right", "l":
if m.actionIdx < len(panelActions)-1 {
m.actionIdx++
}
return m, nil, true
case "up", "k":
m.panelFocus = panelFocusTabs
return m, nil, true
case "enter":
return m.triggerPanelAction(m.actionIdx)
}
}
switch msg.String() {
case "up", "k", "left", "h":
next := (m.bottomTab + 3) % 4
nextM, cmd := m.switchBottomTab(next)
nextM.focusPane = focusPanel
return nextM, cmd, true
case "down", "j", "right", "l":
if m.bottomTab == bottomTabIssue && (msg.String() == "down" || msg.String() == "j") {
m.panelFocus = panelFocusActions
if m.actionIdx < 0 || m.actionIdx >= len(panelActions) {
m.actionIdx = 0
}
return m, nil, true
}
next := (m.bottomTab + 1) % 4
nextM, cmd := m.switchBottomTab(next)
nextM.focusPane = focusPanel
return nextM, cmd, true
}
return m, nil, false
}
func (m Model) triggerPanelAction(idx int) (tea.Model, tea.Cmd, bool) {
if idx < 0 || idx >= len(panelActions) {
return m, nil, true
}
key := panelActions[idx].key
if key == "r" {
m.state = stateLoading
m.loadErr = nil
m.statusMsg = ""
return m, tea.Batch(m.spinner.Tick, loadCmd()), true
}
if key == "q" {
if m.webhookCancel != nil {
m.webhookCancel()
}
return m, tea.Quit, true
}
next, cmd := m.handleInlineDetailKey(tea.KeyPressMsg{Code: []rune(key)[0], Text: key})
return next, cmd, true
}
func (m Model) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
mouse := tea.Mouse(msg)
if mouse.Button != tea.MouseLeft {
return m, nil
}
if m.state != stateLoaded {
return m, nil
}
panelY, ok := m.panelStartY()
if !ok {
m.focusPane = focusBoard
return m, nil
}
if mouse.Y < panelY {
m.focusPane = focusBoard
return m, nil
}
m.focusPane = focusPanel
if mouse.Y == panelY {
m.panelFocus = panelFocusTabs
if tab, ok := tabAtX(mouse.X); ok {
nextM, cmd := m.switchBottomTab(tab)
nextM.focusPane = focusPanel
return nextM, cmd
}
}
if m.bottomTab == bottomTabIssue && mouse.Y == panelY+4 {
m.panelFocus = panelFocusActions
if idx, ok := actionAtX(mouse.X); ok {
m.actionIdx = idx
m.hoverActionIdx = idx
m.hoverActionOK = true
next, cmd, _ := m.triggerPanelAction(idx)
return next, cmd
}
}
return m, nil
}
func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) {
mouse := tea.Mouse(msg)
if m.state != stateLoaded || m.bottomTab != bottomTabIssue {
m.hoverActionOK = false
return m, nil
}
panelY, ok := m.panelStartY()
if !ok || mouse.Y != panelY+4 {
m.hoverActionOK = false
return m, nil
}
idx, ok := actionAtX(mouse.X)
if !ok {
m.hoverActionOK = false
return m, nil
}
m.hoverActionIdx = idx
m.hoverActionOK = true
return m, nil
}
func (m Model) panelStartY() (int, bool) {
iss, ok := m.selectedIssue()
if !ok {
return 0, false
}
width := m.width - 2
if m.width <= 0 {
width = 100
}
if width < 40 {
width = 40
}
panel := m.bottomTabsPanel(iss, width)
panelH := lipgloss.Height(panel)
statusLine := ""
indicator := ""
if m.hasChanges {
indicator = " " + confirmStyle.Render("● 未提交")
}
if status := m.statusBar(); status != "" {
statusLine = status + indicator
} else if indicator != "" {
statusLine = indicator
}
reserved := panelH + 1
if statusLine != "" {
reserved++
}
boardH := m.height - reserved
if m.height == 0 {
boardH = 12
}
if boardH < 5 {
boardH = 5
}
if statusLine != "" {
return boardH + 1, true
}
return boardH, true
}
func tabAtX(x int) (bottomTab, bool) {
// Approximate ranges for the plain header text:
// " 1 工单 " " 2 Profile " " 3 会话 " " 4 提交 ".
switch {
case x >= 0 && x < 8:
return bottomTabIssue, true
case x >= 8 && x < 20:
return bottomTabProfile, true
case x >= 20 && x < 28:
return bottomTabSessions, true
case x >= 28 && x < 36:
return bottomTabCommits, true
default:
return bottomTabIssue, false
}
}
func actionAtX(x int) (int, bool) {
start := 2 // left border + cell's leading inset
for i, action := range panelActions {
w := lipgloss.Width(fmt.Sprintf("[%s] %s", action.key, action.label))
end := start + w
if x >= start && x < end {
return i, true
}
start = end + 2
}
return 0, false
}
func isInlineDetailKey(key string) bool {
switch key {
case "b", "i", "v", "t", "R", "g", "f", "F", "o", "O", "w", "M":
return true
default:
return false
}
}
func (m Model) handleInlineDetailKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
iss, ok := m.selectedIssue()
if !ok {
return m, nil
}
m.detailIss = &iss
m.state = stateDetail
next, cmd := m.Update(msg)
nextModel, ok := next.(Model)
if !ok {
return next, cmd
}
switch nextModel.state {
case stateDetail:
nextModel.state = stateLoaded
nextModel.detailIss = nil
case stateProfilePicker:
if nextModel.profilePickerPrevState == stateDetail {
nextModel.profilePickerPrevState = stateLoaded
}
case stateFilePicker:
if nextModel.filePickerPrevState == stateDetail {
nextModel.filePickerPrevState = stateLoaded
}
case statePRCommits:
if nextModel.prCommitsReturn == stateDetail {
nextModel.prCommitsReturn = stateLoaded
}
}
return nextModel, cmd
}
func (m Model) boardOnlyView(boxH int) string {
colWidth := m.colWidth()
if boxH < 4 {
boxH = 4
}
cols := make([]string, 4)
for ci := range columnOrder {
cols[ci] = m.renderColumn(ci, colWidth, boxH)
}
return lipgloss.JoinHorizontal(lipgloss.Top, cols...)
}
// statusBar returns the status line content.
func (m Model) statusBar() string {
if m.state == stateInput {
return confirmStyle.Render(fmt.Sprintf("设置 #%d 前置工单: ", m.inputPend)) + m.depInput.View()
}
if m.confirmPend != nil {
if m.confirmPend.action == confirmBranchSync {
return confirmStyle.Render(m.statusMsg)
}
if m.confirmPend.action == confirmCommitRun {
return confirmStyle.Render("确认提交代码? (y/n)")
}
var action string
switch m.confirmPend.action {
case confirmClose:
action = "关闭"
case confirmDelete:
action = "删除"
}
return confirmStyle.Render(fmt.Sprintf("确认 %s #%d? (y/n)", action, m.confirmPend.issue.Number))
}
if m.statusMsg != "" {
return helpStyle.Render(m.statusMsg)
}
return ""
}
func (m Model) colWidth() int {
// Subtract borders (2 per column × 4) and padding (2 per column × 4).
available := m.width - 4*(2+2)
if available < 40 {
available = 80 // sensible fallback when no terminal size yet
}
return available / 4
}
func (m Model) renderColumn(ci int, width int, boxH int) string {
label := columnLabels[ci]
issues := m.buckets[ci]
header := colHeaderStyle.Render(
fmt.Sprintf("%s (%d)", label, len(issues)),
)
var cards []string
selectedCardIdx := -1
if ci == 0 {
selectedNum := -1
if m.colIdx == 0 && len(m.buckets[0]) > 0 {
selectedNum = m.buckets[0][m.rowIdx].Number
}
for i, entry := range todoDepTree(issues) {
selected := ci == m.colIdx && entry.iss.Number == selectedNum
if selected {
selectedCardIdx = i
}
blocked := isBlocked(entry.iss, m.issues)
cards = append(cards, m.renderCardDep(entry.iss, selected, width, entry.indent, blocked))
}
} else {
for ri, iss := range issues {
cards = append(cards, m.renderCard(iss, ci == m.colIdx && ri == m.rowIdx, width))
}
if ci == m.colIdx {
selectedCardIdx = m.rowIdx
}
}
visible := windowCards(cards, selectedCardIdx, boxH-3)
body := header + "\n" + visible
return colBorderStyle.
Width(width).
Height(boxH).
MaxHeight(boxH).
BorderForeground(m.colAccent(ci)).
Render(body)
}
// windowCards windows a slice of rendered card strings to fit within cardsH rows.
// If selectedIdx >= 0, the window scrolls to ensure that card is visible.
// Returns the joined string with ▲/▼ clip indicators when cards are hidden.
func windowCards(cards []string, selectedIdx int, cardsH int) string {
if cardsH < 1 {
cardsH = 1
}
if len(cards) == 0 {
return ""
}
// Find scroll start: minimum index such that selectedIdx is visible.
scrollStart := scrollIntoView(cards, selectedIdx, cardsH)
topClipped := scrollStart
// Collect visible cards starting from scrollStart.
var visible []string
used := 0
if topClipped > 0 {
used++ // reserve 1 row for ▲ indicator
}
lastIdx := scrollStart - 1
for i := scrollStart; i < len(cards); i++ {
h := lipgloss.Height(cards[i])
// Reserve 1 row for ▼ indicator if more cards remain after this one.
remainAfter := i < len(cards)-1
reserve := 0
if remainAfter && used+h >= cardsH {
reserve = 1
}
if used+h+reserve > cardsH {
break
}
visible = append(visible, cards[i])
used += h
lastIdx = i
}
bottomClipped := len(cards) - 1 - lastIdx
if lastIdx < scrollStart {
// Nothing fit — show at least a partial indicator
bottomClipped = len(cards) - scrollStart
}
var parts []string
if topClipped > 0 {
parts = append(parts, fmt.Sprintf("▲ %d", topClipped))
}
parts = append(parts, visible...)
if bottomClipped > 0 {
parts = append(parts, fmt.Sprintf("▼ %d", bottomClipped))
}
return strings.Join(parts, "\n")
}
// scrollIntoView returns the minimum scrollStart such that the card at
// selectedIdx is visible within cardsH rows. Returns 0 when selectedIdx < 0.
func scrollIntoView(cards []string, selectedIdx int, cardsH int) int {
if selectedIdx < 0 || cardsH < 1 {
return 0
}
for start := 0; start <= selectedIdx; start++ {
if cardVisible(cards, start, selectedIdx, cardsH) {
return start
}
}
return selectedIdx
}
// cardVisible reports whether the card at targetIdx is reachable when the
// window begins at scrollStart and has cardsH rows of budget.
func cardVisible(cards []string, scrollStart, targetIdx, cardsH int) bool {
used := 0
if scrollStart > 0 {
used++ // ▲ indicator row
}
for i := scrollStart; i < len(cards); i++ {
h := lipgloss.Height(cards[i])
remainAfter := i < len(cards)-1
reserve := 0
if remainAfter && used+h >= cardsH {
reserve = 1
}
if used+h+reserve > cardsH {
return false
}
used += h
if i == targetIdx {
return true
}
}
return false
}
// colAccent returns a subtle accent color for the column border; the selected
// column gets a brighter white so it pops.
func (m Model) colAccent(ci int) color.Color {
if ci == m.colIdx {
return lipgloss.Color("15") // bright white
}
return lipgloss.Color("8") // dark grey
}
func (m Model) renderCard(iss issue.Issue, selected bool, colWidth int) string {
return m.renderCardDep(iss, selected, colWidth, false, false)
}
// renderCardDep renders a card with optional indentation and blocked marker.
func (m Model) renderCardDep(iss issue.Issue, selected bool, colWidth int, indent bool, blocked bool) string {
prefix := ""
if indent {
prefix = " " // two-space indent for dependency child
}
// head occupies fixed display cells; the card sits inside a box of content
// width colWidth with 1-cell padding on each side (card) plus the box's own
// 1-cell padding, so the usable text width is colWidth-4. Budget the title to
// what remains after the head so the line never wraps and the height budget in
// windowCards stays accurate.
head := fmt.Sprintf("%s#%d ", prefix, iss.Number)
titleLine := head + truncate(iss.Title, colWidth-4-lipgloss.Width(head))
badges := cardBadges(iss)
if blocked {
blockedMark := blockedStyle.Render("[blocked]")
if badges != "" {
badges = blockedMark + " " + badges
} else {
badges = blockedMark
}
}
accent := ColorFor(iss)
accentStyle := lipgloss.NewStyle().Foreground(accent)
var cardContent string
if badges != "" {
cardContent = accentStyle.Render(titleLine) + "\n" + badgeStyle.Render(badges)
} else {
cardContent = accentStyle.Render(titleLine)
}
if selected {
return selectedCardStyle.Render(cardContent)
}
return normalCardStyle.Render(cardContent)
}