diff --git a/pkg/tui/views/actions.go b/pkg/tui/views/actions.go index dc4f229..10df0b3 100644 --- a/pkg/tui/views/actions.go +++ b/pkg/tui/views/actions.go @@ -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)) } @@ -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)) } diff --git a/pkg/tui/views/branches.go b/pkg/tui/views/branches.go index df0530d..cb6c6b2 100644 --- a/pkg/tui/views/branches.go +++ b/pkg/tui/views/branches.go @@ -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/) 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 @@ -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 @@ -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)) } diff --git a/pkg/tui/views/dashboard.go b/pkg/tui/views/dashboard.go index db7ccba..2448c44 100644 --- a/pkg/tui/views/dashboard.go +++ b/pkg/tui/views/dashboard.go @@ -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)) } diff --git a/pkg/tui/views/log.go b/pkg/tui/views/log.go index 3d258eb..087b959 100644 --- a/pkg/tui/views/log.go +++ b/pkg/tui/views/log.go @@ -2,6 +2,7 @@ package views import ( "fmt" + "regexp" "strings" "time" @@ -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)) } @@ -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:]) +} diff --git a/pkg/tui/views/newviews_test.go b/pkg/tui/views/newviews_test.go index f534995..924f4a6 100644 --- a/pkg/tui/views/newviews_test.go +++ b/pkg/tui/views/newviews_test.go @@ -1,6 +1,7 @@ package views import ( + "strings" "testing" "github.com/zaidejjo/zgit/pkg/core/models" @@ -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) + } +} diff --git a/pkg/tui/views/notifications.go b/pkg/tui/views/notifications.go index a98057d..0bcdaf4 100644 --- a/pkg/tui/views/notifications.go +++ b/pkg/tui/views/notifications.go @@ -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)) } diff --git a/pkg/tui/views/pr_list.go b/pkg/tui/views/pr_list.go index 53dc327..681cfd1 100644 --- a/pkg/tui/views/pr_list.go +++ b/pkg/tui/views/pr_list.go @@ -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)) } diff --git a/pkg/tui/views/status.go b/pkg/tui/views/status.go index aa52b74..2dbfd73 100644 --- a/pkg/tui/views/status.go +++ b/pkg/tui/views/status.go @@ -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)) } @@ -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)) } @@ -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)) }