Side-by-side semantic diff tool with theme support and hookable integration
3
fork

Configure Feed

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

feat: horizontal scroll support

Add side-scrolling for lines that exceed column width. Both columns share
a single horizontal offset so alignment is preserved mid-scroll.

- keys: h/l step 8, H/L half-column jump, 0 home, $ end
- mouse: trackpad wheel left/right (step 4)
- status bar shows "col:N" when scrolled

Also fix a latent cell-width miscalculation in View(): rows were rendered
3 chars wider than the terminal (m.width/2 vs the correct (m.width-3)/2),
which caused the rightmost cell to wrap/clip. Align View's formula with
printDiff's and recompute maxHOffset the same way so $ lands exactly at
the right edge of the longest line.

+201 -10
+5
README.md
··· 86 86 | Key | Action | 87 87 |-----|--------| 88 88 | `j` / `k` | Move down / up | 89 + | `h` / `l` | Scroll left / right (8 cols) | 90 + | `H` / `L` | Scroll left / right (half-column jump) | 91 + | `0` / `$` | Jump to line start / end | 89 92 | `n` / `p` | Jump to next / previous change | 90 93 | `g` / `G` | Jump to top / bottom | 91 94 | `s` | Toggle semantic ↔ text diff mode | 92 95 | `e` | Open new file in `$EDITOR`; diff reloads on save | 93 96 | `q` | Quit | 97 + 98 + Mouse / trackpad: vertical wheel scrolls rows; horizontal wheel scrolls columns. 94 99 95 100 ## Diff modes 96 101
+1
VERSION
··· 1 + 0.2.0
+4 -1
flake.nix
··· 17 17 systems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; 18 18 19 19 perSystem = {pkgs, ...}: let 20 + # Version is maintained in ./VERSION so it can be bumped without touching flake.nix. 21 + version = pkgs.lib.strings.trim (builtins.readFile ./VERSION); 22 + 20 23 # ── C source trees for each tree-sitter grammar ────────────────── 21 24 # go-tree-sitter bundles the tree-sitter C library under include/ and src/. 22 25 # Tag v0.25.0 has no GitHub release tarball, so we pin by commit. ··· 96 99 in { 97 100 packages.default = pkgs.buildGoModule { 98 101 pname = "adiff"; 99 - version = "0.1.0"; 102 + inherit version; 100 103 src = ./.; 101 104 102 105 vendorHash = null;
+18 -1
internal/highlight.go
··· 76 76 77 77 // RenderCell renders a pre-highlighted ANSI string into a fixed-width cell, 78 78 // applying bgHex as the background colour (empty string = no background). 79 + // hOffset horizontally scrolls the line by dropping that many visible characters 80 + // from the left (ANSI-aware — colour escape sequences are preserved). 79 81 // If fillToEOL is true the background is extended to the end of the terminal 80 82 // line via \033[K — use this on the rightmost cell in a row so that screenshot 81 83 // tools (which render in a wider virtual terminal) show a full-width highlight 82 84 // rather than a truncated one. 83 - func RenderCell(highlighted string, width int, bgHex string, fillToEOL bool) string { 85 + func RenderCell(highlighted string, width int, bgHex string, fillToEOL bool, hOffset int) string { 86 + if hOffset > 0 { 87 + highlighted = ansi.TruncateLeft(highlighted, hOffset, "") 88 + } 84 89 plain := StripANSI(highlighted) 85 90 runeCount := len([]rune(plain)) 86 91 ··· 128 133 return v 129 134 } 130 135 return parse(hex[0:2]), parse(hex[2:4]), parse(hex[4:6]) 136 + } 137 + 138 + // MaxLineWidth returns the widest visible (ANSI-stripped, grapheme-aware) 139 + // width across the given pre-highlighted lines. Used to clamp horizontal scroll. 140 + func MaxLineWidth(lines []string) int { 141 + m := 0 142 + for _, l := range lines { 143 + if w := ansi.StringWidth(l); w > m { 144 + m = w 145 + } 146 + } 147 + return m 131 148 } 132 149 133 150 // StripANSI removes ANSI escape sequences for width calculation purposes.
+71 -6
main.go
··· 32 32 offset int // scroll offset 33 33 height int // terminal rows available for diff 34 34 width int 35 + hOffset int // horizontal scroll offset (shared by both columns) 36 + maxWidth int // widest line across either file — clamps hOffset 35 37 quitting bool 36 38 message string 37 39 } ··· 70 72 } 71 73 } 72 74 75 + mw := max(adiff.MaxLineWidth(oldHL), adiff.MaxLineWidth(newHL)) 73 76 return model{ 74 77 oldFile: oldFile, 75 78 newFile: newFile, ··· 82 85 newHL: newHL, 83 86 mtimes: mtimes, 84 87 cursor: cursor, 88 + maxWidth: mw, 85 89 }, nil 86 90 } 87 91 ··· 114 118 m.height = 1 115 119 } 116 120 m.scrollToCursor() 121 + m.clampHOffset() 117 122 case tea.MouseMsg: 118 123 switch msg.Button { 119 124 case tea.MouseButtonWheelDown: ··· 128 133 m.cursor = 0 129 134 } 130 135 m.scrollToCursor() 136 + case tea.MouseButtonWheelRight: 137 + m.hOffset += 4 138 + m.clampHOffset() 139 + case tea.MouseButtonWheelLeft: 140 + m.hOffset -= 4 141 + m.clampHOffset() 131 142 } 132 143 case tea.KeyMsg: 133 144 m.message = "" ··· 190 201 case "p": 191 202 m.jumpPrev() 192 203 204 + case "l", "right": 205 + m.hOffset += 8 206 + m.clampHOffset() 207 + 208 + case "h", "left": 209 + m.hOffset -= 8 210 + m.clampHOffset() 211 + 212 + case "L", "shift+right": 213 + m.hOffset += m.width / 2 214 + m.clampHOffset() 215 + 216 + case "H", "shift+left": 217 + m.hOffset -= m.width / 2 218 + m.clampHOffset() 219 + 220 + case "0": 221 + m.hOffset = 0 222 + 223 + case "$": 224 + m.hOffset = m.maxHOffset() 225 + 193 226 case "e": 194 227 return m, m.openEditor() 195 228 ··· 244 277 m.lines, m.diffMode = adiff.ComputeSemanticDiff(oldBytes, newBytes, m.oldFile, m.newFile) 245 278 m.oldHL = adiff.HighlightLines(m.oldText, m.oldFile, m.styles.ChromaStyle) 246 279 m.newHL = adiff.HighlightLines(m.newText, m.newFile, m.styles.ChromaStyle) 280 + m.maxWidth = max(adiff.MaxLineWidth(m.oldHL), adiff.MaxLineWidth(m.newHL)) 247 281 if m.cursor >= len(m.lines) { 248 282 m.cursor = len(m.lines) - 1 249 283 } 250 284 m.scrollToCursor() 285 + m.clampHOffset() 251 286 m.message = "reloaded" 252 287 } 253 288 ··· 260 295 } 261 296 } 262 297 298 + // maxHOffset returns the largest useful horizontal scroll offset — i.e. the 299 + // point past which no additional content would be revealed on either side. 300 + // Mirrors View()'s cell-inner width so scrolling to this offset lands the end 301 + // of the widest line exactly at the right edge of the cell. 302 + func (m model) maxHOffset() int { 303 + cellInner := (m.width-3)/2 - 6 304 + if cellInner < 1 { 305 + cellInner = 1 306 + } 307 + max := m.maxWidth - cellInner 308 + if max < 0 { 309 + return 0 310 + } 311 + return max 312 + } 313 + 314 + func (m *model) clampHOffset() { 315 + if m.hOffset < 0 { 316 + m.hOffset = 0 317 + } 318 + if mx := m.maxHOffset(); m.hOffset > mx { 319 + m.hOffset = mx 320 + } 321 + } 322 + 263 323 func (m *model) jumpNext() { 264 324 for i := m.cursor + 1; i < len(m.lines); i++ { 265 325 if m.lines[i].Kind != adiff.KindEqual { ··· 299 359 st := m.styles 300 360 var sb strings.Builder 301 361 302 - colWidth := m.width / 2 362 + // Row layout: 2(prefix) + 4(lNum) + 1 + cellInner + 3(" │ ") + 4(rNum) + 1 + cellInner = 15 + 2·cellInner. 363 + // For this to fit in m.width: colWidth (half) = (m.width-3)/2 so that cellInner = colWidth-6 = (m.width-15)/2. 364 + colWidth := (m.width - 3) / 2 303 365 if colWidth < 10 { 304 366 colWidth = 10 305 367 } ··· 352 414 rNum = st.LineSt.Render(fmt.Sprintf("%4d", dl.NewLine)) 353 415 } 354 416 355 - leftCell := adiff.RenderCell(leftLine, colWidth-6, lBgHex, false) 356 - rightCell := adiff.RenderCell(rightLine, colWidth-6, rBgHex, true) 417 + leftCell := adiff.RenderCell(leftLine, colWidth-6, lBgHex, false, m.hOffset) 418 + rightCell := adiff.RenderCell(rightLine, colWidth-6, rBgHex, true, m.hOffset) 357 419 358 420 row := prefix + lNum + " " + leftCell + " │ " + rNum + " " + rightCell 359 421 sb.WriteString(row) ··· 369 431 } 370 432 } 371 433 status := fmt.Sprintf(" line %d/%d changed:%d mode:%s theme:%s", m.cursor+1, total, changed, m.diffMode, st.ChromaStyle) 434 + if m.hOffset > 0 { 435 + status += fmt.Sprintf(" col:%d", m.hOffset) 436 + } 372 437 if m.message != "" { 373 438 status += " [" + m.message + "]" 374 439 } 375 - help := " j/k:move n/p:next/prev change s:toggle semantic e:edit new g/G:top/bot q:quit" 440 + help := " j/k:move h/l:scroll n/p:next/prev change s:toggle semantic e:edit new g/G:top/bot q:quit" 376 441 377 442 sb.WriteString(st.StatusSt.Render(status)) 378 443 sb.WriteString("\n") ··· 457 522 rNum = st.LineSt.Render(fmt.Sprintf("%4d", dl.NewLine)) 458 523 } 459 524 460 - leftCell := adiff.RenderCell(leftLine, colWidth-6, lBgHex, false) 461 - rightCell := adiff.RenderCell(rightLine, colWidth-6, rBgHex, true) 525 + leftCell := adiff.RenderCell(leftLine, colWidth-6, lBgHex, false, 0) 526 + rightCell := adiff.RenderCell(rightLine, colWidth-6, rBgHex, true, 0) 462 527 463 528 fmt.Println(" " + lNum + " " + leftCell + " │ " + rNum + " " + rightCell) 464 529 }
+102 -2
test/highlight_test.go
··· 41 41 } 42 42 for _, tc := range cases { 43 43 t.Run(tc.name, func(t *testing.T) { 44 - cell := adiff.RenderCell(tc.input, tc.width, tc.bgHex, false) 44 + cell := adiff.RenderCell(tc.input, tc.width, tc.bgHex, false, 0) 45 45 plain := adiff.StripANSI(cell) 46 46 got := len([]rune(plain)) 47 47 if got != tc.width { ··· 51 51 } 52 52 } 53 53 54 + // TestRenderCellHOffset verifies horizontal scrolling: the cell must still be 55 + // exactly width runes wide, and it must show the substring starting at hOffset 56 + // (with possible right-side ellipsis truncation preserved). 57 + func TestRenderCellHOffset(t *testing.T) { 58 + input := "abcdefghijklmnopqrstuvwxyz" // 26 visible chars 59 + 60 + cases := []struct { 61 + name string 62 + width int 63 + hOffset int 64 + want string // expected plain content (without padding) 65 + }{ 66 + {"no offset, fits", 26, 0, "abcdefghijklmnopqrstuvwxyz"}, 67 + {"offset 5, fits", 30, 5, "fghijklmnopqrstuvwxyz"}, 68 + {"offset 10, truncated", 10, 10, "klmnopqrs…"}, 69 + {"offset past end", 10, 100, ""}, 70 + } 71 + for _, tc := range cases { 72 + t.Run(tc.name, func(t *testing.T) { 73 + cell := adiff.RenderCell(input, tc.width, "", false, tc.hOffset) 74 + plain := adiff.StripANSI(cell) 75 + if got := len([]rune(plain)); got != tc.width { 76 + t.Errorf("visual width = %d, want %d; plain=%q", got, tc.width, plain) 77 + } 78 + trimmed := strings.TrimRight(plain, " ") 79 + trimmed = strings.TrimSuffix(trimmed, "\x1b[0m") 80 + if trimmed != tc.want { 81 + t.Errorf("content = %q, want %q", trimmed, tc.want) 82 + } 83 + }) 84 + } 85 + } 86 + 87 + // TestMaxLineWidth verifies that the widest visible line is reported back 88 + // with ANSI escapes ignored. 89 + func TestMaxLineWidth(t *testing.T) { 90 + lines := []string{ 91 + "short", 92 + "\033[38;2;100;200;50mhello\033[39m world", // 11 visible 93 + strings.Repeat("x", 40), // 40 visible 94 + "", 95 + } 96 + if got, want := adiff.MaxLineWidth(lines), 40; got != want { 97 + t.Errorf("MaxLineWidth = %d, want %d", got, want) 98 + } 99 + if got, want := adiff.MaxLineWidth(nil), 0; got != want { 100 + t.Errorf("MaxLineWidth(nil) = %d, want %d", got, want) 101 + } 102 + } 103 + 104 + // TestLogicLine52ScrollsIntoView is the regression test for the "max width is 105 + // too aggressive" report: at maxHOffset, the longest line from logic_new.go 106 + // (line 52) must render fully without an ellipsis-truncation marker. 107 + func TestLogicLine52ScrollsIntoView(t *testing.T) { 108 + data := readFile(t, "testdata/logic_new.go") 109 + lines := adiff.HighlightLines(string(data), "logic_new.go", "nord") 110 + if len(lines) < 52 { 111 + t.Fatalf("expected >= 52 highlighted lines, got %d", len(lines)) 112 + } 113 + hl := lines[51] // line 52 (0-based) 114 + 115 + maxWidth := adiff.MaxLineWidth(lines) 116 + lineWidth := len([]rune(adiff.StripANSI(hl))) 117 + if lineWidth != maxWidth { 118 + t.Fatalf("expected line 52 to be the widest (%d), got %d", maxWidth, lineWidth) 119 + } 120 + 121 + // Sweep across plausible terminal widths. At each, simulate the final 122 + // scroll position (maxHOffset = maxWidth - cellInner) and assert: 123 + // (a) the widest line renders without an ellipsis marker, and 124 + // (b) the full row (15 framing chars + 2*cellInner) fits in termWidth, 125 + // which prevents terminal wrap from visually truncating the tail. 126 + // Mirrors View: colWidth = (termWidth-3)/2, cell inner = colWidth - 6. 127 + for termWidth := 80; termWidth <= 260; termWidth++ { 128 + colWidth := (termWidth - 3) / 2 129 + cellInner := colWidth - 6 130 + if cellInner < 1 { 131 + continue 132 + } 133 + rowWidth := 15 + 2*cellInner 134 + if rowWidth > termWidth { 135 + t.Errorf("termWidth=%d: rendered row (%d) overflows terminal", termWidth, rowWidth) 136 + } 137 + 138 + hOffset := maxWidth - cellInner 139 + if hOffset < 0 { 140 + hOffset = 0 141 + } 142 + cell := adiff.RenderCell(hl, cellInner, "", false, hOffset) 143 + plain := adiff.StripANSI(cell) 144 + if got := len([]rune(plain)); got != cellInner { 145 + t.Errorf("termWidth=%d: cell width = %d, want %d", termWidth, got, cellInner) 146 + } 147 + if strings.ContainsRune(plain, '…') { 148 + t.Errorf("termWidth=%d: line 52 still truncated at max scroll (hOffset=%d cellInner=%d): %q", 149 + termWidth, hOffset, cellInner, plain) 150 + } 151 + } 152 + } 153 + 54 154 // TestStyleFilesNoGaps is the regression test derived from the style_old/new.go 55 155 // screenshot: every highlighted line from both files must fit within a 74-char 56 156 // cell without overflowing. ··· 66 166 data := readFile(t, tc.file) 67 167 lines := adiff.HighlightLines(string(data), tc.file, tc.theme) 68 168 for i, hl := range lines { 69 - cell := adiff.RenderCell(hl, cellWidth, "", false) 169 + cell := adiff.RenderCell(hl, cellWidth, "", false, 0) 70 170 plain := adiff.StripANSI(cell) 71 171 got := len([]rune(plain)) 72 172 if got != cellWidth {