From 943c3b54a089cebc7956996943f060ce4074ab8d Mon Sep 17 00:00:00 2001 From: shatrughan mishra Date: Wed, 18 Feb 2026 21:29:22 +0530 Subject: [PATCH 1/2] feat: add line number keybind and center vertical separator Signed-off-by: shatrughan mishra --- internal/tui/keys.go | 11 +- internal/tui/model.go | 51 ++++++--- internal/tui/update.go | 7 +- internal/tui/view.go | 255 +++++++++++++++++++++++++---------------- 4 files changed, 210 insertions(+), 114 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2fac375..565d8aa 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -12,6 +12,9 @@ type KeyMap struct { // keybindings for changing theme SwitchTheme key.Binding + // keybindings for diff view + ToggleDiffView key.Binding + // keybindings for navigation FocusNext key.Binding FocusPrev key.Binding @@ -89,7 +92,7 @@ func (k KeyMap) FullHelp() []HelpSection { }, { Title: "Misc", - Bindings: []key.Binding{k.SwitchTheme, k.ToggleHelp, k.Escape, k.Quit}, + Bindings: []key.Binding{k.SwitchTheme, k.ToggleDiffView, k.ToggleHelp, k.Escape, k.Quit}, }, } } @@ -151,6 +154,12 @@ func DefaultKeyMap() KeyMap { key.WithHelp("", "switch theme"), ), + // diff view + ToggleDiffView: key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("", "toggle diff view"), + ), + // navigation FocusNext: key.NewBinding( key.WithKeys("tab"), diff --git a/internal/tui/model.go b/internal/tui/model.go index 5f176f5..aa08f1c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -49,6 +49,8 @@ type Model struct { confirmCallback func(bool) tea.Cmd // New fields for command history CommandHistory []string + // Diff view mode: nil = auto (respects threshold), true = split, false = unified + forcedDiffViewMode *bool } // initialModel creates the initial state of the application. @@ -99,22 +101,23 @@ func initialModel() Model { historyVP.SetContent("Command history will appear here...") return Model{ - theme: Themes[selectedThemeName], - themeNames: themeNames, - themeIndex: indexOf(themeNames, selectedThemeName), - focusedPanel: StatusPanel, - activeSourcePanel: StatusPanel, - help: help.New(), - helpViewport: viewport.New(0, 0), - showHelp: false, - git: gc, - repoName: repoName, - branchName: branchName, - panels: panels, - mode: modeNormal, - textInput: ti, - descriptionInput: ta, - CommandHistory: []string{}, + theme: Themes[selectedThemeName], + themeNames: themeNames, + themeIndex: indexOf(themeNames, selectedThemeName), + focusedPanel: StatusPanel, + activeSourcePanel: StatusPanel, + help: help.New(), + helpViewport: viewport.New(0, 0), + showHelp: false, + git: gc, + repoName: repoName, + branchName: branchName, + panels: panels, + mode: modeNormal, + textInput: ti, + descriptionInput: ta, + CommandHistory: []string{}, + forcedDiffViewMode: nil, } } @@ -147,6 +150,22 @@ func (m *Model) nextTheme() { m.theme = Themes[m.themeNames[m.themeIndex]] } +// toggleDiffView cycles through diff view modes: auto -> split -> unified -> auto +func (m *Model) toggleDiffView() { + if m.forcedDiffViewMode == nil { + // Currently auto - switch to split + trueVal := true + m.forcedDiffViewMode = &trueVal + } else if *m.forcedDiffViewMode { + // Currently split - switch to unified + falseVal := false + m.forcedDiffViewMode = &falseVal + } else { + // Currently unified - switch to auto + m.forcedDiffViewMode = nil + } +} + // panelShortHelp returns a slice of key.Binding for the focused Panel. func (m *Model) panelShortHelp() []key.Binding { switch m.focusedPanel { diff --git a/internal/tui/update.go b/internal/tui/update.go index 891daa8..7f1b66f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -220,6 +220,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.SwitchTheme): m.nextTheme() + case key.Matches(msg, keys.ToggleDiffView): + m.toggleDiffView() + cmd = m.updateMainPanel() + cmds = append(cmds, cmd) + case key.Matches(msg, keys.FocusNext), key.Matches(msg, keys.FocusPrev), key.Matches(msg, keys.FocusZero), key.Matches(msg, keys.FocusOne), key.Matches(msg, keys.FocusTwo), key.Matches(msg, keys.FocusThree), @@ -502,7 +507,7 @@ func (m *Model) updateMainPanel() tea.Cmd { // Apply adaptive visual styling: split-view for wide terminals, unified for narrow ones // Calculate right panel width (approximately 65% of total width minus borders) rightPanelWidth := int(float64(m.width)*(1-leftPanelWidthRatio)) - borderWidth - 2 - content = renderAdaptiveDiffView(content, rightPanelWidth, m.theme) + content = renderAdaptiveDiffView(content, rightPanelWidth, m.theme, m.forcedDiffViewMode) return mainContentUpdatedMsg{content: content} } } diff --git a/internal/tui/view.go b/internal/tui/view.go index b723e31..d72f5d0 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "regexp" + "strconv" "strings" "github.com/charmbracelet/bubbles/key" @@ -19,6 +20,12 @@ func stripAnsi(str string) string { return ansiRegex.ReplaceAllString(str, "") } +// parseIntFromString converts a string to an integer, returning an error if it fails. +func parseIntFromString(s string) (int, error) { + n, err := strconv.Atoi(strings.TrimSpace(s)) + return n, err +} + // diffLineType represents the type of a diff line. type diffLineType int @@ -33,16 +40,19 @@ const ( // diffRow represents a single row in the structured diff. type diffRow struct { - lineType diffLineType - oldLine string - newLine string - rawLine string // Original line for fallback + lineType diffLineType + oldLine string + newLine string + rawLine string // Original line for fallback + oldLineNum int // Line number in old file (0 if not applicable) + newLineNum int // Line number in new file (0 if not applicable) } // parseDiffStructure transforms a unified diff into structured rows suitable for split-view rendering. func parseDiffStructure(content string) []diffRow { lines := strings.Split(content, "\n") var rows []diffRow + var oldLineNum, newLineNum int for _, line := range lines { if len(line) == 0 { @@ -77,9 +87,25 @@ func parseDiffStructure(content string) []diffRow { continue } - // Hunk headers + // Hunk headers - extract the starting line numbers if strings.HasPrefix(cleanedLine, "@@") { rows = append(rows, diffRow{lineType: lineTypeHunkHeader, rawLine: line}) + // Parse hunk header format: @@ -oldStart,oldCount +newStart,newCount @@ + hunkParts := strings.Fields(cleanedLine) + if len(hunkParts) >= 2 { + oldPart := strings.TrimPrefix(hunkParts[1], "-") + oldStart := strings.Split(oldPart, ",")[0] + if num, err := parseIntFromString(oldStart); err == nil { + oldLineNum = num + } + if len(hunkParts) >= 3 { + newPart := strings.TrimPrefix(hunkParts[2], "+") + newStart := strings.Split(newPart, ",")[0] + if num, err := parseIntFromString(newStart); err == nil { + newLineNum = num + } + } + } continue } @@ -89,123 +115,190 @@ func parseDiffStructure(content string) []diffRow { continue } - // Added lines (but not +++ headers) + // Added lines if firstChar == '+' { - // Store content without the "+" prefix for rendering contentWithoutPrefix := cleanedLine[1:] - rows = append(rows, diffRow{lineType: lineTypeAdded, newLine: contentWithoutPrefix, rawLine: line}) + row := diffRow{lineType: lineTypeAdded, newLine: contentWithoutPrefix, rawLine: line, newLineNum: newLineNum} + rows = append(rows, row) + newLineNum++ continue } - // Removed lines (but not --- headers) + // Removed lines if firstChar == '-' { - // Store content without the "-" prefix for rendering contentWithoutPrefix := cleanedLine[1:] - rows = append(rows, diffRow{lineType: lineTypeRemoved, oldLine: contentWithoutPrefix, rawLine: line}) + row := diffRow{lineType: lineTypeRemoved, oldLine: contentWithoutPrefix, rawLine: line, oldLineNum: oldLineNum} + rows = append(rows, row) + oldLineNum++ continue } // Context lines (start with space) if firstChar == ' ' { - // Store content without the space prefix contentWithoutSpace := cleanedLine[1:] - rows = append(rows, diffRow{lineType: lineTypeContext, oldLine: contentWithoutSpace, newLine: contentWithoutSpace, rawLine: line}) + row := diffRow{lineType: lineTypeContext, oldLine: contentWithoutSpace, newLine: contentWithoutSpace, rawLine: line, oldLineNum: oldLineNum, newLineNum: newLineNum} + rows = append(rows, row) + oldLineNum++ + newLineNum++ continue } - // Fallback for any other lines + // Fallback rows = append(rows, diffRow{lineType: lineTypeContext, oldLine: cleanedLine, newLine: cleanedLine, rawLine: line}) } return rows } -// renderSplitDiffView renders a GitHub-style split-view diff. -// columnWidth should be roughly half the viewport width minus some padding. +// calculateMaxLineNumber returns the maximum line number in the diff. +func calculateMaxLineNumber(rows []diffRow) int { + max := 0 + for _, row := range rows { + if row.oldLineNum > max { + max = row.oldLineNum + } + if row.newLineNum > max { + max = row.newLineNum + } + } + return max +} + +// renderSplitDiffView renders a GitHub-style split-view diff with line numbers and a separator. +// +// FIX SUMMARY (gap between panels): +// 1. All content styles use NO padding (Padding(0,0)) — padding was causing extra width that +// pushed the separator away. Content is space-padded manually via Width() instead. +// 2. The separator style has NO margin, NO padding, and is exactly Width(1). +// 3. Each half-panel is composed as a fixed-width block with Width(columnWidth) so that +// lipgloss.JoinHorizontal sees exact widths and produces zero gap. +// 4. lineNumStyle, contentStyle, and the half-panel wrapper all use explicit Width so the +// total always equals columnWidth on each side. func renderSplitDiffView(rows []diffRow, columnWidth int, theme Theme) string { if columnWidth < 20 { - // Column too narrow for split view return "" } - // Create themed styles - headerStyle := lipgloss.NewStyle().Bold(true) + // Calculate line number width (minimum 4 chars). + maxLineNum := calculateMaxLineNumber(rows) + lineNumWidth := 4 + if maxLineNum > 0 { + w := len(fmt.Sprintf("%d", maxLineNum)) + 1 + if w > lineNumWidth { + lineNumWidth = w + } + } - // Use theme's added/removed colors for backgrounds + // Layout per half: [lineNum(lineNumWidth)] [content(contentColWidth)] + // Total = lineNumWidth + contentColWidth = columnWidth (no separator chars inside a half) + contentColWidth := columnWidth - lineNumWidth + + // Separator: exactly 1 character wide, same color as active border, zero padding/margin. + separatorColor := theme.ActiveBorder.Style.GetBorderLeftForeground() + separatorStyle := lipgloss.NewStyle(). + Width(1). + Foreground(separatorColor) + sep := separatorStyle.Render("│") + + // Line number style — right-aligned, dimmed, NO extra padding. + lineNumStyle := lipgloss.NewStyle(). + Width(lineNumWidth). + Align(lipgloss.Right). + Foreground(lipgloss.Color("8")) + + // renderLineNum returns a formatted, fixed-width line number string. + renderLineNum := func(n int) string { + if n > 0 { + return lineNumStyle.Render(fmt.Sprintf("%d", n)) + } + return lineNumStyle.Render("") + } + + // Content styles: NO padding, fixed width so the half-panel total is exact. + // Background color is applied only; width controls the column, not padding. addedStyle := theme.GitStaged. - Width(columnWidth). - Padding(0, 1) + Width(contentColWidth). + MaxWidth(contentColWidth) removedStyle := theme.GitUnstaged. - Width(columnWidth). - Padding(0, 1) + Width(contentColWidth). + MaxWidth(contentColWidth) contextStyle := lipgloss.NewStyle(). - Width(columnWidth). - Padding(0, 1) + Width(contentColWidth). + MaxWidth(contentColWidth) emptyStyle := lipgloss.NewStyle(). + Width(contentColWidth). + MaxWidth(contentColWidth) + + headerStyle := lipgloss.NewStyle(). + Bold(true). Width(columnWidth). - Padding(0, 1) + MaxWidth(columnWidth) + + // buildHalf assembles [lineNum][content] into a fixed-width columnWidth string. + buildHalf := func(lineNum string, content string) string { + return lipgloss.JoinHorizontal(lipgloss.Top, lineNum, content) + } var renderedRows []string for _, row := range rows { - var left, right string - switch row.lineType { - case lineTypeFileHeader: - // File headers span full width - fullLine := headerStyle.Width(columnWidth * 2).Render(row.rawLine) - renderedRows = append(renderedRows, fullLine) - case lineTypeHunkHeader: - // Hunk headers span full width - fullLine := headerStyle.Width(columnWidth * 2).Render(row.rawLine) - renderedRows = append(renderedRows, fullLine) + case lineTypeFileHeader, lineTypeHunkHeader: + // Span the full width (both halves + separator). + // Left half holds the header text; right half is blank. + left := headerStyle.Render(stripAnsi(row.rawLine)) + right := lipgloss.NewStyle().Width(columnWidth).Render("") + combined := lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right) + renderedRows = append(renderedRows, combined) case lineTypeRemoved: - // Removed line on left (oldLine is already without "-" prefix), empty on right - left = removedStyle.Render(row.oldLine) - right = emptyStyle.Render("") - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, right)) + left := buildHalf(renderLineNum(row.oldLineNum), removedStyle.Render(row.oldLine)) + right := buildHalf(renderLineNum(0), emptyStyle.Render("")) + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) case lineTypeAdded: - // Empty on left, added line on right (newLine is already without "+" prefix) - left = emptyStyle.Render("") - right = addedStyle.Render(row.newLine) - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, right)) + left := buildHalf(renderLineNum(0), emptyStyle.Render("")) + right := buildHalf(renderLineNum(row.newLineNum), addedStyle.Render(row.newLine)) + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) case lineTypeContext: - // Context lines on both sides (oldLine and newLine are already without space prefix and identical) - left = contextStyle.Render(row.oldLine) - right = contextStyle.Render(row.newLine) - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, right)) + left := buildHalf(renderLineNum(row.oldLineNum), contextStyle.Render(row.oldLine)) + right := buildHalf(renderLineNum(row.newLineNum), contextStyle.Render(row.newLine)) + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) default: - // Fallback: show on both sides - left = contextStyle.Render(row.oldLine) - right = contextStyle.Render(row.newLine) - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, right)) + left := buildHalf(renderLineNum(row.oldLineNum), contextStyle.Render(row.oldLine)) + right := buildHalf(renderLineNum(row.newLineNum), contextStyle.Render(row.newLine)) + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) } } return strings.Join(renderedRows, "\n") } -// renderAdaptiveDiffView returns the appropriately formatted diff based on viewport width. -// If width >= 80, uses split-view; otherwise falls back to unified diff. -func renderAdaptiveDiffView(content string, width int, theme Theme) string { - // Quick check: if content doesn't look like a diff, return as-is +// renderAdaptiveDiffView returns the appropriately formatted diff based on viewport width and view mode preference. +func renderAdaptiveDiffView(content string, width int, theme Theme, forcedViewMode *bool) string { if !strings.Contains(content, "diff --git") && !strings.Contains(content, "@@") { return content } - // Threshold for split-view: 80 chars available const splitViewThreshold = 80 - if width >= splitViewThreshold && width > 60 { - // Use split-view mode + useSplitView := false + if forcedViewMode != nil { + if *forcedViewMode { + useSplitView = width >= 40 + } + } else { + useSplitView = width >= splitViewThreshold && width > 60 + } + + if useSplitView && width > 40 { + // columnWidth = (total - 1 separator) / 2 columnWidth := (width - 1) / 2 rows := parseDiffStructure(content) splitView := renderSplitDiffView(rows, columnWidth, theme) @@ -214,31 +307,20 @@ func renderAdaptiveDiffView(content string, width int, theme Theme) string { } } - // Fallback to unified diff styling return styleDiffContent(content, theme) } // styleDiffContent applies visual highlighting to diff lines for better readability. -// It detects and styles: -// - Diff headers (diff --git, index, ---, +++, @@) with bold magenta -// - Added lines (+) with green -// - Removed lines (-) with red -// - Context lines with normal or dimmed styling -// It also adds visual separation before hunk markers for improved scanability. func styleDiffContent(content string, theme Theme) string { - // Quick check: if content doesn't look like a diff, return as-is if !strings.Contains(content, "diff --git") && !strings.Contains(content, "@@") { return content } lines := strings.Split(content, "\n") - // Create styles for different diff elements headerStyle := lipgloss.NewStyle().Bold(true) - - // Use theme colors for added/removed lines (aligns with git status colors) - addedStyle := theme.GitStaged // Green - removedStyle := theme.GitUnstaged // Red + addedStyle := theme.GitStaged + removedStyle := theme.GitUnstaged var result []string for i, line := range lines { @@ -247,7 +329,6 @@ func styleDiffContent(content string, theme Theme) string { continue } - // Strip ANSI codes before checking prefixes cleanedLine := stripAnsi(line) if len(cleanedLine) == 0 { @@ -257,12 +338,10 @@ func styleDiffContent(content string, theme Theme) string { firstChar := cleanedLine[0] - // Add spacing before hunk markers (visual separation of hunks) if strings.HasPrefix(cleanedLine, "@@") && i > 0 && result[len(result)-1] != "" { - result = append(result, "") // Add blank line for visual separation + result = append(result, "") } - // Handle diff headers if strings.HasPrefix(cleanedLine, "diff --git") || strings.HasPrefix(cleanedLine, "index ") || strings.HasPrefix(cleanedLine, "---") || @@ -270,16 +349,12 @@ func styleDiffContent(content string, theme Theme) string { strings.HasPrefix(cleanedLine, "@@") { result = append(result, headerStyle.Render(line)) } else if firstChar == '+' && !strings.HasPrefix(cleanedLine, "+++") { - // Added line: apply green styling result = append(result, addedStyle.Render(line)) } else if firstChar == '-' && !strings.HasPrefix(cleanedLine, "---") { - // Removed line: apply red styling result = append(result, removedStyle.Render(line)) } else if firstChar == '\\' { - // "\ No newline at end of file" marker - treat as metadata result = append(result, headerStyle.Render(line)) } else { - // Context line (starts with space) - keep as-is result = append(result, line) } } @@ -289,7 +364,6 @@ func styleDiffContent(content string, theme Theme) string { // View is the main render function for the application. func (m Model) View() string { - var finalView string if m.showHelp { finalView = m.renderHelpView() @@ -297,7 +371,6 @@ func (m Model) View() string { finalView = m.renderMainView() } - // If not in normal mode, render a pop-up on top. if m.mode != modeNormal { var popup string switch m.mode { @@ -386,7 +459,7 @@ func (m Model) renderMainView() string { helpBar := m.renderHelpBar() finalView := lipgloss.JoinVertical(lipgloss.Bottom, content, helpBar) - zone.Scan(finalView) // Scan for mouse zones. + zone.Scan(finalView) return finalView } @@ -414,7 +487,6 @@ func (m Model) renderPanel(title string, width, height int, panel Panel) string formattedTitle := fmt.Sprintf("[%d] %s", int(panel), title) p := m.panels[panel] - // Add item count to titles of selectable panels. if panel == FilesPanel || panel == BranchesPanel || panel == CommitsPanel || panel == StashPanel { if len(p.lines) > 0 { formattedTitle = fmt.Sprintf("[%d] %s (%d/%d)", int(panel), title, p.cursor+1, len(p.lines)) @@ -424,7 +496,6 @@ func (m Model) renderPanel(title string, width, height int, panel Panel) string content := p.content contentWidth := width - borderWidth - // For selectable panels, render each line individually. if panel == FilesPanel || panel == BranchesPanel || panel == CommitsPanel || panel == StashPanel { var builder strings.Builder for i, line := range p.lines { @@ -433,9 +504,7 @@ func (m Model) renderPanel(title string, width, height int, panel Panel) string if i == p.cursor && isFocused { var cleanLine string - // For the selected line, strip any existing ANSI codes before applying selection style. if panel == FilesPanel { - // For files panel, don't show the hidden path in the selection. parts := strings.Split(line, "\t") if len(parts) >= 3 { cleanLine = fmt.Sprintf("%s %s %s", parts[0], parts[1], parts[2]) @@ -446,7 +515,7 @@ func (m Model) renderPanel(title string, width, height int, panel Panel) string cleanLine = stripAnsi(line) } - cleanLine = strings.ReplaceAll(cleanLine, "\t", " ") // Also replace tabs + cleanLine = strings.ReplaceAll(cleanLine, "\t", " ") selectionStyle := m.theme.SelectedLine.Width(contentWidth) finalLine = selectionStyle.Render(cleanLine) } else { @@ -463,7 +532,6 @@ func (m Model) renderPanel(title string, width, height int, panel Panel) string isScrollable := !p.viewport.AtTop() || !p.viewport.AtBottom() showScrollbar := isScrollable - // Conditionally hide scrollbar for certain panels when not focused. if panel == StashPanel || panel == SecondaryPanel { showScrollbar = isScrollable && isFocused } @@ -616,16 +684,11 @@ func styleUnselectedLine(line string, panel Panel, theme Theme) string { case CommitsPanel: parts := strings.SplitN(line, "\t", 4) if len(parts) != 4 { - // This is a graph-only line, already colored by git. - // We just replace the placeholder node with a styled one. return strings.ReplaceAll(line, "○", theme.GraphNode.Render("○")) } graph, sha, author, subject := parts[0], parts[1], parts[2], parts[3] - // The graph string is already colored by git, but we style the node. styledGraph := strings.ReplaceAll(graph, "○", theme.GraphNode.Render("○")) - - // Apply our theme's styles to the other parts. styledSHA := theme.CommitSHA.Render(sha) styledAuthor := theme.CommitAuthor.Render(author) if strings.HasPrefix(strings.ToLower(subject), "merge") { @@ -671,4 +734,4 @@ func styleChar(char byte, style lipgloss.Style) string { return " " } return style.Render(string(char)) -} +} \ No newline at end of file From 5a7551bebd03622a9d818993224efcc8fa4939ac Mon Sep 17 00:00:00 2001 From: shatrughan mishra Date: Thu, 19 Feb 2026 18:40:05 +0530 Subject: [PATCH 2/2] fix: improve split layout, styling consistency, and toggle behavior Signed-off-by: shatrughan mishra --- internal/tui/model.go | 34 +++++-- internal/tui/view.go | 220 +++++++++++++++++++++++++++++++++++------- 2 files changed, 213 insertions(+), 41 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index aa08f1c..a02ff59 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -150,18 +150,40 @@ func (m *Model) nextTheme() { m.theme = Themes[m.themeNames[m.themeIndex]] } -// toggleDiffView cycles through diff view modes: auto -> split -> unified -> auto +// toggleDiffView cycles through diff view modes with behavior that matches the +// currently visible layout: +// - If we're in auto mode, pressing the key switches to the *opposite* of what +// auto currently resolves to (so from an auto-split view you go directly to +// forced unified, and from auto-unified you go to forced split). +// - If we're already in a forced mode, we keep the original cycle: +// split -> unified -> auto. func (m *Model) toggleDiffView() { + const splitViewThreshold = 80 + if m.forcedDiffViewMode == nil { - // Currently auto - switch to split - trueVal := true - m.forcedDiffViewMode = &trueVal + // Auto mode: determine what the main panel would currently do, + // then toggle to the opposite. + + // This mirrors the width calculation used in updateMainPanel for the + // right-hand (main) panel content. + rightPanelWidth := int(float64(m.width)*(1-leftPanelWidthRatio)) - borderWidth - 2 + useSplitAuto := rightPanelWidth >= splitViewThreshold && rightPanelWidth > 60 + + if useSplitAuto { + // Auto would render split; user expects a single press to go to unified. + falseVal := false + m.forcedDiffViewMode = &falseVal + } else { + // Auto would render unified; user expects a single press to go to split. + trueVal := true + m.forcedDiffViewMode = &trueVal + } } else if *m.forcedDiffViewMode { - // Currently split - switch to unified + // Currently forced split - switch to forced unified. falseVal := false m.forcedDiffViewMode = &falseVal } else { - // Currently unified - switch to auto + // Currently forced unified - switch back to auto. m.forcedDiffViewMode = nil } } diff --git a/internal/tui/view.go b/internal/tui/view.go index d72f5d0..4b610f3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -165,15 +165,7 @@ func calculateMaxLineNumber(rows []diffRow) int { } // renderSplitDiffView renders a GitHub-style split-view diff with line numbers and a separator. -// -// FIX SUMMARY (gap between panels): -// 1. All content styles use NO padding (Padding(0,0)) — padding was causing extra width that -// pushed the separator away. Content is space-padded manually via Width() instead. -// 2. The separator style has NO margin, NO padding, and is exactly Width(1). -// 3. Each half-panel is composed as a fixed-width block with Width(columnWidth) so that -// lipgloss.JoinHorizontal sees exact widths and produces zero gap. -// 4. lineNumStyle, contentStyle, and the half-panel wrapper all use explicit Width so the -// total always equals columnWidth on each side. + func renderSplitDiffView(rows []diffRow, columnWidth int, theme Theme) string { if columnWidth < 20 { return "" @@ -189,12 +181,13 @@ func renderSplitDiffView(rows []diffRow, columnWidth int, theme Theme) string { } } - // Layout per half: [lineNum(lineNumWidth)] [content(contentColWidth)] - // Total = lineNumWidth + contentColWidth = columnWidth (no separator chars inside a half) - contentColWidth := columnWidth - lineNumWidth + // Layout per half: [lineNum(lineNumWidth)] [space(1)] [content(contentColWidth)] + // Total = lineNumWidth + 1 + contentColWidth = columnWidth + // Reserve 1 char for spacing between line number and content + contentColWidth := columnWidth - lineNumWidth - 1 - // Separator: exactly 1 character wide, same color as active border, zero padding/margin. - separatorColor := theme.ActiveBorder.Style.GetBorderLeftForeground() + // Separator: exactly 1 character wide, same color as inactive border (matches panel borders), zero padding/margin. + separatorColor := theme.InactiveBorder.Style.GetForeground() separatorStyle := lipgloss.NewStyle(). Width(1). Foreground(separatorColor) @@ -214,7 +207,87 @@ func renderSplitDiffView(rows []diffRow, columnWidth int, theme Theme) string { return lineNumStyle.Render("") } - // Content styles: NO padding, fixed width so the half-panel total is exact. + // Helper function to wrap text to fit within contentColWidth. + // Tries to wrap on word boundaries; only splits inside a word when it alone + // is longer than the available width. + wrapText := func(text string, width int) []string { + if width <= 0 { + return []string{""} + } + + // We already store diff content without ANSI codes in diffRow, but strip + // again defensively in case future changes introduce styling here. + cleaned := stripAnsi(text) + runes := []rune(cleaned) + if len(runes) <= width { + return []string{cleaned} + } + + words := strings.Fields(cleaned) + if len(words) == 0 { + return []string{""} + } + + var ( + lines []string + currentLine []rune + lineLen int + ) + + flushLine := func() { + if len(currentLine) > 0 { + lines = append(lines, string(currentLine)) + currentLine = currentLine[:0] + lineLen = 0 + } + } + + for _, w := range words { + wordRunes := []rune(w) + wordLen := len(wordRunes) + + // If the word itself is longer than the width, we need to hard-wrap it. + if wordLen > width { + // First, flush any existing content on the current line. + flushLine() + + for start := 0; start < wordLen; start += width { + end := start + width + if end > wordLen { + end = wordLen + } + lines = append(lines, string(wordRunes[start:end])) + } + continue + } + + // If this word (plus a space when needed) doesn't fit on the current line, + // start a new line. + additional := wordLen + if lineLen > 0 { + additional++ // space + } + if lineLen+additional > width { + flushLine() + } + + if lineLen > 0 { + currentLine = append(currentLine, ' ') + lineLen++ + } + currentLine = append(currentLine, wordRunes...) + lineLen += wordLen + } + + flushLine() + + if len(lines) == 0 { + return []string{""} + } + return lines + } + + // Content styles: NO padding, fixed width with wrapping so the half-panel total is exact. // Background color is applied only; width controls the column, not padding. addedStyle := theme.GitStaged. Width(contentColWidth). @@ -237,9 +310,11 @@ func renderSplitDiffView(rows []diffRow, columnWidth int, theme Theme) string { Width(columnWidth). MaxWidth(columnWidth) - // buildHalf assembles [lineNum][content] into a fixed-width columnWidth string. + // buildHalf assembles [lineNum][space][content] into a fixed-width columnWidth string. buildHalf := func(lineNum string, content string) string { - return lipgloss.JoinHorizontal(lipgloss.Top, lineNum, content) + // Add a space between line number and content + spacer := " " + return lipgloss.JoinHorizontal(lipgloss.Top, lineNum, spacer, content) } var renderedRows []string @@ -252,28 +327,95 @@ func renderSplitDiffView(rows []diffRow, columnWidth int, theme Theme) string { // Left half holds the header text; right half is blank. left := headerStyle.Render(stripAnsi(row.rawLine)) right := lipgloss.NewStyle().Width(columnWidth).Render("") - combined := lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right) + gap := " " + combined := lipgloss.JoinHorizontal(lipgloss.Top, left, gap, sep, gap, right) renderedRows = append(renderedRows, combined) case lineTypeRemoved: - left := buildHalf(renderLineNum(row.oldLineNum), removedStyle.Render(row.oldLine)) - right := buildHalf(renderLineNum(0), emptyStyle.Render("")) - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) + // Wrap the old line if needed + wrappedOld := wrapText(row.oldLine, contentColWidth) + for i, line := range wrappedOld { + lineNum := renderLineNum(0) + if i == 0 { + lineNum = renderLineNum(row.oldLineNum) + } + left := buildHalf(lineNum, removedStyle.Render(line)) + right := buildHalf(renderLineNum(0), emptyStyle.Render("")) + gap := " " + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, gap, sep, gap, right)) + } case lineTypeAdded: - left := buildHalf(renderLineNum(0), emptyStyle.Render("")) - right := buildHalf(renderLineNum(row.newLineNum), addedStyle.Render(row.newLine)) - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) + // Wrap the new line if needed + wrappedNew := wrapText(row.newLine, contentColWidth) + for i, line := range wrappedNew { + lineNum := renderLineNum(0) + if i == 0 { + lineNum = renderLineNum(row.newLineNum) + } + left := buildHalf(renderLineNum(0), emptyStyle.Render("")) + right := buildHalf(lineNum, addedStyle.Render(line)) + gap := " " + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, gap, sep, gap, right)) + } case lineTypeContext: - left := buildHalf(renderLineNum(row.oldLineNum), contextStyle.Render(row.oldLine)) - right := buildHalf(renderLineNum(row.newLineNum), contextStyle.Render(row.newLine)) - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) + // Wrap both old and new lines if needed + wrappedOld := wrapText(row.oldLine, contentColWidth) + wrappedNew := wrapText(row.newLine, contentColWidth) + maxLines := len(wrappedOld) + if len(wrappedNew) > maxLines { + maxLines = len(wrappedNew) + } + for i := 0; i < maxLines; i++ { + oldLineNum := renderLineNum(0) + newLineNum := renderLineNum(0) + if i == 0 { + oldLineNum = renderLineNum(row.oldLineNum) + newLineNum = renderLineNum(row.newLineNum) + } + oldLine := "" + newLine := "" + if i < len(wrappedOld) { + oldLine = wrappedOld[i] + } + if i < len(wrappedNew) { + newLine = wrappedNew[i] + } + left := buildHalf(oldLineNum, contextStyle.Render(oldLine)) + right := buildHalf(newLineNum, contextStyle.Render(newLine)) + gap := " " + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, gap, sep, gap, right)) + } default: - left := buildHalf(renderLineNum(row.oldLineNum), contextStyle.Render(row.oldLine)) - right := buildHalf(renderLineNum(row.newLineNum), contextStyle.Render(row.newLine)) - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) + // Wrap both old and new lines if needed + wrappedOld := wrapText(row.oldLine, contentColWidth) + wrappedNew := wrapText(row.newLine, contentColWidth) + maxLines := len(wrappedOld) + if len(wrappedNew) > maxLines { + maxLines = len(wrappedNew) + } + for i := 0; i < maxLines; i++ { + oldLineNum := renderLineNum(0) + newLineNum := renderLineNum(0) + if i == 0 { + oldLineNum = renderLineNum(row.oldLineNum) + newLineNum = renderLineNum(row.newLineNum) + } + oldLine := "" + newLine := "" + if i < len(wrappedOld) { + oldLine = wrappedOld[i] + } + if i < len(wrappedNew) { + newLine = wrappedNew[i] + } + left := buildHalf(oldLineNum, contextStyle.Render(oldLine)) + right := buildHalf(newLineNum, contextStyle.Render(newLine)) + gap := " " + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, gap, sep, gap, right)) + } } } @@ -287,19 +429,27 @@ func renderAdaptiveDiffView(content string, width int, theme Theme, forcedViewMo } const splitViewThreshold = 80 + const minSplitViewWidth = 40 useSplitView := false if forcedViewMode != nil { + // When forced mode is set, respect it if width allows if *forcedViewMode { - useSplitView = width >= 40 + // Split view requested - use it if width is sufficient + useSplitView = width >= minSplitViewWidth + } else { + // Unified view explicitly requested - always use unified + useSplitView = false } } else { + // Auto mode - use split view only if width is sufficient useSplitView = width >= splitViewThreshold && width > 60 } - if useSplitView && width > 40 { - // columnWidth = (total - 1 separator) / 2 - columnWidth := (width - 1) / 2 + if useSplitView && width >= minSplitViewWidth { + // Layout: [left(columnWidth)] [space] [separator] [space] [right(columnWidth)] + // Total width = 2*columnWidth + 3 + columnWidth := (width - 3) / 2 rows := parseDiffStructure(content) splitView := renderSplitDiffView(rows, columnWidth, theme) if splitView != "" { @@ -734,4 +884,4 @@ func styleChar(char byte, style lipgloss.Style) string { return " " } return style.Render(string(char)) -} \ No newline at end of file +}