Add colorMoved support to kitty diff kitten

Fixes #3241
Fixes #9644
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 04:44:13 +00:00
committed by Kovid Goyal
parent c78592174d
commit f45345c7a7
3 changed files with 97 additions and 8 deletions

View File

@@ -152,6 +152,12 @@ opt('dark_highlight_added_bg', '#31503d', option_type='to_color')
opt('added_margin_bg', '#cdffd8', option_type='to_color')
opt('dark_added_margin_bg', '#31503d', option_type='to_color')
opt('moved_bg', '#fffde7', option_type='to_color', long_text='Moved text backgrounds (same text that was removed in one place and added in another)')
opt('dark_moved_bg', '#2c2200', option_type='to_color')
opt('moved_margin_bg', '#fff3b0', option_type='to_color')
opt('dark_moved_margin_bg', '#4a3800', option_type='to_color')
opt('filler_bg', '#fafbfc', option_type='to_color', long_text='Filler (empty) line background')
opt('dark_filler_bg', '#262c36', option_type='to_color')

View File

@@ -14,6 +14,7 @@ import (
"sync"
parallel "github.com/kovidgoyal/go-parallel"
"github.com/kovidgoyal/kitty/tools/simdstring"
"github.com/kovidgoyal/kitty/tools/utils"
"github.com/kovidgoyal/kitty/tools/utils/images"
"github.com/kovidgoyal/kitty/tools/utils/shlex"
@@ -336,6 +337,7 @@ func (self *Hunk) finalize(left_lines, right_lines []string) error {
type Patch struct {
all_hunks []*Hunk
largest_line_number, added_count, removed_count int
left_moved_lines, right_moved_lines *utils.Set[int]
}
func (self *Patch) Len() int { return len(self.all_hunks) }
@@ -451,6 +453,62 @@ func (self *Patch) compute_centers(left_lines, right_lines []string) error {
return nil
}
func (self *Patch) detect_moved_lines(left_lines, right_lines []string) {
// Build maps from line text to lists of line numbers for removed and added lines.
removed := make(map[string][]int) // text -> left line numbers
added := make(map[string][]int) // text -> right line numbers
// Use SIMD to efficiently find non-blank lines: a line is non-blank if it
// contains at least one character that is not a space or tab.
is_non_blank := func(text string) bool {
for len(text) > 0 {
idx := simdstring.IndexByte2String(text, ' ', '\t')
if idx != 0 {
// idx < 0: no space/tab found, remaining chars are non-blank;
// idx > 0: non-blank chars exist before the first space/tab.
return true
}
text = text[1:]
}
return false
}
for _, hunk := range self.all_hunks {
for _, chunk := range hunk.chunks {
if !chunk.is_context {
for i := 0; i < chunk.left_count; i++ {
lnum := chunk.left_start + i
text := left_lines[lnum]
if is_non_blank(text) {
removed[text] = append(removed[text], lnum)
}
}
for i := 0; i < chunk.right_count; i++ {
rnum := chunk.right_start + i
text := right_lines[rnum]
if is_non_blank(text) {
added[text] = append(added[text], rnum)
}
}
}
}
}
// Lines that appear in both removed and added sets are moved lines. When a
// line appears multiple times on each side, only min(left_count,
// right_count) occurrences are marked as moved.
self.left_moved_lines = utils.NewSet[int]()
self.right_moved_lines = utils.NewSet[int]()
for text, lnums := range removed {
if rnums, ok := added[text]; ok {
count := min(len(lnums), len(rnums))
for _, lnum := range lnums[:count] {
self.left_moved_lines.Add(lnum)
}
for _, rnum := range rnums[:count] {
self.right_moved_lines.Add(rnum)
}
}
}
}
func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err error) {
ans = &Patch{all_hunks: make([]*Hunk, 0, 32)}
var current_hunk *Hunk
@@ -486,6 +544,9 @@ func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err
ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number
}
err = ans.compute_centers(left_lines, right_lines)
if err == nil {
ans.detect_moved_lines(left_lines, right_lines)
}
return
}

View File

