This commit is contained in:
2026-06-24 02:32:22 +08:00
parent e6f1776d4f
commit 495e961e53
6 changed files with 966 additions and 40 deletions
+193 -14
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"path/filepath"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
@@ -117,7 +118,7 @@ var (
// detailBody builds the two stacked blocks (header + STATE JSON table) shown for
// an issue, sized to the given outer width.
func detailBody(iss issue.Issue, width int) string {
func (m Model) detailBody(iss issue.Issue, width int) string {
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(detailBorderColor).
@@ -144,9 +145,8 @@ func detailBody(iss issue.Issue, width int) string {
}
header := lipgloss.JoinVertical(lipgloss.Left,
cell(title, false),
cell("", false),
cell(hint.Render("[b] 头脑风暴 [i] 实施 [v] 审查 [t] 测试 [p] PR提交 [g] PR摘要"), false),
cell(hint.Render("[f] 配置 [F] 测试配置 [o] 打开文件 [c] 关闭 [d] 删除 esc 返回"), false),
cell(hint.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建"), false),
cell(m.actionBarView(), false),
)
headerBox := box.Render(header)
@@ -178,22 +178,201 @@ func detailBody(iss issue.Issue, width int) string {
return lipgloss.JoinVertical(lipgloss.Left, headerBox, tableBox)
}
// detailView renders the issue detail as a centered modal (two stacked blocks)
// floating over the board via lipgloss v2 layer compositing.
// detailView renders the board above a bottom issue property panel, matching
// the VSCode extension's kanban + attributes split.
func (m Model) detailView() string {
bg := m.boardView()
if m.detailIss == nil {
return bg
iss, ok := m.currentDetailIssue()
if !ok {
return m.boardView()
}
width := 84
if m.width > 0 && m.width-8 < width {
width = m.width - 8
return m.boardWithDetailView(iss)
}
func (m Model) boardWithDetailView(iss issue.Issue) string {
width := m.width - 2
if m.width <= 0 {
width = 100
}
if width < 40 {
width = 40
}
modal := detailBody(*m.detailIss, width)
return placeOverlayCenter(bg, modal)
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
}
parts := []string{m.boardOnlyView(boardH)}
if statusLine != "" {
parts = append(parts, statusLine)
}
parts = append(parts, panel)
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) bottomTabsPanel(iss issue.Issue, width int) string {
tabs := m.bottomTabsHeader(width)
content := ""
switch m.bottomTab {
case bottomTabProfile:
content = m.profileTabBody(width)
case bottomTabSessions:
content = m.sessionsTabBody(width)
case bottomTabCommits:
content = m.commitsTabBody(width)
default:
content = m.detailBody(iss, width)
}
return lipgloss.JoinVertical(lipgloss.Left, tabs, content)
}
func (m Model) bottomTabsHeader(width int) string {
labels := []struct {
tab bottomTab
label string
}{
{bottomTabIssue, "1 工单"},
{bottomTabProfile, "2 Profile"},
{bottomTabSessions, "3 会话"},
{bottomTabCommits, "4 提交"},
}
var parts []string
for _, item := range labels {
prefix := " "
if m.focusPane == focusPanel && m.bottomTab == item.tab {
prefix = "▸"
}
text := prefix + item.label + " "
if m.bottomTab == item.tab {
parts = append(parts, selectedCardStyle.Render(text))
} else {
parts = append(parts, helpStyle.Render(text))
}
}
line := strings.Join(parts, " ")
hint := helpStyle.Render(" Tab 切焦点;面板聚焦后 ↑↓/←→ 切 tab;点击区域也可切焦点")
if lipgloss.Width(line)+lipgloss.Width(hint) < width {
line += hint
}
return lipgloss.NewStyle().Width(width).Render(line)
}
func (m Model) tabBox(title string, lines []string, width int) string {
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(detailBorderColor).
Width(width)
titleLine := lipgloss.NewStyle().Bold(true).Render(title)
body := append([]string{titleLine, ""}, lines...)
return box.Render(lipgloss.JoinVertical(lipgloss.Left, body...))
}
func (m Model) profileTabBody(width int) string {
lines := []string{
helpStyle.Render("只读摘要;按 G 打开 Profile 表格编辑器"),
}
if len(m.profileGridData.Profiles) == 0 {
lines = append(lines, "未加载 Profile 数据,按 2 重新加载")
return m.tabBox("Profile", lines, width)
}
lines = append(lines, "Profiles: "+strings.Join(m.profileGridData.Profiles, ", "))
if len(m.profileGridData.Rows) == 0 {
lines = append(lines, "暂无配置行")
return m.tabBox("Profile", lines, width)
}
maxRows := 8
for i, row := range m.profileGridData.Rows {
if i >= maxRows {
lines = append(lines, fmt.Sprintf("... 还有 %d 行", len(m.profileGridData.Rows)-maxRows))
break
}
var vals []string
for _, prof := range m.profileGridData.Profiles {
vals = append(vals, fmt.Sprintf("%s=%s", prof, orDash(row.Values[prof])))
}
lines = append(lines, truncate(row.Key+" "+strings.Join(vals, " "), width-6))
}
return m.tabBox("Profile", lines, width)
}
func (m Model) sessionsTabBody(width int) string {
lines := []string{
helpStyle.Render("只读摘要;按 m 打开会话管理器"),
}
if len(m.managedSessions.Sessions) == 0 {
lines = append(lines, "暂无托管会话")
return m.tabBox("会话", lines, width)
}
maxRows := 8
for i, sess := range m.managedSessions.Sessions {
if i >= maxRows {
lines = append(lines, fmt.Sprintf("... 还有 %d 个会话", len(m.managedSessions.Sessions)-maxRows))
break
}
ts := time.Unix(sess.CreatedAt, 0).Format("2006-01-02 15:04")
id := sess.ID
if len(id) > 12 {
id = id[:12]
}
lines = append(lines, truncate(fmt.Sprintf("%s %s %s %s", sess.Name, id, profileDisplay(sess.ProfilePath), ts), width-6))
}
return m.tabBox("会话", lines, width)
}
func (m Model) commitsTabBody(width int) string {
lines := []string{
helpStyle.Render("只读摘要;按 R 打开 PR 提交确认面板"),
}
if m.prCommitsIss == nil || m.prCommitsIss.PR == "" {
lines = append(lines, "当前工单无关联 PR")
return m.tabBox("提交", lines, width)
}
if m.prCommitsErr != nil {
lines = append(lines, errorStyle.Render(m.prCommitsErr.Error()))
return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width)
}
if !m.prCommitsLoaded {
lines = append(lines, "加载中...")
return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width)
}
maxCommits := 5
lines = append(lines, fmt.Sprintf("Commits (%d):", len(m.prCommits)))
for i, c := range m.prCommits {
if i >= maxCommits {
lines = append(lines, fmt.Sprintf("... 还有 %d 个 commit", len(m.prCommits)-maxCommits))
break
}
mark := " "
if m.prConfirmedSHAs[c.SHA] {
mark = "x"
}
lines = append(lines, truncate(fmt.Sprintf("[%s] %s %s", mark, shortSHA(c.SHA), firstLine(c.Commit.Message)), width-6))
}
lines = append(lines, fmt.Sprintf("Files: %d", len(m.prFiles)))
if allFilesConfirmed(m.prFiles, m.prConfirmed) && len(m.prFiles) > 0 {
lines = append(lines, confirmStyle.Render("所有文件已确认"))
}
return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width)
}
+193 -9
View File
@@ -4,8 +4,10 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"superwork-tui/internal/issue"
"superwork-tui/internal/store"
)
func TestDetailBody(t *testing.T) {
@@ -31,7 +33,7 @@ func TestDetailBody(t *testing.T) {
AutoReview: &autoReview,
}
out := detailBody(iss, 80)
out := Model{}.detailBody(iss, 80)
checks := []string{
"#42",
@@ -58,10 +60,9 @@ func TestDetailBody(t *testing.T) {
}
}
// TestDetailModal_OverlaysBoard verifies the detail view is a centered modal
// composited over the board (true overlay), not a full-screen replacement, and
// that it never overflows the terminal height.
func TestDetailModal_OverlaysBoard(t *testing.T) {
// TestDetailView_RendersBoardAbovePropertyPanel verifies the detail view keeps
// the kanban board visible above a bottom property panel, matching VSCode.
func TestDetailView_RendersBoardAbovePropertyPanel(t *testing.T) {
var issues []issue.Issue
for i := 0; i < 11; i++ {
issues = append(issues, issue.Issue{Number: 238 - i, Title: "支持部署时使用客户自定义域名", Column: issue.ColumnTodo})
@@ -81,18 +82,201 @@ func TestDetailModal_OverlaysBoard(t *testing.T) {
out := m.detailView()
if !strings.Contains(out, "#238") {
t.Errorf("modal title missing")
t.Errorf("detail title missing")
}
if !strings.Contains(out, "STATE JSON") {
t.Errorf("STATE JSON section missing")
}
if !strings.Contains(out, "todo (11)") {
t.Errorf("board background (todo header) not visible behind modal — not a true overlay")
t.Errorf("board header not visible above detail panel")
}
if !strings.Contains(out, "╭") {
t.Errorf("modal rounded border missing")
t.Errorf("detail panel rounded border missing")
}
boardIdx := strings.Index(out, "todo (11)")
panelIdx := strings.Index(out, "STATE JSON")
if boardIdx == -1 || panelIdx == -1 || boardIdx > panelIdx {
t.Errorf("detail view should render board before property panel")
}
if h := lipgloss.Height(out); h > m.height {
t.Errorf("detail overlay height %d exceeds terminal height %d", h, m.height)
t.Errorf("detail split height %d exceeds terminal height %d", h, m.height)
}
}
func TestBoardView_DefaultDetailPanelFollowsSelection(t *testing.T) {
issues := []issue.Issue{
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
{Number: 2, Title: "Second issue", Column: issue.ColumnTodo},
}
m := makeLoadedModel(issues, 0, 0)
out := m.boardView()
if !strings.Contains(out, "STATE JSON") || !strings.Contains(out, "First issue") {
t.Fatalf("default board view should show selected issue properties, got: %q", out)
}
m2, _ := sendKey(m, "j")
if m2.state != stateLoaded {
t.Fatalf("moving selection should stay in board mode, got state %v", m2.state)
}
out = m2.boardView()
if !strings.Contains(out, "Second issue") {
t.Fatalf("detail panel should follow moved selection, got: %q", out)
}
m3, _ := sendKey(m2, "enter")
if m3.state != stateLoaded {
t.Fatalf("enter should not steal keyboard focus, got state %v", m3.state)
}
}
func TestBoardView_BottomTabsSwitchWithoutStealingBoardFocus(t *testing.T) {
issues := []issue.Issue{
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
{Number: 2, Title: "Second issue", Column: issue.ColumnTodo},
}
m := makeLoadedModel(issues, 0, 0)
m.profileGridData = store.ProfilesData{Profiles: []string{"dev"}, Rows: []store.ProfileRow{{Key: "model", Values: map[string]string{"dev": "sonnet"}}}}
m.managedSessions = store.ManagedSessionsData{Sessions: []store.ManagedSession{{ID: "abcdef123456", Name: "daily", CreatedAt: 1}}}
out := m.boardView()
for _, want := range []string{"1 工单", "2 Profile", "3 会话", "4 提交"} {
if !strings.Contains(out, want) {
t.Fatalf("bottom tabs missing %q: %q", want, out)
}
}
m2, _ := sendKey(m, "2")
if m2.state != stateLoaded || m2.bottomTab != bottomTabProfile {
t.Fatalf("profile tab should stay in board mode, state=%v tab=%v", m2.state, m2.bottomTab)
}
if out := m2.boardView(); !strings.Contains(out, "Profile") || !strings.Contains(out, "model") {
t.Fatalf("profile tab body missing expected content: %q", out)
}
m3, _ := sendKey(m2, "j")
if m3.state != stateLoaded || m3.rowIdx != 1 {
t.Fatalf("board selection should still move while tab is active, state=%v row=%d", m3.state, m3.rowIdx)
}
m4, _ := sendKey(m3, "3")
if out := m4.boardView(); !strings.Contains(out, "会话") || !strings.Contains(out, "daily") {
t.Fatalf("sessions tab body missing expected content: %q", out)
}
m5, _ := sendKey(m4, "tab")
if m5.focusPane != focusPanel || m5.bottomTab != bottomTabSessions {
t.Fatalf("tab should focus panel without changing tab, focus=%v tab=%v", m5.focusPane, m5.bottomTab)
}
m6, _ := sendKey(m5, "j")
if m6.focusPane != focusPanel || m6.bottomTab != bottomTabCommits {
t.Fatalf("panel-focused j on tabs should cycle to commits, focus=%v tab=%v", m6.focusPane, m6.bottomTab)
}
m7, _ := sendKey(m6, "tab")
if m7.focusPane != focusBoard {
t.Fatalf("second tab should focus board, got %v", m7.focusPane)
}
}
func TestBoardView_PanelActionBarKeyboardAndMouse(t *testing.T) {
issues := []issue.Issue{
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
}
m := makeLoadedModel(issues, 0, 0)
m.height = 40
m2, _ := sendKey(m, "tab")
m3, _ := sendKey(m2, "j")
if m3.panelFocus != panelFocusActions {
t.Fatalf("down in focused issue panel should enter action row, got %v", m3.panelFocus)
}
m4, _ := sendKey(m3, "l")
if m4.actionIdx != 1 {
t.Fatalf("right should move action selection, got %d", m4.actionIdx)
}
m5, cmd := sendKey(m4, "enter")
if cmd != nil {
t.Fatalf("implement action without plan should not start command")
}
if !strings.Contains(m5.statusMsg, "无计划文件") {
t.Fatalf("enter should trigger selected action, status=%q", m5.statusMsg)
}
panelY, ok := m.panelStartY()
if !ok {
t.Fatal("expected panel start")
}
clickImplement := tea.MouseClickMsg(tea.Mouse{X: 18, Y: panelY + 4, Button: tea.MouseLeft})
next, cmd := m.Update(clickImplement)
m6 := next.(Model)
if cmd != nil {
t.Fatalf("mouse implement action without plan should not start command")
}
if m6.focusPane != focusPanel || m6.panelFocus != panelFocusActions || m6.actionIdx != 1 {
t.Fatalf("mouse click should focus action, focus=%v panel=%v action=%d", m6.focusPane, m6.panelFocus, m6.actionIdx)
}
if !strings.Contains(m6.statusMsg, "无计划文件") {
t.Fatalf("mouse click should trigger action, status=%q", m6.statusMsg)
}
}
func TestBoardView_PanelActionBarMouseHover(t *testing.T) {
issues := []issue.Issue{
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
}
m := makeLoadedModel(issues, 0, 0)
m.height = 40
panelY, ok := m.panelStartY()
if !ok {
t.Fatal("expected panel start")
}
hoverImplement := tea.MouseMotionMsg(tea.Mouse{X: 18, Y: panelY + 4})
next, _ := m.Update(hoverImplement)
m2 := next.(Model)
if !m2.hoverActionOK || m2.hoverActionIdx != 1 {
t.Fatalf("hover should select implement action, ok=%v idx=%d", m2.hoverActionOK, m2.hoverActionIdx)
}
gapBetweenActions := tea.MouseMotionMsg(tea.Mouse{X: 15, Y: panelY + 4})
next, _ = m2.Update(gapBetweenActions)
mGap := next.(Model)
if mGap.hoverActionOK {
t.Fatalf("hover should not select gap between actions")
}
leave := tea.MouseMotionMsg(tea.Mouse{X: 2, Y: 1})
next, _ = mGap.Update(leave)
m3 := next.(Model)
if m3.hoverActionOK {
t.Fatalf("hover should clear after leaving action row")
}
}
func TestBoardView_MouseClickChangesFocusedPaneAndTab(t *testing.T) {
issues := []issue.Issue{
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
}
m := makeLoadedModel(issues, 0, 0)
m.height = 40
panelY, ok := m.panelStartY()
if !ok {
t.Fatal("expected panel start")
}
clickPanel := tea.MouseClickMsg(tea.Mouse{X: 22, Y: panelY, Button: tea.MouseLeft})
next, _ := m.Update(clickPanel)
m2 := next.(Model)
if m2.focusPane != focusPanel || m2.bottomTab != bottomTabSessions {
t.Fatalf("clicking sessions tab should focus panel and switch tab, focus=%v tab=%v", m2.focusPane, m2.bottomTab)
}
clickBoard := tea.MouseClickMsg(tea.Mouse{X: 2, Y: 1, Button: tea.MouseLeft})
next, _ = m2.Update(clickBoard)
m3 := next.(Model)
if m3.focusPane != focusBoard {
t.Fatalf("clicking board should focus board, got %v", m3.focusPane)
}
}
+440 -17
View File
@@ -92,6 +92,31 @@ type confirmPending struct {
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
@@ -166,8 +191,14 @@ type Model struct {
createInboxDir string // /tmp/spx-inbox/<nonce> for this create session
// detail panel state
detailIss *issue.Issue
sessionMgr *SessionManager
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
@@ -179,6 +210,7 @@ type Model struct {
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
@@ -566,6 +598,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -1092,7 +1130,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.buckets = bucketize(m.issues)
m.statusMsg = fmt.Sprintf("正在启动实施 #%d…", iss.Number)
return m, implementCmd(iss, m.sessionMgr)
case "p":
case "p", "R":
if m.detailIss == nil {
break
}
@@ -1103,6 +1141,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
iss := *m.detailIss
m.prCommitsIss = &iss
m.prBaseRef = ""
m.prCommitsReturn = stateDetail
m.state = statePRCommits
m.prCommitsLoaded = false
m.prCommitsErr = nil
@@ -1321,6 +1360,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@@ -1328,11 +1377,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Quit
case "enter":
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
iss := m.buckets[m.colIdx][m.rowIdx]
m.detailIss = &iss
m.state = stateDetail
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":
@@ -1553,6 +1615,7 @@ func prevNonEmpty(buckets [4][]issue.Issue, cur int) int {
func (m Model) View() tea.View {
v := tea.NewView(m.viewString())
v.AltScreen = true
v.MouseMode = tea.MouseModeAllMotion
return v
}
@@ -1602,7 +1665,9 @@ func (m Model) viewString() string {
}
func (m Model) boardView() string {
colWidth := m.colWidth()
if iss, ok := m.selectedIssue(); ok {
return m.boardWithDetailView(iss)
}
boxH := m.height - 2
if m.height == 0 {
@@ -1611,15 +1676,9 @@ func (m Model) boardView() string {
if boxH < 5 {
boxH = 5
}
board := m.boardOnlyView(boxH)
cols := make([]string, 4)
for ci := range columnOrder {
cols[ci] = m.renderColumn(ci, colWidth, boxH)
}
board := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 enter 详情 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出")
help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出")
indicator := ""
if m.hasChanges {
indicator = " " + confirmStyle.Render("● 未提交")
@@ -1634,6 +1693,370 @@ func (m Model) boardView() string {
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 {
+5
View File
@@ -191,6 +191,11 @@ func (m Model) cursorOnFile() (int, bool) {
func (m Model) handlePRCommitsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
if m.prCommitsReturn != stateLoading {
m.state = m.prCommitsReturn
m.prCommitsReturn = stateLoading
return m, nil
}
m.state = stateDetail
return m, nil