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: add hookable support

+214 -16
+34
README.md
··· 132 132 133 133 Place custom themes at `~/.config/sdiff/themes/<name>.toml` and reference them with `-theme <name>`. 134 134 135 + ## hookable integration 136 + 137 + [hookable](https://tangled.org/adriano.tngl.sh/hookable) is a Claude Code hook runner that exposes tool call inputs as environment variables and forwards them to an arbitrary command. sdiff reads these variables natively, so it can be dropped in as the `--cmd` to preview file changes before Claude applies them. 138 + 139 + When invoked with no file arguments and `HOOKABLE_TOOL_NAME` is set, sdiff constructs the before/after diff automatically: 140 + 141 + | Tool | Before | After | 142 + |------|--------|-------| 143 + | `Edit` | Current file on disk | File with `old_string` replaced by `new_string` | 144 + | `Write` | Current file on disk (empty if new) | Incoming content | 145 + 146 + ### Claude Code hook configuration 147 + 148 + Add to `~/.claude/settings.json`: 149 + 150 + ```json 151 + { 152 + "hooks": { 153 + "PreToolUse": [ 154 + { 155 + "matcher": "Edit", 156 + "hooks": [{"type": "command", "command": "hookable --interactive --no-exit-code --cmd 'sdiff -i'"}] 157 + }, 158 + { 159 + "matcher": "Write", 160 + "hooks": [{"type": "command", "command": "hookable --interactive --no-exit-code --cmd 'sdiff -i'"}] 161 + } 162 + ] 163 + } 164 + } 165 + ``` 166 + 167 + `--interactive` runs sdiff under a PTY so the full TUI renders. `--no-exit-code` tells hookable to ignore sdiff's exit code and always wait for a keypress — press `y` to allow the change or `n` to deny it. 168 + 135 169 ## Development 136 170 137 171 ```sh
+25
internal/diff.go
··· 223 223 addRuns := runsOf(added, isChangedNew) 224 224 225 225 if len(remRuns) != len(addRuns) { 226 + // If all removed lines are semantically unchanged, we can partially 227 + // promote: emit genuinely-new added lines (e.g. new comments) as 228 + // KindAdded and promote the style-only added lines as KindEqual. 229 + allRemUnchanged := true 230 + for _, rr := range remRuns { 231 + if rr.changed { 232 + allRemUnchanged = false 233 + break 234 + } 235 + } 236 + if allRemUnchanged { 237 + var changedAdded, unchangedAdded []DiffLine 238 + for _, ar := range addRuns { 239 + if ar.changed { 240 + changedAdded = append(changedAdded, ar.lines...) 241 + } else { 242 + unchangedAdded = append(unchangedAdded, ar.lines...) 243 + } 244 + } 245 + if len(unchangedAdded) > 0 { 246 + result = append(result, changedAdded...) 247 + emitEqualGroup(&result, removed, unchangedAdded) 248 + continue 249 + } 250 + } 226 251 result = append(result, removed...) 227 252 result = append(result, added...) 228 253 continue
+8 -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 - func RenderCell(highlighted string, width int, bgHex string) string { 79 + // If fillToEOL is true the background is extended to the end of the terminal 80 + // line via \033[K — use this on the rightmost cell in a row so that screenshot 81 + // tools (which render in a wider virtual terminal) show a full-width highlight 82 + // rather than a truncated one. 83 + func RenderCell(highlighted string, width int, bgHex string, fillToEOL bool) string { 80 84 plain := StripANSI(highlighted) 81 85 runeCount := len([]rune(plain)) 82 86 ··· 96 100 } 97 101 r, g, b := parseHex(bgHex) 98 102 bg := fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b) 103 + if fillToEOL { 104 + return bg + content + padding + bg + "\033[K\033[0m" 105 + } 99 106 return bg + content + padding + "\033[0m" 100 107 } 101 108
+1 -1
internal/themes/nord.toml
··· 7 7 style = "nord" 8 8 9 9 [diff] 10 - added_bg = "#2E4034" 10 + added_bg = "#2E4A38" 11 11 added_fg = "#A3BE8C" 12 12 removed_bg = "#3D1F1F" 13 13 removed_fg = "#BF616A"
+118 -9
main.go
··· 5 5 "fmt" 6 6 "os" 7 7 "os/exec" 8 + "path/filepath" 9 + "strconv" 8 10 "strings" 9 11 "time" 10 12 ··· 307 309 rNum = st.LineSt.Render(fmt.Sprintf("%4d", dl.NewLine)) 308 310 } 309 311 310 - leftCell := sdiff.RenderCell(leftLine, colWidth-6, lBgHex) 311 - rightCell := sdiff.RenderCell(rightLine, colWidth-6, rBgHex) 312 + leftCell := sdiff.RenderCell(leftLine, colWidth-6, lBgHex, false) 313 + rightCell := sdiff.RenderCell(rightLine, colWidth-6, rBgHex, true) 312 314 313 315 row := prefix + lNum + " " + leftCell + " │ " + rNum + " " + rightCell 314 316 sb.WriteString(row) ··· 345 347 } 346 348 347 349 // termWidth returns the terminal width by querying stdout, stderr, then stdin. 348 - // Falls back to 200 if none of them report a usable size. 350 + // Falls back to $COLUMNS (set by callers like freeze), then 200. 349 351 func termWidth() int { 350 352 for _, fd := range []uintptr{os.Stdout.Fd(), os.Stderr.Fd(), os.Stdin.Fd()} { 351 353 if w, _, err := xterm.GetSize(fd); err == nil && w > 0 { 352 354 return w 353 355 } 354 356 } 357 + if cols := os.Getenv("COLUMNS"); cols != "" { 358 + if w, err := strconv.Atoi(cols); err == nil && w > 0 { 359 + return w 360 + } 361 + } 355 362 return 200 356 363 } 357 364 358 365 // printDiff renders the diff to stdout without the TUI. 359 - func printDiff(m model) { 366 + func printDiff(m model, overrideWidth int) { 360 367 width := termWidth() 368 + if overrideWidth > 0 { 369 + width = overrideWidth 370 + } 361 371 362 372 st := m.styles 363 373 // Row layout: " "(2) + lNum(4) + " "(1) + leftCell + " │ "(3) + rNum(4) + " "(1) + rightCell ··· 392 402 rNum = st.LineSt.Render(fmt.Sprintf("%4d", dl.NewLine)) 393 403 } 394 404 395 - leftCell := sdiff.RenderCell(leftLine, colWidth-6, lBgHex) 396 - rightCell := sdiff.RenderCell(rightLine, colWidth-6, rBgHex) 405 + leftCell := sdiff.RenderCell(leftLine, colWidth-6, lBgHex, false) 406 + rightCell := sdiff.RenderCell(rightLine, colWidth-6, rBgHex, true) 397 407 398 408 fmt.Println(" " + lNum + " " + leftCell + " │ " + rNum + " " + rightCell) 399 409 } ··· 407 417 fmt.Println(st.StatusSt.Render(fmt.Sprintf(" %d lines %d changed mode:%s", len(m.lines), changed, m.diffMode))) 408 418 } 409 419 420 + // ── hookable env-var integration ────────────────────────────────────────────── 421 + 422 + // filesFromHookEnv constructs two temp files (old, new) from the HOOKABLE_* 423 + // environment variables set by hookable, so sdiff can be used directly as a 424 + // --cmd without providing explicit file paths. 425 + // 426 + // Supported tools: 427 + // 428 + // Edit — diffs the current file against the result of applying old_string → 429 + // new_string (mirrors what Claude will actually write). 430 + // Write — diffs the current file (if it exists) against the new content. 431 + // 432 + // Temp files are named to preserve the original extension so that syntax 433 + // highlighting works correctly. The caller must call the returned cleanup 434 + // function when done. 435 + func filesFromHookEnv() (oldFile, newFile string, cleanup func(), err error) { 436 + toolName := os.Getenv("HOOKABLE_TOOL_NAME") 437 + filePath := os.Getenv("HOOKABLE_TOOL_INPUT_FILE_PATH") 438 + if filePath == "" { 439 + return "", "", nil, fmt.Errorf("sdiff: HOOKABLE_TOOL_INPUT_FILE_PATH is not set") 440 + } 441 + 442 + ext := filepath.Ext(filePath) 443 + base := strings.TrimSuffix(filepath.Base(filePath), ext) 444 + 445 + var oldContent, newContent []byte 446 + 447 + switch toolName { 448 + case "Edit": 449 + oldString := os.Getenv("HOOKABLE_TOOL_INPUT_OLD_STRING") 450 + newString := os.Getenv("HOOKABLE_TOOL_INPUT_NEW_STRING") 451 + current, readErr := os.ReadFile(filePath) 452 + if readErr != nil { 453 + return "", "", nil, fmt.Errorf("sdiff: reading %s: %w", filePath, readErr) 454 + } 455 + oldContent = current 456 + newContent = []byte(strings.Replace(string(current), oldString, newString, 1)) 457 + 458 + case "Write": 459 + newContent = []byte(os.Getenv("HOOKABLE_TOOL_INPUT_CONTENT")) 460 + current, readErr := os.ReadFile(filePath) 461 + if readErr == nil { 462 + oldContent = current 463 + } 464 + // If the file doesn't exist yet, oldContent stays nil (empty). 465 + 466 + default: 467 + return "", "", nil, fmt.Errorf("sdiff: unsupported HOOKABLE_TOOL_NAME %q (want Edit or Write)", toolName) 468 + } 469 + 470 + writeTmp := func(tag string, content []byte) (string, error) { 471 + pattern := "sdiff-" + tag + "-" + base + "-*" + ext 472 + f, tmpErr := os.CreateTemp("", pattern) 473 + if tmpErr != nil { 474 + return "", tmpErr 475 + } 476 + defer f.Close() 477 + if _, tmpErr = f.Write(content); tmpErr != nil { 478 + os.Remove(f.Name()) 479 + return "", tmpErr 480 + } 481 + return f.Name(), nil 482 + } 483 + 484 + oldFile, err = writeTmp("before", oldContent) 485 + if err != nil { 486 + return "", "", nil, err 487 + } 488 + newFile, err = writeTmp("after", newContent) 489 + if err != nil { 490 + os.Remove(oldFile) 491 + return "", "", nil, err 492 + } 493 + 494 + cleanup = func() { 495 + os.Remove(oldFile) 496 + os.Remove(newFile) 497 + } 498 + return oldFile, newFile, cleanup, nil 499 + } 500 + 410 501 // ── main ────────────────────────────────────────────────────────────────────── 411 502 412 503 func main() { 413 504 theme := flag.String("theme", "nord", "theme name or path to a .toml theme file") 414 505 interactive := flag.Bool("i", false, "interactive TUI mode") 506 + widthFlag := flag.Int("width", 0, "override output width in columns (default: auto-detect)") 415 507 flag.Usage = func() { 416 508 fmt.Fprintf(os.Stderr, "Usage: sdiff [flags] <old-file> <new-file>\n\nFlags:\n") 417 509 flag.PrintDefaults() 418 510 } 419 511 flag.Parse() 420 512 421 - if flag.NArg() != 2 { 513 + var oldFile, newFile string 514 + switch flag.NArg() { 515 + case 2: 516 + oldFile, newFile = flag.Arg(0), flag.Arg(1) 517 + case 0: 518 + if os.Getenv("HOOKABLE_TOOL_NAME") == "" { 519 + flag.Usage() 520 + os.Exit(1) 521 + } 522 + var cleanup func() 523 + var hookErr error 524 + oldFile, newFile, cleanup, hookErr = filesFromHookEnv() 525 + if hookErr != nil { 526 + fmt.Fprintln(os.Stderr, hookErr) 527 + os.Exit(1) 528 + } 529 + defer cleanup() 530 + default: 422 531 flag.Usage() 423 532 os.Exit(1) 424 533 } 425 534 426 - m, err := newModel(flag.Arg(0), flag.Arg(1), *theme) 535 + m, err := newModel(oldFile, newFile, *theme) 427 536 if err != nil { 428 537 fmt.Fprintln(os.Stderr, err) 429 538 os.Exit(1) 430 539 } 431 540 432 541 if !*interactive { 433 - printDiff(m) 542 + printDiff(m, *widthFlag) 434 543 return 435 544 } 436 545
+23 -3
test/diff_test.go
··· 2 2 3 3 import ( 4 4 "os" 5 + "strings" 5 6 "testing" 6 7 7 8 sdiff "sdiff/internal" ··· 34 35 } 35 36 } 36 37 37 - // TestSemanticDiffStyleOnlyGoIsEqual verifies that pure formatting changes to 38 - // a Go file produce no added/removed lines in semantic mode. 38 + // TestSemanticDiffStyleOnlyGoIsEqual verifies that formatting changes to a Go 39 + // file produce no added/removed lines for code, while newly added comments are 40 + // correctly shown as KindAdded. 39 41 func TestSemanticDiffStyleOnlyGoIsEqual(t *testing.T) { 40 - assertStyleOnlyIsEqual(t, "testdata/style_old.go", "testdata/style_new.go") 42 + oldSrc := readFile(t, "testdata/style_old.go") 43 + newSrc := readFile(t, "testdata/style_new.go") 44 + 45 + lines, mode := sdiff.ComputeSemanticDiff(oldSrc, newSrc, "testdata/style_old.go", "testdata/style_new.go") 46 + if mode != "semantic" { 47 + t.Fatalf("expected semantic mode, got %q", mode) 48 + } 49 + 50 + for _, dl := range lines { 51 + if dl.Kind == sdiff.KindRemoved { 52 + t.Errorf("line %d marked as removed; expected no code removals", dl.OldLine) 53 + } 54 + if dl.Kind == sdiff.KindAdded { 55 + text := strings.TrimSpace(dl.Text) 56 + if !strings.HasPrefix(text, "//") { 57 + t.Errorf("non-comment line %d marked as added: %q", dl.NewLine, dl.Text) 58 + } 59 + } 60 + } 41 61 } 42 62 43 63 // TestSemanticDiffDetectsNewRubyMethod verifies that a newly added Ruby method
+2 -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 := sdiff.RenderCell(tc.input, tc.width, tc.bgHex) 44 + cell := sdiff.RenderCell(tc.input, tc.width, tc.bgHex, false) 45 45 plain := sdiff.StripANSI(cell) 46 46 got := len([]rune(plain)) 47 47 if got != tc.width { ··· 66 66 data := readFile(t, tc.file) 67 67 lines := sdiff.HighlightLines(string(data), tc.file, tc.theme) 68 68 for i, hl := range lines { 69 - cell := sdiff.RenderCell(hl, cellWidth, "") 69 + cell := sdiff.RenderCell(hl, cellWidth, "", false) 70 70 plain := sdiff.StripANSI(cell) 71 71 got := len([]rune(plain)) 72 72 if got != cellWidth {
+3
test/testdata/style_new.go
··· 2 2 3 3 import "fmt" 4 4 5 + // We've formatted add's params on multiple lines instead of one 6 + // because this is a semantic diff, and one vs. multiple lines is 7 + // semantically, equivalent, no diff is shown for that change. 5 8 func add( 6 9 a int, 7 10 b int,