changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(diff): add unified diff view

+920 -26
+20 -3
cmd/diff.go
··· 40 40 func diffCmd() *cobra.Command { 41 41 var filePath string 42 42 var expanded bool 43 + var viewName string 43 44 44 45 c := &cobra.Command{ 45 46 Use: "diff <from>..<to> | diff <from> <to>", ··· 58 59 Args: cobra.RangeArgs(1, 2), 59 60 RunE: func(cmd *cobra.Command, args []string) error { 60 61 from, to := gitlog.ParseRefArgs(args) 61 - return runDiff(from, to, filePath, expanded) 62 + viewKind, err := parseDiffView(viewName) 63 + if err != nil { 64 + return err 65 + } 66 + return runDiff(from, to, filePath, expanded, viewKind) 62 67 }, 63 68 } 64 69 65 70 c.Flags().StringVarP(&filePath, "file", "f", "", "Specific file to diff (optional, shows all files if omitted)") 66 71 c.Flags().BoolVarP(&expanded, "expanded", "e", false, "Show all unchanged lines (disable compression)") 72 + c.Flags().StringVarP(&viewName, "view", "v", "split", "Diff rendering: split or unified") 67 73 68 74 return c 69 75 } 70 76 71 77 // runDiff executes the diff command by reading file contents from two git refs and launching the TUI. 72 - func runDiff(fromRef, toRef, filePath string, expanded bool) error { 78 + func runDiff(fromRef, toRef, filePath string, expanded bool, view diff.DiffViewKind) error { 73 79 repo, err := git.PlainOpen(repoPath) 74 80 if err != nil { 75 81 return fmt.Errorf("failed to open repository: %w", err) ··· 118 124 }) 119 125 } 120 126 121 - model := ui.NewMultiFileDiffModel(allDiffs, expanded) 127 + model := ui.NewMultiFileDiffModel(allDiffs, expanded, view) 122 128 123 129 p := tea.NewProgram(model, tea.WithAltScreen()) 124 130 if _, err := p.Run(); err != nil { ··· 127 133 128 134 return nil 129 135 } 136 + 137 + func parseDiffView(viewName string) (diff.DiffViewKind, error) { 138 + switch strings.ToLower(strings.TrimSpace(viewName)) { 139 + case "", "split", "side-by-side", "s": 140 + return diff.ViewSplit, nil 141 + case "unified", "u": 142 + return diff.ViewUnified, nil 143 + default: 144 + return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName) 145 + } 146 + }
+1 -5
internal/diff/diff.go
··· 460 460 ei := outputs[i].edit 461 461 ej := outputs[j].edit 462 462 463 - // Get effective sort keys 464 463 keyI := ei.BIndex 465 464 if keyI == -1 { 466 465 keyI = ei.AIndex ··· 495 494 return true 496 495 } 497 496 498 - minLen := len(a) 499 - if len(b) < minLen { 500 - minLen = len(b) 501 - } 497 + minLen := min(len(b), len(a)) 502 498 503 499 if minLen == 0 { 504 500 return false
+229
internal/diff/format.go
··· 342 342 } 343 343 return strings.ReplaceAll(s, "\t", strings.Repeat(" ", tabWidth)) 344 344 } 345 + 346 + // UnifiedFormatter renders diff edits in a traditional unified diff layout. 347 + type UnifiedFormatter struct { 348 + // TerminalWidth is the total available width for rendering 349 + TerminalWidth int 350 + // ShowLineNumbers controls whether line numbers are displayed 351 + ShowLineNumbers bool 352 + // Expanded controls whether to show all unchanged lines or compress them 353 + Expanded bool 354 + // EnableWordWrap enables word wrapping for long lines 355 + EnableWordWrap bool 356 + } 357 + 358 + // Format renders the edits as a styled unified diff string. 359 + // 360 + // The output shows deletions with "-" prefix, insertions with "+" prefix, and unchanged lines with " " prefix. 361 + func (f *UnifiedFormatter) Format(edits []Edit) string { 362 + if len(edits) == 0 { 363 + return style.StyleText.Render("No changes") 364 + } 365 + 366 + processedEdits := MergeReplacements(edits) 367 + 368 + if !f.Expanded { 369 + processedEdits = f.compressUnchangedBlocks(processedEdits) 370 + } 371 + 372 + contentWidth := f.calculateContentWidth() 373 + 374 + var sb strings.Builder 375 + lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true) 376 + 377 + for _, edit := range processedEdits { 378 + line := f.renderEdit(edit, contentWidth, lineNumStyle) 379 + sb.WriteString(line) 380 + sb.WriteString("\n") 381 + 382 + if edit.Kind == Replace { 383 + newLine := f.renderReplaceNew(edit, contentWidth, lineNumStyle) 384 + sb.WriteString(newLine) 385 + sb.WriteString("\n") 386 + } 387 + } 388 + 389 + return sb.String() 390 + } 391 + 392 + // calculateContentWidth determines the width available for content. 393 + func (f *UnifiedFormatter) calculateContentWidth() int { 394 + usedWidth := 2 395 + if f.ShowLineNumbers { 396 + usedWidth += 2*lineNumWidth + 2 397 + } 398 + return max(f.TerminalWidth-usedWidth, minPaneWidth) 399 + } 400 + 401 + // renderEdit formats a single edit operation. 402 + func (f *UnifiedFormatter) renderEdit(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { 403 + var sb strings.Builder 404 + 405 + if edit.AIndex == -2 && edit.BIndex == -2 { 406 + compressedStyle := lipgloss.NewStyle(). 407 + Foreground(lipgloss.Color("#6C7A89")). 408 + Faint(true). 409 + Italic(true) 410 + if f.ShowLineNumbers { 411 + sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) 412 + sb.WriteString(" ") 413 + sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) 414 + sb.WriteString(" ") 415 + } 416 + sb.WriteString(compressedStyle.Render(edit.Content)) 417 + return sb.String() 418 + } 419 + 420 + if f.ShowLineNumbers { 421 + oldNum := f.formatLineNum(edit.AIndex, lineNumStyle) 422 + newNum := f.formatLineNum(edit.BIndex, lineNumStyle) 423 + sb.WriteString(oldNum) 424 + sb.WriteString(" ") 425 + sb.WriteString(newNum) 426 + sb.WriteString(" ") 427 + } 428 + 429 + content := detab(edit.Content, 8) 430 + content = f.truncateContent(content, contentWidth) 431 + 432 + switch edit.Kind { 433 + case Equal: 434 + sb.WriteString(style.StyleText.Render(" " + content)) 435 + case Delete: 436 + sb.WriteString(style.StyleRemoved.Render("-" + content)) 437 + case Insert: 438 + sb.WriteString(style.StyleAdded.Render("+" + content)) 439 + case Replace: 440 + sb.WriteString(style.StyleRemoved.Render("-" + content)) 441 + default: 442 + sb.WriteString(" " + content) 443 + } 444 + 445 + return sb.String() 446 + } 447 + 448 + // renderReplaceNew renders the new content line for a Replace operation. 449 + func (f *UnifiedFormatter) renderReplaceNew(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { 450 + var sb strings.Builder 451 + 452 + if f.ShowLineNumbers { 453 + sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) 454 + sb.WriteString(" ") 455 + sb.WriteString(f.formatLineNum(edit.BIndex, lineNumStyle)) 456 + sb.WriteString(" ") 457 + } 458 + 459 + content := detab(edit.NewContent, 8) 460 + content = f.truncateContent(content, contentWidth) 461 + sb.WriteString(style.StyleAdded.Render("+" + content)) 462 + 463 + return sb.String() 464 + } 465 + 466 + // formatLineNum renders a line number with styling. 467 + func (f *UnifiedFormatter) formatLineNum(index int, st lipgloss.Style) string { 468 + if index < 0 { 469 + return st.Width(lineNumWidth).Render("") 470 + } 471 + return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1)) 472 + } 473 + 474 + // truncateContent ensures content fits within the available width. 475 + func (f *UnifiedFormatter) truncateContent(content string, maxWidth int) string { 476 + content = strings.TrimRight(content, " \t\r\n") 477 + 478 + if f.EnableWordWrap { 479 + wrapped := wordwrap.String(content, maxWidth) 480 + lines := strings.Split(wrapped, "\n") 481 + if len(lines) > 0 { 482 + return lines[0] 483 + } 484 + return wrapped 485 + } 486 + 487 + displayWidth := lipgloss.Width(content) 488 + 489 + if displayWidth <= maxWidth { 490 + return content 491 + } 492 + 493 + if maxWidth <= 3 { 494 + return truncateToWidth(content, maxWidth) 495 + } 496 + 497 + return truncateToWidth(content, maxWidth-3) + "..." 498 + } 499 + 500 + // compressUnchangedBlocks compresses large blocks of unchanged lines. 501 + func (f *UnifiedFormatter) compressUnchangedBlocks(edits []Edit) []Edit { 502 + if len(edits) == 0 { 503 + return edits 504 + } 505 + 506 + var result []Edit 507 + var unchangedRun []Edit 508 + 509 + for i, edit := range edits { 510 + if edit.Kind == Equal { 511 + unchangedRun = append(unchangedRun, edit) 512 + 513 + isLast := i == len(edits)-1 514 + nextIsChanged := !isLast && edits[i+1].Kind != Equal 515 + 516 + if isLast || nextIsChanged { 517 + if len(unchangedRun) >= minUnchangedToHide { 518 + for j := 0; j < contextLines && j < len(unchangedRun); j++ { 519 + result = append(result, unchangedRun[j]) 520 + } 521 + 522 + hiddenCount := len(unchangedRun) - (2 * contextLines) 523 + if hiddenCount > 0 { 524 + result = append(result, Edit{ 525 + Kind: Equal, 526 + AIndex: -2, 527 + BIndex: -2, 528 + Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 529 + }) 530 + } 531 + 532 + start := max(len(unchangedRun)-contextLines, contextLines) 533 + for j := start; j < len(unchangedRun); j++ { 534 + result = append(result, unchangedRun[j]) 535 + } 536 + } else { 537 + result = append(result, unchangedRun...) 538 + } 539 + unchangedRun = nil 540 + } 541 + } else { 542 + if len(unchangedRun) > 0 { 543 + if len(unchangedRun) >= minUnchangedToHide { 544 + for j := 0; j < contextLines && j < len(unchangedRun); j++ { 545 + result = append(result, unchangedRun[j]) 546 + } 547 + 548 + hiddenCount := len(unchangedRun) - (2 * contextLines) 549 + if hiddenCount > 0 { 550 + result = append(result, Edit{ 551 + Kind: Equal, 552 + AIndex: -2, 553 + BIndex: -2, 554 + Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 555 + }) 556 + } 557 + 558 + start := max(len(unchangedRun)-contextLines, contextLines) 559 + for j := start; j < len(unchangedRun); j++ { 560 + result = append(result, unchangedRun[j]) 561 + } 562 + } else { 563 + result = append(result, unchangedRun...) 564 + } 565 + unchangedRun = nil 566 + } 567 + 568 + result = append(result, edit) 569 + } 570 + } 571 + 572 + return result 573 + }
+133
internal/diff/format_test.go
··· 288 288 }) 289 289 } 290 290 } 291 + 292 + func TestUnifiedFormatter_Format(t *testing.T) { 293 + tests := []struct { 294 + name string 295 + edits []Edit 296 + width int 297 + expect func(string) bool 298 + }{ 299 + { 300 + name: "empty edits", 301 + edits: []Edit{}, 302 + width: 80, 303 + expect: func(output string) bool { 304 + return strings.Contains(output, "No changes") 305 + }, 306 + }, 307 + { 308 + name: "equal lines", 309 + edits: []Edit{ 310 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "hello world"}, 311 + }, 312 + width: 80, 313 + expect: func(output string) bool { 314 + return strings.Contains(output, " hello world") 315 + }, 316 + }, 317 + { 318 + name: "insert operation", 319 + edits: []Edit{ 320 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new line"}, 321 + }, 322 + width: 80, 323 + expect: func(output string) bool { 324 + return strings.Contains(output, "+new line") 325 + }, 326 + }, 327 + { 328 + name: "delete operation", 329 + edits: []Edit{ 330 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "old line"}, 331 + }, 332 + width: 80, 333 + expect: func(output string) bool { 334 + return strings.Contains(output, "-old line") 335 + }, 336 + }, 337 + { 338 + name: "replace operation", 339 + edits: []Edit{ 340 + {Kind: Replace, AIndex: 0, BIndex: 0, Content: "old content", NewContent: "new content"}, 341 + }, 342 + width: 100, 343 + expect: func(output string) bool { 344 + return strings.Contains(output, "-old content") && 345 + strings.Contains(output, "+new content") 346 + }, 347 + }, 348 + { 349 + name: "mixed operations", 350 + edits: []Edit{ 351 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "unchanged"}, 352 + {Kind: Delete, AIndex: 1, BIndex: -1, Content: "removed"}, 353 + {Kind: Insert, AIndex: -1, BIndex: 1, Content: "added"}, 354 + {Kind: Equal, AIndex: 2, BIndex: 2, Content: "also unchanged"}, 355 + }, 356 + width: 100, 357 + expect: func(output string) bool { 358 + return strings.Contains(output, " unchanged") && 359 + strings.Contains(output, "-removed") && 360 + strings.Contains(output, "+added") && 361 + strings.Contains(output, " also unchanged") 362 + }, 363 + }, 364 + } 365 + 366 + for _, tt := range tests { 367 + t.Run(tt.name, func(t *testing.T) { 368 + formatter := &UnifiedFormatter{ 369 + TerminalWidth: tt.width, 370 + ShowLineNumbers: true, 371 + } 372 + 373 + output := formatter.Format(tt.edits) 374 + 375 + if !tt.expect(output) { 376 + t.Errorf("Format() output did not meet expectations.\nGot:\n%s", output) 377 + } 378 + }) 379 + } 380 + } 381 + 382 + func TestUnifiedFormatter_CalculateContentWidth(t *testing.T) { 383 + tests := []struct { 384 + name string 385 + terminalWidth int 386 + showLineNumbers bool 387 + minExpected int 388 + }{ 389 + { 390 + name: "standard width with line numbers", 391 + terminalWidth: 120, 392 + showLineNumbers: true, 393 + minExpected: 40, 394 + }, 395 + { 396 + name: "narrow terminal", 397 + terminalWidth: 60, 398 + showLineNumbers: true, 399 + minExpected: 40, 400 + }, 401 + { 402 + name: "without line numbers", 403 + terminalWidth: 100, 404 + showLineNumbers: false, 405 + minExpected: 40, 406 + }, 407 + } 408 + 409 + for _, tt := range tests { 410 + t.Run(tt.name, func(t *testing.T) { 411 + formatter := &UnifiedFormatter{ 412 + TerminalWidth: tt.terminalWidth, 413 + ShowLineNumbers: tt.showLineNumbers, 414 + } 415 + 416 + contentWidth := formatter.calculateContentWidth() 417 + 418 + if contentWidth < tt.minExpected { 419 + t.Errorf("calculateContentWidth() = %d, expected at least %d", contentWidth, tt.minExpected) 420 + } 421 + }) 422 + } 423 + }
+114 -2
internal/diff/tools.go
··· 59 59 } 60 60 61 61 // UnifiedDiff implements unified view (single linear view with additions & deletions). 62 - type UnifiedDiff struct{} 62 + // 63 + // TODO: Support pluggable diff algorithms beyond Myers. 64 + type UnifiedDiff struct { 65 + // TerminalWidth is the total available width for rendering 66 + TerminalWidth int 67 + // ShowLineNumbers controls whether line numbers are displayed 68 + ShowLineNumbers bool 69 + // Expanded controls whether to show all unchanged lines or compress them 70 + Expanded bool 71 + // EnableWordWrap enables word wrapping for long lines 72 + EnableWordWrap bool 73 + } 74 + 75 + // Diff generates a unified diff view from two content readers. 76 + func (u *UnifiedDiff) Diff(oldContent io.Reader, newContent io.Reader, viewKind DiffViewKind) (DiffResult, error) { 77 + oldBytes, err := io.ReadAll(oldContent) 78 + if err != nil { 79 + return DiffResult{}, err 80 + } 81 + newBytes, err := io.ReadAll(newContent) 82 + if err != nil { 83 + return DiffResult{}, err 84 + } 85 + 86 + oldLines := splitLines(string(oldBytes)) 87 + newLines := splitLines(string(newBytes)) 88 + 89 + myers := &Myers{} 90 + edits, err := myers.Compute(oldLines, newLines) 91 + if err != nil { 92 + return DiffResult{}, err 93 + } 94 + 95 + formatter := &UnifiedFormatter{ 96 + TerminalWidth: u.TerminalWidth, 97 + ShowLineNumbers: u.ShowLineNumbers, 98 + Expanded: u.Expanded, 99 + EnableWordWrap: u.EnableWordWrap, 100 + } 101 + 102 + content := formatter.Format(edits) 103 + 104 + return DiffResult{ 105 + Content: content, 106 + View: ViewUnified, 107 + }, nil 108 + } 63 109 64 110 // SplitDiff implements side-by-side view (old on left, new on right). 65 - type SplitDiff struct{} 111 + // 112 + // TODO: Support pluggable diff algorithms beyond Myers. 113 + type SplitDiff struct { 114 + // TerminalWidth is the total available width for rendering 115 + TerminalWidth int 116 + // ShowLineNumbers controls whether line numbers are displayed 117 + ShowLineNumbers bool 118 + // Expanded controls whether to show all unchanged lines or compress them 119 + Expanded bool 120 + // EnableWordWrap enables word wrapping for long lines 121 + EnableWordWrap bool 122 + } 123 + 124 + // Diff generates a side-by-side diff view from two content readers. 125 + func (s *SplitDiff) Diff(oldContent io.Reader, newContent io.Reader, viewKind DiffViewKind) (DiffResult, error) { 126 + oldBytes, err := io.ReadAll(oldContent) 127 + if err != nil { 128 + return DiffResult{}, err 129 + } 130 + newBytes, err := io.ReadAll(newContent) 131 + if err != nil { 132 + return DiffResult{}, err 133 + } 134 + 135 + oldLines := splitLines(string(oldBytes)) 136 + newLines := splitLines(string(newBytes)) 137 + 138 + myers := &Myers{} 139 + edits, err := myers.Compute(oldLines, newLines) 140 + if err != nil { 141 + return DiffResult{}, err 142 + } 143 + 144 + formatter := &SideBySideFormatter{ 145 + TerminalWidth: s.TerminalWidth, 146 + ShowLineNumbers: s.ShowLineNumbers, 147 + Expanded: s.Expanded, 148 + EnableWordWrap: s.EnableWordWrap, 149 + } 150 + 151 + content := formatter.Format(edits) 152 + 153 + return DiffResult{ 154 + Content: content, 155 + View: ViewSplit, 156 + }, nil 157 + } 158 + 159 + // splitLines splits a string into lines, preserving empty lines. 160 + func splitLines(s string) []string { 161 + if s == "" { 162 + return []string{} 163 + } 164 + lines := make([]string, 0) 165 + start := 0 166 + for i := 0; i < len(s); i++ { 167 + if s[i] == '\n' { 168 + lines = append(lines, s[start:i]) 169 + start = i + 1 170 + } 171 + } 172 + 173 + if start < len(s) { 174 + lines = append(lines, s[start:]) 175 + } 176 + return lines 177 + } 66 178 67 179 // HunkDiff focuses on changed blocks, minimal context. 68 180 type HunkDiff struct{}
+387
internal/diff/tools_test.go
··· 1 + package diff 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestSplitDiff_Diff(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + oldContent string 12 + newContent string 13 + width int 14 + showLineNum bool 15 + expectFunc func(result DiffResult) bool 16 + }{ 17 + { 18 + name: "empty files", 19 + oldContent: "", 20 + newContent: "", 21 + width: 80, 22 + showLineNum: true, 23 + expectFunc: func(result DiffResult) bool { 24 + return result.View == ViewSplit && strings.Contains(result.Content, "No changes") 25 + }, 26 + }, 27 + { 28 + name: "identical files", 29 + oldContent: "line1\nline2\nline3", 30 + newContent: "line1\nline2\nline3", 31 + width: 100, 32 + showLineNum: true, 33 + expectFunc: func(result DiffResult) bool { 34 + return result.View == ViewSplit && 35 + strings.Contains(result.Content, "line1") && 36 + strings.Contains(result.Content, "line2") && 37 + strings.Contains(result.Content, "line3") 38 + }, 39 + }, 40 + { 41 + name: "simple insertion", 42 + oldContent: "line1\nline3", 43 + newContent: "line1\nline2\nline3", 44 + width: 100, 45 + showLineNum: true, 46 + expectFunc: func(result DiffResult) bool { 47 + return result.View == ViewSplit && 48 + strings.Contains(result.Content, "line1") && 49 + strings.Contains(result.Content, "line2") && 50 + strings.Contains(result.Content, "line3") && 51 + strings.Contains(result.Content, SymbolAdd) 52 + }, 53 + }, 54 + { 55 + name: "simple deletion", 56 + oldContent: "line1\nline2\nline3", 57 + newContent: "line1\nline3", 58 + width: 100, 59 + showLineNum: true, 60 + expectFunc: func(result DiffResult) bool { 61 + return result.View == ViewSplit && 62 + strings.Contains(result.Content, "line1") && 63 + strings.Contains(result.Content, "line2") && 64 + strings.Contains(result.Content, "line3") && 65 + strings.Contains(result.Content, SymbolDeleteLine) 66 + }, 67 + }, 68 + { 69 + name: "replacement", 70 + oldContent: "github.com/foo/bar v1.0.0", 71 + newContent: "github.com/foo/bar v2.0.0", 72 + width: 120, 73 + showLineNum: true, 74 + expectFunc: func(result DiffResult) bool { 75 + return result.View == ViewSplit && 76 + strings.Contains(result.Content, "v1.0.0") && 77 + strings.Contains(result.Content, "v2.0.0") && 78 + strings.Contains(result.Content, SymbolChange) 79 + }, 80 + }, 81 + { 82 + name: "without line numbers", 83 + oldContent: "old line", 84 + newContent: "new line", 85 + width: 100, 86 + showLineNum: false, 87 + expectFunc: func(result DiffResult) bool { 88 + return result.View == ViewSplit && 89 + strings.Contains(result.Content, "old line") && 90 + strings.Contains(result.Content, "new line") 91 + }, 92 + }, 93 + } 94 + 95 + for _, tt := range tests { 96 + t.Run(tt.name, func(t *testing.T) { 97 + splitter := &SplitDiff{ 98 + TerminalWidth: tt.width, 99 + ShowLineNumbers: tt.showLineNum, 100 + Expanded: true, 101 + } 102 + 103 + result, err := splitter.Diff( 104 + strings.NewReader(tt.oldContent), 105 + strings.NewReader(tt.newContent), 106 + ViewSplit, 107 + ) 108 + 109 + if err != nil { 110 + t.Fatalf("unexpected error: %v", err) 111 + } 112 + 113 + if !tt.expectFunc(result) { 114 + t.Errorf("result did not meet expectations.\nGot:\n%s", result.Content) 115 + } 116 + }) 117 + } 118 + } 119 + 120 + func TestUnifiedDiff_Diff(t *testing.T) { 121 + tests := []struct { 122 + name string 123 + oldContent string 124 + newContent string 125 + width int 126 + showLineNum bool 127 + expectFunc func(result DiffResult) bool 128 + }{ 129 + { 130 + name: "empty files", 131 + oldContent: "", 132 + newContent: "", 133 + width: 80, 134 + showLineNum: true, 135 + expectFunc: func(result DiffResult) bool { 136 + return result.View == ViewUnified && strings.Contains(result.Content, "No changes") 137 + }, 138 + }, 139 + { 140 + name: "identical files", 141 + oldContent: "line1\nline2\nline3", 142 + newContent: "line1\nline2\nline3", 143 + width: 100, 144 + showLineNum: true, 145 + expectFunc: func(result DiffResult) bool { 146 + return result.View == ViewUnified && 147 + strings.Contains(result.Content, "line1") && 148 + strings.Contains(result.Content, "line2") && 149 + strings.Contains(result.Content, "line3") 150 + }, 151 + }, 152 + { 153 + name: "simple insertion", 154 + oldContent: "line1\nline3", 155 + newContent: "line1\nline2\nline3", 156 + width: 100, 157 + showLineNum: true, 158 + expectFunc: func(result DiffResult) bool { 159 + return result.View == ViewUnified && 160 + strings.Contains(result.Content, "line1") && 161 + strings.Contains(result.Content, "+line2") && 162 + strings.Contains(result.Content, "line3") 163 + }, 164 + }, 165 + { 166 + name: "simple deletion", 167 + oldContent: "line1\nline2\nline3", 168 + newContent: "line1\nline3", 169 + width: 100, 170 + showLineNum: true, 171 + expectFunc: func(result DiffResult) bool { 172 + return result.View == ViewUnified && 173 + strings.Contains(result.Content, "line1") && 174 + strings.Contains(result.Content, "-line2") && 175 + strings.Contains(result.Content, "line3") 176 + }, 177 + }, 178 + { 179 + name: "replacement", 180 + oldContent: "github.com/foo/bar v1.0.0", 181 + newContent: "github.com/foo/bar v2.0.0", 182 + width: 120, 183 + showLineNum: true, 184 + expectFunc: func(result DiffResult) bool { 185 + return result.View == ViewUnified && 186 + strings.Contains(result.Content, "-github.com/foo/bar v1.0.0") && 187 + strings.Contains(result.Content, "+github.com/foo/bar v2.0.0") 188 + }, 189 + }, 190 + { 191 + name: "without line numbers", 192 + oldContent: "old line", 193 + newContent: "new line", 194 + width: 100, 195 + showLineNum: false, 196 + expectFunc: func(result DiffResult) bool { 197 + return result.View == ViewUnified && 198 + strings.Contains(result.Content, "-old line") && 199 + strings.Contains(result.Content, "+new line") 200 + }, 201 + }, 202 + } 203 + 204 + for _, tt := range tests { 205 + t.Run(tt.name, func(t *testing.T) { 206 + unifier := &UnifiedDiff{ 207 + TerminalWidth: tt.width, 208 + ShowLineNumbers: tt.showLineNum, 209 + Expanded: true, 210 + } 211 + 212 + result, err := unifier.Diff( 213 + strings.NewReader(tt.oldContent), 214 + strings.NewReader(tt.newContent), 215 + ViewUnified, 216 + ) 217 + 218 + if err != nil { 219 + t.Fatalf("unexpected error: %v", err) 220 + } 221 + 222 + if !tt.expectFunc(result) { 223 + t.Errorf("result did not meet expectations.\nGot:\n%s", result.Content) 224 + } 225 + }) 226 + } 227 + } 228 + 229 + func TestSplitLines(t *testing.T) { 230 + tests := []struct { 231 + name string 232 + input string 233 + expected []string 234 + }{ 235 + { 236 + name: "empty string", 237 + input: "", 238 + expected: []string{}, 239 + }, 240 + { 241 + name: "single line no newline", 242 + input: "hello", 243 + expected: []string{"hello"}, 244 + }, 245 + { 246 + name: "single line with newline", 247 + input: "hello\n", 248 + expected: []string{"hello"}, 249 + }, 250 + { 251 + name: "multiple lines", 252 + input: "line1\nline2\nline3", 253 + expected: []string{"line1", "line2", "line3"}, 254 + }, 255 + { 256 + name: "multiple lines with trailing newline", 257 + input: "line1\nline2\nline3\n", 258 + expected: []string{"line1", "line2", "line3"}, 259 + }, 260 + { 261 + name: "empty lines preserved", 262 + input: "line1\n\nline3", 263 + expected: []string{"line1", "", "line3"}, 264 + }, 265 + { 266 + name: "only newlines", 267 + input: "\n\n\n", 268 + expected: []string{"", "", ""}, 269 + }, 270 + } 271 + 272 + for _, tt := range tests { 273 + t.Run(tt.name, func(t *testing.T) { 274 + result := splitLines(tt.input) 275 + 276 + if len(result) != len(tt.expected) { 277 + t.Fatalf("expected %d lines, got %d", len(tt.expected), len(result)) 278 + } 279 + 280 + for i := range result { 281 + if result[i] != tt.expected[i] { 282 + t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i]) 283 + } 284 + } 285 + }) 286 + } 287 + } 288 + 289 + func TestDiffViewKind_String(t *testing.T) { 290 + tests := []struct { 291 + kind DiffViewKind 292 + expected string 293 + }{ 294 + {ViewUnified, "Unified"}, 295 + {ViewSplit, "Split"}, 296 + {ViewHunk, "Hunk"}, 297 + {ViewInline, "Inline"}, 298 + {ViewRich, "Rich"}, 299 + {ViewSource, "Source"}, 300 + {DiffViewKind(999), "Unknown"}, 301 + } 302 + 303 + for _, tt := range tests { 304 + t.Run(tt.expected, func(t *testing.T) { 305 + result := tt.kind.String() 306 + if result != tt.expected { 307 + t.Errorf("expected %q, got %q", tt.expected, result) 308 + } 309 + }) 310 + } 311 + } 312 + 313 + func TestSplitDiff_CompressedView(t *testing.T) { 314 + oldLines := make([]string, 50) 315 + newLines := make([]string, 50) 316 + for i := range 50 { 317 + oldLines[i] = "unchanged line" 318 + newLines[i] = "unchanged line" 319 + } 320 + 321 + newLines[25] = "changed line" 322 + 323 + oldContent := strings.Join(oldLines, "\n") 324 + newContent := strings.Join(newLines, "\n") 325 + 326 + splitter := &SplitDiff{ 327 + TerminalWidth: 100, 328 + ShowLineNumbers: true, 329 + Expanded: false, 330 + } 331 + 332 + result, err := splitter.Diff( 333 + strings.NewReader(oldContent), 334 + strings.NewReader(newContent), 335 + ViewSplit, 336 + ) 337 + 338 + if err != nil { 339 + t.Fatalf("unexpected error: %v", err) 340 + } 341 + 342 + if !strings.Contains(result.Content, "unchanged lines") { 343 + t.Errorf("expected compression indicator in output") 344 + } 345 + 346 + if !strings.Contains(result.Content, "changed line") { 347 + t.Errorf("expected changed line in output") 348 + } 349 + } 350 + 351 + func TestUnifiedDiff_CompressedView(t *testing.T) { 352 + oldLines := make([]string, 50) 353 + newLines := make([]string, 50) 354 + for i := range 50 { 355 + oldLines[i] = "unchanged line" 356 + newLines[i] = "unchanged line" 357 + } 358 + 359 + newLines[25] = "changed line" 360 + 361 + oldContent := strings.Join(oldLines, "\n") 362 + newContent := strings.Join(newLines, "\n") 363 + 364 + unifier := &UnifiedDiff{ 365 + TerminalWidth: 100, 366 + ShowLineNumbers: true, 367 + Expanded: false, // Enable compression 368 + } 369 + 370 + result, err := unifier.Diff( 371 + strings.NewReader(oldContent), 372 + strings.NewReader(newContent), 373 + ViewUnified, 374 + ) 375 + 376 + if err != nil { 377 + t.Fatalf("unexpected error: %v", err) 378 + } 379 + 380 + if !strings.Contains(result.Content, "unchanged lines") { 381 + t.Errorf("expected compression indicator in output") 382 + } 383 + 384 + if !strings.Contains(result.Content, "+changed line") { 385 + t.Errorf("expected changed line with + prefix in output") 386 + } 387 + }
+28 -8
internal/ui/ui.go
··· 219 219 width int 220 220 height int 221 221 expanded bool // Controls whether unchanged blocks are compressed 222 + view diff.DiffViewKind 222 223 } 223 224 224 225 // NewMultiFileDiffModel creates a new multi-file diff viewer with pagination. 225 - func NewMultiFileDiffModel(files []FileDiff, expanded bool) MultiFileDiffModel { 226 + func NewMultiFileDiffModel(files []FileDiff, expanded bool, view diff.DiffViewKind) MultiFileDiffModel { 226 227 p := paginator.New() 227 228 p.Type = paginator.Dots 228 229 p.PerPage = 1 ··· 235 236 paginator: p, 236 237 ready: false, 237 238 expanded: expanded, 239 + view: view, 238 240 } 239 241 240 242 return model ··· 335 337 return 336 338 } 337 339 338 - currentFile := m.files[m.paginator.Page] 339 - formatter := &diff.SideBySideFormatter{ 340 - TerminalWidth: m.width, 341 - ShowLineNumbers: true, 342 - Expanded: m.expanded, 343 - EnableWordWrap: false, 340 + width := m.width 341 + if width <= 0 { 342 + width = 80 344 343 } 345 344 346 - content := formatter.Format(currentFile.Edits) 345 + currentFile := m.files[m.paginator.Page] 346 + 347 + var content string 348 + 349 + switch m.view { 350 + case diff.ViewUnified: 351 + formatter := &diff.UnifiedFormatter{ 352 + TerminalWidth: width, 353 + ShowLineNumbers: true, 354 + Expanded: m.expanded, 355 + EnableWordWrap: false, 356 + } 357 + content = formatter.Format(currentFile.Edits) 358 + default: 359 + formatter := &diff.SideBySideFormatter{ 360 + TerminalWidth: width, 361 + ShowLineNumbers: true, 362 + Expanded: m.expanded, 363 + EnableWordWrap: false, 364 + } 365 + content = formatter.Format(currentFile.Edits) 366 + } 347 367 m.viewport.SetContent(content) 348 368 } 349 369
+8 -8
internal/ui/ui_test.go
··· 142 142 }, 143 143 } 144 144 145 - model := NewMultiFileDiffModel(files, false) 145 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 146 146 147 147 cmd := model.Init() 148 148 if cmd != nil { ··· 164 164 }, 165 165 } 166 166 167 - model := NewMultiFileDiffModel(files, false) 167 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 168 168 169 169 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 170 170 model = updated.(MultiFileDiffModel) ··· 198 198 }, 199 199 } 200 200 201 - model := NewMultiFileDiffModel(files, false) 201 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 202 202 203 203 tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24)) 204 204 ··· 219 219 func TestMultiFileDiffModel_EmptyFiles(t *testing.T) { 220 220 files := []FileDiff{} 221 221 222 - model := NewMultiFileDiffModel(files, false) 222 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 223 223 224 224 view := model.View() 225 225 ··· 237 237 }, 238 238 } 239 239 240 - model := NewMultiFileDiffModel(files, false) 240 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 241 241 242 242 updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) 243 243 model = updated.(MultiFileDiffModel) ··· 266 266 }, 267 267 } 268 268 269 - model := NewMultiFileDiffModel(files, false) 269 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 270 270 271 271 updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) 272 272 model = updated.(MultiFileDiffModel) ··· 294 294 }, 295 295 } 296 296 297 - model := NewMultiFileDiffModel(files, false) 297 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 298 298 header := model.renderMultiFileHeader() 299 299 300 300 if !strings.Contains(header, "old/test.go") { ··· 317 317 }, 318 318 } 319 319 320 - model := NewMultiFileDiffModel(files, false) 320 + model := NewMultiFileDiffModel(files, false, diff.ViewSplit) 321 321 footer := model.renderMultiFileFooter() 322 322 323 323 if !strings.Contains(footer, "h/l") {