diff --git a/cmd/diff.go b/cmd/diff.go index f66def9..d4c0837 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -40,6 +40,7 @@ import ( func diffCmd() *cobra.Command { var filePath string var expanded bool + var viewName string c := &cobra.Command{ Use: "diff .. | diff ", @@ -58,18 +59,23 @@ to show all lines. You can also toggle this with 'e' in the TUI.`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { from, to := gitlog.ParseRefArgs(args) - return runDiff(from, to, filePath, expanded) + viewKind, err := parseDiffView(viewName) + if err != nil { + return err + } + return runDiff(from, to, filePath, expanded, viewKind) }, } c.Flags().StringVarP(&filePath, "file", "f", "", "Specific file to diff (optional, shows all files if omitted)") c.Flags().BoolVarP(&expanded, "expanded", "e", false, "Show all unchanged lines (disable compression)") + c.Flags().StringVarP(&viewName, "view", "v", "split", "Diff rendering: split or unified") return c } // runDiff executes the diff command by reading file contents from two git refs and launching the TUI. -func runDiff(fromRef, toRef, filePath string, expanded bool) error { +func runDiff(fromRef, toRef, filePath string, expanded bool, view diff.DiffViewKind) error { repo, err := git.PlainOpen(repoPath) if err != nil { return fmt.Errorf("failed to open repository: %w", err) @@ -118,7 +124,7 @@ func runDiff(fromRef, toRef, filePath string, expanded bool) error { }) } - model := ui.NewMultiFileDiffModel(allDiffs, expanded) + model := ui.NewMultiFileDiffModel(allDiffs, expanded, view) p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { @@ -127,3 +133,14 @@ func runDiff(fromRef, toRef, filePath string, expanded bool) error { return nil } + +func parseDiffView(viewName string) (diff.DiffViewKind, error) { + switch strings.ToLower(strings.TrimSpace(viewName)) { + case "", "split", "side-by-side", "s": + return diff.ViewSplit, nil + case "unified", "u": + return diff.ViewUnified, nil + default: + return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName) + } +} diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 2dd515d..42c5617 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -460,7 +460,6 @@ func MergeReplacements(edits []Edit) []Edit { ei := outputs[i].edit ej := outputs[j].edit - // Get effective sort keys keyI := ei.BIndex if keyI == -1 { keyI = ei.AIndex @@ -495,10 +494,7 @@ func areSimilarLines(a, b string) bool { return true } - minLen := len(a) - if len(b) < minLen { - minLen = len(b) - } + minLen := min(len(b), len(a)) if minLen == 0 { return false diff --git a/internal/diff/format.go b/internal/diff/format.go index 050535e..82bccf2 100644 --- a/internal/diff/format.go +++ b/internal/diff/format.go @@ -342,3 +342,232 @@ func detab(s string, tabWidth int) string { } return strings.ReplaceAll(s, "\t", strings.Repeat(" ", tabWidth)) } + +// UnifiedFormatter renders diff edits in a traditional unified diff layout. +type UnifiedFormatter struct { + // TerminalWidth is the total available width for rendering + TerminalWidth int + // ShowLineNumbers controls whether line numbers are displayed + ShowLineNumbers bool + // Expanded controls whether to show all unchanged lines or compress them + Expanded bool + // EnableWordWrap enables word wrapping for long lines + EnableWordWrap bool +} + +// Format renders the edits as a styled unified diff string. +// +// The output shows deletions with "-" prefix, insertions with "+" prefix, and unchanged lines with " " prefix. +func (f *UnifiedFormatter) Format(edits []Edit) string { + if len(edits) == 0 { + return style.StyleText.Render("No changes") + } + + processedEdits := MergeReplacements(edits) + + if !f.Expanded { + processedEdits = f.compressUnchangedBlocks(processedEdits) + } + + contentWidth := f.calculateContentWidth() + + var sb strings.Builder + lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true) + + for _, edit := range processedEdits { + line := f.renderEdit(edit, contentWidth, lineNumStyle) + sb.WriteString(line) + sb.WriteString("\n") + + if edit.Kind == Replace { + newLine := f.renderReplaceNew(edit, contentWidth, lineNumStyle) + sb.WriteString(newLine) + sb.WriteString("\n") + } + } + + return sb.String() +} + +// calculateContentWidth determines the width available for content. +func (f *UnifiedFormatter) calculateContentWidth() int { + usedWidth := 2 + if f.ShowLineNumbers { + usedWidth += 2*lineNumWidth + 2 + } + return max(f.TerminalWidth-usedWidth, minPaneWidth) +} + +// renderEdit formats a single edit operation. +func (f *UnifiedFormatter) renderEdit(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { + var sb strings.Builder + + if edit.AIndex == -2 && edit.BIndex == -2 { + compressedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6C7A89")). + Faint(true). + Italic(true) + if f.ShowLineNumbers { + sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) + sb.WriteString(" ") + sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) + sb.WriteString(" ") + } + sb.WriteString(compressedStyle.Render(edit.Content)) + return sb.String() + } + + if f.ShowLineNumbers { + oldNum := f.formatLineNum(edit.AIndex, lineNumStyle) + newNum := f.formatLineNum(edit.BIndex, lineNumStyle) + sb.WriteString(oldNum) + sb.WriteString(" ") + sb.WriteString(newNum) + sb.WriteString(" ") + } + + content := detab(edit.Content, 8) + content = f.truncateContent(content, contentWidth) + + switch edit.Kind { + case Equal: + sb.WriteString(style.StyleText.Render(" " + content)) + case Delete: + sb.WriteString(style.StyleRemoved.Render("-" + content)) + case Insert: + sb.WriteString(style.StyleAdded.Render("+" + content)) + case Replace: + sb.WriteString(style.StyleRemoved.Render("-" + content)) + default: + sb.WriteString(" " + content) + } + + return sb.String() +} + +// renderReplaceNew renders the new content line for a Replace operation. +func (f *UnifiedFormatter) renderReplaceNew(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { + var sb strings.Builder + + if f.ShowLineNumbers { + sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) + sb.WriteString(" ") + sb.WriteString(f.formatLineNum(edit.BIndex, lineNumStyle)) + sb.WriteString(" ") + } + + content := detab(edit.NewContent, 8) + content = f.truncateContent(content, contentWidth) + sb.WriteString(style.StyleAdded.Render("+" + content)) + + return sb.String() +} + +// formatLineNum renders a line number with styling. +func (f *UnifiedFormatter) formatLineNum(index int, st lipgloss.Style) string { + if index < 0 { + return st.Width(lineNumWidth).Render("") + } + return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1)) +} + +// truncateContent ensures content fits within the available width. +func (f *UnifiedFormatter) truncateContent(content string, maxWidth int) string { + content = strings.TrimRight(content, " \t\r\n") + + if f.EnableWordWrap { + wrapped := wordwrap.String(content, maxWidth) + lines := strings.Split(wrapped, "\n") + if len(lines) > 0 { + return lines[0] + } + return wrapped + } + + displayWidth := lipgloss.Width(content) + + if displayWidth <= maxWidth { + return content + } + + if maxWidth <= 3 { + return truncateToWidth(content, maxWidth) + } + + return truncateToWidth(content, maxWidth-3) + "..." +} + +// compressUnchangedBlocks compresses large blocks of unchanged lines. +func (f *UnifiedFormatter) compressUnchangedBlocks(edits []Edit) []Edit { + if len(edits) == 0 { + return edits + } + + var result []Edit + var unchangedRun []Edit + + for i, edit := range edits { + if edit.Kind == Equal { + unchangedRun = append(unchangedRun, edit) + + isLast := i == len(edits)-1 + nextIsChanged := !isLast && edits[i+1].Kind != Equal + + if isLast || nextIsChanged { + if len(unchangedRun) >= minUnchangedToHide { + for j := 0; j < contextLines && j < len(unchangedRun); j++ { + result = append(result, unchangedRun[j]) + } + + hiddenCount := len(unchangedRun) - (2 * contextLines) + if hiddenCount > 0 { + result = append(result, Edit{ + Kind: Equal, + AIndex: -2, + BIndex: -2, + Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), + }) + } + + start := max(len(unchangedRun)-contextLines, contextLines) + for j := start; j < len(unchangedRun); j++ { + result = append(result, unchangedRun[j]) + } + } else { + result = append(result, unchangedRun...) + } + unchangedRun = nil + } + } else { + if len(unchangedRun) > 0 { + if len(unchangedRun) >= minUnchangedToHide { + for j := 0; j < contextLines && j < len(unchangedRun); j++ { + result = append(result, unchangedRun[j]) + } + + hiddenCount := len(unchangedRun) - (2 * contextLines) + if hiddenCount > 0 { + result = append(result, Edit{ + Kind: Equal, + AIndex: -2, + BIndex: -2, + Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), + }) + } + + start := max(len(unchangedRun)-contextLines, contextLines) + for j := start; j < len(unchangedRun); j++ { + result = append(result, unchangedRun[j]) + } + } else { + result = append(result, unchangedRun...) + } + unchangedRun = nil + } + + result = append(result, edit) + } + } + + return result +} diff --git a/internal/diff/format_test.go b/internal/diff/format_test.go index 5349cae..bee6dbd 100644 --- a/internal/diff/format_test.go +++ b/internal/diff/format_test.go @@ -288,3 +288,136 @@ func TestSideBySideFormatter_RenderEdit(t *testing.T) { }) } } + +func TestUnifiedFormatter_Format(t *testing.T) { + tests := []struct { + name string + edits []Edit + width int + expect func(string) bool + }{ + { + name: "empty edits", + edits: []Edit{}, + width: 80, + expect: func(output string) bool { + return strings.Contains(output, "No changes") + }, + }, + { + name: "equal lines", + edits: []Edit{ + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "hello world"}, + }, + width: 80, + expect: func(output string) bool { + return strings.Contains(output, " hello world") + }, + }, + { + name: "insert operation", + edits: []Edit{ + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new line"}, + }, + width: 80, + expect: func(output string) bool { + return strings.Contains(output, "+new line") + }, + }, + { + name: "delete operation", + edits: []Edit{ + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "old line"}, + }, + width: 80, + expect: func(output string) bool { + return strings.Contains(output, "-old line") + }, + }, + { + name: "replace operation", + edits: []Edit{ + {Kind: Replace, AIndex: 0, BIndex: 0, Content: "old content", NewContent: "new content"}, + }, + width: 100, + expect: func(output string) bool { + return strings.Contains(output, "-old content") && + strings.Contains(output, "+new content") + }, + }, + { + name: "mixed operations", + edits: []Edit{ + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "unchanged"}, + {Kind: Delete, AIndex: 1, BIndex: -1, Content: "removed"}, + {Kind: Insert, AIndex: -1, BIndex: 1, Content: "added"}, + {Kind: Equal, AIndex: 2, BIndex: 2, Content: "also unchanged"}, + }, + width: 100, + expect: func(output string) bool { + return strings.Contains(output, " unchanged") && + strings.Contains(output, "-removed") && + strings.Contains(output, "+added") && + strings.Contains(output, " also unchanged") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + formatter := &UnifiedFormatter{ + TerminalWidth: tt.width, + ShowLineNumbers: true, + } + + output := formatter.Format(tt.edits) + + if !tt.expect(output) { + t.Errorf("Format() output did not meet expectations.\nGot:\n%s", output) + } + }) + } +} + +func TestUnifiedFormatter_CalculateContentWidth(t *testing.T) { + tests := []struct { + name string + terminalWidth int + showLineNumbers bool + minExpected int + }{ + { + name: "standard width with line numbers", + terminalWidth: 120, + showLineNumbers: true, + minExpected: 40, + }, + { + name: "narrow terminal", + terminalWidth: 60, + showLineNumbers: true, + minExpected: 40, + }, + { + name: "without line numbers", + terminalWidth: 100, + showLineNumbers: false, + minExpected: 40, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + formatter := &UnifiedFormatter{ + TerminalWidth: tt.terminalWidth, + ShowLineNumbers: tt.showLineNumbers, + } + + contentWidth := formatter.calculateContentWidth() + + if contentWidth < tt.minExpected { + t.Errorf("calculateContentWidth() = %d, expected at least %d", contentWidth, tt.minExpected) + } + }) + } +} diff --git a/internal/diff/tools.go b/internal/diff/tools.go index 5a5a4f7..b4250e6 100644 --- a/internal/diff/tools.go +++ b/internal/diff/tools.go @@ -59,10 +59,122 @@ type DiffTool interface { } // UnifiedDiff implements unified view (single linear view with additions & deletions). -type UnifiedDiff struct{} +// +// TODO: Support pluggable diff algorithms beyond Myers. +type UnifiedDiff struct { + // TerminalWidth is the total available width for rendering + TerminalWidth int + // ShowLineNumbers controls whether line numbers are displayed + ShowLineNumbers bool + // Expanded controls whether to show all unchanged lines or compress them + Expanded bool + // EnableWordWrap enables word wrapping for long lines + EnableWordWrap bool +} + +// Diff generates a unified diff view from two content readers. +func (u *UnifiedDiff) Diff(oldContent io.Reader, newContent io.Reader, viewKind DiffViewKind) (DiffResult, error) { + oldBytes, err := io.ReadAll(oldContent) + if err != nil { + return DiffResult{}, err + } + newBytes, err := io.ReadAll(newContent) + if err != nil { + return DiffResult{}, err + } + + oldLines := splitLines(string(oldBytes)) + newLines := splitLines(string(newBytes)) + + myers := &Myers{} + edits, err := myers.Compute(oldLines, newLines) + if err != nil { + return DiffResult{}, err + } + + formatter := &UnifiedFormatter{ + TerminalWidth: u.TerminalWidth, + ShowLineNumbers: u.ShowLineNumbers, + Expanded: u.Expanded, + EnableWordWrap: u.EnableWordWrap, + } + + content := formatter.Format(edits) + + return DiffResult{ + Content: content, + View: ViewUnified, + }, nil +} // SplitDiff implements side-by-side view (old on left, new on right). -type SplitDiff struct{} +// +// TODO: Support pluggable diff algorithms beyond Myers. +type SplitDiff struct { + // TerminalWidth is the total available width for rendering + TerminalWidth int + // ShowLineNumbers controls whether line numbers are displayed + ShowLineNumbers bool + // Expanded controls whether to show all unchanged lines or compress them + Expanded bool + // EnableWordWrap enables word wrapping for long lines + EnableWordWrap bool +} + +// Diff generates a side-by-side diff view from two content readers. +func (s *SplitDiff) Diff(oldContent io.Reader, newContent io.Reader, viewKind DiffViewKind) (DiffResult, error) { + oldBytes, err := io.ReadAll(oldContent) + if err != nil { + return DiffResult{}, err + } + newBytes, err := io.ReadAll(newContent) + if err != nil { + return DiffResult{}, err + } + + oldLines := splitLines(string(oldBytes)) + newLines := splitLines(string(newBytes)) + + myers := &Myers{} + edits, err := myers.Compute(oldLines, newLines) + if err != nil { + return DiffResult{}, err + } + + formatter := &SideBySideFormatter{ + TerminalWidth: s.TerminalWidth, + ShowLineNumbers: s.ShowLineNumbers, + Expanded: s.Expanded, + EnableWordWrap: s.EnableWordWrap, + } + + content := formatter.Format(edits) + + return DiffResult{ + Content: content, + View: ViewSplit, + }, nil +} + +// splitLines splits a string into lines, preserving empty lines. +func splitLines(s string) []string { + if s == "" { + return []string{} + } + lines := make([]string, 0) + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} // HunkDiff focuses on changed blocks, minimal context. type HunkDiff struct{} diff --git a/internal/diff/tools_test.go b/internal/diff/tools_test.go new file mode 100644 index 0000000..3ffc14c --- /dev/null +++ b/internal/diff/tools_test.go @@ -0,0 +1,387 @@ +package diff + +import ( + "strings" + "testing" +) + +func TestSplitDiff_Diff(t *testing.T) { + tests := []struct { + name string + oldContent string + newContent string + width int + showLineNum bool + expectFunc func(result DiffResult) bool + }{ + { + name: "empty files", + oldContent: "", + newContent: "", + width: 80, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewSplit && strings.Contains(result.Content, "No changes") + }, + }, + { + name: "identical files", + oldContent: "line1\nline2\nline3", + newContent: "line1\nline2\nline3", + width: 100, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewSplit && + strings.Contains(result.Content, "line1") && + strings.Contains(result.Content, "line2") && + strings.Contains(result.Content, "line3") + }, + }, + { + name: "simple insertion", + oldContent: "line1\nline3", + newContent: "line1\nline2\nline3", + width: 100, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewSplit && + strings.Contains(result.Content, "line1") && + strings.Contains(result.Content, "line2") && + strings.Contains(result.Content, "line3") && + strings.Contains(result.Content, SymbolAdd) + }, + }, + { + name: "simple deletion", + oldContent: "line1\nline2\nline3", + newContent: "line1\nline3", + width: 100, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewSplit && + strings.Contains(result.Content, "line1") && + strings.Contains(result.Content, "line2") && + strings.Contains(result.Content, "line3") && + strings.Contains(result.Content, SymbolDeleteLine) + }, + }, + { + name: "replacement", + oldContent: "github.com/foo/bar v1.0.0", + newContent: "github.com/foo/bar v2.0.0", + width: 120, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewSplit && + strings.Contains(result.Content, "v1.0.0") && + strings.Contains(result.Content, "v2.0.0") && + strings.Contains(result.Content, SymbolChange) + }, + }, + { + name: "without line numbers", + oldContent: "old line", + newContent: "new line", + width: 100, + showLineNum: false, + expectFunc: func(result DiffResult) bool { + return result.View == ViewSplit && + strings.Contains(result.Content, "old line") && + strings.Contains(result.Content, "new line") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + splitter := &SplitDiff{ + TerminalWidth: tt.width, + ShowLineNumbers: tt.showLineNum, + Expanded: true, + } + + result, err := splitter.Diff( + strings.NewReader(tt.oldContent), + strings.NewReader(tt.newContent), + ViewSplit, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !tt.expectFunc(result) { + t.Errorf("result did not meet expectations.\nGot:\n%s", result.Content) + } + }) + } +} + +func TestUnifiedDiff_Diff(t *testing.T) { + tests := []struct { + name string + oldContent string + newContent string + width int + showLineNum bool + expectFunc func(result DiffResult) bool + }{ + { + name: "empty files", + oldContent: "", + newContent: "", + width: 80, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewUnified && strings.Contains(result.Content, "No changes") + }, + }, + { + name: "identical files", + oldContent: "line1\nline2\nline3", + newContent: "line1\nline2\nline3", + width: 100, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewUnified && + strings.Contains(result.Content, "line1") && + strings.Contains(result.Content, "line2") && + strings.Contains(result.Content, "line3") + }, + }, + { + name: "simple insertion", + oldContent: "line1\nline3", + newContent: "line1\nline2\nline3", + width: 100, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewUnified && + strings.Contains(result.Content, "line1") && + strings.Contains(result.Content, "+line2") && + strings.Contains(result.Content, "line3") + }, + }, + { + name: "simple deletion", + oldContent: "line1\nline2\nline3", + newContent: "line1\nline3", + width: 100, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewUnified && + strings.Contains(result.Content, "line1") && + strings.Contains(result.Content, "-line2") && + strings.Contains(result.Content, "line3") + }, + }, + { + name: "replacement", + oldContent: "github.com/foo/bar v1.0.0", + newContent: "github.com/foo/bar v2.0.0", + width: 120, + showLineNum: true, + expectFunc: func(result DiffResult) bool { + return result.View == ViewUnified && + strings.Contains(result.Content, "-github.com/foo/bar v1.0.0") && + strings.Contains(result.Content, "+github.com/foo/bar v2.0.0") + }, + }, + { + name: "without line numbers", + oldContent: "old line", + newContent: "new line", + width: 100, + showLineNum: false, + expectFunc: func(result DiffResult) bool { + return result.View == ViewUnified && + strings.Contains(result.Content, "-old line") && + strings.Contains(result.Content, "+new line") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + unifier := &UnifiedDiff{ + TerminalWidth: tt.width, + ShowLineNumbers: tt.showLineNum, + Expanded: true, + } + + result, err := unifier.Diff( + strings.NewReader(tt.oldContent), + strings.NewReader(tt.newContent), + ViewUnified, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !tt.expectFunc(result) { + t.Errorf("result did not meet expectations.\nGot:\n%s", result.Content) + } + }) + } +} + +func TestSplitLines(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty string", + input: "", + expected: []string{}, + }, + { + name: "single line no newline", + input: "hello", + expected: []string{"hello"}, + }, + { + name: "single line with newline", + input: "hello\n", + expected: []string{"hello"}, + }, + { + name: "multiple lines", + input: "line1\nline2\nline3", + expected: []string{"line1", "line2", "line3"}, + }, + { + name: "multiple lines with trailing newline", + input: "line1\nline2\nline3\n", + expected: []string{"line1", "line2", "line3"}, + }, + { + name: "empty lines preserved", + input: "line1\n\nline3", + expected: []string{"line1", "", "line3"}, + }, + { + name: "only newlines", + input: "\n\n\n", + expected: []string{"", "", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := splitLines(tt.input) + + if len(result) != len(tt.expected) { + t.Fatalf("expected %d lines, got %d", len(tt.expected), len(result)) + } + + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i]) + } + } + }) + } +} + +func TestDiffViewKind_String(t *testing.T) { + tests := []struct { + kind DiffViewKind + expected string + }{ + {ViewUnified, "Unified"}, + {ViewSplit, "Split"}, + {ViewHunk, "Hunk"}, + {ViewInline, "Inline"}, + {ViewRich, "Rich"}, + {ViewSource, "Source"}, + {DiffViewKind(999), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.kind.String() + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestSplitDiff_CompressedView(t *testing.T) { + oldLines := make([]string, 50) + newLines := make([]string, 50) + for i := range 50 { + oldLines[i] = "unchanged line" + newLines[i] = "unchanged line" + } + + newLines[25] = "changed line" + + oldContent := strings.Join(oldLines, "\n") + newContent := strings.Join(newLines, "\n") + + splitter := &SplitDiff{ + TerminalWidth: 100, + ShowLineNumbers: true, + Expanded: false, + } + + result, err := splitter.Diff( + strings.NewReader(oldContent), + strings.NewReader(newContent), + ViewSplit, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(result.Content, "unchanged lines") { + t.Errorf("expected compression indicator in output") + } + + if !strings.Contains(result.Content, "changed line") { + t.Errorf("expected changed line in output") + } +} + +func TestUnifiedDiff_CompressedView(t *testing.T) { + oldLines := make([]string, 50) + newLines := make([]string, 50) + for i := range 50 { + oldLines[i] = "unchanged line" + newLines[i] = "unchanged line" + } + + newLines[25] = "changed line" + + oldContent := strings.Join(oldLines, "\n") + newContent := strings.Join(newLines, "\n") + + unifier := &UnifiedDiff{ + TerminalWidth: 100, + ShowLineNumbers: true, + Expanded: false, // Enable compression + } + + result, err := unifier.Diff( + strings.NewReader(oldContent), + strings.NewReader(newContent), + ViewUnified, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(result.Content, "unchanged lines") { + t.Errorf("expected compression indicator in output") + } + + if !strings.Contains(result.Content, "+changed line") { + t.Errorf("expected changed line with + prefix in output") + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 59f8ec5..2845f07 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -219,10 +219,11 @@ type MultiFileDiffModel struct { width int height int expanded bool // Controls whether unchanged blocks are compressed + view diff.DiffViewKind } // NewMultiFileDiffModel creates a new multi-file diff viewer with pagination. -func NewMultiFileDiffModel(files []FileDiff, expanded bool) MultiFileDiffModel { +func NewMultiFileDiffModel(files []FileDiff, expanded bool, view diff.DiffViewKind) MultiFileDiffModel { p := paginator.New() p.Type = paginator.Dots p.PerPage = 1 @@ -235,6 +236,7 @@ func NewMultiFileDiffModel(files []FileDiff, expanded bool) MultiFileDiffModel { paginator: p, ready: false, expanded: expanded, + view: view, } return model @@ -335,15 +337,33 @@ func (m *MultiFileDiffModel) updateViewport() { return } - currentFile := m.files[m.paginator.Page] - formatter := &diff.SideBySideFormatter{ - TerminalWidth: m.width, - ShowLineNumbers: true, - Expanded: m.expanded, - EnableWordWrap: false, + width := m.width + if width <= 0 { + width = 80 } - content := formatter.Format(currentFile.Edits) + currentFile := m.files[m.paginator.Page] + + var content string + + switch m.view { + case diff.ViewUnified: + formatter := &diff.UnifiedFormatter{ + TerminalWidth: width, + ShowLineNumbers: true, + Expanded: m.expanded, + EnableWordWrap: false, + } + content = formatter.Format(currentFile.Edits) + default: + formatter := &diff.SideBySideFormatter{ + TerminalWidth: width, + ShowLineNumbers: true, + Expanded: m.expanded, + EnableWordWrap: false, + } + content = formatter.Format(currentFile.Edits) + } m.viewport.SetContent(content) } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index d7cf335..52f2497 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -142,7 +142,7 @@ func TestMultiFileDiffModel_Init(t *testing.T) { }, } - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) cmd := model.Init() if cmd != nil { @@ -164,7 +164,7 @@ func TestMultiFileDiffModel_View(t *testing.T) { }, } - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) model = updated.(MultiFileDiffModel) @@ -198,7 +198,7 @@ func TestMultiFileDiffModel_Pagination(t *testing.T) { }, } - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24)) @@ -219,7 +219,7 @@ func TestMultiFileDiffModel_Pagination(t *testing.T) { func TestMultiFileDiffModel_EmptyFiles(t *testing.T) { files := []FileDiff{} - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) view := model.View() @@ -237,7 +237,7 @@ func TestMultiFileDiffModel_SingleFile(t *testing.T) { }, } - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) model = updated.(MultiFileDiffModel) @@ -266,7 +266,7 @@ func TestMultiFileDiffModel_UpdateViewport(t *testing.T) { }, } - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) model = updated.(MultiFileDiffModel) @@ -294,7 +294,7 @@ func TestMultiFileDiffModel_RenderHeader(t *testing.T) { }, } - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) header := model.renderMultiFileHeader() if !strings.Contains(header, "old/test.go") { @@ -317,7 +317,7 @@ func TestMultiFileDiffModel_RenderFooter(t *testing.T) { }, } - model := NewMultiFileDiffModel(files, false) + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) footer := model.renderMultiFileFooter() if !strings.Contains(footer, "h/l") {