diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e03c4b3..d2f76f0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -710,7 +710,7 @@ func (m model) View() string { } } oursPane := oursStyle.Render( - titleStyle.Render(oursTitle) + "\n" + + renderPaneTitle(oursTitle, m.viewportOurs.Width, titleStyle) + "\n" + m.viewportOurs.View(), ) @@ -718,7 +718,7 @@ func (m model) View() string { if allResolved(m.doc, m.manualResolved) { resultStyle = resultResolvedPaneStyle } - resultTitle := resultTitleStyle.Render("RESULT " + statusStyle.Render("("+statusText+")")) + resultTitle := renderResultPaneTitle(statusText, m.viewportResult.Width, resultTitleStyle, statusStyle) resultPane := resultStyle.Render( resultTitle + "\n" + m.viewportResult.View(), @@ -735,7 +735,7 @@ func (m model) View() string { } } theirsPane := theirsStyle.Render( - titleStyle.Render(theirsTitle) + "\n" + + renderPaneTitle(theirsTitle, m.viewportTheirs.Width, titleStyle) + "\n" + m.viewportTheirs.View(), ) @@ -1233,6 +1233,83 @@ func formatLabel(label string) string { return label } +func renderPaneTitle(title string, paneWidth int, style lipgloss.Style) string { + if paneWidth <= 0 { + return "" + } + + frameWidth := style.GetHorizontalFrameSize() + if paneWidth <= frameWidth { + return truncateDisplayWidth(title, paneWidth) + } + + trimmed := truncateDisplayWidth(title, paneWidth-frameWidth) + return style.Render(trimmed) +} + +func renderResultPaneTitle(statusText string, paneWidth int, titleStyle lipgloss.Style, statusStyle lipgloss.Style) string { + const prefix = "RESULT " + statusSegment := "(" + statusText + ")" + rawTitle := prefix + statusSegment + + if paneWidth <= 0 { + return "" + } + + frameWidth := titleStyle.GetHorizontalFrameSize() + if paneWidth <= frameWidth { + return truncateDisplayWidth(rawTitle, paneWidth) + } + + trimmed := truncateDisplayWidth(rawTitle, paneWidth-frameWidth) + if !strings.HasPrefix(trimmed, prefix) { + return titleStyle.Render(trimmed) + } + + trimmedStatus := strings.TrimPrefix(trimmed, prefix) + if trimmedStatus == "" { + return titleStyle.Render(prefix) + } + + return titleStyle.Render(prefix + statusStyle.Render(trimmedStatus)) +} + +func truncateDisplayWidth(value string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if lipgloss.Width(value) <= maxWidth { + return value + } + + const ellipsis = "..." + ellipsisWidth := lipgloss.Width(ellipsis) + if maxWidth <= ellipsisWidth { + return trimDisplayWidth(value, maxWidth) + } + + return trimDisplayWidth(value, maxWidth-ellipsisWidth) + ellipsis +} + +func trimDisplayWidth(value string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + + var b strings.Builder + currentWidth := 0 + for _, r := range value { + runeWidth := lipgloss.Width(string(r)) + if currentWidth+runeWidth > maxWidth { + break + } + b.WriteRune(r) + currentWidth += runeWidth + } + + return b.String() +} + func firstHexRun(label string) (int, int) { start := -1 for i, r := range label { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 3d70f88..38d6027 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/chojs23/ec/internal/cli" "github.com/chojs23/ec/internal/engine" "github.com/chojs23/ec/internal/gitmerge" @@ -412,6 +413,43 @@ func TestFormatLabel(t *testing.T) { } } +func TestRenderPaneTitleFitsPaneWidth(t *testing.T) { + title := "OURS (/var/folders/n5/10r8gvt52mq58dpz62c7_jt00000gn/T/ec-local-766054358)" + paneWidth := 34 + + got := renderPaneTitle(title, paneWidth, titleStyle) + if lipgloss.Width(got) > paneWidth { + t.Fatalf("renderPaneTitle width = %d, want <= %d", lipgloss.Width(got), paneWidth) + } + if !strings.Contains(got, "...") { + t.Fatalf("expected truncated title with ellipsis, got %q", got) + } +} + +func TestRenderPaneTitleHandlesVeryNarrowPane(t *testing.T) { + got := renderPaneTitle("OURS (HEAD)", 1, titleStyle) + if lipgloss.Width(got) > 1 { + t.Fatalf("renderPaneTitle width = %d, want <= 1", lipgloss.Width(got)) + } +} + +func TestRenderResultPaneTitleFitsPaneWidth(t *testing.T) { + got := renderResultPaneTitle("Resolved (manual)", 18, resultTitleStyle, statusResolvedStyle) + if lipgloss.Width(got) > 18 { + t.Fatalf("renderResultPaneTitle width = %d, want <= 18", lipgloss.Width(got)) + } + if !strings.Contains(got, "...") { + t.Fatalf("expected truncated title with ellipsis, got %q", got) + } +} + +func TestRenderResultPaneTitleKeepsStatusWhenWide(t *testing.T) { + got := renderResultPaneTitle("Unresolved", 50, resultTitleStyle, statusUnresolvedStyle) + if !strings.Contains(got, "RESULT (Unresolved)") { + t.Fatalf("expected full result status title, got %q", got) + } +} + func TestFirstHexRun(t *testing.T) { start, end := firstHexRun("x1234567y") if start != 1 || end != 8 { @@ -496,9 +534,9 @@ func TestModelViewReady(t *testing.T) { currentConflict: 0, selectedSide: selectedOurs, manualResolved: map[int][]byte{}, - viewportOurs: viewport.New(10, 5), - viewportResult: viewport.New(10, 5), - viewportTheirs: viewport.New(10, 5), + viewportOurs: viewport.New(40, 5), + viewportResult: viewport.New(40, 5), + viewportTheirs: viewport.New(40, 5), width: 80, height: 20, } @@ -530,9 +568,9 @@ func TestModelViewShowsBranchLabels(t *testing.T) { {OursLabel: "HEAD", TheirsLabel: "feature/add-auth"}, }, manualResolved: map[int][]byte{}, - viewportOurs: viewport.New(10, 5), - viewportResult: viewport.New(10, 5), - viewportTheirs: viewport.New(10, 5), + viewportOurs: viewport.New(40, 5), + viewportResult: viewport.New(40, 5), + viewportTheirs: viewport.New(40, 5), width: 120, height: 20, } @@ -547,6 +585,41 @@ func TestModelViewShowsBranchLabels(t *testing.T) { } } +func TestModelViewTruncatesLongBranchLabels(t *testing.T) { + doc := parseSingleConflictDoc(t) + state, err := engine.NewState(doc) + if err != nil { + t.Fatalf("NewState error = %v", err) + } + longLabel := "/var/folders/n5/10r8gvt52mq58dpz62c7_jt00000gn/T/ec-local-766054358" + m := model{ + ready: true, + opts: cliOptionsWithMergedPath("merged.txt"), + state: state, + doc: doc, + currentConflict: 0, + selectedSide: selectedOurs, + mergedLabels: []conflictLabels{ + {OursLabel: longLabel, TheirsLabel: longLabel}, + }, + manualResolved: map[int][]byte{}, + viewportOurs: viewport.New(10, 5), + viewportResult: viewport.New(10, 5), + viewportTheirs: viewport.New(10, 5), + width: 90, + height: 20, + } + m.updateViewports() + + view := m.View() + if strings.Contains(view, longLabel) { + t.Fatalf("expected long labels to be truncated, got:\n%s", view) + } + if !strings.Contains(view, "...") { + t.Fatalf("expected truncated labels with ellipsis, got:\n%s", view) + } +} + func TestModelViewNoLabelsWithoutMergedLabels(t *testing.T) { doc := parseSingleConflictDoc(t) state, err := engine.NewState(doc)