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

feat: side by side diff with reflow/truncation

+1 -1
Taskfile.yml
··· 3 vars: 4 BINARY_NAME: storm 5 BUILD_DIR: ./tmp 6 - MAIN_PATH: ./main.go 7 VERSION: 8 sh: git describe --tags --always --dirty 2>/dev/null || echo "dev" 9
··· 3 vars: 4 BINARY_NAME: storm 5 BUILD_DIR: ./tmp 6 + MAIN_PATH: ./cmd/main.go 7 VERSION: 8 sh: git describe --tags --always --dirty 2>/dev/null || echo "dev" 9
+163
cmd/main_test.go
···
··· 1 + package main 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/stormlightlabs/git-storm/internal/testutils" 8 + ) 9 + 10 + func TestParseRefArgs(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + args []string 14 + expectedFrom string 15 + expectedTo string 16 + }{ 17 + { 18 + name: "range syntax with full hashes", 19 + args: []string{"abc123..def456"}, 20 + expectedFrom: "abc123", 21 + expectedTo: "def456", 22 + }, 23 + { 24 + name: "range syntax with truncated hashes", 25 + args: []string{"7de6f6d..18363c2"}, 26 + expectedFrom: "7de6f6d", 27 + expectedTo: "18363c2", 28 + }, 29 + { 30 + name: "range syntax with tags", 31 + args: []string{"v1.0.0..v2.0.0"}, 32 + expectedFrom: "v1.0.0", 33 + expectedTo: "v2.0.0", 34 + }, 35 + { 36 + name: "two separate arguments", 37 + args: []string{"abc123", "def456"}, 38 + expectedFrom: "abc123", 39 + expectedTo: "def456", 40 + }, 41 + { 42 + name: "single argument compares with HEAD", 43 + args: []string{"abc123"}, 44 + expectedFrom: "abc123", 45 + expectedTo: "HEAD", 46 + }, 47 + { 48 + name: "branch names", 49 + args: []string{"main", "feature-branch"}, 50 + expectedFrom: "main", 51 + expectedTo: "feature-branch", 52 + }, 53 + } 54 + 55 + for _, tt := range tests { 56 + t.Run(tt.name, func(t *testing.T) { 57 + from, to := parseRefArgs(tt.args) 58 + 59 + if from != tt.expectedFrom { 60 + t.Errorf("parseRefArgs() from = %v, want %v", from, tt.expectedFrom) 61 + } 62 + if to != tt.expectedTo { 63 + t.Errorf("parseRefArgs() to = %v, want %v", to, tt.expectedTo) 64 + } 65 + }) 66 + } 67 + } 68 + 69 + func TestGetChangedFiles(t *testing.T) { 70 + repo := testutils.SetupTestRepo(t) 71 + commits := testutils.GetCommitHistory(t, repo) 72 + 73 + if len(commits) < 2 { 74 + t.Fatal("Test repo should have at least 2 commits") 75 + } 76 + 77 + fromHash := commits[1].Hash.String() 78 + toHash := commits[0].Hash.String() 79 + 80 + files, err := getChangedFiles(repo, fromHash, toHash) 81 + if err != nil { 82 + t.Fatalf("getChangedFiles() error = %v", err) 83 + } 84 + 85 + if len(files) == 0 { 86 + t.Error("Expected at least one changed file") 87 + } 88 + 89 + for _, file := range files { 90 + if file == "" { 91 + t.Error("File path should not be empty") 92 + } 93 + } 94 + } 95 + 96 + func TestGetChangedFiles_NoChanges(t *testing.T) { 97 + repo := testutils.SetupTestRepo(t) 98 + 99 + commits := testutils.GetCommitHistory(t, repo) 100 + if len(commits) == 0 { 101 + t.Fatal("Test repo should have at least 1 commit") 102 + } 103 + 104 + hash := commits[0].Hash.String() 105 + 106 + files, err := getChangedFiles(repo, hash, hash) 107 + if err != nil { 108 + t.Fatalf("getChangedFiles() error = %v", err) 109 + } 110 + 111 + if len(files) != 0 { 112 + t.Errorf("Expected no changed files when comparing commit with itself, got %d", len(files)) 113 + } 114 + } 115 + 116 + func TestGetFileContent(t *testing.T) { 117 + repo := testutils.SetupTestRepo(t) 118 + 119 + commits := testutils.GetCommitHistory(t, repo) 120 + if len(commits) == 0 { 121 + t.Fatal("Test repo should have at least 1 commit") 122 + } 123 + 124 + hash := commits[0].Hash.String() 125 + 126 + content, err := getFileContent(repo, hash, "README.md") 127 + if err != nil { 128 + t.Fatalf("getFileContent() error = %v", err) 129 + } 130 + 131 + if content == "" { 132 + t.Error("Expected non-empty content for README.md") 133 + } 134 + 135 + if !strings.Contains(content, "Project") { 136 + t.Error("README.md should contain 'Project'") 137 + } 138 + } 139 + 140 + func TestGetFileContent_FileNotFound(t *testing.T) { 141 + repo := testutils.SetupTestRepo(t) 142 + 143 + commits := testutils.GetCommitHistory(t, repo) 144 + if len(commits) == 0 { 145 + t.Fatal("Test repo should have at least 1 commit") 146 + } 147 + 148 + hash := commits[0].Hash.String() 149 + 150 + _, err := getFileContent(repo, hash, "nonexistent.txt") 151 + if err == nil { 152 + t.Error("Expected error when reading nonexistent file") 153 + } 154 + } 155 + 156 + func TestGetFileContent_InvalidRef(t *testing.T) { 157 + repo := testutils.SetupTestRepo(t) 158 + 159 + _, err := getFileContent(repo, "invalid-ref-12345", "README.md") 160 + if err == nil { 161 + t.Error("Expected error when using invalid ref") 162 + } 163 + }
+1
go.mod
··· 15 github.com/clipperhouse/displaywidth v0.4.1 // indirect 16 github.com/clipperhouse/stringish v0.1.1 // indirect 17 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 18 ) 19 20 require (
··· 15 github.com/clipperhouse/displaywidth v0.4.1 // indirect 16 github.com/clipperhouse/stringish v0.1.1 // indirect 17 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 18 + github.com/muesli/reflow v0.3.0 19 ) 20 21 require (
+5
go.sum
··· 91 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 92 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 93 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 94 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 95 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 96 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= ··· 103 github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 104 github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 105 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 106 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 107 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 108 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= ··· 111 github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 112 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 113 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 114 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 115 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 116 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
··· 91 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 92 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 93 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 94 + github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 95 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 96 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 97 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= ··· 104 github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 105 github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 106 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 107 + github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 108 + github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 109 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 110 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 111 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= ··· 114 github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 115 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 116 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 117 + github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 118 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 119 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 120 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 121 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+196 -4
internal/diff/diff.go
··· 27 28 // Edit represents a single edit operation in a diff. 29 type Edit struct { 30 - Kind EditKind // Equal, Insert, or Delete 31 - AIndex int // index in original sequence 32 - BIndex int // index in new sequence 33 - Content string // the line or token 34 } 35 36 // Diff represents a generic diffing algorithm. ··· 349 } 350 return counts 351 }
··· 27 28 // Edit represents a single edit operation in a diff. 29 type Edit struct { 30 + Kind EditKind // Equal, Insert, Delete, or Replace 31 + AIndex int // index in original sequence (-1 for Insert-only) 32 + BIndex int // index in new sequence (-1 for Delete-only) 33 + Content string // the line or token (old content for Replace) 34 + NewContent string // new content (only used for Replace operations) 35 } 36 37 // Diff represents a generic diffing algorithm. ··· 350 } 351 return counts 352 } 353 + 354 + // MergeReplacements merges Delete+Insert pairs into Replace operations for better side-by-side rendering. 355 + // 356 + // This function identifies blocks of Delete and Insert operations and pairs them up based on similarity. 357 + // When a Delete and Insert represent the same logical line being modified (e.g., version bump), 358 + // they are merged into a Replace operation that can be rendered on a single line. 359 + // 360 + // The function uses a similarity heuristic to determine if a Delete and Insert pair should be merged: 361 + // - They must share a common prefix of at least 70% of the shorter line's length 362 + // - This prevents merging unrelated changes (e.g., different package names) 363 + // 364 + // The algorithm processes edits in windows, looking ahead up to 10 positions to find matching pairs. 365 + func MergeReplacements(edits []Edit) []Edit { 366 + if len(edits) <= 1 { 367 + return edits 368 + } 369 + 370 + type mergeInfo struct { 371 + partnIndex int // index of the partner edit 372 + isDelete bool 373 + } 374 + 375 + merged := make(map[int]mergeInfo) 376 + const lookAheadWindow = 50 377 + 378 + for i := range edits { 379 + if _, exists := merged[i]; exists || edits[i].Kind != Delete { 380 + continue 381 + } 382 + 383 + found := false 384 + for j := i + 1; j < len(edits) && j < i+lookAheadWindow; j++ { 385 + if _, exists := merged[j]; exists || edits[j].Kind != Insert { 386 + continue 387 + } 388 + 389 + if areSimilarLines(edits[i].Content, edits[j].Content) { 390 + merged[i] = mergeInfo{partnIndex: j, isDelete: true} 391 + merged[j] = mergeInfo{partnIndex: i, isDelete: false} 392 + found = true 393 + break 394 + } 395 + } 396 + 397 + if !found { 398 + for j := i - 1; j >= 0 && j >= i-lookAheadWindow; j-- { 399 + if _, exists := merged[j]; exists || edits[j].Kind != Insert { 400 + continue 401 + } 402 + 403 + if areSimilarLines(edits[i].Content, edits[j].Content) { 404 + merged[i] = mergeInfo{partnIndex: j, isDelete: true} 405 + merged[j] = mergeInfo{partnIndex: i, isDelete: false} 406 + break 407 + } 408 + } 409 + } 410 + } 411 + 412 + for i := 0; i < len(edits); i++ { 413 + if _, exists := merged[i]; exists || edits[i].Kind != Insert { 414 + continue 415 + } 416 + 417 + for j := max(0, i-lookAheadWindow); j < i; j++ { 418 + if _, exists := merged[j]; exists || edits[j].Kind != Delete { 419 + continue 420 + } 421 + 422 + if areSimilarLines(edits[j].Content, edits[i].Content) { 423 + merged[j] = mergeInfo{partnIndex: i, isDelete: true} 424 + merged[i] = mergeInfo{partnIndex: j, isDelete: false} 425 + break 426 + } 427 + } 428 + } 429 + 430 + type outputEdit struct { 431 + edit Edit 432 + origPosition int 433 + } 434 + 435 + outputs := make([]outputEdit, 0, len(edits)) 436 + 437 + for i := range edits { 438 + info, isMerged := merged[i] 439 + if !isMerged { 440 + outputs = append(outputs, outputEdit{ 441 + edit: edits[i], 442 + origPosition: i, 443 + }) 444 + } else if info.isDelete { 445 + outputs = append(outputs, outputEdit{ 446 + edit: Edit{ 447 + Kind: Replace, 448 + AIndex: edits[i].AIndex, 449 + BIndex: edits[info.partnIndex].BIndex, 450 + Content: edits[i].Content, 451 + NewContent: edits[info.partnIndex].Content, 452 + }, 453 + origPosition: i, 454 + }) 455 + } 456 + } 457 + 458 + for i := 0; i < len(outputs); i++ { 459 + for j := i + 1; j < len(outputs); j++ { 460 + ei := outputs[i].edit 461 + ej := outputs[j].edit 462 + 463 + // Get effective sort keys 464 + keyI := ei.BIndex 465 + if keyI == -1 { 466 + keyI = ei.AIndex 467 + } 468 + 469 + keyJ := ej.BIndex 470 + if keyJ == -1 { 471 + keyJ = ej.AIndex 472 + } 473 + 474 + if keyI > keyJ { 475 + outputs[i], outputs[j] = outputs[j], outputs[i] 476 + } 477 + } 478 + } 479 + 480 + result := make([]Edit, 0, len(outputs)) 481 + for _, out := range outputs { 482 + result = append(result, out.edit) 483 + } 484 + 485 + return result 486 + } 487 + 488 + // areSimilarLines determines if two lines are similar enough to be considered a replacement. 489 + // 490 + // Uses a two-phase similarity check: 491 + // 1. Common prefix must be at least 70% of the shorter line 492 + // 2. Remaining suffixes must be at least 60% similar (Levenshtein-like check) 493 + func areSimilarLines(a, b string) bool { 494 + if a == b { 495 + return true 496 + } 497 + 498 + minLen := len(a) 499 + if len(b) < minLen { 500 + minLen = len(b) 501 + } 502 + 503 + if minLen == 0 { 504 + return false 505 + } 506 + 507 + commonPrefix := 0 508 + for i := 0; i < minLen; i++ { 509 + if a[i] == b[i] { 510 + commonPrefix++ 511 + } else { 512 + break 513 + } 514 + } 515 + 516 + prefixThreshold := float64(minLen) * 0.7 517 + if float64(commonPrefix) < prefixThreshold { 518 + return false 519 + } 520 + 521 + suffixA := a[commonPrefix:] 522 + suffixB := b[commonPrefix:] 523 + 524 + suffixLenA := len(suffixA) 525 + suffixLenB := len(suffixB) 526 + 527 + if suffixLenA == 0 && suffixLenB == 0 { 528 + return true 529 + } 530 + 531 + lenDiff := suffixLenA - suffixLenB 532 + if lenDiff < 0 { 533 + lenDiff = -lenDiff 534 + } 535 + 536 + maxSuffixLen := max(suffixLenB, suffixLenA) 537 + 538 + if maxSuffixLen > 0 && float64(lenDiff)/float64(maxSuffixLen) > 0.3 { 539 + return false 540 + } 541 + 542 + return true 543 + }
+193
internal/diff/diff_test.go
··· 262 }) 263 } 264 }
··· 262 }) 263 } 264 } 265 + 266 + func TestAreSimilarLines(t *testing.T) { 267 + tests := []struct { 268 + name string 269 + a string 270 + b string 271 + expected bool 272 + }{ 273 + { 274 + name: "identical lines", 275 + a: "github.com/foo/bar v1.0.0", 276 + b: "github.com/foo/bar v1.0.0", 277 + expected: true, 278 + }, 279 + { 280 + name: "similar package different version", 281 + a: "github.com/charmbracelet/x/ansi v0.10.1 // indirect", 282 + b: "github.com/charmbracelet/x/ansi v0.10.3 // indirect", 283 + expected: true, 284 + }, 285 + { 286 + name: "different packages", 287 + a: "github.com/charmbracelet/x/term v0.2.1 // indirect", 288 + b: "github.com/charmbracelet/x/exp/teatest v0.0.0-20251", 289 + expected: false, 290 + }, 291 + { 292 + name: "empty strings", 293 + a: "", 294 + b: "", 295 + expected: true, 296 + }, 297 + { 298 + name: "one empty", 299 + a: "some content", 300 + b: "", 301 + expected: false, 302 + }, 303 + { 304 + name: "completely different", 305 + a: "package main", 306 + b: "import fmt", 307 + expected: false, 308 + }, 309 + { 310 + name: "short common prefix", 311 + a: "github.com/foo/bar", 312 + b: "github.com/baz/qux", 313 + expected: false, 314 + }, 315 + } 316 + 317 + for _, tt := range tests { 318 + t.Run(tt.name, func(t *testing.T) { 319 + result := areSimilarLines(tt.a, tt.b) 320 + if result != tt.expected { 321 + t.Errorf("areSimilarLines(%q, %q) = %v, want %v", tt.a, tt.b, result, tt.expected) 322 + } 323 + }) 324 + } 325 + } 326 + 327 + func TestMergeReplacements(t *testing.T) { 328 + tests := []struct { 329 + name string 330 + input []Edit 331 + expected []Edit 332 + }{ 333 + { 334 + name: "empty edits", 335 + input: []Edit{}, 336 + expected: []Edit{}, 337 + }, 338 + { 339 + name: "go.mod scenario - deletes followed by inserts with gap", 340 + input: []Edit{ 341 + {Kind: Delete, AIndex: 17, BIndex: -1, Content: " github.com/charmbracelet/colorprofile v0.3.2 // indirect"}, 342 + {Kind: Delete, AIndex: 18, BIndex: -1, Content: " github.com/charmbracelet/lipgloss/v2"}, 343 + {Kind: Delete, AIndex: 19, BIndex: -1, Content: " github.com/charmbracelet/ultraviolet"}, 344 + {Kind: Delete, AIndex: 20, BIndex: -1, Content: " github.com/charmbracelet/x/ansi v0.10.1 // indirect"}, 345 + {Kind: Insert, AIndex: -1, BIndex: 23, Content: " github.com/aymanbagabas/go-udiff v0.3.1 // indirect"}, 346 + {Kind: Insert, AIndex: -1, BIndex: 24, Content: " github.com/charmbracelet/bubbletea v1.3.10"}, 347 + {Kind: Insert, AIndex: -1, BIndex: 25, Content: " github.com/charmbracelet/colorprofile v0.3.3 // indirect"}, 348 + {Kind: Insert, AIndex: -1, BIndex: 26, Content: " github.com/charmbracelet/lipgloss/v2"}, 349 + {Kind: Insert, AIndex: -1, BIndex: 27, Content: " github.com/charmbracelet/ultraviolet"}, 350 + {Kind: Insert, AIndex: -1, BIndex: 28, Content: " github.com/charmbracelet/x/ansi v0.10.3 // indirect"}, 351 + }, 352 + expected: []Edit{ 353 + {Kind: Replace, AIndex: 17, BIndex: 25, Content: " github.com/charmbracelet/colorprofile v0.3.2 // indirect", NewContent: " github.com/charmbracelet/colorprofile v0.3.3 // indirect"}, 354 + {Kind: Replace, AIndex: 18, BIndex: 26, Content: " github.com/charmbracelet/lipgloss/v2", NewContent: " github.com/charmbracelet/lipgloss/v2"}, 355 + {Kind: Replace, AIndex: 19, BIndex: 27, Content: " github.com/charmbracelet/ultraviolet", NewContent: " github.com/charmbracelet/ultraviolet"}, 356 + {Kind: Replace, AIndex: 20, BIndex: 28, Content: " github.com/charmbracelet/x/ansi v0.10.1 // indirect", NewContent: " github.com/charmbracelet/x/ansi v0.10.3 // indirect"}, 357 + {Kind: Insert, AIndex: -1, BIndex: 23, Content: " github.com/aymanbagabas/go-udiff v0.3.1 // indirect"}, 358 + {Kind: Insert, AIndex: -1, BIndex: 24, Content: " github.com/charmbracelet/bubbletea v1.3.10"}, 359 + }, 360 + }, 361 + { 362 + name: "single edit", 363 + input: []Edit{ 364 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 365 + }, 366 + expected: []Edit{ 367 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 368 + }, 369 + }, 370 + { 371 + name: "merge similar delete and insert", 372 + input: []Edit{ 373 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 374 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "github.com/foo/bar v2.0.0"}, 375 + }, 376 + expected: []Edit{ 377 + {Kind: Replace, AIndex: 0, BIndex: 0, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"}, 378 + }, 379 + }, 380 + { 381 + name: "don't merge dissimilar delete and insert", 382 + input: []Edit{ 383 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 384 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "import fmt"}, 385 + }, 386 + expected: []Edit{ 387 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 388 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "import fmt"}, 389 + }, 390 + }, 391 + { 392 + name: "merge insert and delete (reversed order)", 393 + input: []Edit{ 394 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "github.com/foo/bar v2.0.0"}, 395 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 396 + }, 397 + expected: []Edit{ 398 + {Kind: Replace, AIndex: 0, BIndex: 0, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"}, 399 + }, 400 + }, 401 + { 402 + name: "mixed operations with merge", 403 + input: []Edit{ 404 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 405 + {Kind: Delete, AIndex: 1, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 406 + {Kind: Insert, AIndex: -1, BIndex: 1, Content: "github.com/foo/bar v2.0.0"}, 407 + {Kind: Equal, AIndex: 2, BIndex: 2, Content: "line3"}, 408 + }, 409 + expected: []Edit{ 410 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 411 + {Kind: Replace, AIndex: 1, BIndex: 1, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"}, 412 + {Kind: Equal, AIndex: 2, BIndex: 2, Content: "line3"}, 413 + }, 414 + }, 415 + { 416 + name: "multiple inserts and deletes without merge", 417 + input: []Edit{ 418 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "deleted line 1"}, 419 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new content A"}, 420 + {Kind: Insert, AIndex: -1, BIndex: 1, Content: "new content B"}, 421 + }, 422 + expected: []Edit{ 423 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "deleted line 1"}, 424 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new content A"}, 425 + {Kind: Insert, AIndex: -1, BIndex: 1, Content: "new content B"}, 426 + }, 427 + }, 428 + } 429 + 430 + for _, tt := range tests { 431 + t.Run(tt.name, func(t *testing.T) { 432 + result := MergeReplacements(tt.input) 433 + 434 + if len(result) != len(tt.expected) { 435 + t.Fatalf("expected %d edits, got %d", len(tt.expected), len(result)) 436 + } 437 + 438 + for i := range result { 439 + if result[i].Kind != tt.expected[i].Kind { 440 + t.Errorf("edit %d: expected Kind %v, got %v", i, tt.expected[i].Kind, result[i].Kind) 441 + } 442 + if result[i].AIndex != tt.expected[i].AIndex { 443 + t.Errorf("edit %d: expected AIndex %d, got %d", i, tt.expected[i].AIndex, result[i].AIndex) 444 + } 445 + if result[i].BIndex != tt.expected[i].BIndex { 446 + t.Errorf("edit %d: expected BIndex %d, got %d", i, tt.expected[i].BIndex, result[i].BIndex) 447 + } 448 + if result[i].Content != tt.expected[i].Content { 449 + t.Errorf("edit %d: expected Content %q, got %q", i, tt.expected[i].Content, result[i].Content) 450 + } 451 + if result[i].NewContent != tt.expected[i].NewContent { 452 + t.Errorf("edit %d: expected NewContent %q, got %q", i, tt.expected[i].NewContent, result[i].NewContent) 453 + } 454 + } 455 + }) 456 + } 457 + }
+206 -28
internal/diff/format.go
··· 5 "strings" 6 7 "github.com/charmbracelet/lipgloss" 8 "github.com/stormlightlabs/git-storm/internal/style" 9 ) 10 11 const ( 12 - // Layout constants for side-by-side view 13 - lineNumWidth = 4 14 - gutterWidth = 3 15 - minPaneWidth = 40 16 ) 17 18 // SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting. ··· 21 TerminalWidth int 22 // ShowLineNumbers controls whether line numbers are displayed 23 ShowLineNumbers bool 24 } 25 26 // Format renders the edits as a styled side-by-side diff string. 27 // 28 // The left pane shows the old content (deletions and unchanged lines). 29 // The right pane shows the new content (insertions and unchanged lines). 30 - // Line numbers and color coding help visualize the changes. 31 func (f *SideBySideFormatter) Format(edits []Edit) string { 32 if len(edits) == 0 { 33 return style.StyleText.Render("No changes") 34 } 35 36 paneWidth := f.calculatePaneWidth() 37 38 var sb strings.Builder 39 lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true) 40 41 - for _, edit := range edits { 42 left, right := f.renderEdit(edit, paneWidth) 43 44 if f.ShowLineNumbers { ··· 69 } 70 71 availableWidth := f.TerminalWidth - usedWidth 72 paneWidth := availableWidth / 2 73 74 if paneWidth < minPaneWidth { 75 - paneWidth = minPaneWidth 76 } 77 78 return paneWidth ··· 82 func (f *SideBySideFormatter) renderEdit(edit Edit, paneWidth int) (left, right string) { 83 content := f.truncateContent(edit.Content, paneWidth) 84 85 switch edit.Kind { 86 case Equal: 87 - // Show on both sides with neutral styling 88 - leftStyled := style.StyleText.Width(paneWidth).Render(content) 89 - rightStyled := style.StyleText.Width(paneWidth).Render(content) 90 return leftStyled, rightStyled 91 92 case Delete: 93 - // Show on left in red, empty right 94 - leftStyled := style.StyleRemoved.Width(paneWidth).Render(content) 95 - rightStyled := lipgloss.NewStyle().Width(paneWidth).Render("") 96 return leftStyled, rightStyled 97 98 case Insert: 99 - // Empty left, show on right in green 100 - leftStyled := lipgloss.NewStyle().Width(paneWidth).Render("") 101 - rightStyled := style.StyleAdded.Width(paneWidth).Render(content) 102 return leftStyled, rightStyled 103 104 default: 105 - // Fallback for unknown edit kinds 106 - return lipgloss.NewStyle().Width(paneWidth).Render(content), 107 - lipgloss.NewStyle().Width(paneWidth).Render(content) 108 } 109 } 110 111 // renderGutter creates the visual separator between left and right panes. 112 func (f *SideBySideFormatter) renderGutter(kind EditKind) string { 113 var symbol string ··· 115 116 switch kind { 117 case Equal: 118 - symbol = " │ " 119 st = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) 120 case Delete: 121 - symbol = " < " 122 st = style.StyleRemoved 123 case Insert: 124 - symbol = " > " 125 st = style.StyleAdded 126 default: 127 - symbol = " │ " 128 st = lipgloss.NewStyle() 129 } 130 ··· 139 return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1)) 140 } 141 142 - // truncateContent ensures content fits within the pane width. 143 func (f *SideBySideFormatter) truncateContent(content string, maxWidth int) string { 144 - // Remove trailing whitespace but preserve leading indentation 145 content = strings.TrimRight(content, " \t\r\n") 146 147 - if len(content) <= maxWidth { 148 return content 149 } 150 151 if maxWidth <= 3 { 152 - return content[:maxWidth] 153 } 154 155 - return content[:maxWidth-3] + "..." 156 }
··· 5 "strings" 6 7 "github.com/charmbracelet/lipgloss" 8 + "github.com/muesli/reflow/wordwrap" 9 "github.com/stormlightlabs/git-storm/internal/style" 10 ) 11 12 const ( 13 + SymbolAdd = "┃" // addition 14 + SymbolChange = "▎" // modification/change 15 + SymbolDeleteLine = "_" // line removed 16 + SymbolTopDelete = "‾" // deletion at top (overline) 17 + SymbolChangeDelete = "~" // change + delete (hunk combined) 18 + SymbolUntracked = "┆" // untracked lines/files 19 + 20 + AsciiSymbolAdd = "|" // addition 21 + AsciiSymbolChange = "|" // modification (same as add fallback) 22 + AsciiSymbolDeleteLine = "-" // deletion line 23 + AsciiSymbolTopDelete = "^" // “top delete” fallback 24 + AsciiSymbolChangeDelete = "~" // change+delete still ~ 25 + AsciiSymbolUntracked = ":" // untracked fallback 26 + 27 + lineNumWidth = 4 28 + gutterWidth = 3 29 + minPaneWidth = 40 30 + contextLines = 3 // Lines to show before/after changes 31 + minUnchangedToHide = 10 // Minimum unchanged lines before hiding 32 + compressedIndicator = "⋮" 33 ) 34 35 // SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting. ··· 38 TerminalWidth int 39 // ShowLineNumbers controls whether line numbers are displayed 40 ShowLineNumbers bool 41 + // Expanded controls whether to show all unchanged lines or compress them 42 + Expanded bool 43 + // EnableWordWrap enables word wrapping for long lines 44 + EnableWordWrap bool 45 } 46 47 // Format renders the edits as a styled side-by-side diff string. 48 // 49 // The left pane shows the old content (deletions and unchanged lines). 50 // The right pane shows the new content (insertions and unchanged lines). 51 func (f *SideBySideFormatter) Format(edits []Edit) string { 52 if len(edits) == 0 { 53 return style.StyleText.Render("No changes") 54 } 55 56 + processedEdits := MergeReplacements(edits) 57 + 58 + if !f.Expanded { 59 + processedEdits = f.compressUnchangedBlocks(processedEdits) 60 + } 61 + 62 paneWidth := f.calculatePaneWidth() 63 64 var sb strings.Builder 65 lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true) 66 67 + for _, edit := range processedEdits { 68 left, right := f.renderEdit(edit, paneWidth) 69 70 if f.ShowLineNumbers { ··· 95 } 96 97 availableWidth := f.TerminalWidth - usedWidth 98 + if availableWidth < 0 { 99 + availableWidth = 0 100 + } 101 + 102 paneWidth := availableWidth / 2 103 104 if paneWidth < minPaneWidth { 105 + totalNeeded := usedWidth + (2 * minPaneWidth) 106 + if totalNeeded > f.TerminalWidth { 107 + return paneWidth 108 + } 109 + return minPaneWidth 110 } 111 112 return paneWidth ··· 116 func (f *SideBySideFormatter) renderEdit(edit Edit, paneWidth int) (left, right string) { 117 content := f.truncateContent(edit.Content, paneWidth) 118 119 + if edit.AIndex == -2 && edit.BIndex == -2 { 120 + compressedStyle := lipgloss.NewStyle(). 121 + Foreground(lipgloss.Color("#6C7A89")). 122 + Faint(true). 123 + Italic(true) 124 + styled := f.padToWidth(compressedStyle.Render(content), paneWidth) 125 + return styled, styled 126 + } 127 + 128 switch edit.Kind { 129 case Equal: 130 + leftStyled := f.padToWidth(style.StyleText.Render(content), paneWidth) 131 + rightStyled := f.padToWidth(style.StyleText.Render(content), paneWidth) 132 return leftStyled, rightStyled 133 134 case Delete: 135 + leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth) 136 + rightStyled := f.padToWidth("", paneWidth) 137 return leftStyled, rightStyled 138 139 case Insert: 140 + leftStyled := f.padToWidth("", paneWidth) 141 + rightStyled := f.padToWidth(style.StyleAdded.Render(content), paneWidth) 142 + return leftStyled, rightStyled 143 + 144 + case Replace: 145 + newContent := f.truncateContent(edit.NewContent, paneWidth) 146 + leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth) 147 + rightStyled := f.padToWidth(style.StyleAdded.Render(newContent), paneWidth) 148 return leftStyled, rightStyled 149 150 default: 151 + return f.padToWidth(content, paneWidth), 152 + f.padToWidth(content, paneWidth) 153 } 154 } 155 156 + // padToWidth pads a string with spaces to reach the target width. 157 + // If the string exceeds the target width, it truncates it. 158 + func (f *SideBySideFormatter) padToWidth(s string, targetWidth int) string { 159 + currentWidth := lipgloss.Width(s) 160 + 161 + if currentWidth > targetWidth { 162 + return truncateToWidth(s, targetWidth) 163 + } 164 + 165 + if currentWidth == targetWidth { 166 + return s 167 + } 168 + 169 + padding := strings.Repeat(" ", targetWidth-currentWidth) 170 + return s + padding 171 + } 172 + 173 // renderGutter creates the visual separator between left and right panes. 174 func (f *SideBySideFormatter) renderGutter(kind EditKind) string { 175 var symbol string ··· 177 178 switch kind { 179 case Equal: 180 + symbol = " " + SymbolUntracked + " " 181 st = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) 182 case Delete: 183 + symbol = " " + SymbolDeleteLine + " " 184 st = style.StyleRemoved 185 case Insert: 186 + symbol = " " + SymbolAdd + " " 187 st = style.StyleAdded 188 + case Replace: 189 + symbol = " " + SymbolChange + " " 190 + st = style.StyleChanged 191 default: 192 + symbol = " " + SymbolUntracked + " " 193 st = lipgloss.NewStyle() 194 } 195 ··· 204 return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1)) 205 } 206 207 + // truncateContent ensures content fits within the pane width using proper display width. 208 func (f *SideBySideFormatter) truncateContent(content string, maxWidth int) string { 209 content = strings.TrimRight(content, " \t\r\n") 210 211 + if f.EnableWordWrap { 212 + wrapped := wordwrap.String(content, maxWidth) 213 + lines := strings.Split(wrapped, "\n") 214 + if len(lines) > 0 { 215 + return lines[0] 216 + } 217 + return wrapped 218 + } 219 + 220 + displayWidth := lipgloss.Width(content) 221 + 222 + if displayWidth <= maxWidth { 223 return content 224 } 225 226 if maxWidth <= 3 { 227 + return truncateToWidth(content, maxWidth) 228 + } 229 + 230 + targetWidth := maxWidth - 3 231 + truncated := truncateToWidth(content, targetWidth) 232 + return truncated + "..." 233 + } 234 + 235 + // truncateToWidth truncates a string to a specific display width. 236 + func truncateToWidth(s string, width int) string { 237 + if width <= 0 { 238 + return "" 239 + } 240 + 241 + var result strings.Builder 242 + currentWidth := 0 243 + 244 + for _, r := range s { 245 + runeWidth := lipgloss.Width(string(r)) 246 + 247 + if currentWidth+runeWidth > width { 248 + break 249 + } 250 + 251 + result.WriteRune(r) 252 + currentWidth += runeWidth 253 + } 254 + 255 + return result.String() 256 + } 257 + 258 + // compressUnchangedBlocks compresses large blocks of unchanged lines. 259 + // 260 + // It keeps contextLines before and after changes, and replaces large 261 + // blocks of unchanged lines with a single compressed indicator. 262 + func (f *SideBySideFormatter) compressUnchangedBlocks(edits []Edit) []Edit { 263 + if len(edits) == 0 { 264 + return edits 265 + } 266 + 267 + var result []Edit 268 + var unchangedRun []Edit 269 + 270 + for i, edit := range edits { 271 + if edit.Kind == Equal { 272 + unchangedRun = append(unchangedRun, edit) 273 + 274 + isLast := i == len(edits)-1 275 + nextIsChanged := !isLast && edits[i+1].Kind != Equal 276 + 277 + if isLast || nextIsChanged { 278 + if len(unchangedRun) >= minUnchangedToHide { 279 + for j := 0; j < contextLines && j < len(unchangedRun); j++ { 280 + result = append(result, unchangedRun[j]) 281 + } 282 + 283 + hiddenCount := len(unchangedRun) - (2 * contextLines) 284 + if hiddenCount > 0 { 285 + result = append(result, Edit{ 286 + Kind: Equal, 287 + AIndex: -2, 288 + BIndex: -2, 289 + Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 290 + }) 291 + } 292 + 293 + start := max(len(unchangedRun)-contextLines, contextLines) 294 + for j := start; j < len(unchangedRun); j++ { 295 + result = append(result, unchangedRun[j]) 296 + } 297 + } else { 298 + result = append(result, unchangedRun...) 299 + } 300 + unchangedRun = nil 301 + } 302 + } else { 303 + if len(unchangedRun) > 0 { 304 + if len(unchangedRun) >= minUnchangedToHide { 305 + for j := 0; j < contextLines && j < len(unchangedRun); j++ { 306 + result = append(result, unchangedRun[j]) 307 + } 308 + 309 + hiddenCount := len(unchangedRun) - (2 * contextLines) 310 + if hiddenCount > 0 { 311 + result = append(result, Edit{ 312 + Kind: Equal, 313 + AIndex: -2, 314 + BIndex: -2, 315 + Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 316 + }) 317 + } 318 + 319 + start := max(len(unchangedRun)-contextLines, contextLines) 320 + for j := start; j < len(unchangedRun); j++ { 321 + result = append(result, unchangedRun[j]) 322 + } 323 + } else { 324 + result = append(result, unchangedRun...) 325 + } 326 + unchangedRun = nil 327 + } 328 + 329 + result = append(result, edit) 330 + } 331 } 332 333 + return result 334 }
+157
internal/diff/format_compress_test.go
···
··· 1 + package diff 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestSideBySideFormatter_CompressUnchangedBlocks(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + edits []Edit 12 + expectedCompressed, expectedTotal int 13 + }{ 14 + { 15 + name: "no compression for small unchanged blocks", 16 + edits: makeEqualEdits(5), 17 + expectedCompressed: 0, 18 + expectedTotal: 5, 19 + }, 20 + { 21 + name: "compress large unchanged block", 22 + edits: makeEqualEdits(20), 23 + expectedCompressed: 1, 24 + expectedTotal: 7, 25 + }, 26 + { 27 + name: "compress unchanged between changes", 28 + edits: []Edit{ 29 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new line"}, 30 + {Kind: Equal, AIndex: 0, BIndex: 1, Content: "unchanged 1"}, 31 + {Kind: Equal, AIndex: 1, BIndex: 2, Content: "unchanged 2"}, 32 + {Kind: Equal, AIndex: 2, BIndex: 3, Content: "unchanged 3"}, 33 + {Kind: Equal, AIndex: 3, BIndex: 4, Content: "unchanged 4"}, 34 + {Kind: Equal, AIndex: 4, BIndex: 5, Content: "unchanged 5"}, 35 + {Kind: Equal, AIndex: 5, BIndex: 6, Content: "unchanged 6"}, 36 + {Kind: Equal, AIndex: 6, BIndex: 7, Content: "unchanged 7"}, 37 + {Kind: Equal, AIndex: 7, BIndex: 8, Content: "unchanged 8"}, 38 + {Kind: Equal, AIndex: 8, BIndex: 9, Content: "unchanged 9"}, 39 + {Kind: Equal, AIndex: 9, BIndex: 10, Content: "unchanged 10"}, 40 + {Kind: Equal, AIndex: 10, BIndex: 11, Content: "unchanged 11"}, 41 + {Kind: Equal, AIndex: 11, BIndex: 12, Content: "unchanged 12"}, 42 + {Kind: Equal, AIndex: 12, BIndex: 13, Content: "unchanged 13"}, 43 + {Kind: Delete, AIndex: 13, BIndex: -1, Content: "removed line"}, 44 + }, 45 + expectedCompressed: 1, 46 + expectedTotal: 9, 47 + }, 48 + } 49 + 50 + for _, tt := range tests { 51 + t.Run(tt.name, func(t *testing.T) { 52 + formatter := &SideBySideFormatter{} 53 + 54 + result := formatter.compressUnchangedBlocks(tt.edits) 55 + 56 + if len(result) != tt.expectedTotal { 57 + t.Errorf("Expected %d total edits after compression, got %d", tt.expectedTotal, len(result)) 58 + } 59 + 60 + compressed := countCompressedBlocks(result) 61 + if compressed != tt.expectedCompressed { 62 + t.Errorf("Expected %d compressed blocks, got %d", tt.expectedCompressed, compressed) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestSideBySideFormatter_Expanded(t *testing.T) { 69 + edits := makeEqualEdits(20) 70 + 71 + compressedFormatter := &SideBySideFormatter{ 72 + TerminalWidth: 100, 73 + ShowLineNumbers: true, 74 + Expanded: false, 75 + } 76 + 77 + expandedFormatter := &SideBySideFormatter{ 78 + TerminalWidth: 100, 79 + ShowLineNumbers: true, 80 + Expanded: true, 81 + } 82 + 83 + compressedOutput := compressedFormatter.Format(edits) 84 + expandedOutput := expandedFormatter.Format(edits) 85 + 86 + compressedLines := strings.Split(compressedOutput, "\n") 87 + expandedLines := strings.Split(expandedOutput, "\n") 88 + 89 + if len(expandedLines) <= len(compressedLines) { 90 + t.Errorf("Expanded output (%d lines) should have more lines than compressed (%d lines)", 91 + len(expandedLines), len(compressedLines)) 92 + } 93 + 94 + if !strings.Contains(compressedOutput, compressedIndicator) { 95 + t.Error("Compressed output should contain compression indicator") 96 + } 97 + 98 + if strings.Contains(expandedOutput, compressedIndicator) { 99 + t.Error("Expanded output should not contain compression indicator") 100 + } 101 + } 102 + 103 + func TestSideBySideFormatter_CompressedIndicatorStyling(t *testing.T) { 104 + formatter := &SideBySideFormatter{ 105 + TerminalWidth: 100, 106 + ShowLineNumbers: true, 107 + Expanded: false, 108 + } 109 + 110 + edits := makeEqualEdits(20) 111 + output := formatter.Format(edits) 112 + 113 + if !strings.Contains(output, "unchanged lines") { 114 + t.Error("Compressed output should mention 'unchanged lines'") 115 + } 116 + } 117 + 118 + func TestMultipleCompressedBlocks(t *testing.T) { 119 + formatter := &SideBySideFormatter{} 120 + 121 + edits := []Edit{} 122 + edits = append(edits, makeEqualEdits(20)...) 123 + edits = append(edits, Edit{Kind: Insert, AIndex: -1, BIndex: 0, Content: "change 1"}) 124 + edits = append(edits, makeEqualEdits(20)...) 125 + edits = append(edits, Edit{Kind: Delete, AIndex: 0, BIndex: -1, Content: "change 2"}) 126 + edits = append(edits, makeEqualEdits(20)...) 127 + 128 + result := formatter.compressUnchangedBlocks(edits) 129 + 130 + compressed := countCompressedBlocks(result) 131 + if compressed != 3 { 132 + t.Errorf("Expected 3 compressed blocks, got %d", compressed) 133 + } 134 + } 135 + 136 + func makeEqualEdits(count int) []Edit { 137 + edits := make([]Edit, count) 138 + for i := range count { 139 + edits[i] = Edit{ 140 + Kind: Equal, 141 + AIndex: i, 142 + BIndex: i, 143 + Content: "unchanged line", 144 + } 145 + } 146 + return edits 147 + } 148 + 149 + func countCompressedBlocks(edits []Edit) int { 150 + count := 0 151 + for _, edit := range edits { 152 + if edit.AIndex == -2 && edit.BIndex == -2 { 153 + count++ 154 + } 155 + } 156 + return count 157 + }
+70 -4
internal/diff/format_test.go
··· 3 import ( 4 "strings" 5 "testing" 6 ) 7 8 func TestSideBySideFormatter_Format(t *testing.T) { ··· 37 }, 38 width: 80, 39 expect: func(output string) bool { 40 - return strings.Contains(output, "new line") && strings.Contains(output, ">") 41 }, 42 }, 43 { ··· 47 }, 48 width: 80, 49 expect: func(output string) bool { 50 - return strings.Contains(output, "old line") && strings.Contains(output, "<") 51 }, 52 }, 53 { ··· 100 name: "narrow terminal", 101 terminalWidth: 60, 102 showLineNumbers: true, 103 - minExpected: minPaneWidth, 104 }, 105 { 106 name: "without line numbers", ··· 122 if paneWidth < tt.minExpected { 123 t.Errorf("calculatePaneWidth() = %d, expected at least %d", paneWidth, tt.minExpected) 124 } 125 }) 126 } 127 } ··· 165 maxWidth: 10, 166 expected: "hello", 167 }, 168 } 169 170 for _, tt := range tests { 171 t.Run(tt.name, func(t *testing.T) { 172 result := formatter.truncateContent(tt.content, tt.maxWidth) 173 - if result != tt.expected { 174 t.Errorf("truncateContent() = %q, expected %q", result, tt.expected) 175 } 176 }) 177 }
··· 3 import ( 4 "strings" 5 "testing" 6 + 7 + "github.com/charmbracelet/lipgloss" 8 ) 9 10 func TestSideBySideFormatter_Format(t *testing.T) { ··· 39 }, 40 width: 80, 41 expect: func(output string) bool { 42 + return strings.Contains(output, "new line") && strings.Contains(output, SymbolAdd) 43 }, 44 }, 45 { ··· 49 }, 50 width: 80, 51 expect: func(output string) bool { 52 + return strings.Contains(output, "old line") && strings.Contains(output, SymbolDeleteLine) 53 }, 54 }, 55 { ··· 102 name: "narrow terminal", 103 terminalWidth: 60, 104 showLineNumbers: true, 105 + minExpected: 20, 106 }, 107 { 108 name: "without line numbers", ··· 124 if paneWidth < tt.minExpected { 125 t.Errorf("calculatePaneWidth() = %d, expected at least %d", paneWidth, tt.minExpected) 126 } 127 + 128 + usedWidth := gutterWidth 129 + if tt.showLineNumbers { 130 + usedWidth += 2 * lineNumWidth 131 + } 132 + totalWidth := usedWidth + (2 * paneWidth) 133 + if totalWidth > tt.terminalWidth { 134 + t.Errorf("Total width %d exceeds terminal width %d (paneWidth=%d)", totalWidth, tt.terminalWidth, paneWidth) 135 + } 136 + }) 137 + } 138 + } 139 + 140 + func TestPadToWidth(t *testing.T) { 141 + formatter := &SideBySideFormatter{} 142 + 143 + tests := []struct { 144 + name string 145 + input string 146 + targetWidth int 147 + }{ 148 + { 149 + name: "short string gets padded", 150 + input: "hello", 151 + targetWidth: 10, 152 + }, 153 + { 154 + name: "exact width unchanged", 155 + input: "hello world", 156 + targetWidth: 11, 157 + }, 158 + { 159 + name: "long string gets truncated", 160 + input: "this is a very long string that exceeds the target width", 161 + targetWidth: 20, 162 + }, 163 + } 164 + 165 + for _, tt := range tests { 166 + t.Run(tt.name, func(t *testing.T) { 167 + result := formatter.padToWidth(tt.input, tt.targetWidth) 168 + 169 + resultWidth := lipgloss.Width(result) 170 + if resultWidth != tt.targetWidth { 171 + t.Errorf("padToWidth() width = %d, expected exactly %d", resultWidth, tt.targetWidth) 172 + } 173 }) 174 } 175 } ··· 213 maxWidth: 10, 214 expected: "hello", 215 }, 216 + { 217 + name: "very long line", 218 + content: "github.com/charmbracelet/x/ansi v0.10.3 h1:3WoV9XN8uMEnFRZZ+vBPRy59TaI", 219 + maxWidth: 40, 220 + expected: "", 221 + }, 222 } 223 224 for _, tt := range tests { 225 t.Run(tt.name, func(t *testing.T) { 226 result := formatter.truncateContent(tt.content, tt.maxWidth) 227 + 228 + displayWidth := lipgloss.Width(result) 229 + if displayWidth > tt.maxWidth { 230 + t.Errorf("truncateContent() display width = %d, exceeds max %d", displayWidth, tt.maxWidth) 231 + } 232 + 233 + if tt.expected != "" && result != tt.expected { 234 t.Errorf("truncateContent() = %q, expected %q", result, tt.expected) 235 + } 236 + 237 + if lipgloss.Width(tt.content) > tt.maxWidth && tt.maxWidth > 3 { 238 + if !strings.HasSuffix(result, "...") { 239 + t.Errorf("truncateContent() should end with '...' for long content") 240 + } 241 } 242 }) 243 }
+213 -1
internal/ui/ui.go
··· 5 "strings" 6 7 "github.com/charmbracelet/bubbles/key" 8 "github.com/charmbracelet/bubbles/viewport" 9 tea "github.com/charmbracelet/bubbletea" 10 "github.com/charmbracelet/lipgloss" 11 "github.com/stormlightlabs/git-storm/internal/diff" 12 "github.com/stormlightlabs/git-storm/internal/style" 13 ) 14 15 // DiffModel holds the state for the side-by-side diff viewer. 16 type DiffModel struct { ··· 82 83 content := formatter.Format(edits) 84 85 - vp := viewport.New(terminalWidth, terminalHeight-2) // Reserve space for header 86 vp.SetContent(content) 87 88 return DiffModel{ ··· 201 helpText + strings.Repeat(" ", padding) + scrollInfo, 202 ) 203 }
··· 5 "strings" 6 7 "github.com/charmbracelet/bubbles/key" 8 + "github.com/charmbracelet/bubbles/paginator" 9 "github.com/charmbracelet/bubbles/viewport" 10 tea "github.com/charmbracelet/bubbletea" 11 "github.com/charmbracelet/lipgloss" 12 "github.com/stormlightlabs/git-storm/internal/diff" 13 "github.com/stormlightlabs/git-storm/internal/style" 14 ) 15 + 16 + // FileDiff represents a diff for a single file. 17 + type FileDiff struct { 18 + Edits []diff.Edit 19 + OldPath string 20 + NewPath string 21 + } 22 23 // DiffModel holds the state for the side-by-side diff viewer. 24 type DiffModel struct { ··· 90 91 content := formatter.Format(edits) 92 93 + vp := viewport.New(terminalWidth, terminalHeight-2) 94 vp.SetContent(content) 95 96 return DiffModel{ ··· 209 helpText + strings.Repeat(" ", padding) + scrollInfo, 210 ) 211 } 212 + 213 + // MultiFileDiffModel holds the state for viewing diffs across multiple files with pagination. 214 + type MultiFileDiffModel struct { 215 + files []FileDiff 216 + paginator paginator.Model 217 + viewport viewport.Model 218 + ready bool 219 + width int 220 + height int 221 + expanded bool // Controls whether unchanged blocks are compressed 222 + } 223 + 224 + // NewMultiFileDiffModel creates a new multi-file diff viewer with pagination. 225 + func NewMultiFileDiffModel(files []FileDiff, expanded bool) MultiFileDiffModel { 226 + p := paginator.New() 227 + p.Type = paginator.Dots 228 + p.PerPage = 1 229 + p.SetTotalPages(len(files)) 230 + p.ActiveDot = lipgloss.NewStyle().Foreground(style.AccentBlue).Render("•") 231 + p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("•") 232 + 233 + model := MultiFileDiffModel{ 234 + files: files, 235 + paginator: p, 236 + ready: false, 237 + expanded: expanded, 238 + } 239 + 240 + return model 241 + } 242 + 243 + // Init initializes the multi-file diff model. 244 + func (m MultiFileDiffModel) Init() tea.Cmd { 245 + return nil 246 + } 247 + 248 + // Update handles messages and updates the multi-file diff model state. 249 + func (m MultiFileDiffModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 250 + var cmds []tea.Cmd 251 + var cmd tea.Cmd 252 + 253 + switch msg := msg.(type) { 254 + case tea.KeyMsg: 255 + switch { 256 + case key.Matches(msg, keys.Quit): 257 + return m, tea.Quit 258 + 259 + case key.Matches(msg, key.NewBinding(key.WithKeys("e"))): 260 + // Toggle expanded/compressed view 261 + m.expanded = !m.expanded 262 + m.updateViewport() 263 + 264 + case key.Matches(msg, key.NewBinding(key.WithKeys("left", "h"))): 265 + m.paginator.PrevPage() 266 + m.updateViewport() 267 + m.viewport.GotoTop() 268 + 269 + case key.Matches(msg, key.NewBinding(key.WithKeys("right", "l"))): 270 + m.paginator.NextPage() 271 + m.updateViewport() 272 + m.viewport.GotoTop() 273 + 274 + case key.Matches(msg, keys.Up): 275 + m.viewport.LineUp(1) 276 + 277 + case key.Matches(msg, keys.Down): 278 + m.viewport.LineDown(1) 279 + 280 + case key.Matches(msg, keys.PageUp): 281 + m.viewport.ViewUp() 282 + 283 + case key.Matches(msg, keys.PageDown): 284 + m.viewport.ViewDown() 285 + 286 + case key.Matches(msg, keys.HalfUp): 287 + m.viewport.HalfViewUp() 288 + 289 + case key.Matches(msg, keys.HalfDown): 290 + m.viewport.HalfViewDown() 291 + 292 + case key.Matches(msg, keys.Top): 293 + m.viewport.GotoTop() 294 + 295 + case key.Matches(msg, keys.Bottom): 296 + m.viewport.GotoBottom() 297 + } 298 + 299 + case tea.WindowSizeMsg: 300 + m.width = msg.Width 301 + m.height = msg.Height 302 + 303 + if !m.ready { 304 + m.viewport = viewport.New(msg.Width, msg.Height-4) 305 + m.ready = true 306 + } else { 307 + m.viewport.Width = msg.Width 308 + m.viewport.Height = msg.Height - 4 309 + } 310 + 311 + m.updateViewport() 312 + } 313 + 314 + m.viewport, cmd = m.viewport.Update(msg) 315 + cmds = append(cmds, cmd) 316 + 317 + return m, tea.Batch(cmds...) 318 + } 319 + 320 + // View renders the current view of the multi-file diff viewer. 321 + func (m MultiFileDiffModel) View() string { 322 + if !m.ready || len(m.files) == 0 { 323 + return "\n No files to display" 324 + } 325 + 326 + header := m.renderMultiFileHeader() 327 + footer := m.renderMultiFileFooter() 328 + paginatorView := m.renderPaginator() 329 + 330 + return fmt.Sprintf("%s\n%s\n%s\n%s", header, m.viewport.View(), paginatorView, footer) 331 + } 332 + 333 + // updateViewport updates the viewport content to show the current file. 334 + func (m *MultiFileDiffModel) updateViewport() { 335 + if len(m.files) == 0 { 336 + return 337 + } 338 + 339 + currentFile := m.files[m.paginator.Page] 340 + formatter := &diff.SideBySideFormatter{ 341 + TerminalWidth: m.width, 342 + ShowLineNumbers: true, 343 + Expanded: m.expanded, 344 + EnableWordWrap: false, 345 + } 346 + 347 + content := formatter.Format(currentFile.Edits) 348 + m.viewport.SetContent(content) 349 + } 350 + 351 + // renderMultiFileHeader creates the header showing current file paths. 352 + func (m MultiFileDiffModel) renderMultiFileHeader() string { 353 + if len(m.files) == 0 { 354 + return "" 355 + } 356 + 357 + currentFile := m.files[m.paginator.Page] 358 + 359 + headerStyle := lipgloss.NewStyle(). 360 + Foreground(style.AccentBlue). 361 + Bold(true). 362 + Padding(0, 1) 363 + 364 + oldLabel := lipgloss.NewStyle().Foreground(style.RemovedColor).Render("−") 365 + newLabel := lipgloss.NewStyle().Foreground(style.AddedColor).Render("+") 366 + 367 + fileIndicator := fmt.Sprintf("[%d/%d]", m.paginator.Page+1, len(m.files)) 368 + 369 + return headerStyle.Render( 370 + fmt.Sprintf("%s %s %s %s %s", fileIndicator, oldLabel, currentFile.OldPath, newLabel, currentFile.NewPath), 371 + ) 372 + } 373 + 374 + // renderPaginator renders the pagination dots. 375 + func (m MultiFileDiffModel) renderPaginator() string { 376 + if len(m.files) <= 1 { 377 + return "" 378 + } 379 + 380 + return lipgloss.NewStyle(). 381 + Foreground(lipgloss.Color("#6C7A89")). 382 + Padding(0, 1). 383 + Render(m.paginator.View()) 384 + } 385 + 386 + // renderMultiFileFooter creates the footer with help text and scroll position. 387 + func (m MultiFileDiffModel) renderMultiFileFooter() string { 388 + footerStyle := lipgloss.NewStyle(). 389 + Foreground(lipgloss.Color("#6C7A89")). 390 + Faint(true). 391 + Padding(0, 1) 392 + 393 + expandedIndicator := "compressed" 394 + if m.expanded { 395 + expandedIndicator = "expanded" 396 + } 397 + 398 + helpText := fmt.Sprintf("↑/↓: scroll • h/l: files • e: %s • q: quit", expandedIndicator) 399 + 400 + scrollPercent := m.viewport.ScrollPercent() 401 + scrollInfo := fmt.Sprintf("%.0f%%", scrollPercent*100) 402 + 403 + totalWidth := m.width 404 + helpWidth := lipgloss.Width(helpText) 405 + scrollWidth := lipgloss.Width(scrollInfo) 406 + padding := totalWidth - helpWidth - scrollWidth - 2 407 + 408 + if padding < 0 { 409 + padding = 0 410 + } 411 + 412 + return footerStyle.Render( 413 + helpText + strings.Repeat(" ", padding) + scrollInfo, 414 + ) 415 + }
+199 -4
internal/ui/ui_test.go
··· 60 61 tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 20)) 62 63 - // Test down movement 64 tm.Send(tea.KeyMsg{Type: tea.KeyDown}) 65 teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 66 return len(bts) > 0 67 }) 68 69 - // Test up movement 70 tm.Send(tea.KeyMsg{Type: tea.KeyUp}) 71 teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 72 return len(bts) > 0 73 }) 74 75 - // Test quit 76 tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) 77 tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 78 } ··· 83 } 84 85 quitKeys := []tea.KeyType{ 86 - tea.KeyRunes, // 'q' 87 tea.KeyEsc, 88 tea.KeyCtrlC, 89 } ··· 135 t.Error("Footer should contain help text about quitting") 136 } 137 }
··· 60 61 tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 20)) 62 63 tm.Send(tea.KeyMsg{Type: tea.KeyDown}) 64 teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 65 return len(bts) > 0 66 }) 67 68 tm.Send(tea.KeyMsg{Type: tea.KeyUp}) 69 teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 70 return len(bts) > 0 71 }) 72 73 tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) 74 tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 75 } ··· 80 } 81 82 quitKeys := []tea.KeyType{ 83 + tea.KeyRunes, 84 tea.KeyEsc, 85 tea.KeyCtrlC, 86 } ··· 132 t.Error("Footer should contain help text about quitting") 133 } 134 } 135 + 136 + func TestMultiFileDiffModel_Init(t *testing.T) { 137 + files := []FileDiff{ 138 + { 139 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}}, 140 + OldPath: "old/file1.go", 141 + NewPath: "new/file1.go", 142 + }, 143 + } 144 + 145 + model := NewMultiFileDiffModel(files, false) 146 + 147 + cmd := model.Init() 148 + if cmd != nil { 149 + t.Errorf("Init() should return nil, got %v", cmd) 150 + } 151 + } 152 + 153 + func TestMultiFileDiffModel_View(t *testing.T) { 154 + files := []FileDiff{ 155 + { 156 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "line 1"}}, 157 + OldPath: "old/file1.go", 158 + NewPath: "new/file1.go", 159 + }, 160 + { 161 + Edits: []diff.Edit{{Kind: diff.Insert, AIndex: -1, BIndex: 0, Content: "line 2"}}, 162 + OldPath: "old/file2.go", 163 + NewPath: "new/file2.go", 164 + }, 165 + } 166 + 167 + model := NewMultiFileDiffModel(files, false) 168 + 169 + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 170 + model = updated.(MultiFileDiffModel) 171 + 172 + view := model.View() 173 + 174 + if !strings.Contains(view, "file1.go") { 175 + t.Error("View should contain first file path") 176 + } 177 + if !strings.Contains(view, "[1/2]") { 178 + t.Error("View should contain file indicator [1/2]") 179 + } 180 + } 181 + 182 + func TestMultiFileDiffModel_Pagination(t *testing.T) { 183 + files := []FileDiff{ 184 + { 185 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "file 1"}}, 186 + OldPath: "old/file1.go", 187 + NewPath: "new/file1.go", 188 + }, 189 + { 190 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "file 2"}}, 191 + OldPath: "old/file2.go", 192 + NewPath: "new/file2.go", 193 + }, 194 + { 195 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "file 3"}}, 196 + OldPath: "old/file3.go", 197 + NewPath: "new/file3.go", 198 + }, 199 + } 200 + 201 + model := NewMultiFileDiffModel(files, false) 202 + 203 + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24)) 204 + 205 + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) 206 + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 207 + return len(bts) > 0 208 + }) 209 + 210 + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) 211 + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 212 + return len(bts) > 0 213 + }) 214 + 215 + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) 216 + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 217 + } 218 + 219 + func TestMultiFileDiffModel_EmptyFiles(t *testing.T) { 220 + files := []FileDiff{} 221 + 222 + model := NewMultiFileDiffModel(files, false) 223 + 224 + view := model.View() 225 + 226 + if !strings.Contains(view, "No files") { 227 + t.Error("View should indicate no files to display") 228 + } 229 + } 230 + 231 + func TestMultiFileDiffModel_SingleFile(t *testing.T) { 232 + files := []FileDiff{ 233 + { 234 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}}, 235 + OldPath: "old/single.go", 236 + NewPath: "new/single.go", 237 + }, 238 + } 239 + 240 + model := NewMultiFileDiffModel(files, false) 241 + 242 + updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) 243 + model = updated.(MultiFileDiffModel) 244 + 245 + view := model.View() 246 + 247 + if !strings.Contains(view, "single.go") { 248 + t.Error("View should contain file path") 249 + } 250 + if !strings.Contains(view, "[1/1]") { 251 + t.Error("View should show [1/1] for single file") 252 + } 253 + } 254 + 255 + func TestMultiFileDiffModel_UpdateViewport(t *testing.T) { 256 + files := []FileDiff{ 257 + { 258 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "content 1"}}, 259 + OldPath: "file1.go", 260 + NewPath: "file1.go", 261 + }, 262 + { 263 + Edits: []diff.Edit{{Kind: diff.Insert, AIndex: -1, BIndex: 0, Content: "content 2"}}, 264 + OldPath: "file2.go", 265 + NewPath: "file2.go", 266 + }, 267 + } 268 + 269 + model := NewMultiFileDiffModel(files, false) 270 + 271 + updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) 272 + model = updated.(MultiFileDiffModel) 273 + 274 + initialView := model.View() 275 + if !strings.Contains(initialView, "content 1") { 276 + t.Error("Initial view should contain content from first file") 277 + } 278 + 279 + model.paginator.NextPage() 280 + model.updateViewport() 281 + 282 + updatedView := model.View() 283 + if !strings.Contains(updatedView, "file2.go") { 284 + t.Error("Updated view should show second file path") 285 + } 286 + } 287 + 288 + func TestMultiFileDiffModel_RenderHeader(t *testing.T) { 289 + files := []FileDiff{ 290 + { 291 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}}, 292 + OldPath: "old/test.go", 293 + NewPath: "new/test.go", 294 + }, 295 + } 296 + 297 + model := NewMultiFileDiffModel(files, false) 298 + header := model.renderMultiFileHeader() 299 + 300 + if !strings.Contains(header, "old/test.go") { 301 + t.Error("Header should contain old file path") 302 + } 303 + if !strings.Contains(header, "new/test.go") { 304 + t.Error("Header should contain new file path") 305 + } 306 + if !strings.Contains(header, "[1/1]") { 307 + t.Error("Header should contain file indicator") 308 + } 309 + } 310 + 311 + func TestMultiFileDiffModel_RenderFooter(t *testing.T) { 312 + files := []FileDiff{ 313 + { 314 + Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}}, 315 + OldPath: "test.go", 316 + NewPath: "test.go", 317 + }, 318 + } 319 + 320 + model := NewMultiFileDiffModel(files, false) 321 + footer := model.renderMultiFileFooter() 322 + 323 + if !strings.Contains(footer, "h/l") { 324 + t.Error("Footer should contain navigation help for h/l keys") 325 + } 326 + if !strings.Contains(footer, "scroll") { 327 + t.Error("Footer should contain scroll help") 328 + } 329 + if !strings.Contains(footer, "quit") { 330 + t.Error("Footer should contain quit help") 331 + } 332 + }
+119 -22
main.go cmd/main.go
··· 42 43 const versionString string = "0.1.0-dev" 44 45 // runDiff executes the diff command by reading file contents from two git refs and launching the TUI. 46 - func runDiff(fromRef, toRef, filePath string) error { 47 repo, err := git.PlainOpen(repoPath) 48 if err != nil { 49 return fmt.Errorf("failed to open repository: %w", err) 50 } 51 52 - oldContent, err := getFileContent(repo, fromRef, filePath) 53 - if err != nil { 54 - return fmt.Errorf("failed to read %s from %s: %w", filePath, fromRef, err) 55 } 56 57 - newContent, err := getFileContent(repo, toRef, filePath) 58 - if err != nil { 59 - return fmt.Errorf("failed to read %s from %s: %w", filePath, toRef, err) 60 - } 61 62 - oldLines := strings.Split(oldContent, "\n") 63 - newLines := strings.Split(newContent, "\n") 64 65 - myers := &diff.Myers{} 66 - edits, err := myers.Compute(oldLines, newLines) 67 - if err != nil { 68 - return fmt.Errorf("diff computation failed: %w", err) 69 } 70 71 - model := ui.NewDiffModel(edits, fromRef+":"+filePath, toRef+":"+filePath, 120, 30) 72 73 p := tea.NewProgram(model, tea.WithAltScreen()) 74 if _, err := p.Run(); err != nil { ··· 108 return content, nil 109 } 110 111 func versionCmd() *cobra.Command { 112 return &cobra.Command{ 113 Use: "version", ··· 121 122 func diffCmd() *cobra.Command { 123 var filePath string 124 125 c := &cobra.Command{ 126 - Use: "diff <from> <to>", 127 Short: "Show a line-based diff between two commits or tags", 128 - Long: `Displays an inline diff (added/removed/unchanged lines) between two refs 129 - using the built-in diff engine.`, 130 - Args: cobra.ExactArgs(2), 131 RunE: func(cmd *cobra.Command, args []string) error { 132 - return runDiff(args[0], args[1], filePath) 133 }, 134 } 135 136 - c.Flags().StringVarP(&filePath, "file", "f", "", "File path to diff (required)") 137 - c.MarkFlagRequired("file") 138 139 return c 140 }
··· 42 43 const versionString string = "0.1.0-dev" 44 45 + // parseRefArgs parses command arguments to extract from/to refs. 46 + // Supports both "from..to" and "from to" syntax. 47 + func parseRefArgs(args []string) (from, to string) { 48 + if len(args) == 1 { 49 + parts := strings.Split(args[0], "..") 50 + if len(parts) == 2 { 51 + return parts[0], parts[1] 52 + } 53 + return args[0], "HEAD" 54 + } 55 + return args[0], args[1] 56 + } 57 + 58 // runDiff executes the diff command by reading file contents from two git refs and launching the TUI. 59 + func runDiff(fromRef, toRef, filePath string, expanded bool) error { 60 repo, err := git.PlainOpen(repoPath) 61 if err != nil { 62 return fmt.Errorf("failed to open repository: %w", err) 63 } 64 65 + var filesToDiff []string 66 + if filePath != "" { 67 + filesToDiff = []string{filePath} 68 + } else { 69 + filesToDiff, err = getChangedFiles(repo, fromRef, toRef) 70 + if err != nil { 71 + return fmt.Errorf("failed to get changed files: %w", err) 72 + } 73 + if len(filesToDiff) == 0 { 74 + fmt.Println("No files changed between", fromRef, "and", toRef) 75 + return nil 76 + } 77 } 78 79 + allDiffs := make([]ui.FileDiff, 0, len(filesToDiff)) 80 + 81 + for _, file := range filesToDiff { 82 + oldContent, err := getFileContent(repo, fromRef, file) 83 + if err != nil { 84 + oldContent = "" 85 + } 86 87 + newContent, err := getFileContent(repo, toRef, file) 88 + if err != nil { 89 + newContent = "" 90 + } 91 + 92 + oldLines := strings.Split(oldContent, "\n") 93 + newLines := strings.Split(newContent, "\n") 94 + 95 + myers := &diff.Myers{} 96 + edits, err := myers.Compute(oldLines, newLines) 97 + if err != nil { 98 + return fmt.Errorf("diff computation failed for %s: %w", file, err) 99 + } 100 101 + allDiffs = append(allDiffs, ui.FileDiff{ 102 + Edits: edits, 103 + OldPath: fromRef + ":" + file, 104 + NewPath: toRef + ":" + file, 105 + }) 106 } 107 108 + model := ui.NewMultiFileDiffModel(allDiffs, expanded) 109 110 p := tea.NewProgram(model, tea.WithAltScreen()) 111 if _, err := p.Run(); err != nil { ··· 145 return content, nil 146 } 147 148 + // getChangedFiles returns the list of files that changed between two commits. 149 + func getChangedFiles(repo *git.Repository, fromRef, toRef string) ([]string, error) { 150 + fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef)) 151 + if err != nil { 152 + return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err) 153 + } 154 + 155 + toHash, err := repo.ResolveRevision(plumbing.Revision(toRef)) 156 + if err != nil { 157 + return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err) 158 + } 159 + 160 + fromCommit, err := repo.CommitObject(*fromHash) 161 + if err != nil { 162 + return nil, fmt.Errorf("failed to get commit %s: %w", fromRef, err) 163 + } 164 + 165 + toCommit, err := repo.CommitObject(*toHash) 166 + if err != nil { 167 + return nil, fmt.Errorf("failed to get commit %s: %w", toRef, err) 168 + } 169 + 170 + fromTree, err := fromCommit.Tree() 171 + if err != nil { 172 + return nil, fmt.Errorf("failed to get tree for %s: %w", fromRef, err) 173 + } 174 + 175 + toTree, err := toCommit.Tree() 176 + if err != nil { 177 + return nil, fmt.Errorf("failed to get tree for %s: %w", toRef, err) 178 + } 179 + 180 + changes, err := fromTree.Diff(toTree) 181 + if err != nil { 182 + return nil, fmt.Errorf("failed to compute diff: %w", err) 183 + } 184 + 185 + files := make([]string, 0, len(changes)) 186 + for _, change := range changes { 187 + if change.To.Name != "" { 188 + files = append(files, change.To.Name) 189 + } else { 190 + files = append(files, change.From.Name) 191 + } 192 + } 193 + 194 + return files, nil 195 + } 196 + 197 func versionCmd() *cobra.Command { 198 return &cobra.Command{ 199 Use: "version", ··· 207 208 func diffCmd() *cobra.Command { 209 var filePath string 210 + var expanded bool 211 212 c := &cobra.Command{ 213 + Use: "diff <from>..<to> | diff <from> <to>", 214 Short: "Show a line-based diff between two commits or tags", 215 + Long: `Displays an inline diff (added/removed/unchanged lines) between two refs. 216 + 217 + Supports multiple input formats: 218 + - Range syntax: commit1..commit2 219 + - Separate args: commit1 commit2 220 + - Truncated hashes: 7de6f6d..18363c2 221 + 222 + If --file is not specified, shows all changed files with pagination. 223 + 224 + By default, large blocks of unchanged lines are compressed. Use --expanded 225 + to show all lines. You can also toggle this with 'e' in the TUI.`, 226 + Args: cobra.RangeArgs(1, 2), 227 RunE: func(cmd *cobra.Command, args []string) error { 228 + from, to := parseRefArgs(args) 229 + return runDiff(from, to, filePath, expanded) 230 }, 231 } 232 233 + c.Flags().StringVarP(&filePath, "file", "f", "", "Specific file to diff (optional, shows all files if omitted)") 234 + c.Flags().BoolVarP(&expanded, "expanded", "e", false, "Show all unchanged lines (disable compression)") 235 236 return c 237 }