diff --git a/internal/app/issues.go b/internal/app/issues.go index 90920ff..600ffbe 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -49,6 +49,7 @@ func (s *Service) ViewIssue(ctx context.Context, t Target, rkey string) (*ViewRe return &ViewResult{ Rkey: rkey, Title: decoded.Title, + State: found.State, Body: decoded.Body, Author: s.resolveAuthor(ctx, extractDID(found.URI)), CreatedAt: decoded.CreatedAt, diff --git a/internal/app/pulls.go b/internal/app/pulls.go index a3e1e25..dbf51a2 100644 --- a/internal/app/pulls.go +++ b/internal/app/pulls.go @@ -88,6 +88,7 @@ func (s *Service) ViewPull(ctx context.Context, t Target, rkey string) (*ViewRes return &ViewResult{ Rkey: rkey, Title: decoded.Title, + State: found.State, Body: decoded.Body, Author: s.resolveAuthor(ctx, extractDID(found.URI)), CreatedAt: decoded.CreatedAt, diff --git a/internal/app/types.go b/internal/app/types.go index d6ac228..d282b77 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -65,6 +65,7 @@ type StringViewResult struct { type ViewResult struct { Rkey string `json:"rkey"` Title string `json:"title"` + State string `json:"state"` Body string `json:"body,omitempty"` Author Author `json:"author"` CreatedAt string `json:"createdAt"` diff --git a/internal/cli/detail.go b/internal/cli/detail.go index 3306a8b..a6f849f 100644 --- a/internal/cli/detail.go +++ b/internal/cli/detail.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "image/color" "io" "strings" @@ -22,7 +23,7 @@ type detailField struct { func renderDetail(writer io.Writer, fields []detailField, body string) { labelStyle := lipgloss.NewStyle().Width(labelColumnWidth(fields)) if isTerminal(writer) { - labelStyle = labelStyle.Bold(true) + labelStyle = labelStyle.Faint(true) } for _, field := range fields { @@ -37,6 +38,35 @@ func renderDetail(writer io.Writer, fields []detailField, body string) { renderMarkdown(writer, body) } +func formatDetailState(writer io.Writer, state string) string { + if !isTerminal(writer) { + return state + } + + style := lipgloss.NewStyle() + if terminalColor := stateColor(state); terminalColor != nil { + style = style.Foreground(terminalColor) + } else { + style = style.Faint(true) + } + return style.Render(state) +} + +func stateColor(state string) color.Color { + switch strings.ToLower(state) { + case "open": + return lipgloss.Green + case "closed": + return lipgloss.Red + case "merged": + return lipgloss.Magenta + case "draft": + return lipgloss.Yellow + default: + return nil + } +} + // labelColumnWidth returns the width to pad every label to; the +2 accounts // for the trailing colon and one separator space. func labelColumnWidth(fields []detailField) int { diff --git a/internal/cli/issue_view.go b/internal/cli/issue_view.go index fd00607..ee21475 100644 --- a/internal/cli/issue_view.go +++ b/internal/cli/issue_view.go @@ -29,6 +29,7 @@ directory's git origin remote.`, return output(cmd, view, func(view *app.ViewResult) { fields := []detailField{ {"Title", view.Title}, + {"Status", formatDetailState(cmd.OutOrStdout(), view.State)}, {"Author", view.Author.Handle}, {"Created", view.CreatedAt}, } diff --git a/internal/cli/pr_view.go b/internal/cli/pr_view.go index f1c379e..2e82287 100644 --- a/internal/cli/pr_view.go +++ b/internal/cli/pr_view.go @@ -29,6 +29,7 @@ directory's git origin remote.`, return output(cmd, view, func(view *app.ViewResult) { fields := []detailField{ {"Title", view.Title}, + {"Status", formatDetailState(cmd.OutOrStdout(), view.State)}, {"Author", view.Author.Handle}, {"Created", view.CreatedAt}, {"Branch", view.SourceBranch + " → " + view.TargetBranch}, diff --git a/internal/cli/rows.go b/internal/cli/rows.go index 291e942..222b57a 100644 --- a/internal/cli/rows.go +++ b/internal/cli/rows.go @@ -50,8 +50,8 @@ var terminalWidth = func(w io.Writer) int { } // renderTable writes a table of rows under header to writer. When writer -// is a terminal the table is drawn with a border and a bold header; when -// piped or redirected it falls back to a plain tab-aligned table. +// is a terminal the table uses a muted header and separator; when piped or +// redirected it falls back to a plain tab-aligned table. // emptyMessage is shown when rows is empty. func renderTable(writer io.Writer, header []string, rows [][]string, emptyMessage string) { if len(rows) == 0 { @@ -60,7 +60,7 @@ func renderTable(writer io.Writer, header []string, rows [][]string, emptyMessag } if isTerminal(writer) { - renderBorderedTable(writer, header, rows) + renderTerminalTable(writer, header, rows) return } @@ -72,39 +72,71 @@ func renderTable(writer io.Writer, header []string, rows [][]string, emptyMessag tw.Flush() } -// renderBorderedTable renders a bordered, styled table for interactive -// terminals. Cells truncate (with "…") rather than wrap; on narrow -// terminals the table is shrunk to fit the terminal width. -func renderBorderedTable(writer io.Writer, header []string, rows [][]string) { - rendered := newBorderedTable(header, rows).Render() +// renderTerminalTable renders a styled table for interactive terminals. Cells +// truncate (with "…") rather than wrap; on narrow terminals the table is +// shrunk to fit the terminal width. +func renderTerminalTable(writer io.Writer, header []string, rows [][]string) { + rendered := newTerminalTable(header, rows).Render() // The top border spans the full table width, so it tells us whether // the natural width fits the terminal. If it overflows, re-render // with a width cap so lipgloss shrinks columns instead of spilling. if w := terminalWidth(writer); w > 0 { if first, _, _ := strings.Cut(rendered, "\n"); lipgloss.Width(first) > w { - rendered = newBorderedTable(header, rows).Width(w).Render() + rendered = newTerminalTable(header, rows).Width(w).Render() } } fmt.Fprintln(writer, rendered) } -func newBorderedTable(header []string, rows [][]string) *table.Table { +func newTerminalTable(header []string, rows [][]string) *table.Table { return table.New(). Headers(header...). Rows(rows...). Border(lipgloss.NormalBorder()). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false). + BorderColumn(false). + BorderRow(false). + BorderHeader(true). + BorderStyle(lipgloss.NewStyle().Faint(true)). Wrap(false). - StyleFunc(func(row, _ int) lipgloss.Style { + StyleFunc(func(row, column int) lipgloss.Style { s := lipgloss.NewStyle().Padding(0, 1) if row == table.HeaderRow { - s = s.Bold(true) + return s.Bold(true).Faint(true) } - return s + return styleTableCell(s, header, rows, row, column) }) } +func styleTableCell(style lipgloss.Style, header []string, rows [][]string, row, column int) lipgloss.Style { + if row < 0 || row >= len(rows) || column < 0 || column >= len(header) || column >= len(rows[row]) { + return style + } + + columnName := strings.ToLower(header[column]) + value := strings.ToLower(rows[row][column]) + switch columnName { + case "active": + if rows[row][column] == "✓" { + return style.Bold(true).Foreground(lipgloss.Green) + } + case "state": + if terminalColor := stateColor(value); terminalColor != nil { + return style.Foreground(terminalColor) + } + case "title", "account": + return style.Bold(true) + case "did", "rkey", "method", "updated": + return style.Faint(true) + } + return style +} + // renderList renders issue or pull-request items as a table. func renderList(writer io.Writer, items []app.Item, emptyMessage string) { rows := make([][]string, 0, len(items)) diff --git a/internal/cli/rows_test.go b/internal/cli/rows_test.go index feda58e..9f5dfa4 100644 --- a/internal/cli/rows_test.go +++ b/internal/cli/rows_test.go @@ -72,7 +72,7 @@ func TestRenderTable(t *testing.T) { } }) - t.Run("tty is bordered", func(t *testing.T) { + t.Run("tty has a header separator", func(t *testing.T) { isTerminal = func(io.Writer) bool { return true } var buf bytes.Buffer renderTable(&buf, header, rows, "") @@ -83,8 +83,11 @@ func TestRenderTable(t *testing.T) { t.Errorf("output missing %q:\n%s", want, out) } } - if !strings.ContainsAny(out, "│─") { - t.Errorf("tty output should have border glyphs:\n%s", out) + if !strings.Contains(out, "─") { + t.Errorf("tty output should have a header separator:\n%s", out) + } + if strings.ContainsAny(out, "│╭╮╰╯") { + t.Errorf("tty output should not have an outer border:\n%s", out) } }) @@ -105,8 +108,8 @@ func TestRenderTable(t *testing.T) { if strings.Contains(out, "…") { t.Errorf("natural render should not truncate:\n%s", out) } - if !strings.ContainsAny(out, "│─") { - t.Errorf("tty output should have border glyphs:\n%s", out) + if strings.ContainsAny(out, "│╭╮╰╯") { + t.Errorf("tty output should not have an outer border:\n%s", out) } })