379 lines
11 KiB
Go
379 lines
11 KiB
Go
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)
|
||
}
|