@@ -40,6 +40,7 @@ type HalfScreenLine struct {
marked_up_margin_text string
marked_up_text string
is_filler bool
is_moved bool
cached_wcswidth int
}
@@ -81,6 +82,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c
if sl.left.is_filler {
left_margin = format_as_sgr.margin_filler + left_margin
left_text = format_as_sgr.filler + left_text
} else if sl.left.is_moved {
left_margin = format_as_sgr.moved_margin + left_margin
left_text = format_as_sgr.moved + left_text
} else {
switch self.line_type {
case CHANGE_LINE, IMAGE_LINE:
@@ -104,6 +108,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c
if sl.right.is_filler {
right_margin = format_as_sgr.margin_filler + right_margin
right_text = format_as_sgr.filler + right_text
} else if sl.right.is_moved {
right_margin = format_as_sgr.moved_margin + right_margin
right_text = format_as_sgr.moved + right_text
} else {
switch self.line_type {
case CHANGE_LINE, IMAGE_LINE:
@@ -156,7 +163,7 @@ func place_in(text string, sz int) string {
}
var format_as_sgr struct {
title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search string
title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search, moved, moved_margin string
}
var statusline_format, added_count_format, removed_count_format, message_format func(...any) string
@@ -175,6 +182,8 @@ type ResolvedColors struct {
Margin_bg style.RGBA
Margin_fg style.RGBA
Margin_filler_bg style.NullableColor
Moved_bg style.RGBA
Moved_margin_bg style.RGBA
Removed_bg style.RGBA
Removed_margin_bg style.RGBA
Search_bg style.RGBA
@@ -202,6 +211,8 @@ func create_formatters() {
rc.Margin_bg = conf.Dark_margin_bg
rc.Margin_fg = conf.Dark_margin_fg
rc.Margin_filler_bg = conf.Dark_margin_filler_bg
rc.Moved_bg = conf.Dark_moved_bg
rc.Moved_margin_bg = conf.Dark_moved_margin_bg
rc.Removed_bg = conf.Dark_removed_bg
rc.Removed_margin_bg = conf.Dark_removed_margin_bg
rc.Search_bg = conf.Dark_search_bg
@@ -223,6 +234,8 @@ func create_formatters() {
rc.Margin_bg = conf.Margin_bg
rc.Margin_fg = conf.Margin_fg
rc.Margin_filler_bg = conf.Margin_filler_bg
rc.Moved_bg = conf.Moved_bg
rc.Moved_margin_bg = conf.Moved_margin_bg
rc.Removed_bg = conf.Removed_bg
rc.Removed_margin_bg = conf.Removed_margin_bg
rc.Search_bg = conf.Search_bg
@@ -248,6 +261,8 @@ func create_formatters() {
format_as_sgr.added_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Added_margin_bg.AsRGBSharp()))
format_as_sgr.removed = only_open("bg=" + rc.Removed_bg.AsRGBSharp())
format_as_sgr.removed_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Removed_margin_bg.AsRGBSharp()))
format_as_sgr.moved = only_open("bg=" + rc.Moved_bg.AsRGBSharp())
format_as_sgr.moved_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Moved_margin_bg.AsRGBSharp()))
format_as_sgr.title = only_open(fmt.Sprintf("fg=%s bg=%s bold", rc.Title_fg.AsRGBSharp(), rc.Title_bg.AsRGBSharp()))
format_as_sgr.margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Margin_bg.AsRGBSharp()))
format_as_sgr.hunk = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Hunk_bg.AsRGBSharp()))
@@ -532,7 +547,9 @@ type DiffData struct {
left_path, right_path string
available_cols, margin_size int
left_lines, right_lines []string
left_lines, right_lines []string
left_moved_lines *utils.Set[int]
right_moved_lines *utils.Set[int]
}
func hunk_title(hunk *Hunk) string {
@@ -567,7 +584,7 @@ func splitlines(text string, width int) []string {
return style.WrapTextAsLines(text, width, style.WrapOptions{})
}
func render_half_line(line_number int, line, ltype string, available_cols int, center Center, ans []HalfScreenLine) []HalfScreenLine {
func render_half_line(line_number int, line, ltype string, available_cols int, center Center, is_moved bool, ans []HalfScreenLine) []HalfScreenLine {
var regions []Region
if ltype == "remove" {
regions = center.left_regions
@@ -583,7 +600,7 @@ func render_half_line(line_number int, line, ltype string, available_cols int, c
}
lnum := strconv.Itoa(line_number + 1)
for _, sc := range splitlines(line, available_cols) {
ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc})
ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc, is_moved: is_moved})
lnum = ""
}
return ans
@@ -601,13 +618,15 @@ func lines_for_diff_chunk(data *DiffData, _ int, chunk *Chunk, _ int, ans []*Log
}
if i < chunk.left_count {
left_lnum = chunk.left_start + i
ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, ll)
left_is_moved := data.left_moved_lines != nil && data.left_moved_lines.Has(left_lnum)
ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, left_is_moved, ll)
left_lnum++
}
if i < chunk.right_count {
right_lnum = chunk.right_start + i
rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, rl)
right_is_moved := data.right_moved_lines != nil && data.right_moved_lines.Has(right_lnum)
rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, right_is_moved, rl)
right_lnum++
}
@@ -663,7 +682,10 @@ func lines_for_diff(left_path string, right_path string, patch *Patch, columns,
return append(ans, &ht), nil
}
available_cols := columns/2 - margin_size
data := DiffData{left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size}
data := DiffData{
left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size,
left_moved_lines: patch.left_moved_lines, right_moved_lines: patch.right_moved_lines,
}
if left_path != "" {
data.left_lines, err = highlighted_lines_for_path(left_path)
if err != nil {
@@ -720,7 +742,7 @@ func all_lines(path string, columns, margin_size int, is_add bool, ans []*Logica
}
for line_number, line := range lines {
hlines := make([]HalfScreenLine, 0, 8)
hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, hlines)
hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, false, hlines)
l := ll
if is_add {
l.right_reference.linenum = line_number + 1