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

379 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"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)
}