// Package config bundles what every view needs to know: the theme and the
// user settings. All views share one Config.
//
// The settings come from ~/.config/jdiff: main.toml holds the settings and the
// name of the theme, state.toml, written by jdiff, the toggles as last set.
// Each themes/<name>.toml is a theme: style and label options as in delta's
// git config, and named colors in a [colors] table. A theme leaves unset
// options to jdiff's defaults; without a name, delta's git config is the
// theme.
package config
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/BurntSushi/toml"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/settings"
"jdiff/internal/theme"
)
// Config is shared by pointer, so a setting changed in one view applies to
// all of them.
type Config struct {
Theme *theme.Theme
Settings settings.Settings
Themes []theme.Spec // all themes found; Theme is the selected one
warnings []string // problems with the files
dir string // the configuration directory, empty for none
statePath string // where SaveState writes, empty for no state file
marks marksState // jump marks, see LoadMarks
// AutoReload is whether what is shown is loaded again when the repository
// changes; R toggles it. It is not saved, so it is off at each start, as
// it ends by itself: see Settings.AutoReloadTimeout.
AutoReload bool
// Search is what / searched for, which . and , search again and the
// views highlight; it is not saved.
Search Search
}
// Query is a search of /: plain text, or with Regex a regular expression.
type Query struct {
Text string
Regex bool
}
// Search is the state of /: the query searched for last, empty for none,
// and all searched for, oldest first.
type Search struct {
Query
History []Query
}
// Add makes q the query searched for, last in the history, once.
func (s *Search) Add(q Query) {
s.Query = q
s.History = append(slices.DeleteFunc(s.History, func(h Query) bool { return h == q }), q)
}
// Default returns the default theme and settings, without files.
func Default() *Config {
return &Config{Theme: theme.Default(), Settings: settings.Default()}
}
// Dir returns jdiff's configuration directory: $XDG_CONFIG_HOME/jdiff, by
// default ~/.config/jdiff, on all systems.
func Dir() (string, error) {
if dir := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(dir) {
return filepath.Join(dir, "jdiff"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "jdiff"), nil
}
// mainFile is the content of main.toml.
type mainFile struct {
settings.Settings
Theme string `toml:"theme"`
}
// Load reads the files in dir over the default settings and selects the
// theme. The themes are jdiff's defaults ("default"), delta's git config
// gitCfg with the features env (DELTA_FEATURES) and flag (--features) select
// ("delta"), and the theme files; main.toml selects one by name, else it is
// "delta". Missing files are skipped and unknown keys reported by Warning;
// files that cannot be parsed are an error, unless they are theme files not
// selected. An empty dir reads no files.
func Load(dir string, gitCfg theme.Config, env, flag string) (*Config, error) {
c := &Config{Settings: settings.Default()}
c.Themes = []theme.Spec{theme.Builtin(), theme.FromGit(gitCfg, theme.Features(gitCfg, env, flag))}
name := ""
if dir != "" {
c.dir = dir
c.statePath = filepath.Join(dir, "state.toml")
m := mainFile{Settings: c.Settings}
if err := c.decode(filepath.Join(dir, "main.toml"), &m); err != nil {
return nil, err
}
c.Settings = m.Settings
if iv := c.Settings.WatchInterval; iv < 0 {
c.warnings = append(c.warnings, fmt.Sprintf("main.toml: watch_interval %v is negative, files are not watched", iv))
c.Settings.WatchInterval = 0
}
if n := c.Settings.InotifyMaxDirs; n < 0 {
c.warnings = append(c.warnings, fmt.Sprintf("main.toml: inotify_max_dirs %d is negative, inotify is off", n))
c.Settings.InotifyMaxDirs = 0
}
// Lines are cut and wrapped by columns: the symbols take one.
def := settings.Default()
for _, sym := range []struct {
key string
v *string
fallback string
}{
{"line_end", &c.Settings.LineEnd, def.LineEnd}, {"cr_end", &c.Settings.CREnd, def.CREnd},
{"extends", &c.Settings.Extends, def.Extends}, {"precedes", &c.Settings.Precedes, def.Precedes},
} {
if ansi.StringWidth(*sym.v) != 1 {
c.warnings = append(c.warnings, fmt.Sprintf("main.toml: %s %q is not one column wide, %q is used",
sym.key, *sym.v, sym.fallback))
*sym.v = sym.fallback
}
}
// state.toml holds only the toggles. Earlier versions kept untracked
// there too: it is left out, without a warning.
state := struct {
settings.Toggles
Untracked bool `toml:"untracked"`
}{Toggles: c.Settings.Toggles}
if err := c.decode(c.statePath, &state); err != nil {
return nil, err
}
c.Settings.Toggles = state.Toggles
name = m.Theme
if err := c.readThemes(filepath.Join(dir, "themes"), name); err != nil {
return nil, err
}
}
c.Theme = theme.Resolve(c.selectTheme(name, dir))
return c, nil
}
// readThemes adds the theme files in dir to the themes. A file that cannot
// be parsed is an error when it is the selected theme, else a warning, and
// unknown keys are reported for the selected theme only.
func (c *Config) readThemes(dir, selected string) error {
paths, _ := filepath.Glob(filepath.Join(dir, "*.toml"))
for _, path := range paths {
name := strings.TrimSuffix(filepath.Base(path), ".toml")
if c.theme(name) != nil {
c.warnings = append(c.warnings, fmt.Sprintf("%s: %q is a built-in theme, file ignored", filepath.Base(path), name))
continue
}
sp, unknown, err := theme.FromFile(path)
if err != nil {
err = fmt.Errorf("%s: %w", path, err)
if name == selected {
return err
}
c.warnings = append(c.warnings, err.Error())
continue
}
if name == selected {
c.unknown(path, unknown)
}
c.Themes = append(c.Themes, sp)
}
return nil
}
// selectTheme returns the theme called name, "delta" for "", or with a
// warning "delta" when there is none.
func (c *Config) selectTheme(name, dir string) theme.Spec {
if name == "" {
name = "delta"
}
if sp := c.theme(name); sp != nil {
return *sp
}
c.warnings = append(c.warnings, fmt.Sprintf("theme %q: no file %s", name, filepath.Join(dir, "themes", name+".toml")))
return *c.theme("delta")
}
// theme returns the theme called name, or nil.
func (c *Config) theme(name string) *theme.Spec {
for i := range c.Themes {
if c.Themes[i].Name == name {
return &c.Themes[i]
}
}
return nil
}
// decode reads the TOML file path into v, if it exists, and records its
// unknown keys.
func (c *Config) decode(path string, v any) error {
md, err := toml.DecodeFile(path, v)
switch {
case errors.Is(err, fs.ErrNotExist):
return nil
case err != nil:
return fmt.Errorf("%s: %w", path, err)
}
var unknown []string
for _, k := range md.Undecoded() {
unknown = append(unknown, k.String())
}
c.unknown(path, unknown)
return nil
}
// unknown reports the unknown keys of the file path.
func (c *Config) unknown(path string, keys []string) {
if len(keys) > 0 {
sort.Strings(keys)
c.warnings = append(c.warnings, fmt.Sprintf("%s: unknown %s", filepath.Base(path), strings.Join(keys, ", ")))
}
}
// SaveState writes the toggled settings to state.toml, through a temporary
// file so that the file is never left half written.
func (c *Config) SaveState() error {
if c.statePath == "" {
return nil
}
return writeTOML(c.statePath, "# Written by jdiff: the toggles as last set, over main.toml.\n", c.Settings.Toggles)
}
// writeTOML writes header and v as TOML to path, through a temporary file
// so that the file is never left half written.
func writeTOML(path, header string, v any) error {
var b bytes.Buffer
b.WriteString(header)
if err := toml.NewEncoder(&b).Encode(v); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// Dir returns the configuration directory the files were read from, "" for
// none.
func (c *Config) Dir() string { return c.dir }
// AddWarning adds a problem with a file that another package reads from the
// configuration directory, for Warning.
func (c *Config) AddWarning(w string) { c.warnings = append(c.warnings, w) }
// Warning returns the problems with the theme and the files, or "".
func (c *Config) Warning() string {
var parts []string
if len(c.Theme.Warnings) > 0 {
parts = append(parts, "theme: "+strings.Join(c.Theme.Warnings, "; "))
}
return strings.Join(append(parts, c.warnings...), "; ")
}
package config
import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"sort"
"github.com/BurntSushi/toml"
)
// Mark is a jump mark: a line of the new side of a file.
type Mark struct {
Path string `toml:"path"` // relative to the repository, absolute outside one
Line int `toml:"line"` // 1-based
Text string `toml:"text"` // the line when the mark was set, to find it again
}
// LetterMark is a mark with its letter.
type LetterMark struct {
Letter string
Mark
}
// marksState holds the jump marks of marks.toml: a table per repository,
// keyed by its top directory, "" outside repositories.
type marksState struct {
path string // marks.toml, empty for no file
repo string // the current repository
all map[string]map[string]Mark // the marks of all repositories
}
// LoadMarks reads the jump marks of repository repo, its top directory or ""
// outside repositories, from marks.toml in the configuration directory. A
// file that cannot be parsed is reported by Warning and replaced by the next
// change of a mark.
func (c *Config) LoadMarks(repo string) {
c.marks = marksState{repo: repo, all: map[string]map[string]Mark{}}
if c.dir == "" {
return
}
c.marks.path = filepath.Join(c.dir, "marks.toml")
if _, err := toml.DecodeFile(c.marks.path, &c.marks.all); err != nil && !errors.Is(err, fs.ErrNotExist) {
c.warnings = append(c.warnings, fmt.Sprintf("marks.toml: %v; marks are lost on the next change", err))
c.marks.all = map[string]map[string]Mark{}
}
}
// Repo returns the repository whose marks are used, "" outside one.
func (c *Config) Repo() string { return c.marks.repo }
// Mark returns the mark of letter in the current repository.
func (c *Config) Mark(letter string) (Mark, bool) {
m, ok := c.marks.all[c.marks.repo][letter]
return m, ok
}
// Marks returns the marks of the current repository, sorted by letter.
func (c *Config) Marks() []LetterMark {
var out []LetterMark
for letter, m := range c.marks.all[c.marks.repo] {
out = append(out, LetterMark{letter, m})
}
sort.Slice(out, func(i, j int) bool { return out[i].Letter < out[j].Letter })
return out
}
// SetMark sets the mark of letter in the current repository and saves the
// marks.
func (c *Config) SetMark(letter string, m Mark) error {
if c.marks.all == nil {
c.marks.all = map[string]map[string]Mark{}
}
if c.marks.all[c.marks.repo] == nil {
c.marks.all[c.marks.repo] = map[string]Mark{}
}
c.marks.all[c.marks.repo][letter] = m
return c.saveMarks()
}
// DeleteMark deletes the mark of letter in the current repository and saves
// the marks.
func (c *Config) DeleteMark(letter string) error {
delete(c.marks.all[c.marks.repo], letter)
if len(c.marks.all[c.marks.repo]) == 0 {
delete(c.marks.all, c.marks.repo)
}
return c.saveMarks()
}
func (c *Config) saveMarks() error {
if c.marks.path == "" {
return nil
}
return writeTOML(c.marks.path, "# Written by jdiff: jump marks per repository (m<letter> sets, '<letter> jumps).\n", c.marks.all)
}
// Package diff aligns two files line by line for side-by-side display.
//
// Lines are matched on their content with surrounding whitespace removed, so
// re-indented code still lines up with its original. User-chosen anchors force
// specific line pairs to line up and split the matching into independent
// sections.
package diff
import "strings"
// Kind classifies a display row.
type Kind int
const (
Equal Kind = iota // both lines identical
Change // both lines present but different
Delete // only the left line is present
Insert // only the right line is present
)
// Anchor forces line Left of the old file to line up with line Right of the
// new file. Both are 0-based.
type Anchor struct{ Left, Right int }
// Span is a byte range [Start, End) within a line.
type Span struct{ Start, End int }
// Row is one display row of the side-by-side view.
type Row struct {
Kind Kind
Left, Right int // 0-based line indices, -1 when absent
Anchored bool // the row is a user anchor
// Emphasized parts of Change rows, as computed by Intraline.
LeftEmph, RightEmph []Span
}
// Options control how changed lines are compared.
type Options struct {
// SplitIndent compares indentation character by character, so only its
// added or removed part is emphasized.
SplitIndent bool
}
// Align computes the display rows for old lines a and new lines b. anchors
// must be sorted by Left and must not cross (see Conflicts).
func Align(a, b []string, anchors []Anchor, opt Options) []Row {
al := aligner{a: a, b: b, ka: keys(a), kb: keys(b), opt: opt}
pa, pb := 0, 0
for _, an := range anchors {
al.section(pa, an.Left, pb, an.Right)
al.addPair(an.Left, an.Right, true)
pa, pb = an.Left+1, an.Right+1
}
al.section(pa, len(a), pb, len(b))
return al.rows
}
// aligner accumulates the rows of one alignment.
type aligner struct {
a, b, ka, kb []string
opt Options
rows []Row
}
// Conflicts returns the anchors that cannot coexist with n, because they
// share a line with it or would cross it.
func Conflicts(anchors []Anchor, n Anchor) []Anchor {
var out []Anchor
for _, an := range anchors {
dl, dr := an.Left-n.Left, an.Right-n.Right
if dl == 0 || dr == 0 || (dl < 0) != (dr < 0) {
out = append(out, an)
}
}
return out
}
// Stats counts the added and removed lines of an alignment. A changed line
// counts as both.
func Stats(rows []Row) (added, removed int) {
for _, r := range rows {
switch r.Kind {
case Change:
added, removed = added+1, removed+1
case Insert:
added++
case Delete:
removed++
}
}
return added, removed
}
func keys(lines []string) []string {
out := make([]string, len(lines))
for i, l := range lines {
out[i] = strings.TrimSpace(l)
}
return out
}
// section appends the rows for a[a0:a1] against b[b0:b1].
func (al *aligner) section(a0, a1, b0, b1 int) {
i, j := a0, b0
for _, p := range patience(al.ka[a0:a1], al.kb[b0:b1]) {
al.gap(i, a0+p.a, j, b0+p.b)
al.addPair(a0+p.a, b0+p.b, false)
i, j = a0+p.a+1, b0+p.b+1
}
al.gap(i, a1, j, b1)
}
// gap appends rows for unmatched lines. Lines are paired by position as
// changes; the remainder becomes plain deletions or insertions.
func (al *aligner) gap(i0, i1, j0, j1 int) {
for i0 < i1 && j0 < j1 {
al.addPair(i0, j0, false)
i0, j0 = i0+1, j0+1
}
for ; i0 < i1; i0++ {
al.rows = append(al.rows, Row{Kind: Delete, Left: i0, Right: -1})
}
for ; j0 < j1; j0++ {
al.rows = append(al.rows, Row{Kind: Insert, Left: -1, Right: j0})
}
}
// addPair appends the row of line l of a next to line r of b.
func (al *aligner) addPair(l, r int, anchored bool) {
row := Row{Kind: Equal, Left: l, Right: r, Anchored: anchored}
if al.a[l] != al.b[r] {
row.Kind = Change
row.LeftEmph, row.RightEmph = Intraline(al.a[l], al.b[r], al.opt.SplitIndent)
}
al.rows = append(al.rows, row)
}
package diff
import (
"unicode"
"unicode/utf8"
)
// maxLineDistance is the share of changed content above which two paired
// lines count as unrelated and get no emphasis (delta uses the same default).
const maxLineDistance = 0.6
type token struct {
text string
start, end int
}
// Intraline returns the parts of a and b that differ, at word granularity.
// Leading whitespace is its own token, so an indentation change emphasizes
// just the indentation; with splitIndent each indentation character is a
// token, and runs of whitespace next to the same unchanged words keep what
// they have in common, so only the added or removed part is emphasized. Both
// results are nil when the lines are too different for emphasis to be
// useful.
func Intraline(a, b string, splitIndent bool) (ea, eb []Span) {
ta, tb := tokenize(a, splitIndent), tokenize(b, splitIndent)
ma := make([]bool, len(ta))
mb := make([]bool, len(tb))
texts := func(ts []token) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.text
}
return out
}
pairs := myers(texts(ta), texts(tb))
for _, p := range pairs {
ma[p.a], mb[p.b] = true, true
}
// keep holds the bytes at the start and the end of an unmatched token
// that are the same as on the other side.
ka, kb := make([]kept, len(ta)), make([]kept, len(tb))
if splitIndent {
prev := pair{-1, -1}
for _, p := range append(pairs, pair{len(ta), len(tb)}) {
keepWhitespace(ta[prev.a+1:p.a], tb[prev.b+1:p.b], ka[prev.a+1:p.a], kb[prev.b+1:p.b])
prev = p
}
}
changedA, totalA := contentBytes(ta, ma, ka)
changedB, totalB := contentBytes(tb, mb, kb)
if total := totalA + totalB; total > 0 &&
float64(changedA+changedB)/float64(total) > maxLineDistance {
return nil, nil
}
return spans(ta, ma, ka), spans(tb, mb, kb)
}
// kept is the unchanged start and end of a token, in bytes.
type kept struct{ start, end int }
// keepWhitespace finds what the unmatched tokens a and b between the same
// two matches have in common in whitespace, into ka and kb: a whitespace
// run that starts both keeps their common start, one that ends both their
// common end, as words inserted into padding take its place. A run that is
// all of one side pairs with the run of the other that has more in common.
func keepWhitespace(a, b []token, ka, kb []kept) {
if len(a) == 0 || len(b) == 0 {
return
}
common := func(x, y string, fromEnd bool) int {
n := 0
for n < len(x) && n < len(y) {
i, j := n, n
if fromEnd {
i, j = len(x)-1-n, len(y)-1-n
}
if x[i] != y[j] {
break
}
n++
}
return n
}
first, last := 0, 0
if isSpace(a[0].text) && isSpace(b[0].text) {
first = common(a[0].text, b[0].text, false)
}
la, lb := len(a)-1, len(b)-1
if isSpace(a[la].text) && isSpace(b[lb].text) {
last = common(a[la].text, b[lb].text, true)
}
switch {
case la > 0 && lb > 0: // distinct first and last runs: both
ka[0].start, kb[0].start = first, first
ka[la].end, kb[lb].end = last, last
case last > first:
ka[la].end, kb[lb].end = last, last
default:
ka[0].start, kb[0].start = first, first
}
}
// contentBytes counts the changed and total bytes, leaving out indentation so
// that re-indented lines do not look unrelated. keep is the unchanged start
// and end of each token, see keepWhitespace.
func contentBytes(ts []token, matched []bool, keep []kept) (changed, total int) {
indent := true
for i, t := range ts {
if indent = indent && isSpace(t.text); indent {
continue
}
total += len(t.text)
if !matched[i] {
changed += max(len(t.text)-keep[i].start-keep[i].end, 0)
}
}
return changed, total
}
// spans returns the parts of the tokens ts that changed: the unmatched ones
// without their unchanged start and end keep.
func spans(ts []token, matched []bool, keep []kept) []Span {
var out []Span
for i, t := range ts {
start, end := t.start+keep[i].start, t.end-keep[i].end
if matched[i] || start >= end {
continue
}
if n := len(out); n > 0 && out[n-1].End == start {
out[n-1].End = end
} else {
out = append(out, Span{start, end})
}
}
return out
}
// tokenize splits s into runs of word characters, runs of whitespace and
// single other characters. With splitIndent, each character of the leading
// whitespace is a token of its own.
func tokenize(s string, splitIndent bool) []token {
var out []token
indent := splitIndent
for i := 0; i < len(s); {
r, n := utf8.DecodeRuneInString(s[i:])
j := i + n
class := runeClass(r)
if indent = indent && class == classSpace; indent {
out = append(out, token{s[i:j], i, j})
i = j
continue
}
if class != classSingle {
for j < len(s) {
r2, n2 := utf8.DecodeRuneInString(s[j:])
if runeClass(r2) != class {
break
}
j += n2
}
}
out = append(out, token{s[i:j], i, j})
i = j
}
return out
}
// class is the kind of character a token is made of.
type class int
const (
classSingle class = iota // characters that form a token on their own
classWord
classSpace
)
func runeClass(r rune) class {
switch {
case r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r):
return classWord
case unicode.IsSpace(r):
return classSpace
}
return classSingle
}
func isSpace(s string) bool {
r, _ := utf8.DecodeRuneInString(s)
return runeClass(r) == classSpace
}
package diff
// pair is a matched pair of indices into the two compared sequences.
type pair struct{ a, b int }
// maxEditDistance bounds the Myers search. Beyond it the inputs are treated
// as unrelated, which keeps memory at O(D²) for pathological inputs.
const maxEditDistance = 2000
// patience matches a against b using the patience algorithm: lines that are
// unique in both inputs become fixed points, the gaps between them are
// matched recursively, and gaps without unique lines fall back to Myers.
func patience(a, b []string) []pair {
var out []pair
patienceRec(a, b, 0, 0, &out)
return out
}
func patienceRec(a, b []string, ao, bo int, out *[]pair) {
for len(a) > 0 && len(b) > 0 && a[0] == b[0] {
*out = append(*out, pair{ao, bo})
a, b = a[1:], b[1:]
ao, bo = ao+1, bo+1
}
suffix := 0
for suffix < len(a) && suffix < len(b) && a[len(a)-1-suffix] == b[len(b)-1-suffix] {
suffix++
}
a, b = a[:len(a)-suffix], b[:len(b)-suffix]
if len(a) > 0 && len(b) > 0 {
fixed := uniqueLIS(a, b)
if len(fixed) == 0 {
for _, p := range myers(a, b) {
*out = append(*out, pair{ao + p.a, bo + p.b})
}
} else {
pa, pb := 0, 0
for _, p := range fixed {
patienceRec(a[pa:p.a], b[pb:p.b], ao+pa, bo+pb, out)
*out = append(*out, pair{ao + p.a, bo + p.b})
pa, pb = p.a+1, p.b+1
}
patienceRec(a[pa:], b[pb:], ao+pa, bo+pb, out)
}
}
for i := range suffix {
*out = append(*out, pair{ao + len(a) + i, bo + len(b) + i})
}
}
// uniqueLIS returns the longest sequence of lines that occur exactly once in
// both a and b and appear in the same order in both.
func uniqueLIS(a, b []string) []pair {
type count struct{ a, b, bi int }
counts := make(map[string]*count)
for _, s := range a {
c := counts[s]
if c == nil {
c = &count{}
counts[s] = c
}
c.a++
}
for i, s := range b {
if c := counts[s]; c != nil {
c.b++
c.bi = i
}
}
var cand []pair
for i, s := range a {
if c := counts[s]; c.a == 1 && c.b == 1 {
cand = append(cand, pair{i, c.bi})
}
}
if len(cand) == 0 {
return nil
}
// Patience sorting: tails[k] is the index into cand of the smallest b
// ending an increasing run of length k+1.
var tails []int
prev := make([]int, len(cand))
for i, p := range cand {
lo, hi := 0, len(tails)
for lo < hi {
mid := (lo + hi) / 2
if cand[tails[mid]].b < p.b {
lo = mid + 1
} else {
hi = mid
}
}
prev[i] = -1
if lo > 0 {
prev[i] = tails[lo-1]
}
if lo == len(tails) {
tails = append(tails, i)
} else {
tails[lo] = i
}
}
lis := make([]pair, len(tails))
for i, k := len(tails)-1, tails[len(tails)-1]; i >= 0; i, k = i-1, prev[k] {
lis[i] = cand[k]
}
return lis
}
// myers returns a longest common subsequence of a and b as matched index
// pairs, or nil when the edit distance exceeds maxEditDistance.
func myers[T comparable](a, b []T) []pair {
n, m := len(a), len(b)
if n == 0 || m == 0 {
return nil
}
limit := min(n+m, maxEditDistance)
off := limit + 1
v := make([]int, 2*limit+3)
// trace[d] holds v[-d..d] as it was before step d.
var trace [][]int
for d := 0; d <= limit; d++ {
trace = append(trace, append([]int(nil), v[off-d:off+d+1]...))
for k := -d; k <= d; k += 2 {
var x int
if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
x = v[off+k+1]
} else {
x = v[off+k-1] + 1
}
y := x - k
for x < n && y < m && a[x] == b[y] {
x, y = x+1, y+1
}
v[off+k] = x
if x >= n && y >= m {
return myersBacktrack(trace, n, m)
}
}
}
return nil
}
func myersBacktrack(trace [][]int, n, m int) []pair {
var out []pair
x, y := n, m
for d := len(trace) - 1; d > 0; d-- {
v := trace[d]
at := func(k int) int { return v[k+d] }
k := x - y
var pk int
if k == -d || (k != d && at(k-1) < at(k+1)) {
pk = k + 1
} else {
pk = k - 1
}
px := at(pk)
py := px - pk
for x > px && y > py {
x, y = x-1, y-1
out = append(out, pair{x, y})
}
x, y = px, py
}
for x > 0 && y > 0 {
x, y = x-1, y-1
out = append(out, pair{x, y})
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
package diff
import (
"strings"
"unicode"
)
// MoveInput is one file of the files searched for moved code, such as the
// files of one commit: its old and new lines and their alignment.
type MoveInput struct {
Old, New []string
Rows []Row
}
// Ref is a line of one of the files: the index of the file and of the line.
type Ref struct{ File, Line int }
// Moves are the moved lines of the files, per file: From maps removed lines
// (indices into Old) to the lines they moved to, To maps added lines
// (indices into New) to the lines they came from.
type Moves struct {
From, To []map[int]Ref
}
// minMovedAlnum is the number of letters and digits a block needs to count
// as moved, as in git, so that lone braces and the like are not marked.
const minMovedAlnum = 20
// FindMoves finds blocks of consecutive removed lines that were added again
// as consecutive lines, in the same file or another one. Lines are compared
// without surrounding whitespace, as Align matches them. Lines of an Equal
// row are neither removed nor added, and the two lines of a Change row are a
// change in place, not a move. Blocks are taken in file and line order, the
// longest at each line first; each line belongs to at most one block.
func FindMoves(files []MoveInput) Moves {
type side struct {
row map[int]int // line -> row, for removed or added lines only
used map[int]bool
}
removed := make([]side, len(files))
added := make([]side, len(files))
candidates := map[string][]Ref{} // added lines by their key
// Added lines followed by another added line, by the keys of both: the
// starts of blocks of two lines or more, so that a line found in many
// places, like a blank one, is not tried against all of them.
pairs := map[string][]Ref{}
pairKey := func(lines []string, line int) string {
return strings.TrimSpace(lines[line]) + "\n" + strings.TrimSpace(lines[line+1])
}
for i, f := range files {
removed[i] = side{row: map[int]int{}, used: map[int]bool{}}
added[i] = side{row: map[int]int{}, used: map[int]bool{}}
for r, row := range f.Rows {
if row.Kind == Equal {
continue
}
if row.Left >= 0 {
removed[i].row[row.Left] = r
}
if row.Right >= 0 {
added[i].row[row.Right] = r
}
}
for line := range f.New {
if _, ok := added[i].row[line]; ok {
key := strings.TrimSpace(f.New[line])
candidates[key] = append(candidates[key], Ref{i, line})
if _, ok := added[i].row[line+1]; ok {
pk := pairKey(f.New, line)
pairs[pk] = append(pairs[pk], Ref{i, line})
}
}
}
}
// matches reports whether removed line l of file i can move to added
// line to.
matches := func(i, l int, to Ref) bool {
rr, ok := removed[i].row[l]
if !ok || removed[i].used[l] {
return false
}
ar, ok := added[to.File].row[to.Line]
if !ok || added[to.File].used[to.Line] || (to.File == i && ar == rr) {
return false
}
return strings.TrimSpace(files[i].Old[l]) == strings.TrimSpace(files[to.File].New[to.Line])
}
moves := Moves{From: make([]map[int]Ref, len(files)), To: make([]map[int]Ref, len(files))}
for i := range files {
moves.From[i], moves.To[i] = map[int]Ref{}, map[int]Ref{}
}
// longest returns the longest block starting at removed line l of file
// i among the added lines cands, the first one on a tie, and its length.
longest := func(i, l int, cands []Ref) (best Ref, n int) {
for _, c := range cands {
k := 0
for matches(i, l+k, Ref{c.File, c.Line + k}) {
k++
}
if k > n {
best, n = c, k
}
}
return best, n
}
for i, f := range files {
for l := 0; l < len(f.Old); l++ {
// Blocks of two lines or more start among the pairs, in the
// order of the candidates. A single line counts only with
// enough letters and digits of its own, rarely found in many
// places: only then are all candidates tried.
var best Ref
n := 0
if l+1 < len(f.Old) {
best, n = longest(i, l, pairs[pairKey(f.Old, l)])
}
if n < 2 && alnum(f.Old[l:l+1]) >= minMovedAlnum {
best, n = longest(i, l, candidates[strings.TrimSpace(f.Old[l])])
}
if n == 0 || alnum(f.Old[l:l+n]) < minMovedAlnum {
continue
}
for k := range n {
to := Ref{best.File, best.Line + k}
removed[i].used[l+k], added[to.File].used[to.Line] = true, true
moves.From[i][l+k] = to
moves.To[to.File][to.Line] = Ref{i, l + k}
}
l += n - 1
}
}
return moves
}
// alnum counts the letters and digits in lines.
func alnum(lines []string) int {
n := 0
for _, l := range lines {
for _, r := range l {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
n++
}
}
}
return n
}
// Package gitsrc reads commits and their changed files from git.
package gitsrc
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"jdiff/internal/source"
)
// emptyTree is the id of git's empty tree, the base of root commits.
const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
// Commit is one commit of the log.
type Commit struct {
Hash, Short string
Author, Date string
Subject string
Body string // the message after the subject, trimmed
Parent string // first parent, empty for a root commit
Added, Removed int // line counts, which Log leaves 0: see Counts
}
// LineCounts are the lines a commit added and removed.
type LineCounts struct{ Added, Removed int }
// Repo runs git in a directory; the empty Dir means the current directory.
type Repo struct{ Dir string }
func (r Repo) git(args ...string) ([]byte, error) { return r.run(nil, nil, args...) }
// run runs git with env added to its environment and stdin as its input.
func (r Repo) run(env []string, stdin []byte, args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = r.Dir
cmd.Env = append(cmd.Environ(), env...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return nil, fmt.Errorf("git %s: %s", args[0], msg)
}
return nil, fmt.Errorf("git %s: %w", args[0], err)
}
return out, nil
}
// Reversed reports whether git log lists the commits of args oldest first:
// each --reverse before a -- flips the order.
func Reversed(args []string) bool {
rev := false
for _, a := range args {
if a == "--" {
break
}
if a == "--reverse" {
rev = !rev
}
}
return rev
}
// Log returns the commits git log selects with args, in its order, without
// their line counts: counting diffs every commit, which takes seconds in a
// long history, so Counts does it for the commits shown. Records start with
// RS, fields are separated by US and the body, which may span lines, ends
// with GS.
func (r Repo) Log(args []string) ([]Commit, error) {
out, err := r.git(append([]string{"log",
"--format=%x1e%H%x1f%h%x1f%an%x1f%ad%x1f%P%x1f%s%x1f%b%x1d",
"--date=short"}, args...)...)
if err != nil {
return nil, err
}
var commits []Commit
for rec := range strings.SplitSeq(string(out), "\x1e") {
if rec == "" {
continue
}
header, _, _ := strings.Cut(rec, "\x1d")
f := strings.Split(header, "\x1f")
if len(f) != 7 {
return nil, fmt.Errorf("git log: unexpected record %q", header)
}
c := Commit{Hash: f[0], Short: f[1], Author: f[2], Date: f[3], Subject: f[5],
Body: strings.TrimSpace(f[6])}
c.Parent, _, _ = strings.Cut(f[4], " ")
commits = append(commits, c)
}
return commits, nil
}
// Counts returns the lines the commits hashes added and removed, by hash.
// Merges are counted against their first parent, as Files compares them,
// root commits against the empty tree; binary files are not counted.
func (r Repo) Counts(hashes []string) (map[string]LineCounts, error) {
counts := map[string]LineCounts{}
if len(hashes) == 0 {
return counts, nil
}
out, err := r.git(append([]string{"log", "--no-walk=unsorted", "--format=%x1e%H", "--numstat", "--no-relative",
"--diff-merges=first-parent", "--end-of-options"}, hashes...)...)
if err != nil {
return nil, err
}
for rec := range strings.SplitSeq(string(out), "\x1e") {
hash, stat, _ := strings.Cut(rec, "\n")
if hash == "" {
continue
}
var c LineCounts
for line := range strings.SplitSeq(stat, "\n") {
// "added<TAB>removed<TAB>path"; binary files show "-".
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 3 {
continue
}
added, err1 := strconv.Atoi(parts[0])
removed, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil {
c.Added += added
c.Removed += removed
}
}
counts[hash] = c
}
return counts, nil
}
// TopLevel returns the top directory of the repository, or an error outside
// one. git writes it with slashes also on Windows; it is returned in the
// system's form, so that it compares with other paths.
func (r Repo) TopLevel() (string, error) {
out, err := r.git("rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
return filepath.Clean(strings.TrimSpace(string(out))), nil
}
// Branch returns the name of the checked out branch, or "detached HEAD".
func (r Repo) Branch() string {
out, err := r.git("symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return "detached HEAD"
}
return strings.TrimSpace(string(out))
}
// WatchPaths returns the files that change when the repository does: HEAD, the
// index, the packed refs and the ref of the checked out branch. They are where
// git has them, also in a worktree.
func (r Repo) WatchPaths() ([]string, error) {
names := []string{"HEAD", "index", "packed-refs"}
if ref, err := r.git("symbolic-ref", "-q", "HEAD"); err == nil {
names = append(names, strings.TrimSpace(string(ref)))
}
args := []string{"rev-parse"}
for _, n := range names {
args = append(args, "--git-path", n)
}
out, err := r.git(args...)
if err != nil {
return nil, err
}
paths := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, p := range paths {
if !filepath.IsAbs(p) {
p = filepath.Join(r.Dir, p) // relative to where git ran
}
paths[i] = filepath.Clean(p) // git writes / also on Windows
}
return paths, nil
}
// Status returns the state of the repository: the commit of HEAD, the branch,
// staged, changed and untracked files. It does not write the index.
func (r Repo) Status() (string, error) {
out, err := r.run([]string{"GIT_OPTIONAL_LOCKS=0"}, nil, "status", "--porcelain=v2", "--branch", "--untracked-files=all")
return string(out), err
}
// Files returns the files c changed against its first parent, or against
// the empty tree for a root commit, with their contents.
func (r Repo) Files(c Commit) ([]source.File, error) {
base := c.Parent
if base == "" {
base = emptyTree
}
// Paths relative to the top, also with diff.relative set.
out, err := r.git("diff", "--no-ext-diff", "--no-relative", "--raw", "-z", "--no-abbrev", "-M", base, c.Hash)
if err != nil {
return nil, err
}
// Blobs are requested by object id, not by "rev:path": git cat-file
// reads one request per line, and paths may hold newlines.
entries, err := parseRaw(out)
if err != nil {
return nil, err
}
var files []source.File
var specs []string // blob requests for git cat-file, two per file
for _, e := range entries {
specs = append(specs, e.oldID, e.newID)
files = append(files, e.file)
}
blobs, err := r.catFiles(specs)
if err != nil {
return nil, err
}
for i := range files {
f := &files[i]
if f.InOld {
f.Old, f.Binary = lines(blobs[2*i], f.Binary)
}
if f.InNew {
f.New, f.Binary = lines(blobs[2*i+1], f.Binary)
}
}
return files, nil
}
// rawEntry is a file in the output of git diff --raw -z, without its content,
// and the ids of its sides.
type rawEntry struct {
file source.File
oldID, newID string
}
// parseRaw parses the output of git diff --raw -z. Unmerged files are left
// out. Files and diff share only this: diff needs a working tree, which git
// log does not.
func parseRaw(out []byte) ([]rawEntry, error) {
var entries []rawEntry
tokens := strings.Split(strings.TrimSuffix(string(out), "\x00"), "\x00")
for i := 0; i+1 < len(tokens); {
// ":oldmode newmode oldid newid status", then the path or paths.
meta := strings.Fields(strings.TrimPrefix(tokens[i], ":"))
if len(meta) != 5 {
return nil, fmt.Errorf("git diff: unexpected entry %q", tokens[i])
}
f := source.File{Path: tokens[i+1], InOld: true, InNew: true}
i += 2
switch meta[4][0] {
case 'A':
f.InOld = false
case 'D':
f.InNew = false
case 'R', 'C':
if i >= len(tokens) {
return nil, fmt.Errorf("git diff: truncated rename of %s", f.Path)
}
f.OldPath, f.Path = f.Path, tokens[i]
i++
case 'U':
continue
}
entries = append(entries, rawEntry{f, meta[2], meta[3]})
}
return entries, nil
}
// Diff returns the files git diff compares with args, the arguments of git
// diff: the working tree against the index without any, --cached for the
// index against HEAD, commits and paths. Sides in the working tree, which
// git names by the zero id, are read from disk and set OldFile or NewFile,
// so that they can be watched. Unmerged files are left out.
func (r Repo) Diff(args []string) ([]source.File, error) { return r.diff(nil, args) }
// DiffUntracked returns what Diff does, and the untracked files as added
// where git diff compares with the working tree. They are marked with git add
// -N in a copy of the index, which is gone on return: the real one stays as
// it is. Directories, as nested repositories, and files that cannot be read,
// as broken symlinks, are left out.
func (r Repo) DiffUntracked(args []string) ([]source.File, error) {
top, err := r.TopLevel()
if err != nil {
return nil, err
}
paths, err := Repo{Dir: top}.untracked()
if err != nil {
return nil, err
}
if len(paths) == 0 {
return r.Diff(args)
}
dir, err := os.MkdirTemp("", "jdiff-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
index, err := r.copyIndex(dir)
if err != nil {
return nil, err
}
env := []string{"GIT_INDEX_FILE=" + index}
// One file may be named like a pathspec, say with a * in it.
list := []byte(strings.Join(paths, "\x00"))
if _, err := (Repo{Dir: top}).run(append(env, "GIT_LITERAL_PATHSPECS=1"), list,
"add", "-N", "--pathspec-from-file=-", "--pathspec-file-nul"); err != nil {
return nil, err
}
return r.diff(env, args)
}
// untracked returns the paths of the untracked files that are not ignored,
// relative to r.Dir, which must be the top of the working tree.
func (r Repo) untracked() ([]string, error) {
out, err := r.git("ls-files", "--others", "--exclude-standard", "-z")
if err != nil {
return nil, err
}
var paths []string
for _, p := range strings.Split(strings.TrimSuffix(string(out), "\x00"), "\x00") {
if p != "" && readableFile(filepath.Join(r.Dir, filepath.FromSlash(p))) {
paths = append(paths, p)
}
}
return paths, nil
}
// readableFile reports whether path is, or links to, a regular file that can
// be opened.
func readableFile(path string) bool {
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return false
}
f, err := os.Open(path)
if err != nil {
return false
}
f.Close()
return true
}
// copyIndex copies the index into dir and returns the path of the copy. A
// repository without an index yet leaves nothing to copy.
func (r Repo) copyIndex(dir string) (string, error) {
out, err := r.git("rev-parse", "--git-path", "index")
if err != nil {
return "", err
}
from := strings.TrimSpace(string(out))
if !filepath.IsAbs(from) {
from = filepath.Join(r.Dir, from)
}
to := filepath.Join(dir, "index")
data, err := os.ReadFile(from)
if errors.Is(err, fs.ErrNotExist) {
return to, nil
}
if err != nil {
return "", err
}
return to, os.WriteFile(to, data, 0o600)
}
// diff is Diff with env added to the environment of git diff.
func (r Repo) diff(env, args []string) ([]source.File, error) {
top, err := r.TopLevel()
if err != nil {
return nil, err
}
// Paths relative to the top, also with diff.relative set.
out, err := r.run(env, nil, append([]string{"diff", "--no-ext-diff", "--no-relative", "--raw", "-z", "--no-abbrev", "-M"},
args...)...)
if err != nil {
return nil, err
}
entries, err := parseRaw(out)
if err != nil {
return nil, err
}
// A side of a file, in the order of the files and old before new: a
// blob, or the working copy for the zero id.
type sideOf struct {
blob int // index into specs, -1 for the working copy
path string
}
var files []source.File
var sides []sideOf
var specs []string
for _, e := range entries {
f := e.file
files = append(files, f)
for _, sd := range []struct {
in bool
id string
path string
}{{f.InOld, e.oldID, f.PathInOld()}, {f.InNew, e.newID, f.Path}} {
if !sd.in {
continue
}
s := sideOf{blob: -1, path: sd.path}
if strings.Trim(sd.id, "0") != "" {
s.blob = len(specs)
specs = append(specs, sd.id)
}
sides = append(sides, s)
}
}
blobs, err := r.catFiles(specs)
if err != nil {
return nil, err
}
n := 0
for i := range files {
f := &files[i]
for _, old := range []bool{true, false} {
if (old && !f.InOld) || (!old && !f.InNew) {
continue
}
sd := sides[n]
n++
var content []string
binary := false
if sd.blob >= 0 {
content, binary = lines(blobs[sd.blob], false)
} else {
path := filepath.Join(top, filepath.FromSlash(sd.path))
l, stamp, err := source.Read(path)
switch {
case errors.Is(err, source.ErrBinary):
binary = true
case err != nil:
return nil, err
}
content = l
if old {
f.OldFile, f.OldStamp = path, stamp
} else {
f.NewFile, f.NewStamp = path, stamp
}
}
f.Binary = f.Binary || binary
if old {
f.Old = content
} else {
f.New = content
}
}
}
return files, nil
}
// lines splits a blob, reporting missing blobs (e.g. submodules) and
// binary content as binary.
func lines(b blob, binary bool) ([]string, bool) {
if b.missing {
return nil, true
}
l, err := source.SplitLines(b.data)
return l, binary || errors.Is(err, source.ErrBinary)
}
type blob struct {
data []byte
missing bool
}
// catFiles reads the objects named by specs, their ids, with a single git
// cat-file --batch process.
func (r Repo) catFiles(specs []string) ([]blob, error) {
if len(specs) == 0 {
return nil, nil
}
cmd := exec.Command("git", "cat-file", "--batch")
cmd.Dir = r.Dir
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
go func() {
w := bufio.NewWriter(stdin)
for _, s := range specs {
fmt.Fprintln(w, s)
}
w.Flush()
stdin.Close()
}()
// fail stops git, which may be blocked writing output no longer read.
fail := func(err error) ([]blob, error) {
cmd.Process.Kill()
cmd.Wait()
return nil, err
}
rd := bufio.NewReader(stdout)
blobs := make([]blob, len(specs))
for i := range specs {
header, err := rd.ReadString('\n')
if err != nil {
return fail(fmt.Errorf("git cat-file: %w", err))
}
// "<oid> <type> <size>" or "<object> missing"
f := strings.Fields(header)
if len(f) != 3 {
blobs[i].missing = true
continue
}
size, err := strconv.Atoi(f[2])
if err != nil {
return fail(fmt.Errorf("git cat-file: bad header %q", header))
}
data := make([]byte, size+1) // content and trailing newline
if _, err := io.ReadFull(rd, data); err != nil {
return fail(fmt.Errorf("git cat-file: %w", err))
}
blobs[i] = blob{data: data[:size], missing: f[1] != "blob"}
}
return blobs, cmd.Wait()
}
// Package notify reports changes to files as the kernel sees them, instead
// of checking them over and over. It watches the directories the files are
// in, not the files: editors often save by writing a new file and renaming
// it over the old one, which a watch on the old file would not see. Only
// these directories are watched, not their subdirectories.
//
// Only Linux (inotify) is supported; elsewhere New fails with ErrUnsupported
// and the caller checks the files itself.
package notify
import (
"errors"
"path/filepath"
"slices"
)
var (
// ErrUnsupported is returned where the kernel cannot report changes.
ErrUnsupported = errors.New("not supported on this system")
// ErrTooManyDirs is returned when the files are in more directories
// than allowed.
ErrTooManyDirs = errors.New("files in too many directories")
)
// watched maps each path the kernel names to the paths it was given as: a
// path stands for itself and, when it is a symlink, for its target, which
// is what git difftool --dir-diff puts in place of working tree files.
func watched(paths []string) map[string][]string {
names := map[string][]string{}
for _, p := range paths {
p = filepath.Clean(p)
names[p] = append(names[p], p)
if target, err := filepath.EvalSymlinks(p); err == nil && target != p {
names[target] = append(names[target], p)
}
}
return names
}
// dirs returns the directories of the paths of names, sorted.
func dirs(names map[string][]string) []string {
var out []string
for p := range names {
out = append(out, filepath.Dir(p))
}
slices.Sort(out)
return slices.Compact(out)
}
//go:build linux
package notify
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"sync"
"time"
"golang.org/x/sys/unix"
)
// settle is how long changes are collected after the first one, so that
// the events of one save come as one batch.
const settle = 50 * time.Millisecond
// mask selects the events that change a file's content or replace or
// remove it; touching its times or mode does not.
const mask = unix.IN_MODIFY | unix.IN_CLOSE_WRITE | unix.IN_CREATE | unix.IN_DELETE |
unix.IN_MOVED_FROM | unix.IN_MOVED_TO | unix.IN_ONLYDIR | unix.IN_EXCL_UNLINK
// Watcher reports changes to files. All watches share one inotify instance,
// which takes one file descriptor.
type Watcher struct {
file *os.File
dirs map[int32]string // watch descriptor -> directory
names map[string][]string // see watched
changes chan []string
done chan struct{}
once sync.Once
}
// New starts watching paths. It fails with ErrTooManyDirs when they are in
// more than maxDirs directories, counting those of symlink targets, and
// when the kernel refuses, e.g. because the user's inotify instances or
// watches are used up. A directory that does not exist is left out.
func New(paths []string, maxDirs int) (*Watcher, error) {
names := watched(paths)
ds := dirs(names)
if len(ds) > maxDirs {
return nil, ErrTooManyDirs
}
fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
return nil, fmt.Errorf("inotify: %w", err)
}
// Non-blocking, the file is read through Go's poller, so that Close
// ends a pending read.
w := &Watcher{file: os.NewFile(uintptr(fd), "inotify"), dirs: map[int32]string{}, names: names,
changes: make(chan []string), done: make(chan struct{})}
for _, d := range ds {
wd, err := unix.InotifyAddWatch(fd, d, mask)
if errors.Is(err, unix.ENOENT) {
continue
}
if err != nil {
w.file.Close()
return nil, fmt.Errorf("inotify: %s: %w", d, err)
}
w.dirs[int32(wd)] = d
}
raw := make(chan string)
go w.read(raw)
go w.batch(raw)
return w, nil
}
// Changes returns the channel of changed paths, as given to New, in sorted
// batches. It is closed when the watcher is.
func (w *Watcher) Changes() <-chan []string { return w.changes }
// Close stops watching.
func (w *Watcher) Close() error {
err := os.ErrClosed
w.once.Do(func() {
close(w.done)
err = w.file.Close()
})
return err
}
// read sends the watched paths the kernel reports to raw, until the
// watcher is closed. When the kernel's queue overflowed, events were lost:
// all paths are sent.
func (w *Watcher) read(raw chan<- string) {
defer close(raw)
send := func(paths []string) bool {
for _, p := range paths {
select {
case raw <- p:
case <-w.done:
return false
}
}
return true
}
buf := make([]byte, 64*1024)
for {
n, err := w.file.Read(buf)
if err != nil {
return
}
for off := 0; off+unix.SizeofInotifyEvent <= n; {
wd := int32(binary.NativeEndian.Uint32(buf[off:]))
evMask := binary.NativeEndian.Uint32(buf[off+4:])
nameLen := int(binary.NativeEndian.Uint32(buf[off+12:]))
name := bytes.TrimRight(buf[off+unix.SizeofInotifyEvent:off+unix.SizeofInotifyEvent+nameLen], "\x00")
off += unix.SizeofInotifyEvent + nameLen
var paths []string
if evMask&unix.IN_Q_OVERFLOW != 0 {
for _, ps := range w.names {
paths = append(paths, ps...)
}
} else if dir, ok := w.dirs[wd]; ok && len(name) > 0 {
paths = w.names[filepath.Join(dir, string(name))]
}
if !send(paths) {
return
}
}
}
}
// batch collects the paths from raw for settle after the first one and
// sends them on as one batch, until raw is closed.
func (w *Watcher) batch(raw <-chan string) {
defer close(w.changes)
for {
p, ok := <-raw
if !ok {
return
}
set := map[string]bool{p: true}
timer := time.NewTimer(settle)
collect:
for {
select {
case p, ok := <-raw:
if !ok {
timer.Stop()
return
}
set[p] = true
case <-timer.C:
break collect
}
}
select {
case w.changes <- slices.Sorted(maps.Keys(set)):
case <-w.done:
return
}
}
}
// Package settings holds jdiff's user settings.
package settings
// Settings are the user-configurable options, read from main.toml and,
// for the toggles, state.toml (see package config); the TOML keys are given
// in brackets.
type Settings struct {
// Context (context) is the number of unchanged lines kept visible around changes
// while unchanged code is collapsed.
Context int `toml:"context"`
// CollapseUnchanged (collapse_unchanged) starts the diff view with
// unchanged code collapsed.
CollapseUnchanged bool `toml:"collapse_unchanged"`
// OnlyChangedWhitespaces (only_changed_whitespaces) compares indentation
// character by character, and a run of whitespace in place of another
// one by its length, so only the added or removed part is marked.
OnlyChangedWhitespaces bool `toml:"only_changed_whitespaces"`
// WatchInterval (watch_interval) is how often, in seconds, files on disk
// are checked for changes and reloaded: both files of a comparison, or
// the current file of the file list and its neighbors. 0 turns watching
// off, also by inotify.
WatchInterval float64 `toml:"watch_interval"`
// InotifyMaxDirs (inotify_max_dirs) is the most directories whose files
// Linux reports changes of (inotify), instead of jdiff checking them
// every WatchInterval: the directories of the files on disk, not their
// subdirectories. With inotify, all files are reloaded when they
// change. 0 turns inotify off.
InotifyMaxDirs int `toml:"inotify_max_dirs"`
// AutoReloadInterval (auto_reload_interval) is how often, in seconds, auto
// reload runs git status, which sees new files, and all changes where
// Linux does not report them at once.
AutoReloadInterval int `toml:"auto_reload_interval"`
// LineEnd (line_end) is drawn at the end of lines while all whitespace
// is shown; it must be one column wide.
LineEnd string `toml:"line_end"`
// CREnd (cr_end) is drawn for the \r of a \r\n line end where it
// changed, and on all lines while ShowCR is set; one column wide.
CREnd string `toml:"cr_end"`
// Extends (extends) and Precedes (precedes) mark lines cut at the edge
// of their pane while lines are not wrapped: Extends in the last column
// when the line goes on, Precedes in the first when text is scrolled out
// to the left; one column wide each.
Extends string `toml:"extends"`
Precedes string `toml:"precedes"`
// Untracked (untracked) also shows the untracked files in jdiff diff,
// as added, where it compares with the working tree. u in the settings
// popup toggles it and --untracked turns it on, for the run only: it is
// the one toggle state.toml does not keep.
Untracked bool `toml:"untracked"`
Toggles
}
// Toggles are the settings switched by keys, which state.toml keeps as last
// set; their TOML keys are on the same level as the other settings.
type Toggles struct {
// Flow (flow) shows all files one after the other instead of one file
// at a time; f toggles it.
Flow bool `toml:"flow"`
// SideBySide (side_by_side) shows the old and new file next to each
// other; off, they are shown in one column like git diff. s in the settings
// popup toggles it.
SideBySide bool `toml:"side_by_side"`
// Preview (preview) shows a box below the commit and file lists with
// the selected commit's message or file's diff; v in the settings popup
// toggles it.
Preview bool `toml:"preview"`
// ShowWhitespaces (show_whitespaces) draws all whitespace as · and →,
// not only changed whitespace, and the end of lines as LineEnd; W
// in the settings popup toggles it.
ShowWhitespaces bool `toml:"show_whitespaces"`
// MarkWhitespace (mark_whitespace) draws changed whitespace as · and →
// in the style of changed words: indentation, tabs and runs of spaces,
// not a single space between changed words; off, all changed whitespace
// looks like unchanged whitespace. ctrl+w in the settings popup
// toggles it.
MarkWhitespace bool `toml:"mark_whitespace"`
// ShowTrailing (show_trailing) draws whitespace at the end of lines as ·
// and → in the trailing whitespace style. t in the settings popup toggles
// it.
ShowTrailing bool `toml:"show_trailing"`
// ShowCR (show_cr) draws the \r of all \r\n line ends as CREnd, not
// only of changed ones. e in the settings popup toggles it.
ShowCR bool `toml:"show_cr"`
// Wrap (wrap) wraps lines longer than their pane onto further screen
// lines; off, they are cut and h and l scroll them. w in the
// settings popup toggles it.
Wrap bool `toml:"wrap"`
// BoldChanges (bold_changes) draws changed words and whitespace bold
// where the theme says so; off, without bold, which terminals often
// draw brighter. b in the settings popup toggles it.
BoldChanges bool `toml:"bold_changes"`
// FullMessages (full_messages) shows the whole commit message in the
// flow view of the log; off, only the subject. c in the settings popup
// toggles it.
FullMessages bool `toml:"full_messages"`
// StayInCommit (stay_in_commit) keeps the cursor of the flow view of the
// log in the current commit: moves out of it stop with a warning, N and
// P still switch commits. C in the settings popup toggles it.
StayInCommit bool `toml:"stay_in_commit"`
// SearchRegex (search_regex) searches with / for a regular expression;
// off, for plain text. shift+tab in the search bar toggles it.
SearchRegex bool `toml:"search_regex"`
// SearchAll (search_all) searches with / in all lines; off, only in
// changed ones, added, removed and moved. tab in the search bar toggles
// it.
SearchAll bool `toml:"search_all"`
// AutoReloadTimeout (auto_reload_timeout) is after how many minutes
// without a key pressed or a change of the repository auto reload ends,
// and with it its git commands; 0 is never. The settings popup sets it.
AutoReloadTimeout int `toml:"auto_reload_timeout"`
}
// Default returns the default settings.
func Default() Settings {
return Settings{
Context: 3,
CollapseUnchanged: true,
OnlyChangedWhitespaces: true,
WatchInterval: 1,
InotifyMaxDirs: 20,
AutoReloadInterval: 10,
LineEnd: "¶",
CREnd: "␍",
Extends: "»",
Precedes: "«",
Toggles: Toggles{
SideBySide: true,
Preview: true,
ShowWhitespaces: false,
MarkWhitespace: true,
BoldChanges: true,
FullMessages: true,
AutoReloadTimeout: 5,
},
}
}
// Package source loads the files to compare from disk.
package source
import (
"bytes"
"errors"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// ErrBinary is returned for files that contain NUL bytes.
var ErrBinary = errors.New("binary file")
// File is one file of a comparison.
type File struct {
Path string // relative to both sides, slash separated
OldPath string // the old path of a renamed file, else empty
Old, New []string
// InOld and InNew report whether the file exists on that side.
InOld, InNew bool
Binary bool
// OldFile and NewFile are the paths of the sides on disk, empty for
// files from git; OldStamp and NewStamp identify the versions read.
OldFile, NewFile string
OldStamp, NewStamp Stamp
}
// Stamp identifies a version of a file on disk.
type Stamp struct {
ModTime time.Time
Size int64
}
// StampOf returns the stamp of the file at path, following symlinks.
func StampOf(path string) (Stamp, error) {
info, err := os.Stat(path)
if err != nil {
return Stamp{}, err
}
return Stamp{info.ModTime(), info.Size()}, nil
}
// PathInOld returns the path of the file on the old side.
func (f File) PathInOld() string {
if f.OldPath != "" {
return f.OldPath
}
return f.Path
}
// ReadLines reads a text file into lines without their newlines; the \r of
// a \r\n line end is kept, so that changed line ends show.
func ReadLines(path string) ([]string, error) {
lines, _, err := Read(path)
return lines, err
}
// Read reads a text file like ReadLines and returns the stamp of the version
// read. The stamp is taken first, so a change while reading shows later.
func Read(path string) ([]string, Stamp, error) {
st, err := StampOf(path)
if err != nil {
return nil, Stamp{}, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, Stamp{}, err
}
lines, err := SplitLines(data)
return lines, st, err
}
// SplitLines splits text into lines without their newlines, keeping the \r
// of \r\n line ends, or returns ErrBinary for data containing NUL bytes.
func SplitLines(data []byte) ([]string, error) {
if bytes.IndexByte(data, 0) >= 0 {
return nil, ErrBinary
}
if len(data) == 0 {
return nil, nil
}
return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n"), nil
}
// LoadDirs compares the regular files of two directory trees, as handed over
// by git difftool --dir-diff. Files with identical content are left out.
// Symlinks are followed, since git links work tree files into the new tree.
func LoadDirs(oldDir, newDir string) ([]File, error) {
oldFiles, err := listFiles(oldDir)
if err != nil {
return nil, err
}
newFiles, err := listFiles(newDir)
if err != nil {
return nil, err
}
paths := append(slices.Clone(oldFiles), newFiles...)
slices.Sort(paths)
paths = slices.Compact(paths)
inOld, inNew := pathSet(oldFiles), pathSet(newFiles)
var out []File
for _, p := range paths {
f := File{Path: p, InOld: inOld[p], InNew: inNew[p]}
if f.InOld {
f.OldFile = filepath.Join(oldDir, p)
if f.Old, f.OldStamp, err = Read(f.OldFile); err != nil && !errors.Is(err, ErrBinary) {
return nil, err
}
f.Binary = f.Binary || errors.Is(err, ErrBinary)
}
if f.InNew {
f.NewFile = filepath.Join(newDir, p)
if f.New, f.NewStamp, err = Read(f.NewFile); err != nil && !errors.Is(err, ErrBinary) {
return nil, err
}
f.Binary = f.Binary || errors.Is(err, ErrBinary)
}
if !f.Binary && f.InOld == f.InNew && slices.Equal(f.Old, f.New) {
continue
}
out = append(out, f)
}
return out, nil
}
func pathSet(paths []string) map[string]bool {
set := make(map[string]bool, len(paths))
for _, p := range paths {
set[p] = true
}
return set
}
// listFiles returns the sorted slash-separated paths of all regular files
// below dir, following symlinks to files.
func listFiles(dir string) ([]string, error) {
var out []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
out = append(out, filepath.ToSlash(rel))
return nil
})
return out, err
}
package theme
import (
"bytes"
"os/exec"
"slices"
"strings"
)
// Config holds git config entries as printed by git config --get-regexp:
// section and key lowercased, subsections (feature names) as written.
type Config map[string]string
// ReadGitConfig reads the delta and jdiff sections of the effective git
// config, so includes and repository config apply as they do for delta.
// Without git or any matching entries the result is empty.
func ReadGitConfig() Config {
out, err := exec.Command("git", "config", "--null", "--get-regexp", `^(delta|jdiff)\.`).Output()
if err != nil {
return Config{}
}
return parseGitConfig(out)
}
// parseGitConfig parses the output of git config --null --get-regexp: one
// "key\nvalue" record per NUL. Later entries win, as in git.
func parseGitConfig(out []byte) Config {
cfg := Config{}
for rec := range bytes.SplitSeq(out, []byte{0}) {
if len(rec) == 0 {
continue
}
key, value, _ := strings.Cut(string(rec), "\n")
cfg[key] = value
}
return cfg
}
// Features returns the enabled delta features, highest priority first,
// following delta's rules: features from DELTA_FEATURES (env) and --features
// (flag) come first; [delta] features is used only when neither replaces it
// (a leading "+" in env adds instead). Each feature precedes the features it
// enables itself, and among the features of one setting the rightmost wins.
func Features(cfg Config, env, flag string) []string {
var input []string
useConfig := flag == ""
switch {
case strings.HasPrefix(env, "+"):
input = append(strings.Fields(env[1:]), strings.Fields(flag)...)
case env != "":
input = strings.Fields(env)
useConfig = false
default:
input = strings.Fields(flag)
}
var out []string
var visit func(f string)
visit = func(f string) {
out = append(out, f)
children := strings.Fields(cfg["delta."+f+".features"])
for _, c := range slices.Backward(children) {
if !slices.Contains(out, c) {
visit(c)
}
}
}
for _, f := range slices.Backward(input) {
visit(f)
}
if useConfig {
for _, f := range slices.Backward(strings.Fields(cfg["delta.features"])) {
visit(f)
}
}
return out
}
// lookup returns the value of a delta option: the main [delta] section wins,
// then the features in priority order.
func lookup(cfg Config, features []string, key string) (string, bool) {
if v, ok := cfg["delta."+key]; ok {
return v, true
}
for _, f := range features {
if v, ok := cfg["delta."+f+"."+key]; ok {
return v, true
}
}
return "", false
}
package theme
import (
"path/filepath"
"reflect"
"strings"
"github.com/BurntSushi/toml"
)
// Kind is where a theme comes from.
type Kind int
const (
KindBuiltin Kind = iota // jdiff's defaults
KindGit // delta's and jdiff's git config sections
KindFile // a theme file
)
// Spec is a theme as its source defines it: the values as written, nil for
// options the source leaves unset. Resolve turns it into a Theme.
//
// The TOML keys are the option names; in the git config they are keys of
// the [delta] section, or of [jdiff] where the git tag says so.
type Spec struct {
Name string `toml:"-"`
Kind Kind `toml:"-"`
MinusStyle *string `toml:"minus-style"`
MinusEmphStyle *string `toml:"minus-emph-style"`
MinusNonEmphStyle *string `toml:"minus-non-emph-style"` // unset: minus-style
PlusStyle *string `toml:"plus-style"`
PlusEmphStyle *string `toml:"plus-emph-style"`
PlusNonEmphStyle *string `toml:"plus-non-emph-style"` // unset: plus-style
ZeroStyle *string `toml:"zero-style"`
LineNumbersMinusStyle *string `toml:"line-numbers-minus-style"`
LineNumbersPlusStyle *string `toml:"line-numbers-plus-style"`
LineNumbersZeroStyle *string `toml:"line-numbers-zero-style"`
LineNumbersLeftStyle *string `toml:"line-numbers-left-style"`
LineNumbersRightStyle *string `toml:"line-numbers-right-style"`
FileStyle *string `toml:"file-style"`
FileDecorationStyle *string `toml:"file-decoration-style"` // only its color is used
InlineHintStyle *string `toml:"inline-hint-style"`
FileAddedLabel *string `toml:"file-added-label"`
FileRemovedLabel *string `toml:"file-removed-label"`
FileModifiedLabel *string `toml:"file-modified-label"`
FileRenamedLabel *string `toml:"file-renamed-label"`
AnchorStyle *string `toml:"anchor-style" git:"jdiff"`
PendingStyle *string `toml:"pending-style" git:"jdiff"`
CursorStyle *string `toml:"cursor-style" git:"jdiff"`
FileBarStyle *string `toml:"file-bar-style" git:"jdiff"`
CommitBarStyle *string `toml:"commit-bar-style" git:"jdiff"`
CommitHashStyle *string `toml:"commit-hash-style" git:"jdiff"`
BranchStyle *string `toml:"branch-style" git:"jdiff"`
FillerStyle *string `toml:"filler-style" git:"jdiff"` // unset: file-decoration-style's color
WhitespaceStyle *string `toml:"whitespace-style" git:"jdiff"` // unset: file-decoration-style's color
TrailingWhitespaceStyle *string `toml:"trailing-whitespace-style" git:"jdiff"` // shown by t
HiddenCharStyle *string `toml:"hidden-char-style" git:"jdiff"` // ☠ for invisible characters
OverflowStyle *string `toml:"overflow-style" git:"jdiff"` // « » at the edges of cut lines
MoreStyle *string `toml:"more-style" git:"jdiff"` // ↑ ↓ for items of lists out of view; unset: whitespace-style
SearchStyle *string `toml:"search-style" git:"jdiff"` // text found by /
MinusIndentStyle *string `toml:"minus-indent-style" git:"jdiff"` // unset: minus-emph-style
PlusIndentStyle *string `toml:"plus-indent-style" git:"jdiff"` // unset: plus-emph-style
MinusMovedStyle *string `toml:"minus-moved-style" git:"jdiff"` // removed lines that moved elsewhere
PlusMovedStyle *string `toml:"plus-moved-style" git:"jdiff"` // added lines that moved here
DocHeadingStyle *string `toml:"doc-heading-style" git:"jdiff"` // headings of --readme; unset: branch-style
DocCodeStyle *string `toml:"doc-code-style" git:"jdiff"` // code in --readme; unset: file-style
DocLinkStyle *string `toml:"doc-link-style" git:"jdiff"` // links in --readme
// Colors are named colors that styles can use like color names.
Colors map[string]string `toml:"colors"`
}
// options calls f with the key, git config section and field of each option
// of s.
func (s *Spec) options(f func(key, section string, field **string)) {
v := reflect.ValueOf(s).Elem()
for i := range v.NumField() {
key := v.Type().Field(i).Tag.Get("toml")
if key == "-" || key == "colors" {
continue
}
section := v.Type().Field(i).Tag.Get("git")
if section == "" {
section = "delta"
}
f(key, section, v.Field(i).Addr().Interface().(**string))
}
}
// isJdiffKey reports whether key is a [jdiff] key rather than a delta one.
func isJdiffKey(key string) bool {
jdiff := false
(&Spec{}).options(func(k, section string, _ **string) {
jdiff = jdiff || (k == key && section == "jdiff")
})
return jdiff
}
// Builtin returns jdiff's default theme. Its colors are made for 16 color
// mode: unlike delta's defaults they are foreground colors only, since ANSI
// backgrounds are too loud for whole lines.
func Builtin() Spec {
str := func(s string) *string { return &s }
return Spec{
Name: "default",
Kind: KindBuiltin,
MinusStyle: str("red"),
MinusEmphStyle: str("bright-red bold"),
PlusStyle: str("green"),
PlusEmphStyle: str("bright-green bold"),
ZeroStyle: str("normal"),
LineNumbersMinusStyle: str("red"),
LineNumbersPlusStyle: str("green"),
LineNumbersZeroStyle: str("normal"),
LineNumbersLeftStyle: str("blue"),
LineNumbersRightStyle: str("blue"),
FileStyle: str("blue"),
FileDecorationStyle: str("blue ul"),
InlineHintStyle: str("blue"),
FileAddedLabel: str("added:"),
FileRemovedLabel: str("removed:"),
FileModifiedLabel: str(""),
FileRenamedLabel: str("renamed:"),
AnchorStyle: str("yellow bold"),
PendingStyle: str("magenta bold"),
CursorStyle: str("reverse bold"),
// Bars with a background, so file and commit borders stand out.
FileBarStyle: str("bold bright-white blue"),
CommitBarStyle: str("bold black yellow"),
CommitHashStyle: str("yellow"),
BranchStyle: str("yellow bold"),
// Moved code as git colors it by default.
MinusMovedStyle: str("magenta"),
PlusMovedStyle: str("cyan"),
// Trailing whitespace as git marks whitespace errors.
TrailingWhitespaceStyle: str("normal red"),
HiddenCharStyle: str("normal red"),
OverflowStyle: str("blue"),
SearchStyle: str("black yellow"),
// Links as browsers draw them.
DocLinkStyle: str("bright-blue bold"),
}
}
// FromGit returns the theme that delta's and jdiff's git config sections in
// cfg define with the given features (see Features): an option in [delta]
// wins over the features in their order, jdiff's options are read from
// [jdiff]. As in delta, any key of [delta] can name a color.
func FromGit(cfg Config, features []string) Spec {
s := Spec{Name: "delta", Kind: KindGit, Colors: map[string]string{}}
s.options(func(key, section string, field **string) {
if section == "jdiff" {
if v, ok := cfg["jdiff."+key]; ok {
*field = &v
}
} else if v, ok := lookup(cfg, features, key); ok {
*field = &v
}
})
for k, v := range cfg {
if name, ok := strings.CutPrefix(k, "delta."); ok && !strings.Contains(name, ".") {
s.Colors[name] = v
}
}
return s
}
// FromFile reads the theme file path, a TOML file of options and a [colors]
// table, and names the theme after the file. It also returns the keys of the
// file that are no options.
func FromFile(path string) (Spec, []string, error) {
s := Spec{Name: strings.TrimSuffix(filepath.Base(path), ".toml"), Kind: KindFile}
md, err := toml.DecodeFile(path, &s)
if err != nil {
return Spec{}, nil, err
}
var unknown []string
for _, k := range md.Undecoded() {
unknown = append(unknown, k.String())
}
// Color words are matched lowercased, as git lowercases its keys.
colors := make(map[string]string, len(s.Colors))
for name, c := range s.Colors {
colors[strings.ToLower(name)] = c
}
s.Colors = colors
return s, unknown, nil
}
package theme
import (
"fmt"
"image/color"
"strconv"
"strings"
"charm.land/lipgloss/v2"
)
// ansiNames are delta's names for the 16 ANSI colors.
var ansiNames = map[string]int{
"black": 0, "red": 1, "green": 2, "yellow": 3,
"blue": 4, "magenta": 5, "purple": 5, "cyan": 6, "white": 7,
}
// ParseStyle parses a delta style string: attributes, then an optional
// foreground and background color. Color words that are neither a known
// color nor "normal" are looked up in named, like delta does.
// Words that only matter to delta (omit, raw, box, ...) are ignored.
func ParseStyle(s string, named map[string]string) (lipgloss.Style, error) {
style := lipgloss.NewStyle()
colors := 0
for _, word := range strings.Fields(strings.ToLower(s)) {
word = strings.Trim(word, `"'`)
switch word {
case "bold":
style = style.Bold(true)
case "dim":
style = style.Faint(true)
case "italic":
style = style.Italic(true)
case "ul", "underline":
style = style.Underline(true)
case "blink":
style = style.Blink(true)
case "reverse":
style = style.Reverse(true)
case "strike":
style = style.Strikethrough(true)
case "hidden", "omit", "raw", "line-number", "file", "omit-code-fragment", "box", "ol", "none":
default:
c, err := parseColor(word, named)
if err != nil {
return lipgloss.NewStyle(), fmt.Errorf("style %q: %w", s, err)
}
switch colors {
case 0:
if c != nil {
style = style.Foreground(c)
}
case 1:
if c != nil {
style = style.Background(c)
}
default:
return lipgloss.NewStyle(), fmt.Errorf("style %q: more than two colors", s)
}
colors++
}
}
return style, nil
}
// parseColor returns the color for word, or nil for no color. Colors beyond
// the 16 ANSI ones are passed on as-is; the 16 color profile of the program
// maps them to the nearest ANSI color when it draws.
func parseColor(word string, named map[string]string) (color.Color, error) {
switch word {
case "normal", "auto", "syntax":
return nil, nil
}
if n, ok := ansiNames[word]; ok {
return lipgloss.Color(strconv.Itoa(n)), nil
}
if base, ok := strings.CutPrefix(word, "bright"); ok {
if n, ok := ansiNames[strings.TrimPrefix(base, "-")]; ok {
return lipgloss.Color(strconv.Itoa(n + 8)), nil
}
}
if n, err := strconv.Atoi(word); err == nil && n >= 0 && n <= 255 {
return lipgloss.Color(word), nil
}
if isHexColor(word) {
return lipgloss.Color(word), nil
}
if v, ok := named[word]; ok && !strings.HasPrefix(word, "delta.") {
// A named color defined elsewhere in the config; no further nesting.
return parseColor(strings.ToLower(strings.TrimSpace(v)), nil)
}
return nil, fmt.Errorf("unknown color %q", word)
}
func isHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' {
return false
}
_, err := strconv.ParseUint(s[1:], 16, 32)
return err == nil
}
// Package theme builds jdiff's styles. A theme comes from a source, as a
// Spec: jdiff's defaults, delta's git config settings or a theme file;
// Resolve turns it into the styles, with the defaults for what it leaves
// unset.
//
// jdiff renders in 16 color mode: every color is an ANSI color, so the
// terminal palette decides the actual colors. Hex and 256 colors in a theme
// are mapped to the nearest ANSI color.
package theme
import (
"fmt"
"slices"
"charm.land/lipgloss/v2"
)
// Theme holds all styles of the jdiff views.
type Theme struct {
Minus, MinusNonEmph, MinusEmph, MinusIndent lipgloss.Style
Plus, PlusNonEmph, PlusEmph, PlusIndent lipgloss.Style
Zero lipgloss.Style
NumMinus, NumPlus, NumZero lipgloss.Style
NumLeft, NumRight lipgloss.Style // separators after the numbers
Title lipgloss.Style // file names
Decoration lipgloss.Style // pane separator
Hint lipgloss.Style // status bar
Anchor, Pending, Cursor, Filler lipgloss.Style
Whitespace lipgloss.Style // unchanged whitespace when shown
TrailingWhitespace lipgloss.Style // whitespace at the end of lines when shown
HiddenChar lipgloss.Style // ☠ for invisible and direction changing characters
Overflow lipgloss.Style // « and » at the edges of cut lines
More lipgloss.Style // ↑ and ↓ for items of lists out of view
Search lipgloss.Style // text found by /
MinusMoved, PlusMoved lipgloss.Style // moved code: removed here, added there
FileBar, CommitBar lipgloss.Style // flow mode: start of a file, a commit
CommitHash lipgloss.Style // commit hashes in the log
Branch lipgloss.Style // branch above the commit list
DocHeading, DocCode, DocLink lipgloss.Style // --readme: headings, code, links
AddedLabel, RemovedLabel, ModifiedLabel, RenamedLabel string
// Warnings lists settings that could not be used.
Warnings []string
}
// Default returns the theme without any configuration.
func Default() *Theme { return Resolve(Builtin()) }
// Resolve builds the theme that sp defines. Options sp leaves unset or sets
// to an invalid value take their default; invalid values are reported in
// Warnings.
func Resolve(sp Spec) *Theme {
def := Builtin()
t := &Theme{}
name := func(key string) string {
switch {
case sp.Kind == KindFile:
return sp.Name + ".toml: " + key
case isJdiffKey(key):
return "jdiff." + key
}
return "delta." + key
}
val := func(v, d *string) string {
if v != nil {
return *v
}
return *d
}
warn := func(key string, err error) {
// Options that default to another one may report its error again.
if w := fmt.Sprintf("%s: %v", name(key), err); !slices.Contains(t.Warnings, w) {
t.Warnings = append(t.Warnings, w)
}
}
parse := func(key, s, fallback string) lipgloss.Style {
st, err := ParseStyle(s, sp.Colors)
if err != nil {
warn(key, err)
st, _ = ParseStyle(fallback, nil)
}
return st
}
// style returns option key with value v and default d.
style := func(key string, v, d *string) lipgloss.Style {
if v != nil {
return parse(key, *v, *d)
}
return parse(key, *d, "")
}
// styleOr returns option key with value v, which defaults to option
// fbKey with value fv and default fd.
styleOr := func(key string, v *string, fbKey string, fv, fd *string) lipgloss.Style {
if v != nil {
return parse(key, *v, val(fv, fd))
}
return style(fbKey, fv, fd)
}
// styleOrStyle returns option key with value v, which defaults to the
// style d, also when it is invalid.
styleOrStyle := func(key string, v *string, d lipgloss.Style) lipgloss.Style {
if v == nil {
return d
}
st, err := ParseStyle(*v, sp.Colors)
if err != nil {
warn(key, err)
return d
}
return st
}
t.Minus = style("minus-style", sp.MinusStyle, def.MinusStyle)
t.MinusEmph = style("minus-emph-style", sp.MinusEmphStyle, def.MinusEmphStyle)
t.Plus = style("plus-style", sp.PlusStyle, def.PlusStyle)
t.PlusEmph = style("plus-emph-style", sp.PlusEmphStyle, def.PlusEmphStyle)
t.MinusNonEmph = styleOr("minus-non-emph-style", sp.MinusNonEmphStyle, "minus-style", sp.MinusStyle, def.MinusStyle)
t.PlusNonEmph = styleOr("plus-non-emph-style", sp.PlusNonEmphStyle, "plus-style", sp.PlusStyle, def.PlusStyle)
t.Zero = style("zero-style", sp.ZeroStyle, def.ZeroStyle)
t.NumMinus = style("line-numbers-minus-style", sp.LineNumbersMinusStyle, def.LineNumbersMinusStyle)
t.NumPlus = style("line-numbers-plus-style", sp.LineNumbersPlusStyle, def.LineNumbersPlusStyle)
t.NumZero = style("line-numbers-zero-style", sp.LineNumbersZeroStyle, def.LineNumbersZeroStyle)
t.NumLeft = style("line-numbers-left-style", sp.LineNumbersLeftStyle, def.LineNumbersLeftStyle)
t.NumRight = style("line-numbers-right-style", sp.LineNumbersRightStyle, def.LineNumbersRightStyle)
// Only the color of the decoration is used: jdiff draws no underlines.
decoration := style("file-decoration-style", sp.FileDecorationStyle, def.FileDecorationStyle)
t.Title = style("file-style", sp.FileStyle, def.FileStyle)
t.Decoration = lipgloss.NewStyle().Foreground(decoration.GetForeground())
t.Hint = style("inline-hint-style", sp.InlineHintStyle, def.InlineHintStyle)
t.AddedLabel = val(sp.FileAddedLabel, def.FileAddedLabel)
t.RemovedLabel = val(sp.FileRemovedLabel, def.FileRemovedLabel)
t.ModifiedLabel = val(sp.FileModifiedLabel, def.FileModifiedLabel)
t.RenamedLabel = val(sp.FileRenamedLabel, def.FileRenamedLabel)
t.Anchor = style("anchor-style", sp.AnchorStyle, def.AnchorStyle)
t.Pending = style("pending-style", sp.PendingStyle, def.PendingStyle)
t.Cursor = style("cursor-style", sp.CursorStyle, def.CursorStyle)
t.FileBar = style("file-bar-style", sp.FileBarStyle, def.FileBarStyle)
t.CommitBar = style("commit-bar-style", sp.CommitBarStyle, def.CommitBarStyle)
t.CommitHash = style("commit-hash-style", sp.CommitHashStyle, def.CommitHashStyle)
t.Branch = style("branch-style", sp.BranchStyle, def.BranchStyle)
t.MinusMoved = style("minus-moved-style", sp.MinusMovedStyle, def.MinusMovedStyle)
t.PlusMoved = style("plus-moved-style", sp.PlusMovedStyle, def.PlusMovedStyle)
t.Filler = styleOrStyle("filler-style", sp.FillerStyle, t.Decoration)
t.Whitespace = styleOrStyle("whitespace-style", sp.WhitespaceStyle, t.Decoration)
t.TrailingWhitespace = style("trailing-whitespace-style", sp.TrailingWhitespaceStyle, def.TrailingWhitespaceStyle)
t.HiddenChar = style("hidden-char-style", sp.HiddenCharStyle, def.HiddenCharStyle)
t.Overflow = style("overflow-style", sp.OverflowStyle, def.OverflowStyle)
t.More = styleOrStyle("more-style", sp.MoreStyle, t.Whitespace)
t.Search = style("search-style", sp.SearchStyle, def.SearchStyle)
// Changed whitespace is drawn as · and →, by default like changed words.
t.MinusIndent = styleOrStyle("minus-indent-style", sp.MinusIndentStyle, t.MinusEmph)
t.PlusIndent = styleOrStyle("plus-indent-style", sp.PlusIndentStyle, t.PlusEmph)
t.DocHeading = styleOrStyle("doc-heading-style", sp.DocHeadingStyle, t.Branch)
t.DocCode = styleOrStyle("doc-code-style", sp.DocCodeStyle, t.Title)
t.DocLink = style("doc-link-style", sp.DocLinkStyle, def.DocLinkStyle)
return t
}
package ui
import (
"fmt"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/diff"
"jdiff/internal/gitsrc"
"jdiff/internal/notify"
"jdiff/internal/source"
"jdiff/internal/theme"
)
// entry is one file in the selector. Its diff is created with the selector,
// since moved code is searched in all files together, and its diff view on
// first open; both are kept, so anchors survive going back to the list.
type entry struct {
source.File
added, removed int
diff *fileDiff
view *Model
}
// label returns the status label, using delta's file labels.
func (e *entry) label(th *theme.Theme) string {
switch {
case e.OldPath != "":
return th.RenamedLabel
case !e.InOld:
return th.AddedLabel
case !e.InNew:
return th.RemovedLabel
}
return th.ModifiedLabel
}
// name returns the path shown for the file, "old → new" for renames,
// printable: paths may hold control characters.
func (e *entry) name() string {
if e.OldPath != "" {
return printable(e.OldPath) + " → " + printable(e.Path)
}
return printable(e.Path)
}
// paneNames returns the names shown above the panes of the file's diff.
func (e *entry) paneNames() [2]string {
return [2]string{"a/" + e.PathInOld(), "b/" + e.Path}
}
// refresh reloads the sides of e that changed on disk, see refreshFile. Its
// diff view and after, unless nil, follow each reload of its diff.
func (e *entry) refresh(after func(*fileDiff)) string {
if e.Binary {
return ""
}
msg := refreshFile(&e.File, e.diff, func() { e.followReload(after) })
if msg != "" {
e.updateStats()
}
return msg
}
// update takes f, the file listed again, into e, reloading the sides of
// its diff whose content changed. Its diff view and after, unless nil,
// follow each reload, as in refresh. It returns the anchors dropped and
// whether the content changed.
func (e *entry) update(f source.File, after func(*fileDiff)) (dropped int, changed bool) {
was := [2][]string{e.Old, e.New}
e.File = f
if e.Binary {
return 0, false
}
e.diff.names = e.paneNames()
for s, lines := range [2][]string{f.Old, f.New} {
if slices.Equal(was[s], lines) {
continue
}
changed = true
dropped += e.diff.reload(side(s), lines)
e.followReload(after)
}
return dropped, changed
}
// followReload lets its diff view and after, unless nil, follow a reload of
// its diff.
func (e *entry) followReload(after func(*fileDiff)) {
if e.view != nil {
e.view.syncLayout()
}
if after != nil {
after(e.diff)
}
}
func (e *entry) updateStats() {
if e.Binary {
return
}
if e.diff != nil {
e.added, e.removed = diff.Stats(e.diff.rows)
return
}
e.added, e.removed = diff.Stats(diff.Align(e.Old, e.New, nil, diff.Options{}))
}
// fileListRows is the most rows the file list takes while the preview shows,
// so that on a tall screen the preview gets the rest.
const fileListRows = 15
// App is the Bubble Tea model of the file selector. It shows the diff view
// of the selected file and returns to the list on backspace. A single file
// is opened directly.
type App struct {
cfg *config.Config
commit *gitsrc.Commit // whose files are shown, nil outside the log
files []*entry
relist ListFunc // lists the files again, nil when they cannot be
// gitDiff runs git diff with arguments, nil outside jdiff diff; diffArgs
// are those of the files shown.
gitDiff DiffFunc
diffArgs []string
// notify reports changes to the files on disk; nil while they are
// checked every watch_interval, ticking while such a check is pending.
notify *notify.Watcher
ticking bool
list
// previewTop is the first screen line of the preview scrolled by pgup
// and pgdn, while previewFor is the file under the cursor; moving the
// cursor drops it.
previewFor *entry
previewTop int
open *entry // file whose diff view is shown, nil for the list
flow *Flow // flow view outside the log, created on first use
msg string
prefix string // "'" while waiting for the letter of a mark
}
// NewApp returns the file selector for files.
func NewApp(cfg *config.Config, files []source.File) *App { return newApp(cfg, files, nil) }
// ListFunc lists the files of a comparison again.
type ListFunc func() ([]source.File, error)
// DiffFunc returns the files git diff compares with args.
type DiffFunc func(args []string) ([]source.File, error)
// WithGitDiff makes a the files of git diff with args, which r runs again,
// and returns a.
func (a *App) WithGitDiff(args []string, gitDiff DiffFunc) *App {
a.gitDiff, a.diffArgs = gitDiff, args
a.relist = func() ([]source.File, error) { return a.gitDiff(a.diffArgs) }
return a
}
// WithRelist sets how r lists the files again and returns a.
func (a *App) WithRelist(list ListFunc) *App {
a.relist = list
return a
}
// newApp returns the file selector for files, as part of the log for the
// files of commit when it is set.
func newApp(cfg *config.Config, files []source.File, commit *gitsrc.Commit) *App {
a := &App{cfg: cfg, commit: commit}
// The files are searched for moved code together.
group := &moveGroup{}
for _, f := range files {
a.addEntry(group, &entry{File: f})
}
a.list = list{n: len(a.files), preview: &cfg.Settings.Preview, maxRows: fileListRows}
if len(files) == 0 {
a.msg = "no changed files"
}
if w := cfg.Warning(); w != "" {
a.msg = w
}
if a.single() {
a.openFile()
}
return a
}
// addEntry adds e to the files and its diff, unless binary, to group, the
// files searched for moved code together.
func (a *App) addEntry(group *moveGroup, e *entry) {
if !e.Binary {
group.add(a.diffOf(e), [2]string{e.PathInOld(), e.Path})
}
e.updateStats()
a.files = append(a.files, e)
}
// stop stops watching the files: the view is replaced.
func (a *App) stop() {
if a.notify != nil {
a.notify.Close()
}
}
// diffCommand returns the git diff command line of args.
func diffCommand(args []string) string {
return strings.TrimSpace("git diff " + strings.Join(args, " "))
}
// inLog reports whether a is part of the log: backspace and N/P go to the
// log.
func (a *App) inLog() bool { return a.commit != nil }
// single reports whether there is exactly one file and it can be shown.
func (a *App) single() bool { return len(a.files) == 1 && !a.files[0].Binary }
// openPath opens the file with the given path and reports whether it could.
func (a *App) openPath(path string) bool {
i := a.fileByPath(path)
if i >= 0 {
a.openIndex(i)
}
return i >= 0
}
// fileByPath returns the index of the file with the given path that can be
// shown, or -1.
func (a *App) fileByPath(path string) int {
return slices.IndexFunc(a.files, func(e *entry) bool { return e.Path == path && !e.Binary })
}
// fileByGroup returns the index of the file at index i of the files searched
// for moved code, or -1.
func (a *App) fileByGroup(i int) int {
return slices.IndexFunc(a.files, func(e *entry) bool { return e.diff != nil && e.diff.groupIndex == i })
}
// openIndex opens file i, after updating the counts of the file it leaves.
func (a *App) openIndex(i int) {
if a.open != nil {
a.open.updateStats()
}
a.cursor = i
a.scrollToCursor()
a.openFile()
}
// setMsg shows msg in the status bar of whatever is displayed.
func (a *App) setMsg(msg string) {
switch {
case a.flowShown():
a.flow.msg = msg
case a.open != nil:
a.open.view.msg = msg
default:
a.msg = msg
}
}
// Init starts watching the files, outside the log.
func (a *App) Init() tea.Cmd {
if a.inLog() {
return nil
}
return a.watch()
}
// watch starts watching the files on disk anew, by inotify when it can, and
// returns the command waiting for their changes. A pending check of polling
// is not started twice.
func (a *App) watch() tea.Cmd {
if a.notify != nil {
a.notify.Close()
}
var paths []string
for _, e := range a.files {
paths = append(paths, diskPaths(e.File)...)
}
w, cmd, msg := startWatching(a.cfg, a, paths)
a.notify = w
if msg != "" {
a.setMsg(msg)
}
if w == nil && cmd != nil {
if a.ticking {
return nil
}
a.ticking = true
}
return cmd
}
// refresh reloads the files i that changed on disk for which which holds.
func (a *App) refresh(which func(i int) bool) {
var msgs []string
reload := func(after func(*fileDiff)) {
for i := range a.files {
if !which(i) {
continue
}
if msg := a.files[i].refresh(after); msg != "" {
msgs = append(msgs, msg)
}
}
}
if a.flowShown() {
a.flow.refresh(reload)
} else {
reload(nil)
}
if len(msgs) > 0 {
a.setMsg(strings.Join(msgs, "; "))
}
}
// relistFiles lists the files again (r). New files are added and gone ones
// dropped; a file still there keeps its entry, and so its diff with anchors
// and folds, reloaded where its content changed. A file that became binary
// or stopped being binary counts as gone and new. The cursor stays on its
// file; when the file shown is gone, the list is shown. The files are
// watched anew; it returns the command waiting for their changes. With quiet,
// it does not say when nothing changed.
func (a *App) relistFiles(quiet bool) tea.Cmd {
if a.relist == nil {
a.setMsg("nothing to load again")
return nil
}
files, err := a.relist()
if err != nil {
a.setMsg(firstLine(err))
return nil
}
kept := map[string]*entry{}
for _, e := range a.files {
kept[e.Path] = e
}
var cur *entry
if a.cursor < len(a.files) {
cur = a.files[a.cursor]
}
added, changed, dropped := 0, 0, 0
update := func(after func(*fileDiff)) {
old := a.files
a.files = nil
// The files are searched for moved code together.
group := &moveGroup{}
for _, f := range files {
e := kept[f.Path]
if e == nil || e.Binary != f.Binary {
e = &entry{File: f}
added++
} else {
delete(kept, f.Path)
n, ok := e.update(f, after)
if ok {
changed++
}
dropped += n
}
a.addEntry(group, e)
}
if a.flow != nil {
a.flow.followFiles(0, old, a.files)
}
}
shown := a.flowShown()
if shown {
a.flow.refresh(update)
} else {
update(nil)
}
a.list.n = len(a.files)
if i := slices.Index(a.files, cur); i >= 0 {
a.cursor = i
} else {
a.cursor = max(min(a.cursor, len(a.files)-1), 0)
}
msg := refreshed(added, len(kept), changed, dropped)
switch {
case len(a.files) == 0:
a.open = nil
case shown:
if _, file, _ := a.flow.position(); file >= 0 {
a.cursor = file
}
a.open = a.files[a.cursor]
case a.open != nil && !slices.Contains(a.files, a.open):
msg = a.open.Path + " is gone; " + msg
a.open = nil
}
a.scrollToCursor()
if !quiet || msg != nothingChanged {
a.setMsg(msg)
}
if a.inLog() {
return nil
}
return a.watch()
}
// flowShown reports whether the flow view is shown instead of a diff view.
// Inside the log, the log shows its own flow view.
func (a *App) flowShown() bool { return a.open != nil && a.cfg.Settings.Flow && !a.inLog() }
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width, a.height = msg.Width, msg.Height
a.scrollToCursor()
case settingChangedMsg:
a.scrollToCursor() // the preview takes room from the list
if msg.problem != "" && a.open == nil {
a.msg = msg.problem
}
case watchTickMsg:
if msg.owner != nil && msg.owner != a {
return a, nil
}
a.ticking = false
if a.notify != nil {
return a, nil // inotify took over
}
// Polling checks the current file and its neighbors.
a.refresh(func(i int) bool { return i >= a.cursor-1 && i <= a.cursor+1 })
a.ticking = true
return a, watchTick(a.cfg, a)
case changedMsg:
if msg.w != a.notify {
return a, nil
}
changed := func(i int) bool {
return slices.ContainsFunc(diskPaths(a.files[i].File), func(p string) bool { return slices.Contains(msg.paths, p) })
}
a.refresh(changed)
return a, waitChanges(a.notify)
case jumpMsg:
a.jump(msg)
return a, nil
case markJumpMsg:
a.gotoMark(msg.letter, msg.screenRow)
return a, nil
case switchFileMsg:
a.switchFile(int(msg))
return a, nil
case toggleFlowMsg:
a.toggleFlow()
if w := saveState(a.cfg); w != "" {
a.setMsg(w)
}
return a, nil
case backMsg: // from the flow view
a.open = nil
return a, nil
case refreshMsg:
return a, a.relistFiles(msg.quiet)
case showMsg:
a.setMsg(string(msg))
return a, nil
case toggleUntrackedMsg:
if a.gitDiff == nil {
a.setMsg(untrackedOnlyInDiff)
return a, nil
}
a.cfg.Settings.Untracked = !a.cfg.Settings.Untracked // not saved
return a, a.relistFiles(false)
}
if a.flowShown() {
_, cmd := a.flow.Update(msg)
if _, file, _ := a.flow.position(); file >= 0 {
a.cursor = file
a.scrollToCursor()
}
return a, cmd
}
if a.open != nil {
if k, ok := msg.(tea.KeyPressMsg); ok && keyFile.key(diffView, k.String()).action == "to_files" {
a.open.updateStats()
a.open = nil
if a.inLog() && a.single() {
return a, func() tea.Msg { return backMsg{} }
}
return a, nil
}
_, cmd := a.open.view.Update(msg)
return a, cmd
}
if k, ok := msg.(tea.KeyPressMsg); ok {
return a, a.key(k.String())
}
return a, nil
}
func (a *App) key(k string) tea.Cmd {
a.msg = ""
cursor := a.cursor
defer func() {
if a.cursor != cursor {
a.previewFor = nil // another file shows its preview from the start
}
}()
if a.prefix != "" {
a.prefix = ""
if isMarkLetter(k) {
a.gotoMark(k, (a.height-2)/2)
}
return nil
}
h := keyFile.key(fileList, k)
page := max(a.listHeight()-1, 1)
if delta, paging, ok := motion(h, page, len(a.files)); ok {
if h := a.boxHeight(); paging && h > 0 && a.cursor < len(a.files) {
// The preview scrolls, by its height less a line, as the list pages.
a.scrollPreview(direction(delta > 0) * max(h-3, 1))
return nil
}
a.moveCursor(delta)
return nil
}
switch h.action {
case "quit":
return tea.Quit
case "open":
if a.inLog() && a.cfg.Settings.Flow && a.cursor < len(a.files) {
file := a.cursor
return func() tea.Msg { return flowAtMsg(file) }
}
a.openFile()
case "to_commits":
if a.inLog() {
return func() tea.Msg { return backMsg{} }
}
case "history":
if a.inLog() {
return switchCommit(h)
}
case "flow":
return toggleFlow
case "jump":
a.prefix, a.msg = h.action, "jump to mark: press its letter"
default:
return pageKey(h, false)
}
return nil
}
// diffOf returns the diff of e, creating it on first use.
func (a *App) diffOf(e *entry) *fileDiff {
if e.diff == nil {
e.diff = newFileDiff(a.cfg, e.paneNames(), [2][]string{e.Old, e.New})
e.diff.markPath = e.Path
}
return e.diff
}
// viewOf returns the diff view of e, creating it on first use.
func (a *App) viewOf(e *entry) *Model {
if e.view == nil {
e.view = newModel(a.diffOf(e))
e.view.canBack = true
e.view.commitNav = a.inLog()
}
return e.view
}
func (a *App) openFile() {
if a.cursor >= len(a.files) {
return
}
if a.cfg.Settings.Flow && !a.inLog() {
a.showFlow(a.cursor, nil)
return
}
e := a.files[a.cursor]
if e.Binary {
a.msg = e.Path + " is a binary file"
return
}
a.viewOf(e).Update(tea.WindowSizeMsg{Width: a.width, Height: a.height})
a.open = e
}
// showFlow shows the flow view on file, where its diff view from leaves
// it, or on its bar for from nil (see Flow.showFrom).
func (a *App) showFlow(file int, from *Model) {
if a.flow == nil {
a.flow = flowOfApp(a)
}
a.open = a.files[file]
a.flow.width, a.flow.height = a.width, a.height
a.flow.showFrom(0, file, from)
}
// toggleFlow switches between the diff view of the file under the cursor
// and the flow view, keeping the cursor on its line and screen row.
func (a *App) toggleFlow() {
st := &a.cfg.Settings
if !st.Flow {
st.Flow = true
switch {
case len(a.files) == 0:
case a.open == nil:
a.showFlow(a.cursor, nil)
default:
a.showFlow(a.cursor, a.open.view)
}
return
}
st.Flow = false
if a.open == nil {
return
}
_, file, line := a.flow.position()
screenRow := a.flow.cursor - a.flow.top
a.open = nil
if file < 0 {
return
}
a.cursor = file
a.scrollToCursor()
if a.files[file].Binary {
return
}
a.openFile()
a.open.view.takeOver(a.flow, line, screenRow)
}
// gotoMark opens the file of mark letter, when it is in the diff, with the
// cursor on its line at screen row screenRow.
func (a *App) gotoMark(letter string, screenRow int) {
mk, ok := a.cfg.Mark(letter)
if !ok {
a.setMsg("no mark " + letter)
return
}
i := a.fileByPath(mk.Path)
if i < 0 {
a.setMsg(notInDiff(letter, mk))
return
}
a.openIndex(i)
if a.flowShown() {
a.flow.gotoMark(letter)
return
}
if at, ok := a.open.view.markSpot(letter, mk); ok {
a.open.view.show(at, screenRow)
}
}
// jump opens the file of j's end of moved code with the cursor on its line.
func (a *App) jump(j jumpMsg) {
i := a.fileByGroup(j.end.file)
if i < 0 {
return
}
a.openIndex(i)
a.open.view.show(a.open.diff.spotOf(j.end.side, j.end.line, a.open.view.shape()), j.screenRow)
a.open.view.msg = j.msg
}
// switchFile opens the next (dir > 0) or previous file that can be shown.
func (a *App) switchFile(dir int) {
for i := a.cursor + dir; i >= 0 && i < len(a.files); i += dir {
if !a.files[i].Binary {
a.openIndex(i)
return
}
}
a.setMsg(ends(dir > 0, "last file", "first file"))
}
// scrollPreview scrolls the preview of the file under the cursor by delta
// lines: up to its first line, down until its last one is the last shown.
func (a *App) scrollPreview(delta int) {
e := a.files[a.cursor]
if e.Binary {
return
}
screen, at := a.diffOf(e).screenLines(a.previewLayout(e).shape)
from := a.previewStart(e, screen, at)
if delta > 0 {
from = min(from+delta, max(len(screen)-(a.boxHeight()-2), from))
} else {
from = max(from+delta, 0)
}
a.previewFor, a.previewTop = e, from
}
// previewLayout returns the layout of the preview of e: as wide as the
// box's inside, scrolled like the file's diff view.
func (a *App) previewLayout(e *entry) layout {
lo := layout{width: max(a.width-4, 0)}
lo.shape = newShape(a.cfg, lo.width)
if e.view != nil {
lo.xoff, lo.active = e.view.xoff, e.view.active
}
return lo
}
// previewStart returns the first of the screen lines of e's diff, indexed by
// at, that its preview shows: where pgup and pgdn scrolled it, else the
// context of the first change.
func (a *App) previewStart(e *entry, screen []sline, at map[sline]int) int {
if a.previewFor == e {
return max(min(a.previewTop, len(screen)-1), 0)
}
d := a.diffOf(e)
for i := range screen {
if d.blockStart(screen, i) {
// The rows of context before the block, else its first line.
return min(at[sline{max(screen[i].v-a.cfg.Settings.Context, 0), 0}], i)
}
}
return 0
}
// preview renders the diff of e in a box height lines high, starting with
// the context of the first change, or where pgup and pgdn scrolled it.
func (a *App) preview(e *entry, height int) string {
t := a.cfg.Theme
title := lipgloss.NewStyle().Bold(true).Render(" " + e.name() + " ")
if e.Binary {
return box(t, a.width, height, title, []string{t.Hint.Render("binary file")})
}
d := a.diffOf(e)
if len(d.view) == 0 { // empty on both sides
return box(t, a.width, height, title, nil)
}
lo := a.previewLayout(e)
screen, at := d.screenLines(lo.shape)
from := a.previewStart(e, screen, at)
var lines []string
for i := from; i < len(screen) && len(lines) < height-2; i++ {
lines = append(lines, d.render(screen[i], false, lo))
}
return box(t, a.width, height, title, lines)
}
func (a *App) View() tea.View { return tea.NewView(a.frame()) }
func (a *App) frame() string {
if a.flowShown() {
return a.flow.frame()
}
if a.open != nil {
return a.open.view.frame()
}
if a.width == 0 {
return ""
}
t := a.cfg.Theme
var added, removed int
addW, remW, lblW := 1, 1, 0
for _, e := range a.files {
added, removed = added+e.added, removed+e.removed
addW = max(addW, len(fmt.Sprint(e.added)))
remW = max(remW, len(fmt.Sprint(e.removed)))
lblW = max(lblW, ansi.StringWidth(e.label(t)))
}
// Styles are rendered side by side: lipgloss underlining breaks nested
// escape sequences.
bold := lipgloss.NewStyle().Bold(true)
head := " " + bold.Render(fmt.Sprintf("%d files changed", len(a.files)))
if c := a.commit; c != nil {
head = " " + t.CommitHash.Render(c.Short) + " " + bold.Render(c.Subject)
}
head += " " + t.Plus.Render(fmt.Sprintf("+%d", added)) + " " + t.Minus.Render(fmt.Sprintf("-%d", removed))
if a.gitDiff != nil {
cmd := diffCommand(a.diffArgs)
if a.cfg.Settings.Untracked {
cmd += " + untracked"
}
head += " " + t.Hint.Render(cmd)
}
item := func(i int, st func(lipgloss.Style) lipgloss.Style) string {
e := a.files[i]
cnt := counts(t, st, e.added, e.removed, addW, remW)
if e.Binary {
cnt = st(t.Filler).Render(fmt.Sprintf("%-*s", addW+remW+3, "binary"))
}
lbl := st(lipgloss.NewStyle()).Render(" " + pad(e.label(t), lblW, lipgloss.NewStyle()) + " ")
return lbl + " " + cnt + " " + st(lipgloss.NewStyle()).Render(e.name())
}
k := barKeys{enter: "open", commits: a.inLog(), flow: true}
if a.inLog() {
k.back = "to_commits"
}
hints := statusKeys(a.cfg, k)
return a.render(t, head, item, func(h int) string { return a.preview(a.files[a.cursor], h) }, pageStatus(a.cfg, a.msg, hints))
}
package ui
import (
"cmp"
"fmt"
"io/fs"
"path"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
)
// Doc shows the pages of jdiff's documentation, markdown rendered for the
// terminal: jdiff --readme. A cursor line moves through a page; enter follows
// the link it is on, the first unless h l or tab chose another, to another
// page or heading, backspace goes back. Only links that lead to a file of
// docs, and a heading there, or to the web are links. A file that is no
// markdown, reached by a link, shows as it is.
type Doc struct {
cfg *config.Config
docs fs.FS
order []string // the pages in docs, in the order of n and p
page string // the file of docs shown
anchors map[string]map[string]int // the headings of other pages, see anchorsOf
rendered docPage
forWidth int // the width rendered is for, 0 before the first
top int // the first line shown
cursor int // the line of the cursor
link int // the selected link, on the cursor's line; -1 for none
back []docPlace
msg string
width int
height int
}
// docPlace is where a link was followed from, and the link.
type docPlace struct {
page string
top, cursor, link int
}
// NewDoc returns the documentation in docs, whose pages are order, on page
// order[page].
func NewDoc(cfg *config.Config, docs fs.FS, order []string, page int) *Doc {
return &Doc{cfg: cfg, docs: docs, order: order, page: order[page], anchors: map[string]map[string]int{}, link: -1}
}
func (d *Doc) Init() tea.Cmd { return nil }
func (d *Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
d.width, d.height = msg.Width, msg.Height
d.render()
case tea.KeyPressMsg:
return d, d.key(msg.String())
case findMsg:
d.findText(int(msg))
}
return d, nil
}
// textWidth is the width pages are rendered for: the screen but a column
// on each side, the left one for the cursor.
func (d *Doc) textWidth() int { return max(d.width-2, 1) }
// bodyHeight is the number of lines shown, between the title and the status
// bar.
func (d *Doc) bodyHeight() int { return max(d.height-2, 1) }
// render renders the page for the width of the screen, unless it is already.
func (d *Doc) render() {
if d.forWidth == d.textWidth() {
return
}
src, err := fs.ReadFile(d.docs, d.page)
if err != nil {
d.msg = err.Error()
}
d.forWidth = d.textWidth()
if path.Ext(d.page) == ".md" {
d.rendered = renderMarkdown(src, d.forWidth)
} else {
d.rendered = renderText(src, d.forWidth)
}
for i, l := range d.rendered.links {
_, _, ok := d.resolve(l.target)
d.rendered.links[i].broken = !ok
}
d.scrollTo(d.top)
d.moveTo(d.cursor)
}
// show shows page from line top, the cursor on line cursor.
func (d *Doc) show(page string, top, cursor int) {
d.page, d.forWidth = page, 0
d.render()
d.scrollTo(top)
d.moveTo(cursor)
}
// scrollTo scrolls to line top, kept where lines fill the screen.
func (d *Doc) scrollTo(top int) {
d.top = max(min(top, len(d.rendered.lines)-d.bodyHeight()), 0)
}
// moveTo moves the cursor to line i, kept in the page, and scrolls it into
// view; the first link on the line that resolves is selected.
func (d *Doc) moveTo(i int) {
n := len(d.rendered.lines)
d.cursor = max(min(i, n-1), 0)
d.top = scrollWindow(d.cursor, d.top, d.bodyHeight(), n)
d.link = -1
if d.cursor < n {
for _, s := range d.rendered.lines[d.cursor].segs {
if d.live(s.link) {
d.link = s.link
break
}
}
}
}
func (d *Doc) key(k string) tea.Cmd {
d.msg = ""
h := keyFile.key(docView, k)
body := d.bodyHeight()
if delta, paging, ok := motion(h, max(body-1, 1), len(d.rendered.lines)); ok {
if paging {
d.scrollTo(d.top + delta)
}
d.moveTo(d.cursor + delta)
return nil
}
switch h.action {
case "quit":
return tea.Quit
case "half_page":
delta := direction(h.i == 1) * (body / 2)
d.scrollTo(d.top + delta)
d.moveTo(d.cursor + delta)
case "link":
d.nextLink(direction(h.i == 0))
case "line_link":
d.lineLink(direction(h.i == 1))
case "follow":
d.follow()
case "back":
if len(d.back) == 0 {
d.msg = "no link followed to go back from"
break
}
p := d.back[len(d.back)-1]
d.back = d.back[:len(d.back)-1]
d.show(p.page, p.top, p.cursor)
d.link = p.link
case "next_page":
i := slices.Index(d.order, d.page)
next := i + direction(h.i == 0)
switch {
case i < 0:
d.msg = d.page + " is no page of " + keyFile.keyText("{next_page}") + ": " + keyFile.keyText("{back}") + " goes back"
case next < 0 || next >= len(d.order):
d.msg = ends(h.i == 0, "last page", "first page")
default:
d.show(d.order[next], 0, 0)
}
case "search":
return askSearch
case "match":
d.findText(direction(h.i == 0))
}
return nil
}
// live reports whether link i of the page is one: it resolves.
func (d *Doc) live(i int) bool { return i >= 0 && !d.rendered.links[i].broken }
// nextLink moves the cursor to the next link in direction dir and selects
// it: the one after the selected link, also on the same line, or the first
// below or last above the cursor.
func (d *Doc) nextLink(dir int) {
links := d.rendered.links
next := -1
for i := range links {
j := i
if dir < 0 {
j = len(links) - 1 - i
}
after := d.link >= 0 && (j-d.link)*dir > 0 || d.link < 0 && (links[j].line-d.cursor)*dir > 0
if after && d.live(j) {
next = j
break
}
}
if next < 0 {
d.msg = ends(dir > 0, "no link further down", "no link further up")
return
}
d.moveTo(links[next].line)
d.link = next
}
// lineLink selects the next link on the cursor's line in direction dir,
// right or left.
func (d *Doc) lineLink(dir int) {
var on []int
for _, s := range d.rendered.lines[min(d.cursor, len(d.rendered.lines)-1)].segs {
if d.live(s.link) && !slices.Contains(on, s.link) {
on = append(on, s.link)
}
}
i := slices.Index(on, d.link) + dir
switch {
case len(on) == 0:
d.msg = "no link on this line"
case i < 0 || i >= len(on):
d.msg = ends(dir > 0, "no link further right", "no link further left")
default:
d.link = on[i]
}
}
// follow follows the selected link to its page and heading, which shows at
// the top; for a web address the box of frame shows it already.
func (d *Doc) follow() {
if d.link < 0 {
d.msg = "no link on this line: " + keyFile.keyText("{link}") + " go to the next, previous"
return
}
target := d.rendered.links[d.link].target
page, anchor, _ := d.resolve(target)
if page == "" {
return
}
d.back = append(d.back, docPlace{d.page, d.top, d.cursor, d.link})
d.show(page, 0, 0)
if line := d.rendered.anchors[anchor]; anchor != "" {
d.show(page, line, line)
}
}
// resolve returns the file of docs and the heading that target, a link of
// the page shown, goes to, and whether both are there; for a web address
// the file is "".
func (d *Doc) resolve(target string) (page, anchor string, ok bool) {
file, anchor, _ := strings.Cut(target, "#")
if strings.Contains(file, ":") { // https:, mailto:
return "", "", true
}
page = d.page
if file != "" {
page = path.Join(path.Dir(d.page), file)
if _, err := fs.Stat(d.docs, page); err != nil {
return page, anchor, false
}
}
if anchor == "" {
return page, "", true
}
_, ok = d.anchorsOf(page)[anchor]
return page, anchor, ok
}
// anchorsOf returns the headings of page by the names links give them, with
// their lines for the width of the page shown when it is that page.
func (d *Doc) anchorsOf(page string) map[string]int {
if page == d.page {
return d.rendered.anchors
}
a, ok := d.anchors[page]
if !ok {
if src, err := fs.ReadFile(d.docs, page); err == nil && path.Ext(page) == ".md" {
a = renderMarkdown(src, 80).anchors
}
d.anchors[page] = a
}
return a
}
// findText moves the cursor to the next line, in direction dir, that holds
// what config.Search searched for, past the cursor's own line.
func (d *Doc) findText(dir int) {
q := d.cfg.Search.Query
m := matcherOf(q)
if m == nil {
d.msg = nothingSearched
return
}
var hits []int
for i, l := range d.rendered.lines {
if len(m.spans(l.plain())) > 0 {
hits = append(hits, i)
}
}
if len(hits) == 0 {
d.msg = "no match for " + q.Text + " in this page"
return
}
i, ok := nextHit(len(hits), dir, func(i int) int { return cmp.Compare(hits[i], d.cursor) })
if !ok {
d.msg = noFurther(q, dir, "")
return
}
d.moveTo(hits[i])
d.msg = searchMsg(q, i, len(hits))
}
func (d *Doc) View() tea.View { return tea.NewView(d.frame()) }
func (d *Doc) frame() string {
if d.width == 0 {
return ""
}
t := d.cfg.Theme
lines := d.rendered.lines
var b strings.Builder
var where []string
if i := slices.Index(d.order, d.page); i >= 0 {
where = append(where, fmt.Sprintf("page %d/%d", i+1, len(d.order)))
}
if n := len(lines); n > d.bodyHeight() {
where = append(where, fmt.Sprintf("%d%%", 100*min(d.top+d.bodyHeight(), n)/n))
}
b.WriteString(statusLine(t, d.width, t.Title.Render(" "+printable(d.page)), strings.Join(where, " · ")+" ") + "\n")
m := matcherOf(d.cfg.Search.Query)
for i := d.top; i < d.top+d.bodyHeight(); i++ {
if i < len(lines) {
margin := " "
if i == d.cursor {
margin = t.Cursor.Render(margin)
}
b.WriteString(margin + d.drawLine(lines[i], m))
}
b.WriteString("\n")
}
keys := statusKeys(d.cfg, barKeys{enter: "follow", back: "back", text: true})
b.WriteString(statusLine(t, d.width, pageStatus(d.cfg, d.msg, keys), ""))
return d.withWebAddress(b.String())
}
// withWebAddress returns frame with a box of the web address of the selected
// link below its line, or above it near the bottom, while the cursor is on
// it; links into the documentation need none.
func (d *Doc) withWebAddress(frame string) string {
if d.link < 0 {
return frame
}
target := d.rendered.links[d.link].target
if page, _, _ := d.resolve(target); page != "" {
return frame
}
row := 1 + d.cursor - d.top // in frame, below the title
y := row + 1
if y+minBoxHeight > 1+d.bodyHeight() {
y = row - minBoxHeight
}
if y < 1 {
return frame // no room
}
x := 1 // the cursor's column, then the line up to the link
for _, s := range d.rendered.lines[d.cursor].segs {
if s.link == d.link {
break
}
x += ansi.StringWidth(s.text)
}
address := printable(target)
w := min(ansi.StringWidth(address)+4, d.width)
return overlayAt(frame, box(d.cfg.Theme, w, minBoxHeight, "", []string{address}), max(min(x, d.width-w), 0), y, w, d.width)
}
// drawLine draws l in the styles of its pieces, the selected link in the
// cursor style and what m finds in the search style.
func (d *Doc) drawLine(l docLine, m *matcher) string {
var found []bool
if m != nil {
plain := l.plain()
found = make([]bool, len(plain))
for _, s := range m.spans(plain) {
for j := s.Start; j < s.End; j++ {
found[j] = true
}
}
}
var b strings.Builder
off := 0
for _, s := range l.segs {
st := d.style(s.style)
switch {
case s.link >= 0 && s.link == d.link:
st = d.cfg.Theme.Cursor
case s.link >= 0 && !d.live(s.link):
st = d.style(docPlain) // leads nowhere
}
// Runs of the piece in or out of a match.
for start := 0; start < len(s.text); {
end := start + 1
in := found != nil && found[off+start]
for end < len(s.text) && (found != nil && found[off+end]) == in {
end++
}
if in {
b.WriteString(d.cfg.Theme.Search.Render(s.text[start:end]))
} else {
b.WriteString(st.Render(s.text[start:end]))
}
start = end
}
off += len(s.text)
}
return b.String()
}
// style returns the style of the theme for a piece of a page.
func (d *Doc) style(s docStyle) lipgloss.Style {
t := d.cfg.Theme
switch s {
case docBold:
return lipgloss.NewStyle().Bold(true)
case docItalic:
return lipgloss.NewStyle().Italic(true)
case docCode:
return t.DocCode
case docHeading:
return t.DocHeading
case docLink:
return t.DocLink
case docDecoration:
return t.Decoration
case docOverflow:
return t.Overflow
}
return lipgloss.NewStyle()
}
package ui
import (
"fmt"
"slices"
"strings"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/diff"
"jdiff/internal/settings"
"jdiff/internal/theme"
)
type side int
const (
left side = iota
right
)
func (s side) other() side { return 1 - s }
// lineOf returns the line of side s shown in r, or -1.
func lineOf(r diff.Row, s side) int {
if s == left {
return r.Left
}
return r.Right
}
// emphOf returns the emphasized parts of the line of side s in r.
func emphOf(r diff.Row, s side) []diff.Span {
if s == left {
return r.LeftEmph
}
return r.RightEmph
}
// anchorLine returns the line of side s that an anchors.
func anchorLine(an diff.Anchor, s side) int {
if s == left {
return an.Left
}
return an.Right
}
// mark is a line chosen as the first half of a new anchor.
type mark struct {
side side
line int
}
// vrow is a displayed row: the diff row row, or when hidden > 0 a fold
// standing for the unchanged rows [row, row+hidden). block is the index of
// the foldable block the row belongs to, or -1.
type vrow struct{ row, hidden, block int }
// sline is a screen line of a diff: line sub of displayed row v. Side by
// side a displayed row is one line, or with wrapping as many as its longer
// line needs; in the normal layout a changed row has the lines of its
// removed line, then of its added line, each as many as it needs, which
// screenLines puts into the old and the new half of their block.
type sline struct{ v, sub int }
// block is a foldable run of unchanged rows [start, end).
type block struct{ start, end int }
// spot is a cursor in a diff: on line sub of displayed row v, in the pane of
// side side.
type spot struct {
v, sub int
side side
}
// shape is what the screen lines of a diff depend on: the normal layout
// when unified, the width of the view when wrap is not 0, at which long
// lines are wrapped instead of cut, and with lineEnd the column of the
// symbol at the end of lines (line_end, one column).
type shape struct {
unified bool
wrap int
lineEnd bool
cr bool // the \r of all \r\n line ends is drawn, see crShown
}
// layout is how a view draws a diff: width columns wide, scrolled by xoff
// columns unless lines are wrapped, with the cursor in the pane of side
// active, in shape.
type layout struct {
width, xoff int
active side
shape
}
// fileDiff is the diff of one file as the user anchored and folded it. The
// single file view, the flow view and the preview all show the same
// fileDiff, each with its own cursor.
type fileDiff struct {
cfg *config.Config
names [2]string
lines [2][]string
anchors []diff.Anchor // sorted by Left, never crossing
rows []diff.Row
rowOf [2][]int // line index -> row index, per side
blocks []block // foldable blocks of unchanged rows
foldOpen map[int]bool // open state by the block's first left line
view []vrow // displayed rows
viewOf []int // row index -> view index
changes int // counts the rebuilds of view
pending *mark // first half of an anchor being created
replace *diff.Anchor // anchor waiting for confirmation to replace conflicts
// The last reload: it replaced the lines of side reloadSide, moved maps
// their old indices to the new ones, -1 for lines gone. reloads counts
// the reloads.
reloads int
reloadSide side
moved []int
widths [2][]int // the columns each line takes when drawn, per side
// highlighted is the line drawn last, per side, see content.
highlighted [2]highlightCache
markPath string // the file's path in marks
aligns int // counts the alignments
group *moveGroup // the files searched for moved code with this one
groupIndex int // the index of this file in group
}
// highlightKey is what the highlight of a line depends on: the line, its row
// in the alignment, the cursor on it, whether it moved, the text searched
// for, the settings and the theme.
type highlightKey struct {
line, aligns int
cursor, moved bool
search config.Query
settings settings.Settings
theme *theme.Theme
}
type highlightCache struct {
key highlightKey
out string
ok bool
}
func newFileDiff(cfg *config.Config, names [2]string, lines [2][]string) *fileDiff {
d := &fileDiff{cfg: cfg, names: names, lines: lines, foldOpen: map[int]bool{}}
d.measure(left)
d.measure(right)
d.align()
return d
}
// measure computes the widths of the lines of side s, without the \r of a
// \r\n line end: see crShown.
func (d *fileDiff) measure(s side) {
d.widths[s] = make([]int, len(d.lines[s]))
for i, l := range d.lines[s] {
d.widths[s][i] = ansi.StringWidth(printable(strings.TrimSuffix(l, "\r")))
}
}
// crShown reports whether line of side s ends in a \r that is drawn in sh:
// where it changed, or on all lines with sh.cr.
func (d *fileDiff) crShown(s side, line int, sh shape) bool {
text := d.lines[s][line]
if !strings.HasSuffix(text, "\r") {
return false
}
if sh.cr {
return true
}
for _, sp := range emphOf(d.rows[d.rowOf[s][line]], s) {
if sp.End == len(text) {
return true
}
}
return false
}
// reload replaces the lines of side s by lines, the file's new content on
// disk. Lines that are unchanged keep their anchors and folds; anchors on
// lines that are gone are dropped and counted. A pending mark or replace
// prompt is cancelled. Views follow their cursor line through moved, so
// they must catch up before the next reload.
func (d *fileDiff) reload(s side, lines []string) (dropped int) {
moved := lineMap(d.lines[s], lines)
var anchors []diff.Anchor
for _, an := range d.anchors {
n := moved[anchorLine(an, s)]
if n < 0 {
dropped++
continue
}
if s == left {
an.Left = n
} else {
an.Right = n
}
anchors = append(anchors, an)
}
d.anchors = anchors
if s == left {
open := map[int]bool{}
for line, o := range d.foldOpen {
if n := moved[line]; n >= 0 {
open[n] = o
}
}
d.foldOpen = open
}
d.pending, d.replace = nil, nil
d.lines[s] = lines
d.measure(s)
d.reloads, d.reloadSide, d.moved = d.reloads+1, s, moved
d.align()
return dropped
}
// follow returns the displayed row that now shows a line of r, a row before
// the diff changed: its left line, else its right one, through the last
// reload when reloaded is set. It returns -1 when neither line is left.
func (d *fileDiff) follow(r diff.Row, reloaded bool) int {
if i := d.rowNow(r, reloaded); i >= 0 {
return d.viewOf[i]
}
return -1
}
// rowAfterReload returns the index of the row that now holds a line of r, a
// row before the last reload, as follow does, or -1.
func (d *fileDiff) rowAfterReload(r diff.Row) int { return d.rowNow(r, true) }
// rowNow returns the index of the row that now holds the left line of r,
// else its right one, through the last reload when reloaded is set, or -1.
func (d *fileDiff) rowNow(r diff.Row, reloaded bool) int {
for _, s := range []side{left, right} {
line := lineOf(r, s)
if line >= 0 && reloaded && s == d.reloadSide {
line = d.moved[line]
}
if line >= 0 {
return d.rowOf[s][line]
}
}
return -1
}
// lineMap maps the indices of old to those of the same lines in new, -1
// for lines that are not in new. It keeps the order of the lines.
func lineMap(old, new []string) []int {
moved := make([]int, len(old))
for i := range moved {
moved[i] = -1
}
for _, r := range diff.Align(old, new, nil, diff.Options{}) {
if r.Kind == diff.Equal {
moved[r.Left] = r.Right
}
}
return moved
}
// act runs the anchor and fold action (anchor, cancel_mark, fold or
// fold_all) at the cursor at, in the screen lines of sh. It returns the cursor
// afterwards and the message to show, "" for none.
func (d *fileDiff) act(action string, at spot, sh shape) (spot, string) {
if d.replace != nil {
an := *d.replace
d.replace = nil
switch action {
case "anchor":
for _, c := range diff.Conflicts(d.anchors, an) {
d.removeAnchor(c)
}
return d.addAnchor(an, at.side, sh)
case "cancel_mark":
return at, "replace cancelled"
}
}
switch action {
case "cancel_mark":
if d.pending != nil {
d.pending = nil
return at, "mark cancelled"
}
case "anchor":
return d.enter(at, sh)
case "fold":
return d.toggleNearestBlock(at)
case "fold_all":
return d.toggleAllBlocks(at)
}
return at, ""
}
// markPrompt returns what to do with the pending mark, "" without one: the
// status lines show it until another message, while the mark waits.
func (d *fileDiff) markPrompt() string {
if d.pending == nil {
return ""
}
return fmt.Sprintf("marked %s%d, pick the matching line (esc cancels)", sideName(d.pending.side), d.pending.line+1)
}
// enter implements the anchor workflow: mark a line, then pick its partner
// in the other pane. Enter on an anchored line removes that anchor. In the
// normal layout the line under the cursor decides the side.
func (d *fileDiff) enter(at spot, sh shape) (spot, string) {
if sh.unified {
at.side = d.unifiedSide(at, sh)
}
line := d.lineAt(at.side, at.v)
if line < 0 {
if at.v < len(d.view) && d.view[at.v].hidden > 0 {
return at, "unchanged lines folded, space expands"
}
return at, "no line here"
}
if an, ok := d.anchorAt(at.side, line); ok {
d.removeAnchor(an)
d.align()
return d.spotOf(at.side, line, sh), "removed anchor " + anchorName(an)
}
if d.pending == nil || d.pending.side == at.side {
if d.pending != nil && d.pending.line == line {
d.pending = nil
return at, "mark cancelled"
}
d.pending = &mark{at.side, line}
at.side = at.side.other()
return at, d.markPrompt()
}
an := diff.Anchor{Left: d.pending.line, Right: line}
if at.side == left {
an = diff.Anchor{Left: line, Right: d.pending.line}
}
if conflicts := diff.Conflicts(d.anchors, an); len(conflicts) > 0 {
names := make([]string, len(conflicts))
for i, c := range conflicts {
names[i] = anchorName(c)
}
d.replace = &an
return at, fmt.Sprintf("%s crosses %s: enter replaces, esc cancels",
anchorName(an), strings.Join(names, ", "))
}
return d.addAnchor(an, at.side, sh)
}
// addAnchor adds an, picked last on side s, and returns the cursor on its
// row in the screen lines of sh: the line picked last keeps its place, the
// rest aligns around it.
func (d *fileDiff) addAnchor(an diff.Anchor, s side, sh shape) (spot, string) {
i, _ := slices.BinarySearchFunc(d.anchors, an, func(x, y diff.Anchor) int {
return x.Left - y.Left
})
d.anchors = slices.Insert(d.anchors, i, an)
d.pending = nil
d.align()
return d.spotOf(s, anchorLine(an, s), sh), "anchored " + anchorName(an)
}
// unifiedSide returns the side of the line under the cursor at in the normal
// layout with the screen lines of sh. An unchanged line belongs to both: it
// takes the side opposite a pending mark, else the old side.
func (d *fileDiff) unifiedSide(at spot, sh shape) side {
if at.v >= len(d.view) || d.view[at.v].hidden > 0 {
return at.side
}
switch r := d.rows[d.view[at.v].row]; {
case r.Kind == diff.Delete:
return left
case r.Kind == diff.Insert:
return right
case r.Kind == diff.Change:
s, _ := d.unifiedPiece(at.v, at.sub, sh)
return s
case d.pending != nil:
return d.pending.side.other()
}
return left
}
func (d *fileDiff) removeAnchor(an diff.Anchor) {
d.anchors = slices.DeleteFunc(d.anchors, func(x diff.Anchor) bool { return x == an })
}
func (d *fileDiff) anchorAt(s side, line int) (diff.Anchor, bool) {
for _, an := range d.anchors {
if anchorLine(an, s) == line {
return an, true
}
}
return diff.Anchor{}, false
}
// align computes the rows from the lines and anchors, then the displayed
// rows.
func (d *fileDiff) align() {
d.rows = diff.Align(d.lines[left], d.lines[right], d.anchors,
diff.Options{SplitIndent: d.cfg.Settings.OnlyChangedWhitespaces})
for s := range d.rowOf {
d.rowOf[s] = make([]int, len(d.lines[s]))
}
for i, r := range d.rows {
if r.Left >= 0 {
d.rowOf[left][r.Left] = i
}
if r.Right >= 0 {
d.rowOf[right][r.Right] = i
}
}
d.aligns++
d.buildView()
}
// buildView computes the displayed rows. Unchanged rows further than the
// context from any change, anchor or pending mark form foldable blocks,
// shown as one fold row while closed; a single such row is never a block.
// The view is built anew, so views holding the old one can still read it.
func (d *fileDiff) buildView() {
keep := make([]bool, len(d.rows))
ctx := max(d.cfg.Settings.Context, 0)
for i, r := range d.rows {
if r.Kind != diff.Equal || r.Anchored || d.isPending(r) {
for j := max(i-ctx, 0); j <= min(i+ctx, len(d.rows)-1); j++ {
keep[j] = true
}
}
}
d.blocks = nil
d.view = nil
d.viewOf = make([]int, len(d.rows))
for i := 0; i < len(d.rows); {
j := i + 1
if !keep[i] {
for j < len(d.rows) && !keep[j] {
j++
}
}
if j-i < 2 {
d.viewOf[i] = len(d.view)
d.view = append(d.view, vrow{row: i, block: -1})
i = j
continue
}
b := len(d.blocks)
d.blocks = append(d.blocks, block{i, j})
open := d.blockOpen(d.blocks[b])
for k := i; k < j; k++ {
d.viewOf[k] = len(d.view)
if open {
d.view = append(d.view, vrow{row: k, block: b})
}
}
if !open {
d.view = append(d.view, vrow{row: i, hidden: j - i, block: b})
}
i = j
}
d.changes++
}
// newShape returns the shape of the screen lines of a view width columns
// wide, as the settings of cfg ask for.
func newShape(cfg *config.Config, width int) shape {
sh := shape{unified: !cfg.Settings.SideBySide}
if cfg.Settings.Wrap {
sh.wrap, sh.lineEnd, sh.cr = width, cfg.Settings.ShowWhitespaces, cfg.Settings.ShowCR
}
return sh
}
// contentWidth returns the columns left for the text of a line in a view
// width columns wide, in the normal layout when unified, else side by side.
func (d *fileDiff) contentWidth(width int, unified bool) int {
nw := d.numWidth()
if unified {
return max(width-2*nw-4, 0)
}
return max((width-1)/2-nw-3, 0)
}
// pieces returns the number of screen lines line of side s takes in sh: 1,
// or the pieces it is wrapped into. A missing line takes 1.
func (d *fileDiff) pieces(s side, line int, sh shape) int {
cw := d.contentWidth(sh.wrap, sh.unified)
if sh.wrap == 0 || line < 0 || cw == 0 {
return 1
}
w := d.widths[s][line]
if sh.lineEnd {
w++
}
if d.crShown(s, line, sh) {
w++
}
return max((w+cw-1)/cw, 1)
}
// rowLines returns the number of screen lines of displayed row v in sh.
func (d *fileDiff) rowLines(v int, sh shape) int {
if d.view[v].hidden > 0 {
return 1
}
r := d.rows[d.view[v].row]
switch {
case !sh.unified:
return max(d.pieces(left, r.Left, sh), d.pieces(right, r.Right, sh))
case r.Kind == diff.Change:
return d.pieces(left, r.Left, sh) + d.pieces(right, r.Right, sh)
}
s, _ := d.unifiedPiece(v, 0, sh)
return d.pieces(s, lineOf(r, s), sh)
}
// unifiedPiece returns the side whose line screen line sub of displayed row
// v shows in the normal layout with sh, and which piece of it.
func (d *fileDiff) unifiedPiece(v, sub int, sh shape) (side, int) {
if d.view[v].hidden > 0 {
return right, 0
}
switch r := d.rows[d.view[v].row]; r.Kind {
case diff.Delete:
return left, sub
case diff.Change:
if n := d.pieces(left, r.Left, sh); sub >= n {
return right, sub - n
}
return left, sub
}
return right, sub
}
// firstSub returns the screen line of displayed row v that starts the line
// of side s in sh. Side by side without wrapping it is the one of the normal
// layout: the view clamps it to the row's only line, and it keeps the side
// for switching back.
func (d *fileDiff) firstSub(v int, s side, sh shape) int {
if !sh.unified && sh.wrap > 0 {
return 0
}
r := d.rows[d.view[v].row]
if d.view[v].hidden > 0 || r.Kind != diff.Change || s == left {
return 0
}
return d.pieces(left, r.Left, sh)
}
// convertSub returns the screen line of displayed row v in sh that shows
// what screen line sub in from shows: the same piece of the same side's
// line, as far as it has one. Side by side with wrapping, active is the side
// of the cursor. Without wrapping in either shape, sub is kept as it is, as
// it was before lines could wrap.
func (d *fileDiff) convertSub(v, sub int, from, sh shape, active side) int {
if from.wrap == 0 && sh.wrap == 0 || d.view[v].hidden > 0 {
return sub
}
s, piece := active, sub
switch {
case from.unified:
s, piece = d.unifiedPiece(v, sub, from)
case from.wrap == 0:
s, piece = d.unifiedPiece(v, sub, shape{unified: true})
}
if !sh.unified && sh.wrap == 0 {
return d.firstSub(v, s, sh)
}
r := d.rows[d.view[v].row]
if !sh.unified {
return min(piece, d.rowLines(v, sh)-1)
}
if lineOf(r, s) < 0 || (r.Kind != diff.Change && r.Kind != diff.Equal) {
s, _ = d.unifiedPiece(v, 0, sh) // the row shows only one side
}
return d.firstSub(v, s, sh) + min(piece, d.pieces(s, lineOf(r, s), sh)-1)
}
// screenLines returns the screen lines of the displayed rows in sh and the
// index of each of them. In the normal layout a block of changed rows shows
// all its old lines, then all its new ones, as git diff does; so the two
// lines of a changed row are not next to each other.
func (d *fileDiff) screenLines(sh shape) (screen []sline, at map[sline]int) {
at = make(map[sline]int, len(d.view))
add := func(v, sub int) {
at[sline{v, sub}] = len(screen)
screen = append(screen, sline{v, sub})
}
for v := 0; v < len(d.view); {
if !sh.unified || !d.changed(v) {
for sub := range d.rowLines(v, sh) {
add(v, sub)
}
v++
continue
}
end := v
for end < len(d.view) && d.changed(end) {
end++
}
for _, s := range []side{left, right} {
for w := v; w < end; w++ {
for sub := range d.rowLines(w, sh) {
if of, _ := d.unifiedPiece(w, sub, sh); of == s {
add(w, sub)
}
}
}
}
v = end
}
return screen, at
}
// blockStart reports whether screen line i of screen starts a block of
// changed rows.
func (d *fileDiff) blockStart(screen []sline, i int) bool {
return d.changed(screen[i].v) && (i == 0 || !d.changed(screen[i-1].v))
}
// blockOpen reports whether b is unfolded. Blocks start closed when
// collapse_unchanged is set; toggled state is kept by the block's first
// line, so it survives realigning as long as the block starts there.
func (d *fileDiff) blockOpen(b block) bool {
if open, ok := d.foldOpen[d.rows[b.start].Left]; ok {
return open
}
return !d.cfg.Settings.CollapseUnchanged
}
func (d *fileDiff) isPending(r diff.Row) bool {
return d.pending != nil && lineOf(r, d.pending.side) == d.pending.line
}
// toggleNearestBlock folds or unfolds the block of unchanged rows under the
// cursor at, or else the one closest to it (the lower one on a tie). The
// cursor stays on its row, or on the fold containing it.
func (d *fileDiff) toggleNearestBlock(at spot) (spot, string) {
b := d.nearestBlock(at.v)
if b < 0 {
return at, "no unchanged code to fold"
}
r := d.view[at.v].row
blk := d.blocks[b]
d.foldOpen[d.rows[blk.start].Left] = !d.blockOpen(blk)
d.buildView()
at.v = d.viewOf[r]
return at, ""
}
// toggleAllBlocks unfolds all blocks of unchanged rows when one of them is
// folded, and folds them all otherwise. The cursor at stays on its row, or
// on the fold containing it.
func (d *fileDiff) toggleAllBlocks(at spot) (spot, string) {
if len(d.blocks) == 0 {
return at, "no unchanged code to fold"
}
open := false
for _, b := range d.blocks {
if !d.blockOpen(b) {
open = true
break
}
}
r := d.view[at.v].row
for _, b := range d.blocks {
d.foldOpen[d.rows[b.start].Left] = open
}
d.buildView()
at.v = d.viewOf[r]
return at, ""
}
// nearestBlock returns the index of the block at or closest to displayed
// row v, or -1 when there is none.
func (d *fileDiff) nearestBlock(v int) int {
for dist := 0; dist < len(d.view); dist++ {
for _, w := range []int{v + dist, v - dist} {
if w >= 0 && w < len(d.view) && d.view[w].block >= 0 {
return d.view[w].block
}
}
}
return -1
}
// lineAt returns the line index shown on side s of displayed row v, or -1.
func (d *fileDiff) lineAt(s side, v int) int {
if v < 0 || v >= len(d.view) || d.view[v].hidden > 0 {
return -1
}
return lineOf(d.rows[d.view[v].row], s)
}
// changed reports whether displayed row v is a changed row, not unchanged or
// a fold.
func (d *fileDiff) changed(v int) bool {
return d.view[v].hidden == 0 && d.rows[d.view[v].row].Kind != diff.Equal
}
func sideName(s side) string {
if s == left {
return "L"
}
return "R"
}
func anchorName(an diff.Anchor) string {
return fmt.Sprintf("L%d↔R%d", an.Left+1, an.Right+1)
}
package ui
import (
"fmt"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/diff"
"jdiff/internal/gitsrc"
)
// flowKind is the kind of a row of the flow view.
type flowKind int
const (
flowCommit flowKind = iota // a line of a commit's header
flowFile // the bar starting a file
flowNote // a note in place of a diff: binary file, load error
flowDiff // a displayed row of a file's diff view
)
// flowRow is a row of the flow view. part is the index of its part, file
// the index of its file within the part (-1 for rows of the commit), and
// line the line of the commit header or the index into the file's
// displayed rows; sub is the line of a diff row in the normal layout.
type flowRow struct {
kind flowKind
part, file, line, sub int
}
// flowPart is one commit of the log, or all files outside the log.
type flowPart struct {
commit *commitEntry // nil outside the log
full []string // commit header lines with the whole message, the first one is the bar
short []string // commit header lines with the subject only
app *App // the files, nil until loaded
err string // why the files could not be loaded
}
// far moves the cursor to the first or last row.
const far = 1 << 30
// Flow is the Bubble Tea model of the flow view: the diffs of all files one
// after the other, each starting with a bar, and in the log each commit
// starting with its message. Files share their diffs with the single file
// view, so anchors and folds carry over; cursor and scrolling are the
// flow's own. Commits are loaded once they come near the cursor.
type Flow struct {
cfg *config.Config
parts []*flowPart
load func(part int) (*App, error) // loads the files of a commit, nil outside the log
order historyOrder // of the commits, the parts in the log
rows []flowRow
built shape // what the rows of the diffs were built for
cursor int
top int
xoff int
active side
marked *fileDiff // diff holding a pending mark
replacing *fileDiff // diff waiting for confirmation to replace anchors
prefix string // "m" or "'" while waiting for the letter of a jump mark
width, height int
msg string
}
func newFlow(cfg *config.Config, parts []*flowPart, load func(int) (*App, error)) *Flow {
f := &Flow{cfg: cfg, parts: parts, load: load}
f.build()
return f
}
// flowOfApp returns the flow view of the files of a, outside the log.
func flowOfApp(a *App) *Flow { return newFlow(a.cfg, []*flowPart{{app: a}}, nil) }
// newCommitPart returns the part of the flow view for commit e.
func newCommitPart(e *commitEntry) *flowPart {
return &flowPart{commit: e, full: commitHeader(e.Commit, true), short: commitHeader(e.Commit, false)}
}
// commitHeader returns the header lines of c: the bar, then the message, or
// with full unset its subject only, indented as git log does.
func commitHeader(c gitsrc.Commit, full bool) []string {
h := []string{fmt.Sprintf("commit %s · %s · %s", c.Short, c.Author, c.Date), "", " " + c.Subject}
if full && c.Body != "" {
h = append(h, "")
for l := range strings.SplitSeq(c.Body, "\n") {
h = append(h, strings.TrimRight(" "+l, " "))
}
}
return append(h, "")
}
func (f *Flow) Init() tea.Cmd { return nil }
// commitLines returns the header lines of part as currently shown.
func (f *Flow) commitLines(part *flowPart) []string {
if f.cfg.Settings.FullMessages {
return part.full
}
return part.short
}
func (f *Flow) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.keepScreenRow(func() {
f.width, f.height = msg.Width, msg.Height
f.relocate() // wrapped lines take other pieces
})
case tea.KeyPressMsg:
cmd = f.key(msg.String())
case findMsg:
f.findText(int(msg))
case settingChangedMsg:
// Wrapped lines take other pieces, and end in line_end or ␍, which take
// a column.
f.keepScreenRow(f.relocate)
if msg.action == "stay_in_commit" && f.inLog() && f.cfg.Settings.StayInCommit {
f.msg = "staying in commit " + f.parts[f.rows[f.cursor].part].commit.Short
}
if msg.problem != "" {
f.msg = msg.problem
}
}
f.loadVisible()
return f, cmd
}
func (f *Flow) key(k string) tea.Cmd {
h := keyFile.key(flowView, k)
if f.replacing != nil && h.action != "anchor" && h.action != "cancel_mark" {
f.replacing.replace = nil
f.replacing = nil
}
f.msg = ""
if p := f.prefix; p != "" {
f.prefix = ""
switch {
case !isMarkLetter(k):
case p == "set_mark":
f.setMark(k)
default:
f.gotoMark(k)
}
return nil
}
page := max(f.bodyHeight()-1, 1)
if delta, paging, ok := motion(h, page, far); ok {
if paging {
f.scroll(delta)
} else {
f.moveCursor(delta)
}
return nil
}
switch h.action {
case "quit":
return tea.Quit
case "flow":
return toggleFlow
case "to_files":
return func() tea.Msg { return backMsg{} }
case "pane":
f.active = f.active.other()
case "half_page":
f.scroll(direction(h.i == 1) * (page / 2))
case "scroll":
if h.i == 0 {
f.xoff = max(f.xoff-8, 0)
} else {
f.xoff += 8
}
case "change":
if !f.search(direction(h.i == 0), f.changeStart, true) && f.msg == "" {
f.msg = "no more changes"
}
case "next_file":
if f.search(direction(h.i == 0), f.isKind(flowFile, 0), true) {
f.barToTop()
} else if f.msg == "" {
f.msg = ends(h.i == 0, "last file", "first file")
}
case "history":
switch {
case !f.inLog():
case f.search(f.order.step(historyDir(h)), f.isKind(flowCommit, 0), false):
f.barToTop()
default:
f.msg = ends(historyDir(h) > 0, "last commit", "first commit")
}
case "anchor", "cancel_mark", "fold", "fold_all":
f.delegate(h.action)
case "search":
return askSearch
case "match":
f.findText(direction(h.i == 0))
case "moved":
f.jump()
case "set_mark":
f.prefix, f.msg = h.action, "set mark: press a letter"
case "jump":
f.prefix, f.msg = h.action, "jump to mark: press its letter"
default:
return pageKey(h, f.inLog())
}
return nil
}
// delegate runs an anchor or fold action on the diff of the file under the
// cursor.
func (f *Flow) delegate(action string) {
if action == "cancel_mark" && f.replacing == nil && f.marked != nil && f.marked.pending != nil {
// The mark may be in another file than the cursor.
f.keepScreenRow(func() {
f.dropMark()
f.relocate()
})
f.msg = "mark cancelled"
return
}
if len(f.rows) == 0 || f.rows[f.cursor].kind != flowDiff {
if action != "cancel_mark" {
f.msg = "no line here"
}
return
}
r := f.rows[f.cursor]
e := f.parts[r.part].app.files[r.file]
d := e.diff
if action == "anchor" && f.marked != d {
f.dropMark()
}
var at spot
f.keepScreenRow(func() {
at, f.msg = d.act(action, spot{r.line, r.sub, f.active}, f.built)
e.updateStats()
f.build()
if i := f.findDiff(r.part, r.file, at.v, at.sub); i >= 0 {
f.cursor = i
}
})
f.active = at.side
f.replacing = nil
if d.replace != nil {
f.replacing = d
}
if d.pending != nil {
f.marked = d
} else if f.marked == d {
f.marked = nil
}
}
// jump moves the cursor to the other end of the moved line under it, in this
// or another file of the commit (see jumpTo).
func (f *Flow) jump() {
if len(f.rows) == 0 || f.rows[f.cursor].kind != flowDiff {
f.msg = "not moved"
return
}
r := f.rows[f.cursor]
app := f.parts[r.part].app
d := app.files[r.file].diff
end, msg, ok := d.otherEnd(spot{r.line, r.sub, f.active}, f.built)
f.msg = msg
if !ok {
return
}
i := app.fileByGroup(end.file)
if i < 0 {
return
}
at := app.files[i].diff.spotOf(end.side, end.line, f.built)
f.jumpTo(f.findDiff(r.part, i, at.v, at.sub))
f.active = at.side
}
// jumpTo moves the cursor to row i, unless it is -1, for a jump within the
// flow (g, '): the screen stays when i is on it, see jumpTop.
func (f *Flow) jumpTo(i int) {
if i < 0 {
return
}
from := f.cursor
f.cursor = i
f.top = jumpTop(from, i, f.top, f.bodyHeight(), len(f.rows))
}
// setMark sets jump mark letter on the new-file line under the cursor.
func (f *Flow) setMark(letter string) {
if len(f.rows) == 0 || f.rows[f.cursor].kind != flowDiff {
f.msg = "no line of the new file here"
return
}
r := f.rows[f.cursor]
f.msg = setMark(f.cfg, f.parts[r.part].app.files[r.file].diff, letter, r.line)
}
// gotoMark moves the cursor to the line of jump mark letter when its file is
// in the commit under the cursor, or outside the log among the files (see
// jumpTo).
func (f *Flow) gotoMark(letter string) {
mk, ok := f.cfg.Mark(letter)
if !ok {
f.msg = "no mark " + letter
return
}
if len(f.rows) == 0 || f.parts[f.rows[f.cursor].part].app == nil {
f.msg = notInDiff(letter, mk)
return
}
r := f.rows[f.cursor]
app := f.parts[r.part].app
i := app.fileByPath(mk.Path)
if i < 0 {
f.msg = notInDiff(letter, mk)
return
}
d := app.files[i].diff
line, note, ok := d.markLine(mk)
if !ok {
f.msg = markMsg(letter, "the new file is empty")
return
}
at := d.reveal(right, line, f.built)
f.relocate() // a fold may have opened: the cursor keeps its row
f.jumpTo(f.findDiff(r.part, i, at.v, at.sub))
f.active, f.msg = right, markMsg(letter, note)
}
// dropMark cancels a pending mark; the caller rebuilds the rows.
func (f *Flow) dropMark() {
if d := f.marked; d != nil && d.pending != nil {
d.pending = nil
d.buildView()
}
f.marked = nil
}
// build computes the rows from the parts.
func (f *Flow) build() {
f.rows = f.rows[:0]
f.built = newShape(f.cfg, f.width)
add := func(k flowKind, part, file, line int) {
f.rows = append(f.rows, flowRow{k, part, file, line, 0})
}
for p, part := range f.parts {
for i := range f.commitLines(part) {
add(flowCommit, p, -1, i)
}
if part.err != "" || (part.app != nil && len(part.app.files) == 0) {
add(flowNote, p, -1, 0)
}
if part.app == nil {
continue
}
for i, e := range part.app.files {
add(flowFile, p, i, 0)
if e.Binary {
add(flowNote, p, i, 0)
continue
}
screen, _ := part.app.diffOf(e).screenLines(f.built)
for _, s := range screen {
f.rows = append(f.rows, flowRow{flowDiff, p, i, s.v, s.sub})
}
}
}
f.cursor = max(min(f.cursor, len(f.rows)-1), 0)
}
// refresh runs reload, which may reload files, calling its argument after
// each reload of a diff, and rebuilds the rows. The cursor stays on its
// screen row and, in a reloaded file, on its line.
func (f *Flow) refresh(reload func(after func(*fileDiff))) {
var cur flowRow
var d *fileDiff
var row diff.Row
reloads, lost := 0, false
if f.cursor < len(f.rows) && f.rows[f.cursor].kind == flowDiff {
cur = f.rows[f.cursor]
d = f.parts[cur.part].app.files[cur.file].diff
row, reloads = d.rows[d.view[cur.line].row], d.reloads
}
f.keepScreenRow(func() {
// The cursor's row is followed through each reload: a diff may
// reload both sides, and each reload maps only its own side.
reload(func(rd *fileDiff) {
if rd != d || lost {
return
}
if i := d.rowAfterReload(row); i >= 0 {
row = d.rows[i]
} else {
lost = true
}
})
if d != nil {
// The files may have been listed again, in another order.
cur.file = slices.IndexFunc(f.parts[cur.part].app.files, func(e *entry) bool { return e.diff == d })
}
f.relocate()
if d == nil || cur.file < 0 || d.reloads == reloads || lost {
return
}
if v := d.follow(row, false); v >= 0 {
if i := f.findDiff(cur.part, cur.file, v, cur.sub); i >= 0 {
f.cursor = i
}
}
})
}
// followFiles keeps the cursor on its file of part when the files were
// listed again, from old to new: on the file's row, or for a file that is
// gone on the bar of the next file left, else of the last. The rows are
// rebuilt next.
func (f *Flow) followFiles(part int, old, new []*entry) {
if f.cursor >= len(f.rows) {
return
}
r := &f.rows[f.cursor]
if r.part != part || r.file < 0 {
return
}
for i := r.file; i < len(old); i++ {
if j := slices.Index(new, old[i]); j >= 0 {
if i == r.file {
r.file = j
} else {
*r = flowRow{flowFile, part, j, 0, 0}
}
return
}
}
*r = flowRow{flowFile, part, len(new) - 1, 0, 0}
}
// followParts keeps the cursor on its commit when the commits were listed
// again, from the parts old to f.parts, which still hold it. The rows are
// rebuilt next.
func (f *Flow) followParts(old []*flowPart) {
if f.cursor < len(f.rows) {
r := &f.rows[f.cursor]
r.part = slices.Index(f.parts, old[r.part])
}
}
// relocate rebuilds the rows, keeping the cursor on its row: with wrapped
// lines on the same piece of the same line, else on the first line of its
// diff row when the layout changed.
func (f *Flow) relocate() {
var cur flowRow
ok := f.cursor < len(f.rows)
if ok {
cur = f.rows[f.cursor]
}
from := f.built
f.build()
i := f.find(cur)
if i < 0 && cur.kind == flowCommit {
// A message line no longer shown: on to the subject.
i = f.find(flowRow{flowCommit, cur.part, -1, 2, 0})
}
if cur.kind == flowDiff {
sub := cur.sub
if d := f.parts[cur.part].app.files[cur.file].diff; cur.line < len(d.view) {
sub = d.convertSub(cur.line, cur.sub, from, f.built, f.active)
}
i = f.findDiff(cur.part, cur.file, cur.line, sub)
}
if ok && i >= 0 {
f.cursor = i
}
}
// findDiff returns the index of line sub of diff row line, or of its first
// line when it has no line sub, or -1.
func (f *Flow) findDiff(part, file, line, sub int) int {
if i := f.find(flowRow{flowDiff, part, file, line, sub}); i >= 0 {
return i
}
return f.find(flowRow{flowDiff, part, file, line, 0})
}
func (f *Flow) find(r flowRow) int {
for i, x := range f.rows {
if x == r {
return i
}
}
return -1
}
// inLog reports whether f shows the commits of the log, rather than the
// files outside it.
func (f *Flow) inLog() bool { return f.load != nil }
func (f *Flow) unloaded(part int) bool {
p := f.parts[part]
return p.app == nil && p.err == ""
}
// unloadedIn returns a commit with rows in [a, b] that is not loaded yet, or
// -1.
func (f *Flow) unloadedIn(a, b int) int {
if !f.inLog() {
return -1
}
for i := max(a, 0); i <= min(b, len(f.rows)-1); i++ {
if p := f.rows[i].part; f.unloaded(p) {
return p
}
}
return -1
}
// loadPart loads the files of commit p; the cursor stays on its row and its
// screen row.
func (f *Flow) loadPart(p int) {
part := f.parts[p]
app, err := f.load(p)
if err != nil {
part.err = firstLine(err)
} else {
part.app = app
}
f.keepScreenRow(f.relocate)
}
// loadVisible loads the commits shown on screen.
func (f *Flow) loadVisible() {
for {
p := f.unloadedIn(f.top, f.top+f.bodyHeight()-1)
if p < 0 {
return
}
f.loadPart(p)
}
}
// moveCursor moves the cursor by delta rows. Commits on the way and a
// screen beyond are loaded first, so it lands where it would with all
// commits loaded; for far only those around where it lands, the others load
// as they come near.
func (f *Flow) moveCursor(delta int) {
if max(delta, -delta) >= far {
f.loadAroundEnd(delta)
} else {
f.loadAhead(delta)
}
f.cursor = f.confine(max(min(f.cursor+delta, len(f.rows)-1), 0))
f.scrollToCursor()
}
// loadAroundEnd loads the commits within a screen of the first row the cursor
// may go to, for delta below 0, else of the last. Only home and end need it:
// should those keys go, as they may, this and the far branch of moveCursor go
// with their cases in motion.
func (f *Flow) loadAroundEnd(delta int) {
h := f.bodyHeight()
for {
lo, hi := f.bounds()
end := hi
if delta < 0 {
end = lo
}
p := f.unloadedIn(end-h, end+h)
if p < 0 {
return
}
f.loadPart(p)
}
}
// bounds returns the first and last row the cursor may go to: those of its
// commit while stay_in_commit is set, else all.
func (f *Flow) bounds() (lo, hi int) {
lo, hi = 0, len(f.rows)-1
if !f.inLog() || !f.cfg.Settings.StayInCommit || len(f.rows) == 0 {
return lo, hi
}
p := f.rows[f.cursor].part
for lo = f.cursor; lo > 0 && f.rows[lo-1].part == p; lo-- {
}
for hi = f.cursor; hi < len(f.rows)-1 && f.rows[hi+1].part == p; hi++ {
}
return lo, hi
}
// confine returns row i, or the nearest row within the bounds with a
// warning that the cursor stays in its commit.
func (f *Flow) confine(i int) int {
lo, hi := f.bounds()
if i >= lo && i <= hi {
return i
}
f.msg = "stays in commit " + f.parts[f.rows[f.cursor].part].commit.Short + " (C)"
return max(min(i, hi), lo)
}
// scroll moves the window by delta rows, as far as it can, and the cursor
// along, so that the cursor keeps its screen row. Commits are loaded first,
// as for moveCursor.
func (f *Flow) scroll(delta int) {
f.loadAhead(delta)
top := f.top
f.top = max(min(f.top+delta, len(f.rows)-f.bodyHeight()), 0)
f.cursor = f.confine(max(min(f.cursor+f.top-top, len(f.rows)-1), 0))
f.scrollToCursor()
}
// loadAhead loads the commits on the way of a cursor move by delta rows and
// a screen beyond.
func (f *Flow) loadAhead(delta int) {
h := f.bodyHeight()
for {
lo, hi := f.cursor, f.cursor+delta+h
if delta < 0 {
lo, hi = f.cursor+delta-h, f.cursor
}
p := f.unloadedIn(lo, hi)
if p < 0 {
break
}
f.loadPart(p)
}
}
// search moves the cursor to the next row in direction dir for which ok
// holds, loading commits on the way, and reports whether there is one. With
// stay set it does not leave the bounds; reaching them sets a warning.
func (f *Flow) search(dir int, ok func(int) bool, stay bool) bool {
for {
loaded := false
for i := f.cursor + dir; i >= 0 && i < len(f.rows); i += dir {
if lo, hi := f.bounds(); stay && (i < lo || i > hi) {
f.confine(i)
return false
}
if p := f.rows[i].part; f.inLog() && f.unloaded(p) {
f.loadPart(p)
loaded = true
break
}
if ok(i) {
f.cursor = i
f.scrollToCursor()
return true
}
}
if !loaded {
return false
}
}
}
// barToTop scrolls the bar under the cursor to the top of the screen, so
// that what it starts is visible.
func (f *Flow) barToTop() {
f.top = f.cursor
f.scrollToCursor()
}
func (f *Flow) isKind(k flowKind, line int) func(int) bool {
return func(i int) bool { return f.rows[i].kind == k && f.rows[i].line == line }
}
// changeStart reports whether row i starts a block of changed rows.
func (f *Flow) changeStart(i int) bool {
changed := func(i int) bool {
r := f.rows[i]
return r.kind == flowDiff && f.parts[r.part].app.files[r.file].diff.changed(r.line)
}
return changed(i) && (i == 0 || !changed(i-1))
}
// show puts the cursor on line sub of diff row line of file in part, on
// the file bar for line -1 and on the commit bar for file -1, at screen row
// screenRow.
func (f *Flow) show(part, file, line, sub, screenRow int) {
if f.inLog() && f.unloaded(part) {
f.loadPart(part)
}
// While hidden, diff views may have changed the diffs or the layout.
f.build()
f.adopt(part, file)
i := f.find(flowRow{flowCommit, part, -1, 0, 0})
switch {
case f.parts[part].app == nil:
case file >= 0 && line >= 0:
i = f.findDiff(part, file, line, sub)
case file >= 0:
i = f.find(flowRow{flowFile, part, file, 0, 0})
}
f.cursor = max(i, 0)
f.top = f.cursor - screenRow
f.scrollToCursor()
f.loadVisible()
}
// showFrom shows file of part where its diff view from leaves it: on the
// cursor's line at its screen row, in its pane. For from nil it shows the
// file's bar, or the commit's bar for file -1. Model.takeOver is the way
// back.
func (f *Flow) showFrom(part, file int, from *Model) {
if from == nil {
f.show(part, file, -1, 0, 0)
return
}
from.syncLayout()
f.show(part, file, from.cursor, from.sub, from.pos()-from.top)
f.active = from.active
}
// adopt takes over the pending mark and replace prompt that diff views of
// the files of part left, so that esc and enter treat them as the flow's
// own. Of several pending marks the one of file is kept, else the first;
// the others are dropped, as the flow keeps one.
func (f *Flow) adopt(part, file int) {
f.marked, f.replacing = nil, nil
app := f.parts[part].app
if app == nil {
return
}
var marked []*fileDiff
for i, e := range app.files {
d := e.diff
if d == nil {
continue
}
if d.pending != nil {
if i == file {
marked = append([]*fileDiff{d}, marked...)
} else {
marked = append(marked, d)
}
}
if d.replace != nil {
f.replacing = d
}
}
if len(marked) == 0 {
return
}
for _, d := range marked[1:] {
f.marked = d
f.dropMark()
}
f.marked = marked[0]
if len(marked) > 1 {
f.build() // the dropped marks may have folded rows
}
}
// position returns the part of the cursor, its file (-1 on rows of the
// commit) and the line in the file's diff view (-1 on other rows).
func (f *Flow) position() (part, file, line int) {
if len(f.rows) == 0 {
return 0, -1, -1
}
r := f.rows[f.cursor]
line = -1
if r.kind == flowDiff {
line = r.line
}
return r.part, r.file, line
}
// headerLines is the number of lines above the rows: the commit in the log,
// and the file names.
func (f *Flow) headerLines() int {
if f.inLog() {
return 2
}
return 1
}
// cursorSub returns the line of the cursor within its diff row.
func (f *Flow) cursorSub() int {
if len(f.rows) == 0 {
return 0
}
return f.rows[f.cursor].sub
}
func (f *Flow) bodyHeight() int { return max(f.height-1-f.headerLines(), 1) }
func (f *Flow) keepScreenRow(fn func()) {
offset := f.cursor - f.top
fn()
f.top = f.cursor - offset
f.scrollToCursor()
}
func (f *Flow) scrollToCursor() {
f.top = scrollWindow(f.cursor, f.top, f.bodyHeight(), len(f.rows))
}
// help returns the keys of the status bar.
func (f *Flow) help() string {
return statusKeys(f.cfg, barKeys{enter: "anchor", back: "to_files", commits: f.inLog(), flow: true})
}
func (f *Flow) View() tea.View { return tea.NewView(f.frame()) }
func (f *Flow) frame() string {
if f.width == 0 {
return ""
}
var b strings.Builder
b.WriteString(f.header())
for i := f.top; i < f.top+f.bodyHeight(); i++ {
if i < len(f.rows) {
b.WriteString(f.row(i))
}
b.WriteString("\n")
}
b.WriteString(f.status())
return b.String()
}
// header renders the lines above the rows: in the log the commit of the
// cursor, then the names of its file as in the single file view.
func (f *Flow) header() string {
t := f.cfg.Theme
p, file, _ := f.position()
var b strings.Builder
if f.inLog() {
c := f.parts[p].commit
line := " " + t.CommitHash.Render(c.Short) + " " + t.Title.Render(c.Subject)
b.WriteString(ansi.Truncate(line, f.width, "…") + "\n")
}
if file >= 0 {
b.WriteString(paneTitles(t, f.parts[p].app.files[file].paneNames(), f.active, f.cfg.Settings.SideBySide, f.width))
}
return b.String() + "\n"
}
func (f *Flow) status() string {
p, file, _ := f.position()
var where []string
if f.inLog() {
c := fmt.Sprintf("commit %d/%d", p+1, len(f.parts))
if f.cfg.Settings.StayInCommit {
c += " (C)"
}
where = append(where, c)
}
if file >= 0 {
files := f.parts[p].app.files
where = append(where, fmt.Sprintf("file %d/%d", file+1, len(files)))
if d := files[file].diff; d != nil {
where = append(where, fmt.Sprintf("anchors %d", len(d.anchors)))
}
}
msg := f.msg
if msg == "" && f.marked != nil {
msg = f.marked.markPrompt()
}
left := pageStatus(f.cfg, msg, f.help())
return statusLine(f.cfg.Theme, f.width, left, " "+strings.Join(where, " · "))
}
// row renders row i. Diff rows are drawn by the file's diff with the flow's
// pane, scroll offset and width; other rows show the cursor in the first
// column and in their text, in the cursor style.
func (f *Flow) row(i int) string {
t := f.cfg.Theme
r := f.rows[i]
part := f.parts[r.part]
cursor := i == f.cursor
mark := " "
if cursor {
mark = t.Cursor.Render(" ")
}
w := max(f.width-1, 0)
// bar renders s across the row in st.
bar := func(s string, st lipgloss.Style) string {
s = ansi.Truncate(s, w, "…")
if cursor {
st = t.Cursor.Inherit(st)
}
return st.Render(s + strings.Repeat(" ", max(w-ansi.StringWidth(s), 0)))
}
// text renders s in st, on the cursor row past its indentation.
text := func(s string, st lipgloss.Style) string {
s = ansi.Truncate(s, w, "…")
if !cursor {
return st.Render(s)
}
body := strings.TrimLeft(s, " ")
return st.Render(s[:len(s)-len(body)]) + t.Cursor.Inherit(st).Render(body)
}
switch r.kind {
case flowCommit:
line := f.commitLines(part)[r.line]
switch r.line {
case 0:
return mark + bar(" "+line, t.CommitBar)
case 2:
return mark + text(line, t.Zero.Bold(true))
}
return mark + text(line, t.Zero)
case flowFile:
e := part.app.files[r.file]
line := " "
if lbl := e.label(t); lbl != "" {
line += lbl + " "
}
line += e.name()
if e.Binary {
line += " binary"
} else if e.added > 0 || e.removed > 0 {
line += fmt.Sprintf(" +%d -%d", e.added, e.removed)
}
return mark + bar(line, t.FileBar)
case flowNote:
note := "no changed files"
switch {
case r.file >= 0:
note = "binary file"
case part.err != "":
note = printable(part.err)
}
return mark + text(" "+note, t.Hint)
}
d := part.app.files[r.file].diff
return d.render(sline{r.line, r.sub}, i == f.cursor, layout{f.width, f.xoff, f.active, f.built})
}
package ui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
)
// binding is a key and what it does; a binding without key is a heading.
type binding struct{ key, what string }
// bindings lists the lines of the help popup, from keybindings.toml.
var bindings = keyFile.bindings()
// bindings returns the lines of the help popup.
func (p keyPages) bindings() []binding {
var bs []binding
for _, h := range p.Help {
switch {
case h.Heading != "":
bs = append(bs, binding{"", h.Heading})
case h.Note != "":
bs = append(bs, binding{"", " " + p.keyText(h.Note)})
default:
r := p.row(h.Action)
bs = append(bs, binding{strings.Join(r.Keys, " "), p.keyText(r.Help)})
}
}
return bs
}
// Help wraps the top-level model and shows the key bindings of all pages in
// a popup over it on ?.
type Help struct {
tea.Model
cfg *config.Config
open bool
top int // first shown line of the popup
width, height int
}
// WithHelp returns m with the help popup.
func WithHelp(cfg *config.Config, m tea.Model) *Help { return &Help{Model: m, cfg: cfg} }
func (h *Help) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h.width, h.height = msg.Width, msg.Height
h.scroll(0)
case tea.KeyPressMsg:
if cmd, ok := popupKeys(msg, h.open, "help", h.key); ok {
return h, cmd
}
}
_, cmd := h.Model.Update(msg)
return h, cmd
}
// popupKeys passes the key press msg to key, when the popup is open or msg
// opens it, being a key of action opener, and reports whether it did; else
// msg is for the page below.
func popupKeys(msg tea.KeyPressMsg, open bool, opener string, key func(string) tea.Cmd) (tea.Cmd, bool) {
k := msg.String()
if !open && !isAction(k, opener) {
return nil, false
}
return key(k), true
}
// key handles k in the popup, or opens it on ?.
func (h *Help) key(k string) tea.Cmd {
if !h.open {
h.open, h.top = true, 0
return nil
}
if isAction(k, "help") {
h.open = false
return nil
}
switch k {
case "esc", "q":
h.open = false
case "ctrl+c":
return tea.Quit
case "down", "j":
h.scroll(1)
case "up", "k":
h.scroll(-1)
}
return nil
}
// boxHeight is the height of the popup.
func (h *Help) boxHeight() int { return popupHeight(len(bindings), h.height) }
// scroll moves the shown lines by delta; from the top, it moves on by two
// at once, as the window never starts at the second line.
func (h *Help) scroll(delta int) {
top := h.top + delta
if top == 1 && delta > 0 {
top = 2
}
h.top = windowAt(top, h.boxHeight()-2, len(bindings)).first
}
func (h *Help) View() tea.View { return tea.NewView(h.frame()) }
func (h *Help) frame() string {
base := h.Model.View().Content
if !h.open || h.width == 0 {
return base
}
t := h.cfg.Theme
keyW, whatW := 0, 0
for _, b := range bindings {
keyW = max(keyW, ansi.StringWidth(b.key))
whatW = max(whatW, ansi.StringWidth(b.what))
}
line := func(i int) string {
switch b := bindings[i]; {
case b.key == "" && !strings.HasPrefix(b.what, " "):
return t.Branch.Render(b.what)
case b.key == "":
return strings.Repeat(" ", keyW+2) + t.Hint.Render(strings.TrimSpace(b.what))
default:
return lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf(" %-*s", keyW, b.key)) + " " + b.what
}
}
lines := windowAt(h.top, h.boxHeight()-2, len(bindings)).rows(t, len(bindings), line)
w := min(keyW+whatW+8, h.width) // the key indent, gap, border and padding
title := t.CommitHash.Render(" keys ")
if h.boxHeight()-2 < len(bindings) {
title += t.Hint.Render(fmt.Sprintf(" %d/%d ", h.top+1, len(bindings)))
}
return overlay(base, box(t, w, h.boxHeight(), title, lines), w, h.width)
}
// overlay draws the popup pop, w columns wide, centered over base, which
// is width columns wide.
func overlay(base, pop string, w, width int) string {
y := max((strings.Count(base, "\n")+1-strings.Count(strings.TrimSuffix(pop, "\n"), "\n")-1)/2, 0)
return overlayAt(base, pop, max((width-w)/2, 0), y, w, width)
}
// overlayAt draws the popup pop, w columns wide, over base, which is width
// columns wide, from column x of row y on.
func overlayAt(base, pop string, x, y, w, width int) string {
rows := strings.Split(base, "\n")
popRows := strings.Split(strings.TrimSuffix(pop, "\n"), "\n")
for i, p := range popRows {
if y+i >= len(rows) {
break
}
row := rows[y+i]
left := ansi.Truncate(row, x, "")
left += strings.Repeat(" ", x-ansi.StringWidth(left))
rows[y+i] = left + p + ansi.Cut(row, x+w, width)
}
return strings.Join(rows, "\n")
}
package ui
import (
"fmt"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/gitsrc"
"jdiff/internal/source"
)
// LoadFunc returns the files a commit changed.
type LoadFunc func(gitsrc.Commit) ([]source.File, error)
// CountFunc returns the line counts of the commits with the given hashes.
type CountFunc func(hashes []string) (map[string]gitsrc.LineCounts, error)
// countsMsg brings the line counts of the commits hashes, or why they could
// not be counted.
type countsMsg struct {
hashes []string
counts map[string]gitsrc.LineCounts
err error
}
// countWidth is the width the count columns keep while counts arrive, so
// that they do not move with each batch.
const countWidth = 4
// commitEntry is one commit in the log. Its file selector is created on
// first open and kept, so anchors survive going back to the list.
type commitEntry struct {
gitsrc.Commit
counted bool // Added and Removed are known
app *App
}
// Log is the Bubble Tea model of the commit list. Enter shows the files of
// a commit; N/P step through commits from the deeper views.
type Log struct {
cfg *config.Config
branch string // shown above the commit list
load LoadFunc
// counts counts the lines of the commits shown, in the background; nil
// when the commits came counted or counting failed.
counts CountFunc
relist CommitsFunc // lists the commits again, nil when they cannot be
counting bool // a count is running
byHash map[string]*commitEntry // the commits by hash
minCount int // the least width of the count columns
commits []*commitEntry
order historyOrder // of commits
list
open *commitEntry // commit whose files are shown, nil for the list
files bool // in flow mode: the file list of open, not the flow view
flow *Flow // all commits one after the other
msg string
prefix string // "'" while waiting for the letter of a mark
}
// NewLog returns the commit list for commits of branch, in the order of git
// log: newest first, or with oldestFirst (git log --reverse) oldest. With
// counts, their line counts are counted once they are shown; without, they
// come with the commits.
func NewLog(cfg *config.Config, branch string, commits []gitsrc.Commit, oldestFirst bool,
load LoadFunc, counts CountFunc) *Log {
l := &Log{cfg: cfg, branch: branch, load: load, counts: counts, byHash: map[string]*commitEntry{}, minCount: 1,
order: orderOf(oldestFirst)}
if counts != nil {
l.minCount = countWidth
}
var parts []*flowPart
for _, c := range commits {
e := newCommitEntry(c, counts == nil)
l.commits = append(l.commits, e)
l.byHash[c.Hash] = e
parts = append(parts, newCommitPart(e))
}
l.list = list{n: len(l.commits), preview: &cfg.Settings.Preview}
l.flow = newFlow(cfg, parts, l.app)
l.flow.order = l.order
if len(commits) == 0 {
l.msg = "no commits"
}
if w := cfg.Warning(); w != "" {
l.msg = w
}
return l
}
// newCommitEntry returns the entry of c, counted when its line counts are
// known.
func newCommitEntry(c gitsrc.Commit, counted bool) *commitEntry {
// Commits are only shown: their text is made printable once.
c.Author, c.Subject = printable(c.Author), printable(c.Subject)
body := strings.Split(c.Body, "\n")
for i, line := range body {
body[i] = printable(line)
}
c.Body = strings.Join(body, "\n")
return &commitEntry{Commit: c, counted: counted}
}
// CommitsFunc lists the commits of the log again, with the branch.
type CommitsFunc func() (branch string, commits []gitsrc.Commit, err error)
// WithRelist sets how r lists the commits again and returns l.
func (l *Log) WithRelist(list CommitsFunc) *Log {
l.relist = list
return l
}
func (l *Log) Init() tea.Cmd { return nil }
func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m, ok := msg.(countsMsg); ok {
l.addCounts(m)
} else if cmd := l.update(msg); cmd != nil {
return l, cmd
}
return l, l.countShown()
}
// countShown returns the command counting the lines of the commits on the
// list's screen and a screen beyond that are not counted yet, or nil when
// they are, a count is running or the list is not shown.
func (l *Log) countShown() tea.Cmd {
if l.counts == nil || l.counting || l.open != nil || l.height == 0 {
return nil
}
var hashes []string
for i := l.top; i < min(l.top+2*l.listHeight(), len(l.commits)); i++ {
if e := l.commits[i]; !e.counted {
hashes = append(hashes, e.Hash)
}
}
if len(hashes) == 0 {
return nil
}
l.counting = true
counts := l.counts
return func() tea.Msg {
c, err := counts(hashes)
return countsMsg{hashes, c, err}
}
}
// addCounts takes the line counts of m. After an error the counts are not
// tried again: the list stays without them.
func (l *Log) addCounts(m countsMsg) {
l.counting = false
if m.err != nil {
l.counts = nil
l.setMsg("line counts: " + firstLine(m.err))
return
}
for _, h := range m.hashes {
if e := l.byHash[h]; e != nil {
c := m.counts[h]
e.Added, e.Removed, e.counted = c.Added, c.Removed, true
}
}
}
func (l *Log) update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
l.width, l.height = msg.Width, msg.Height
l.scrollToCursor()
if l.flowShown() {
l.flow.Update(msg)
return nil
}
case settingChangedMsg:
l.scrollToCursor() // the message box takes room from the list
if msg.problem != "" && l.open == nil {
l.msg = msg.problem
}
case backMsg:
if l.flowShown() {
l.filesOfFlow()
} else {
l.open, l.files = nil, false
}
return nil
case flowAtMsg:
l.showFlow(int(msg), nil)
return nil
case switchCommitMsg:
l.switchCommit(int(msg))
return nil
case refreshMsg:
l.relistCommits(msg.quiet)
return nil
case showMsg:
l.setMsg(string(msg))
return nil
case toggleUntrackedMsg:
l.setMsg(untrackedOnlyInDiff)
return nil
case toggleFlowMsg:
l.toggleFlow()
if w := saveState(l.cfg); w != "" {
l.setMsg(w)
}
return nil
}
if l.flowShown() {
_, cmd := l.flow.Update(msg)
l.cursor, _, _ = l.flow.position()
l.scrollToCursor()
return cmd
}
if l.open != nil {
_, cmd := l.open.app.Update(msg)
return cmd
}
if k, ok := msg.(tea.KeyPressMsg); ok {
return l.key(k.String())
}
return nil
}
func (l *Log) key(k string) tea.Cmd {
l.msg = ""
if l.prefix != "" {
// Marks are lines of files: the list has none to jump to.
l.prefix = ""
if isMarkLetter(k) {
l.msg = "mark " + k + ": open a commit to jump to it"
}
return nil
}
h := keyFile.key(commitList, k)
page := max(l.listHeight()-1, 1)
if delta, _, ok := motion(h, page, len(l.commits)); ok {
l.moveCursor(delta)
return nil
}
switch h.action {
case "quit":
return tea.Quit
case "open":
if l.cfg.Settings.Flow {
l.showFlow(-1, nil)
} else {
l.openCommit(l.cursor, "")
}
case "flow":
return toggleFlow
case "history":
// The next or previous commit in history, as N and P go from its files.
dir := historyDir(h)
if i := l.cursor + l.order.step(dir); i >= 0 && i < len(l.commits) {
l.moveCursor(i - l.cursor)
} else {
l.msg = ends(dir > 0, "last commit", "first commit")
}
case "jump":
l.prefix, l.msg = h.action, "jump to mark: press its letter"
default:
return pageKey(h, false)
}
return nil
}
// flowShown reports whether the flow view is shown.
func (l *Log) flowShown() bool { return l.open != nil && l.cfg.Settings.Flow && !l.files }
// showFlow shows the flow view on file of the commit under the cursor,
// where its diff view from leaves it, or on its bar for from nil and on the
// commit's bar for file -1 (see Flow.showFrom).
func (l *Log) showFlow(file int, from *Model) {
l.open, l.files = l.commits[l.cursor], false
l.flow.width, l.flow.height = l.width, l.height
l.flow.showFrom(l.cursor, file, from)
}
// toggleFlow switches between the flow view and the files of the commit
// under the cursor, keeping the cursor on its line and screen row.
func (l *Log) toggleFlow() {
st := &l.cfg.Settings
if !st.Flow {
st.Flow = true
switch {
case l.open == nil:
l.showFlow(-1, nil)
case l.open.app.open == nil:
l.showFlow(l.open.app.cursor, nil)
default:
l.showFlow(l.open.app.cursor, l.open.app.open.view)
}
return
}
st.Flow = false
if l.open == nil {
return
}
if l.files { // the file list stays
l.files = false
return
}
p, file, line := l.flow.position()
screenRow := l.flow.cursor - l.flow.top
path := ""
if e := l.commits[p]; e.app != nil && file >= 0 {
path = e.app.files[file].Path
}
l.open = nil
if !l.openCommit(p, path) {
return
}
app := l.open.app
if app.open == nil && file >= 0 {
app.cursor = file
app.scrollToCursor()
}
if app.open != nil && app.open.Path == path {
app.open.view.takeOver(l.flow, line, screenRow)
}
}
// app returns the file selector of commit i, loading its files on first
// use.
func (l *Log) app(i int) (*App, error) {
e := l.commits[i]
if e.app == nil {
files, err := l.load(e.Commit)
if err != nil {
return nil, err
}
e.app = newApp(l.cfg, files, &e.Commit)
}
return e.app, nil
}
// openCommit shows the files of commit i, opening path directly when the
// commit changed it, and the only file of a single-file commit.
func (l *Log) openCommit(i int, path string) bool {
if i < 0 || i >= len(l.commits) {
return false
}
e := l.commits[i]
if _, err := l.app(i); err != nil {
l.msg = firstLine(err)
return false
}
e.app.Update(tea.WindowSizeMsg{Width: l.width, Height: l.height})
switch {
case path != "" && e.app.openPath(path):
case e.app.single():
e.app.cursor = 0
e.app.openFile()
default:
e.app.open = nil
}
l.open = e
l.cursor = i
l.scrollToCursor()
return true
}
// switchCommit shows the next (dir > 0) or previous commit in history,
// keeping the open file when the other commit changed it too.
func (l *Log) switchCommit(dir int) {
if l.open == nil {
return
}
app := l.open.app
path := ""
if app.open != nil {
path = app.open.Path
}
i := l.cursor + l.order.step(dir)
if i < 0 || i >= len(l.commits) {
app.setMsg(ends(dir > 0, "last commit", "first commit"))
return
}
if app.open != nil {
app.open.updateStats()
}
if !l.openCommit(i, path) {
app.setMsg(l.msg)
}
}
// relistCommits lists the commits again (r). New commits are added and gone
// ones dropped; a commit still there keeps its entry, and so its files with
// their anchors and folds: its content cannot change. The cursor stays on
// its commit; when the commit shown is gone, the list is shown. With quiet, it
// does not say when nothing changed.
func (l *Log) relistCommits(quiet bool) {
if l.relist == nil {
l.setMsg("nothing to load again")
return
}
branch, commits, err := l.relist()
if err != nil {
l.setMsg(firstLine(err))
return
}
parts := map[*commitEntry]*flowPart{}
for _, p := range l.flow.parts {
parts[p.commit] = p
}
var cur *commitEntry
if l.cursor < len(l.commits) {
cur = l.commits[l.cursor]
}
kept, oldParts, shown, open := l.byHash, l.flow.parts, l.flowShown(), l.open
if shown {
p, _, _ := l.flow.position()
open = oldParts[p].commit
}
l.branch, l.commits, l.flow.parts, l.byHash = branch, nil, nil, map[string]*commitEntry{}
added := 0
for _, c := range commits {
e, p := kept[c.Hash], (*flowPart)(nil)
if e != nil {
delete(kept, c.Hash)
p = parts[e]
} else {
e = newCommitEntry(c, l.counts == nil)
p = newCommitPart(e)
added++
}
l.commits = append(l.commits, e)
l.byHash[c.Hash] = e
l.flow.parts = append(l.flow.parts, p)
}
l.list.n = len(l.commits)
if i := slices.Index(l.commits, cur); i >= 0 {
l.cursor = i
} else {
l.cursor = max(min(l.cursor, len(l.commits)-1), 0)
}
msg := refreshed(added, len(kept), 0, 0)
switch {
case open != nil && kept[open.Hash] == open:
msg = "commit " + open.Short + " is gone; " + msg
l.open = nil
case shown:
l.flow.followParts(oldParts)
l.flow.keepScreenRow(l.flow.relocate)
l.flow.loadVisible()
l.cursor, _, _ = l.flow.position()
l.open = l.commits[l.cursor]
case open != nil:
l.cursor = slices.Index(l.commits, open)
}
l.scrollToCursor()
if !quiet || msg != nothingChanged {
l.setMsg(msg)
}
}
// filesOfFlow shows the file list of the commit under the flow view's
// cursor, on its file, as backspace does from a diff view. A commit of one
// file, or one that cannot be loaded, has no list to show: the commits are
// shown, on it.
func (l *Log) filesOfFlow() {
p, file, _ := l.flow.position()
l.cursor = p
l.scrollToCursor()
app, err := l.app(p)
if err != nil || app.single() {
l.open, l.files = nil, false
return
}
app.Update(tea.WindowSizeMsg{Width: l.width, Height: l.height})
app.open = nil
if file >= 0 {
app.cursor = file
app.scrollToCursor()
}
l.open, l.files = l.commits[p], true
}
// setMsg shows msg in the status bar of whatever is displayed.
func (l *Log) setMsg(msg string) {
switch {
case l.flowShown():
l.flow.msg = msg
case l.open != nil:
l.open.app.setMsg(msg)
default:
l.msg = msg
}
}
// maxAuthorWidth caps the author column.
const maxAuthorWidth = 16
func (l *Log) View() tea.View { return tea.NewView(l.frame()) }
func (l *Log) frame() string {
if l.flowShown() {
return l.flow.frame()
}
if l.open != nil {
return l.open.app.frame()
}
if l.width == 0 {
return ""
}
t := l.cfg.Theme
addW, remW, authW, subjW, fixedW := l.minCount, l.minCount, 0, 0, 0
for _, c := range l.commits {
addW = max(addW, len(fmt.Sprint(c.Added)))
remW = max(remW, len(fmt.Sprint(c.Removed)))
authW = max(authW, min(ansi.StringWidth(c.Author), maxAuthorWidth))
subjW = max(subjW, ansi.StringWidth(c.Subject))
fixedW = max(fixedW, ansi.StringWidth(c.Short)+ansi.StringWidth(c.Date))
}
// The subject gets the room left by the other columns, so that author
// and date stay aligned and visible.
fixedW += 1 + 2 + (addW + 1) + 1 + (remW + 1) + 2 + 2 + authW + 2
subjW = max(min(subjW, l.width-fixedW), 1)
head := " " + t.Branch.Render(l.branch) + " " +
lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("(%d commits)", len(l.commits)))
item := func(i int, st func(lipgloss.Style) lipgloss.Style) string {
c := l.commits[i]
author := pad(ansi.Truncate(c.Author, maxAuthorWidth, "…"), authW, lipgloss.NewStyle())
subject := ansi.Truncate(c.Subject, subjW, "…")
subject += strings.Repeat(" ", subjW-ansi.StringWidth(subject))
return " " + st(t.CommitHash).Render(c.Short) + " " + counts(t, st, c.Added, c.Removed, addW, remW) + " " +
st(lipgloss.NewStyle()).Render(subject) + " " + st(t.Decoration).Render(author) + " " +
st(t.Decoration).Render(c.Date)
}
hints := statusKeys(l.cfg, barKeys{enter: "open", flow: true})
return l.render(t, head, item, func(h int) string { return l.messageBox(l.commits[l.cursor].Commit, h) }, pageStatus(l.cfg, l.msg, hints))
}
// messageBox renders the full message of c in a box height lines high,
// with the hash in the top border. A last … line marks a cut message.
func (l *Log) messageBox(c gitsrc.Commit, height int) string {
t := l.cfg.Theme
lines := []string{t.Zero.Bold(true).Render(c.Subject)}
if c.Body != "" {
lines = append(lines, "")
for line := range strings.SplitSeq(c.Body, "\n") {
lines = append(lines, t.Zero.Render(line))
}
}
if n := height - 2; len(lines) > n {
lines = lines[:n]
if n > 1 {
lines[n-1] = t.Hint.Render("…")
}
}
return box(t, l.width, height, t.CommitHash.Render(" "+c.Hash+" "), lines)
}
package ui
import (
"fmt"
"strings"
"unicode"
"github.com/charmbracelet/x/ansi"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
east "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/text"
)
// docStyle is what a piece of a page is, which decides its style in the
// theme when it is drawn.
type docStyle int
const (
docPlain docStyle = iota
docBold
docItalic
docCode
docHeading
docLink
docDecoration // bullets, rules, the lines of tables and quotes
docOverflow // » at the end of a cut line
)
// docSeg is a piece of a line of a page in one style: printable text, part
// of link number link of the page, -1 for none.
type docSeg struct {
text string
style docStyle
link int
}
// docLine is a line of a page; anchor is the name of the heading it starts,
// as links to it write it after #.
type docLine struct {
segs []docSeg
anchor string
}
// plain returns the text of l, without styles.
func (l docLine) plain() string {
var b strings.Builder
for _, s := range l.segs {
b.WriteString(s.text)
}
return b.String()
}
// docPage is a markdown page rendered for a width: its lines, none wider,
// and the targets of its links with the line each starts on.
type docPage struct {
lines []docLine
links []pageLink
anchors map[string]int // the line of each heading
}
// pageLink is a link of a page: where it goes, as written, and its first
// line; broken when it leads nowhere, which Doc decides.
type pageLink struct {
target string
line int
broken bool
}
var markdown = goldmark.New(goldmark.WithExtensions(extension.Table))
// renderMarkdown renders src for a screen width columns wide. Images are left
// out, so a line of badges is gone; a line still too wide, of code or a table,
// is cut with ».
func renderMarkdown(src []byte, width int) docPage {
width = max(width, 1)
r := &mdRenderer{src: src}
lines := r.blocks(markdown.Parser().Parse(text.NewReader(src)), width, false)
page := docPage{anchors: map[string]int{}}
for i := range r.links {
page.links = append(page.links, pageLink{target: r.links[i], line: -1})
}
for i, l := range lines {
l.segs = cutSegs(l.segs, width)
lines[i] = l
if l.anchor != "" {
page.anchors[l.anchor] = i
}
for _, s := range l.segs {
if s.link >= 0 && page.links[s.link].line < 0 {
page.links[s.link].line = i
}
}
}
page.lines = lines
return page
}
// renderText renders a file that is no markdown for a screen width columns
// wide: its lines as code, the long ones wrapped at the last space that
// fits, past their indentation, else where the line ends; the further pieces
// indented.
func renderText(src []byte, width int) docPage {
width = max(width, 1)
page := docPage{anchors: map[string]int{}}
text := strings.TrimSuffix(strings.ReplaceAll(string(src), "\r\n", "\n"), "\n")
for _, line := range strings.Split(text, "\n") {
rest, lead, w := printable(line), "", width
add := func(s string) {
page.lines = append(page.lines, docLine{segs: trimSegs([]docSeg{{lead, docPlain, -1}, {s, docCode, -1}})})
}
for ansi.StringWidth(rest) > w {
piece := ansi.Truncate(rest, w, "")
if i := strings.LastIndex(piece, " "); i > 0 && strings.TrimSpace(piece[:i]) != "" {
piece = piece[:i+1]
}
add(piece)
rest = rest[len(piece):]
if lead == "" && width > 2 {
lead, w = " ", width-2
}
}
add(rest)
}
return page
}
// mdRenderer renders the blocks of a markdown tree into lines.
type mdRenderer struct {
src []byte
links []string // the targets of the links found so far
anchors map[string]bool // the anchors given so far
depth int // of nested lists
}
// blocks renders the children of n one below the other, with an empty line
// between them unless tight, as in the items of a tight list.
func (r *mdRenderer) blocks(n ast.Node, width int, tight bool) []docLine {
var out []docLine
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
lines := r.block(c, width)
if len(lines) == 0 {
continue
}
if len(out) > 0 && !tight {
out = append(out, docLine{})
}
out = append(out, lines...)
}
return out
}
// block renders the block n, width columns wide but for code.
func (r *mdRenderer) block(n ast.Node, width int) []docLine {
switch n := n.(type) {
case *ast.Heading:
lines := wrapSegs(r.inline(n, docHeading), width)
if len(lines) == 0 {
return nil
}
lines[0].anchor = r.anchor(n)
if n.Level <= 2 {
w := 0
for _, l := range lines {
w = max(w, segsWidth(l.segs))
}
rule := "─"
if n.Level == 1 {
rule = "═"
}
lines = append(lines, docLine{segs: []docSeg{{strings.Repeat(rule, w), docDecoration, -1}}})
}
return lines
case *ast.Paragraph, *ast.TextBlock:
return wrapSegs(r.inline(n, docPlain), width)
case *ast.List:
return r.list(n, width)
case *ast.FencedCodeBlock, *ast.CodeBlock:
var out []docLine
lines := n.Lines()
for i := range lines.Len() {
seg := lines.At(i)
line := printable(strings.TrimRight(string(seg.Value(r.src)), "\r\n"))
out = append(out, docLine{segs: trimSegs([]docSeg{{" ", docPlain, -1}, {line, docCode, -1}})})
}
return out
case *ast.Blockquote:
inner := r.blocks(n, max(width-2, 1), false)
for i := range inner {
inner[i].segs = append([]docSeg{{"│ ", docDecoration, -1}}, inner[i].segs...)
}
return inner
case *ast.ThematicBreak:
return []docLine{{segs: []docSeg{{strings.Repeat("─", width), docDecoration, -1}}}}
case *east.Table:
return r.table(n, width)
}
return nil // HTML
}
// bullets are the marks of the items of unordered lists, by depth.
var bullets = []string{"•", "◦", "▪"}
// list renders the items of n, each after its bullet or number, its further
// lines indented to its text.
func (r *mdRenderer) list(n *ast.List, width int) []docLine {
var marks []string
for c, i := n.FirstChild(), 0; c != nil; c, i = c.NextSibling(), i+1 {
if n.IsOrdered() {
marks = append(marks, fmt.Sprintf("%d.", n.Start+i))
} else {
marks = append(marks, bullets[min(r.depth, len(bullets)-1)])
}
}
markW := 0
for _, m := range marks {
markW = max(markW, ansi.StringWidth(m))
}
indent := markW + 1
r.depth++
defer func() { r.depth-- }()
var out []docLine
for c, i := n.FirstChild(), 0; c != nil; c, i = c.NextSibling(), i+1 {
if i > 0 && !n.IsTight {
out = append(out, docLine{})
}
lines := r.blocks(c, max(width-indent, 1), n.IsTight)
if len(lines) == 0 {
lines = []docLine{{}}
}
for j, l := range lines {
lead := strings.Repeat(" ", indent)
style := docPlain
if j == 0 {
lead, style = fmt.Sprintf("%*s ", markW, marks[i]), docDecoration
}
if len(l.segs) == 0 && j > 0 {
out = append(out, l) // an empty line stays empty
continue
}
l.segs = append([]docSeg{{lead, style, -1}}, l.segs...)
out = append(out, l)
}
}
return out
}
// table renders t with its columns aligned between bars, the header bold
// and ruled off. When it does not fit, the widest column is narrowed and its
// cells wrapped, down to tableMinColumn; beyond that the lines are cut.
func (r *mdRenderer) table(t *east.Table, width int) []docLine {
var rows [][][]docSeg
for row := t.FirstChild(); row != nil; row = row.NextSibling() {
style := docPlain
if _, ok := row.(*east.TableHeader); ok {
style = docBold
}
var cells [][]docSeg
for cell := row.FirstChild(); cell != nil; cell = cell.NextSibling() {
cells = append(cells, r.inline(cell, style))
}
rows = append(rows, cells)
}
if len(rows) == 0 {
return nil
}
cols := len(t.Alignments)
widths := make([]int, cols)
for _, cells := range rows {
for i, c := range cells {
if i < cols {
for _, l := range wrapSegs(c, 1<<20) {
widths[i] = max(widths[i], segsWidth(l.segs))
}
}
}
}
const gap = 3 // " │ "
total := func() int {
sum := gap * (cols - 1)
for _, w := range widths {
sum += w
}
return sum
}
for total() > width {
widest := 0
for i, w := range widths {
if w > widths[widest] {
widest = i
}
}
if widths[widest] <= tableMinColumn {
break
}
widths[widest]--
}
bar := docSeg{" │ ", docDecoration, -1}
var out []docLine
for ri, cells := range rows {
wrapped := make([][]docLine, cols)
height := 1
for i := range cols {
if i < len(cells) {
wrapped[i] = wrapSegs(cells[i], widths[i])
}
height = max(height, len(wrapped[i]))
}
for j := range height {
var segs []docSeg
for i := range cols {
if i > 0 {
segs = append(segs, bar)
}
var cell []docSeg
if j < len(wrapped[i]) {
cell = wrapped[i][j].segs
}
segs = append(segs, alignSegs(cell, widths[i], t.Alignments[i])...)
}
out = append(out, docLine{segs: trimSegs(segs)})
}
if ri == 0 {
var rule []string
for _, w := range widths {
rule = append(rule, strings.Repeat("─", w))
}
out = append(out, docLine{segs: []docSeg{{strings.Join(rule, "─┼─"), docDecoration, -1}}})
}
}
return out
}
// tableMinColumn is the narrowest a column of a table is wrapped to.
const tableMinColumn = 10
// alignSegs pads segs to width as align says: right, centered or left.
func alignSegs(segs []docSeg, width int, align east.Alignment) []docSeg {
pad := max(width-segsWidth(segs), 0)
left := 0
switch align {
case east.AlignRight:
left = pad
case east.AlignCenter:
left = pad / 2
}
out := []docSeg{{strings.Repeat(" ", left), docPlain, -1}}
out = append(out, segs...)
return append(out, docSeg{strings.Repeat(" ", pad-left), docPlain, -1})
}
// trimSegs drops the spaces at the end of a line.
func trimSegs(segs []docSeg) []docSeg {
for len(segs) > 0 {
last := &segs[len(segs)-1]
last.text = strings.TrimRight(last.text, " ")
if last.text != "" {
break
}
segs = segs[:len(segs)-1]
}
return segs
}
// anchor returns the name links give heading h after #, as GitHub makes it:
// lower case, spaces as -, without punctuation, numbered when it repeats.
func (r *mdRenderer) anchor(h *ast.Heading) string {
var b strings.Builder
for _, s := range r.inlineQuiet(h) {
for _, c := range strings.ToLower(s.text) {
switch {
case c == ' ':
b.WriteRune('-')
case c == '-' || c == '_' || unicode.IsLetter(c) || unicode.IsDigit(c):
b.WriteRune(c)
}
}
}
name := b.String()
if r.anchors == nil {
r.anchors = map[string]bool{}
}
for i := 1; r.anchors[name]; i++ {
name = fmt.Sprintf("%s-%d", b.String(), i)
}
r.anchors[name] = true
return name
}
// inlineQuiet returns the inline pieces of n without counting its links.
func (r *mdRenderer) inlineQuiet(n ast.Node) []docSeg {
links := len(r.links)
segs := r.inline(n, docPlain)
r.links = r.links[:links]
return segs
}
// inline returns the text of the inline children of n in style, as pieces;
// emphasis and code inside it take their own style, but in a heading or a
// link. A hard line break is a piece "\n".
func (r *mdRenderer) inline(n ast.Node, style docStyle) []docSeg {
var out []docSeg
r.inlineInto(n, style, -1, &out)
return out
}
func (r *mdRenderer) inlineInto(n ast.Node, style docStyle, link int, out *[]docSeg) {
inner := func(s docStyle) docStyle {
if style == docHeading || style == docLink {
return style
}
return s
}
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
switch c := c.(type) {
case *ast.Text:
*out = append(*out, docSeg{printable(string(c.Segment.Value(r.src))), style, link})
switch {
case c.HardLineBreak():
*out = append(*out, docSeg{"\n", style, link})
case c.SoftLineBreak():
*out = append(*out, docSeg{" ", style, link})
}
case *ast.String:
*out = append(*out, docSeg{printable(string(c.Value)), style, link})
case *ast.CodeSpan:
var b strings.Builder
for t := c.FirstChild(); t != nil; t = t.NextSibling() {
if t, ok := t.(*ast.Text); ok {
b.Write(t.Segment.Value(r.src))
}
}
*out = append(*out, docSeg{printable(b.String()), inner(docCode), link})
case *ast.Emphasis:
s := docItalic
if c.Level >= 2 {
s = docBold
}
r.inlineInto(c, inner(s), link, out)
case *ast.Link:
id, before := len(r.links), len(*out)
r.links = append(r.links, string(c.Destination))
r.inlineInto(c, docLink, id, out)
if len(*out) == before { // only an image: a badge
r.links = r.links[:id]
}
case *ast.AutoLink:
id := len(r.links)
r.links = append(r.links, string(c.URL(r.src)))
*out = append(*out, docSeg{printable(string(c.Label(r.src))), docLink, id})
case *ast.Image, *ast.RawHTML:
default:
r.inlineInto(c, style, link, out)
}
}
}
// segsWidth returns the width of segs in columns.
func segsWidth(segs []docSeg) int {
w := 0
for _, s := range segs {
w += ansi.StringWidth(s.text)
}
return w
}
// wordToken is a word of inline text, which may be made of pieces in several
// styles, and the space before it, in the style of the text it was in.
type wordToken struct {
space docSeg
parts []docSeg
brk bool // a hard line break
}
// wrapSegs fills lines of width columns with the words of segs, breaking
// at spaces; a word wider than a line is broken where the line ends.
func wrapSegs(segs []docSeg, width int) []docLine {
var words []wordToken
cur := wordToken{}
for _, s := range segs {
if s.text == "\n" {
words = append(words, cur, wordToken{brk: true})
cur = wordToken{}
continue
}
for i, part := range strings.Split(s.text, " ") {
if i > 0 {
words = append(words, cur)
cur = wordToken{space: docSeg{" ", s.style, s.link}}
}
if part != "" {
cur.parts = append(cur.parts, docSeg{part, s.style, s.link})
}
}
}
words = append(words, cur)
var out []docLine
var line []docSeg
lineW := 0
flush := func() {
out = append(out, docLine{segs: line})
line, lineW = nil, 0
}
for _, w := range words {
if w.brk {
flush()
continue
}
if len(w.parts) == 0 {
continue
}
ww := segsWidth(w.parts)
switch {
case lineW == 0:
case lineW+1+ww <= width:
line = append(line, w.space)
lineW++
default:
flush()
}
for ww > width-lineW {
// Only a word too long for a line of its own is broken.
if lineW > 0 {
flush()
continue
}
line = sliceSegs(w.parts, 0, width)
w.parts = sliceSegs(w.parts, width, ww)
ww -= width
flush()
}
line = append(line, w.parts...)
lineW += ww
}
if len(line) > 0 {
flush()
}
return out
}
// sliceSegs returns the columns from to to of segs.
func sliceSegs(segs []docSeg, from, to int) []docSeg {
var out []docSeg
col := 0
for _, s := range segs {
w := ansi.StringWidth(s.text)
if col+w > from && col < to {
s.text = ansi.Cut(s.text, max(from-col, 0), min(to-col, w))
out = append(out, s)
}
col += w
}
return out
}
// cutSegs returns segs cut to width columns, the last of them », when they
// are wider.
func cutSegs(segs []docSeg, width int) []docSeg {
if segsWidth(segs) <= width {
return segs
}
return append(sliceSegs(segs, 0, width-1), docSeg{"»", docOverflow, -1})
}
package ui
import (
"fmt"
"path/filepath"
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"jdiff/internal/config"
)
// markJumpMsg asks the file selector to show mark letter in another file of
// the diff, at screen row screenRow.
type markJumpMsg struct {
letter string
screenRow int
}
// isMarkLetter reports whether k names a mark: a letter a-z or A-Z.
func isMarkLetter(k string) bool {
if utf8.RuneCountInString(k) != 1 {
return false
}
c := k[0]
return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z'
}
// repoPath returns the path of file in marks: relative to the repository
// repo when inside it, else absolute.
func repoPath(repo, file string) string {
abs, err := filepath.Abs(file)
if err != nil {
return file
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
if repo != "" {
if rel, err := filepath.Rel(repo, abs); err == nil && filepath.IsLocal(rel) {
return filepath.ToSlash(rel)
}
}
return abs
}
// setMark sets mark letter on the new-file line of displayed row v of d and
// returns the message to show.
func setMark(cfg *config.Config, d *fileDiff, letter string, v int) string {
line := d.lineAt(right, v)
if line < 0 {
return "no line of the new file here"
}
m := config.Mark{Path: d.markPath, Line: line + 1, Text: strings.TrimSuffix(d.lines[right][line], "\r")}
if err := cfg.SetMark(letter, m); err != nil {
return "marks: " + err.Error()
}
return fmt.Sprintf("mark %s: %s:%d", letter, m.Path, m.Line)
}
// markLine returns the new-file line of mark m in d: its line when it still
// holds its text, else the nearest line that does, else its line number
// within the file, with a note. ok is false for an empty new file.
func (d *fileDiff) markLine(m config.Mark) (line int, note string, ok bool) {
lines := d.lines[right]
if len(lines) == 0 {
return 0, "", false
}
want := m.Line - 1
// Out from the old line until both directions left the file, which may
// have shrunk far below it.
for dist := 0; want-dist >= 0 || want+dist < len(lines); dist++ {
for _, i := range []int{want - dist, want + dist} {
// Without the \r of a \r\n line end, which may have changed.
if i >= 0 && i < len(lines) && strings.TrimSuffix(lines[i], "\r") == strings.TrimSuffix(m.Text, "\r") {
return i, "", true
}
}
}
return max(min(want, len(lines)-1), 0), "line changed", true
}
// reveal returns the cursor on line of side s of d in the screen lines of
// sh, unfolding the block of unchanged rows the line is in.
func (d *fileDiff) reveal(s side, line int, sh shape) spot {
row := d.rowOf[s][line]
for _, b := range d.blocks {
if row >= b.start && row < b.end && !d.blockOpen(b) {
d.foldOpen[d.rows[b.start].Left] = true
d.buildView()
break
}
}
return d.spotOf(s, line, sh)
}
// markMsg returns the message for having jumped to mark letter.
func markMsg(letter, note string) string {
if note != "" {
return "mark " + letter + ": " + note
}
return "mark " + letter
}
// notInDiff returns the message for mark letter m outside the diff shown.
func notInDiff(letter string, m config.Mark) string {
return fmt.Sprintf("mark %s: %s is not in this diff", letter, m.Path)
}
// MarksPopup wraps the top-level model and shows the jump marks of the
// repository in a popup over it on \: enter jumps to the selected mark, d or
// del deletes it.
type MarksPopup struct {
tea.Model
cfg *config.Config
open bool
listPopup
msg string // a problem saving the marks
}
// WithMarks returns m with the marks popup.
func WithMarks(cfg *config.Config, m tea.Model) *MarksPopup { return &MarksPopup{Model: m, cfg: cfg} }
func (p *MarksPopup) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width, p.height = msg.Width, msg.Height
p.move(0)
case tea.KeyPressMsg:
if cmd, ok := popupKeys(msg, p.open, "marks", p.key); ok {
return p, cmd
}
}
_, cmd := p.Model.Update(msg)
return p, cmd
}
// key handles k in the popup, or opens it on \\.
func (p *MarksPopup) key(k string) tea.Cmd {
if !p.open {
p.open, p.cursor, p.top, p.msg = true, 0, 0, ""
return nil
}
if isAction(k, "marks") {
p.open = false
return nil
}
marks := p.cfg.Marks()
switch k {
case "esc", "q":
p.open = false
case "ctrl+c":
return tea.Quit
case "down", "j":
p.move(1)
case "up", "k":
p.move(-1)
case "d", "delete":
if p.cursor < len(marks) {
if err := p.cfg.DeleteMark(marks[p.cursor].Letter); err != nil {
p.msg = "marks: " + err.Error()
}
p.move(0)
}
case "enter":
if p.cursor < len(marks) {
// Jump as the key of jump, ', and the letter do on the page.
p.open = false
p.Model.Update(runeKey(keyName(keyFile.row("jump").Keys[0])))
_, cmd := p.Model.Update(runeKey(marks[p.cursor].Letter))
return cmd
}
}
return nil
}
// boxHeight is the height of the popup.
func (p *MarksPopup) boxHeight() int { return popupHeight(len(p.cfg.Marks()), p.height) }
func (p *MarksPopup) move(delta int) { p.moveIn(delta, len(p.cfg.Marks())) }
func (p *MarksPopup) View() tea.View { return tea.NewView(p.frame()) }
func (p *MarksPopup) frame() string {
base := p.Model.View().Content
if !p.open || p.width == 0 {
return base
}
t := p.cfg.Theme
marks := p.cfg.Marks()
all := make([]string, len(marks))
for i, mk := range marks {
all[i] = fmt.Sprintf(" %s %s:%d %s ", mk.Letter, printable(mk.Path), mk.Line, printable(strings.TrimSpace(mk.Text)))
}
title := popupTitle(t, "marks", p.msg, "enter jump · d delete · esc close")
return p.draw(t, base, title, all, "no marks: m and a letter sets one")
}
package ui
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"jdiff/internal/config"
)
// switchFileMsg asks the file selector to show the next (1) or previous (-1)
// file.
type switchFileMsg int
// switchCommitMsg asks the log to show the next (1) or previous (-1) commit
// in history.
type switchCommitMsg int
// backMsg asks the log to return to the commit list, or from its flow view
// to the file list of the commit; or the file selector to its list when it
// shows the flow view.
type backMsg struct{}
// flowAtMsg asks the log to show its flow view on the file with this index
// of the commit whose files it shows: enter in that file list in flow mode.
type flowAtMsg int
// jumpMsg asks the file selector to show the line of the other end of moved
// code in another file: end, in the file of the group with index end.file,
// at screen row screenRow, with message msg.
type jumpMsg struct {
end moveEnd
screenRow int
msg string
}
// refreshMsg asks the top-level view to load what it shows again: run git
// diff or git log again, list the directories again or read the two files.
// With quiet, as auto reload asks, it does not say when nothing changed.
type refreshMsg struct{ quiet bool }
func refreshAll() tea.Msg { return refreshMsg{} }
// nothingChanged is the message after loading again the same.
const nothingChanged = "nothing changed"
// askAutoReloadMsg asks DiffSwitch to toggle auto reload.
type askAutoReloadMsg struct{}
func askAutoReload() tea.Msg { return askAutoReloadMsg{} }
// showMsg asks the top-level view to show a message in its status bar.
type showMsg string
// refreshed returns the message after loading again: what was added,
// removed and changed, and the anchors dropped from changed files.
func refreshed(added, gone, changed, dropped int) string {
var parts []string
for _, c := range []struct {
n int
what string
}{{added, "new"}, {gone, "gone"}, {changed, "changed"}} {
if c.n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", c.n, c.what))
}
}
switch {
case dropped == 1:
parts = append(parts, "1 anchor dropped")
case dropped > 1:
parts = append(parts, fmt.Sprintf("%d anchors dropped", dropped))
}
if len(parts) == 0 {
return nothingChanged
}
return strings.Join(parts, ", ")
}
// askDiffMsg asks DiffSwitch for the arguments of git diff to show next.
type askDiffMsg struct{}
func askDiff() tea.Msg { return askDiffMsg{} }
// askSettingsMsg asks SettingsPopup to open.
type askSettingsMsg struct{}
func askSettings() tea.Msg { return askSettingsMsg{} }
// toggleSettingMsg asks SettingsPopup to toggle the option of action, with the
// popup closed.
type toggleSettingMsg struct{ action string }
// settingKey returns the command toggling the option of action, nil when no
// option has it or, outside the log's flow view (logFlow), one that only
// matters there.
func settingKey(action string, logFlow bool) tea.Cmd {
i := slices.IndexFunc(settingOptions, func(o settingOption) bool { return o.key != "" && o.action == action })
if i < 0 || settingOptions[i].logFlow && !logFlow {
return nil
}
return func() tea.Msg { return toggleSettingMsg{action} }
}
// pageKey returns the command of key h that all pages hand on: loading again,
// auto reload, the popups of git diff and the settings, or the option of its
// action, see settingKey; nil when h is none of them. The pages hand them on,
// and not the popups themselves as the help and the marks: o is a mark letter
// too, which m and ' take before the page looks at the key.
func pageKey(h keyHit, logFlow bool) tea.Cmd {
switch h.action {
case "reload":
return refreshAll
case "auto_reload":
return askAutoReload
case "git_diff":
return askDiff
case "settings":
return askSettings
}
return settingKey(h.action, logFlow)
}
// settingChangedMsg tells the page below SettingsPopup that a toggle changed,
// so that it lays out what depends on it. The diff view does on any message.
// problem says why it is not saved, for the page to show when the popup is
// closed.
type settingChangedMsg struct{ action, problem string }
// toggleUntrackedMsg asks the top-level view to toggle the untracked files of
// its git diff, which only the file selector of one has.
type toggleUntrackedMsg struct{}
func toggleUntracked() tea.Msg { return toggleUntrackedMsg{} }
// untrackedOnlyInDiff is the answer of the other views.
const untrackedOnlyInDiff = "untracked files: only in jdiff diff"
// flowOnlyOfSeveral is the answer of the diff view of two files.
const flowOnlyOfSeveral = "flow: only with a file list or the log"
// toggleFlowMsg asks the log or the file selector to switch between the diff
// view of one file and the flow view of all files.
type toggleFlowMsg struct{}
func toggleFlow() tea.Msg { return toggleFlowMsg{} }
// firstLine returns the first line of err: git writes errors of several lines,
// where the status bar and notes have one.
func firstLine(err error) string {
line, _, _ := strings.Cut(err.Error(), "\n")
return line
}
// saveState writes the toggles to state.toml, returning a message when that
// fails, else "".
func saveState(cfg *config.Config) string {
if err := cfg.SaveState(); err != nil {
return "state: " + err.Error()
}
return ""
}
// The direction in history is decided here only: historyDir turns a key
// into it, historyOrder.step into a step through a list of commits.
// historyDir returns where key h of history goes: 1 to the next commit, for
// the first key of its row, as N and n, -1 to the previous one, as P and p.
func historyDir(h keyHit) int { return direction(h.i == 0) }
// historyOrder is the order of a list of commits, as git log lists them:
// the step from a commit to the next one in history, -1 when the newest
// comes first, as by default, 1 when the oldest does, with --reverse.
type historyOrder int
// orderOf returns the order of a list of commits, the oldest first or not.
func orderOf(oldestFirst bool) historyOrder {
if oldestFirst {
return 1
}
return -1
}
// step returns the step through the list to go in direction dir in history.
func (o historyOrder) step(dir int) int { return dir * int(o) }
// switchCommit returns the command asking the log for the commit key h goes
// to in history.
func switchCommit(h keyHit) tea.Cmd {
dir := switchCommitMsg(historyDir(h))
return func() tea.Msg { return dir }
}
// toggle flips setting, one of the toggles of cfg, and saves them, returning
// a message when that fails, else "".
func toggle(cfg *config.Config, setting *bool) string {
*setting = !*setting
return saveState(cfg)
}
// runeKey returns the key press typing the character s.
func runeKey(s string) tea.KeyPressMsg {
r, _ := utf8.DecodeRuneInString(s)
return tea.KeyPressMsg{Code: r, Text: s}
}
// motion returns how many rows the cursor moves on key h, page rows for a
// page and far to the first or last row, whether h pages, so that views of
// file content move the window along, and whether h moves the cursor at all.
func motion(h keyHit, page, far int) (delta int, paging, ok bool) {
switch h.action {
case "move":
return direction(h.i == 0), false, true
case "page":
return direction(h.i == 1) * page, true, true
case "ends":
return direction(h.i == 1) * far, false, true
}
return 0, false, false
}
func direction(forward bool) int {
if forward {
return 1
}
return -1
}
// ends returns the message for having reached the last or first item.
func ends(forward bool, last, first string) string {
if forward {
return last
}
return first
}
// Package ui implements the interactive side-by-side diff view.
package ui
import (
"slices"
tea "charm.land/bubbletea/v2"
"jdiff/internal/config"
"jdiff/internal/diff"
"jdiff/internal/notify"
"jdiff/internal/source"
)
// Model is the Bubble Tea model of the diff view: a single file view of a
// fileDiff, which other views may show and change too.
type Model struct {
*fileDiff
screen []sline // screen lines of the displayed rows
screenAt map[sline]int // screen line -> its index in screen
screenShape shape // what screen was built for
// The displayed rows screen was built for, to find the cursor's line
// again when another view or a reload changed them.
screenChanges int
screenReloads int
screenView []vrow
screenRows []diff.Row
cursor int // index into view
sub int // line of the cursor row in the normal layout
active side
top int // first screen line
xoff int // horizontal scroll in columns
width, height int
canBack bool // shown from the file selector, which handles backspace
commitNav bool // shown from the log, which switches commits
msg string
watch *source.File // the files on disk, reloaded when they change; nil when not watched
notify *notify.Watcher // reports their changes, nil while they are checked every watch_interval
prefix string // "m" or "'" while waiting for the letter of a mark
}
// New returns a model comparing oldLines (left) with newLines (right).
func New(cfg *config.Config, oldName, newName string, oldLines, newLines []string) *Model {
d := newFileDiff(cfg, [2]string{oldName, newName}, [2][]string{oldLines, newLines})
d.markPath = newName
(&moveGroup{}).add(d, d.names)
return newModel(d)
}
// NewWatched returns a model comparing the sides of f, which it reloads
// when they change on disk (see watch_interval).
func NewWatched(cfg *config.Config, f source.File) *Model {
m := New(cfg, f.OldFile, f.NewFile, f.Old, f.New)
m.watch = &f
m.markPath = repoPath(cfg.Repo(), f.NewFile)
return m
}
// newModel returns the single file view of d.
func newModel(d *fileDiff) *Model {
m := &Model{fileDiff: d}
m.buildScreen()
if slices.Equal(d.lines[left], d.lines[right]) {
m.msg = "files are identical"
}
if w := d.cfg.Warning(); w != "" {
m.msg = w
}
return m
}
// Init starts watching the files, when they are on disk.
func (m *Model) Init() tea.Cmd {
if m.watch == nil {
return nil
}
w, cmd, msg := startWatching(m.cfg, m, diskPaths(*m.watch))
m.notify = w
if msg != "" {
m.msg = msg
}
return cmd
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.syncLayout()
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.keepScreenRow(func() {
m.width, m.height = msg.Width, msg.Height
m.syncLayout() // wrapped lines take other pieces
})
case tea.KeyPressMsg:
return m, m.key(msg.String())
case watchTickMsg:
if msg.owner != nil && msg.owner != m {
return m, nil
}
if msg := refreshFile(m.watch, m.fileDiff, m.syncLayout); msg != "" {
m.msg = msg
}
return m, watchTick(m.cfg, m)
case changedMsg:
if msg.w != m.notify {
return m, nil
}
if msg := refreshFile(m.watch, m.fileDiff, m.syncLayout); msg != "" {
m.msg = msg
}
return m, waitChanges(m.notify)
case refreshMsg:
m.refresh(msg.quiet)
case showMsg:
m.msg = string(msg)
case toggleUntrackedMsg:
m.msg = untrackedOnlyInDiff
case toggleFlowMsg:
m.msg = flowOnlyOfSeveral
case settingChangedMsg:
if msg.problem != "" {
m.msg = msg.problem
}
case findMsg:
m.findText(int(msg))
}
return m, nil
}
// stop stops watching the files: the view is replaced.
func (m *Model) stop() {
if m.notify != nil {
m.notify.Close()
}
}
// refresh reads both files again, whether their stamps changed or not. With
// quiet, it does not say when nothing changed.
func (m *Model) refresh(quiet bool) {
if m.watch == nil {
m.msg = "nothing to load again"
return
}
m.watch.OldStamp, m.watch.NewStamp = source.Stamp{}, source.Stamp{}
switch msg := refreshFile(m.watch, m.fileDiff, m.syncLayout); {
case msg != "":
m.msg = msg
case !quiet:
m.msg = nothingChanged
}
}
func (m *Model) key(k string) tea.Cmd {
m.msg = ""
if p := m.prefix; p != "" {
m.prefix = ""
switch {
case !isMarkLetter(k):
case p == "set_mark":
m.msg = setMark(m.cfg, m.fileDiff, k, m.cursor)
default:
return m.gotoMark(k, m.pos()-m.top)
}
return nil
}
h := keyFile.key(diffView, k)
if m.replace != nil && h.action != "anchor" && h.action != "cancel_mark" {
m.replace = nil
}
page := max(m.bodyHeight()-1, 1)
if delta, paging, ok := motion(h, page, len(m.screen)); ok {
if paging {
m.scroll(delta)
} else {
m.moveCursor(delta)
}
return nil
}
switch h.action {
case "quit":
return tea.Quit
case "pane":
m.active = m.active.other()
case "half_page":
m.scroll(direction(h.i == 1) * (page / 2))
case "scroll":
if h.i == 0 {
m.xoff = max(m.xoff-8, 0)
} else {
m.xoff += 8
}
case "change":
m.nextChange(direction(h.i == 0))
case "next_file":
if m.canBack {
dir := switchFileMsg(direction(h.i == 0))
return func() tea.Msg { return dir }
}
case "history":
if m.commitNav {
return switchCommit(h)
}
case "search":
return askSearch
case "match":
m.findText(direction(h.i == 0))
case "flow":
if m.canBack {
return toggleFlow
}
default:
return pageKey(h, false)
case "anchor", "cancel_mark", "fold", "fold_all":
m.act(h.action)
case "moved":
return m.jump()
case "set_mark":
m.prefix, m.msg = h.action, "set mark: press a letter"
case "jump":
m.prefix, m.msg = h.action, "jump to mark: press its letter"
}
return nil
}
// gotoMark moves the cursor to the line of mark letter, at screen row
// screenRow. A mark in another file of the diff is shown by the file
// selector.
func (m *Model) gotoMark(letter string, screenRow int) tea.Cmd {
mk, ok := m.cfg.Mark(letter)
switch {
case !ok:
m.msg = "no mark " + letter
case mk.Path == m.markPath:
if at, ok := m.markSpot(letter, mk); ok {
m.jumpTo(at)
}
case m.canBack:
j := markJumpMsg{letter, screenRow}
return func() tea.Msg { return j }
default:
m.msg = notInDiff(letter, mk)
}
return nil
}
// markSpot returns the cursor on the line of mark mk, called letter, of
// this file, unfolding it, and sets the message. ok is false for an empty
// new file.
func (m *Model) markSpot(letter string, mk config.Mark) (at spot, ok bool) {
line, note, ok := m.markLine(mk)
if !ok {
m.msg = markMsg(letter, "the new file is empty")
return spot{}, false
}
m.msg = markMsg(letter, note)
return m.reveal(right, line, m.shape()), true
}
// jump moves the cursor to the other end of the moved line under it (see
// jumpTo). An end in another file is shown by the file selector, at the
// cursor's screen row.
func (m *Model) jump() tea.Cmd {
end, msg, ok := m.otherEnd(spot{m.cursor, m.sub, m.active}, m.screenShape)
if !ok {
m.msg = msg
return nil
}
if end.file != m.groupIndex {
j := jumpMsg{end, m.pos() - m.top, msg}
return func() tea.Msg { return j }
}
m.jumpTo(m.spotOf(end.side, end.line, m.screenShape))
m.msg = msg
return nil
}
// jumpTo puts the cursor on at, for a jump within this view (g, '): the
// screen stays when at is on it, see jumpTop.
func (m *Model) jumpTo(at spot) {
m.syncLayout() // a fold opened for at: the cursor keeps its line
from := m.pos()
m.active, m.cursor, m.sub = at.side, at.v, at.sub
m.top = jumpTop(from, m.pos(), m.top, m.bodyHeight(), len(m.screen))
}
// show puts the cursor on at, at screen row screenRow.
func (m *Model) show(at spot, screenRow int) {
m.active = at.side
m.place(at.v, at.sub, screenRow)
}
// act runs an anchor or fold action on the diff at the cursor, which stays on
// its screen row.
func (m *Model) act(action string) {
var msg string
m.keepScreenRow(func() {
changes := m.changes
var at spot
at, msg = m.fileDiff.act(action, spot{m.cursor, m.sub, m.active}, m.screenShape)
if m.changes != changes {
m.buildScreen()
}
m.cursor, m.sub, m.active = at.v, at.sub, at.side
})
if msg != "" {
m.msg = msg
}
}
// realign recomputes the diff, after its settings changed.
func (m *Model) realign() {
m.align()
m.buildScreen()
m.cursor = min(m.cursor, max(len(m.view)-1, 0))
}
// shape returns the shape of the screen lines for the current layout and
// width.
func (m *Model) shape() shape { return newShape(m.cfg, m.width) }
// buildScreen computes the screen lines of the displayed rows for the
// current layout and width.
func (m *Model) buildScreen() {
m.screenShape = m.shape()
m.screen, m.screenAt = m.screenLines(m.screenShape)
m.screenChanges, m.screenReloads, m.screenView, m.screenRows = m.changes, m.reloads, m.view, m.rows
}
// syncLayout rebuilds the screen lines when another view or a reload changed
// the diff, keeping the cursor on its line, or when the layout was switched,
// here or in another view, or wrapped lines need other pieces, keeping the
// cursor on its line, piece and screen row.
func (m *Model) syncLayout() {
switch {
case m.screenChanges != m.changes:
m.cursor = m.cursorAfterChange()
m.buildScreen()
m.scrollToCursor()
case m.screenShape != m.shape():
from := m.screenShape
m.keepScreenRow(func() {
m.buildScreen()
if m.cursor < len(m.view) {
m.sub = m.convertSub(m.cursor, m.sub, from, m.screenShape, m.active)
}
})
}
}
// cursorAfterChange returns the displayed row that shows the line of the
// cursor's row in the rows screen was built for: its old line, else its new
// one, followed through a reload since.
func (m *Model) cursorAfterChange() int {
if m.cursor >= len(m.screenView) || len(m.view) == 0 {
return 0
}
if v := m.follow(m.screenRows[m.screenView[m.cursor].row], m.screenReloads != m.reloads); v >= 0 {
return v
}
return min(m.cursor, len(m.view)-1)
}
// pos returns the screen line of the cursor.
func (m *Model) pos() int {
if m.cursor >= len(m.view) {
return 0
}
return m.screenAt[sline{m.cursor, min(m.sub, m.rowLines(m.cursor, m.screenShape)-1)}]
}
// place puts the cursor on line sub of displayed row v at screen row
// screenRow.
func (m *Model) place(v, sub, screenRow int) {
m.syncLayout()
m.cursor, m.sub = v, sub
m.top = m.pos() - screenRow
m.scrollToCursor()
}
// takeOver continues where flow f leaves this file: with its pane and
// scrolling, and, unless line is -1, its cursor on diff row line at screen
// row screenRow.
func (m *Model) takeOver(f *Flow, line, screenRow int) {
m.active, m.xoff = f.active, f.xoff
if line >= 0 {
m.place(line, f.cursorSub(), screenRow)
}
}
// moveCursor moves the cursor by delta screen lines.
func (m *Model) moveCursor(delta int) {
if len(m.screen) == 0 {
return
}
s := m.screen[max(min(m.pos()+delta, len(m.screen)-1), 0)]
m.cursor, m.sub = s.v, s.sub
m.scrollToCursor()
}
// scroll moves the window by delta screen lines, as far as it can, and the
// cursor along, so that the cursor keeps its screen row.
func (m *Model) scroll(delta int) {
if len(m.screen) == 0 {
return
}
top := m.top
m.top = max(min(m.top+delta, len(m.screen)-m.bodyHeight()), 0)
m.moveCursor(m.top - top)
}
// nextChange moves the cursor to the first line of the next (dir > 0) or
// previous block of changed rows.
func (m *Model) nextChange(dir int) {
for i := m.pos() + dir; i >= 0 && i < len(m.screen); i += dir {
if m.blockStart(m.screen, i) {
m.cursor, m.sub = m.screen[i].v, m.screen[i].sub
m.scrollToCursor()
return
}
}
m.msg = "no more changes"
}
func (m *Model) bodyHeight() int { return max(m.height-2, 1) }
// keepScreenRow runs f, which may rebuild the view, move the cursor or
// resize, and then scrolls so that the cursor stays on the same screen row.
// Near the start or end of the file it moves as little as possible.
func (m *Model) keepScreenRow(f func()) {
offset := m.pos() - m.top
f()
m.top = m.pos() - offset
m.scrollToCursor()
}
func (m *Model) scrollToCursor() {
m.top = scrollWindow(m.pos(), m.top, m.bodyHeight(), len(m.screen))
}
package ui
import (
"fmt"
"jdiff/internal/diff"
)
// moveGroup is the files searched together for moved code: the files of one
// commit or of a comparison of two directories, or the one file of a
// comparison of two files. Never files of different commits.
type moveGroup struct {
diffs []*fileDiff
names [][2]string // the names of the sides of the files, for messages
aligns []int // the alignments the moves were found for
moves diff.Moves
}
// add adds d, whose sides are called names, to the group.
func (g *moveGroup) add(d *fileDiff, names [2]string) {
d.group, d.groupIndex = g, len(g.diffs)
g.diffs = append(g.diffs, d)
g.names = append(g.names, names)
g.aligns = nil
}
// ensure finds the moves again when a diff of the group was aligned anew,
// after an anchor was added or removed or the file was reloaded.
func (g *moveGroup) ensure() {
fresh := len(g.aligns) == len(g.diffs)
for i, d := range g.diffs {
fresh = fresh && g.aligns[i] == d.aligns
}
if fresh {
return
}
in := make([]diff.MoveInput, len(g.diffs))
g.aligns = make([]int, len(g.diffs))
for i, d := range g.diffs {
in[i] = diff.MoveInput{Old: d.lines[left], New: d.lines[right], Rows: d.rows}
g.aligns[i] = d.aligns
}
g.moves = diff.FindMoves(in)
}
// movedTo returns where line of side s of d moved: for a removed line the
// line it moved to, for an added line the line it came from.
func (d *fileDiff) movedTo(s side, line int) (diff.Ref, bool) {
if d.group == nil || line < 0 {
return diff.Ref{}, false
}
d.group.ensure()
moves := d.group.moves.To
if s == left {
moves = d.group.moves.From
}
r, ok := moves[d.groupIndex][line]
return r, ok
}
// moveEnd is the other end of a moved line: line of side side of the file
// at index file of the group.
type moveEnd struct {
file int
side side
line int
}
// otherEnd returns the other end of the moved line under the cursor at, in
// the screen lines of sh, and the message to show there, which says where
// the cursor came from: side by side the line of the active pane, else the
// one beside it. ok is false when neither moved.
func (d *fileDiff) otherEnd(at spot, sh shape) (end moveEnd, msg string, ok bool) {
s := at.side
if sh.unified {
s = d.unifiedSide(at, sh)
}
line := d.lineAt(s, at.v)
r, ok := d.movedTo(s, line)
if !ok && !sh.unified {
// Side by side the pane is for anchors: a moved line has no line
// beside it, so the cursor may be in either pane.
s = s.other()
line = d.lineAt(s, at.v)
r, ok = d.movedTo(s, line)
}
if !ok {
return moveEnd{}, "not moved", false
}
how := "moved from"
if s == right {
how = "moved to"
}
return moveEnd{r.File, s.other(), r.Line}, fmt.Sprintf("%s %s:%d", how, d.group.names[d.groupIndex][s], line+1), true
}
// spotOf returns the cursor on the start of line of side s of d in the
// screen lines of sh (see firstSub).
func (d *fileDiff) spotOf(s side, line int, sh shape) spot {
v := d.viewOf[d.rowOf[s][line]]
return spot{v, d.firstSub(v, s, sh), s}
}
package ui
import (
"cmp"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/diff"
"jdiff/internal/settings"
)
// askSearchMsg asks SearchBar for the query to search for.
type askSearchMsg struct{}
func askSearch() tea.Msg { return askSearchMsg{} }
// findMsg asks the view shown to search for the query of config.Search from
// its cursor on, forward (1) or backward (-1).
type findMsg int
// matcher finds a query in lines. Case is ignored unless the query holds
// an upper case letter, also in a regular expression.
type matcher struct {
q config.Query
re *regexp.Regexp // for a regular expression
}
// compile returns the matcher of q, or why its regular expression does not
// compile.
func compile(q config.Query) (*matcher, error) {
m := &matcher{q: q}
if q.Regex {
src := q.Text
if !strings.ContainsFunc(src, unicode.IsUpper) {
src = "(?i)" + src
}
re, err := regexp.Compile(src)
if err != nil {
return nil, err
}
m.re = re
}
return m, nil
}
// compiled is the matcher used last: lines are drawn one at a time, all
// with the same query.
var compiled struct {
sync.Mutex
q config.Query
m *matcher
}
// matcherOf returns the matcher of q, nil when q is empty or its regular
// expression does not compile.
func matcherOf(q config.Query) *matcher {
if q.Text == "" {
return nil
}
compiled.Lock()
defer compiled.Unlock()
if compiled.m == nil || compiled.q != q {
compiled.q = q
compiled.m, _ = compile(q)
}
return compiled.m
}
// spans returns where m's query is found in text, without overlaps; empty
// matches of a regular expression are left out.
func (m *matcher) spans(text string) []diff.Span {
var out []diff.Span
if m.re != nil {
for _, loc := range m.re.FindAllStringIndex(text, -1) {
if loc[1] > loc[0] {
out = append(out, diff.Span{Start: loc[0], End: loc[1]})
}
}
return out
}
q := m.q.Text
exact := strings.ContainsFunc(q, unicode.IsUpper)
for i := 0; i+len(q) <= len(text); {
part := text[i : i+len(q)]
if exact && part == q || !exact && strings.EqualFold(part, q) {
out = append(out, diff.Span{Start: i, End: i + len(q)})
i += len(q)
continue
}
_, n := utf8.DecodeRuneInString(text[i:])
i += n
}
return out
}
// hit is a line found by /: side's line of diff row row.
type hit struct {
row int
side side
}
// order is where h stands among the lines of its diff: by row, the left
// line first.
func (h hit) order() int { return 2*h.row + int(h.side) }
// hits returns the lines of d that m finds something in, in order: only
// the changed ones unless all is set.
func (d *fileDiff) hits(m *matcher, all bool) []hit {
var out []hit
for i, r := range d.rows {
if !all && r.Kind == diff.Equal {
continue
}
for _, s := range []side{left, right} {
if line := lineOf(r, s); line >= 0 && m.spans(d.lines[s][line]) != nil {
out = append(out, hit{i, s})
}
}
}
return out
}
// nextHit returns the index of the hit to go to in direction dir among n
// hits in order, which compare compares to the cursor: forward the first
// after it, backward the last before it, so that the cursor's own line is
// passed; false past the last or before the first, as the search does not
// wrap around.
func nextHit(n, dir int, compare func(i int) int) (int, bool) {
before := func(i int) bool {
if dir > 0 {
return compare(i) <= 0
}
return compare(i) < 0
}
after := 0 // the first hit not before the cursor
for after < n && before(after) {
after++
}
if dir > 0 {
return after, after < n
}
return after - 1, after > 0
}
// showMatch returns the horizontal scroll that shows the first match of m
// in the line of side s of d, in a view viewWidth columns wide, scrolled by
// xoff: xoff when lines are wrapped or the match is in view, else in steps of
// 8 columns, as h and l scroll, the match a step from the left edge.
func (d *fileDiff) showMatch(m *matcher, s side, line, xoff, viewWidth int) int {
spans := m.spans(d.lines[s][line])
if d.cfg.Settings.Wrap || len(spans) == 0 {
return xoff
}
width := d.contentWidth(viewWidth, !d.cfg.Settings.SideBySide)
text := d.lines[s][line]
from := ansi.StringWidth(printable(text[:spans[0].Start]))
to := from + ansi.StringWidth(printable(text[spans[0].Start:spans[0].End]))
if from >= xoff && to <= xoff+width-1 { // the last column may hold »
return xoff
}
return max(from-8, 0) / 8 * 8
}
// nothingSearched is the message of . and , before the first search.
const nothingSearched = "nothing searched yet: / searches"
// searchMsg returns the status message of match i of n for q.
func searchMsg(q config.Query, i, n int) string {
return queryName(q) + fmt.Sprintf(": match %d/%d · . next · , previous", i+1, n)
}
// queryName returns q as the status line names it.
func queryName(q config.Query) string {
if q.Regex {
return "regex /" + q.Text
}
return "/" + q.Text
}
// noFurther returns the status message when no match of q lies further in
// direction dir: the cursor stays.
func noFurther(q config.Query, dir int, where string) string {
return queryName(q) + ": " + ends(dir > 0, "no match further down", "no match further up") + strings.Replace(where, " of ", " in ", 1)
}
// scope returns where / searches, as messages name it.
func scope(st settings.Settings) string {
if st.SearchAll {
return "all lines"
}
return "changed lines"
}
// findText moves the cursor to the next line, in direction dir, that holds
// what config.Search searched for, past the cursor's own line; past the
// last or first match it stays. Without wrapping lines, it scrolls the
// match into view.
func (m *Model) findText(dir int) {
q := m.cfg.Search.Query
mt := matcherOf(q)
if mt == nil {
m.msg = nothingSearched
return
}
hits := m.hits(mt, m.cfg.Settings.SearchAll)
if len(hits) == 0 {
m.msg = "no match for " + q.Text + " in " + scope(m.cfg.Settings)
return
}
m.syncLayout()
pos := hit{0, m.active}
if m.cursor < len(m.view) {
pos.row = m.view[m.cursor].row
}
i, ok := nextHit(len(hits), dir, func(i int) int { return cmp.Compare(hits[i].order(), pos.order()) })
if !ok {
m.msg = noFurther(q, dir, "")
return
}
h := hits[i]
line := lineOf(m.rows[h.row], h.side)
m.jumpTo(m.reveal(h.side, line, m.shape()))
m.xoff = m.showMatch(mt, h.side, line, m.xoff, m.width)
m.msg = searchMsg(q, i, len(hits))
}
// flowHit is a line found by / in the flow view: hit in file of part.
type flowHit struct {
part, file int
hit
}
// findText moves the cursor to the next line, in direction dir, that holds
// what config.Search searched for, past the cursor's own line, in all
// files; in the log in those of the cursor's commit, N and P go to the
// others. Past the last or first match it stays. Without wrapping lines, it
// scrolls the match into view.
func (f *Flow) findText(dir int) {
q := f.cfg.Search.Query
mt := matcherOf(q)
if mt == nil {
f.msg = nothingSearched
return
}
cur := 0 // the cursor's part
if f.cursor < len(f.rows) {
cur = f.rows[f.cursor].part
}
where := ""
if f.inLog() {
where = " of this commit"
}
var hits []flowHit
for p, part := range f.parts {
if part.app == nil || f.inLog() && p != cur {
continue
}
for i, e := range part.app.files {
if e.Binary {
continue
}
for _, h := range part.app.diffOf(e).hits(mt, f.cfg.Settings.SearchAll) {
hits = append(hits, flowHit{p, i, h})
}
}
}
if len(hits) == 0 {
f.msg = "no match for " + q.Text + " in " + scope(f.cfg.Settings) + where
return
}
// The cursor's position: a commit's or file's own rows come before the
// lines of its files.
pos := flowHit{hit: hit{-1, f.active}}
if f.cursor < len(f.rows) {
r := f.rows[f.cursor]
pos.part, pos.file = r.part, r.file
if r.kind == flowDiff {
pos.row = f.parts[r.part].app.files[r.file].diff.view[r.line].row
}
}
i, ok := nextHit(len(hits), dir, func(i int) int {
a := hits[i]
return cmp.Or(cmp.Compare(a.part, pos.part), cmp.Compare(a.file, pos.file), cmp.Compare(a.order(), pos.order()))
})
if !ok {
f.msg = noFurther(q, dir, where)
return
}
h := hits[i]
d := f.parts[h.part].app.files[h.file].diff
line := lineOf(d.rows[h.row], h.side)
at := d.reveal(h.side, line, f.built)
f.relocate() // a fold may have opened: the cursor keeps its row
f.jumpTo(f.findDiff(h.part, h.file, at.v, at.sub))
f.active, f.msg = h.side, searchMsg(q, i, len(hits))
f.xoff = d.showMatch(mt, h.side, line, f.xoff, f.width)
}
// SearchBar wraps the view: / opens a bar at the bottom to type what to
// search for, in changed lines or, switched with tab, in all, plain text
// or, switched with shift+tab, a regular expression; both are toggles,
// kept in state.toml. Up and down go through what was searched for
// before, enter searches from the cursor on, as . does. What was searched
// for is kept in config.Search.
type SearchBar struct {
tea.Model
cfg *config.Config
input *lineInput // while the bar is open
regex bool // the text is a regular expression, as last searched or toggled
// hist is the entry of config.Search.History shown, its length for the
// text typed, draft, kept while going through the history.
hist int
draft config.Query
err string // why the regular expression does not compile, or the state is not saved
width, height int
}
// WithSearch returns m with the search bar.
func WithSearch(cfg *config.Config, m tea.Model) *SearchBar { return &SearchBar{Model: m, cfg: cfg} }
func (s *SearchBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width, s.height = msg.Width, msg.Height
case askSearchMsg:
s.input, s.regex, s.err = &lineInput{}, s.cfg.Settings.SearchRegex, ""
s.hist = len(s.cfg.Search.History)
return s, nil
case tea.KeyPressMsg:
if s.input != nil {
return s, s.key(msg)
}
case tea.PasteMsg:
if s.input != nil {
s.err = ""
s.input.text += msg.Content
return s, nil
}
}
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(msg)
return s, cmd
}
// key edits the query: enter searches it, esc, enter on nothing typed or
// backspace on it closes the bar; tab switches between changed and all
// lines, shift+tab between plain text and regular expression; up and
// down go through the history, each query in its mode.
func (s *SearchBar) key(k tea.KeyPressMsg) tea.Cmd {
in, history, st := s.input, s.cfg.Search.History, &s.cfg.Settings
s.err = ""
show := func(q config.Query) { in.text, s.regex = q.Text, q.Regex }
switch {
case k.String() == "ctrl+c":
return tea.Quit
case in.cancels(k):
s.input = nil
return nil
}
switch k.String() {
case "enter":
q := config.Query{Text: in.text, Regex: s.regex}
if q.Text == "" {
s.input = nil
return nil
}
if _, err := compile(q); err != nil {
s.err = err.Error()
return nil
}
s.input = nil
s.cfg.Search.Add(q)
if st.SearchRegex != q.Regex { // from the history
st.SearchRegex = q.Regex
s.err = saveState(s.cfg)
}
return func() tea.Msg { return findMsg(1) }
case "tab":
s.err = toggle(s.cfg, &st.SearchAll)
case "shift+tab":
s.regex = !s.regex
st.SearchRegex = s.regex
s.err = saveState(s.cfg)
case "up":
if s.hist == len(history) {
s.draft = config.Query{Text: in.text, Regex: s.regex}
}
if s.hist > 0 {
s.hist--
show(history[s.hist])
}
case "down":
if s.hist < len(history) {
s.hist++
if s.hist == len(history) {
show(s.draft)
} else {
show(history[s.hist])
}
}
default:
in.edit(k)
}
return nil
}
// frame draws the bar over the view, above its status line, while it is
// open: the query, and why its regular expression does not compile, see
// inputBox.
func (s *SearchBar) View() tea.View { return tea.NewView(s.frame()) }
func (s *SearchBar) frame() string {
base := s.Model.View().Content
if s.input == nil || s.width == 0 {
return base
}
t := s.cfg.Theme
// The title says how the bar searches and what tab and shift+tab switch
// to.
mode, otherMode := "search", "regex"
if s.regex {
mode, otherMode = "regex", "text"
}
otherScope := "all"
if s.cfg.Settings.SearchAll {
otherScope = "changed"
}
keys := []string{"enter find", "tab " + otherScope, "shift+tab " + otherMode, "esc cancel"}
if len(s.cfg.Search.History) > 0 {
keys = slices.Insert(keys, 1, "↑↓ history")
}
title := popupTitle(t, mode+" "+scope(s.cfg.Settings), "", strings.Join(keys, " · "))
bar, w := inputBox(t, s.width, title, "/"+printable(s.input.text), s.err, false)
// Above the status line, the last row.
y := max(strings.Count(base, "\n")-strings.Count(bar, "\n"), 0)
return overlayAt(base, bar, max((s.width-w)/2, 0), y, w, s.width)
}
package ui
import (
"cmp"
_ "embed"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/BurntSushi/toml"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
)
// settingOption is a line of the settings popup: the key that toggles it
// there and on the pages, the action of its row in keybindings.toml, what it
// does while on, from that row, and the toggle. With ask, the page below
// toggles it, when it can. With logFlow, its key works only in the log's flow
// view, where it shows; in the popup always. With number instead of the toggle,
// it is a number, asked for in a line; it has no key, the cursor goes to it in
// the popup.
type settingOption struct {
key, action, what string
setting func(*config.Config) *bool
ask tea.Cmd
logFlow bool
number func(*config.Config) *int
}
var settingOptions = settingWords([]settingOption{
{action: "layout", setting: func(c *config.Config) *bool { return &c.Settings.SideBySide }},
{action: "flow", setting: func(c *config.Config) *bool { return &c.Settings.Flow }, ask: toggleFlow},
{action: "untracked", setting: func(c *config.Config) *bool { return &c.Settings.Untracked }, ask: toggleUntracked},
{action: "wrap", setting: func(c *config.Config) *bool { return &c.Settings.Wrap }},
{action: "whitespace", setting: func(c *config.Config) *bool { return &c.Settings.ShowWhitespaces }},
{action: "mark_whitespace", setting: func(c *config.Config) *bool { return &c.Settings.MarkWhitespace }},
{action: "trailing", setting: func(c *config.Config) *bool { return &c.Settings.ShowTrailing }},
{action: "line_ends", setting: func(c *config.Config) *bool { return &c.Settings.ShowCR }},
{action: "bold", setting: func(c *config.Config) *bool { return &c.Settings.BoldChanges }},
{action: "preview", setting: func(c *config.Config) *bool { return &c.Settings.Preview }},
{action: "full_messages", setting: func(c *config.Config) *bool { return &c.Settings.FullMessages }, logFlow: true},
{action: "stay_in_commit", setting: func(c *config.Config) *bool { return &c.Settings.StayInCommit }, logFlow: true},
{action: "auto_reload_timeout", number: func(c *config.Config) *int { return &c.Settings.AutoReloadTimeout }},
})
// settingWords returns options with their keys and what they do from
// keybindings.toml.
func settingWords(options []settingOption) []settingOption {
for i := range options {
r := keyFile.row(options[i].action)
if len(r.Keys) > 0 {
options[i].key = r.Keys[0]
}
options[i].what = keyFile.keyText(r.Setting)
}
return options
}
// keybindingsFile binds the keys of all pages to actions, gives their words and
// decides the order of the settings.
//
//go:embed keybindings.toml
var keybindingsFile string
// keyRow is a row of keybindings.toml.
type keyRow struct {
Action string
Keys []string
Label []string
Help string
Setting string
}
// helpRow is a line of the help popup in keybindings.toml.
type helpRow struct{ Heading, Note, Action string }
// keyPages holds keybindings.toml: the rows of each page and the help's lines.
type keyPages struct {
CommitList []keyRow `toml:"commit_list"`
FileList []keyRow `toml:"file_list"`
DiffView []keyRow `toml:"diff_view"`
FlowView []keyRow `toml:"flow_view"`
Everywhere []keyRow `toml:"everywhere"`
DocView []keyRow `toml:"doc_view"`
Help []helpRow `toml:"help"`
}
func readKeys(text string) (keyPages, error) {
var p keyPages
_, err := toml.Decode(text, &p)
return p, err
}
// keyFile is keybindingsFile, read. It is part of the program, so that failing
// to read it is a bug, which TestKeybindingsFile finds.
var keyFile = func() keyPages {
p, err := readKeys(keybindingsFile)
if err != nil {
panic(err)
}
return p
}()
// row returns the row that gives the keys and words of action; see
// keybindings.toml for the order. Without one, it has no keys.
func (p keyPages) row(action string) keyRow {
if rows := p.rowsOf(action); len(rows) > 0 {
return rows[0]
}
return keyRow{}
}
// rowsOf returns the rows of action in the first page that has it, in the
// order of row.
func (p keyPages) rowsOf(action string) []keyRow {
for _, rows := range [][]keyRow{p.Everywhere, p.DiffView, p.FlowView, p.CommitList, p.FileList, p.DocView} {
var of []keyRow
for _, r := range rows {
if r.Action == action {
of = append(of, r)
}
}
if len(of) > 0 {
return of
}
}
return nil
}
// placeholder matches {action} and {action:n} in the words of keybindings.toml.
var placeholder = regexp.MustCompile(`\{([a-z_]+)(?::([0-9]+))?\}`)
// keyText returns s with each placeholder replaced by the keys of its row, ""
// for a row that does not exist.
func (p keyPages) keyText(s string) string {
return placeholder.ReplaceAllStringFunc(s, func(m string) string {
sub := placeholder.FindStringSubmatch(m)
n, _ := strconv.Atoi(sub[2])
if rows := p.rowsOf(sub[1]); n < len(rows) {
return strings.Join(rows[n].Keys, " ")
}
return ""
})
}
// twoWay lists the actions whose rows take two keys, one for each way.
var twoWay = []string{"move", "page", "half_page", "ends", "scroll", "change", "next_file", "history", "match", "link", "line_link", "next_page"}
// userRow is a row of the user's keybindings.toml, which changes only keys.
type userRow struct {
Action string
Keys []string
}
// userKeys returns p with the keys of text, the user's keybindings.toml, and
// what is wrong with it. The rows of an action on a page replace all its rows
// there, keeping their words; a file with a problem changes nothing, so that
// a mistake cannot take away the keys to quit or get help.
func (p keyPages) userKeys(text string) (keyPages, []string) {
var u struct {
CommitList []userRow `toml:"commit_list"`
FileList []userRow `toml:"file_list"`
DiffView []userRow `toml:"diff_view"`
FlowView []userRow `toml:"flow_view"`
Everywhere []userRow `toml:"everywhere"`
DocView []userRow `toml:"doc_view"`
}
md, err := toml.Decode(text, &u)
if err != nil {
return p, []string{err.Error()}
}
var problems []string
for _, k := range md.Undecoded() {
problems = append(problems, "unknown "+k.String())
}
merged := p
for _, t := range []struct {
name string
rows *[]keyRow
user []userRow
}{
{"everywhere", &merged.Everywhere, u.Everywhere}, {"diff_view", &merged.DiffView, u.DiffView},
{"flow_view", &merged.FlowView, u.FlowView}, {"commit_list", &merged.CommitList, u.CommitList},
{"file_list", &merged.FileList, u.FileList}, {"doc_view", &merged.DocView, u.DocView},
} {
for _, r := range t.user {
switch {
case !slices.ContainsFunc(*t.rows, func(b keyRow) bool { return b.Action == r.Action }):
problems = append(problems, "no action "+r.Action+" in "+t.name)
case slices.Contains(twoWay, r.Action) && len(r.Keys) != 2:
problems = append(problems, r.Action+" in "+t.name+" takes two keys, one each way")
}
}
*t.rows = replaceKeys(*t.rows, t.user)
}
problems = append(problems, merged.conflicts()...)
if len(problems) > 0 {
return p, problems
}
return merged, nil
}
// replaceKeys returns rows with the rows of each action of user in place of
// its rows: the first where the first was, with the words of the row it
// replaces; rows of user beyond them have none.
func replaceKeys(rows []keyRow, user []userRow) []keyRow {
var out []keyRow
done := map[string]bool{}
for _, r := range rows {
if !slices.ContainsFunc(user, func(u userRow) bool { return u.Action == r.Action }) {
out = append(out, r)
continue
}
if done[r.Action] {
continue
}
done[r.Action] = true
var old []keyRow
for _, o := range rows {
if o.Action == r.Action {
old = append(old, o)
}
}
i := 0
for _, u := range user {
if u.Action != r.Action {
continue
}
row := keyRow{Action: u.Action}
if i < len(old) {
row = old[i]
}
row.Keys = u.Keys
out = append(out, row)
i++
}
}
return out
}
// KeysFile is the user's keybindings.toml in the configuration directory.
const KeysFile = "keybindings.toml"
// LoadKeys reads the user's keybindings.toml from the configuration directory
// of cfg, if there is one, over the built-in keys; a problem with it is a
// warning of cfg, and the built-in keys stay. Call it before the first page is
// made, which shows the warnings, and before OrderSettings.
func LoadKeys(cfg *config.Config) {
if cfg.Dir() == "" {
return
}
data, err := os.ReadFile(filepath.Join(cfg.Dir(), KeysFile))
if errors.Is(err, fs.ErrNotExist) {
return
}
p, problems := keyFile, []string(nil)
if err != nil {
problems = []string{err.Error()}
} else {
p, problems = keyFile.userKeys(string(data))
}
if len(problems) > 0 {
cfg.AddWarning(KeysFile + ": " + strings.Join(problems, "; ") + "; the built-in keys are used")
return
}
keyFile = p
settingOptions = settingWords(settingOptions)
bindings = keyFile.bindings()
}
// keyPage is a page whose keys are looked up in keybindings.toml.
type keyPage int
const (
commitList keyPage = iota
fileList
diffView
flowView
docView
everywhere // the popups over all pages
)
// keyHit is what a key does: the action of its row, "" for none, and its place
// in the row, which gives the way of actions that go two ways (see
// keybindings.toml).
type keyHit struct {
action string
i int
}
// tables returns the rows the keys of page pg are looked up in, in order.
func (p keyPages) tables(pg keyPage) [][]keyRow {
switch pg {
case commitList:
return [][]keyRow{p.CommitList, p.Everywhere}
case fileList:
return [][]keyRow{p.FileList, p.Everywhere}
case diffView:
return [][]keyRow{p.DiffView, p.Everywhere}
case flowView:
return [][]keyRow{p.FlowView, p.DiffView, p.Everywhere}
case docView:
return [][]keyRow{p.DocView, p.Everywhere}
}
return [][]keyRow{p.Everywhere}
}
// key returns what key k, as Bubble Tea names it, does on page pg.
func (p keyPages) key(pg keyPage, k string) keyHit {
for _, rows := range p.tables(pg) {
for _, r := range rows {
for i, name := range r.Keys {
if keyName(name) == k {
return keyHit{r.Action, i}
}
}
}
}
return keyHit{}
}
// isAction reports whether key k does action on all pages: the popups over
// them open and close on such keys.
func isAction(k, action string) bool { return keyFile.key(everywhere, k).action == action }
// keyName returns Bubble Tea's name of the key written as name in
// keybindings.toml, which writes pgdn and F5 as the help shows them, and
// <letter> for the letter that follows.
func keyName(name string) string {
name = strings.TrimSuffix(name, "<letter>")
if name == "pgdn" {
return "pgdown"
}
if n, ok := strings.CutPrefix(name, "F"); ok && n != "" && strings.Trim(n, "0123456789") == "" {
return "f" + n
}
return name
}
// label returns the word of the status bar in state, "" when there is none.
func (r keyRow) label(state int) string {
if state < len(r.Label) {
return r.Label[state]
}
return ""
}
// order returns the first key of each row of everywhere, which decides the
// order of the settings.
func (p keyPages) order() []string {
var keys []string
for _, r := range p.Everywhere {
if len(r.Keys) > 0 {
keys = append(keys, r.Keys[0])
}
}
return keys
}
// orderSettings returns options and bindings with the settings in the order of
// keys. Options not in keys, as c and C, which keybindings.toml lists with the
// flow view, stay behind, as they are; the other bindings stay where they are.
func orderSettings(keys []string, options []settingOption, bs []binding) ([]settingOption, []binding) {
rank := func(k string) int {
if i := slices.Index(keys, k); i >= 0 {
return i
}
return len(keys)
}
options = slices.Clone(options)
slices.SortStableFunc(options, func(a, b settingOption) int { return cmp.Compare(rank(a.key), rank(b.key)) })
isSetting := func(b binding) bool {
return b.key != "" && slices.Contains(keys, b.key) && slices.ContainsFunc(options, func(o settingOption) bool { return o.key == b.key })
}
bs = slices.Clone(bs)
var at []int
var settings []binding
for i, b := range bs {
if isSetting(b) {
at, settings = append(at, i), append(settings, b)
}
}
slices.SortStableFunc(settings, func(a, b binding) int { return cmp.Compare(rank(a.key), rank(b.key)) })
for i, b := range settings {
bs[at[i]] = b
}
return options, bs
}
// OrderSettings puts the settings of the popup and the help in the order of
// keybindings.toml. Call it before the first view is drawn.
func OrderSettings() {
settingOptions, bindings = orderSettings(keyFile.order(), settingOptions, bindings)
}
// keyOrderProblems returns what is wrong with text, a keybindings.toml: a row of
// all pages with a key that does not exist or stands twice, a setting missing or
// without words, a line of the help with an action that has no help, a key with
// two actions on one page, a placeholder without its row.
func keyOrderProblems(text string) []string {
p, err := readKeys(text)
if err != nil {
return []string{err.Error()}
}
var problems []string
keys := p.order()
if len(keys) == 0 {
return []string{"no rows in everywhere"}
}
for i, k := range keys {
if slices.Contains(keys[:i], k) {
problems = append(problems, "key "+k+" stands twice")
}
if !slices.ContainsFunc(bindings, func(b binding) bool { return slices.Contains(strings.Fields(b.key), k) }) {
problems = append(problems, "key "+k+" is not in the help")
}
}
for _, o := range settingOptions {
if o.key != "" && !o.logFlow && !slices.Contains(keys, o.key) {
problems = append(problems, "setting "+o.key+" is not in the table")
}
}
for _, o := range settingOptions {
if p.row(o.action).Setting == "" {
problems = append(problems, "setting "+o.action+" has no words")
}
}
problems = append(problems, p.conflicts()...)
for _, r := range p.Help {
if r.Action != "" && p.row(r.Action).Help == "" {
problems = append(problems, "the help's action "+r.Action+" has no help")
}
}
var words []string
for _, rows := range [][]keyRow{p.Everywhere, p.DiffView, p.FlowView, p.CommitList, p.FileList, p.DocView} {
for _, r := range rows {
words = append(words, r.Help, r.Setting)
}
}
for _, r := range p.Help {
words = append(words, r.Note)
}
for _, w := range words {
for _, m := range placeholder.FindAllStringSubmatch(w, -1) {
n, _ := strconv.Atoi(m[2])
if n >= len(p.rowsOf(m[1])) {
problems = append(problems, "placeholder "+m[0]+" has no row")
}
}
}
return problems
}
// conflicts returns the keys with two actions on one page.
func (p keyPages) conflicts() []string {
var problems []string
for _, t := range []struct {
name string
rows []keyRow
}{{"everywhere", p.Everywhere}, {"diff_view", p.DiffView}, {"flow_view", p.FlowView}, {"commit_list", p.CommitList}, {"file_list", p.FileList},
{"doc_view", p.DocView}} {
actions := map[string]string{}
for _, r := range t.rows {
for _, name := range r.Keys {
if a, ok := actions[keyName(name)]; ok && a != r.Action {
problems = append(problems, "key "+name+" has two actions in "+t.name+": "+a+", "+r.Action)
}
actions[keyName(name)] = r.Action
}
}
}
return problems
}
// SettingsPopup wraps the top-level model and shows the display toggles in a
// popup over it on askSettingsMsg. It stays open after a toggle, so that
// several can be set while the page below shows them.
type SettingsPopup struct {
tea.Model
cfg *config.Config
open bool
listPopup
msg string // a problem saving the toggles
input *lineInput // while it asks for the number of the option
inputErr string // why the number typed is none
}
// WithSettings returns m with the settings popup.
func WithSettings(cfg *config.Config, m tea.Model) *SettingsPopup {
return &SettingsPopup{Model: m, cfg: cfg}
}
func (p *SettingsPopup) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width, p.height = msg.Width, msg.Height
p.move(0)
case askSettingsMsg:
p.open, p.cursor, p.top, p.msg = true, 0, 0, ""
return p, nil
case toggleSettingMsg:
i := slices.IndexFunc(settingOptions, func(o settingOption) bool { return o.action == msg.action })
return p, p.flip(settingOptions[i])
case tea.KeyPressMsg:
switch {
case p.input != nil:
return p, p.editNumber(msg)
case p.open:
return p, p.key(msg.String())
}
case tea.PasteMsg:
if p.input != nil {
p.inputErr = ""
p.input.text += msg.Content
return p, nil
}
}
_, cmd := p.Model.Update(msg)
return p, cmd
}
// key handles k in the open popup.
func (p *SettingsPopup) key(k string) tea.Cmd {
if isAction(k, "settings") {
p.open = false
return nil
}
switch k {
case "esc", "q":
p.open = false
case "ctrl+c":
return tea.Quit
case "down", "j":
p.move(1)
case "up", "k":
p.move(-1)
case "enter", "space":
return p.flip(settingOptions[p.cursor])
default:
if i := slices.IndexFunc(settingOptions, func(o settingOption) bool { return o.key != "" && keyName(o.key) == k }); i >= 0 {
p.move(i - p.cursor)
return p.flip(settingOptions[i])
}
}
return nil
}
// flip toggles option o and tells the page below; a problem saving it shows
// in the popup while that is open, else on the page.
func (p *SettingsPopup) flip(o settingOption) tea.Cmd {
if o.number != nil {
p.input, p.inputErr = &lineInput{text: strconv.Itoa(*o.number(p.cfg))}, ""
return nil
}
if o.ask != nil {
return o.ask
}
problem := toggle(p.cfg, o.setting(p.cfg))
if p.open {
p.msg, problem = problem, ""
}
_, cmd := p.Model.Update(settingChangedMsg{o.action, problem})
return cmd
}
// editNumber edits the number asked for: enter sets and saves it, unless it is
// none, esc and backspace on nothing typed cancel.
func (p *SettingsPopup) editNumber(k tea.KeyPressMsg) tea.Cmd {
in := p.input
switch {
case k.String() == "ctrl+c":
return tea.Quit
case in.cancels(k):
p.input = nil
case k.String() == "enter":
n, err := strconv.Atoi(strings.TrimSpace(in.text))
if err != nil || n < 0 {
p.inputErr = "a whole number, 0 or more"
return nil
}
*settingOptions[p.cursor].number(p.cfg) = n
p.input, p.msg = nil, saveState(p.cfg)
default:
p.inputErr = ""
in.edit(k)
}
return nil
}
func (p *SettingsPopup) move(delta int) { p.moveIn(delta, len(settingOptions)) }
func (p *SettingsPopup) View() tea.View { return tea.NewView(p.frame()) }
func (p *SettingsPopup) frame() string {
base := p.Model.View().Content
if !p.open || p.width == 0 {
return base
}
t := p.cfg.Theme
n := len(settingOptions)
all := make([]string, n)
states := make([]string, n)
keyW, stateW := 0, 0
for i, o := range settingOptions {
switch {
case o.number != nil:
states[i] = fmt.Sprintf("[%d]", *o.number(p.cfg))
case *o.setting(p.cfg):
states[i] = "[x]"
default:
states[i] = "[ ]"
}
keyW = max(keyW, ansi.StringWidth(o.key))
stateW = max(stateW, ansi.StringWidth(states[i]))
}
for i, o := range settingOptions {
all[i] = fmt.Sprintf(" %-*s %-*s %s ", stateW, states[i], keyW, o.key, o.what)
}
title := popupTitle(t, "settings", p.msg, "enter toggle · esc close")
view := p.draw(t, base, title, all, "")
if p.input == nil {
return view
}
pop, popW := inputBox(t, p.width, popupTitle(t, "auto reload timeout", "", "enter set · esc cancel"), printable(p.input.text), p.inputErr, true)
return overlay(view, pop, popW, p.width)
}
package ui
import (
"fmt"
"slices"
"strconv"
"strings"
"unicode"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/diff"
)
const tabWidth = 4
// lineStyles are the styles of one rendered line.
type lineStyles struct {
base lipgloss.Style // text outside emphasized spans
emph lipgloss.Style // changed words
space lipgloss.Style // changed whitespace, drawn as · and →
ws lipgloss.Style // other whitespace, drawn as · and → when showAll
// showAll draws all whitespace visibly, not only changed whitespace.
showAll bool
// plainSpace draws changed whitespace like unchanged whitespace.
plainSpace bool
// showTrailing draws whitespace at the end of the line visibly in
// trailing; with showAll the end of the line is drawn as lineEnd.
trailing lipgloss.Style
showTrailing bool
lineEnd string
// cr is drawn for the \r of a \r\n line end where it changed, and with
// showCR on all lines.
cr string
showCR bool
// hidden is the style of ☠, drawn for invisible characters.
hidden lipgloss.Style
// matches are the parts of the line found by /, drawn in match over all
// other styles.
matches []diff.Span
match lipgloss.Style
}
// painter draws text in a style as lipgloss renders it, without the cost of
// a lipgloss render for each run of a line: the escape sequences come from
// rendering a sample once. Underlined and struck through styles are
// rendered by lipgloss, which styles their spaces apart.
type painter struct {
style lipgloss.Style
open, close string
slow bool
}
func newPainter(st lipgloss.Style) painter {
p := painter{style: st, slow: st.GetUnderline() || st.GetStrikethrough()}
if !p.slow {
out := st.Render("x")
i := strings.Index(out, "x")
p.open, p.close = out[:i], out[i+1:]
}
return p
}
func (p painter) paint(s string) string {
if p.slow {
return p.style.Render(s)
}
return p.open + s + p.close
}
// hiddenSymbol stands for characters that are not visible text, a space or
// a tab: control and format characters, among them those that change the
// direction of text and could reorder what is shown, and unusual spaces.
const hiddenSymbol = "☠"
// hiddenChar reports whether r is drawn as hiddenSymbol.
func hiddenChar(r rune) bool {
if r < utf8.RuneSelf { // ASCII: only control characters
return r != '\t' && (r < 0x20 || r == 0x7f)
}
return r != '\t' && (unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r) || unicode.IsSpace(r) && r != ' ')
}
// sideStyles returns the styles of side s for whole-line changes, the
// unchanged parts of changed lines, changed words and changed whitespace.
func (d *fileDiff) sideStyles(s side) (line, nonEmph, emph, indent lipgloss.Style) {
t := d.cfg.Theme
if s == left {
return t.Minus, t.MinusNonEmph, t.MinusEmph, t.MinusIndent
}
return t.Plus, t.PlusNonEmph, t.PlusEmph, t.PlusIndent
}
// help returns the keys of the status bar: backspace and f when shown from a
// file list, N/P in the log.
func (m *Model) help() string {
k := barKeys{enter: "anchor", commits: m.commitNav, flow: m.canBack}
if m.canBack {
k.back = "to_files"
}
return statusKeys(m.cfg, k)
}
func (m *Model) View() tea.View { return tea.NewView(m.frame()) }
func (m *Model) frame() string {
if m.width == 0 {
return ""
}
var b strings.Builder
b.WriteString(m.header() + "\n")
m.syncLayout()
pos := m.pos()
for i := m.top; i < m.top+m.bodyHeight(); i++ {
if i < len(m.screen) {
b.WriteString(m.screenLine(m.screen[i], i == pos))
}
b.WriteString("\n")
}
b.WriteString(m.status())
return b.String()
}
func (d *fileDiff) numWidth() int {
return len(strconv.Itoa(max(len(d.lines[left]), len(d.lines[right]), 1)))
}
// header renders the file names above both panes.
func (m *Model) header() string {
return paneTitles(m.cfg.Theme, m.names, m.active, m.cfg.Settings.SideBySide, m.width)
}
// screenLine renders screen line s in the current layout, marked as the
// cursor line when cursor is set.
func (m *Model) screenLine(s sline, cursor bool) string {
return m.render(s, cursor, layout{m.width, m.xoff, m.active, m.screenShape})
}
// render renders screen line s in layout lo, marked as the cursor line when
// cursor is set.
func (d *fileDiff) render(s sline, cursor bool, lo layout) string {
if lo.unified {
return d.unifiedLine(s.v, s.sub, cursor, lo)
}
return d.bodyRow(s.v, s.sub, cursor, lo)
}
// startCol returns the first column of a line shown in a pane width columns
// wide: of its piece when lines are wrapped, else the scroll offset.
func startCol(piece, width int, lo layout) int {
if lo.wrap > 0 {
return piece * width
}
return lo.xoff
}
// unifiedLine renders line sub of displayed row v in the normal layout:
// marker, old and new line number, separator and the content. Unchanged
// lines show both numbers, changed ones the number of their side, further
// pieces of a wrapped line none.
func (d *fileDiff) unifiedLine(v, sub int, cursor bool, lo layout) string {
if d.view[v].hidden > 0 {
return d.fold(v, cursor, lo.width)
}
t := d.cfg.Theme
row := d.rows[d.view[v].row]
s, piece := d.unifiedPiece(v, sub, lo.shape)
nw := d.numWidth()
changed := row.Kind != diff.Equal
number := func(line int, of side) string {
if line < 0 || (changed && of != s) || piece > 0 {
return strings.Repeat(" ", nw)
}
return fmt.Sprintf("%*d", nw, line+1)
}
nums := number(row.Left, left) + " " + number(row.Right, right)
pending := piece == 0 && d.isPending(row) && (row.Kind == diff.Equal || d.pending.side == s)
line := lineOf(row, s)
_, moved := d.movedTo(s, line)
gutter := d.gutter(row, s, changed, pending, moved, piece > 0, nums, t.Decoration, cursor, cursor)
width := d.contentWidth(lo.width, true)
return gutter + d.content(s, row, line, width, cursor, moved, startCol(piece, width, lo), lo.wrap == 0)
}
// bodyRow renders line sub of displayed row v side by side, marked as the
// cursor row when cursor is set.
func (d *fileDiff) bodyRow(v, sub int, cursor bool, lo layout) string {
if d.view[v].hidden > 0 {
return d.fold(v, cursor, lo.width)
}
w, nw := (lo.width-1)/2, d.numWidth()
return d.cell(left, v, sub, w, nw, cursor, lo) + d.cfg.Theme.Decoration.Render("│") +
d.cell(right, v, sub, w, nw, cursor, lo)
}
func (m *Model) status() string {
msg := m.msg
if msg == "" {
msg = m.markPrompt()
}
left := pageStatus(m.cfg, msg, m.help())
row := 0
if m.cursor < len(m.view) {
row = m.view[m.cursor].row + 1
}
right := fmt.Sprintf(" row %d/%d · anchors %d", row, len(m.rows), len(m.anchors))
return statusLine(m.cfg.Theme, m.width, left, right)
}
// fold renders displayed row v, a fold of unchanged rows, across both panes
// of a view width columns wide.
func (d *fileDiff) fold(v int, cursor bool, width int) string {
label := fmt.Sprintf(" ⋯ %d unchanged lines ⋯ ", d.view[v].hidden)
if cursor {
label = d.cfg.Theme.Cursor.Render(label)
}
side := max(width-ansi.StringWidth(label), 0)
line := d.cfg.Theme.Decoration.Render(strings.Repeat("─", side/2)) + label +
d.cfg.Theme.Decoration.Render(strings.Repeat("─", side-side/2))
return ansi.Truncate(line, width, "")
}
// cell renders piece of side s of displayed row v: marker, line number,
// separator and the line content; further pieces of a wrapped line have no
// number, and a side without that piece is empty. On the cursor row both
// sides are bold, and the line number of the active side is in the cursor
// style.
func (d *fileDiff) cell(s side, v, piece, width, numWidth int, cursor bool, lo layout) string {
t := d.cfg.Theme
row := d.rows[d.view[v].row]
line := d.lineAt(s, v)
changed := row.Kind != diff.Equal && line >= 0
sepStyle := t.NumLeft
if s == right {
sepStyle = t.NumRight
}
contentWidth := max(width-numWidth-3, 0)
num := strings.Repeat(" ", numWidth)
if line >= 0 && piece >= d.pieces(s, line, lo.shape) {
// The other side's line takes more pieces.
return strings.Repeat(" ", 1+numWidth) + sepStyle.Render("│") + strings.Repeat(" ", 1+contentWidth)
}
if line >= 0 && piece == 0 {
num = fmt.Sprintf("%*d", numWidth, line+1)
}
pending := piece == 0 && d.pending != nil && d.pending.side == s && d.pending.line == line && line >= 0
_, moved := d.movedTo(s, line)
gutter := d.gutter(row, s, changed, pending, moved, piece > 0 && line >= 0, num, sepStyle, cursor,
cursor && s == lo.active)
if line < 0 {
return gutter + t.Filler.Render(strings.Repeat("╱", contentWidth))
}
return gutter + d.content(s, row, line, contentWidth, cursor, moved, startCol(piece, contentWidth, lo), lo.wrap == 0)
}
// gutter renders what precedes a line of side s in row: the marker, the line
// numbers nums and the separator in sepStyle. The marker is ↪ on further
// pieces of a wrapped line (cont), ● when pending holds, ◆ on anchors and -
// or + on changed lines, in the moved style for moved ones; changed lines
// also have their side's number style. On the cursor line marker and numbers
// are bold, with active the numbers are in the cursor style.
func (d *fileDiff) gutter(row diff.Row, s side, changed, pending, moved, cont bool, nums string,
sepStyle lipgloss.Style, cursor, active bool) string {
t := d.cfg.Theme
lineStyle, _, _, _ := d.sideStyles(s)
if moved {
lineStyle = d.movedStyle(s)
}
numStyle := t.NumZero
marker := " "
switch {
case cont && changed:
marker = boldIf(lineStyle, cursor).Render("↪")
case cont:
marker = boldIf(t.Decoration, cursor).Render("↪")
case pending:
marker = t.Pending.Render("●")
case row.Anchored:
marker = boldIf(t.Anchor, cursor).Render("◆")
case changed && s == left:
marker = boldIf(lineStyle, cursor).Render("-")
case changed:
marker = boldIf(lineStyle, cursor).Render("+")
}
if changed {
numStyle = t.NumPlus
if s == left {
numStyle = t.NumMinus
}
}
if active {
// The cursor's line numbers stand out in the cursor style, in
// their own color.
numStyle = t.Cursor.Inherit(numStyle)
} else {
numStyle = boldIf(numStyle, cursor)
}
return marker + numStyle.Render(nums) + sepStyle.Render("│") + " "
}
// boldIf returns style, bold when on is set.
func boldIf(style lipgloss.Style, on bool) lipgloss.Style {
if on {
return style.Bold(true)
}
return style
}
// movedStyle returns the style of moved lines of side s.
func (d *fileDiff) movedStyle(s side) lipgloss.Style {
if s == left {
return d.cfg.Theme.MinusMoved
}
return d.cfg.Theme.PlusMoved
}
// content renders line of side s, shown in row, width columns wide with
// changes emphasized, from column from on. On the cursor line unchanged text
// is bold; a moved line is drawn in the moved style, without emphasis.
// With cut, a line that goes on past the pane ends in Extends, one with
// text scrolled out to the left starts with Precedes.
func (d *fileDiff) content(s side, row diff.Row, line, width int, cursor, moved bool, from int, cut bool) string {
t := d.cfg.Theme
lineStyle, nonEmph, emph, indent := d.sideStyles(s)
st := lineStyles{base: boldIf(t.Zero, cursor && row.Kind == diff.Equal), ws: t.Whitespace,
showAll: d.cfg.Settings.ShowWhitespaces, plainSpace: !d.cfg.Settings.MarkWhitespace,
trailing: t.TrailingWhitespace, showTrailing: d.cfg.Settings.ShowTrailing, lineEnd: d.cfg.Settings.LineEnd,
cr: d.cfg.Settings.CREnd, showCR: d.cfg.Settings.ShowCR, hidden: t.HiddenChar}
var spans []diff.Span
switch {
case row.Kind == diff.Equal:
case moved:
st.base = d.movedStyle(s)
case row.Kind == diff.Change:
st.base, st.emph, st.space, spans = nonEmph, emph, indent, emphOf(row, s)
if !d.cfg.Settings.BoldChanges {
st.emph, st.space = st.emph.UnsetBold(), st.space.UnsetBold()
}
default:
st.base = lineStyle
}
if m := matcherOf(d.cfg.Search.Query); m != nil && (d.cfg.Settings.SearchAll || row.Kind != diff.Equal) {
st.matches, st.match = m.spans(d.lines[s][line]), t.Search
}
// The pieces of a wrapped line are drawn from one highlight of it.
key := highlightKey{line, d.aligns, cursor, moved, d.cfg.Search.Query, d.cfg.Settings, t}
if c := &d.highlighted[s]; !c.ok || c.key != key {
*c = highlightCache{key, highlight(d.lines[s][line], spans, st), true}
}
content := d.highlighted[s].out
plain := lipgloss.NewStyle()
if !cut { // a piece of a wrapped line
return pad(ansi.Cut(content, from, from+width), width, plain)
}
// fit returns s as w columns.
fit := func(s string, w int) string { return pad(ansi.Truncate(s, w, ""), w, plain) }
full := ansi.StringWidth(content)
content = fit(cutLeft(content, from), width)
// The edges say where the line goes on.
if width >= 2 {
mark := d.cfg.Theme.Overflow
if full > from+width {
content = fit(content, width-1) + mark.Render(d.cfg.Settings.Extends)
}
if from > 0 && full > 0 {
content = mark.Render(d.cfg.Settings.Precedes) + fit(cutLeft(content, 1), width-1)
}
}
return content
}
// cutLeft returns s without its first n columns. A wide character they cut
// through leaves a space, so that what follows keeps its column.
func cutLeft(s string, n int) string {
if n <= 0 {
return s
}
r := ansi.TruncateLeft(s, n, "")
if ansi.StringWidth(r) > ansi.StringWidth(s)-n { // kept the cut character
r = " " + ansi.TruncateLeft(s, n+1, "")
}
return r
}
// highlight renders text with the given spans emphasized. Changed
// whitespace (inside spans) is drawn visibly, · for a space and → for a tab,
// when it is indentation, holds a tab or is more than one space: a single
// space between changed words is no news. With showTrailing whitespace at
// the end of the line is drawn visibly in its style, with showAll all
// whitespace, and the end of the line as lineEnd. The \r of a \r\n line
// end is drawn as cr where it changed or with showCR, else not at all. Tabs
// are expanded and hidden characters drawn as ☠ in their style.
func highlight(text string, spans []diff.Span, st lineStyles) string {
cr, changedCR := strings.HasSuffix(text, "\r"), false
if cr {
text = text[:len(text)-1]
}
var b strings.Builder
col := 0
pBase, pEmph, pSpace, pWS := newPainter(st.base), newPainter(st.emph), newPainter(st.space), newPainter(st.ws)
pTrailing, pHidden, pMatch := newPainter(st.trailing), newPainter(st.hidden), newPainter(st.match)
render := func(part string, p painter, visible bool) {
if part == "" {
return
}
var e strings.Builder
expand(&e, part, &col, visible)
b.WriteString(p.paint(e.String()))
}
lead := true // in the line's indentation
at := 0 // where the part rendered next starts in text
trailFrom := len(strings.TrimRightFunc(text, unicode.IsSpace))
// class is the kind of run r belongs to: hidden, whitespace or text.
class := func(r rune) int {
switch {
case hiddenChar(r):
return 2
case unicode.IsSpace(r):
return 1
}
return 0
}
// renderPart splits a part into hidden, whitespace and other runs.
renderPart := func(part string, emph, match bool) {
for part != "" {
r, _ := utf8.DecodeRuneInString(part)
c := class(r)
ws := c == 1
i := strings.IndexFunc(part, func(r rune) bool { return class(r) != c })
if i < 0 {
i = len(part)
}
run := part[:i]
if c == 2 {
lead = false
at += i
render(run, pHidden, false)
part = part[i:]
continue
}
mark := lead || strings.ContainsRune(run, '\t') || utf8.RuneCountInString(run) > 1
lead = lead && ws
trailing := ws && at >= trailFrom
at += i
switch {
case match: // found by /: over all styles, whitespace visible as around
render(run, pMatch, ws && (st.showAll || trailing && st.showTrailing))
case trailing && st.showTrailing:
render(run, pTrailing, true)
case ws && emph && !st.plainSpace && mark:
render(part[:i], pSpace, true)
case ws && st.showAll:
render(part[:i], pWS, true)
case ws:
render(part[:i], pBase, false)
case emph:
render(part[:i], pEmph, false)
default:
render(part[:i], pBase, false)
}
part = part[i:]
}
}
// The line is drawn in parts between the edges of the spans and the
// matches, each emphasized or found by / or not.
cuts := []int{0, len(text)}
for _, sp := range spans {
changedCR = changedCR || sp.End > len(text)
cuts = append(cuts, min(sp.Start, len(text)), min(sp.End, len(text)))
}
for _, sp := range st.matches {
cuts = append(cuts, min(sp.Start, len(text)), min(sp.End, len(text)))
}
slices.Sort(cuts)
cuts = slices.Compact(cuts)
in := func(spans []diff.Span, at int) bool {
return slices.ContainsFunc(spans, func(sp diff.Span) bool { return at >= sp.Start && at < sp.End })
}
for i := 0; i+1 < len(cuts); i++ {
from, to := cuts[i], cuts[i+1]
renderPart(text[from:to], in(spans, from), in(st.matches, from))
}
if cr && (changedCR || st.showCR) {
style := st.ws
if changedCR {
style = st.emph
}
b.WriteString(style.Render(st.cr))
}
if st.showAll {
b.WriteString(st.ws.Render(st.lineEnd))
}
return b.String()
}
// expand writes text, which starts at column *col, to b as drawn: tabs
// expanded to the next tab stop and hidden characters as ☠; with visible
// set, spaces are drawn as · and tabs start with →. *col advances.
func expand(b *strings.Builder, text string, col *int, visible bool) {
for _, r := range text {
switch {
case r == '\t':
n := tabWidth - *col%tabWidth
if visible {
b.WriteString("→" + strings.Repeat(" ", n-1))
} else {
b.WriteString(strings.Repeat(" ", n))
}
*col += n
case r == ' ' && visible:
b.WriteRune('·')
*col++
case hiddenChar(r):
b.WriteString(hiddenSymbol)
*col++
default:
b.WriteRune(r)
*col += ansi.StringWidth(string(r))
}
}
}
// printable returns line as drawn, see expand, so that its width is known:
// text from files, git or marks may hold tabs and control characters.
func printable(line string) string {
var b strings.Builder
col := 0
expand(&b, line, &col, false)
return b.String()
}
package ui
import (
"errors"
"fmt"
"path/filepath"
"slices"
"strconv"
"time"
tea "charm.land/bubbletea/v2"
"jdiff/internal/config"
"jdiff/internal/notify"
"jdiff/internal/source"
)
// racyWindow is how long after its modification time a file is compared by
// content: file systems keep the time in coarse steps, so a change of the
// same size right after the last read may leave the stamp as it was.
const racyWindow = 2 * time.Second
// watchTickMsg asks the top-level view to check its watched files. owner is
// the view that asked for it; another view, which took its place, drops it.
// Without owner, any view takes it.
type watchTickMsg struct{ owner tea.Model }
// changedMsg reports files on disk that changed, by their paths cleaned,
// from inotify watcher w.
type changedMsg struct {
w *notify.Watcher
paths []string
}
// waitChanges returns the command waiting for the next changes w reports.
func waitChanges(w *notify.Watcher) tea.Cmd {
return func() tea.Msg {
paths, ok := <-w.Changes()
if !ok {
return nil
}
return changedMsg{w, paths}
}
}
// startWatching starts watching the files at paths and returns the
// watcher and the command waiting for its changes. When inotify is off, not
// supported or the files are in more than inotify_max_dirs directories, the
// watcher is nil and the command the first check of polling; when the
// kernel refuses inotify, msg says why. With watching off or no paths, it
// returns nothing.
func startWatching(cfg *config.Config, owner tea.Model, paths []string) (w *notify.Watcher, cmd tea.Cmd, msg string) {
if cfg.Settings.WatchInterval <= 0 || len(paths) == 0 {
return nil, nil, ""
}
if cfg.Settings.InotifyMaxDirs > 0 {
w, err := notify.New(paths, cfg.Settings.InotifyMaxDirs)
switch {
case err == nil:
return w, waitChanges(w), ""
case !errors.Is(err, notify.ErrUnsupported) && !errors.Is(err, notify.ErrTooManyDirs):
msg = err.Error() + "; checking files every " + strconv.FormatFloat(cfg.Settings.WatchInterval, 'g', -1, 64) + "s"
}
}
return nil, watchTick(cfg, owner), msg
}
// autoTickMsg asks DiffSwitch for the next check of auto reload number gen;
// a later or ended one drops it.
type autoTickMsg struct{ gen int }
// autoStatusMsg is the result of git status for auto reload number gen.
type autoStatusMsg struct {
gen int
status string
err error
}
// toggleAutoReload starts or stops auto reload and returns the commands of
// its checks. Starting also loads again, since the repository may have changed
// since the last load. When it cannot start, the view says why.
func (s *DiffSwitch) toggleAutoReload() tea.Cmd {
if s.cfg.AutoReload {
s.stopAutoReload()
return nil
}
if s.repoStatus == nil {
return showAutoReloadError(errors.New("nothing to watch"))
}
if s.cfg.Settings.AutoReloadInterval <= 0 {
return showAutoReloadError(errors.New("auto_reload_interval is 0"))
}
status, err := s.repoStatus()
if err != nil {
return showAutoReloadError(err)
}
s.cfg.AutoReload, s.lastStatus, s.lastActive = true, status, time.Now()
s.autoGen++
s.watchRepo()
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(refreshMsg{quiet: true})
return tea.Batch(cmd, s.autoTick(), s.waitRepo())
}
// stopAutoReload ends auto reload: no git commands run for it from now on.
func (s *DiffSwitch) stopAutoReload() {
s.cfg.AutoReload = false
s.autoGen++
if s.repoWatch != nil {
s.repoWatch.Close()
s.repoWatch = nil
}
}
// autoTick returns the command for the next regular check.
func (s *DiffSwitch) autoTick() tea.Cmd {
gen := s.autoGen
every := time.Duration(s.cfg.Settings.AutoReloadInterval) * time.Second
return tea.Tick(every, func(time.Time) tea.Msg { return autoTickMsg{gen} })
}
// checkStatus returns the command running git status.
func (s *DiffSwitch) checkStatus() tea.Cmd {
s.statusRuns++
gen, status := s.autoGen, s.repoStatus
return func() tea.Msg {
st, err := status()
return autoStatusMsg{gen, st, err}
}
}
// onAutoTick ends auto reload after the timeout without activity, else checks
// the status, unless git is still at it, as in a large repository.
func (s *DiffSwitch) onAutoTick(msg autoTickMsg) tea.Cmd {
if msg.gen != s.autoGen {
return nil
}
if m := s.cfg.Settings.AutoReloadTimeout; m > 0 && time.Since(s.lastActive) >= time.Duration(m)*time.Minute {
s.stopAutoReload()
return func() tea.Msg { return showMsg(fmt.Sprintf("auto reload off: %d min without keys or changes", m)) }
}
if s.statusRuns > 0 {
return s.autoTick()
}
return tea.Batch(s.autoTick(), s.checkStatus())
}
// onAutoStatus loads what is shown again when the status differs from the last
// one, which counts as activity. git status elsewhere, or git diff refreshing
// the index, makes the watcher report a change but leaves the status as it is.
// When git fails, auto reload ends.
func (s *DiffSwitch) onAutoStatus(msg autoStatusMsg) tea.Cmd {
s.statusRuns--
if msg.gen != s.autoGen {
return nil
}
if msg.err != nil {
s.stopAutoReload()
return showAutoReloadError(msg.err)
}
if msg.status == s.lastStatus {
return nil
}
s.lastStatus, s.lastActive = msg.status, time.Now()
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(refreshMsg{quiet: true})
return cmd
}
// onRepoChanged checks the status at once after the watcher reported a change.
// The files are watched anew, since a checkout changes the branch's ref.
func (s *DiffSwitch) onRepoChanged() tea.Cmd {
s.watchRepo()
return tea.Batch(s.checkStatus(), s.waitRepo())
}
// watchRepo starts watching the repository's files, in place of the watcher
// before, which stays when that fails: the regular checks still see all
// changes, only later. A symlink counts with the directory of its target.
func (s *DiffSwitch) watchRepo() {
if s.repoPaths == nil {
return
}
paths, err := s.repoPaths()
if err != nil {
return
}
w, err := notify.New(paths, 2*len(paths))
if err != nil {
return
}
if s.repoWatch != nil {
s.repoWatch.Close()
}
s.repoWatch = w
}
// waitRepo returns the command waiting for the watcher's next change, nil
// without one.
func (s *DiffSwitch) waitRepo() tea.Cmd {
if s.repoWatch == nil {
return nil
}
return waitChanges(s.repoWatch)
}
// showAutoReloadError returns the command showing why auto reload is off.
func showAutoReloadError(err error) tea.Cmd {
return func() tea.Msg { return showMsg("auto reload: " + firstLine(err)) }
}
// diskPaths returns the paths of the sides of f on disk, cleaned as
// changedMsg reports them.
func diskPaths(f source.File) []string {
var out []string
for _, p := range []string{f.OldFile, f.NewFile} {
if p != "" {
out = append(out, filepath.Clean(p))
}
}
return out
}
// watchTick returns the command for the next check of the watched files,
// nil when watching is off.
func watchTick(cfg *config.Config, owner tea.Model) tea.Cmd {
interval := cfg.Settings.WatchInterval
if interval <= 0 {
return nil
}
return tea.Tick(time.Duration(interval*float64(time.Second)), func(time.Time) tea.Msg { return watchTickMsg{owner} })
}
// refreshFile reloads the sides of f that changed on disk into f and into
// its diff d, unless nil, calling after, unless nil, after each reload of d
// so that views follow their cursor line before the next one. It returns the
// message to show, "" when nothing changed. A side changed when its stamp
// did, or, while its modification time is recent, its content. A side that
// cannot be read is checked again next time; a side that became binary keeps
// its content.
func refreshFile(f *source.File, d *fileDiff, after func()) string {
type sideFile struct {
path string
stamp *source.Stamp
lines *[]string
}
sides := [2]sideFile{{f.OldFile, &f.OldStamp, &f.Old}, {f.NewFile, &f.NewStamp, &f.New}}
reloaded, binary, dropped := false, false, 0
for s, sf := range sides {
if sf.path == "" {
continue
}
if st, err := source.StampOf(sf.path); err != nil || (st == *sf.stamp && time.Since(st.ModTime) >= racyWindow) {
continue
}
lines, st, err := source.Read(sf.path)
if errors.Is(err, source.ErrBinary) {
*sf.stamp, binary = st, true
continue
}
if err != nil {
continue
}
if slices.Equal(lines, *sf.lines) {
*sf.stamp = st
continue
}
*sf.stamp, *sf.lines, reloaded = st, lines, true
if d != nil {
dropped += d.reload(side(s), lines)
if after != nil {
after()
}
}
}
switch {
case binary:
return f.Path + " is binary now, not reloaded"
case !reloaded:
return ""
case dropped == 1:
return f.Path + " reloaded, 1 anchor dropped"
case dropped > 1:
return fmt.Sprintf("%s reloaded, %d anchors dropped", f.Path, dropped)
}
return f.Path + " reloaded"
}
package ui
import (
"fmt"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/notify"
"jdiff/internal/theme"
)
// paneTitles renders names above the panes of a view width columns wide.
// Side by side the name of the active pane is bold, the other one faint; in
// the normal layout, one column of both, both are bold.
func paneTitles(t *theme.Theme, names [2]string, active side, sideBySide bool, width int) string {
w := (width - 1) / 2
title := func(s side) string {
style := lipgloss.NewStyle().Bold(true)
if sideBySide && s != active {
style = lipgloss.NewStyle().Faint(true)
}
return pad(style.Render(ansi.Truncate(" "+printable(names[s]), w, "…")), w, lipgloss.NewStyle())
}
return title(left) + t.Decoration.Render("│") + title(right)
}
// statusLine renders the status bar with left, already styled, and right,
// in the hint style, aligned to its edges; left is shortened when both do
// not fit.
func statusLine(t *theme.Theme, width int, left, right string) string {
left = ansi.Truncate(left, max(width-ansi.StringWidth(right), 0), "…")
gap := max(width-ansi.StringWidth(left)-ansi.StringWidth(right), 0)
return ansi.Truncate(left+t.Hint.Render(strings.Repeat(" ", gap)+right), width, "")
}
// popupTitle returns the title of a popup: its name, then msg or the keys
// of hints, as statusText renders them.
func popupTitle(t *theme.Theme, name, msg, hints string) string {
return t.CommitHash.Render(" "+name+" ") + t.Hint.Render(" ") + statusText(t, msg, hints) + t.Hint.Render(" ")
}
// statusText returns the left part of a status bar: msg, or without one
// the hints, each a key and what it does, separated by " · ", in the hint
// style. msg may name files or quote git, so it is drawn printable.
func statusText(t *theme.Theme, msg, hints string) string {
if msg != "" {
return t.Hint.Render(printable(msg))
}
return t.Hint.Render(hints)
}
// pageStatus returns the left part of the status bar of a page, see
// statusText; while auto reload is on, its name comes first.
func pageStatus(cfg *config.Config, msg, hints string) string {
if cfg.AutoReload {
return popupTitle(cfg.Theme, "auto reload", msg, hints)
}
return statusText(cfg.Theme, msg, hints)
}
// barKeys says what differs between the status bars of the pages.
type barKeys struct {
enter string // the action of enter
back string // the action of backspace, "" when it does nothing
commits bool // N and P switch commits
flow bool // f switches between the flow and the single file view
text bool // a page of text, --readme: no layout, git diff or settings
}
// barState returns the index of an action's label while it is on or off.
func barState(on bool) int {
if on {
return 0
}
return 1
}
// statusKeys returns the keys of the status bar of a page, the same on all of
// them, in one order, so that a key that switches what is shown changes only
// its own label: f and s name what they switch to. All other keys are in the
// help. keybindings.toml gives the keys and the words.
func statusKeys(cfg *config.Config, k barKeys) string {
var keys []string
add := func(row keyRow, keyNames string, state int) {
if l := row.label(state); l != "" {
keys = append(keys, keyNames+" "+l)
}
}
first := func(action string, state int) {
if r := keyFile.row(action); len(r.Keys) > 0 {
add(r, r.Keys[0], state)
}
}
first(k.enter, 0)
if k.back != "" {
first(k.back, 0)
}
if k.commits {
r := keyFile.row("history")
add(r, strings.Join(r.Keys, "/"), 0)
}
if k.flow {
first("flow", barState(cfg.Settings.Flow))
}
actions := []string{"git_diff", "settings", "help", "quit"}
if k.text {
actions = actions[2:]
} else {
first("layout", barState(cfg.Settings.SideBySide))
}
for _, action := range actions {
first(action, 0)
}
return strings.Join(keys, " · ")
}
// minBoxHeight fits the borders and one line.
const minBoxHeight = 3
// splitList divides a screen height high between a heading, an empty line,
// a list of n items, the box below it when shown and the status bar. The
// list takes at most half of the room and the box, after an empty line, the
// rest; box is 0 when hidden, for an empty list or when the screen is too
// small for it.
func splitList(height, n int, showBox bool) (list, box int) {
room := max(height-3, 1)
if !showBox || n == 0 {
return room, 0
}
list = max(min(n, room/2), 1)
box = room - list - 1
if box < minBoxHeight {
return room, 0
}
return list, box
}
// box renders lines in a rounded box width columns wide and height lines
// high, with title in the top border. Lines that do not fit are cut.
func box(t *theme.Theme, width, height int, title string, lines []string) string {
border := t.Decoration
inner := max(width-4, 0) // border and a space on each side
title = ansi.Truncate(title, inner, "")
var b strings.Builder
b.WriteString(border.Render("╭─") + title +
border.Render(strings.Repeat("─", max(width-3-ansi.StringWidth(title), 0))+"╮") + "\n")
for i := range height - 2 {
line := ""
if i < len(lines) {
line = ansi.Truncate(lines[i], inner, "…")
}
b.WriteString(border.Render("│") + " " + pad(line, inner, lipgloss.NewStyle()) + " " + border.Render("│") + "\n")
}
b.WriteString(border.Render("╰"+strings.Repeat("─", max(width-2, 0))+"╯") + "\n")
return b.String()
}
// popupHeight is the height of a popup of n lines, at least one, on a screen
// height lines high: all lines when they fit, else the screen but a line above
// and below.
func popupHeight(n, height int) int { return min(max(n, 1)+2, max(height-2, minBoxHeight)) }
// emptyListWidth is the least width of the line of a listPopup without items.
const emptyListWidth = 40
// listPopup is the state of a popup that lists items with a cursor, so that
// such popups move and look the same.
type listPopup struct {
cursor, top int // the selected item, the first one shown
width, height int
}
// moveIn moves the cursor by delta among n items.
func (p *listPopup) moveIn(delta, n int) {
p.cursor = max(min(p.cursor+delta, n-1), 0)
p.top = windowAround(p.cursor, p.top, popupHeight(n, p.height)-2, n).first
}
// draw draws the popup over base: the items all in a box, the selected one in
// the cursor style, or empty as a hint when there are none. It is as wide as
// the items and title need, up to the width of the screen.
func (p *listPopup) draw(t *theme.Theme, base, title string, all []string, empty string) string {
n := len(all)
height := popupHeight(n, p.height)
lines := windowAround(p.cursor, p.top, height-2, n).rows(t, n, func(i int) string {
if i == p.cursor {
return t.Cursor.Render(all[i])
}
return all[i]
})
widest := 0
for _, item := range all {
widest = max(widest, ansi.StringWidth(item))
}
if n == 0 {
lines, widest = []string{t.Hint.Render(empty)}, emptyListWidth
}
width := min(max(widest+4, ansi.StringWidth(title)+4), p.width)
return overlay(base, box(t, width, height, title, lines), width, p.width)
}
// scrollWindow returns the first shown item of a window h items high over n
// items: top, moved as little as needed to show cursor.
func scrollWindow(cursor, top, h, n int) int {
if cursor < top {
top = cursor
} else if cursor >= top+h {
top = cursor - h + 1
}
return max(min(top, n-h), 0)
}
// jumpTop returns the first shown item after the cursor jumped from item
// from to item to, with the window h items high over n items at top: it
// stays when to is in it, moves as little as needed when from and to fit in
// it together, so that both stay in view, and else keeps the cursor's
// screen row, so that the eye stays where it was.
func jumpTop(from, to, top, h, n int) int {
if d := to - from; d < h && -d < h {
return scrollWindow(to, top, h, n)
}
return scrollWindow(to, top+to-from, h, n)
}
// list is the state of the commit and the file list: n items below a
// heading and an empty line, the box below them while preview is set, and the
// status bar.
type list struct {
cursor, top int
n int
preview *bool // the setting that shows the box
maxRows int // the most rows of the list while the box shows, 0 for no limit
width, height int
}
// rows returns how many rows the list would take with the box: all items, up
// to maxRows.
func (li *list) rows() int {
if li.maxRows > 0 {
return min(li.n, li.maxRows)
}
return li.n
}
// listHeight is the height of the list below the heading and an empty line.
func (li *list) listHeight() int {
h, _ := splitList(li.height, li.rows(), *li.preview)
return h
}
// boxHeight is the height of the box below the list, 0 when it is hidden.
func (li *list) boxHeight() int {
_, h := splitList(li.height, li.rows(), *li.preview)
return h
}
func (li *list) moveCursor(delta int) {
li.cursor = max(min(li.cursor+delta, li.n-1), 0)
li.scrollToCursor()
}
func (li *list) scrollToCursor() {
li.top = windowAround(li.cursor, li.top, li.listHeight(), li.n).first
}
// window is the part of n lines that a view h rows high shows: the lines
// from first to end, after a row with an arrow for the lines hidden above
// when up, and before one for those hidden below when down. Arrows take
// rows when the lines do not fit in three rows or more; an arrow hides at
// least two lines, a single one is shown instead.
type window struct {
first, end int
up, down bool
}
// windowAt returns the window of h rows over n lines from top, moved into
// the lines. It never starts at 1, which would show the lines it shows from
// 0, with an arrow in place of the first.
func windowAt(top, h, n int) window {
if n <= h || h < 3 {
top = max(min(top, n-h), 0)
return window{top, min(top+h, n), false, false}
}
// Up to the end of the lines, the window holds an arrow and h-1 lines.
top = max(min(top, n-h+1), 0)
if top == 1 {
top = 0
}
end := shownEnd(top, h, n)
return window{top, end, top > 0, end < n}
}
// windowAround returns the window of h rows over n lines from top, moved as
// little as needed to show the line cursor.
func windowAround(cursor, top, h, n int) window {
if n <= h || h < 3 {
return windowAt(scrollWindow(cursor, top, h, n), h, n)
}
w := windowAt(min(top, cursor), h, n)
for cursor >= w.end {
next := w.first + 1
if next == 1 {
next = 2
}
w = windowAt(next, h, n)
}
return w
}
// shownEnd returns the end of the lines shown from top in a window h rows
// high over n lines, after the rows of the arrows.
func shownEnd(top, h, n int) int {
end := top + h
if top > 0 {
end-- // the arrow above
}
if end < n {
end-- // the arrow below
}
return min(end, n)
}
// rows returns the rows of w over n lines: line(i) for the lines shown and
// the arrows with the number of lines they hide, drawn by moreRow.
func (w window) rows(t *theme.Theme, n int, line func(i int) string) []string {
var out []string
if w.up {
out = append(out, moreRow(t, "↑", w.first))
}
for i := w.first; i < w.end; i++ {
out = append(out, line(i))
}
if w.down {
out = append(out, moreRow(t, "↓", n-w.end))
}
return out
}
// moreRow renders the row of an arrow for hidden lines of a list or popup.
func moreRow(t *theme.Theme, arrow string, hidden int) string {
return t.More.Render(fmt.Sprintf(" %s %d more", arrow, hidden))
}
// render renders the list: head, the shown items as drawn by item, which
// renders the selected one bold through st, the box as drawn by box when it
// is shown, and status, styled, see statusText.
func (li *list) render(t *theme.Theme, head string, item func(i int, st func(lipgloss.Style) lipgloss.Style) string,
box func(height int) string, status string) string {
var b strings.Builder
b.WriteString(ansi.Truncate(head, li.width, "…") + "\n\n")
h := li.listHeight()
w := windowAround(li.cursor, li.top, h, li.n)
rows := w.rows(t, li.n, func(i int) string {
return item(i, func(s lipgloss.Style) lipgloss.Style { return s.Bold(i == li.cursor) })
})
for i := range h {
if i < len(rows) {
b.WriteString(ansi.Truncate(rows[i], li.width, "…"))
}
b.WriteString("\n")
}
if h := li.boxHeight(); h > 0 && li.cursor < li.n {
b.WriteString("\n" + box(h))
}
b.WriteString(ansi.Truncate(status, li.width, ""))
return b.String()
}
// DiffSwitch wraps the top-level view, whatever it shows: d asks for the
// arguments of git diff in a popup, whose files then take the view's place,
// as with jdiff diff. When git fails, the popup stays open and says why.
type DiffSwitch struct {
tea.Model // the view in its wrappers
view tea.Model // the view itself
wrap func(tea.Model) tea.Model
cfg *config.Config
gitDiff DiffFunc
// Auto reload: repoStatus returns the state of the repository, nil
// without auto reload, which is checked every auto_reload_interval and at once
// when repoWatch, if the system has one, reports a change of the files of
// repoPaths. statusRuns counts the checks under way; autoGen tells this
// run of auto reload from earlier ones.
repoStatus func() (string, error)
repoPaths func() ([]string, error)
repoWatch *notify.Watcher
lastStatus string
lastActive time.Time
statusRuns int
autoGen int
input *lineInput // while it asks
err string // why git failed, until the next key
width int
height int
}
// WithDiffSwitch returns the switch for view, which wrap puts in its
// wrappers, as it does the views that replace it; gitDiff runs git diff.
func WithDiffSwitch(cfg *config.Config, view tea.Model, wrap func(tea.Model) tea.Model, gitDiff DiffFunc) *DiffSwitch {
return &DiffSwitch{Model: wrap(view), view: view, wrap: wrap, cfg: cfg, gitDiff: gitDiff}
}
// WithAutoReload sets how auto reload gets the state of the repository and
// the files to watch for changes of it, and returns s.
func (s *DiffSwitch) WithAutoReload(status func() (string, error), paths func() ([]string, error)) *DiffSwitch {
s.repoStatus, s.repoPaths = status, paths
return s
}
// current returns the arguments of the git diff shown, and whether the view
// shows one.
func (s *DiffSwitch) current() ([]string, bool) {
if a, ok := s.view.(*App); ok && a.gitDiff != nil {
return a.diffArgs, true
}
return nil, false
}
func (s *DiffSwitch) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width, s.height = msg.Width, msg.Height
case askDiffMsg:
s.input, s.err = &lineInput{}, ""
return s, nil
case askAutoReloadMsg:
return s, s.toggleAutoReload()
case autoTickMsg:
return s, s.onAutoTick(msg)
case autoStatusMsg:
return s, s.onAutoStatus(msg)
case changedMsg:
if s.repoWatch != nil && msg.w == s.repoWatch {
return s, s.onRepoChanged()
}
case tea.KeyPressMsg:
s.lastActive = time.Now()
if s.input != nil {
s.err = ""
return s, s.key(msg)
}
case tea.PasteMsg:
if s.input != nil {
s.err = ""
s.input.text += msg.Content
return s, nil
}
}
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(msg)
return s, cmd
}
// key edits the arguments asked for; all text typed goes into the line. Up
// takes the arguments of the git diff shown to edit. Enter shows the diff of
// the line; with nothing typed it closes the popup, as esc and backspace on
// it do.
func (s *DiffSwitch) key(k tea.KeyPressMsg) tea.Cmd {
in := s.input
switch {
case k.String() == "ctrl+c":
return tea.Quit
case in.cancels(k):
s.input = nil
return nil
}
switch k.String() {
case "enter":
args := strings.Fields(in.text)
if len(args) == 0 {
s.input = nil
return nil
}
return s.show(args)
case "up":
if args, ok := s.current(); ok {
in.text = strings.Join(args, " ")
}
default:
in.edit(k)
}
return nil
}
// show replaces the view by the files of git diff with args, from scratch,
// closes the popup and returns the command starting the new view. When git
// fails, the view and the popup stay, which shows the first line of git's
// message.
func (s *DiffSwitch) show(args []string) tea.Cmd {
files, err := s.gitDiff(args)
if err != nil {
s.err = firstLine(err)
return nil
}
s.input = nil
if v, ok := s.view.(interface{ stop() }); ok {
v.stop()
}
a := NewApp(s.cfg, files).WithGitDiff(args, s.gitDiff)
if len(files) > 0 {
a.setMsg(fmt.Sprintf("%s: %d files", diffCommand(args), len(files)))
}
s.view, s.Model = a, s.wrap(a)
s.Model.Update(tea.WindowSizeMsg{Width: s.width, Height: s.height})
return s.Model.Init()
}
// minInputWidth is the least width of the line in the popups of inputBox.
const minInputWidth = 40
// frame draws the popup over the view while it asks: the line typed,
// centered, and why git failed. The popup grows with the line, up to the
// width of the screen; beyond, the line's start is cut so that its end
// stays in view.
func (s *DiffSwitch) View() tea.View { return tea.NewView(s.frame()) }
func (s *DiffSwitch) frame() string {
base := s.Model.View().Content
if s.input == nil || s.width == 0 {
return base
}
t := s.cfg.Theme
keys := "enter show · esc cancel"
if _, ok := s.current(); ok {
keys = "enter show · ↑ current · esc cancel"
}
pop, w := inputBox(t, s.width, popupTitle(t, "git diff", "", keys), printable(s.input.text), s.err, true)
return overlay(base, pop, w, s.width)
}
// inputBox renders a popup holding a line typed: title, text with the
// cursor at its end, centered between empty lines with centered, and err
// below when set. It is at least minInputWidth wide and grows with them up
// to width, beyond which the start of text is cut, so that its end stays
// in view. It returns the popup and its width.
func inputBox(t *theme.Theme, width int, title, text, err string, centered bool) (string, int) {
text += "█"
inner := max(minInputWidth, ansi.StringWidth(title), ansi.StringWidth(text), ansi.StringWidth(err))
inner = min(inner, width-4) // borders and a space on each side
if over := ansi.StringWidth(text) - inner; over > 0 {
text = cutLeft(text, over)
}
lines := []string{text}
if centered {
lines = []string{"", strings.Repeat(" ", (inner-ansi.StringWidth(text))/2) + text, ""}
}
if err != "" {
lines = append(lines, t.Minus.Render(err))
}
return box(t, inner+4, len(lines)+2, title, lines), inner + 4
}
// lineInput is a line of text being typed, with the cursor at its end.
type lineInput struct{ text string }
// cancels reports whether k closes the input without using it: esc, or
// backspace on nothing typed.
func (in *lineInput) cancels(k tea.KeyPressMsg) bool {
return k.String() == "esc" || k.String() == "backspace" && in.text == ""
}
// edit applies k to the text: typed text is added, backspace deletes a
// character, ctrl+w a word and ctrl+u the line. Pasted text comes as a
// tea.PasteMsg, which the owner adds.
func (in *lineInput) edit(k tea.KeyPressMsg) {
switch k.String() {
case "backspace":
if r := []rune(in.text); len(r) > 0 {
in.text = string(r[:len(r)-1])
}
case "ctrl+w":
in.text = strings.TrimRight(in.text, " ")
in.text = in.text[:strings.LastIndex(in.text, " ")+1]
case "ctrl+u":
in.text = ""
default:
in.text += k.Text
}
}
// pad fills s with spaces in style up to width columns.
func pad(s string, width int, style lipgloss.Style) string {
if n := width - ansi.StringWidth(s); n > 0 {
return s + style.Render(strings.Repeat(" ", n))
}
return s
}
// count formats n with sign right aligned to width, or blanks when n is 0.
func count(sign string, n, width int) string {
if n == 0 {
return strings.Repeat(" ", width)
}
return fmt.Sprintf("%*s", width, sign+fmt.Sprint(n))
}
// counts renders the added and removed line counts in columns addW and remW
// digits wide, in st of the plus and minus styles.
func counts(t *theme.Theme, st func(lipgloss.Style) lipgloss.Style, added, removed, addW, remW int) string {
return st(t.Plus).Render(count("+", added, addW+1)) + " " + st(t.Minus).Render(count("-", removed, remW+1))
}
// minWidth and minHeight are the smallest terminal the views are drawn in.
// Side by side the gutters of both panes take 7 columns and twice the digits
// of the line numbers, 19 for files of up to 999999 lines; the lists need a
// heading, an empty line, an item and the status bar.
const minWidth, minHeight = 20, 4
// SizeGuard wraps the top-level model and puts it on the alternate screen.
// In a terminal smaller than minWidth by minHeight it shows that in a
// centered box instead of the page and ignores keys but ctrl+c, until the
// terminal is large enough again; the page still learns the size and gets
// all other messages.
type SizeGuard struct {
tea.Model
cfg *config.Config
width, height int
}
// WithSizeGuard returns m guarded against too small terminals.
func WithSizeGuard(cfg *config.Config, m tea.Model) *SizeGuard { return &SizeGuard{Model: m, cfg: cfg} }
func (g *SizeGuard) tooSmall() bool { return g.width < minWidth || g.height < minHeight }
func (g *SizeGuard) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
g.width, g.height = msg.Width, msg.Height
case tea.KeyPressMsg:
if g.tooSmall() {
if msg.String() == "ctrl+c" {
return g, tea.Quit
}
return g, nil
}
}
_, cmd := g.Model.Update(msg)
return g, cmd
}
func (g *SizeGuard) View() tea.View {
v := tea.NewView(g.frame())
v.AltScreen = true
return v
}
func (g *SizeGuard) frame() string {
if g.width == 0 || !g.tooSmall() {
return g.Model.View().Content
}
first := "terminal too small"
if ansi.StringWidth(first) > g.width {
first = "too small"
}
lines := []string{first, fmt.Sprintf("%dx%d, need %dx%d", g.width, g.height, minWidth, minHeight)}
w := 0
for _, l := range lines {
w = max(w, ansi.StringWidth(l)+4) // border and a space on each side
}
notice := strings.Join(lines, "\n")
if w <= g.width && len(lines)+2 <= g.height {
notice = strings.TrimSuffix(box(g.cfg.Theme, w, len(lines)+2, "", lines), "\n")
}
// Without room for all of it, it is cut.
rows := strings.Split(lipgloss.Place(g.width, g.height, lipgloss.Center, lipgloss.Center, notice), "\n")
rows = rows[:min(len(rows), g.height)]
for i, r := range rows {
rows[i] = ansi.Truncate(r, g.width, "")
}
return strings.Join(rows, "\n")
}
// Command jdiff is an interactive side-by-side diff viewer with manual line
// alignment.
package main
import (
"bytes"
"embed"
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/term"
"github.com/spf13/cobra"
"jdiff/internal/config"
"jdiff/internal/gitsrc"
"jdiff/internal/source"
"jdiff/internal/theme"
"jdiff/internal/ui"
)
//go:embed LICENSE
var license string
// thirdParty holds the licenses of the modules linked into jdiff, which
// they require a binary to carry; make licenses writes it.
//
//go:embed LICENSE-THIRD-PARTY
var thirdParty string
// docs holds the documentation that --readme shows: the packages install only
// the binary, so it is the only copy at hand. doc/config.md links to the
// built-in keybindings.toml.
//
//go:embed README.md doc/*.md internal/ui/keybindings.toml
var docs embed.FS
// docPages are the pages of docs in the order of n and p; --readme starts
// with the first.
var docPages = []string{"README.md", "doc/usage.md", "doc/features.md", "doc/install.md", "doc/config.md",
"doc/keybindings.md", "doc/development.md"}
func main() {
cmd, err := newRoot(start).ExecuteC()
if err == nil {
return
}
// The errors of running a view end in fatal; those left are of the
// command line, of the command cmd.
fmt.Fprintf(os.Stderr, "jdiff: %v\n\n%s", err, cmd.UsageString())
os.Exit(2)
}
// options are the flags of the command line.
type options struct {
version, license, readme, untracked bool
features string
}
// newRoot returns the command line: jdiff OLD NEW, jdiff log and jdiff diff,
// whose arguments go to git as they are. jdiff's flags come before log and
// diff. It calls start with what to show, see start.
func newRoot(start func(o options, mode string, args []string)) *cobra.Command {
var o options
cobra.EnableCommandSorting = false // log, diff, completion
root := &cobra.Command{
Use: "jdiff [flags] OLD NEW",
Short: "An interactive diff viewer for the terminal, with manual line alignment",
Example: " jdiff old.txt new.txt compare two files, or two directories\n" +
" jdiff log -20 browse the last 20 commits\n" +
" jdiff -u diff HEAD what git diff HEAD shows, and the untracked files\n" +
" jdiff -- log log1 compare files named like a command",
Args: func(cmd *cobra.Command, args []string) error {
for _, f := range []struct {
on bool
name string
}{{o.version, "version"}, {o.license, "license"}, {o.readme, "readme"}} {
if f.on && len(args) > 0 {
return fmt.Errorf("--%s takes no arguments, not %q", f.name, args[0])
}
if f.on {
return nil
}
}
return cobra.ExactArgs(2)(cmd, args)
},
RunE: func(cmd *cobra.Command, args []string) error {
mode := ""
switch {
case o.version:
fmt.Println(resolveVersion())
return nil
case o.license:
printLicense()
return nil
case o.readme:
mode = "readme"
}
start(o, mode, args)
return nil
},
SilenceErrors: true,
SilenceUsage: true,
TraverseChildren: true, // the flags before log and diff
}
f := root.Flags()
f.BoolVar(&o.version, "version", false, "print the version")
f.BoolVar(&o.license, "license", false, "print the license and those of the libraries")
f.BoolVar(&o.readme, "readme", false, "show the README and the documentation it links to; print the README as markdown\n"+
"when the output is no terminal")
f.BoolVarP(&o.untracked, "untracked", "u", false, "also show the untracked files in a git diff; u in the view switches them.\n"+
"Neither is saved")
f.StringVar(&o.features, "features", "", "delta features to use instead of delta.features: `NAMES` of [delta \"name\"]\n"+
"sections of your git config, separated by spaces. They only shape the \"delta\" theme,\n"+
"which is used unless main.toml names another. DELTA_FEATURES, when set, wins; with a\n"+
"leading + it adds to them.")
for _, c := range []struct{ name, use, short string }{
{"log", "log [GIT LOG ARGS]", "browse the commits that git log lists"},
{"diff", "diff [GIT DIFF ARGS]", "show the files that git diff shows"},
} {
root.AddCommand(&cobra.Command{
Use: c.use,
Short: c.short,
DisableFlagParsing: true, // all of them are git's
RunE: func(cmd *cobra.Command, args []string) error {
start(o, c.name, args)
return nil
},
})
}
root.AddCommand(&cobra.Command{
Use: "completion bash|zsh|fish",
Short: "print the script that completes jdiff's arguments in the shell",
Long: "Prints the script that completes jdiff's flags and arguments in the shell; after log and diff\n" +
"it completes as git does, when git's completion is installed. To use it, e.g.:\n\n" +
" bash: source <(jdiff completion bash) in ~/.bashrc\n" +
" zsh: source <(jdiff completion zsh) in ~/.zshrc, after compinit\n" +
" fish: jdiff completion fish | source in ~/.config/fish/config.fish\n\n" +
"The packages install it, so that this is not needed.",
ValidArgs: []string{"bash", "zsh", "fish"},
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
RunE: func(cmd *cobra.Command, args []string) error {
return printCompletion(cmd.OutOrStdout(), cmd.Root(), args[0])
},
})
// A usage line for each command, jdiff's flags before log and diff, and
// no "jdiff [command] --help": the help of log and diff is git's.
usage := strings.Replace(root.UsageTemplate(), "\n {{.CommandPath}} [command]{{end}}",
"{{range .Commands}}{{if .IsAvailableCommand}}\n {{$.CommandPath}} {{if .DisableFlagParsing}}[flags] {{end}}{{.Use}}"+
"{{end}}{{end}}{{end}}", 1)
usage = strings.Replace(usage, "{{if .HasAvailableSubCommands}}\n\nUse \"{{.CommandPath}} [command] --help\" "+
"for more information about a command.{{end}}", "", 1)
root.SetUsageTemplate(usage)
root.SetHelpCommand(&cobra.Command{Hidden: true}) // --help does it
root.CompletionOptions.DisableDefaultCmd = true // the one above
return root
}
// shellParts holds what printCompletion adds to cobra's scripts, git.bash,
// git.zsh and git.fish: after log and diff, git's completion takes over.
//
//go:embed shell
var shellParts embed.FS
// printCompletion writes the completion script of root for shell to w:
// cobra's, then its part of shellParts. zsh calls a completion function by
// the name of its file, _jdiff, so cobra's _jdiff is renamed for the one of
// shellParts to call; jdiff stays bound to _jdiff.
func printCompletion(w io.Writer, root *cobra.Command, shell string) error {
var b bytes.Buffer
var err error
switch shell {
case "bash":
err = root.GenBashCompletionV2(&b, true)
case "zsh":
err = root.GenZshCompletion(&b)
case "fish":
err = root.GenFishCompletion(&b, true)
}
if err != nil {
return err
}
script := b.String()
if shell == "zsh" {
script = strings.ReplaceAll(script, "_jdiff", "_jdiff_cobra")
script = strings.Replace(script, "compdef _jdiff_cobra jdiff", "compdef _jdiff jdiff", 1)
}
part, err := shellParts.ReadFile("shell/git." + shell)
if err != nil {
return err
}
_, err = io.WriteString(w, script+"\n"+string(part))
return err
}
// start shows what mode asks for: the README, git log or git diff with the
// git arguments args, or else the two files or directories of args.
func start(o options, mode string, args []string) {
if mode == "readme" && !term.IsTerminal(os.Stdout.Fd()) {
src, err := docs.ReadFile(docPages[0])
if err != nil {
fatal(err)
}
fmt.Print(withoutBadges(string(src)))
return
}
dir, err := config.Dir()
if err != nil {
dir = "" // no home: defaults only
}
cfg, err := config.Load(dir, theme.ReadGitConfig(), os.Getenv("DELTA_FEATURES"), o.features)
if err != nil {
fatal(err)
}
repo, err := gitsrc.Repo{}.TopLevel()
if err != nil {
repo = "" // not in a repository: marks with absolute paths
}
if o.untracked {
cfg.Settings.Untracked = true
}
cfg.LoadMarks(repo)
ui.LoadKeys(cfg)
if mode == "readme" {
run(ui.WithSizeGuard(cfg, ui.WithSearch(cfg, ui.WithHelp(cfg, ui.NewDoc(cfg, docs, docPages, 0)))))
return
}
var m tea.Model
switch mode {
case "log":
m, err = loadLog(cfg, args)
case "diff":
m, err = loadDiff(cfg, args)
default:
m, err = load(cfg, args[0], args[1])
}
if err != nil {
fatal(err)
}
ui.OrderSettings()
// d replaces whatever is shown by the files of git diff, which runs in
// the current directory; the search bar and the popups are put around
// the new view too.
wrap := func(m tea.Model) tea.Model {
return ui.WithSearch(cfg, ui.WithHelp(cfg, ui.WithSettings(cfg, ui.WithMarks(cfg, m))))
}
run(ui.WithSizeGuard(cfg, ui.WithDiffSwitch(cfg, m, wrap, gitDiff(cfg)).WithAutoReload(gitsrc.Repo{}.Status, gitsrc.Repo{}.WatchPaths)))
}
// run runs the program that shows page. 16 color mode: colors are drawn as
// ANSI colors, so the terminal palette applies. Terminals without color
// support stay without.
func run(page tea.Model) {
var opts []tea.ProgramOption
if colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ANSI {
opts = append(opts, tea.WithColorProfile(colorprofile.ANSI))
}
if _, err := tea.NewProgram(page, opts...).Run(); err != nil {
fatal(err)
}
}
// load opens the file selector for two directories, as passed by
// git difftool --dir-diff, and the diff view for two files; both reload
// files that change on disk.
func load(cfg *config.Config, oldPath, newPath string) (tea.Model, error) {
oldInfo, errOld := os.Stat(oldPath)
newInfo, errNew := os.Stat(newPath)
if errOld == nil && errNew == nil && oldInfo.IsDir() && newInfo.IsDir() {
files, err := source.LoadDirs(oldPath, newPath)
if err != nil {
return nil, err
}
relist := func() ([]source.File, error) { return source.LoadDirs(oldPath, newPath) }
return ui.NewApp(cfg, files).WithRelist(relist), nil
}
// Path names the file in messages; the panes show the paths as given.
f := source.File{Path: filepath.Base(newPath), OldFile: oldPath, NewFile: newPath, InOld: true, InNew: true}
var err error
if f.Old, f.OldStamp, err = source.Read(oldPath); err != nil {
return nil, fmt.Errorf("%s: %w", oldPath, err)
}
if f.New, f.NewStamp, err = source.Read(newPath); err != nil {
return nil, fmt.Errorf("%s: %w", newPath, err)
}
return ui.NewWatched(cfg, f), nil
}
// loadLog opens the commit list for the commits git log selects with args.
func loadLog(cfg *config.Config, args []string) (tea.Model, error) {
repo := gitsrc.Repo{}
commits, err := repo.Log(args)
if err != nil {
return nil, err
}
if len(commits) == 0 {
return nil, fmt.Errorf("no commits")
}
relist := func() (string, []gitsrc.Commit, error) {
commits, err := repo.Log(args)
return repo.Branch(), commits, err
}
return ui.NewLog(cfg, repo.Branch(), commits, gitsrc.Reversed(args), repo.Files, repo.Counts).WithRelist(relist), nil
}
// loadDiff opens the file selector for the files git diff compares with
// args; files of the working tree are reloaded when they change on disk.
func loadDiff(cfg *config.Config, args []string) (tea.Model, error) {
diff := gitDiff(cfg)
files, err := diff(args)
if err != nil {
return nil, err
}
return ui.NewApp(cfg, files).WithGitDiff(args, diff), nil
}
// gitDiff returns the function running git diff, which follows the toggle of
// the untracked files.
func gitDiff(cfg *config.Config) ui.DiffFunc {
repo := gitsrc.Repo{}
return func(args []string) ([]source.File, error) {
if cfg.Settings.Untracked {
return repo.DiffUntracked(args)
}
return repo.Diff(args)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "jdiff:", err)
os.Exit(1)
}
// withoutBadges drops the lines of badges from a page of the documentation:
// images to load are of no use in a terminal.
func withoutBadges(readme string) string {
var b strings.Builder
for _, line := range strings.SplitAfter(readme, "\n") {
if !strings.HasPrefix(line, "[![") {
b.WriteString(line)
}
}
return b.String()
}
// printLicense prints jdiff's license and those of the modules linked into it.
func printLicense() {
fmt.Print(license, "\njdiff is built with these libraries, under their licenses:\n\n", thirdParty)
}
func resolveVersion() string {
bi, ok := debug.ReadBuildInfo()
if !ok {
return "dev"
}
var rev, dirty string
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
if s.Value == "true" {
dirty = "-dirty"
}
}
}
if rev == "" {
return "dev"
}
// Main.Version is the tag, or a pseudo-version containing the revision.
v := strings.TrimSuffix(bi.Main.Version, "+dirty")
if v == "" || v == "(devel)" || strings.Contains(v, rev[:min(len(rev), 12)]) {
v = rev[:min(len(rev), 7)]
}
return v + dirty
}