Move the kittens Go code into the kittens folder
This commit is contained in:
386
kittens/diff/collect.go
Normal file
386
kittens/diff/collect.go
Normal file
@@ -0,0 +1,386 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"kitty/tools/utils"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
var path_name_map, remote_dirs map[string]string
|
||||
|
||||
var mimetypes_cache, data_cache, hash_cache *utils.LRUCache[string, string]
|
||||
var size_cache *utils.LRUCache[string, int64]
|
||||
var lines_cache *utils.LRUCache[string, []string]
|
||||
var highlighted_lines_cache *utils.LRUCache[string, []string]
|
||||
var is_text_cache *utils.LRUCache[string, bool]
|
||||
|
||||
func init_caches() {
|
||||
path_name_map = make(map[string]string, 32)
|
||||
remote_dirs = make(map[string]string, 32)
|
||||
const sz = 4096
|
||||
size_cache = utils.NewLRUCache[string, int64](sz)
|
||||
mimetypes_cache = utils.NewLRUCache[string, string](sz)
|
||||
data_cache = utils.NewLRUCache[string, string](sz)
|
||||
is_text_cache = utils.NewLRUCache[string, bool](sz)
|
||||
lines_cache = utils.NewLRUCache[string, []string](sz)
|
||||
highlighted_lines_cache = utils.NewLRUCache[string, []string](sz)
|
||||
hash_cache = utils.NewLRUCache[string, string](sz)
|
||||
}
|
||||
|
||||
func add_remote_dir(val string) {
|
||||
x := filepath.Base(val)
|
||||
idx := strings.LastIndex(x, "-")
|
||||
if idx > -1 {
|
||||
x = x[idx+1:]
|
||||
} else {
|
||||
x = ""
|
||||
}
|
||||
remote_dirs[val] = x
|
||||
}
|
||||
|
||||
func mimetype_for_path(path string) string {
|
||||
return mimetypes_cache.MustGetOrCreate(path, func(path string) string {
|
||||
mt := utils.GuessMimeTypeWithFileSystemAccess(path)
|
||||
if mt == "" {
|
||||
mt = "application/octet-stream"
|
||||
}
|
||||
if utils.KnownTextualMimes[mt] {
|
||||
if _, a, found := strings.Cut(mt, "/"); found {
|
||||
mt = "text/" + a
|
||||
}
|
||||
}
|
||||
return mt
|
||||
})
|
||||
}
|
||||
|
||||
func data_for_path(path string) (string, error) {
|
||||
return data_cache.GetOrCreate(path, func(path string) (string, error) {
|
||||
ans, err := os.ReadFile(path)
|
||||
return utils.UnsafeBytesToString(ans), err
|
||||
})
|
||||
}
|
||||
|
||||
func size_for_path(path string) (int64, error) {
|
||||
return size_cache.GetOrCreate(path, func(path string) (int64, error) {
|
||||
s, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.Size(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func is_image(path string) bool {
|
||||
return strings.HasPrefix(mimetype_for_path(path), "image/")
|
||||
}
|
||||
|
||||
func is_path_text(path string) bool {
|
||||
return is_text_cache.MustGetOrCreate(path, func(path string) bool {
|
||||
if is_image(path) {
|
||||
return false
|
||||
}
|
||||
s1, err := os.Stat(path)
|
||||
if err == nil {
|
||||
s2, err := os.Stat("/dev/null")
|
||||
if err == nil && os.SameFile(s1, s2) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
d, err := data_for_path(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return utf8.ValidString(d)
|
||||
})
|
||||
}
|
||||
|
||||
func hash_for_path(path string) (string, error) {
|
||||
return hash_cache.GetOrCreate(path, func(path string) (string, error) {
|
||||
ans, err := data_for_path(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := md5.Sum(utils.UnsafeStringToBytes(ans))
|
||||
return utils.UnsafeBytesToString(hash[:]), err
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Remove all control codes except newlines
|
||||
func sanitize_control_codes(x string) string {
|
||||
pat := utils.MustCompile("[\x00-\x09\x0b-\x1f\x7f\u0080-\u009f]")
|
||||
return pat.ReplaceAllLiteralString(x, "░")
|
||||
}
|
||||
|
||||
func sanitize_tabs_and_carriage_returns(x string) string {
|
||||
return strings.NewReplacer("\t", conf.Replace_tab_by, "\r", "⏎").Replace(x)
|
||||
}
|
||||
|
||||
func sanitize(x string) string {
|
||||
return sanitize_control_codes(sanitize_tabs_and_carriage_returns(x))
|
||||
}
|
||||
|
||||
func text_to_lines(text string) []string {
|
||||
lines := make([]string, 0, 512)
|
||||
splitlines_like_git(text, false, func(line string) { lines = append(lines, line) })
|
||||
return lines
|
||||
}
|
||||
|
||||
func lines_for_path(path string) ([]string, error) {
|
||||
return lines_cache.GetOrCreate(path, func(path string) ([]string, error) {
|
||||
ans, err := data_for_path(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return text_to_lines(sanitize(ans)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func highlighted_lines_for_path(path string) ([]string, error) {
|
||||
plain_lines, err := lines_for_path(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ans, found := highlighted_lines_cache.Get(path); found && len(ans) == len(plain_lines) {
|
||||
return ans, nil
|
||||
}
|
||||
return plain_lines, nil
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
changes, renames, type_map map[string]string
|
||||
adds, removes *utils.Set[string]
|
||||
all_paths []string
|
||||
paths_to_highlight *utils.Set[string]
|
||||
added_count, removed_count int
|
||||
}
|
||||
|
||||
func (self *Collection) add_change(left, right string) {
|
||||
self.changes[left] = right
|
||||
self.all_paths = append(self.all_paths, left)
|
||||
self.paths_to_highlight.Add(left)
|
||||
self.paths_to_highlight.Add(right)
|
||||
self.type_map[left] = `diff`
|
||||
}
|
||||
|
||||
func (self *Collection) add_rename(left, right string) {
|
||||
self.renames[left] = right
|
||||
self.all_paths = append(self.all_paths, left)
|
||||
self.type_map[left] = `rename`
|
||||
}
|
||||
|
||||
func (self *Collection) add_add(right string) {
|
||||
self.adds.Add(right)
|
||||
self.all_paths = append(self.all_paths, right)
|
||||
self.paths_to_highlight.Add(right)
|
||||
self.type_map[right] = `add`
|
||||
if is_path_text(right) {
|
||||
num, _ := lines_for_path(right)
|
||||
self.added_count += len(num)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Collection) add_removal(left string) {
|
||||
self.removes.Add(left)
|
||||
self.all_paths = append(self.all_paths, left)
|
||||
self.paths_to_highlight.Add(left)
|
||||
self.type_map[left] = `removal`
|
||||
if is_path_text(left) {
|
||||
num, _ := lines_for_path(left)
|
||||
self.removed_count += len(num)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Collection) finalize() {
|
||||
utils.StableSortWithKey(self.all_paths, func(path string) string {
|
||||
return path_name_map[path]
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Collection) Len() int { return len(self.all_paths) }
|
||||
|
||||
func (self *Collection) Items() int { return len(self.all_paths) }
|
||||
|
||||
func (self *Collection) Apply(f func(path, typ, changed_path string) error) error {
|
||||
for _, path := range self.all_paths {
|
||||
typ := self.type_map[path]
|
||||
changed_path := ""
|
||||
switch typ {
|
||||
case "diff":
|
||||
changed_path = self.changes[path]
|
||||
case "rename":
|
||||
changed_path = self.renames[path]
|
||||
}
|
||||
if err := f(path, typ, changed_path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowed(path string, patterns ...string) bool {
|
||||
name := filepath.Base(path)
|
||||
for _, pat := range patterns {
|
||||
if matched, err := filepath.Match(pat, name); err == nil && matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func remote_hostname(path string) (string, string) {
|
||||
for q, val := range remote_dirs {
|
||||
if strings.HasPrefix(path, q) {
|
||||
return q, val
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func resolve_remote_name(path, defval string) string {
|
||||
remote_dir, rh := remote_hostname(path)
|
||||
if remote_dir != "" && rh != "" {
|
||||
r, err := filepath.Rel(remote_dir, path)
|
||||
if err == nil {
|
||||
return rh + ":" + r
|
||||
}
|
||||
}
|
||||
return defval
|
||||
}
|
||||
|
||||
func walk(base string, patterns []string, names *utils.Set[string], pmap, path_name_map map[string]string) error {
|
||||
return filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
|
||||
is_allowed := allowed(path, patterns...)
|
||||
if !is_allowed {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
path, err = filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := filepath.Rel(base, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name != "." {
|
||||
path_name_map[path] = name
|
||||
names.Add(name)
|
||||
pmap[name] = path
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Collection) collect_files(left, right string) error {
|
||||
left_names, right_names := utils.NewSet[string](16), utils.NewSet[string](16)
|
||||
left_path_map, right_path_map := make(map[string]string, 16), make(map[string]string, 16)
|
||||
err := walk(left, conf.Ignore_name, left_names, left_path_map, path_name_map)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = walk(right, conf.Ignore_name, right_names, right_path_map, path_name_map)
|
||||
common_names := left_names.Intersect(right_names)
|
||||
changed_names := utils.NewSet[string](common_names.Len())
|
||||
for n := range common_names.Iterable() {
|
||||
ld, err := data_for_path(left_path_map[n])
|
||||
var rd string
|
||||
if err == nil {
|
||||
rd, err = data_for_path(right_path_map[n])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ld != rd {
|
||||
changed_names.Add(n)
|
||||
self.add_change(left_path_map[n], right_path_map[n])
|
||||
}
|
||||
}
|
||||
removed := left_names.Subtract(common_names)
|
||||
added := right_names.Subtract(common_names)
|
||||
ahash, rhash := make(map[string]string, added.Len()), make(map[string]string, removed.Len())
|
||||
for a := range added.Iterable() {
|
||||
ahash[a], err = hash_for_path(right_path_map[a])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for r := range removed.Iterable() {
|
||||
rhash[r], err = hash_for_path(left_path_map[r])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for name, rh := range rhash {
|
||||
found := false
|
||||
for n, ah := range ahash {
|
||||
if ah == rh {
|
||||
ld, _ := data_for_path(left_path_map[name])
|
||||
rd, _ := data_for_path(right_path_map[n])
|
||||
if ld == rd {
|
||||
self.add_rename(left_path_map[name], right_path_map[n])
|
||||
added.Discard(n)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
self.add_removal(left_path_map[name])
|
||||
}
|
||||
}
|
||||
for name := range added.Iterable() {
|
||||
self.add_add(right_path_map[name])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func create_collection(left, right string) (ans *Collection, err error) {
|
||||
ans = &Collection{
|
||||
changes: make(map[string]string),
|
||||
renames: make(map[string]string),
|
||||
type_map: make(map[string]string),
|
||||
adds: utils.NewSet[string](32),
|
||||
removes: utils.NewSet[string](32),
|
||||
paths_to_highlight: utils.NewSet[string](32),
|
||||
all_paths: make([]string, 0, 32),
|
||||
}
|
||||
left_stat, err := os.Stat(left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if left_stat.IsDir() {
|
||||
err = ans.collect_files(left, right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
pl, err := filepath.Abs(left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr, err := filepath.Abs(right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path_name_map[pl] = resolve_remote_name(pl, left)
|
||||
path_name_map[pr] = resolve_remote_name(pr, right)
|
||||
ans.add_change(pl, pr)
|
||||
}
|
||||
ans.finalize()
|
||||
return ans, err
|
||||
}
|
||||
53
kittens/diff/collect_test.go
Normal file
53
kittens/diff/collect_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"kitty/tools/utils"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestDiffCollectWalk(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
j := func(x ...string) string { return filepath.Join(append([]string{tdir}, x...)...) }
|
||||
os.MkdirAll(j("a", "b"), 0o700)
|
||||
os.WriteFile(j("a/b/c"), nil, 0o600)
|
||||
os.WriteFile(j("b"), nil, 0o600)
|
||||
os.WriteFile(j("d"), nil, 0o600)
|
||||
os.WriteFile(j("e"), nil, 0o600)
|
||||
os.WriteFile(j("#d#"), nil, 0o600)
|
||||
os.WriteFile(j("e~"), nil, 0o600)
|
||||
os.MkdirAll(j("f"), 0o700)
|
||||
os.WriteFile(j("f/g"), nil, 0o600)
|
||||
os.WriteFile(j("h space"), nil, 0o600)
|
||||
|
||||
expected_names := utils.NewSetWithItems("d", "e", "f/g", "h space")
|
||||
expected_pmap := map[string]string{
|
||||
"d": j("d"),
|
||||
"e": j("e"),
|
||||
"f/g": j("f/g"),
|
||||
"h space": j("h space"),
|
||||
}
|
||||
names := utils.NewSet[string](16)
|
||||
pmap := make(map[string]string, 16)
|
||||
if err := walk(tdir, []string{"*~", "#*#", "b"}, names, pmap, map[string]string{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff(
|
||||
utils.Sort(expected_names.AsSlice(), func(a, b string) bool { return a < b }),
|
||||
utils.Sort(names.AsSlice(), func(a, b string) bool { return a < b }),
|
||||
); diff != "" {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
if diff := cmp.Diff(expected_pmap, pmap); diff != "" {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
264
kittens/diff/diff.go
Normal file
264
kittens/diff/diff.go
Normal file
@@ -0,0 +1,264 @@
|
||||
// Copied from the Go stdlib, with modifications.
|
||||
//https://github.com/golang/go/raw/master/src/internal/diff/diff.go
|
||||
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A pair is a pair of values tracked for both the x and y side of a diff.
|
||||
// It is typically a pair of line indexes.
|
||||
type pair struct{ x, y int }
|
||||
|
||||
// Diff returns an anchored diff of the two texts old and new
|
||||
// in the “unified diff” format. If old and new are identical,
|
||||
// Diff returns a nil slice (no output).
|
||||
//
|
||||
// Unix diff implementations typically look for a diff with
|
||||
// the smallest number of lines inserted and removed,
|
||||
// which can in the worst case take time quadratic in the
|
||||
// number of lines in the texts. As a result, many implementations
|
||||
// either can be made to run for a long time or cut off the search
|
||||
// after a predetermined amount of work.
|
||||
//
|
||||
// In contrast, this implementation looks for a diff with the
|
||||
// smallest number of “unique” lines inserted and removed,
|
||||
// where unique means a line that appears just once in both old and new.
|
||||
// We call this an “anchored diff” because the unique lines anchor
|
||||
// the chosen matching regions. An anchored diff is usually clearer
|
||||
// than a standard diff, because the algorithm does not try to
|
||||
// reuse unrelated blank lines or closing braces.
|
||||
// The algorithm also guarantees to run in O(n log n) time
|
||||
// instead of the standard O(n²) time.
|
||||
//
|
||||
// Some systems call this approach a “patience diff,” named for
|
||||
// the “patience sorting” algorithm, itself named for a solitaire card game.
|
||||
// We avoid that name for two reasons. First, the name has been used
|
||||
// for a few different variants of the algorithm, so it is imprecise.
|
||||
// Second, the name is frequently interpreted as meaning that you have
|
||||
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
|
||||
// when in fact the algorithm is faster than the standard one.
|
||||
func Diff(oldName, old, newName, new string, num_of_context_lines int) []byte {
|
||||
if old == new {
|
||||
return nil
|
||||
}
|
||||
x := lines(old)
|
||||
y := lines(new)
|
||||
|
||||
// Print diff header.
|
||||
var out bytes.Buffer
|
||||
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
|
||||
fmt.Fprintf(&out, "--- %s\n", oldName)
|
||||
fmt.Fprintf(&out, "+++ %s\n", newName)
|
||||
|
||||
// Loop over matches to consider,
|
||||
// expanding each match to include surrounding lines,
|
||||
// and then printing diff chunks.
|
||||
// To avoid setup/teardown cases outside the loop,
|
||||
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
|
||||
// in the sequence of matches.
|
||||
var (
|
||||
done pair // printed up to x[:done.x] and y[:done.y]
|
||||
chunk pair // start lines of current chunk
|
||||
count pair // number of lines from each side in current chunk
|
||||
ctext []string // lines for current chunk
|
||||
)
|
||||
for _, m := range tgs(x, y) {
|
||||
if m.x < done.x {
|
||||
// Already handled scanning forward from earlier match.
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand matching lines as far possible,
|
||||
// establishing that x[start.x:end.x] == y[start.y:end.y].
|
||||
// Note that on the first (or last) iteration we may (or definitey do)
|
||||
// have an empty match: start.x==end.x and start.y==end.y.
|
||||
start := m
|
||||
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
|
||||
start.x--
|
||||
start.y--
|
||||
}
|
||||
end := m
|
||||
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
|
||||
end.x++
|
||||
end.y++
|
||||
}
|
||||
|
||||
// Emit the mismatched lines before start into this chunk.
|
||||
// (No effect on first sentinel iteration, when start = {0,0}.)
|
||||
for _, s := range x[done.x:start.x] {
|
||||
ctext = append(ctext, "-"+s)
|
||||
count.x++
|
||||
}
|
||||
for _, s := range y[done.y:start.y] {
|
||||
ctext = append(ctext, "+"+s)
|
||||
count.y++
|
||||
}
|
||||
|
||||
// If we're not at EOF and have too few common lines,
|
||||
// the chunk includes all the common lines and continues.
|
||||
C := num_of_context_lines // number of context lines
|
||||
if (end.x < len(x) || end.y < len(y)) &&
|
||||
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
|
||||
for _, s := range x[start.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
continue
|
||||
}
|
||||
|
||||
// End chunk with common lines for context.
|
||||
if len(ctext) > 0 {
|
||||
n := end.x - start.x
|
||||
if n > C {
|
||||
n = C
|
||||
}
|
||||
for _, s := range x[start.x : start.x+n] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = pair{start.x + n, start.y + n}
|
||||
|
||||
// Format and emit chunk.
|
||||
// Convert line numbers to 1-indexed.
|
||||
// Special case: empty file shows up as 0,0 not 1,0.
|
||||
if count.x > 0 {
|
||||
chunk.x++
|
||||
}
|
||||
if count.y > 0 {
|
||||
chunk.y++
|
||||
}
|
||||
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
|
||||
for _, s := range ctext {
|
||||
out.WriteString(s)
|
||||
}
|
||||
count.x = 0
|
||||
count.y = 0
|
||||
ctext = ctext[:0]
|
||||
}
|
||||
|
||||
// If we reached EOF, we're done.
|
||||
if end.x >= len(x) && end.y >= len(y) {
|
||||
break
|
||||
}
|
||||
|
||||
// Otherwise start a new chunk.
|
||||
chunk = pair{end.x - C, end.y - C}
|
||||
for _, s := range x[chunk.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
}
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// lines returns the lines in the file x, including newlines.
|
||||
// If the file does not end in a newline, one is supplied
|
||||
// along with a warning about the missing newline.
|
||||
func lines(x string) []string {
|
||||
l := strings.SplitAfter(x, "\n")
|
||||
if l[len(l)-1] == "" {
|
||||
l = l[:len(l)-1]
|
||||
} else {
|
||||
// Treat last line as having a message about the missing newline attached,
|
||||
// using the same text as BSD/GNU diff (including the leading backslash).
|
||||
l[len(l)-1] += "\n\\ No newline at end of file\n"
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// tgs returns the pairs of indexes of the longest common subsequence
|
||||
// of unique lines in x and y, where a unique line is one that appears
|
||||
// once in x and once in y.
|
||||
//
|
||||
// The longest common subsequence algorithm is as described in
|
||||
// Thomas G. Szymanski, “A Special Case of the Maximal Common
|
||||
// Subsequence Problem,” Princeton TR #170 (January 1975),
|
||||
// available at https://research.swtch.com/tgs170.pdf.
|
||||
func tgs(x, y []string) []pair {
|
||||
// Count the number of times each string appears in a and b.
|
||||
// We only care about 0, 1, many, counted as 0, -1, -2
|
||||
// for the x side and 0, -4, -8 for the y side.
|
||||
// Using negative numbers now lets us distinguish positive line numbers later.
|
||||
m := make(map[string]int)
|
||||
for _, s := range x {
|
||||
if c := m[s]; c > -2 {
|
||||
m[s] = c - 1
|
||||
}
|
||||
}
|
||||
for _, s := range y {
|
||||
if c := m[s]; c > -8 {
|
||||
m[s] = c - 4
|
||||
}
|
||||
}
|
||||
|
||||
// Now unique strings can be identified by m[s] = -1+-4.
|
||||
//
|
||||
// Gather the indexes of those strings in x and y, building:
|
||||
// xi[i] = increasing indexes of unique strings in x.
|
||||
// yi[i] = increasing indexes of unique strings in y.
|
||||
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
|
||||
var xi, yi, inv []int
|
||||
for i, s := range y {
|
||||
if m[s] == -1+-4 {
|
||||
m[s] = len(yi)
|
||||
yi = append(yi, i)
|
||||
}
|
||||
}
|
||||
for i, s := range x {
|
||||
if j, ok := m[s]; ok && j >= 0 {
|
||||
xi = append(xi, i)
|
||||
inv = append(inv, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Algorithm A from Szymanski's paper.
|
||||
// In those terms, A = J = inv and B = [0, n).
|
||||
// We add sentinel pairs {0,0}, and {len(x),len(y)}
|
||||
// to the returned sequence, to help the processing loop.
|
||||
J := inv
|
||||
n := len(xi)
|
||||
T := make([]int, n)
|
||||
L := make([]int, n)
|
||||
for i := range T {
|
||||
T[i] = n + 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
k := sort.Search(n, func(k int) bool {
|
||||
return T[k] >= J[i]
|
||||
})
|
||||
T[k] = J[i]
|
||||
L[i] = k + 1
|
||||
}
|
||||
k := 0
|
||||
for _, v := range L {
|
||||
if k < v {
|
||||
k = v
|
||||
}
|
||||
}
|
||||
seq := make([]pair, 2+k)
|
||||
seq[1+k] = pair{len(x), len(y)} // sentinel at end
|
||||
lastj := n
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if L[i] == k && J[i] < lastj {
|
||||
seq[k] = pair{xi[i], yi[J[i]]}
|
||||
k--
|
||||
}
|
||||
}
|
||||
seq[0] = pair{0, 0} // sentinel at start
|
||||
return seq
|
||||
}
|
||||
207
kittens/diff/highlight.go
Normal file
207
kittens/diff/highlight.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
var _ = os.WriteFile
|
||||
|
||||
var ErrNoLexer = errors.New("No lexer available for this format")
|
||||
var DefaultStyle = (&utils.Once[*chroma.Style]{Run: func() *chroma.Style {
|
||||
// Default style generated by python style.py default pygments.styles.default.DefaultStyle
|
||||
// with https://raw.githubusercontent.com/alecthomas/chroma/master/_tools/style.py
|
||||
return styles.Register(chroma.MustNewStyle("default", chroma.StyleEntries{
|
||||
chroma.TextWhitespace: "#bbbbbb",
|
||||
chroma.Comment: "italic #3D7B7B",
|
||||
chroma.CommentPreproc: "noitalic #9C6500",
|
||||
chroma.Keyword: "bold #008000",
|
||||
chroma.KeywordPseudo: "nobold",
|
||||
chroma.KeywordType: "nobold #B00040",
|
||||
chroma.Operator: "#666666",
|
||||
chroma.OperatorWord: "bold #AA22FF",
|
||||
chroma.NameBuiltin: "#008000",
|
||||
chroma.NameFunction: "#0000FF",
|
||||
chroma.NameClass: "bold #0000FF",
|
||||
chroma.NameNamespace: "bold #0000FF",
|
||||
chroma.NameException: "bold #CB3F38",
|
||||
chroma.NameVariable: "#19177C",
|
||||
chroma.NameConstant: "#880000",
|
||||
chroma.NameLabel: "#767600",
|
||||
chroma.NameEntity: "bold #717171",
|
||||
chroma.NameAttribute: "#687822",
|
||||
chroma.NameTag: "bold #008000",
|
||||
chroma.NameDecorator: "#AA22FF",
|
||||
chroma.LiteralString: "#BA2121",
|
||||
chroma.LiteralStringDoc: "italic",
|
||||
chroma.LiteralStringInterpol: "bold #A45A77",
|
||||
chroma.LiteralStringEscape: "bold #AA5D1F",
|
||||
chroma.LiteralStringRegex: "#A45A77",
|
||||
chroma.LiteralStringSymbol: "#19177C",
|
||||
chroma.LiteralStringOther: "#008000",
|
||||
chroma.LiteralNumber: "#666666",
|
||||
chroma.GenericHeading: "bold #000080",
|
||||
chroma.GenericSubheading: "bold #800080",
|
||||
chroma.GenericDeleted: "#A00000",
|
||||
chroma.GenericInserted: "#008400",
|
||||
chroma.GenericError: "#E40000",
|
||||
chroma.GenericEmph: "italic",
|
||||
chroma.GenericStrong: "bold",
|
||||
chroma.GenericPrompt: "bold #000080",
|
||||
chroma.GenericOutput: "#717171",
|
||||
chroma.GenericTraceback: "#04D",
|
||||
chroma.Error: "border:#FF0000",
|
||||
chroma.Background: " bg:#f8f8f8",
|
||||
}))
|
||||
}}).Get
|
||||
|
||||
// Clear the background colour.
|
||||
func clear_background(style *chroma.Style) *chroma.Style {
|
||||
builder := style.Builder()
|
||||
bg := builder.Get(chroma.Background)
|
||||
bg.Background = 0
|
||||
bg.NoInherit = true
|
||||
builder.AddEntry(chroma.Background, bg)
|
||||
style, _ = builder.Build()
|
||||
return style
|
||||
}
|
||||
|
||||
func ansi_formatter(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
|
||||
const SGR_PREFIX = "\033["
|
||||
const SGR_SUFFIX = "m"
|
||||
style = clear_background(style)
|
||||
before, after := make([]byte, 0, 64), make([]byte, 0, 64)
|
||||
nl := []byte{'\n'}
|
||||
write_sgr := func(which []byte) {
|
||||
if len(which) > 1 {
|
||||
w.Write(utils.UnsafeStringToBytes(SGR_PREFIX))
|
||||
w.Write(which[:len(which)-1])
|
||||
w.Write(utils.UnsafeStringToBytes(SGR_SUFFIX))
|
||||
}
|
||||
}
|
||||
write := func(text string) {
|
||||
write_sgr(before)
|
||||
w.Write(utils.UnsafeStringToBytes(text))
|
||||
write_sgr(after)
|
||||
}
|
||||
|
||||
for token := it(); token != chroma.EOF; token = it() {
|
||||
entry := style.Get(token.Type)
|
||||
before, after = before[:0], after[:0]
|
||||
if !entry.IsZero() {
|
||||
if entry.Bold == chroma.Yes {
|
||||
before = append(before, '1', ';')
|
||||
after = append(after, '2', '2', '1', ';')
|
||||
}
|
||||
if entry.Underline == chroma.Yes {
|
||||
before = append(before, '4', ';')
|
||||
after = append(after, '2', '4', ';')
|
||||
}
|
||||
if entry.Italic == chroma.Yes {
|
||||
before = append(before, '3', ';')
|
||||
after = append(after, '2', '3', ';')
|
||||
}
|
||||
if entry.Colour.IsSet() {
|
||||
before = append(before, fmt.Sprintf("38:2:%d:%d:%d;", entry.Colour.Red(), entry.Colour.Green(), entry.Colour.Blue())...)
|
||||
after = append(after, '3', '9', ';')
|
||||
}
|
||||
}
|
||||
// independently format each line in a multiline token, needed for the diff kitten highlighting to work, also
|
||||
// pagers like less reset SGR formatting at line boundaries
|
||||
text := sanitize(token.Value)
|
||||
for text != "" {
|
||||
idx := strings.IndexByte(text, '\n')
|
||||
if idx < 0 {
|
||||
write(text)
|
||||
break
|
||||
}
|
||||
write(text[:idx])
|
||||
w.Write(nl)
|
||||
text = text[idx+1:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func highlight_file(path string) (highlighted string, err error) {
|
||||
filename_for_detection := filepath.Base(path)
|
||||
ext := filepath.Ext(filename_for_detection)
|
||||
if ext != "" {
|
||||
ext = strings.ToLower(ext[1:])
|
||||
r := conf.Syntax_aliases[ext]
|
||||
if r != "" {
|
||||
filename_for_detection = "file." + r
|
||||
}
|
||||
}
|
||||
text, err := data_for_path(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lexer := lexers.Match(filename_for_detection)
|
||||
if lexer == nil {
|
||||
if err == nil {
|
||||
lexer = lexers.Analyse(text)
|
||||
}
|
||||
}
|
||||
if lexer == nil {
|
||||
return "", fmt.Errorf("Cannot highlight %#v: %w", path, ErrNoLexer)
|
||||
}
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
name := conf.Pygments_style
|
||||
var style *chroma.Style
|
||||
if name == "default" {
|
||||
style = DefaultStyle()
|
||||
} else {
|
||||
style = styles.Get(name)
|
||||
}
|
||||
if style == nil {
|
||||
if conf.Background.IsDark() && !conf.Foreground.IsDark() {
|
||||
style = styles.Get("monokai")
|
||||
if style == nil {
|
||||
style = styles.Get("github-dark")
|
||||
}
|
||||
} else {
|
||||
style = DefaultStyle()
|
||||
}
|
||||
if style == nil {
|
||||
style = styles.Fallback
|
||||
}
|
||||
}
|
||||
iterator, err := lexer.Tokenise(nil, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
formatter := chroma.FormatterFunc(ansi_formatter)
|
||||
w := strings.Builder{}
|
||||
w.Grow(len(text) * 2)
|
||||
err = formatter.Format(&w, style, iterator)
|
||||
// os.WriteFile(filepath.Base(path+".highlighted"), []byte(w.String()), 0o600)
|
||||
return w.String(), err
|
||||
}
|
||||
|
||||
func highlight_all(paths []string) {
|
||||
ctx := images.Context{}
|
||||
ctx.Parallel(0, len(paths), func(nums <-chan int) {
|
||||
for i := range nums {
|
||||
path := paths[i]
|
||||
raw, err := highlight_file(path)
|
||||
if err == nil {
|
||||
highlighted_lines_cache.Set(path, text_to_lines(raw))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
174
kittens/diff/main.go
Normal file
174
kittens/diff/main.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kitty/kittens/ssh"
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func load_config(opts *Options) (ans *Config, err error) {
|
||||
ans = NewConfig()
|
||||
p := config.ConfigParser{LineHandler: ans.Parse}
|
||||
err = p.LoadConfig("diff.conf", opts.Config, opts.Override)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ans, nil
|
||||
}
|
||||
|
||||
var conf *Config
|
||||
var opts *Options
|
||||
var lp *loop.Loop
|
||||
|
||||
func isdir(path string) bool {
|
||||
if s, err := os.Stat(path); err == nil {
|
||||
return s.IsDir()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func get_ssh_file(hostname, rpath string) (string, error) {
|
||||
tdir, err := os.MkdirTemp("", "*-"+hostname)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
add_remote_dir(tdir)
|
||||
is_abs := strings.HasPrefix(rpath, "/")
|
||||
for strings.HasPrefix(rpath, "/") {
|
||||
rpath = rpath[1:]
|
||||
}
|
||||
cmd := []string{ssh.SSHExe(), hostname, "tar", "-c", "-f", "-"}
|
||||
if is_abs {
|
||||
cmd = append(cmd, "-C", "/")
|
||||
}
|
||||
cmd = append(cmd, rpath)
|
||||
c := exec.Command(cmd[0], cmd[1:]...)
|
||||
c.Stdin, c.Stderr = os.Stdin, os.Stderr
|
||||
stdout, err := c.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to ssh into remote host %s to get file %s with error: %w", hostname, rpath, err)
|
||||
}
|
||||
tf := tar.NewReader(bytes.NewReader(stdout))
|
||||
count, err := utils.ExtractAllFromTar(tf, tdir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to untar data from remote host %s to get file %s with error: %w", hostname, rpath, err)
|
||||
}
|
||||
ans := filepath.Join(tdir, rpath)
|
||||
if count == 1 {
|
||||
filepath.WalkDir(tdir, func(path string, d fs.DirEntry, err error) error {
|
||||
if !d.IsDir() {
|
||||
ans = path
|
||||
return fs.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return ans, nil
|
||||
}
|
||||
|
||||
func get_remote_file(path string) (string, error) {
|
||||
if strings.HasPrefix(path, "ssh:") {
|
||||
parts := strings.SplitN(path, ":", 3)
|
||||
if len(parts) == 3 {
|
||||
return get_ssh_file(parts[1], parts[2])
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func main(_ *cli.Command, opts_ *Options, args []string) (rc int, err error) {
|
||||
opts = opts_
|
||||
conf, err = load_config(opts)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if len(args) != 2 {
|
||||
return 1, fmt.Errorf("You must specify exactly two files/directories to compare")
|
||||
}
|
||||
if err = set_diff_command(conf.Diff_cmd); err != nil {
|
||||
return 1, err
|
||||
}
|
||||
init_caches()
|
||||
create_formatters()
|
||||
defer func() {
|
||||
for tdir := range remote_dirs {
|
||||
os.RemoveAll(tdir)
|
||||
}
|
||||
}()
|
||||
left, err := get_remote_file(args[0])
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
right, err := get_remote_file(args[1])
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if isdir(left) != isdir(right) {
|
||||
return 1, fmt.Errorf("The items to be diffed should both be either directories or files. Comparing a directory to a file is not valid.'")
|
||||
}
|
||||
if !exists(left) {
|
||||
return 1, fmt.Errorf("%s does not exist", left)
|
||||
}
|
||||
if !exists(right) {
|
||||
return 1, fmt.Errorf("%s does not exist", right)
|
||||
}
|
||||
lp, err = loop.New()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
h := Handler{left: left, right: right, lp: lp}
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
lp.SetCursorVisible(false)
|
||||
lp.SetCursorShape(loop.BAR_CURSOR, true)
|
||||
lp.AllowLineWrapping(false)
|
||||
lp.SetWindowTitle(fmt.Sprintf("%s vs. %s", left, right))
|
||||
h.initialize()
|
||||
return "", nil
|
||||
}
|
||||
lp.OnWakeup = h.on_wakeup
|
||||
lp.OnFinalize = func() string {
|
||||
lp.SetCursorVisible(true)
|
||||
lp.SetCursorShape(loop.BLOCK_CURSOR, true)
|
||||
h.finalize()
|
||||
return ""
|
||||
}
|
||||
lp.OnResize = h.on_resize
|
||||
lp.OnKeyEvent = h.on_key_event
|
||||
lp.OnText = h.on_text
|
||||
err = lp.Run()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, main)
|
||||
}
|
||||
376
kittens/diff/patch.go
Normal file
376
kittens/diff/patch.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
"kitty/tools/utils/shlex"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
const GIT_DIFF = `git diff --no-color --no-ext-diff --exit-code -U_CONTEXT_ --no-index --`
|
||||
const DIFF_DIFF = `diff -p -U _CONTEXT_ --`
|
||||
|
||||
var diff_cmd []string
|
||||
|
||||
var GitExe = (&utils.Once[string]{Run: func() string {
|
||||
return utils.FindExe("git")
|
||||
}}).Get
|
||||
|
||||
var DiffExe = (&utils.Once[string]{Run: func() string {
|
||||
return utils.FindExe("diff")
|
||||
}}).Get
|
||||
|
||||
func find_differ() {
|
||||
if GitExe() != "git" && exec.Command(GitExe(), "--help").Run() == nil {
|
||||
diff_cmd, _ = shlex.Split(GIT_DIFF)
|
||||
} else if DiffExe() != "diff" && exec.Command(DiffExe(), "--help").Run() == nil {
|
||||
diff_cmd, _ = shlex.Split(DIFF_DIFF)
|
||||
} else {
|
||||
diff_cmd = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func set_diff_command(q string) error {
|
||||
switch q {
|
||||
case "auto":
|
||||
find_differ()
|
||||
case "builtin", "":
|
||||
diff_cmd = []string{}
|
||||
case "diff":
|
||||
diff_cmd, _ = shlex.Split(DIFF_DIFF)
|
||||
case "git":
|
||||
diff_cmd, _ = shlex.Split(GIT_DIFF)
|
||||
default:
|
||||
c, err := shlex.Split(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
diff_cmd = c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Center struct{ offset, left_size, right_size int }
|
||||
|
||||
type Chunk struct {
|
||||
is_context bool
|
||||
left_start, right_start int
|
||||
left_count, right_count int
|
||||
centers []Center
|
||||
}
|
||||
|
||||
func (self *Chunk) add_line() {
|
||||
self.right_count++
|
||||
}
|
||||
|
||||
func (self *Chunk) remove_line() {
|
||||
self.left_count++
|
||||
}
|
||||
|
||||
func (self *Chunk) context_line() {
|
||||
self.left_count++
|
||||
self.right_count++
|
||||
}
|
||||
|
||||
func changed_center(left, right string) (ans Center) {
|
||||
if len(left) > 0 && len(right) > 0 {
|
||||
ll, rl := len(left), len(right)
|
||||
ml := utils.Min(ll, rl)
|
||||
for ; ans.offset < ml && left[ans.offset] == right[ans.offset]; ans.offset++ {
|
||||
}
|
||||
suffix_count := 0
|
||||
for ; suffix_count < ml && left[ll-1-suffix_count] == right[rl-1-suffix_count]; suffix_count++ {
|
||||
}
|
||||
ans.left_size = ll - suffix_count - ans.offset
|
||||
ans.right_size = rl - suffix_count - ans.offset
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (self *Chunk) finalize(left_lines, right_lines []string) {
|
||||
if !self.is_context && self.left_count == self.right_count {
|
||||
for i := 0; i < self.left_count; i++ {
|
||||
self.centers = append(self.centers, changed_center(left_lines[self.left_start+i], right_lines[self.right_start+i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Hunk struct {
|
||||
left_start, left_count int
|
||||
right_start, right_count int
|
||||
title string
|
||||
added_count, removed_count int
|
||||
chunks []*Chunk
|
||||
current_chunk *Chunk
|
||||
largest_line_number int
|
||||
}
|
||||
|
||||
func (self *Hunk) new_chunk(is_context bool) *Chunk {
|
||||
left_start, right_start := self.left_start, self.right_start
|
||||
if len(self.chunks) > 0 {
|
||||
c := self.chunks[len(self.chunks)-1]
|
||||
left_start = c.left_start + c.left_count
|
||||
right_start = c.right_start + c.right_count
|
||||
}
|
||||
return &Chunk{is_context: is_context, left_start: left_start, right_start: right_start}
|
||||
}
|
||||
|
||||
func (self *Hunk) ensure_diff_chunk() {
|
||||
if self.current_chunk == nil || self.current_chunk.is_context {
|
||||
if self.current_chunk != nil {
|
||||
self.chunks = append(self.chunks, self.current_chunk)
|
||||
}
|
||||
self.current_chunk = self.new_chunk(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Hunk) ensure_context_chunk() {
|
||||
if self.current_chunk == nil || !self.current_chunk.is_context {
|
||||
if self.current_chunk != nil {
|
||||
self.chunks = append(self.chunks, self.current_chunk)
|
||||
}
|
||||
self.current_chunk = self.new_chunk(true)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Hunk) add_line() {
|
||||
self.ensure_diff_chunk()
|
||||
self.current_chunk.add_line()
|
||||
self.added_count++
|
||||
}
|
||||
|
||||
func (self *Hunk) remove_line() {
|
||||
self.ensure_diff_chunk()
|
||||
self.current_chunk.remove_line()
|
||||
self.removed_count++
|
||||
}
|
||||
|
||||
func (self *Hunk) context_line() {
|
||||
self.ensure_context_chunk()
|
||||
self.current_chunk.context_line()
|
||||
}
|
||||
|
||||
func (self *Hunk) finalize(left_lines, right_lines []string) error {
|
||||
if self.current_chunk != nil {
|
||||
self.chunks = append(self.chunks, self.current_chunk)
|
||||
}
|
||||
// Sanity check
|
||||
c := self.chunks[len(self.chunks)-1]
|
||||
if c.left_start+c.left_count != self.left_start+self.left_count {
|
||||
return fmt.Errorf("Left side line mismatch %d != %d", c.left_start+c.left_count, self.left_start+self.left_count)
|
||||
}
|
||||
if c.right_start+c.right_count != self.right_start+self.right_count {
|
||||
return fmt.Errorf("Right side line mismatch %d != %d", c.right_start+c.right_count, self.right_start+self.right_count)
|
||||
}
|
||||
for _, c := range self.chunks {
|
||||
c.finalize(left_lines, right_lines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
all_hunks []*Hunk
|
||||
largest_line_number, added_count, removed_count int
|
||||
}
|
||||
|
||||
func (self *Patch) Len() int { return len(self.all_hunks) }
|
||||
|
||||
func splitlines_like_git(raw string, strip_trailing_lines bool, process_line func(string)) {
|
||||
sz := len(raw)
|
||||
if strip_trailing_lines {
|
||||
for sz > 0 && (raw[sz-1] == '\n' || raw[sz-1] == '\r') {
|
||||
sz--
|
||||
}
|
||||
}
|
||||
start := 0
|
||||
for i := 0; i < sz; i++ {
|
||||
switch raw[i] {
|
||||
case '\n':
|
||||
process_line(raw[start:i])
|
||||
start = i + 1
|
||||
case '\r':
|
||||
process_line(raw[start:i])
|
||||
start = i + 1
|
||||
if start < sz && raw[start] == '\n' {
|
||||
i++
|
||||
start++
|
||||
}
|
||||
}
|
||||
}
|
||||
if start < sz {
|
||||
process_line(raw[start:sz])
|
||||
}
|
||||
}
|
||||
|
||||
func parse_range(x string) (start, count int) {
|
||||
s, c, found := strings.Cut(x, ",")
|
||||
start, _ = strconv.Atoi(s)
|
||||
if start < 0 {
|
||||
start = -start
|
||||
}
|
||||
count = 1
|
||||
if found {
|
||||
count, _ = strconv.Atoi(c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func parse_hunk_header(line string) *Hunk {
|
||||
parts := strings.SplitN(line, "@@", 3)
|
||||
linespec := strings.TrimSpace(parts[1])
|
||||
title := ""
|
||||
if len(parts) == 3 {
|
||||
title = strings.TrimSpace(parts[2])
|
||||
}
|
||||
left, right, _ := strings.Cut(linespec, " ")
|
||||
ls, lc := parse_range(left)
|
||||
rs, rc := parse_range(right)
|
||||
return &Hunk{
|
||||
title: title, left_start: ls - 1, left_count: lc, right_start: rs - 1, right_count: rc,
|
||||
largest_line_number: utils.Max(ls-1+lc, rs-1+rc),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
splitlines_like_git(raw, true, func(line string) {
|
||||
if strings.HasPrefix(line, "@@ ") {
|
||||
current_hunk = parse_hunk_header(line)
|
||||
ans.all_hunks = append(ans.all_hunks, current_hunk)
|
||||
} else if current_hunk != nil {
|
||||
var ch byte
|
||||
if len(line) > 0 {
|
||||
ch = line[0]
|
||||
}
|
||||
switch ch {
|
||||
case '+':
|
||||
current_hunk.add_line()
|
||||
case '-':
|
||||
current_hunk.remove_line()
|
||||
case '\\':
|
||||
default:
|
||||
current_hunk.context_line()
|
||||
}
|
||||
}
|
||||
})
|
||||
for _, h := range ans.all_hunks {
|
||||
err = h.finalize(left_lines, right_lines)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ans.added_count += h.added_count
|
||||
ans.removed_count += h.removed_count
|
||||
}
|
||||
if len(ans.all_hunks) > 0 {
|
||||
ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func run_diff(file1, file2 string, num_of_context_lines int) (ok, is_different bool, patch string, err error) {
|
||||
// we resolve symlinks because git diff does not follow symlinks, while diff
|
||||
// does. We want consistent behavior, also for integration with git difftool
|
||||
// we always want symlinks to be followed.
|
||||
path1, err := filepath.EvalSymlinks(file1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path2, err := filepath.EvalSymlinks(file2)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(diff_cmd) == 0 {
|
||||
data1, err := data_for_path(path1)
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
data2, err := data_for_path(path2)
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
patchb := Diff(path1, data1, path2, data2, num_of_context_lines)
|
||||
if patchb == nil {
|
||||
return true, false, "", nil
|
||||
}
|
||||
return true, len(patchb) > 0, utils.UnsafeBytesToString(patchb), nil
|
||||
} else {
|
||||
context := strconv.Itoa(num_of_context_lines)
|
||||
cmd := utils.Map(func(x string) string {
|
||||
return strings.ReplaceAll(x, "_CONTEXT_", context)
|
||||
}, diff_cmd)
|
||||
|
||||
cmd = append(cmd, path1, path2)
|
||||
c := exec.Command(cmd[0], cmd[1:]...)
|
||||
stdout, stderr := bytes.Buffer{}, bytes.Buffer{}
|
||||
c.Stdout, c.Stderr = &stdout, &stderr
|
||||
err = c.Run()
|
||||
if err != nil {
|
||||
var e *exec.ExitError
|
||||
if errors.As(err, &e) && e.ExitCode() == 1 {
|
||||
return true, true, stdout.String(), nil
|
||||
}
|
||||
return false, false, stderr.String(), err
|
||||
}
|
||||
return true, false, stdout.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func do_diff(file1, file2 string, context_count int) (ans *Patch, err error) {
|
||||
ok, _, raw, err := run_diff(file1, file2, context_count)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Failed to diff %s vs. %s with errors:\n%s", file1, file2, raw)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
left_lines, err := lines_for_path(file1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
right_lines, err := lines_for_path(file2)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ans, err = parse_patch(raw, left_lines, right_lines)
|
||||
return
|
||||
}
|
||||
|
||||
type diff_job struct{ file1, file2 string }
|
||||
|
||||
func diff(jobs []diff_job, context_count int) (ans map[string]*Patch, err error) {
|
||||
ans = make(map[string]*Patch)
|
||||
ctx := images.Context{}
|
||||
type result struct {
|
||||
file1, file2 string
|
||||
err error
|
||||
patch *Patch
|
||||
}
|
||||
results := make(chan result, len(jobs))
|
||||
ctx.Parallel(0, len(jobs), func(nums <-chan int) {
|
||||
for i := range nums {
|
||||
job := jobs[i]
|
||||
r := result{file1: job.file1, file2: job.file2}
|
||||
r.patch, r.err = do_diff(job.file1, job.file2, context_count)
|
||||
results <- r
|
||||
}
|
||||
})
|
||||
close(results)
|
||||
for r := range results {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
ans[r.file1] = r.patch
|
||||
}
|
||||
return ans, nil
|
||||
}
|
||||
652
kittens/diff/render.go
Normal file
652
kittens/diff/render.go
Normal file
@@ -0,0 +1,652 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/tui/sgr"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/style"
|
||||
"kitty/tools/wcswidth"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type LineType int
|
||||
|
||||
const (
|
||||
TITLE_LINE LineType = iota
|
||||
FULL_TITLE_LINE
|
||||
CHANGE_LINE
|
||||
HUNK_TITLE_LINE
|
||||
IMAGE_LINE
|
||||
EMPTY_LINE
|
||||
)
|
||||
|
||||
type Reference struct {
|
||||
path string
|
||||
linenum int
|
||||
}
|
||||
|
||||
type LogicalLine struct {
|
||||
src Reference
|
||||
line_type LineType
|
||||
screen_lines []string
|
||||
is_change_start bool
|
||||
left_image, right_image struct {
|
||||
key string
|
||||
count int
|
||||
}
|
||||
image_lines_offset int
|
||||
}
|
||||
|
||||
func (self *LogicalLine) IncrementScrollPosBy(pos *ScrollPos, amt int) (delta int) {
|
||||
if len(self.screen_lines) > 0 {
|
||||
npos := utils.Max(0, utils.Min(pos.screen_line+amt, len(self.screen_lines)-1))
|
||||
delta = npos - pos.screen_line
|
||||
pos.screen_line = npos
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func join_half_lines(left, right string) string {
|
||||
return left + "\x1b[m" + right + "\x1b[m"
|
||||
}
|
||||
|
||||
func fit_in(text string, count int) string {
|
||||
truncated := wcswidth.TruncateToVisualLength(text, count)
|
||||
if len(truncated) >= len(text) {
|
||||
return text
|
||||
}
|
||||
if count > 1 {
|
||||
truncated = wcswidth.TruncateToVisualLength(text, count-1)
|
||||
}
|
||||
return truncated + `…`
|
||||
}
|
||||
|
||||
func fill_in(text string, sz int) string {
|
||||
w := wcswidth.Stringwidth(text)
|
||||
if w < sz {
|
||||
text += strings.Repeat(` `, (sz - w))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func place_in(text string, sz int) string {
|
||||
return fill_in(fit_in(text, sz), sz)
|
||||
}
|
||||
|
||||
var title_format, text_format, margin_format, added_format, removed_format, added_margin_format, removed_margin_format, filler_format, margin_filler_format, hunk_margin_format, hunk_format, statusline_format, added_count_format, removed_count_format, message_format func(...any) string
|
||||
|
||||
func create_formatters() {
|
||||
ctx := style.Context{AllowEscapeCodes: true}
|
||||
text_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Background.AsRGBSharp()))
|
||||
filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Filler_bg.AsRGBSharp()))
|
||||
if conf.Margin_filler_bg.IsSet {
|
||||
margin_filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Margin_filler_bg.Color.AsRGBSharp()))
|
||||
} else {
|
||||
margin_filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Filler_bg.AsRGBSharp()))
|
||||
}
|
||||
added_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Added_bg.AsRGBSharp()))
|
||||
added_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Added_margin_bg.AsRGBSharp()))
|
||||
removed_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Removed_bg.AsRGBSharp()))
|
||||
removed_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Removed_margin_bg.AsRGBSharp()))
|
||||
title_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s bold", conf.Title_fg.AsRGBSharp(), conf.Title_bg.AsRGBSharp()))
|
||||
margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Margin_bg.AsRGBSharp()))
|
||||
statusline_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Margin_fg.AsRGBSharp()))
|
||||
added_count_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Highlight_added_bg.AsRGBSharp()))
|
||||
removed_count_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Highlight_removed_bg.AsRGBSharp()))
|
||||
hunk_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Hunk_bg.AsRGBSharp()))
|
||||
hunk_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Hunk_margin_bg.AsRGBSharp()))
|
||||
message_format = ctx.SprintFunc("bold")
|
||||
}
|
||||
|
||||
func center_span(ltype string, offset, size int) *sgr.Span {
|
||||
ans := sgr.NewSpan(offset, size)
|
||||
switch ltype {
|
||||
case "add":
|
||||
ans.SetBackground(conf.Highlight_added_bg).SetClosingBackground(conf.Added_bg)
|
||||
case "remove":
|
||||
ans.SetBackground(conf.Highlight_removed_bg).SetClosingBackground(conf.Removed_bg)
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func title_lines(left_path, right_path string, columns, margin_size int, ans []*LogicalLine) []*LogicalLine {
|
||||
left_name, right_name := path_name_map[left_path], path_name_map[right_path]
|
||||
name := ""
|
||||
m := strings.Repeat(` `, margin_size)
|
||||
ll := LogicalLine{line_type: TITLE_LINE, src: Reference{path: left_path, linenum: 0}}
|
||||
if right_name != "" && right_name != left_name {
|
||||
n1 := fit_in(m+sanitize(left_name), columns/2-margin_size)
|
||||
n1 = place_in(n1, columns/2)
|
||||
n2 := fit_in(m+sanitize(right_name), columns/2-margin_size)
|
||||
n2 = place_in(n2, columns/2)
|
||||
name = n1 + n2
|
||||
} else {
|
||||
name = place_in(m+sanitize(left_name), columns)
|
||||
ll.line_type = FULL_TITLE_LINE
|
||||
}
|
||||
l1 := ll
|
||||
l1.screen_lines = []string{title_format(name)}
|
||||
l2 := ll
|
||||
l2.line_type = EMPTY_LINE
|
||||
l2.screen_lines = []string{title_format(strings.Repeat("━", columns))}
|
||||
return append(ans, &l1, &l2)
|
||||
}
|
||||
|
||||
type LogicalLines struct {
|
||||
lines []*LogicalLine
|
||||
margin_size, columns int
|
||||
}
|
||||
|
||||
func (self *LogicalLines) At(i int) *LogicalLine { return self.lines[i] }
|
||||
func (self *LogicalLines) ScreenLineAt(pos ScrollPos) string {
|
||||
if pos.logical_line < len(self.lines) && pos.logical_line >= 0 {
|
||||
line := self.lines[pos.logical_line]
|
||||
if pos.screen_line < len(line.screen_lines) && pos.screen_line >= 0 {
|
||||
return self.lines[pos.logical_line].screen_lines[pos.screen_line]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (self *LogicalLines) Len() int { return len(self.lines) }
|
||||
|
||||
func (self *LogicalLines) NumScreenLinesTo(a ScrollPos) (ans int) {
|
||||
return self.Minus(a, ScrollPos{})
|
||||
}
|
||||
|
||||
// a - b in terms of number of screen lines between the positions
|
||||
func (self *LogicalLines) Minus(a, b ScrollPos) (delta int) {
|
||||
if a.logical_line == b.logical_line {
|
||||
return a.screen_line - b.screen_line
|
||||
}
|
||||
amt := 1
|
||||
if a.Less(b) {
|
||||
amt = -1
|
||||
} else {
|
||||
a, b = b, a
|
||||
}
|
||||
for i := a.logical_line; i < utils.Min(len(self.lines), b.logical_line+1); i++ {
|
||||
line := self.lines[i]
|
||||
switch i {
|
||||
case a.logical_line:
|
||||
delta += utils.Max(0, len(line.screen_lines)-a.screen_line)
|
||||
case b.logical_line:
|
||||
delta += b.screen_line
|
||||
default:
|
||||
delta += len(line.screen_lines)
|
||||
}
|
||||
}
|
||||
return delta * amt
|
||||
}
|
||||
|
||||
func (self *LogicalLines) IncrementScrollPosBy(pos *ScrollPos, amt int) (delta int) {
|
||||
if pos.logical_line < 0 || pos.logical_line >= len(self.lines) || amt == 0 {
|
||||
return
|
||||
}
|
||||
one := 1
|
||||
if amt < 0 {
|
||||
one = -1
|
||||
}
|
||||
for amt != 0 {
|
||||
line := self.lines[pos.logical_line]
|
||||
d := line.IncrementScrollPosBy(pos, amt)
|
||||
if d == 0 {
|
||||
nlp := pos.logical_line + one
|
||||
if nlp < 0 || nlp >= len(self.lines) {
|
||||
break
|
||||
}
|
||||
pos.logical_line = nlp
|
||||
if one > 0 {
|
||||
pos.screen_line = 0
|
||||
} else {
|
||||
pos.screen_line = len(self.lines[nlp].screen_lines) - 1
|
||||
}
|
||||
delta += one
|
||||
amt -= one
|
||||
} else {
|
||||
amt -= d
|
||||
delta += d
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func human_readable(size int64) string {
|
||||
divisor, suffix := 1, "B"
|
||||
for i, candidate := range []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} {
|
||||
if size < (1 << ((i + 1) * 10)) {
|
||||
divisor, suffix = (1 << (i * 10)), candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
fs := float64(size) / float64(divisor)
|
||||
s := strconv.FormatFloat(fs, 'f', 2, 64)
|
||||
if idx := strings.Index(s, "."); idx > -1 {
|
||||
s = s[:idx+2]
|
||||
}
|
||||
if strings.HasSuffix(s, ".0") || strings.HasSuffix(s, ".00") {
|
||||
idx := strings.IndexByte(s, '.')
|
||||
s = s[:idx]
|
||||
}
|
||||
return s + " " + suffix
|
||||
}
|
||||
|
||||
func render_diff_line(number, text, ltype string, margin_size int, available_cols int) string {
|
||||
m, c := margin_format, text_format
|
||||
switch ltype {
|
||||
case `filler`:
|
||||
m = margin_filler_format
|
||||
c = filler_format
|
||||
case `remove`:
|
||||
m = removed_margin_format
|
||||
c = removed_format
|
||||
case `add`:
|
||||
m = added_margin_format
|
||||
c = added_format
|
||||
}
|
||||
margin := m(place_in(number, margin_size))
|
||||
content := c(fill_in(text, available_cols))
|
||||
return margin + content
|
||||
}
|
||||
|
||||
func image_lines(left_path, right_path string, screen_size screen_size, margin_size int, image_size graphics.Size, ans []*LogicalLine) ([]*LogicalLine, error) {
|
||||
columns := screen_size.columns
|
||||
available_cols := columns/2 - margin_size
|
||||
ll, err := first_binary_line(left_path, right_path, columns, margin_size, func(path string, formatter, margin_formatter formatter) (string, error) {
|
||||
sz, err := size_for_path(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := fmt.Sprintf("Size: %s", human_readable(sz))
|
||||
res := image_collection.ResolutionOf(path)
|
||||
if res.Width > -1 {
|
||||
text = fmt.Sprintf("Dimensions: %dx%d %s", res.Width, res.Height, text)
|
||||
}
|
||||
text = place_in(text, available_cols)
|
||||
return margin_formatter(strings.Repeat(` `, margin_size)) + formatter(text), err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ll.image_lines_offset = len(ll.screen_lines)
|
||||
|
||||
do_side := func(path string, filler string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
sz, err := image_collection.GetSizeIfAvailable(path, image_size)
|
||||
if err == nil {
|
||||
count := int(math.Ceil(float64(sz.Height) / float64(screen_size.cell_height)))
|
||||
return utils.Repeat(filler, count)
|
||||
}
|
||||
if errors.Is(err, graphics.ErrNotFound) {
|
||||
return style.WrapTextAsLines("Loading image...", "", available_cols)
|
||||
}
|
||||
return style.WrapTextAsLines(fmt.Sprintf("Failed to load image: %s", err), "", available_cols)
|
||||
}
|
||||
left_lines := do_side(left_path, removed_format(strings.Repeat(` `, available_cols)))
|
||||
if ll.left_image.count = len(left_lines); ll.left_image.count > 0 {
|
||||
ll.left_image.key = left_path
|
||||
}
|
||||
right_lines := do_side(right_path, added_format(strings.Repeat(` `, available_cols)))
|
||||
if ll.right_image.count = len(right_lines); ll.right_image.count > 0 {
|
||||
ll.right_image.key = right_path
|
||||
}
|
||||
filler := filler_format(strings.Repeat(` `, available_cols))
|
||||
m := strings.Repeat(` `, margin_size)
|
||||
get_line := func(i int, which []string, margin_fmt func(...any) string) string {
|
||||
if i < len(which) {
|
||||
return margin_fmt(m) + which[i]
|
||||
}
|
||||
return margin_filler_format(m) + filler
|
||||
}
|
||||
for i := 0; i < utils.Max(len(left_lines), len(right_lines)); i++ {
|
||||
left, right := get_line(i, left_lines, removed_margin_format), get_line(i, right_lines, added_margin_format)
|
||||
ll.screen_lines = append(ll.screen_lines, left+right)
|
||||
}
|
||||
|
||||
ll.line_type = IMAGE_LINE
|
||||
return append(ans, ll), nil
|
||||
}
|
||||
|
||||
type formatter = func(...any) string
|
||||
|
||||
func first_binary_line(left_path, right_path string, columns, margin_size int, renderer func(path string, formatter, margin_formatter formatter) (string, error)) (*LogicalLine, error) {
|
||||
available_cols := columns/2 - margin_size
|
||||
line := ""
|
||||
if left_path == "" {
|
||||
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
|
||||
r, err := renderer(right_path, added_format, added_margin_format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = filler + r
|
||||
} else if right_path == "" {
|
||||
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
|
||||
l, err := renderer(left_path, removed_format, removed_margin_format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = l + filler
|
||||
} else {
|
||||
l, err := renderer(left_path, removed_format, removed_margin_format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := renderer(right_path, added_format, added_margin_format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = l + r
|
||||
}
|
||||
ref := left_path
|
||||
if ref == "" {
|
||||
ref = right_path
|
||||
}
|
||||
ll := LogicalLine{is_change_start: true, line_type: CHANGE_LINE, src: Reference{path: ref, linenum: 0}, screen_lines: []string{line}}
|
||||
if left_path == "" {
|
||||
ll.src.path = right_path
|
||||
}
|
||||
return &ll, nil
|
||||
}
|
||||
|
||||
func binary_lines(left_path, right_path string, columns, margin_size int, ans []*LogicalLine) (ans2 []*LogicalLine, err error) {
|
||||
available_cols := columns/2 - margin_size
|
||||
ll, err := first_binary_line(left_path, right_path, columns, margin_size, func(path string, formatter, margin_formatter formatter) (string, error) {
|
||||
sz, err := size_for_path(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := fmt.Sprintf("Binary file: %s", human_readable(sz))
|
||||
text = place_in(text, available_cols)
|
||||
return margin_formatter(strings.Repeat(` `, margin_size)) + formatter(text), err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(ans, ll), nil
|
||||
}
|
||||
|
||||
type DiffData struct {
|
||||
left_path, right_path string
|
||||
available_cols, margin_size int
|
||||
|
||||
left_lines, right_lines []string
|
||||
filler_line, left_filler_line, right_filler_line string
|
||||
}
|
||||
|
||||
func hunk_title(hunk_num int, hunk *Hunk, margin_size, available_cols int) string {
|
||||
m := hunk_margin_format(strings.Repeat(" ", margin_size))
|
||||
t := fmt.Sprintf("@@ -%d,%d +%d,%d @@ %s", hunk.left_start+1, hunk.left_count, hunk.right_start+1, hunk.right_count, hunk.title)
|
||||
return m + hunk_format(place_in(t, available_cols))
|
||||
}
|
||||
|
||||
func lines_for_context_chunk(data *DiffData, hunk_num int, chunk *Chunk, chunk_num int, ans []*LogicalLine) []*LogicalLine {
|
||||
for i := 0; i < chunk.left_count; i++ {
|
||||
left_line_number := chunk.left_start + i
|
||||
right_line_number := chunk.right_start + i
|
||||
ll := LogicalLine{line_type: CHANGE_LINE, src: Reference{path: data.left_path, linenum: left_line_number}}
|
||||
left_line_number_s := strconv.Itoa(left_line_number + 1)
|
||||
right_line_number_s := strconv.Itoa(right_line_number + 1)
|
||||
for _, text := range splitlines(data.left_lines[left_line_number], data.available_cols) {
|
||||
line := render_diff_line(left_line_number_s, text, `context`, data.margin_size, data.available_cols)
|
||||
if right_line_number_s == left_line_number_s {
|
||||
line += line
|
||||
} else {
|
||||
line += render_diff_line(right_line_number_s, text, `context`, data.margin_size, data.available_cols)
|
||||
}
|
||||
ll.screen_lines = append(ll.screen_lines, line)
|
||||
left_line_number_s, right_line_number_s = "", ""
|
||||
}
|
||||
ans = append(ans, &ll)
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func splitlines(text string, width int) []string {
|
||||
return style.WrapTextAsLines(text, "", width)
|
||||
}
|
||||
|
||||
func render_half_line(line_number int, line, ltype string, margin_size, available_cols int, center Center, ans []string) []string {
|
||||
size := center.left_size
|
||||
if ltype != "remove" {
|
||||
size = center.right_size
|
||||
}
|
||||
if size > 0 {
|
||||
span := center_span(ltype, center.offset, size)
|
||||
line = sgr.InsertFormatting(line, span)
|
||||
}
|
||||
lnum := strconv.Itoa(line_number + 1)
|
||||
for _, sc := range splitlines(line, available_cols) {
|
||||
ans = append(ans, render_diff_line(lnum, sc, ltype, margin_size, available_cols))
|
||||
lnum = ""
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func lines_for_diff_chunk(data *DiffData, hunk_num int, chunk *Chunk, chunk_num int, ans []*LogicalLine) []*LogicalLine {
|
||||
common := utils.Min(chunk.left_count, chunk.right_count)
|
||||
ll, rl := make([]string, 0, 32), make([]string, 0, 32)
|
||||
for i := 0; i < utils.Max(chunk.left_count, chunk.right_count); i++ {
|
||||
ll, rl = ll[:0], rl[:0]
|
||||
ref_ln, ref_path := 0, ""
|
||||
var center Center
|
||||
if i < len(chunk.centers) {
|
||||
center = chunk.centers[i]
|
||||
}
|
||||
if i < chunk.left_count {
|
||||
ref_path = data.left_path
|
||||
ref_ln = chunk.left_start + i
|
||||
ll = render_half_line(ref_ln, data.left_lines[ref_ln], "remove", data.margin_size, data.available_cols, center, ll)
|
||||
}
|
||||
|
||||
if i < chunk.right_count {
|
||||
ref_path = data.right_path
|
||||
ref_ln = chunk.right_start + i
|
||||
rl = render_half_line(ref_ln, data.right_lines[ref_ln], "add", data.margin_size, data.available_cols, center, rl)
|
||||
}
|
||||
|
||||
if i < common {
|
||||
extra := len(ll) - len(rl)
|
||||
if extra < 0 {
|
||||
ll = append(ll, utils.Repeat(data.left_filler_line, -extra)...)
|
||||
} else if extra > 0 {
|
||||
rl = append(rl, utils.Repeat(data.right_filler_line, extra)...)
|
||||
}
|
||||
} else {
|
||||
if len(ll) > 0 {
|
||||
rl = append(rl, utils.Repeat(data.filler_line, len(ll))...)
|
||||
} else if len(rl) > 0 {
|
||||
ll = append(ll, utils.Repeat(data.filler_line, len(rl))...)
|
||||
}
|
||||
}
|
||||
logline := LogicalLine{line_type: CHANGE_LINE, src: Reference{path: ref_path, linenum: ref_ln}, is_change_start: i == 0}
|
||||
for l := 0; l < len(ll); l++ {
|
||||
logline.screen_lines = append(logline.screen_lines, join_half_lines(ll[l], rl[l]))
|
||||
}
|
||||
ans = append(ans, &logline)
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func lines_for_diff(left_path string, right_path string, patch *Patch, columns, margin_size int, ans []*LogicalLine) (result []*LogicalLine, err error) {
|
||||
ht := LogicalLine{line_type: HUNK_TITLE_LINE, src: Reference{path: left_path}}
|
||||
if patch.Len() == 0 {
|
||||
ht.screen_lines = []string{"The files are identical"}
|
||||
ht.line_type = EMPTY_LINE
|
||||
ans = append(ans, &ht)
|
||||
return ans, 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}
|
||||
if left_path != "" {
|
||||
data.left_lines, err = highlighted_lines_for_path(left_path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if right_path != "" {
|
||||
data.right_lines, err = highlighted_lines_for_path(right_path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
data.filler_line = render_diff_line("", "", "filler", margin_size, available_cols)
|
||||
data.left_filler_line = render_diff_line("", "", "remove", margin_size, available_cols)
|
||||
data.right_filler_line = render_diff_line("", "", "add", margin_size, available_cols)
|
||||
|
||||
for hunk_num, hunk := range patch.all_hunks {
|
||||
htl := ht
|
||||
htl.src.linenum = hunk.left_start
|
||||
htl.screen_lines = []string{hunk_title(hunk_num, hunk, margin_size, columns-margin_size)}
|
||||
ans = append(ans, &htl)
|
||||
for cnum, chunk := range hunk.chunks {
|
||||
if chunk.is_context {
|
||||
ans = lines_for_context_chunk(&data, hunk_num, chunk, cnum, ans)
|
||||
} else {
|
||||
ans = lines_for_diff_chunk(&data, hunk_num, chunk, cnum, ans)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ans, nil
|
||||
}
|
||||
|
||||
func all_lines(path string, columns, margin_size int, is_add bool, ans []*LogicalLine) ([]*LogicalLine, error) {
|
||||
available_cols := columns/2 - margin_size
|
||||
ltype := `add`
|
||||
if !is_add {
|
||||
ltype = `remove`
|
||||
}
|
||||
lines, err := highlighted_lines_for_path(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
|
||||
msg_written := false
|
||||
|
||||
ll := LogicalLine{src: Reference{path: path}, line_type: CHANGE_LINE}
|
||||
for line_number, line := range lines {
|
||||
hlines := make([]string, 0, 8)
|
||||
hlines = render_half_line(line_number, line, ltype, margin_size, available_cols, Center{}, hlines)
|
||||
l := ll
|
||||
l.src.linenum = line_number
|
||||
l.is_change_start = line_number == 0
|
||||
for _, hl := range hlines {
|
||||
empty := filler
|
||||
if !msg_written {
|
||||
msg_written = true
|
||||
msg := `This file was added`
|
||||
if !is_add {
|
||||
msg = `This file was removed`
|
||||
}
|
||||
empty = render_diff_line(``, msg, `filler`, margin_size, available_cols)
|
||||
}
|
||||
var text string
|
||||
if is_add {
|
||||
text = join_half_lines(empty, hl)
|
||||
} else {
|
||||
text = join_half_lines(hl, empty)
|
||||
}
|
||||
l.screen_lines = append(l.screen_lines, text)
|
||||
}
|
||||
ans = append(ans, &l)
|
||||
}
|
||||
return ans, nil
|
||||
}
|
||||
|
||||
func rename_lines(path, other_path string, columns, margin_size int, ans []*LogicalLine) ([]*LogicalLine, error) {
|
||||
m := strings.Repeat(" ", margin_size)
|
||||
ll := LogicalLine{src: Reference{path: path, linenum: 0}, line_type: CHANGE_LINE, is_change_start: true}
|
||||
for _, line := range splitlines(fmt.Sprintf(`The file %s was renamed to %s`, sanitize(path_name_map[path]), sanitize(path_name_map[other_path])), columns-margin_size) {
|
||||
ll.screen_lines = append(ll.screen_lines, m+line)
|
||||
}
|
||||
return append(ans, &ll), nil
|
||||
}
|
||||
|
||||
func render(collection *Collection, diff_map map[string]*Patch, screen_size screen_size, largest_line_number int, image_size graphics.Size) (result *LogicalLines, err error) {
|
||||
margin_size := utils.Max(3, len(strconv.Itoa(largest_line_number))+1)
|
||||
ans := make([]*LogicalLine, 0, 1024)
|
||||
empty_line := LogicalLine{line_type: EMPTY_LINE}
|
||||
columns := screen_size.columns
|
||||
err = collection.Apply(func(path, item_type, changed_path string) error {
|
||||
ans = title_lines(path, changed_path, columns, margin_size, ans)
|
||||
defer func() {
|
||||
el := empty_line
|
||||
ans = append(ans, &el)
|
||||
}()
|
||||
|
||||
is_binary := !is_path_text(path)
|
||||
if !is_binary && item_type == `diff` && !is_path_text(changed_path) {
|
||||
is_binary = true
|
||||
}
|
||||
is_img := is_binary && is_image(path) || (item_type == `diff` && is_image(changed_path))
|
||||
_ = is_img
|
||||
switch item_type {
|
||||
case "diff":
|
||||
if is_binary {
|
||||
if is_img {
|
||||
ans, err = image_lines(path, changed_path, screen_size, margin_size, image_size, ans)
|
||||
} else {
|
||||
ans, err = binary_lines(path, changed_path, columns, margin_size, ans)
|
||||
}
|
||||
} else {
|
||||
ans, err = lines_for_diff(path, changed_path, diff_map[path], columns, margin_size, ans)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "add":
|
||||
if is_binary {
|
||||
if is_img {
|
||||
ans, err = image_lines("", path, screen_size, margin_size, image_size, ans)
|
||||
} else {
|
||||
ans, err = binary_lines("", path, columns, margin_size, ans)
|
||||
}
|
||||
} else {
|
||||
ans, err = all_lines(path, columns, margin_size, true, ans)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "removal":
|
||||
if is_binary {
|
||||
if is_img {
|
||||
ans, err = image_lines(path, "", screen_size, margin_size, image_size, ans)
|
||||
} else {
|
||||
ans, err = binary_lines(path, "", columns, margin_size, ans)
|
||||
}
|
||||
} else {
|
||||
ans, err = all_lines(path, columns, margin_size, false, ans)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "rename":
|
||||
ans, err = rename_lines(path, changed_path, columns, margin_size, ans)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("Unknown change type: %#v", item_type)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return &LogicalLines{lines: ans[:len(ans)-1], margin_size: margin_size, columns: columns}, err
|
||||
}
|
||||
|
||||
func (self *LogicalLines) num_of_screen_lines() (ans int) {
|
||||
for _, l := range self.lines {
|
||||
ans += len(l.screen_lines)
|
||||
}
|
||||
return
|
||||
}
|
||||
145
kittens/diff/search.go
Normal file
145
kittens/diff/search.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kitty/tools/tui/sgr"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
"kitty/tools/wcswidth"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type Search struct {
|
||||
pat *regexp.Regexp
|
||||
matches map[ScrollPos][]*sgr.Span
|
||||
}
|
||||
|
||||
func (self *Search) Len() int { return len(self.matches) }
|
||||
|
||||
func (self *Search) find_matches_in_lines(clean_lines []string, origin int, send_result func(screen_line, offset, size int)) {
|
||||
lengths := utils.Map(func(x string) int { return len(x) }, clean_lines)
|
||||
offsets := make([]int, len(clean_lines))
|
||||
for i := range clean_lines {
|
||||
if i > 0 {
|
||||
offsets[i] = offsets[i-1] + lengths[i-1]
|
||||
}
|
||||
}
|
||||
matches := self.pat.FindAllStringIndex(strings.Join(clean_lines, ""), -1)
|
||||
pos := 0
|
||||
|
||||
find_pos := func(start int) int {
|
||||
for i := pos; i < len(clean_lines); i++ {
|
||||
if start < offsets[i]+lengths[i] {
|
||||
pos = i
|
||||
return pos
|
||||
}
|
||||
|
||||
}
|
||||
return -1
|
||||
}
|
||||
for _, m := range matches {
|
||||
start, end := m[0], m[1]
|
||||
total_size := end - start
|
||||
if total_size < 1 {
|
||||
continue
|
||||
}
|
||||
start_line := find_pos(start)
|
||||
if start_line > -1 {
|
||||
end_line := find_pos(end)
|
||||
if end_line > -1 {
|
||||
for i := start_line; i <= end_line; i++ {
|
||||
offset := 0
|
||||
if i == start_line {
|
||||
offset = start - offsets[i]
|
||||
}
|
||||
size := len(clean_lines[i]) - offset
|
||||
if i == end_line {
|
||||
size = (end - offsets[i]) - offset
|
||||
}
|
||||
send_result(i, origin+offset, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (self *Search) find_matches_in_line(line *LogicalLine, margin_size, cols int, send_result func(screen_line, offset, size int)) {
|
||||
half_width := cols / 2
|
||||
right_offset := half_width + margin_size
|
||||
left_clean_lines, right_clean_lines := make([]string, len(line.screen_lines)), make([]string, len(line.screen_lines))
|
||||
lt := line.line_type
|
||||
for i, line := range line.screen_lines {
|
||||
line = wcswidth.StripEscapeCodes(line)
|
||||
if lt == HUNK_TITLE_LINE || lt == FULL_TITLE_LINE {
|
||||
if len(line) > margin_size {
|
||||
left_clean_lines[i] = line[margin_size:]
|
||||
}
|
||||
} else {
|
||||
if len(line) >= half_width+1 {
|
||||
left_clean_lines[i] = line[margin_size:half_width]
|
||||
}
|
||||
if len(line) > right_offset {
|
||||
right_clean_lines[i] = line[right_offset:]
|
||||
}
|
||||
}
|
||||
}
|
||||
self.find_matches_in_lines(left_clean_lines, margin_size, send_result)
|
||||
self.find_matches_in_lines(right_clean_lines, right_offset, send_result)
|
||||
}
|
||||
|
||||
func (self *Search) Has(pos ScrollPos) bool {
|
||||
return len(self.matches[pos]) > 0
|
||||
}
|
||||
|
||||
func (self *Search) search(logical_lines *LogicalLines) {
|
||||
margin_size := logical_lines.margin_size
|
||||
cols := logical_lines.columns
|
||||
self.matches = make(map[ScrollPos][]*sgr.Span)
|
||||
ctx := images.Context{}
|
||||
mutex := sync.Mutex{}
|
||||
s := sgr.NewSpan(0, 0)
|
||||
s.SetForeground(conf.Search_fg).SetBackground(conf.Search_bg)
|
||||
ctx.Parallel(0, logical_lines.Len(), func(nums <-chan int) {
|
||||
for i := range nums {
|
||||
line := logical_lines.At(i)
|
||||
if line.line_type == EMPTY_LINE || line.line_type == IMAGE_LINE {
|
||||
continue
|
||||
}
|
||||
self.find_matches_in_line(line, margin_size, cols, func(screen_line, offset, size int) {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
sn := *s
|
||||
sn.Offset, sn.Size = offset, size
|
||||
pos := ScrollPos{i, screen_line}
|
||||
self.matches[pos] = append(self.matches[pos], &sn)
|
||||
})
|
||||
}
|
||||
})
|
||||
for _, spans := range self.matches {
|
||||
slices.SortFunc(spans, func(a, b *sgr.Span) bool { return a.Offset < b.Offset })
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Search) markup_line(line string, pos ScrollPos) string {
|
||||
spans := self.matches[pos]
|
||||
if spans == nil {
|
||||
return line
|
||||
}
|
||||
return sgr.InsertFormatting(line, spans...)
|
||||
}
|
||||
|
||||
func do_search(pat *regexp.Regexp, logical_lines *LogicalLines) *Search {
|
||||
ans := &Search{pat: pat, matches: make(map[ScrollPos][]*sgr.Span)}
|
||||
ans.search(logical_lines)
|
||||
return ans
|
||||
}
|
||||
629
kittens/diff/ui.go
Normal file
629
kittens/diff/ui.go
Normal file
@@ -0,0 +1,629 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/tui/readline"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/wcswidth"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type ResultType int
|
||||
|
||||
const (
|
||||
COLLECTION ResultType = iota
|
||||
DIFF
|
||||
HIGHLIGHT
|
||||
IMAGE_LOAD
|
||||
IMAGE_RESIZE
|
||||
)
|
||||
|
||||
type ScrollPos struct {
|
||||
logical_line, screen_line int
|
||||
}
|
||||
|
||||
func (self ScrollPos) Less(other ScrollPos) bool {
|
||||
return self.logical_line < other.logical_line || (self.logical_line == other.logical_line && self.screen_line < other.screen_line)
|
||||
}
|
||||
|
||||
func (self ScrollPos) Add(other ScrollPos) ScrollPos {
|
||||
return ScrollPos{self.logical_line + other.logical_line, self.screen_line + other.screen_line}
|
||||
}
|
||||
|
||||
type AsyncResult struct {
|
||||
err error
|
||||
rtype ResultType
|
||||
collection *Collection
|
||||
diff_map map[string]*Patch
|
||||
page_size graphics.Size
|
||||
}
|
||||
|
||||
var image_collection *graphics.ImageCollection
|
||||
|
||||
type screen_size struct{ rows, columns, num_lines, cell_width, cell_height int }
|
||||
type Handler struct {
|
||||
async_results chan AsyncResult
|
||||
shortcut_tracker config.ShortcutTracker
|
||||
left, right string
|
||||
collection *Collection
|
||||
diff_map map[string]*Patch
|
||||
logical_lines *LogicalLines
|
||||
lp *loop.Loop
|
||||
current_context_count, original_context_count int
|
||||
added_count, removed_count int
|
||||
screen_size screen_size
|
||||
scroll_pos, max_scroll_pos ScrollPos
|
||||
restore_position *ScrollPos
|
||||
inputting_command bool
|
||||
statusline_message string
|
||||
rl *readline.Readline
|
||||
current_search *Search
|
||||
current_search_is_regex, current_search_is_backward bool
|
||||
largest_line_number int
|
||||
images_resized_to graphics.Size
|
||||
}
|
||||
|
||||
func (self *Handler) calculate_statistics() {
|
||||
self.added_count, self.removed_count = self.collection.added_count, self.collection.removed_count
|
||||
self.largest_line_number = 0
|
||||
for _, patch := range self.diff_map {
|
||||
self.added_count += patch.added_count
|
||||
self.removed_count += patch.removed_count
|
||||
self.largest_line_number = utils.Max(patch.largest_line_number, self.largest_line_number)
|
||||
}
|
||||
}
|
||||
|
||||
var DebugPrintln = tty.DebugPrintln
|
||||
|
||||
func (self *Handler) update_screen_size(sz loop.ScreenSize) {
|
||||
self.screen_size.rows = int(sz.HeightCells)
|
||||
self.screen_size.columns = int(sz.WidthCells)
|
||||
self.screen_size.num_lines = self.screen_size.rows - 1
|
||||
self.screen_size.cell_height = int(sz.CellHeight)
|
||||
self.screen_size.cell_width = int(sz.CellWidth)
|
||||
}
|
||||
|
||||
func (self *Handler) on_escape_code(etype loop.EscapeCodeType, payload []byte) error {
|
||||
switch etype {
|
||||
case loop.APC:
|
||||
gc := graphics.GraphicsCommandFromAPC(payload)
|
||||
if gc != nil {
|
||||
if !image_collection.HandleGraphicsCommand(gc) {
|
||||
self.draw_screen()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Handler) finalize() {
|
||||
image_collection.Finalize(self.lp)
|
||||
}
|
||||
|
||||
func (self *Handler) initialize() {
|
||||
self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"})
|
||||
self.lp.OnEscapeCode = self.on_escape_code
|
||||
image_collection = graphics.NewImageCollection()
|
||||
image_collection.Initialize(self.lp)
|
||||
self.current_context_count = opts.Context
|
||||
if self.current_context_count < 0 {
|
||||
self.current_context_count = int(conf.Num_context_lines)
|
||||
}
|
||||
sz, _ := self.lp.ScreenSize()
|
||||
self.update_screen_size(sz)
|
||||
self.original_context_count = self.current_context_count
|
||||
self.lp.SetDefaultColor(loop.FOREGROUND, conf.Foreground)
|
||||
self.lp.SetDefaultColor(loop.CURSOR, conf.Foreground)
|
||||
self.lp.SetDefaultColor(loop.BACKGROUND, conf.Background)
|
||||
self.lp.SetDefaultColor(loop.SELECTION_BG, conf.Select_bg)
|
||||
if conf.Select_fg.IsSet {
|
||||
self.lp.SetDefaultColor(loop.SELECTION_FG, conf.Select_fg.Color)
|
||||
}
|
||||
self.async_results = make(chan AsyncResult, 32)
|
||||
go func() {
|
||||
r := AsyncResult{}
|
||||
r.collection, r.err = create_collection(self.left, self.right)
|
||||
self.async_results <- r
|
||||
self.lp.WakeupMainThread()
|
||||
}()
|
||||
self.draw_screen()
|
||||
}
|
||||
|
||||
func (self *Handler) generate_diff() {
|
||||
self.diff_map = nil
|
||||
jobs := make([]diff_job, 0, 32)
|
||||
self.collection.Apply(func(path, typ, changed_path string) error {
|
||||
if typ == "diff" {
|
||||
if is_path_text(path) && is_path_text(changed_path) {
|
||||
jobs = append(jobs, diff_job{path, changed_path})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
go func() {
|
||||
r := AsyncResult{rtype: DIFF}
|
||||
r.diff_map, r.err = diff(jobs, self.current_context_count)
|
||||
self.async_results <- r
|
||||
self.lp.WakeupMainThread()
|
||||
}()
|
||||
}
|
||||
|
||||
func (self *Handler) on_wakeup() error {
|
||||
var r AsyncResult
|
||||
for {
|
||||
select {
|
||||
case r = <-self.async_results:
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
r.err = self.handle_async_result(r)
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Handler) highlight_all() {
|
||||
text_files := utils.Filter(self.collection.paths_to_highlight.AsSlice(), is_path_text)
|
||||
go func() {
|
||||
r := AsyncResult{rtype: HIGHLIGHT}
|
||||
highlight_all(text_files)
|
||||
self.async_results <- r
|
||||
self.lp.WakeupMainThread()
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
func (self *Handler) load_all_images() {
|
||||
self.collection.Apply(func(path, item_type, changed_path string) error {
|
||||
if path != "" && is_image(path) {
|
||||
image_collection.AddPaths(path)
|
||||
}
|
||||
if changed_path != "" && is_image(changed_path) {
|
||||
image_collection.AddPaths(changed_path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
go func() {
|
||||
r := AsyncResult{rtype: IMAGE_LOAD}
|
||||
image_collection.LoadAll()
|
||||
self.async_results <- r
|
||||
self.lp.WakeupMainThread()
|
||||
}()
|
||||
}
|
||||
|
||||
func (self *Handler) resize_all_images_if_needed() {
|
||||
if self.logical_lines == nil {
|
||||
return
|
||||
}
|
||||
margin_size := self.logical_lines.margin_size
|
||||
columns := self.logical_lines.columns
|
||||
available_cols := columns/2 - margin_size
|
||||
sz := graphics.Size{
|
||||
Width: available_cols * self.screen_size.cell_width,
|
||||
Height: self.screen_size.num_lines * 2 * self.screen_size.cell_height,
|
||||
}
|
||||
if sz != self.images_resized_to {
|
||||
go func() {
|
||||
image_collection.ResizeForPageSize(sz.Width, sz.Height)
|
||||
r := AsyncResult{rtype: IMAGE_RESIZE, page_size: sz}
|
||||
self.async_results <- r
|
||||
self.lp.WakeupMainThread()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Handler) rerender_diff() error {
|
||||
if self.diff_map != nil && self.collection != nil {
|
||||
err := self.render_diff()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.draw_screen()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Handler) handle_async_result(r AsyncResult) error {
|
||||
switch r.rtype {
|
||||
case COLLECTION:
|
||||
self.collection = r.collection
|
||||
self.generate_diff()
|
||||
self.highlight_all()
|
||||
self.load_all_images()
|
||||
case DIFF:
|
||||
self.diff_map = r.diff_map
|
||||
self.calculate_statistics()
|
||||
err := self.render_diff()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.scroll_pos = ScrollPos{}
|
||||
if self.restore_position != nil {
|
||||
self.scroll_pos = *self.restore_position
|
||||
if self.max_scroll_pos.Less(self.scroll_pos) {
|
||||
self.scroll_pos = self.max_scroll_pos
|
||||
}
|
||||
self.restore_position = nil
|
||||
}
|
||||
self.draw_screen()
|
||||
case IMAGE_RESIZE:
|
||||
self.images_resized_to = r.page_size
|
||||
return self.rerender_diff()
|
||||
case IMAGE_LOAD, HIGHLIGHT:
|
||||
return self.rerender_diff()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Handler) on_resize(old_size, new_size loop.ScreenSize) error {
|
||||
self.update_screen_size(new_size)
|
||||
if self.diff_map != nil && self.collection != nil {
|
||||
err := self.render_diff()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if self.max_scroll_pos.Less(self.scroll_pos) {
|
||||
self.scroll_pos = self.max_scroll_pos
|
||||
}
|
||||
}
|
||||
self.draw_screen()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Handler) render_diff() (err error) {
|
||||
if self.screen_size.columns < 8 {
|
||||
return fmt.Errorf("Screen too narrow, need at least 8 columns")
|
||||
}
|
||||
if self.screen_size.rows < 2 {
|
||||
return fmt.Errorf("Screen too short, need at least 2 rows")
|
||||
}
|
||||
self.logical_lines, err = render(self.collection, self.diff_map, self.screen_size, self.largest_line_number, self.images_resized_to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
last := self.logical_lines.Len() - 1
|
||||
self.max_scroll_pos.logical_line = last
|
||||
if last > -1 {
|
||||
self.max_scroll_pos.screen_line = len(self.logical_lines.At(last).screen_lines) - 1
|
||||
} else {
|
||||
self.max_scroll_pos.screen_line = 0
|
||||
}
|
||||
self.logical_lines.IncrementScrollPosBy(&self.max_scroll_pos, -self.screen_size.num_lines+1)
|
||||
if self.current_search != nil {
|
||||
self.current_search.search(self.logical_lines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Handler) draw_image(key string, num_rows, starting_row int) {
|
||||
image_collection.PlaceImageSubRect(self.lp, key, self.images_resized_to, 0, self.screen_size.cell_height*starting_row, -1, -1)
|
||||
}
|
||||
|
||||
func (self *Handler) draw_image_pair(ll *LogicalLine, starting_row int) {
|
||||
if ll.left_image.key != "" {
|
||||
self.lp.MoveCursorHorizontally(self.logical_lines.margin_size)
|
||||
self.draw_image(ll.left_image.key, ll.left_image.count, starting_row)
|
||||
self.lp.QueueWriteString("\r")
|
||||
}
|
||||
if ll.right_image.key != "" {
|
||||
self.lp.MoveCursorHorizontally(self.logical_lines.margin_size + self.logical_lines.columns/2)
|
||||
self.draw_image(ll.right_image.key, ll.right_image.count, starting_row)
|
||||
self.lp.QueueWriteString("\r")
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Handler) draw_screen() {
|
||||
self.lp.StartAtomicUpdate()
|
||||
defer self.lp.EndAtomicUpdate()
|
||||
self.resize_all_images_if_needed()
|
||||
image_collection.DeleteAllVisiblePlacements(self.lp)
|
||||
lp.MoveCursorTo(1, 1)
|
||||
lp.ClearToEndOfScreen()
|
||||
if self.logical_lines == nil || self.diff_map == nil || self.collection == nil {
|
||||
lp.Println(`Calculating diff, please wait...`)
|
||||
return
|
||||
}
|
||||
pos := self.scroll_pos
|
||||
seen_images := utils.NewSet[int]()
|
||||
for num_written := 0; num_written < self.screen_size.num_lines; num_written++ {
|
||||
ll := self.logical_lines.At(pos.logical_line)
|
||||
is_image := ll != nil && ll.line_type == IMAGE_LINE
|
||||
sl := self.logical_lines.ScreenLineAt(pos)
|
||||
if is_image && !seen_images.Has(pos.logical_line) && pos.screen_line >= ll.image_lines_offset {
|
||||
seen_images.Add(pos.logical_line)
|
||||
self.draw_image_pair(ll, pos.screen_line-ll.image_lines_offset)
|
||||
}
|
||||
if self.current_search != nil {
|
||||
sl = self.current_search.markup_line(sl, pos)
|
||||
}
|
||||
lp.QueueWriteString(sl)
|
||||
lp.MoveCursorVertically(1)
|
||||
lp.QueueWriteString("\r")
|
||||
if self.logical_lines.IncrementScrollPosBy(&pos, 1) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
self.draw_status_line()
|
||||
}
|
||||
|
||||
func (self *Handler) draw_status_line() {
|
||||
if self.logical_lines == nil || self.diff_map == nil {
|
||||
return
|
||||
}
|
||||
self.lp.MoveCursorTo(1, self.screen_size.rows)
|
||||
self.lp.ClearToEndOfLine()
|
||||
self.lp.SetCursorVisible(self.inputting_command)
|
||||
if self.inputting_command {
|
||||
self.rl.RedrawNonAtomic()
|
||||
} else if self.statusline_message != "" {
|
||||
self.lp.QueueWriteString(message_format(wcswidth.TruncateToVisualLength(sanitize(self.statusline_message), self.screen_size.columns)))
|
||||
} else {
|
||||
num := self.logical_lines.NumScreenLinesTo(self.scroll_pos)
|
||||
den := self.logical_lines.NumScreenLinesTo(self.max_scroll_pos)
|
||||
var frac int
|
||||
if den > 0 {
|
||||
frac = int((float64(num) * 100.0) / float64(den))
|
||||
}
|
||||
sp := statusline_format(fmt.Sprintf("%d%%", frac))
|
||||
var counts string
|
||||
if self.current_search == nil {
|
||||
counts = added_count_format(strconv.Itoa(self.added_count)) + statusline_format(`,`) + removed_count_format(strconv.Itoa(self.removed_count))
|
||||
} else {
|
||||
counts = statusline_format(fmt.Sprintf("%d matches", self.current_search.Len()))
|
||||
}
|
||||
suffix := counts + " " + sp
|
||||
prefix := statusline_format(":")
|
||||
filler := strings.Repeat(" ", utils.Max(0, self.screen_size.columns-wcswidth.Stringwidth(prefix)-wcswidth.Stringwidth(suffix)))
|
||||
self.lp.QueueWriteString(prefix + filler + suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Handler) on_text(text string, a, b bool) error {
|
||||
if self.inputting_command {
|
||||
defer self.draw_status_line()
|
||||
return self.rl.OnText(text, a, b)
|
||||
}
|
||||
if self.statusline_message != "" {
|
||||
self.statusline_message = ""
|
||||
self.draw_status_line()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Handler) do_search(query string) {
|
||||
self.current_search = nil
|
||||
if len(query) < 2 {
|
||||
return
|
||||
}
|
||||
if !self.current_search_is_regex {
|
||||
query = regexp.QuoteMeta(query)
|
||||
}
|
||||
pat, err := regexp.Compile(`(?i)` + query)
|
||||
if err != nil {
|
||||
self.statusline_message = fmt.Sprintf("Bad regex: %s", err)
|
||||
self.lp.Beep()
|
||||
return
|
||||
}
|
||||
self.current_search = do_search(pat, self.logical_lines)
|
||||
if self.current_search.Len() == 0 {
|
||||
self.current_search = nil
|
||||
self.statusline_message = fmt.Sprintf("No matches for: %#v", query)
|
||||
self.lp.Beep()
|
||||
} else {
|
||||
if self.scroll_to_next_match(false, true) {
|
||||
self.draw_screen()
|
||||
} else {
|
||||
self.lp.Beep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Handler) on_key_event(ev *loop.KeyEvent) error {
|
||||
if self.inputting_command {
|
||||
defer self.draw_status_line()
|
||||
if ev.MatchesPressOrRepeat("esc") {
|
||||
self.inputting_command = false
|
||||
ev.Handled = true
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("enter") {
|
||||
self.inputting_command = false
|
||||
ev.Handled = true
|
||||
self.do_search(self.rl.AllText())
|
||||
self.draw_screen()
|
||||
return nil
|
||||
}
|
||||
return self.rl.OnKeyEvent(ev)
|
||||
}
|
||||
if self.statusline_message != "" {
|
||||
if ev.Type != loop.RELEASE {
|
||||
ev.Handled = true
|
||||
self.statusline_message = ""
|
||||
self.draw_status_line()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if self.current_search != nil && ev.MatchesPressOrRepeat("esc") {
|
||||
self.current_search = nil
|
||||
self.draw_screen()
|
||||
return nil
|
||||
}
|
||||
ac := self.shortcut_tracker.Match(ev, conf.KeyboardShortcuts)
|
||||
if ac != nil {
|
||||
return self.dispatch_action(ac.Name, ac.Args)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Handler) scroll_lines(amt int) (delta int) {
|
||||
before := self.scroll_pos
|
||||
delta = self.logical_lines.IncrementScrollPosBy(&self.scroll_pos, amt)
|
||||
if delta > 0 && self.max_scroll_pos.Less(self.scroll_pos) {
|
||||
self.scroll_pos = self.max_scroll_pos
|
||||
delta = self.logical_lines.Minus(self.scroll_pos, before)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (self *Handler) scroll_to_next_change(backwards bool) bool {
|
||||
if backwards {
|
||||
for i := self.scroll_pos.logical_line - 1; i >= 0; i-- {
|
||||
line := self.logical_lines.At(i)
|
||||
if line.is_change_start {
|
||||
self.scroll_pos = ScrollPos{i, 0}
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i := self.scroll_pos.logical_line + 1; i < self.logical_lines.Len(); i++ {
|
||||
line := self.logical_lines.At(i)
|
||||
if line.is_change_start {
|
||||
self.scroll_pos = ScrollPos{i, 0}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (self *Handler) scroll_to_next_match(backwards, include_current_match bool) bool {
|
||||
if self.current_search == nil {
|
||||
return false
|
||||
}
|
||||
if self.current_search_is_backward {
|
||||
backwards = !backwards
|
||||
}
|
||||
offset, delta := 1, 1
|
||||
if include_current_match {
|
||||
offset = 0
|
||||
}
|
||||
if backwards {
|
||||
offset *= -1
|
||||
delta *= -1
|
||||
}
|
||||
pos := self.scroll_pos
|
||||
if offset != 0 && self.logical_lines.IncrementScrollPosBy(&pos, offset) == 0 {
|
||||
return false
|
||||
}
|
||||
for {
|
||||
if self.current_search.Has(pos) {
|
||||
self.scroll_pos = pos
|
||||
self.draw_screen()
|
||||
return true
|
||||
}
|
||||
if self.logical_lines.IncrementScrollPosBy(&pos, delta) == 0 || self.max_scroll_pos.Less(pos) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (self *Handler) change_context_count(val int) bool {
|
||||
val = utils.Max(0, val)
|
||||
if val == self.current_context_count {
|
||||
return false
|
||||
}
|
||||
self.current_context_count = val
|
||||
p := self.scroll_pos
|
||||
self.restore_position = &p
|
||||
self.generate_diff()
|
||||
self.draw_screen()
|
||||
return true
|
||||
}
|
||||
|
||||
func (self *Handler) start_search(is_regex, is_backward bool) {
|
||||
if self.inputting_command {
|
||||
self.lp.Beep()
|
||||
return
|
||||
}
|
||||
self.inputting_command = true
|
||||
self.current_search_is_regex = is_regex
|
||||
self.current_search_is_backward = is_backward
|
||||
self.rl.SetText(``)
|
||||
self.draw_status_line()
|
||||
}
|
||||
|
||||
func (self *Handler) dispatch_action(name, args string) error {
|
||||
switch name {
|
||||
case `quit`:
|
||||
self.lp.Quit(0)
|
||||
case `scroll_by`:
|
||||
if args == "" {
|
||||
args = "1"
|
||||
}
|
||||
amt, err := strconv.Atoi(args)
|
||||
if err == nil {
|
||||
if self.scroll_lines(amt) == 0 {
|
||||
self.lp.Beep()
|
||||
} else {
|
||||
self.draw_screen()
|
||||
}
|
||||
} else {
|
||||
self.lp.Beep()
|
||||
}
|
||||
case `scroll_to`:
|
||||
done := false
|
||||
switch {
|
||||
case strings.Contains(args, `change`):
|
||||
done = self.scroll_to_next_change(strings.Contains(args, `prev`))
|
||||
case strings.Contains(args, `match`):
|
||||
done = self.scroll_to_next_match(strings.Contains(args, `prev`), false)
|
||||
case strings.Contains(args, `page`):
|
||||
amt := self.screen_size.num_lines
|
||||
if strings.Contains(args, `prev`) {
|
||||
amt *= -1
|
||||
}
|
||||
done = self.scroll_lines(amt) != 0
|
||||
default:
|
||||
npos := self.scroll_pos
|
||||
if strings.Contains(args, `end`) {
|
||||
npos = self.max_scroll_pos
|
||||
} else {
|
||||
npos = ScrollPos{}
|
||||
}
|
||||
done = npos != self.scroll_pos
|
||||
self.scroll_pos = npos
|
||||
}
|
||||
if done {
|
||||
self.draw_screen()
|
||||
} else {
|
||||
self.lp.Beep()
|
||||
}
|
||||
case `change_context`:
|
||||
new_ctx := self.current_context_count
|
||||
switch args {
|
||||
case `all`:
|
||||
new_ctx = 100000
|
||||
case `default`:
|
||||
new_ctx = self.original_context_count
|
||||
default:
|
||||
delta, _ := strconv.Atoi(args)
|
||||
new_ctx += delta
|
||||
}
|
||||
if !self.change_context_count(new_ctx) {
|
||||
self.lp.Beep()
|
||||
}
|
||||
case `start_search`:
|
||||
if self.diff_map != nil && self.logical_lines != nil {
|
||||
a, b, _ := strings.Cut(args, " ")
|
||||
self.start_search(config.StringToBool(a), config.StringToBool(b))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user