Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pkg/tui/views/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func (m *ActionsModel) View(width int) string {
line = fmt.Sprintf(" %s %s #%d", runMark(r), truncateStr(r.WorkflowName, width-12), r.RunNumber)
}
if globalIdx == m.Cursor {
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down Expand Up @@ -207,7 +207,7 @@ func (m *ActionsModel) jobsView(width int) string {
globalIdx := m.Offset + i
line := fmt.Sprintf(" %s %s", jobMark(j), truncateStr(j.Name, width-6))
if globalIdx == m.Cursor {
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down
33 changes: 26 additions & 7 deletions pkg/tui/views/branches.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,45 @@ func (m BranchModel) View(width int) string {
for i, br := range visible {
globalIdx := m.Offset + i
marker := " "
name := br.Name
if br.IsHead {
marker = styles.StatusStagedStyle.Render("*")
name = styles.StatusStagedStyle.Render(br.Name)
}

// Self-tracking upstreams (origin/<name>) repeat the branch
// name — show only ahead/behind counts for those.
trackInfo := ""
if br.Upstream != "" {
up := br.Upstream
self := up == "origin/"+br.Name
if self {
up = ""
}
track := ""
if br.Ahead > 0 {
track += fmt.Sprintf(" +%d", br.Ahead)
track += fmt.Sprintf("↑%d", br.Ahead)
}
if br.Behind > 0 {
track += fmt.Sprintf(" -%d", br.Behind)
if track != "" {
track += " "
}
track += fmt.Sprintf("↓%d", br.Behind)
}
if up != "" {
if track != "" {
track = " " + track
}
trackInfo = styles.SubtitleStyle.Render(fmt.Sprintf(" [%s%s]", up, track))
} else if track != "" {
trackInfo = " " + styles.SubtitleStyle.Render(track)
}
trackInfo = styles.SubtitleStyle.Render(fmt.Sprintf(" [%s%s]", br.Upstream, track))
}

// Calculate available width for message: marker (2) + " " + name + trackInfo + " " + msg
markerWidth := lipgloss.Width(marker)
trackWidth := lipgloss.Width(trackInfo)
nameWidth := lipgloss.Width(br.Name)
nameWidth := lipgloss.Width(name)
msgMax := width - markerWidth - 1 - nameWidth - 1 - trackWidth - 1
if msgMax < 0 {
msgMax = 0
Expand All @@ -86,12 +105,12 @@ func (m BranchModel) View(width int) string {
msg = styles.SubtitleStyle.Render(" " + trimmed)
}

line := fmt.Sprintf(" %s %s%s%s", marker, br.Name, trackInfo, msg)
line := fmt.Sprintf(" %s %s%s%s", marker, name, trackInfo, msg)

// Verify line fits panel width — if not, strip msg entirely
if lipgloss.Width(line) > width {
msg = ""
line = fmt.Sprintf(" %s %s%s", marker, br.Name, trackInfo)
line = fmt.Sprintf(" %s %s%s", marker, name, trackInfo)
// If still too wide, truncate name
if lipgloss.Width(line) > width {
availName := width - markerWidth - 1 - trackWidth - 1
Expand All @@ -105,7 +124,7 @@ func (m BranchModel) View(width int) string {

if globalIdx == m.Cursor {
// Replace leading space with ❯ prefix
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/tui/views/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func (m *DashModel) View(width int) string {
}

if globalIdx == m.Cursor {
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down
26 changes: 18 additions & 8 deletions pkg/tui/views/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package views

import (
"fmt"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -125,14 +126,7 @@ func (m LogModel) View(width int) string {
}

if globalIdx == m.Cursor {
// Cursor: ❯ prefix replaces first rune (handles multi-byte tree symbols)
runes := []rune(line)
if len(runes) > 0 {
cursorLine := "❯" + string(runes[1:])
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine))
} else {
b.WriteString(styles.ListItemActiveStyle.Render("❯"))
}
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down Expand Up @@ -235,3 +229,19 @@ func truncateStr(s string, maxLen int) string {
}
return string(runes[:maxLen-1]) + "…"
}

// ansiEscape strips SGR/control sequences for width-safe reuse.
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]")

// cursorLine rebuilds a rendered line for the selected row: ANSI is
// stripped first (never slice styled strings by byte/rune — that
// decapitates escapes into visible "[38;2;…" garbage), the first
// visible rune becomes ❯, and the caller wraps it in the active style.
func cursorLine(line string) string {
plain := ansiEscape.ReplaceAllString(line, "")
runes := []rune(plain)
if len(runes) == 0 {
return "❯"
}
return "❯" + string(runes[1:])
}
35 changes: 35 additions & 0 deletions pkg/tui/views/newviews_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package views

import (
"strings"
"testing"

"github.com/zaidejjo/zgit/pkg/core/models"
Expand Down Expand Up @@ -87,3 +88,37 @@ func TestNotesUnreadFirst(t *testing.T) {
t.Fatalf("mark all failed")
}
}

func TestCursorLineStripsANSI(t *testing.T) {
styled := "\x1b[38;2;166;227;161m◆\x1b[0m 3fec75a msg"
got := cursorLine(styled)
if got != "❯ 3fec75a msg" {
t.Fatalf("cursorLine = %q", got)
}
if strings.Contains(got, "[") && strings.Contains(got, "38;2") {
t.Fatalf("ANSI leaked: %q", got)
}
if cursorLine("") != "❯" {
t.Fatalf("empty should be bare cursor")
}
// Multibyte first rune survives.
if got := cursorLine("◆ abc"); got != "❯ abc" {
t.Fatalf("multibyte = %q", got)
}
}

func TestBranchSelfTrackingShort(t *testing.T) {
m := NewBranchModel()
m.UpdateBranches([]*models.Branch{
{Name: "dev", IsHead: true, Upstream: "origin/dev", LatestMsg: "Merge PR #61"},
{Name: "feat/x", Upstream: "origin/feat/x", LatestMsg: "wip"},
{Name: "other", Upstream: "fork/other", Ahead: 1, LatestMsg: "change"},
})
out := m.View(100)
if strings.Contains(out, "[origin/dev]") || strings.Contains(out, "[origin/feat/x]") {
t.Fatalf("self-tracking upstream should collapse:\n%s", out)
}
if !strings.Contains(out, "[fork/other") {
t.Fatalf("foreign upstream should stay:\n%s", out)
}
}
2 changes: 1 addition & 1 deletion pkg/tui/views/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func (m *NotesModel) View(width int) string {
}

if globalIdx == m.Cursor {
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/tui/views/pr_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func (m *PRListModel) View(width int) string {

if globalIdx == m.Cursor {
// ❯ replaces leading space
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down
6 changes: 3 additions & 3 deletions pkg/tui/views/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func (m StatusModel) View(width int) string {
localIdx := stagedOffset + i
if localIdx == m.Cursor {
// Replace first space with ❯ prefix. First byte is always ASCII space (safe byte slice).
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand All @@ -114,7 +114,7 @@ func (m StatusModel) View(width int) string {
line := fmt.Sprintf(" %s %s", statusMark("unstaged"), formatFilePath(f.Path, width-6))
localIdx := unstagedOffset + i
if localIdx == m.Cursor {
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand All @@ -130,7 +130,7 @@ func (m StatusModel) View(width int) string {
line := fmt.Sprintf(" %s %s", statusMark("untracked"), formatFilePath(f.Path, width-6))
localIdx := untrackedOffset + i
if localIdx == m.Cursor {
b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:]))
b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line)))
} else {
b.WriteString(styles.ListItemStyle.Render(line))
}
Expand Down
Loading