2290 lines
60 KiB
Go
2290 lines
60 KiB
Go
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)
|
||
}
|