package tui import ( "fmt" "path/filepath" "strings" "time" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "superwork-tui/internal/issue" ) var columnZhLabel = map[issue.Column]string{ issue.ColumnTodo: "待办", issue.ColumnInProgress: "进行中", issue.ColumnReview: "审查", issue.ColumnDone: "完成", } func columnDisplay(col issue.Column) string { if zh, ok := columnZhLabel[col]; ok { return fmt.Sprintf("%s(%s)", col, zh) } return string(col) } func autoReviewDisplay(v *bool) string { if v == nil { return "(默认)" } if *v { return "✓ True" } return "✗ False" } // colorDisplay shows a colored dot plus the short color name (the palette key // with its "terminal.ansi" prefix stripped), matching the VSCode panel. func colorDisplay(key string) string { if key == "" { return "未分配" } dot := "●" if c, ok := keyToColor[key]; ok { dot = lipgloss.NewStyle().Foreground(c).Render("●") } return dot + " " + strings.TrimPrefix(key, "terminal.ansi") } func profileDisplay(path string) string { if path == "" { return "(默认)" } base := filepath.Base(path) return strings.TrimSuffix(base, filepath.Ext(base)) } func prDisplay(iss issue.Issue) string { if iss.PR == "" { return "未关联 PR" } s := "#" + iss.PR if iss.PrMerged { s += "(已合并)" } return s } func orDash(s string) string { if s == "" { return "—" } return s } func orNotLoaded(s string) string { if s == "" { return "未加载" } return s } // detailRows is the ordered field list of the STATE JSON section, mirroring the // VSCode IssueDetailPanel schema (label → formatted value). func detailRows(iss issue.Issue) [][2]string { return [][2]string{ {"状态", columnDisplay(iss.Column)}, {"头脑风暴会话id", orDash(iss.SessionID)}, {"实施会话id", orDash(iss.ImplementSessionID)}, {"审查会话id", orDash(iss.ReviewSessionID)}, {"测试会话id", orDash(iss.TestSessionID)}, {"自动审查", autoReviewDisplay(iss.AutoReview)}, {"颜色", colorDisplay(iss.Color)}, {"实施配置文件", profileDisplay(iss.ProfilePath)}, {"测试配置文件", profileDisplay(iss.TestProfilePath)}, {"规格文件", orNotLoaded(iss.SpecFile)}, {"计划文件", orNotLoaded(iss.PlanFile)}, {"PR 变更摘要", orNotLoaded(iss.PrDiffFile)}, {"合并请求", prDisplay(iss)}, {"分支", orDash(iss.Branch)}, {"工作树", orNotLoaded(iss.WorktreePath)}, } } // padCells right-pads s with spaces to a target display width. func padCells(s string, w int) string { if gap := w - lipgloss.Width(s); gap > 0 { return s + strings.Repeat(" ", gap) } return s } var ( detailBorderColor = lipgloss.Color("12") detailZebraBG = lipgloss.Color("236") ) // detailBody builds the two stacked blocks (header + STATE JSON table) shown for // an issue, sized to the given outer width. func (m Model) detailBody(iss issue.Issue, width int) string { box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(detailBorderColor). Width(width) innerW := width - 2 // border-to-border content width // cell renders one full-width content line with a 1-space left inset, // optionally with a zebra background, so nothing re-wraps inside the box. cell := func(s string, zebra bool) string { st := lipgloss.NewStyle().Width(innerW) if zebra { st = st.Background(detailZebraBG) } return st.Render(" " + s) } hint := lipgloss.NewStyle().Faint(true) // ── header block ── titleStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorFor(iss)) title := titleStyle.Render(fmt.Sprintf("#%d %s", iss.Number, iss.Title)) if iss.Prerequisite > 0 { title += " " + blockedStyle.Render(fmt.Sprintf("等待 #%d 完成", iss.Prerequisite)) } header := lipgloss.JoinVertical(lipgloss.Left, cell(title, false), cell(hint.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建"), false), cell(m.actionBarView(), false), ) headerBox := box.Render(header) // ── STATE JSON table block ── rows := detailRows(iss) labelW := 16 sep := " │ " valueW := innerW - 1 - labelW - lipgloss.Width(sep) // 1 = left inset if valueW < 8 { valueW = 8 } labelStyle := lipgloss.NewStyle().Faint(true) countStr := fmt.Sprintf("%d 项", len(rows)) gap := innerW - 1 - lipgloss.Width("STATE JSON") - lipgloss.Width(countStr) - 1 if gap < 1 { gap = 1 } lines := []string{cell( lipgloss.NewStyle().Bold(true).Render("STATE JSON")+strings.Repeat(" ", gap)+hint.Render(countStr), false, )} for i, r := range rows { line := labelStyle.Render(padCells(r[0], labelW)) + sep + ansi.Truncate(r[1], valueW, "…") lines = append(lines, cell(line, i%2 == 1)) } tableBox := box.Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) return lipgloss.JoinVertical(lipgloss.Left, headerBox, tableBox) } // detailView renders the board above a bottom issue property panel, matching // the VSCode extension's kanban + attributes split. func (m Model) detailView() string { iss, ok := m.currentDetailIssue() if !ok { return m.boardView() } 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 } 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) }