// 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"
"regexp"
"slices"
"strings"
"github.com/BurntSushi/toml"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/keys"
"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
Keys *keys.Map // the keys of all pages, the built-in ones with the user's
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
watchIgnore []*regexp.Regexp // watch_ignore compiled, see WatchIgnored
// 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 {
c := &Config{Theme: theme.Default(), Keys: keys.Builtin(), Settings: settings.Default()}
c.compileWatchIgnore(DefaultWatchIgnore)
return c
}
// 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
}
// minInterval and maxInterval bound watch_interval and auto_reload_interval,
// in seconds.
const (
minInterval = 0.1
maxInterval = 86400
)
// mainFile is the content of main.toml. WatchIgnore is not one of the
// settings, which views compare as a whole.
type mainFile struct {
settings.Settings
Theme string `toml:"theme"`
// WatchIgnore (watch_ignore) are regular expressions of paths from the
// top of the working tree, with / and directories with a / at the end:
// while auto reload watches the working tree, a change of a file that
// one matches does not run git status, and a directory that one matches
// is not watched, nor those below it. See WatchIgnored.
WatchIgnore []string `toml:"watch_ignore"`
}
// DefaultWatchIgnore is watch_ignore by default: editors' files beside the
// one saved, Vim's swap files, backups and the file it tries writing (4913),
// Emacs' locks and autosaves.
var DefaultWatchIgnore = []string{`\.sw[a-px]$`, `~$`, `(^|/)\.#`, `(^|/)#[^/]*#$`, `(^|/)4913$`, `\.tmp$`}
// 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{Keys: keys.Builtin(), Settings: settings.Default()}
c.Themes = []theme.Spec{theme.Builtin(), theme.FromGit(gitCfg, theme.Features(gitCfg, env, flag))}
name, ignore := "", DefaultWatchIgnore
if dir != "" {
c.dir = dir
c.statePath = filepath.Join(dir, "state.toml")
m := mainFile{Settings: c.Settings, WatchIgnore: slices.Clone(DefaultWatchIgnore)}
if err := c.decode(filepath.Join(dir, "main.toml"), &m); err != nil {
return nil, err
}
c.Settings, ignore = m.Settings, m.WatchIgnore
c.checkIntervals()
// 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
// inotify_max_dirs is a toggle: it may come from main.toml or
// state.toml, so the warning names neither.
if n := c.Settings.InotifyMaxDirs; n < 0 {
c.warnings = append(c.warnings, fmt.Sprintf("inotify_max_dirs %d is negative, it is off", n))
c.Settings.InotifyMaxDirs = 0
}
var problems []string
if c.Keys, problems = keys.Load(dir); len(problems) > 0 {
c.warnings = append(c.warnings, keys.FileName+": "+strings.Join(problems, "; ")+"; the built-in keys are used")
}
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))
c.compileWatchIgnore(ignore)
return c, nil
}
// checkIntervals clamps watch_interval and auto_reload_interval of main.toml
// to what works, with a warning for each.
func (c *Config) checkIntervals() {
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
}
// Shorter checks keep a core busy; longer ones overflow the
// durations they become.
if iv := c.Settings.WatchInterval; iv > 0 && iv < minInterval {
c.warnings = append(c.warnings, fmt.Sprintf("main.toml: watch_interval %v is below %v, %v is used", iv, minInterval, minInterval))
c.Settings.WatchInterval = minInterval
}
if iv := c.Settings.WatchInterval; iv > maxInterval {
c.warnings = append(c.warnings, fmt.Sprintf("main.toml: watch_interval %v is above %d, %d is used", iv, maxInterval, maxInterval))
c.Settings.WatchInterval = maxInterval
}
if iv := c.Settings.AutoReloadInterval; iv > maxInterval {
c.warnings = append(c.warnings, fmt.Sprintf("main.toml: auto_reload_interval %d is above %d, %d is used", iv, maxInterval, maxInterval))
c.Settings.AutoReloadInterval = maxInterval
}
}
// compileWatchIgnore compiles exprs, watch_ignore, for WatchIgnored; one that
// is no regular expression is left out with a warning.
func (c *Config) compileWatchIgnore(exprs []string) {
c.watchIgnore = nil
for _, expr := range exprs {
re, err := regexp.Compile(expr)
if err != nil {
c.warnings = append(c.warnings, fmt.Sprintf("main.toml: watch_ignore %q is not a regular expression, left out: %v", expr, err))
continue
}
c.watchIgnore = append(c.watchIgnore, re)
}
}
// WatchIgnored reports whether a pattern of watch_ignore matches rel, a path
// from the top of the working tree with /, of a directory with a / at the
// end.
func (c *Config) WatchIgnored(rel string) bool {
return slices.ContainsFunc(c.watchIgnore, func(re *regexp.Regexp) bool { return re.MatchString(rel) })
}
// 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 {
slices.Sort(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. Each write has a temporary
// file of its own: jdiffs running at the same time must not rename each
// other's.
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, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
_, err = tmp.Write(b.Bytes())
err = errors.Join(err, tmp.Close(), os.Chmod(tmp.Name(), 0o644))
if err == nil {
err = os.Rename(tmp.Name(), path)
}
if err != nil {
os.Remove(tmp.Name())
}
return err
}
// Dir returns the configuration directory the files were read from, "" for
// none.
func (c *Config) Dir() string { return c.dir }
// 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 (
"cmp"
"errors"
"fmt"
"io/fs"
"path/filepath"
"slices"
"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})
}
slices.SortFunc(out, func(a, b LetterMark) int { return cmp.Compare(a.Letter, b.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 {
c.reloadMarks()
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 {
c.reloadMarks()
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()
}
// reloadMarks reads the marks of all repositories again before a change, so
// that it keeps those another jdiff saved since this one read them. A file
// that cannot be parsed leaves the marks as they are, see LoadMarks.
func (c *Config) reloadMarks() {
if c.marks.path == "" {
return
}
all := map[string]map[string]Mark{}
if _, err := toml.DecodeFile(c.marks.path, &all); err == nil || errors.Is(err, fs.ErrNotExist) {
c.marks.all = all
}
}
func (c *Config) saveMarks() error {
if c.marks.path == "" {
return nil
}
return writeTOML(c.marks.path, "# Written by jdiff: jump marks per repository.\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. The emphasized parts of
// a Change row are not part of it: Intraline computes them for the rows
// shown, as most rows of a large change never are.
type Row struct {
Kind Kind
Left, Right int // 0-based line indices, -1 when absent
Anchored bool // the row is a user anchor
}
// 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) []Row {
al := aligner{a: a, b: b, ka: trimmed(a), kb: trimmed(b)}
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
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 trimmed(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
}
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
// MaxEmphLine is the longest line, in bytes, whose changed words are
// emphasized: comparing the words of longer ones, as of minified code, takes
// tens of milliseconds a line.
const MaxEmphLine = 4096
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, or one of them is longer than MaxEmphLine.
func Intraline(a, b string, splitIndent bool) (ea, eb []Span) {
if len(a) > MaxEmphLine || len(b) > MaxEmphLine {
return nil, nil
}
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
}
// ka and kb hold 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
// maxCommonStarts is how many places a block may start at among the added
// lines when its first two lines have fewer than minMovedAlnum letters and
// digits, as a closing brace and a blank line: found in thousands of places
// in a large change, trying them all from each removed pair took seconds.
const maxCommonStarts = 64
// 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. It is
// one function on purpose: its closures share the index of the added lines
// and the lines already in a block, so it reads top to bottom; should it
// grow, a struct holding that state is the way to split it.
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.
// With limit above 0 it tries only the first limit of them still free.
longest := func(i, l int, cands []Ref, limit int) (best Ref, n int) {
tried := 0
for _, c := range cands {
if limit > 0 {
if added[c.File].used[c.Line] {
continue
}
if tried++; tried > limit {
break
}
}
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 {
// The letters and digits of each line, counted once: a block is
// counted again for each line it may start at.
counted := make([]int, len(f.Old))
for l := range counted {
counted[l] = -1
}
letters := func(from, to int) int {
n := 0
for l := from; l < to; l++ {
if counted[l] < 0 {
counted[l] = alnum(f.Old[l : l+1])
}
n += counted[l]
}
return n
}
for l := 0; l < len(f.Old); l++ {
// Only a removed line not in a block yet starts one, see
// matches.
if _, ok := removed[i].row[l]; !ok || removed[i].used[l] {
continue
}
// 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) {
limit := 0
if letters(l, l+2) < minMovedAlnum {
limit = maxCommonStarts
}
best, n = longest(i, l, pairs[pairKey(f.Old, l)], limit)
}
if n < 2 && letters(l, l+1) >= minMovedAlnum {
best, n = longest(i, l, candidates[strings.TrimSpace(f.Old[l])], 0)
}
if n == 0 || letters(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"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"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 // Date: of the author, local, as 2006-01-02 15:04
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
ctx context.Context // ends the git commands, see WithContext
}
// WithContext returns r running its git commands with ctx: when it is done,
// they are killed and the methods return its error. Without, they run to
// their end.
func (r Repo) WithContext(ctx context.Context) Repo {
r.ctx = ctx
return r
}
// running counts the git commands that run, see Wait.
var running sync.WaitGroup
// Wait waits until the git commands that run have ended, at most d, and
// reports whether they have. Killing the commands of a cancelled context
// takes a moment, which a program that ends must wait for: else they run
// on without it.
func Wait(d time.Duration) bool {
done := make(chan struct{})
go func() {
running.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
// command returns the command running git with args in r.Dir.
func (r Repo) command(args ...string) *exec.Cmd {
ctx := r.ctx
if ctx == nil {
ctx = context.Background()
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = r.Dir
return cmd
}
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 := r.command(args...)
cmd.Env = append(cmd.Environ(), env...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
running.Add(1)
out, err := cmd.Output()
running.Done()
if err != nil {
return nil, r.gitError(args[0], &stderr, err)
}
return out, nil
}
// gitError returns the error of git name that failed with err: the context's
// error when it was cancelled, as what git says then is of no interest, else
// what git wrote to stderr, else err.
func (r Repo) gitError(name string, stderr *bytes.Buffer, err error) error {
if r.ctx != nil && r.ctx.Err() != nil {
return r.ctx.Err()
}
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return fmt.Errorf("git %s: %s", name, msg)
}
return fmt.Errorf("git %s: %w", name, err)
}
// 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
}
// logFormatFlags and diffFormatFlags are the flags of git log and git diff
// that change the output jdiff reads, alone or with =value.
var (
logFormatFlags = []string{"--oneline", "--pretty", "--format", "--graph", "--show-signature"}
diffFormatFlags = []string{"-p", "-u", "--patch", "-s", "--no-patch", "--stat", "--numstat", "--shortstat",
"--dirstat", "--name-only", "--name-status", "--summary", "--compact-summary", "--check",
"--patch-with-raw", "--patch-with-stat", "--output"}
)
// checkFormat returns an error naming the first argument before a -- that
// is one of flags: they change the output of git command, which jdiff reads.
func checkFormat(command string, args, flags []string) error {
for _, a := range args {
if a == "--" {
break
}
name, _, _ := strings.Cut(a, "=")
if slices.Contains(flags, name) {
return fmt.Errorf("git %s: %s changes the output jdiff reads, leave it out", command, name)
}
}
return nil
}
// 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. Signatures are not shown, even with log.showSignature set: their
// output would come between the records.
func (r Repo) Log(args []string) ([]Commit, error) {
if err := checkFormat("log", args, logFormatFlags); err != nil {
return nil, err
}
out, err := r.git(append([]string{"log", "--no-show-signature",
"--format=%x1e%H%x1f%h%x1f%an%x1f%ad%x1f%P%x1f%s%x1f%b%x1d",
"--date=format-local:%Y-%m-%d %H:%M"}, 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")
fields := strings.Split(header, "\x1f")
if len(fields) != 7 {
return nil, fmt.Errorf("git log: unexpected record %q", header)
}
c := Commit{Hash: fields[0], Short: fields[1], Author: fields[2], Date: fields[3],
Subject: fields[5], Body: strings.TrimSpace(fields[6])}
c.Parent, _, _ = strings.Cut(fields[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", "--no-show-signature", "--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)))
}
return r.gitPaths(names...)
}
// gitPaths returns where git keeps the files names, also in a worktree:
// absolute and cleaned, as git gives them relative to where it ran.
func (r Repo) gitPaths(names ...string) ([]string, error) {
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
}
// vcsDirs are where version control systems keep their own data, which is
// not worked on: WorkDirs leaves them out, and what is below them. Fossil
// keeps its data in files.
var vcsDirs = []string{".git", ".svn", ".hg", ".bzr", "CVS", "_darcs", ".jj", ".pijul", ".sl"}
// WorkDirs returns the directories of the working tree that hold its files,
// tracked or untracked and not ignored, with the time each was modified, as
// when a file there is created, deleted or renamed, as editors save; the top
// level always. A directory takes one lstat, not each of its files, so that
// this stays cheap in large repositories. A submodule and a nested
// repository count as entries of the directory they are in, which is not
// entered. Left out are the directories of vcsDirs, and those for which
// ignore holds of their path from the top with / and a / at the end, with
// the directories below them.
func (r Repo) WorkDirs(ignore func(rel string) bool) (map[string]time.Time, error) {
top, err := r.TopLevel()
if err != nil {
return nil, err
}
// All files, also from a subdirectory, by their paths from the top.
out, err := r.git("ls-files", "-z", "--cached", "--others", "--exclude-standard", "--full-name", "--", ":/")
if err != nil {
return nil, err
}
dirs := map[string]time.Time{top: {}}
left := map[string]bool{} // directories from the top, whether left out
var leftOut func(rel string) bool
leftOut = func(rel string) bool {
if rel == "." {
return false
}
if l, ok := left[rel]; ok {
return l
}
l := slices.Contains(vcsDirs, path.Base(rel)) || ignore(rel+"/") || leftOut(path.Dir(rel))
left[rel] = l
return l
}
for _, rel := range strings.Split(string(out), "\x00") {
// A nested repository comes as its directory with a /.
rel = strings.TrimSuffix(rel, "/")
if rel == "" || leftOut(path.Dir(rel)) {
continue
}
dirs[filepath.Dir(filepath.Join(top, filepath.FromSlash(rel)))] = time.Time{}
}
for dir := range dirs {
if info, err := os.Lstat(dir); err == nil {
dirs[dir] = info.ModTime()
}
}
return dirs, 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
}
// StatusPaths returns the paths of the files that the lines of status, from
// Status, name and those of old do not: changed, renamed (the new path),
// unmerged, untracked or ignored. They are relative to where git ran. Paths
// git quotes, with special characters, are left out.
func StatusPaths(status, old string) []string {
had := map[string]bool{}
for _, line := range strings.Split(old, "\n") {
had[line] = true
}
var paths []string
for _, line := range strings.Split(status, "\n") {
if had[line] || len(line) < 2 {
continue
}
// The fields before the path, which may hold spaces: see git status
// --porcelain=v2.
var p string
switch line[0] {
case '1':
p = field(line, 8)
case '2':
p, _, _ = strings.Cut(field(line, 9), "\t")
case 'u':
p = field(line, 10)
case '?', '!':
p = line[2:]
}
if p != "" && !strings.HasPrefix(p, `"`) {
paths = append(paths, filepath.FromSlash(p))
}
}
return paths
}
// field returns the rest of line after n fields separated by single spaces,
// "" when it has fewer.
func field(line string, n int) string {
parts := strings.SplitN(line, " ", n+1)
if len(parts) <= n {
return ""
}
return parts[n]
}
// Files returns the files c changed against its first parent, or against
// the empty tree for a root commit, with their contents: what Diff returns
// for the two commits, but without asking for the top of the working tree,
// as two commits have no side in it and a bare repository has none.
func (r Repo) Files(c Commit) ([]source.File, error) {
base := c.Parent
if base == "" {
base = emptyTree
}
return r.diffFiles(nil, "", []string{base, c.Hash})
}
// 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.
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 (see readWorking) and, but
// symlinks, 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, ctx: r.ctx}.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, ctx: r.ctx}).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) {
paths, err := r.gitPaths("index")
if err != nil {
return "", err
}
from := paths[0]
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) {
if err := checkFormat("diff", args, diffFormatFlags); err != nil {
return nil, err
}
top, err := r.TopLevel()
if err != nil {
return nil, err
}
return r.diffFiles(env, top, args)
}
// diffFiles returns the files git diff compares with args, with env added to
// its environment. Sides git names by the zero id are read from the working
// tree at top; Files passes none, as two commits have no such sides. Blobs
// are requested by object id, not by "rev:path": git cat-file reads one
// request per line, and paths may hold newlines.
func (r Repo) diffFiles(env []string, top string, args []string) ([]source.File, error) {
// 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 fileSide struct {
blob int // index into specs, -1 for the working copy
path string
}
var files []source.File
var sides []fileSide
var specs []string
for _, e := range entries {
f := e.file
files = append(files, f)
for _, want := range []struct {
in bool
id string
path string
}{{f.InOld, e.oldID, f.PathInOld()}, {f.InNew, e.newID, f.Path}} {
if !want.in {
continue
}
s := fileSide{blob: -1, path: want.path}
if strings.Trim(want.id, "0") != "" {
s.blob = len(specs)
specs = append(specs, want.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
}
side := sides[n]
n++
var content []string
binary := false
if side.blob >= 0 {
content, binary = lines(blobs[side.blob])
} else {
path := filepath.Join(top, filepath.FromSlash(side.path))
w := readWorking(path)
content, binary = w.lines, w.binary
if w.watch {
if old {
f.OldFile, f.OldStamp = path, w.stamp
} else {
f.NewFile, f.NewStamp = path, w.stamp
}
}
}
f.Binary = f.Binary || binary
if old {
f.Old = content
} else {
f.New = content
}
}
}
return files, nil
}
// workingSide is a side of a file read from the working tree, see
// readWorking.
type workingSide struct {
lines []string
stamp source.Stamp
binary bool
watch bool // reloaded when it changes on disk
}
// readWorking reads the side of a file in the working tree at path as git
// compares it, so that one file cannot fail the whole list: a symlink by
// the path it points to, as git stores it, also one to a directory, and not
// watched, since watching reads what it points to; a directory, as of a
// submodule, and a file that cannot be read as binary, not watched; binary
// content as binary, watched; a file gone since git listed it as empty,
// watched so that it shows when it is back.
func readWorking(path string) workingSide {
info, err := os.Lstat(path)
switch {
case errors.Is(err, fs.ErrNotExist):
return workingSide{watch: true}
case err != nil:
return workingSide{binary: true}
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
return workingSide{binary: true}
}
return workingSide{lines: []string{target}}
}
l, stamp, err := source.Read(path)
switch {
case errors.Is(err, source.ErrBinary):
return workingSide{stamp: stamp, binary: true, watch: true}
case err != nil:
return workingSide{binary: true}
}
return workingSide{lines: l, stamp: stamp, watch: true}
}
// lines splits a blob, reporting missing blobs (e.g. submodules) and
// binary content as binary.
func lines(b blob) ([]string, bool) {
if b.missing {
return nil, true
}
l, err := source.SplitLines(b.data)
return l, 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 := r.command("cat-file", "--batch")
var stderr bytes.Buffer
cmd.Stderr = &stderr
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
}
running.Add(1)
defer running.Done()
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.
// When the context was cancelled, git was killed and err is only the
// end of its output: the context's error is returned, as by run.
fail := func(err error) ([]blob, error) {
cmd.Process.Kill()
cmd.Wait()
if r.ctx != nil && r.ctx.Err() != nil {
return nil, r.ctx.Err()
}
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"
fields := strings.Fields(header)
if len(fields) != 3 {
blobs[i].missing = true
continue
}
size, err := strconv.Atoi(fields[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: fields[1] != "blob"}
}
if err := cmd.Wait(); err != nil {
return nil, r.gitError("cat-file", &stderr, err)
}
return blobs, nil
}
// Package keys holds the keymap of jdiff: keybindings.toml, which binds the
// keys of all pages to actions, gives their words and decides the order of
// the settings, with the keys of the user's keybindings.toml over it. A Map
// is built once, with the configuration, and not changed after.
package keys
import (
_ "embed"
"errors"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
// BuiltinText is the built-in keybindings.toml.
//
//go:embed keybindings.toml
var BuiltinText string
// Row is a row of keybindings.toml.
type Row 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 }
// Map holds keybindings.toml: the rows of each page and the help's lines.
type Map struct {
CommitList []Row `toml:"commit_list"`
FileList []Row `toml:"file_list"`
DiffView []Row `toml:"diff_view"`
FlowView []Row `toml:"flow_view"`
Everywhere []Row `toml:"everywhere"`
DocView []Row `toml:"doc_view"`
Help []HelpRow `toml:"help"`
}
// page is a page of keybindings.toml: its name and its rows.
type page struct {
name string
rows *[]Row
}
// pages returns the pages of km in the order of keybindings.toml, which
// decides which page's row Row and RowsOf return.
func (km *Map) pages() []page {
return []page{
{"everywhere", &km.Everywhere}, {"diff_view", &km.DiffView}, {"flow_view", &km.FlowView},
{"commit_list", &km.CommitList}, {"file_list", &km.FileList}, {"doc_view", &km.DocView},
}
}
// Parse returns the Map of text, a keybindings.toml.
func Parse(text string) (Map, error) {
var p Map
_, err := toml.Decode(text, &p)
return p, err
}
// builtin is BuiltinText, read. It is part of the program, so that failing to
// read it is a bug, which TestKeybindingsFile finds.
var builtin = func() Map {
p, err := Parse(BuiltinText)
if err != nil {
panic(err)
}
return p
}()
// Builtin returns the built-in keys. They are shared: do not change them.
func Builtin() *Map { return &builtin }
// 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 (km Map) Row(action string) Row {
if rows := km.RowsOf(action); len(rows) > 0 {
return rows[0]
}
return Row{}
}
// RowsOf returns the rows of action in the first page that has it, in the
// order of Row.
func (km Map) RowsOf(action string) []Row {
for _, pg := range km.pages() {
var of []Row
for _, r := range *pg.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]+))?\}`)
// Text returns s with each placeholder replaced by the keys of its row, ""
// for a row that does not exist.
func (km Map) Text(s string) string {
return placeholder.ReplaceAllStringFunc(s, func(m string) string {
sub := placeholder.FindStringSubmatch(m)
n, _ := strconv.Atoi(sub[2])
if rows := km.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
}
// ApplyUserKeys returns km with the keys of text, the user's
// keybindings.toml, applied over them, 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 (km Map) ApplyUserKeys(text string) (Map, []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 km, []string{err.Error()}
}
var problems []string
for _, k := range md.Undecoded() {
problems = append(problems, "unknown "+k.String())
}
merged := km
fromFile := map[string][]userRow{
"everywhere": u.Everywhere, "diff_view": u.DiffView, "flow_view": u.FlowView,
"commit_list": u.CommitList, "file_list": u.FileList, "doc_view": u.DocView,
}
for _, pg := range merged.pages() {
for _, r := range fromFile[pg.name] {
switch {
case !slices.ContainsFunc(*pg.rows, func(b Row) bool { return b.Action == r.Action }):
problems = append(problems, "no action "+r.Action+" in "+pg.name)
case len(r.Keys) == 0:
problems = append(problems, r.Action+" in "+pg.name+" has no keys")
case slices.Contains(twoWay, r.Action) && len(r.Keys) != 2:
problems = append(problems, r.Action+" in "+pg.name+" takes two keys, one each way")
}
}
*pg.rows = replaceKeys(*pg.rows, fromFile[pg.name])
}
problems = append(problems, merged.conflicts()...)
if len(problems) > 0 {
return km, 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 []Row, user []userRow) []Row {
var out []Row
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 []Row
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 := Row{Action: u.Action}
if i < len(old) {
row = old[i]
}
row.Keys = u.Keys
out = append(out, row)
i++
}
}
return out
}
// FileName is the user's keybindings.toml in the configuration directory.
const FileName = "keybindings.toml"
// Load returns the built-in keys with those of the user's keybindings.toml in
// the configuration directory dir, if there is one, and what is wrong with
// it; with a problem, the built-in keys.
func Load(dir string) (*Map, []string) {
data, err := os.ReadFile(filepath.Join(dir, FileName))
if errors.Is(err, fs.ErrNotExist) {
return Builtin(), nil
}
if err != nil {
return Builtin(), []string{err.Error()}
}
p, problems := Builtin().ApplyUserKeys(string(data))
if len(problems) > 0 {
return Builtin(), problems
}
return &p, nil
}
// Page is a page whose keys are looked up in keybindings.toml.
type Page int
const (
CommitList Page = iota
FileList
DiffView
FlowView
DocView
Everywhere // the popups over all pages
)
// Hit is what a key does: the action of its row, "" for none, and its index
// in the row, which gives the way of actions that go two ways (see
// keybindings.toml).
type Hit struct {
Action string
Index int
}
// tables returns the rows the keys of page pg are looked up in, in order.
func (km Map) tables(pg Page) [][]Row {
switch pg {
case CommitList:
return [][]Row{km.CommitList, km.Everywhere}
case FileList:
return [][]Row{km.FileList, km.Everywhere}
case DiffView:
return [][]Row{km.DiffView, km.Everywhere}
case FlowView:
return [][]Row{km.FlowView, km.DiffView, km.Everywhere}
case DocView:
return [][]Row{km.DocView, km.Everywhere}
}
return [][]Row{km.Everywhere}
}
// Key returns what key k, as Bubble Tea names it, does on page pg.
func (km Map) Key(pg Page, k string) Hit {
for _, rows := range km.tables(pg) {
for _, r := range rows {
for i, name := range r.Keys {
if Name(name) == k {
return Hit{r.Action, i}
}
}
}
}
return Hit{}
}
// Name 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 Name(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
}
// LabelIn returns the word of the status bar in state, "" when there is none.
func (r Row) LabelIn(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 (km Map) Order() []string {
var keys []string
for _, r := range km.Everywhere {
if len(r.Keys) > 0 {
keys = append(keys, r.Keys[0])
}
}
return keys
}
// Problems returns what is wrong with km by itself: a key with two actions on
// one page, a line of the help with an action that has no help, a placeholder
// without its row. What the settings and the help need of it is checked by
// the package ui.
func (km Map) Problems() []string {
problems := km.conflicts()
for _, r := range km.Help {
if r.Action != "" && km.Row(r.Action).Help == "" {
problems = append(problems, "the help's action "+r.Action+" has no help")
}
}
var words []string
for _, pg := range km.pages() {
for _, r := range *pg.rows {
words = append(words, r.Help, r.Setting)
}
}
for _, r := range km.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(km.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 (km Map) conflicts() []string {
var problems []string
for _, pg := range km.pages() {
actions := map[string]string{}
for _, r := range *pg.rows {
for _, name := range r.Keys {
if a, ok := actions[Name(name)]; ok && a != r.Action {
problems = append(problems, "key "+name+" has two actions in "+pg.name+": "+a+", "+r.Action)
}
actions[Name(name)] = r.Action
}
}
}
return problems
}
// 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
// that are not watched (InotifyMaxDirs) are checked for changes and
// reloaded while auto reload is on: both files of a comparison, or the
// current file of the file list and its neighbors. 0 turns the checks
// off, and watching too.
WatchInterval float64 `toml:"watch_interval"`
// 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. The key of action
// untracked 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. The keys
// are those of the action named with each, in keybindings.toml of package
// keys, which the user may change.
type Toggles struct {
// Flow (flow) shows all files one after the other instead of one file
// at a time; action flow.
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; action layout.
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; action preview.
Preview bool `toml:"preview"`
// ShowWhitespaces (show_whitespaces) draws all whitespace as · and →,
// not only changed whitespace, and the end of lines as LineEnd; action
// whitespace.
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; action mark_whitespace.
MarkWhitespace bool `toml:"mark_whitespace"`
// ShowTrailing (show_trailing) draws whitespace at the end of lines as ·
// and → in the trailing whitespace style; action trailing.
ShowTrailing bool `toml:"show_trailing"`
// ShowCR (show_cr) draws the \r of all \r\n line ends as CREnd, not
// only of changed ones; action line_ends.
ShowCR bool `toml:"show_cr"`
// Wrap (wrap) wraps lines longer than their pane onto further screen
// lines; off, they are cut and scrolled sideways; action wrap.
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; action bold.
BoldChanges bool `toml:"bold_changes"`
// FullMessages (full_messages) shows the whole commit message in the
// flow view of the log; off, only the subject; action full_messages.
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, the keys
// of action history still switch commits; action stay_in_commit.
StayInCommit bool `toml:"stay_in_commit"`
// SearchRegex (search_regex) searches, action search, 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, action search, 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"`
// InotifyMaxDirs (inotify_max_dirs) is the most directories whose
// changes the system reports (on Linux, inotify) while auto reload is on,
// the most recently used ones: the directories of the files on disk, not
// their subdirectories, and those of the working tree; HEAD and the index
// are watched besides. Files in the others are checked every
// WatchInterval. 0 watches only HEAD and the index. The settings popup
// sets it.
InotifyMaxDirs int `toml:"inotify_max_dirs"`
}
// Default returns the default settings.
func Default() Settings {
return Settings{
Context: 3,
CollapseUnchanged: true,
OnlyChangedWhitespaces: true,
WatchInterval: 1,
AutoReloadInterval: 10,
LineEnd: "¶",
CREnd: "␍",
Extends: "»",
Precedes: "«",
Toggles: Toggles{
SideBySide: true,
Preview: true,
ShowWhitespaces: false,
MarkWhitespace: true,
BoldChanges: true,
FullMessages: true,
AutoReloadTimeout: 5,
InotifyMaxDirs: 100,
},
}
}
// 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
}
// Read reads a text file into lines without their newlines, and returns the
// stamp of the version read; the \r of a \r\n line end is kept, so that
// changed line ends show. 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
HeadingStyle *string `toml:"heading-style" git:"jdiff"` // headings of lists and boxes, the active pane's title
InactiveHeadingStyle *string `toml:"inactive-heading-style" git:"jdiff"` // the inactive pane's title, side by side
KeyStyle *string `toml:"key-style" git:"jdiff"` // the keys in the help
// 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"),
HeadingStyle: str("bold"),
InactiveHeadingStyle: str("dim"),
KeyStyle: str("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
Heading lipgloss.Style // headings of lists and boxes, the active pane's title
InactiveHeading lipgloss.Style // the inactive pane's title, side by side
Key lipgloss.Style // the keys in the help
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. It lists the options one per line, in the order of Theme, on
// purpose: a new option is one more line, the helpers are those of resolver.
func Resolve(sp Spec) *Theme {
def := Builtin()
t := &Theme{}
r := &resolver{sp: sp, t: t}
t.Minus = r.style("minus-style", sp.MinusStyle, def.MinusStyle)
t.MinusEmph = r.style("minus-emph-style", sp.MinusEmphStyle, def.MinusEmphStyle)
t.Plus = r.style("plus-style", sp.PlusStyle, def.PlusStyle)
t.PlusEmph = r.style("plus-emph-style", sp.PlusEmphStyle, def.PlusEmphStyle)
t.MinusNonEmph = r.styleOr("minus-non-emph-style", sp.MinusNonEmphStyle, "minus-style", sp.MinusStyle, def.MinusStyle)
t.PlusNonEmph = r.styleOr("plus-non-emph-style", sp.PlusNonEmphStyle, "plus-style", sp.PlusStyle, def.PlusStyle)
t.Zero = r.style("zero-style", sp.ZeroStyle, def.ZeroStyle)
t.NumMinus = r.style("line-numbers-minus-style", sp.LineNumbersMinusStyle, def.LineNumbersMinusStyle)
t.NumPlus = r.style("line-numbers-plus-style", sp.LineNumbersPlusStyle, def.LineNumbersPlusStyle)
t.NumZero = r.style("line-numbers-zero-style", sp.LineNumbersZeroStyle, def.LineNumbersZeroStyle)
t.NumLeft = r.style("line-numbers-left-style", sp.LineNumbersLeftStyle, def.LineNumbersLeftStyle)
t.NumRight = r.style("line-numbers-right-style", sp.LineNumbersRightStyle, def.LineNumbersRightStyle)
// Only the color of the decoration is used: jdiff draws no underlines.
decoration := r.style("file-decoration-style", sp.FileDecorationStyle, def.FileDecorationStyle)
t.Title = r.style("file-style", sp.FileStyle, def.FileStyle)
t.Decoration = lipgloss.NewStyle().Foreground(decoration.GetForeground())
t.Hint = r.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 = r.style("anchor-style", sp.AnchorStyle, def.AnchorStyle)
t.Pending = r.style("pending-style", sp.PendingStyle, def.PendingStyle)
t.Cursor = r.style("cursor-style", sp.CursorStyle, def.CursorStyle)
t.FileBar = r.style("file-bar-style", sp.FileBarStyle, def.FileBarStyle)
t.CommitBar = r.style("commit-bar-style", sp.CommitBarStyle, def.CommitBarStyle)
t.CommitHash = r.style("commit-hash-style", sp.CommitHashStyle, def.CommitHashStyle)
t.Branch = r.style("branch-style", sp.BranchStyle, def.BranchStyle)
t.MinusMoved = r.style("minus-moved-style", sp.MinusMovedStyle, def.MinusMovedStyle)
t.PlusMoved = r.style("plus-moved-style", sp.PlusMovedStyle, def.PlusMovedStyle)
t.Filler = r.styleOrStyle("filler-style", sp.FillerStyle, t.Decoration)
t.Whitespace = r.styleOrStyle("whitespace-style", sp.WhitespaceStyle, t.Decoration)
t.TrailingWhitespace = r.style("trailing-whitespace-style", sp.TrailingWhitespaceStyle, def.TrailingWhitespaceStyle)
t.HiddenChar = r.style("hidden-char-style", sp.HiddenCharStyle, def.HiddenCharStyle)
t.Overflow = r.style("overflow-style", sp.OverflowStyle, def.OverflowStyle)
t.More = r.styleOrStyle("more-style", sp.MoreStyle, t.Whitespace)
t.Search = r.style("search-style", sp.SearchStyle, def.SearchStyle)
// Changed whitespace is drawn as · and →, by default like changed words.
t.MinusIndent = r.styleOrStyle("minus-indent-style", sp.MinusIndentStyle, t.MinusEmph)
t.PlusIndent = r.styleOrStyle("plus-indent-style", sp.PlusIndentStyle, t.PlusEmph)
t.DocHeading = r.styleOrStyle("doc-heading-style", sp.DocHeadingStyle, t.Branch)
t.DocCode = r.styleOrStyle("doc-code-style", sp.DocCodeStyle, t.Title)
t.DocLink = r.style("doc-link-style", sp.DocLinkStyle, def.DocLinkStyle)
t.Heading = r.style("heading-style", sp.HeadingStyle, def.HeadingStyle)
t.InactiveHeading = r.style("inactive-heading-style", sp.InactiveHeadingStyle, def.InactiveHeadingStyle)
t.Key = r.style("key-style", sp.KeyStyle, def.KeyStyle)
return t
}
// resolver resolves the options of sp into t, reporting invalid values in
// t.Warnings: the helpers of Resolve, which lists the options one per line.
type resolver struct {
sp Spec
t *Theme
}
// name returns how a warning names option key.
func (r *resolver) name(key string) string {
switch {
case r.sp.Kind == KindFile:
return r.sp.Name + ".toml: " + key
case isJdiffKey(key):
return "jdiff." + key
}
return "delta." + key
}
// val returns v, or the default d when v is unset.
func val(v, d *string) string {
if v != nil {
return *v
}
return *d
}
// warn reports err of option key once.
func (r *resolver) warn(key string, err error) {
// Options that default to another one may report its error again.
if w := fmt.Sprintf("%s: %v", r.name(key), err); !slices.Contains(r.t.Warnings, w) {
r.t.Warnings = append(r.t.Warnings, w)
}
}
// parse returns the style s of option key, fallback when s is invalid.
func (r *resolver) parse(key, s, fallback string) lipgloss.Style {
st, err := ParseStyle(s, r.sp.Colors)
if err != nil {
r.warn(key, err)
st, _ = ParseStyle(fallback, nil)
}
return st
}
// style returns option key with value v and default d.
func (r *resolver) style(key string, v, d *string) lipgloss.Style {
if v != nil {
return r.parse(key, *v, *d)
}
return r.parse(key, *d, "")
}
// styleOr returns option key with value v, which defaults to option fbKey
// with value fv and default fd.
func (r *resolver) styleOr(key string, v *string, fbKey string, fv, fd *string) lipgloss.Style {
if v != nil {
return r.parse(key, *v, val(fv, fd))
}
return r.style(fbKey, fv, fd)
}
// styleOrStyle returns option key with value v, which defaults to the style
// d, also when it is invalid.
func (r *resolver) styleOrStyle(key string, v *string, d lipgloss.Style) lipgloss.Style {
if v == nil {
return d
}
st, err := ParseStyle(*v, r.sp.Colors)
if err != nil {
r.warn(key, err)
return d
}
return st
}
package ui
import (
"context"
"fmt"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"jdiff/internal/config"
"jdiff/internal/source"
"jdiff/watch"
)
// App is the top-level model of jdiff: it wraps the page shown, whatever it
// is, and keeps what all pages share: the watcher, auto reload, and git diff
// switched to by d, which asks for the arguments of git diff in a popup,
// whose files then take the page's place, as with jdiff diff. When git
// fails, the popup stays open and says why.
type App struct {
tea.Model // the view in its wrappers
view tea.Model // the view itself
wrap func(tea.Model) tea.Model
cfg *config.Config
// w is the watcher the views share, nil where the system has none;
// limit is the inotify_max_dirs it has.
w *watch.Watcher
limit int
auto autoReload
popup diffPopup
width int
height int
}
// diffPopup is the popup of git diff of App: run lists the diff of the
// arguments asked for in input, which is set while it asks; err is why git
// failed, until the next key. running is set while git diff runs for the
// request number gen of show; cancel ends it.
type diffPopup struct {
run DiffFunc
input *lineInput
err string
running bool
gen int
cancel context.CancelFunc
}
// NewApp returns the app showing view in the search bar, the help, the
// settings and the marks popups, as it does the views that replace it;
// gitDiff runs git diff.
func NewApp(cfg *config.Config, view tea.Model, gitDiff DiffFunc) *App {
return newApp(cfg, view, func(m tea.Model) tea.Model {
return WithSearch(cfg, WithHelp(cfg, WithSettings(cfg, WithMarks(cfg, m))))
}, gitDiff)
}
// newApp returns the app showing view, which wrap puts in its wrappers, as
// it does the views that replace it. The tests choose the wrappers.
func newApp(cfg *config.Config, view tea.Model, wrap func(tea.Model) tea.Model, gitDiff DiffFunc) *App {
return &App{Model: wrap(view), view: view, wrap: wrap, cfg: cfg, popup: diffPopup{run: gitDiff}}
}
// WithAutoReload sets how auto reload gets the state of the repository, the
// files to watch for changes of it and the directories of its working tree,
// and returns s.
func (s *App) WithAutoReload(status func(ctx context.Context) (string, error), paths func() ([]string, error),
dirs func(ignore func(rel string) bool) (map[string]time.Time, error)) *App {
s.auto.repoStatus, s.auto.repoPaths, s.auto.workDirs = status, paths, dirs
return s
}
// Init starts the watcher the views share, unless watching is off, and
// gives it to the view, before it starts.
func (s *App) Init() tea.Cmd {
var wait tea.Cmd
if s.cfg.Settings.WatchInterval > 0 {
if w, err := watch.New(s.cfg.Settings.InotifyMaxDirs); err == nil {
s.w, s.limit, wait = w, s.cfg.Settings.InotifyMaxDirs, waitChanges(w)
s.share()
}
}
return batch(s.Model.Init(), wait)
}
// share gives the watcher to the view, if it watches files.
func (s *App) share() {
if v, ok := s.view.(interface{ shareWatcher(*watch.Watcher) }); ok && s.w != nil {
v.shareWatcher(s.w)
}
}
// current returns the arguments of the git diff shown, and whether the view
// shows one.
func (s *App) current() ([]string, bool) {
if d, ok := s.view.(*Diff); ok && d.gitDiff != nil {
return d.diffArgs, true
}
return nil, false
}
func (s *App) 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.popup.input, s.popup.err = &lineInput{}, ""
s.endDiff()
return s, nil
case diffListedMsg:
return s, s.showDiff(msg)
case askAutoReloadMsg:
return s, s.toggleAutoReload()
case autoTickMsg:
return s, s.onAutoTick(msg)
case autoStatusMsg:
return s, s.onAutoStatus(msg)
case changedMsg:
if s.w != nil && msg.w == s.w {
check := s.onRepoChanged(msg.paths)
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(msg)
return s, batch(cmd, check, waitChanges(s.w))
}
case watchEndedMsg:
if s.w != nil && msg.w == s.w {
s.w = nil // the regular checks still see all changes; the view checks its files
}
case repoWatchMsg:
s.onRepoWatch(msg)
return s, nil
case tea.KeyPressMsg:
s.auto.lastActive = time.Now()
if s.popup.input != nil {
s.popup.err = ""
return s, s.key(msg)
}
case tea.PasteMsg:
if s.popup.input != nil {
s.popup.err = ""
s.popup.input.paste(msg.Content)
return s, nil
}
}
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(msg)
// Set in the settings popup; Diff and FileDiff applied it already
// (diskWatch.limit), not the views without files on disk. See "Kept on
// purpose" in doc/development.md.
if s.w != nil && s.limit != s.cfg.Settings.InotifyMaxDirs {
s.limit = s.cfg.Settings.InotifyMaxDirs
s.w.SetLimit(s.limit)
}
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 *App) key(k tea.KeyPressMsg) tea.Cmd {
in := s.popup.input
switch {
case k.String() == "ctrl+c":
return tea.Quit
case in.cancels(k):
s.popup.input = nil
s.endDiff()
return nil
}
switch k.String() {
case "enter":
args, err := splitArgs(in.text)
if err != nil {
s.popup.err = err.Error()
return nil
}
if len(args) == 0 {
s.popup.input = nil
return nil
}
return s.show(args)
case "up":
if args, ok := s.current(); ok {
in.text = joinArgs(args)
}
default:
in.edit(k)
}
return nil
}
// splitArgs splits text into arguments at spaces, as a shell does for
// quotes: 'single' and "double" quotes keep spaces, a backslash outside
// single quotes the character after it.
func splitArgs(text string) ([]string, error) {
var args []string
var cur strings.Builder
inArg, quote, escaped := false, rune(0), false
for _, r := range text {
switch {
case escaped:
cur.WriteRune(r)
escaped = false
case r == '\\' && quote != '\'':
escaped, inArg = true, true
case quote != 0 && r == quote:
quote = 0
case quote != 0:
cur.WriteRune(r)
case r == '\'' || r == '"':
quote, inArg = r, true
case r == ' ':
if inArg {
args, inArg = append(args, cur.String()), false
cur.Reset()
}
default:
cur.WriteRune(r)
inArg = true
}
}
if quote != 0 {
return nil, fmt.Errorf("missing closing %c", quote)
}
if inArg {
args = append(args, cur.String())
}
return args, nil
}
// joinArgs returns args as splitArgs reads them back: quoted where they hold
// spaces, quotes or backslashes, or are empty.
func joinArgs(args []string) string {
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = a
if a == "" || strings.ContainsAny(a, " '\"\\") {
quoted[i] = "'" + strings.ReplaceAll(a, "'", `'"'"'`) + "'"
}
}
return strings.Join(quoted, " ")
}
// endDiff ends the git diff of the popup, if one runs: its files are no
// longer wanted.
func (s *App) endDiff() {
s.popup.running = false
if s.popup.cancel != nil {
s.popup.cancel()
s.popup.cancel = nil
}
}
// stop ends all git commands of s and its view: the program ends.
func (s *App) stop() {
s.endDiff()
s.stopAutoReload()
if v, ok := s.view.(interface{ stop() }); ok {
v.stop()
}
if s.w != nil {
s.w.Close()
}
}
// diffListedMsg brings the files of git diff with args, run for the request
// number gen of show, or why git failed.
type diffListedMsg struct {
gen int
args []string
files []source.File
err error
}
// show returns the command running git diff with args in the background,
// for showDiff. The popup stays open meanwhile and says so; esc closes it
// and drops the files, as another request does.
func (s *App) show(args []string) tea.Cmd {
s.endDiff()
s.popup.gen++
s.popup.running = true
ctx, cancel := context.WithCancel(context.Background())
s.popup.cancel = cancel
gen, untracked, gitDiff := s.popup.gen, s.cfg.Settings.Untracked, s.popup.run
return func() tea.Msg {
files, err := gitDiff(ctx, args, untracked)
return diffListedMsg{gen, args, files, err}
}
}
// showDiff replaces the view by the files msg brings, from scratch, closes
// the popup and returns the command starting the new view; when the popup was
// closed or asked again meanwhile, it drops them. When git failed, the view
// and the popup stay, which shows the first line of git's message.
func (s *App) showDiff(msg diffListedMsg) tea.Cmd {
if msg.gen != s.popup.gen || !s.popup.running { // closed or opened again meanwhile
return nil
}
s.endDiff()
args, files := msg.args, msg.files
if msg.err != nil {
s.popup.err = firstLine(msg.err)
return nil
}
s.popup.input = nil
if v, ok := s.view.(interface{ stop() }); ok {
v.stop()
}
d := NewDiff(s.cfg, files).WithGitDiff(args, s.popup.run)
if len(files) > 0 {
d.setMsg(fmt.Sprintf("%s: %d files", diffCommand(args), len(files)))
}
s.view, s.Model = d, s.wrap(d)
s.share()
s.Model.Update(tea.WindowSizeMsg{Width: s.width, Height: s.height})
cmd := s.Model.Init()
if s.cfg.AutoReload && !s.auto.gitMode { // now git diff: checked by git status
s.stopAutoReload()
cmd = batch(cmd, s.toggleAutoReload())
}
return cmd
}
// 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 *App) View() tea.View { return tea.NewView(s.frame()) }
func (s *App) frame() string {
base := s.Model.View().Content
if s.popup.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"
}
msg := ""
if s.popup.running {
msg = "running git diff…"
}
pop, w := inputBox(t, s.width, popupTitle(t, "git diff", msg, keys), printable(s.popup.input.text), s.popup.err, true)
return overlay(base, pop, w, s.width)
}
package ui
import (
"context"
"errors"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"jdiff/internal/gitsrc"
)
// autoReload is the state of auto reload of App: repoStatus returns the
// state of the repository, which is checked, while gitMode is set, every
// auto_reload_interval and at once when App's watcher reports a change of the
// files of repoPaths, pinned, or in the directories of workDirs. statusRuns
// counts the checks under way, statusAgain asks for one more after them; tick
// numbers the regular checks, of which only the last counts; gen tells this
// run of auto reload from earlier ones; ctx ends its git commands, cancel
// ends ctx.
type autoReload struct {
repoStatus func(ctx context.Context) (string, error)
repoPaths func() ([]string, error)
workDirs func(ignore func(rel string) bool) (map[string]time.Time, error)
gitMode bool
tick int
pins []string
statusAgain bool
lastStatus string
lastActive time.Time
statusRuns int
gen int
ctx context.Context
cancel context.CancelFunc
}
// autoReloadMsg tells the view that auto reload went on or off: it watches
// its files on disk only while it is on.
type autoReloadMsg struct{ on bool }
// autoTickMsg asks App for the next check of auto reload number gen,
// the tick number tick of it; a later or ended one drops it, and so does a
// change reported, which starts the next tick anew.
type autoTickMsg struct{ gen, tick 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. On, the view watches its files on disk. Of git diff and git
// log, the repository is watched and checked by git status too, and what is
// shown loaded again at once, since the repository may have changed since
// the last load; of two files or directories, only the files shown are
// watched (gitMode off), and the ticks only end it after the timeout. When it
// cannot start, the view says why.
func (s *App) toggleAutoReload() tea.Cmd {
if s.cfg.AutoReload {
s.stopAutoReload()
return nil
}
s.auto.gitMode = s.auto.repoStatus != nil && s.gitView()
if s.auto.gitMode {
if s.cfg.Settings.AutoReloadInterval <= 0 {
return showAutoReloadError(errors.New("auto_reload_interval is 0"))
}
ctx, cancel := context.WithCancel(context.Background())
status, err := s.auto.repoStatus(ctx)
if err != nil {
cancel()
return showAutoReloadError(err)
}
s.auto.ctx, s.auto.cancel, s.auto.lastStatus = ctx, cancel, status
}
s.cfg.AutoReload, s.auto.lastActive = true, time.Now()
s.auto.gen++
var on, cmd tea.Cmd
s.Model, on = s.Model.Update(autoReloadMsg{on: true})
if s.auto.gitMode {
s.Model, cmd = s.Model.Update(refreshMsg{quiet: true})
}
return batch(cmd, on, s.autoTick(), s.watchRepo())
}
// gitView reports whether the view shows git diff or git log, which auto
// reload checks by git status.
func (s *App) gitView() bool {
_, diff := s.current()
_, log := s.view.(*Log)
return diff || log
}
// stopAutoReload ends auto reload: its git commands are killed, and none
// run for it from now on; nothing is watched.
func (s *App) stopAutoReload() {
s.cfg.AutoReload = false
s.auto.gen++
if s.auto.cancel != nil {
s.auto.cancel()
s.auto.cancel = nil
}
if s.w != nil {
s.w.Pin(nil)
s.w.SetDirs(nil)
}
s.auto.pins, s.auto.statusAgain, s.auto.gitMode = nil, false, false
s.Model, _ = s.Model.Update(autoReloadMsg{on: false})
}
// autoTick returns the command for the next regular check, nil without an
// interval.
func (s *App) autoTick() tea.Cmd {
if s.cfg.Settings.AutoReloadInterval <= 0 {
return nil
}
s.auto.tick++
gen, tick := s.auto.gen, s.auto.tick
every := time.Duration(s.cfg.Settings.AutoReloadInterval) * time.Second
return tea.Tick(every, func(time.Time) tea.Msg { return autoTickMsg{gen, tick} })
}
// checkStatus returns the command running git status.
func (s *App) checkStatus() tea.Cmd {
s.auto.statusRuns++
gen, status, ctx := s.auto.gen, s.auto.repoStatus, s.auto.ctx
return func() tea.Msg {
st, err := status(ctx)
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, or there
// is no git status to check.
func (s *App) onAutoTick(msg autoTickMsg) tea.Cmd {
if msg.gen != s.auto.gen || msg.tick != s.auto.tick || !s.cfg.AutoReload {
return nil
}
// In minutes as float: a large timeout overflows a time.Duration.
if m := s.cfg.Settings.AutoReloadTimeout; m > 0 && time.Since(s.auto.lastActive).Minutes() >= float64(m) {
s.stopAutoReload()
return func() tea.Msg { return showMsg(fmt.Sprintf("auto reload off: %d min without keys or changes", m)) }
}
if s.auto.statusRuns > 0 || !s.auto.gitMode {
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, and watches the repository anew, whose files
// to watch it may have changed. The directories of the files it names anew
// count as used. 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. A change reported while git was at it is checked
// once more.
func (s *App) onAutoStatus(msg autoStatusMsg) tea.Cmd {
s.auto.statusRuns--
if msg.gen != s.auto.gen {
return nil
}
if msg.err != nil {
s.stopAutoReload()
return showAutoReloadError(msg.err)
}
var again tea.Cmd
if s.auto.statusAgain && s.auto.statusRuns == 0 {
s.auto.statusAgain, again = false, s.checkStatus()
}
if msg.status == s.auto.lastStatus {
return again
}
if s.w != nil {
var paths []string
for _, p := range gitsrc.StatusPaths(msg.status, s.auto.lastStatus) {
if abs, err := filepath.Abs(p); err == nil { // relative to where git ran
paths = append(paths, abs)
}
}
s.w.Touch(paths)
}
s.auto.lastStatus, s.auto.lastActive = msg.status, time.Now()
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(refreshMsg{quiet: true})
return batch(cmd, s.watchRepo(), again)
}
// onRepoChanged checks the status at once when the watcher reported a change
// of the repository or of the working tree, unless only of files that
// watch_ignore matches; while git is at it, once more after. The next regular
// check comes a full interval later. Without git status, a change of the
// files shown counts as activity.
func (s *App) onRepoChanged(paths []string) tea.Cmd {
if !s.cfg.AutoReload {
return nil
}
if !s.auto.gitMode {
s.auto.lastActive = time.Now()
return nil
}
if !slices.ContainsFunc(paths, s.repoChange) {
return nil
}
if s.auto.statusRuns > 0 {
s.auto.statusAgain = true
return s.autoTick()
}
return batch(s.checkStatus(), s.autoTick())
}
// repoChange reports whether a change of path counts for auto reload: of the
// pinned files of the repository, or in the working tree, but of those that
// watch_ignore matches.
func (s *App) repoChange(path string) bool {
if slices.Contains(s.auto.pins, path) {
return true
}
rel := path
if top := s.cfg.Repo(); top != "" {
r, err := filepath.Rel(top, path)
if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
return false
}
rel = r
}
return !s.cfg.WatchIgnored(filepath.ToSlash(rel))
}
// repoWatchMsg brings, for auto reload number gen, the files of the
// repository to watch and the directories of its working tree.
type repoWatchMsg struct {
gen int
pins []string
dirs map[string]time.Time
}
// watchRepo returns the command finding in the background what auto reload
// watches of git: the files of the repository, which a checkout changes, and
// the directories of the working tree, unless inotify_max_dirs is 0; nil
// without a watcher or git. When git fails,
// the watcher keeps what it watched: the regular checks still see all
// changes, only later.
func (s *App) watchRepo() tea.Cmd {
if s.w == nil || s.auto.repoPaths == nil || !s.auto.gitMode {
return nil
}
gen, paths, workDirs, ignored := s.auto.gen, s.auto.repoPaths, s.auto.workDirs, s.cfg.WatchIgnored
// Without directories to watch, the working tree is not looked at.
scan := workDirs != nil && s.cfg.Settings.InotifyMaxDirs > 0
return func() tea.Msg {
pins, err := paths()
if err != nil {
return nil
}
var dirs map[string]time.Time
if scan {
if dirs, err = workDirs(ignored); err != nil {
return nil
}
}
return repoWatchMsg{gen, pins, dirs}
}
}
// onRepoWatch watches what msg brings, the files of the repository always,
// unless auto reload ended meanwhile.
func (s *App) onRepoWatch(msg repoWatchMsg) {
if msg.gen != s.auto.gen || !s.cfg.AutoReload || s.w == nil {
return
}
s.auto.pins = msg.pins
s.w.Pin(msg.pins)
s.w.SetDirs(msg.dirs)
}
// 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)) }
}
package ui
import (
"context"
"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/keys"
"jdiff/internal/source"
"jdiff/internal/theme"
"jdiff/watch"
)
// fileEntry is one file in the list. Its alignment is created with the
// list, 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 fileEntry struct {
source.File
added, removed int
align *fileAlign
view *FileDiff
}
// label returns the status label, using delta's file labels.
func (e *fileEntry) 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 *fileEntry) 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 *fileEntry) 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 *fileEntry) refresh(after func(*fileAlign)) string {
if e.Binary {
return ""
}
msg := refreshFile(&e.File, e.align, 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 *fileEntry) update(f source.File, after func(*fileAlign)) (dropped int, changed bool) {
was := [2][]string{e.Old, e.New}
e.File = f
if e.Binary {
return 0, false
}
e.align.names = e.paneNames()
for s, lines := range [2][]string{f.Old, f.New} {
if slices.Equal(was[s], lines) {
continue
}
changed = true
dropped += e.align.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 *fileEntry) followReload(after func(*fileAlign)) {
if e.view != nil {
e.view.syncLayout()
}
if after != nil {
after(e.align)
}
}
func (e *fileEntry) updateStats() {
if e.Binary {
return
}
if e.align != nil {
e.added, e.removed = diff.Stats(e.align.rows)
return
}
e.added, e.removed = diff.Stats(diff.Align(e.Old, e.New, nil))
}
// 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
// Diff is the Bubble Tea model of the file list (keys of file_list), of jdiff
// diff, two directories and a commit of the log. It shows the diff view
// of the selected file and returns to the list on backspace. A single file
// has its list too, as many do.
type Diff struct {
cfg *config.Config
commit *gitsrc.Commit // whose files are shown, nil outside the log
files []*fileEntry
relist ListFunc // lists the files again, nil when they cannot be
relisting relistState // of the files, see relistFiles
// ctx ends the git commands of the view, cancel ends ctx, see stop.
ctx context.Context
cancel context.CancelFunc
// gitDiff runs git diff with arguments, nil outside jdiff diff; diffArgs
// are those of the files shown.
gitDiff DiffFunc
diffArgs []string
// disk watches the files on disk while auto reload is on, from
// watchFiles on; those near the cursor that it does not watch are checked
// every watch_interval. touched is the file the cursor was on last.
disk diskWatch
watching bool
touched *fileEntry
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 *fileEntry
previewTop int
open *fileEntry // 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 // the action, "jump", while waiting for the letter of a mark
}
// NewDiff returns the file list for files.
func NewDiff(cfg *config.Config, files []source.File) *Diff { return newDiff(cfg, files, nil) }
// ListFunc lists the files of a comparison again. It runs in the background,
// and ends when ctx is done.
type ListFunc func(ctx context.Context) ([]source.File, error)
// DiffFunc returns the files git diff compares with args, with the
// untracked files as added when untracked is set. It runs in the background:
// it must not read the settings, which the keys change meanwhile; it ends
// when ctx is done.
type DiffFunc func(ctx context.Context, args []string, untracked bool) ([]source.File, error)
// WithGitDiff makes d the files of git diff with args, which r runs again,
// and returns d.
func (d *Diff) WithGitDiff(args []string, gitDiff DiffFunc) *Diff {
d.gitDiff, d.diffArgs = gitDiff, args
return d
}
// WithRelist sets how r lists the files again and returns d.
func (d *Diff) WithRelist(list ListFunc) *Diff {
d.relist = list
return d
}
// newDiff returns the file list for files, as part of the log for the
// files of commit when it is set.
func newDiff(cfg *config.Config, files []source.File, commit *gitsrc.Commit) *Diff {
d := &Diff{cfg: cfg, commit: commit}
d.ctx, d.cancel = context.WithCancel(context.Background())
// The files are searched for moved code together.
group := &moveGroup{}
for _, f := range files {
d.addEntry(group, &fileEntry{File: f})
}
d.list = list{n: len(d.files), preview: &cfg.Settings.Preview, maxRows: fileListRows}
if len(files) == 0 {
d.msg = "no changed files"
}
if w := cfg.Warning(); w != "" {
d.msg = w
}
return d
}
// addEntry adds e to the files and its diff, unless binary, to group, the
// files searched for moved code together.
func (d *Diff) addEntry(group *moveGroup, e *fileEntry) {
if !e.Binary {
group.add(d.diffOf(e), [2]string{e.PathInOld(), e.Path})
}
e.updateStats()
d.files = append(d.files, e)
}
// stop stops watching the files and ends the git commands of the view: it
// is replaced, or the program ends.
func (d *Diff) stop() {
d.cancel()
d.disk.stop()
}
// shareWatcher makes d watch its files by w, which App shares.
func (d *Diff) shareWatcher(w *watch.Watcher) { d.disk.share(w) }
// diffCommand returns the git diff command line of args, quoted as the
// popup of git diff reads them.
func diffCommand(args []string) string {
return strings.TrimSpace("git diff " + joinArgs(args))
}
// inLog reports whether a is part of the log: backspace and N/P go to the
// log.
func (d *Diff) inLog() bool { return d.commit != nil }
// openPath opens the file with the given path and reports whether it could.
func (d *Diff) openPath(path string) bool {
i := d.fileByPath(path)
if i >= 0 {
d.openIndex(i)
}
return i >= 0
}
// fileByPath returns the index of the file with the given path that can be
// shown, or -1.
func (d *Diff) fileByPath(path string) int {
return slices.IndexFunc(d.files, func(e *fileEntry) 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 (d *Diff) fileByGroup(i int) int {
return slices.IndexFunc(d.files, func(e *fileEntry) bool { return e.align != nil && e.align.groupIndex == i })
}
// openIndex opens file i, after updating the counts of the file it leaves.
func (d *Diff) openIndex(i int) {
if d.open != nil {
d.open.updateStats()
}
d.cursor = i
d.scrollToCursor()
d.openFile()
}
// setMsg shows msg in the status bar of whatever is displayed.
func (d *Diff) setMsg(msg string) {
switch {
case d.flowShown():
d.flow.msg = msg
case d.open != nil:
d.open.view.msg = msg
default:
d.msg = msg
}
}
// Init starts watching the files, outside the log.
func (d *Diff) Init() tea.Cmd {
if d.inLog() {
return nil
}
return d.watchFiles()
}
// watchFiles watches the files on disk, in place of those before, while auto
// reload is on, and returns the command waiting for their changes and the
// check of those not watched; off, it watches none.
func (d *Diff) watchFiles() tea.Cmd {
if !d.cfg.AutoReload {
d.disk.stop()
d.watching = false
return nil
}
var paths []string
for _, e := range d.files {
paths = append(paths, diskPaths(e.File)...)
}
cmd, msg := d.disk.set(d.cfg, paths)
if msg != "" {
d.setMsg(msg)
}
d.watching = true
// The cursor's file counts as used from the start, not only from the
// next message on: under inotify_max_dirs its directories go first.
return batch(cmd, d.followCursor(), d.poll())
}
// poll returns the next check of the files near the cursor, the current one
// and its neighbors, that are not watched; nil while one is pending, or when
// none is to be checked.
func (d *Diff) poll() tea.Cmd {
var paths []string
for _, i := range d.nearCursor() {
paths = append(paths, diskPaths(d.files[i].File)...)
}
return d.disk.poll(d.cfg, d, paths)
}
// nearCursor returns the indices of the current file and its neighbors.
func (d *Diff) nearCursor() []int {
var out []int
if cur := d.current(); cur >= 0 {
for i := max(cur-1, 0); i <= min(cur+1, len(d.files)-1); i++ {
out = append(out, i)
}
}
return out
}
// unwatched reports whether a side of file i on disk is not watched.
func (d *Diff) unwatched(i int) bool {
return d.disk.unwatched(diskPaths(d.files[i].File))
}
// current returns the index of the file the cursor is on: in the flow view,
// the diff view or the list; -1 for none.
func (d *Diff) current() int {
if d.flowShown() {
_, file, _ := d.flow.position()
return file
}
if d.open != nil {
return slices.Index(d.files, d.open)
}
if d.cursor < len(d.files) {
return d.cursor
}
return -1
}
// followCursor counts the directories of the file the cursor is on as used
// when it is on another one than before, and reloads it when it changed on
// disk, as it may while its directory was not watched. It returns the check
// of the files near the cursor not watched.
func (d *Diff) followCursor() tea.Cmd {
i := d.current()
if !d.watching || !d.cfg.AutoReload || i < 0 || d.files[i] == d.touched {
return nil
}
d.touched = d.files[i]
d.disk.touch(diskPaths(d.touched.File))
f := d.touched.File
if f.OldFile != "" && stale(f.OldFile, f.OldStamp) || f.NewFile != "" && stale(f.NewFile, f.NewStamp) {
d.refresh(func(j int) bool { return j == i })
}
return d.poll()
}
// refresh reloads the files i that changed on disk for which which holds.
func (d *Diff) refresh(which func(i int) bool) {
var msgs []string
reload := func(after func(*fileAlign)) {
for i := range d.files {
if !which(i) {
continue
}
if msg := d.files[i].refresh(after); msg != "" {
msgs = append(msgs, msg)
}
}
}
if d.flowShown() {
d.flow.refresh(reload)
} else {
reload(nil)
}
if len(msgs) > 0 {
d.setMsg(strings.Join(msgs, "; "))
}
}
// filesListedMsg brings the files d listed again, or why it could not.
type filesListedMsg struct {
diff *Diff
files []source.File
err error
quiet bool
}
// relistFiles returns the command listing the files again (r) in the
// background: git diff, or the two directories. The files stay usable
// meanwhile. While a listing runs, another one is not started, but run once
// it is done, since what to list may have changed, as the untracked files.
// With quiet, as auto reload asks, nothing is said unless something changed.
func (d *Diff) relistFiles(quiet bool) tea.Cmd {
list := d.relist
if d.gitDiff != nil {
args, untracked, gitDiff := d.diffArgs, d.cfg.Settings.Untracked, d.gitDiff
list = func(ctx context.Context) ([]source.File, error) { return gitDiff(ctx, args, untracked) }
}
if list == nil {
d.setMsg(nothingToReload)
return nil
}
if !d.relisting.start(quiet) {
return nil
}
if !quiet {
d.setMsg(reloading)
}
ctx := d.ctx
return func() tea.Msg {
files, err := list(ctx)
return filesListedMsg{d, files, err, quiet}
}
}
// filesListed takes the files listed again. 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, and the
// one listing them once more when that was asked for meanwhile.
func (d *Diff) filesListed(res filesListedMsg) tea.Cmd {
var next tea.Cmd
if again, quiet := d.relisting.done(); again {
next = d.relistFiles(quiet)
}
files, quiet := res.files, res.quiet
if res.err != nil {
d.setMsg(firstLine(res.err))
return next
}
kept := map[string]*fileEntry{}
for _, e := range d.files {
kept[e.Path] = e
}
var cur *fileEntry
if d.cursor < len(d.files) {
cur = d.files[d.cursor]
}
added, changed, dropped := 0, 0, 0
update := func(after func(*fileAlign)) {
old := d.files
d.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 = &fileEntry{File: f}
added++
} else {
delete(kept, f.Path)
n, ok := e.update(f, after)
if ok {
changed++
}
dropped += n
}
d.addEntry(group, e)
}
if d.flow != nil {
d.flow.followFiles(0, old, d.files)
}
}
shown := d.flowShown()
if shown {
d.flow.refresh(update)
} else {
update(nil)
}
d.list.n = len(d.files)
if i := slices.Index(d.files, cur); i >= 0 {
d.cursor = i
} else {
d.cursor = max(min(d.cursor, len(d.files)-1), 0)
}
msg := refreshed(added, len(kept), changed, dropped)
switch {
case len(d.files) == 0:
d.open = nil
case shown:
if _, file, _ := d.flow.position(); file >= 0 {
d.cursor = file
}
d.open = d.files[d.cursor]
case d.open != nil && !slices.Contains(d.files, d.open):
msg = d.open.Path + " is gone; " + msg
d.open = nil
}
d.scrollToCursor()
if !quiet || msg != nothingChanged {
d.setMsg(msg)
}
if d.inLog() {
return next
}
return batch(d.watchFiles(), next)
}
// batch returns the commands that are not nil, as one: tea.Batch of them
// when there are several, so that a single command stays plain, as the
// tests run them.
func batch(cmds ...tea.Cmd) tea.Cmd {
cmds = slices.DeleteFunc(cmds, func(c tea.Cmd) bool { return c == nil })
switch len(cmds) {
case 0:
return nil
case 1:
return cmds[0]
}
return tea.Batch(cmds...)
}
// flowShown reports whether the flow view is shown instead of a diff view.
// Inside the log, the log shows its own flow view.
func (d *Diff) flowShown() bool { return d.open != nil && d.cfg.Settings.Flow && !d.inLog() }
// Update handles msg, then follows the cursor, see followCursor.
func (d *Diff) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m, cmd := d.update(msg)
return m, batch(cmd, d.followCursor())
}
func (d *Diff) update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
d.width, d.height = msg.Width, msg.Height
d.scrollToCursor()
case settingChangedMsg:
d.scrollToCursor() // the preview takes room from the list
if msg.problem != "" && d.open == nil {
d.msg = msg.problem
}
if msg.action == "inotify_max_dirs" {
d.disk.limit(d.cfg)
return d, d.poll()
}
case autoReloadMsg:
if d.inLog() {
return d, nil
}
if msg.on { // changed while off
d.touched = nil
d.refresh(func(int) bool { return true })
}
return d, d.watchFiles()
case watchTickMsg:
if !d.disk.ticked(d.cfg, d, msg) {
return d, nil
}
near := d.nearCursor()
d.refresh(func(i int) bool { return slices.Contains(near, i) && d.unwatched(i) })
return d, d.poll()
case changedMsg:
if msg.w == nil || msg.w != d.disk.w {
return d, nil
}
changed := func(i int) bool {
return slices.ContainsFunc(diskPaths(d.files[i].File), func(p string) bool { return slices.Contains(msg.paths, p) })
}
d.refresh(changed)
return d, batch(d.disk.next(), d.poll())
case watchEndedMsg:
if !d.disk.ended(msg.w) {
return d, nil
}
return d, d.poll()
case jumpMsg:
d.jump(msg)
return d, nil
case markJumpMsg:
d.gotoMark(msg.letter, msg.screenRow)
return d, nil
case switchFileMsg:
d.switchFile(int(msg))
return d, nil
case toggleFlowMsg:
d.toggleFlow()
if w := saveState(d.cfg); w != "" {
d.setMsg(w)
}
return d, nil
case backMsg: // from the flow view
d.open = nil
return d, nil
case refreshMsg:
return d, d.relistFiles(msg.quiet)
case filesListedMsg:
if msg.diff != d {
return d, nil
}
return d, d.filesListed(msg)
case showMsg:
d.setMsg(string(msg))
return d, nil
case toggleUntrackedMsg:
if d.gitDiff == nil {
d.setMsg(untrackedOnlyInDiff)
return d, nil
}
d.cfg.Settings.Untracked = !d.cfg.Settings.Untracked // not saved
return d, d.relistFiles(false)
}
if d.flowShown() {
_, cmd := d.flow.Update(msg)
if _, file, _ := d.flow.position(); file >= 0 {
d.cursor = file
d.scrollToCursor()
}
return d, cmd
}
if d.open != nil {
if k, ok := msg.(tea.KeyPressMsg); ok && d.cfg.Keys.Key(keys.DiffView, k.String()).Action == "to_files" {
d.open.updateStats()
d.open = nil
return d, nil
}
_, cmd := d.open.view.Update(msg)
return d, cmd
}
if k, ok := msg.(tea.KeyPressMsg); ok {
return d, d.key(k.String())
}
return d, nil
}
func (d *Diff) key(k string) tea.Cmd {
d.msg = ""
cursor := d.cursor
defer func() {
if d.cursor != cursor {
d.previewFor = nil // another file shows its preview from the start
}
}()
if d.prefix != "" {
d.prefix = ""
if isMarkLetter(k) {
d.gotoMark(k, (d.height-2)/2)
}
return nil
}
h := d.cfg.Keys.Key(keys.FileList, k)
page := max(d.listHeight()-1, 1)
if delta, paging, ok := motion(h, page, len(d.files)); ok {
if h := d.boxHeight(); paging && h > 0 && d.cursor < len(d.files) {
// The preview scrolls, by its height less a line, as the list pages.
d.scrollPreview(direction(delta > 0) * max(h-3, 1))
return nil
}
d.moveCursor(delta)
return nil
}
switch h.Action {
case "quit":
return tea.Quit
case "open":
if d.inLog() && d.cfg.Settings.Flow && d.cursor < len(d.files) {
file := d.cursor
return func() tea.Msg { return flowAtMsg(file) }
}
d.openFile()
case "to_commits":
if d.inLog() {
return func() tea.Msg { return backMsg{} }
}
case "history":
if d.inLog() {
return switchCommit(h)
}
case "flow":
return toggleFlow
case "jump":
d.prefix, d.msg = h.Action, jumpPrompt
default:
return pageKey(d.cfg.Keys, h, false)
}
return nil
}
// diffOf returns the diff of e, creating it on first use.
func (d *Diff) diffOf(e *fileEntry) *fileAlign {
if e.align == nil {
e.align = newFileAlign(d.cfg, e.paneNames(), [2][]string{e.Old, e.New})
e.align.markPath = e.Path
}
return e.align
}
// viewOf returns the diff view of e, creating it on first use.
func (d *Diff) viewOf(e *fileEntry) *FileDiff {
if e.view == nil {
e.view = newFileDiff(d.diffOf(e))
e.view.canBack = true
e.view.commitNav = d.inLog()
}
return e.view
}
func (d *Diff) openFile() {
if d.cursor >= len(d.files) {
return
}
if d.cfg.Settings.Flow && !d.inLog() {
d.showFlow(d.cursor, nil)
return
}
e := d.files[d.cursor]
if e.Binary {
d.msg = e.Path + " is a binary file"
return
}
d.viewOf(e).Update(tea.WindowSizeMsg{Width: d.width, Height: d.height})
d.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 (d *Diff) showFlow(file int, from *FileDiff) {
if d.flow == nil {
d.flow = flowOfDiff(d)
}
d.open = d.files[file]
d.flow.width, d.flow.height = d.width, d.height
d.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 (d *Diff) toggleFlow() {
st := &d.cfg.Settings
if !st.Flow {
st.Flow = true
switch {
case len(d.files) == 0:
case d.open == nil:
d.showFlow(d.cursor, nil)
default:
d.showFlow(d.cursor, d.open.view)
}
return
}
st.Flow = false
if d.open == nil {
return
}
_, file, line := d.flow.position()
screenRow := d.flow.cursor - d.flow.top
d.open = nil
if file < 0 {
return
}
d.cursor = file
d.scrollToCursor()
if d.files[file].Binary {
return
}
d.openFile()
d.open.view.takeOver(d.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 (d *Diff) gotoMark(letter string, screenRow int) {
mk, ok := d.cfg.Mark(letter)
if !ok {
d.setMsg("no mark " + letter)
return
}
i := d.fileByPath(mk.Path)
if i < 0 {
d.setMsg(notInDiff(letter, mk))
return
}
d.openIndex(i)
if d.flowShown() {
d.flow.gotoMark(letter)
return
}
if at, ok := d.open.view.markSpot(letter, mk); ok {
d.open.view.show(at, screenRow)
}
}
// jump opens the file of j's end of moved code with the cursor on its line.
func (d *Diff) jump(j jumpMsg) {
i := d.fileByGroup(j.end.file)
if i < 0 {
return
}
d.openIndex(i)
d.open.view.show(d.open.align.spotOf(j.end.side, j.end.line, d.open.view.shape()), j.screenRow)
d.open.view.msg = j.msg
}
// switchFile opens the next (dir > 0) or previous file that can be shown.
func (d *Diff) switchFile(dir int) {
for i := d.cursor + dir; i >= 0 && i < len(d.files); i += dir {
if !d.files[i].Binary {
d.openIndex(i)
return
}
}
d.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 (d *Diff) scrollPreview(delta int) {
e := d.files[d.cursor]
if e.Binary {
return
}
screen, at := d.diffOf(e).screenLines(d.previewLayout(e).shape)
from := d.previewStart(e, screen, at)
if delta > 0 {
from = min(from+delta, max(len(screen)-(d.boxHeight()-2), from))
} else {
from = max(from+delta, 0)
}
d.previewFor, d.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 (d *Diff) previewLayout(e *fileEntry) layout {
lo := layout{width: max(d.width-4, 0)}
lo.shape = newShape(d.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 (d *Diff) previewStart(e *fileEntry, screen []sline, at map[sline]int) int {
if d.previewFor == e {
return max(min(d.previewTop, len(screen)-1), 0)
}
a := d.diffOf(e)
for i := range screen {
if a.blockStart(screen, i) {
// The rows of context before the block, else its first line.
return min(at[sline{max(screen[i].v-d.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 (d *Diff) preview(e *fileEntry, height int) string {
t := d.cfg.Theme
title := t.Heading.Render(" " + e.name() + " ")
if e.Binary {
return box(t, d.width, height, title, []string{t.Hint.Render("binary file")})
}
a := d.diffOf(e)
if len(a.view) == 0 { // empty on both sides
return box(t, d.width, height, title, nil)
}
lo := d.previewLayout(e)
screen, at := a.screenLines(lo.shape)
from := d.previewStart(e, screen, at)
var lines []string
for i := from; i < len(screen) && len(lines) < height-2; i++ {
lines = append(lines, a.render(screen[i], false, lo))
}
return box(t, d.width, height, title, lines)
}
func (d *Diff) View() tea.View { return tea.NewView(d.frame()) }
func (d *Diff) frame() string {
if d.flowShown() {
return d.flow.frame()
}
if d.open != nil {
return d.open.view.frame()
}
if d.width == 0 {
return ""
}
t := d.cfg.Theme
var added, removed int
addW, remW, lblW := 1, 1, 0
for _, e := range d.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.
heading := t.Heading
changed := fmt.Sprintf("%d files changed", len(d.files))
if len(d.files) == 1 {
changed = "1 file changed"
}
head := " " + heading.Render(changed)
if c := d.commit; c != nil {
head = " " + t.CommitHash.Render(c.Short) + " " + heading.Render(c.Subject)
}
head += " " + t.Plus.Render(fmt.Sprintf("+%d", added)) + " " + t.Minus.Render(fmt.Sprintf("-%d", removed))
if c := d.commit; c != nil {
head += " " + t.Decoration.Render(c.Author+" "+c.Date)
}
if d.gitDiff != nil {
cmd := diffCommand(d.diffArgs)
if d.cfg.Settings.Untracked {
cmd += " + untracked"
}
head += " " + t.Hint.Render(printable(cmd))
}
item := func(i int, st func(lipgloss.Style) lipgloss.Style) string {
e := d.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: d.inLog(), flow: true}
if d.inLog() {
k.back = "to_commits"
}
hints := statusKeys(d.cfg, k)
return d.render(t, head, item, func(h int) string { return d.preview(d.files[d.cursor], h) }, pageStatus(d.cfg, d.msg, hints))
}
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 row i, none
// unless it is a Change row. They are computed when the row is first drawn
// and kept until the next alignment: computing them for all rows when a
// large change is loaded took seconds.
func (a *fileAlign) emphOf(i int, s side) []diff.Span {
r := a.rows[i]
if r.Kind != diff.Change {
return nil
}
e, ok := a.emph[i]
if !ok {
e[left], e[right] = diff.Intraline(a.lines[left][r.Left], a.lines[right][r.Right],
a.cfg.Settings.OnlyChangedWhitespaces)
a.emph[i] = e
}
return e[s]
}
// 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
}
// fileAlign 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
// fileAlign, each with its own cursor.
type fileAlign 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
emph map[int][2][]diff.Span // emphasized parts by row, see emphOf
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, -1 until measured
// 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 newFileAlign(cfg *config.Config, names [2]string, lines [2][]string) *fileAlign {
a := &fileAlign{cfg: cfg, names: names, lines: lines, foldOpen: map[int]bool{}}
a.measure(left)
a.measure(right)
a.align()
return a
}
// measure forgets the widths of the lines of side s, after its lines were
// set: see width.
func (a *fileAlign) measure(s side) {
a.widths[s] = make([]int, len(a.lines[s]))
for i := range a.widths[s] {
a.widths[s][i] = -1
}
}
// width returns the columns line of side s takes when drawn, without the \r
// of a \r\n line end (see crShown). It is measured on first use: only
// wrapping needs it, and measuring all lines took most of the time loading a
// large change takes.
func (a *fileAlign) width(s side, line int) int {
if w := a.widths[s][line]; w >= 0 {
return w
}
w := ansi.StringWidth(printable(strings.TrimSuffix(a.lines[s][line], "\r")))
a.widths[s][line] = w
return w
}
// 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 (a *fileAlign) crShown(s side, line int, sh shape) bool {
text := a.lines[s][line]
if !strings.HasSuffix(text, "\r") {
return false
}
if sh.cr {
return true
}
for _, sp := range a.emphOf(a.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 (a *fileAlign) reload(s side, lines []string) (dropped int) {
moved := lineMap(a.lines[s], lines)
var anchors []diff.Anchor
for _, an := range a.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)
}
a.anchors = anchors
if s == left {
open := map[int]bool{}
for line, o := range a.foldOpen {
if n := moved[line]; n >= 0 {
open[n] = o
}
}
a.foldOpen = open
}
a.pending, a.replace = nil, nil
a.lines[s] = lines
a.measure(s)
a.reloads, a.reloadSide, a.moved = a.reloads+1, s, moved
a.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 (a *fileAlign) follow(r diff.Row, reloaded bool) int {
if i := a.rowNow(r, reloaded); i >= 0 {
return a.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 (a *fileAlign) rowAfterReload(r diff.Row) int { return a.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 (a *fileAlign) rowNow(r diff.Row, reloaded bool) int {
for _, s := range []side{left, right} {
line := lineOf(r, s)
if line >= 0 && reloaded && s == a.reloadSide {
line = a.moved[line]
}
if line >= 0 {
return a.rowOf[s][line]
}
}
return -1
}
// lineMap maps the indices of before to those of the same lines in after, -1
// for lines that are not in after. It keeps the order of the lines.
func lineMap(before, after []string) []int {
to := make([]int, len(before))
for i := range to {
to[i] = -1
}
for _, r := range diff.Align(before, after, nil) {
if r.Kind == diff.Equal {
to[r.Left] = r.Right
}
}
return to
}
// 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 (a *fileAlign) act(action string, at spot, sh shape) (spot, string) {
if a.replace != nil {
an := *a.replace
a.replace = nil
switch action {
case "anchor":
for _, c := range diff.Conflicts(a.anchors, an) {
a.removeAnchor(c)
}
return a.addAnchor(an, at.side, sh)
case "cancel_mark":
return at, "replace cancelled"
}
}
switch action {
case "cancel_mark":
if a.pending != nil {
a.pending = nil
return at, "mark cancelled"
}
case "anchor":
return a.enter(at, sh)
case "fold":
return a.toggleNearestBlock(at)
case "fold_all":
return a.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 (a *fileAlign) markPrompt() string {
if a.pending == nil {
return ""
}
return fmt.Sprintf("marked %s%d, pick the matching line (esc cancels)", sideName(a.pending.side), a.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 (a *fileAlign) enter(at spot, sh shape) (spot, string) {
if sh.unified {
at.side = a.unifiedSide(at, sh)
}
line := a.lineAt(at.side, at.v)
if line < 0 {
if at.v < len(a.view) && a.view[at.v].hidden > 0 {
return at, "unchanged lines folded, space expands"
}
return at, "no line here"
}
if an, ok := a.anchorAt(at.side, line); ok {
a.removeAnchor(an)
a.align()
return a.spotOf(at.side, line, sh), "removed anchor " + anchorName(an)
}
if a.pending == nil || a.pending.side == at.side {
if a.pending != nil && a.pending.line == line {
a.pending = nil
return at, "mark cancelled"
}
a.pending = &mark{at.side, line}
at.side = at.side.other()
return at, a.markPrompt()
}
an := diff.Anchor{Left: a.pending.line, Right: line}
if at.side == left {
an = diff.Anchor{Left: line, Right: a.pending.line}
}
if conflicts := diff.Conflicts(a.anchors, an); len(conflicts) > 0 {
names := make([]string, len(conflicts))
for i, c := range conflicts {
names[i] = anchorName(c)
}
a.replace = &an
return at, fmt.Sprintf("%s crosses %s: enter replaces, esc cancels",
anchorName(an), strings.Join(names, ", "))
}
return a.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 (a *fileAlign) addAnchor(an diff.Anchor, s side, sh shape) (spot, string) {
i, _ := slices.BinarySearchFunc(a.anchors, an, func(x, y diff.Anchor) int {
return x.Left - y.Left
})
a.anchors = slices.Insert(a.anchors, i, an)
a.pending = nil
a.align()
return a.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 (a *fileAlign) unifiedSide(at spot, sh shape) side {
if at.v >= len(a.view) || a.view[at.v].hidden > 0 {
return at.side
}
switch r := a.rows[a.view[at.v].row]; {
case r.Kind == diff.Delete:
return left
case r.Kind == diff.Insert:
return right
case r.Kind == diff.Change:
s, _ := a.unifiedPiece(at.v, at.sub, sh)
return s
case a.pending != nil:
return a.pending.side.other()
}
return left
}
func (a *fileAlign) removeAnchor(an diff.Anchor) {
a.anchors = slices.DeleteFunc(a.anchors, func(x diff.Anchor) bool { return x == an })
}
func (a *fileAlign) anchorAt(s side, line int) (diff.Anchor, bool) {
for _, an := range a.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 (a *fileAlign) align() {
a.rows = diff.Align(a.lines[left], a.lines[right], a.anchors)
a.emph = map[int][2][]diff.Span{}
for s := range a.rowOf {
a.rowOf[s] = make([]int, len(a.lines[s]))
}
for i, r := range a.rows {
if r.Left >= 0 {
a.rowOf[left][r.Left] = i
}
if r.Right >= 0 {
a.rowOf[right][r.Right] = i
}
}
a.aligns++
a.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 (a *fileAlign) buildView() {
keep := make([]bool, len(a.rows))
ctx := max(a.cfg.Settings.Context, 0)
for i, r := range a.rows {
if r.Kind != diff.Equal || r.Anchored || a.isPending(r) {
for j := max(i-ctx, 0); j <= min(i+ctx, len(a.rows)-1); j++ {
keep[j] = true
}
}
}
a.blocks = nil
a.view = nil
a.viewOf = make([]int, len(a.rows))
for i := 0; i < len(a.rows); {
j := i + 1
if !keep[i] {
for j < len(a.rows) && !keep[j] {
j++
}
}
if j-i < 2 {
a.viewOf[i] = len(a.view)
a.view = append(a.view, vrow{row: i, block: -1})
i = j
continue
}
b := len(a.blocks)
a.blocks = append(a.blocks, block{i, j})
open := a.blockOpen(a.blocks[b])
for k := i; k < j; k++ {
a.viewOf[k] = len(a.view)
if open {
a.view = append(a.view, vrow{row: k, block: b})
}
}
if !open {
a.view = append(a.view, vrow{row: i, hidden: j - i, block: b})
}
i = j
}
a.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 (a *fileAlign) contentWidth(width int, unified bool) int {
nw := a.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 (a *fileAlign) pieces(s side, line int, sh shape) int {
cw := a.contentWidth(sh.wrap, sh.unified)
if sh.wrap == 0 || line < 0 || cw == 0 {
return 1
}
w := a.width(s, line)
if sh.lineEnd {
w++
}
if a.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 (a *fileAlign) rowLines(v int, sh shape) int {
if a.view[v].hidden > 0 {
return 1
}
r := a.rows[a.view[v].row]
switch {
case !sh.unified:
return max(a.pieces(left, r.Left, sh), a.pieces(right, r.Right, sh))
case r.Kind == diff.Change:
return a.pieces(left, r.Left, sh) + a.pieces(right, r.Right, sh)
}
s, _ := a.unifiedPiece(v, 0, sh)
return a.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 (a *fileAlign) unifiedPiece(v, sub int, sh shape) (side, int) {
if a.view[v].hidden > 0 {
return right, 0
}
switch r := a.rows[a.view[v].row]; r.Kind {
case diff.Delete:
return left, sub
case diff.Change:
if n := a.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 (a *fileAlign) firstSub(v int, s side, sh shape) int {
if !sh.unified && sh.wrap > 0 {
return 0
}
r := a.rows[a.view[v].row]
if a.view[v].hidden > 0 || r.Kind != diff.Change || s == left {
return 0
}
return a.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 (a *fileAlign) convertSub(v, sub int, from, sh shape, active side) int {
if from.wrap == 0 && sh.wrap == 0 || a.view[v].hidden > 0 {
return sub
}
s, piece := active, sub
switch {
case from.unified:
s, piece = a.unifiedPiece(v, sub, from)
case from.wrap == 0:
s, piece = a.unifiedPiece(v, sub, shape{unified: true})
}
if !sh.unified && sh.wrap == 0 {
return a.firstSub(v, s, sh)
}
r := a.rows[a.view[v].row]
if !sh.unified {
return min(piece, a.rowLines(v, sh)-1)
}
if lineOf(r, s) < 0 || (r.Kind != diff.Change && r.Kind != diff.Equal) {
s, _ = a.unifiedPiece(v, 0, sh) // the row shows only one side
}
return a.firstSub(v, s, sh) + min(piece, a.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 (a *fileAlign) screenLines(sh shape) (screen []sline, at map[sline]int) {
at = make(map[sline]int, len(a.view))
add := func(v, sub int) {
at[sline{v, sub}] = len(screen)
screen = append(screen, sline{v, sub})
}
for v := 0; v < len(a.view); {
if !sh.unified || !a.changed(v) {
for sub := range a.rowLines(v, sh) {
add(v, sub)
}
v++
continue
}
end := v
for end < len(a.view) && a.changed(end) {
end++
}
for _, s := range []side{left, right} {
for w := v; w < end; w++ {
for sub := range a.rowLines(w, sh) {
if of, _ := a.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 (a *fileAlign) blockStart(screen []sline, i int) bool {
return a.changed(screen[i].v) && (i == 0 || !a.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 (a *fileAlign) blockOpen(b block) bool {
if open, ok := a.foldOpen[a.rows[b.start].Left]; ok {
return open
}
return !a.cfg.Settings.CollapseUnchanged
}
func (a *fileAlign) isPending(r diff.Row) bool {
return a.pending != nil && lineOf(r, a.pending.side) == a.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 (a *fileAlign) toggleNearestBlock(at spot) (spot, string) {
b := a.nearestBlock(at.v)
if b < 0 {
return at, "no unchanged code to fold"
}
r := a.view[at.v].row
blk := a.blocks[b]
a.foldOpen[a.rows[blk.start].Left] = !a.blockOpen(blk)
a.buildView()
at.v = a.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 (a *fileAlign) toggleAllBlocks(at spot) (spot, string) {
if len(a.blocks) == 0 {
return at, "no unchanged code to fold"
}
open := false
for _, b := range a.blocks {
if !a.blockOpen(b) {
open = true
break
}
}
r := a.view[at.v].row
for _, b := range a.blocks {
a.foldOpen[a.rows[b.start].Left] = open
}
a.buildView()
at.v = a.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 (a *fileAlign) nearestBlock(v int) int {
for dist := 0; dist < len(a.view); dist++ {
for _, w := range []int{v + dist, v - dist} {
if w >= 0 && w < len(a.view) && a.view[w].block >= 0 {
return a.view[w].block
}
}
}
return -1
}
// lineAt returns the line index shown on side s of displayed row v, or -1.
func (a *fileAlign) lineAt(s side, v int) int {
if v < 0 || v >= len(a.view) || a.view[v].hidden > 0 {
return -1
}
return lineOf(a.rows[a.view[v].row], s)
}
// changed reports whether displayed row v is a changed row, not unchanged or
// a fold.
func (a *fileAlign) changed(v int) bool {
return a.view[v].hidden == 0 && a.rows[a.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 (
"slices"
tea "charm.land/bubbletea/v2"
"jdiff/internal/config"
"jdiff/internal/diff"
"jdiff/internal/keys"
"jdiff/internal/source"
"jdiff/watch"
)
// FileDiff is the Bubble Tea model of the diff view (keys of diff_view): a
// single file view of a fileAlign, which other views may show and change too.
type FileDiff struct {
*fileAlign
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 list, which handles backspace
commitNav bool // shown from the log, which switches commits
msg string
onDisk *source.File // the files on disk, reloaded when they change; nil when not watched
// disk watches them; those it does not watch are checked every
// watch_interval.
disk diskWatch
prefix string // the action, "set_mark" or "jump", while waiting for the letter of a mark
}
// NewFileDiff returns a model comparing oldLines (left) with newLines (right).
func NewFileDiff(cfg *config.Config, oldName, newName string, oldLines, newLines []string) *FileDiff {
a := newFileAlign(cfg, [2]string{oldName, newName}, [2][]string{oldLines, newLines})
a.markPath = newName
(&moveGroup{}).add(a, a.names)
return newFileDiff(a)
}
// NewWatchedFileDiff returns a model comparing the sides of f, which it reloads
// when they change on disk (see watch_interval).
func NewWatchedFileDiff(cfg *config.Config, f source.File) *FileDiff {
m := NewFileDiff(cfg, f.OldFile, f.NewFile, f.Old, f.New)
m.onDisk = &f
m.markPath = repoPath(cfg.Repo(), f.NewFile)
return m
}
// newFileDiff returns the single file view of a.
func newFileDiff(a *fileAlign) *FileDiff {
m := &FileDiff{fileAlign: a}
m.buildScreen()
if slices.Equal(a.lines[left], a.lines[right]) {
m.msg = "files are identical"
}
if w := a.cfg.Warning(); w != "" {
m.msg = w
}
return m
}
// Init starts watching the files, when they are on disk, see watchFiles.
func (m *FileDiff) Init() tea.Cmd { return m.watchFiles() }
// watchFiles watches the files, when they are on disk, while auto reload is
// on, and returns the command waiting for their changes and the check of
// those not watched; off, it watches them no more.
func (m *FileDiff) watchFiles() tea.Cmd {
if m.onDisk == nil {
return nil
}
if !m.cfg.AutoReload {
m.disk.stop()
return nil
}
cmd, msg := m.disk.set(m.cfg, diskPaths(*m.onDisk))
if msg != "" {
m.msg = msg
}
return batch(cmd, m.poll())
}
// shareWatcher makes m watch its files by w, which App shares.
func (m *FileDiff) shareWatcher(w *watch.Watcher) { m.disk.share(w) }
// poll returns the next check of the files, unless one is pending or both
// are watched.
func (m *FileDiff) poll() tea.Cmd {
if m.onDisk == nil {
return nil
}
return m.disk.poll(m.cfg, m, diskPaths(*m.onDisk))
}
func (m *FileDiff) 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 autoReloadMsg:
if msg.on && m.onDisk != nil { // changed while off
if msg := refreshFile(m.onDisk, m.fileAlign, m.syncLayout); msg != "" {
m.msg = msg
}
}
return m, m.watchFiles()
case watchTickMsg:
if !m.disk.ticked(m.cfg, m, msg) {
return m, nil
}
if msg := refreshFile(m.onDisk, m.fileAlign, m.syncLayout); msg != "" {
m.msg = msg
}
return m, m.poll()
case changedMsg:
if msg.w == nil || msg.w != m.disk.w {
return m, nil
}
if msg := refreshFile(m.onDisk, m.fileAlign, m.syncLayout); msg != "" {
m.msg = msg
}
return m, batch(m.disk.next(), m.poll())
case watchEndedMsg:
if !m.disk.ended(msg.w) {
return m, nil
}
return m, m.poll()
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
}
if msg.action == "inotify_max_dirs" {
m.disk.limit(m.cfg)
return m, m.poll()
}
case findMsg:
m.findText(int(msg))
}
return m, nil
}
// stop stops watching the files: the view is replaced.
func (m *FileDiff) stop() { m.disk.stop() }
// refresh reads both files again, whether their stamps changed or not. With
// quiet, it does not say when nothing changed.
func (m *FileDiff) refresh(quiet bool) {
if m.onDisk == nil {
m.msg = nothingToReload
return
}
m.onDisk.OldStamp, m.onDisk.NewStamp = source.Stamp{}, source.Stamp{}
switch msg := refreshFile(m.onDisk, m.fileAlign, m.syncLayout); {
case msg != "":
m.msg = msg
case !quiet:
m.msg = nothingChanged
}
}
func (m *FileDiff) key(k string) tea.Cmd {
h := m.cfg.Keys.Key(keys.DiffView, k)
if m.replace != nil && h.Action != "anchor" && h.Action != "cancel_mark" {
m.replace = nil
}
m.msg = ""
if p := m.prefix; p != "" {
m.prefix = ""
switch {
case !isMarkLetter(k):
case p == "set_mark":
m.msg = setMark(m.cfg, m.fileAlign, k, m.cursor)
default:
return m.gotoMark(k, m.pos()-m.top)
}
return nil
}
if cmd, ok := diffKey(m, h, max(m.bodyHeight()-1, 1), len(m.screen)); ok {
return cmd
}
switch h.Action {
case "next_file":
if m.canBack {
dir := switchFileMsg(direction(h.Index == 0))
return func() tea.Msg { return dir }
}
case "history":
if m.commitNav {
return switchCommit(h)
}
case "flow":
if m.canBack {
return toggleFlow
}
case "anchor", "cancel_mark", "fold", "fold_all":
m.act(h.Action)
case "moved":
return m.jump()
default:
return pageKey(m.cfg.Keys, h, false)
}
return nil
}
// scrollSideways, switchPane and askLetter act for diffKey.
func (m *FileDiff) scrollSideways(cols int) { m.xoff = max(m.xoff+cols, 0) }
func (m *FileDiff) switchPane() { m.active = m.active.other() }
func (m *FileDiff) askLetter(action, msg string) { m.prefix, m.msg = action, msg }
// 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
// list.
func (m *FileDiff) 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 *FileDiff) 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 list, at the
// cursor's screen row.
func (m *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) act(action string) {
var msg string
m.keepScreenRow(func() {
changes := m.changes
var at spot
at, msg = m.fileAlign.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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) 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 *FileDiff) keepScreenRow(f func()) {
offset := m.pos() - m.top
f()
m.top = m.pos() - offset
m.scrollToCursor()
}
func (m *FileDiff) scrollToCursor() {
m.top = scrollWindow(m.pos(), m.top, m.bodyHeight(), len(m.screen))
}
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/keys"
)
// 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
diff *Diff // 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 (keys of 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. In the log, commits load in the background once they are on
// the screen, one at a time; until then a note says that they load.
type Flow struct {
cfg *config.Config
parts []*flowPart
// load returns the command loading the files of the commit of part,
// whose result the log gives back to partLoaded; nil outside the log.
load func(part int) tea.Cmd
order historyOrder // of the commits, the parts in the log
loading *flowPart // the commit loading, nil for none
ahead int // the way of the last move, whose commits a screen beyond load too
atEnd bool // end was pressed last: the cursor stays on the last row
waiting *flowWait // a search waiting for a commit to load
rows []flowRow
rowAt map[flowRow]int // index of each row, see find
stale bool // a row changed since rowAt was built
built shape // what the rows of the diffs were built for
cursor int
top int
xoff int
active side
marked *fileAlign // diff holding a pending mark
replacing *fileAlign // diff waiting for confirmation to replace anchors
prefix string // the action, "set_mark" or "jump", while waiting for the letter of a mark
width, height int
msg string
}
// flowWait is a search that reached a commit not loaded yet: redo runs it
// again once part is loaded.
type flowWait struct {
part *flowPart
redo func()
}
func newFlow(cfg *config.Config, parts []*flowPart, load func(int) tea.Cmd) *Flow {
f := &Flow{cfg: cfg, parts: parts, load: load}
f.build()
return f
}
// flowOfDiff returns the flow view of the files of d, outside the log.
func flowOfDiff(d *Diff) *Flow { return newFlow(d.cfg, []*flowPart{{diff: d}}, 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
}
}
// A key's own command comes first, alone, as the tests run commands one
// at a time; the next message starts the load.
if cmd == nil {
cmd = f.nextLoad()
}
return f, cmd
}
func (f *Flow) key(k string) tea.Cmd {
h := f.cfg.Keys.Key(keys.FlowView, k)
if f.replacing != nil && h.Action != "anchor" && h.Action != "cancel_mark" {
f.replacing.replace = nil
f.replacing = nil
}
f.msg = ""
f.atEnd, f.waiting = false, nil // another key: end and searches let go
if p := f.prefix; p != "" {
f.prefix = ""
switch {
case !isMarkLetter(k):
case p == "set_mark":
f.setMark(k)
default:
f.gotoMark(k)
}
return nil
}
if cmd, ok := diffKey(f, h, max(f.bodyHeight()-1, 1), far); ok {
return cmd
}
switch h.Action {
case "flow":
return toggleFlow
case "to_files":
return func() tea.Msg { return backMsg{} }
case "next_file":
f.nextFile(direction(h.Index == 0))
case "history":
if !f.inLog() {
break
}
// The bars of all commits are there, loaded or not: no waiting.
if r, _ := f.search(f.order.step(historyDir(h)), f.isKind(flowCommit, 0), false, false); r == found {
f.barToTop()
} else {
f.msg = ends(historyDir(h) > 0, "last commit", "first commit")
}
case "anchor", "cancel_mark", "fold", "fold_all":
f.delegate(h.Action)
case "moved":
f.jump()
default:
return pageKey(f.cfg.Keys, h, f.inLog())
}
return nil
}
// scrollSideways, switchPane and askLetter act for diffKey.
func (f *Flow) scrollSideways(cols int) { f.xoff = max(f.xoff+cols, 0) }
func (f *Flow) switchPane() { f.active = f.active.other() }
func (f *Flow) askLetter(action, msg string) { f.prefix, f.msg = action, msg }
// 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].diff.files[r.file]
a := e.align
if action == "anchor" && f.marked != a {
f.dropMark()
}
var at spot
f.keepScreenRow(func() {
at, f.msg = a.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 a.replace != nil {
f.replacing = a
}
if a.pending != nil {
f.marked = a
} else if f.marked == a {
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]
d := f.parts[r.part].diff
a := d.files[r.file].align
end, msg, ok := a.otherEnd(spot{r.line, r.sub, f.active}, f.built)
f.msg = msg
if !ok {
return
}
i := d.fileByGroup(end.file)
if i < 0 {
return
}
at := d.files[i].align.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].diff.files[r.file].align, 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].diff == nil {
f.msg = notInDiff(letter, mk)
return
}
r := f.rows[f.cursor]
d := f.parts[r.part].diff
i := d.fileByPath(mk.Path)
if i < 0 {
f.msg = notInDiff(letter, mk)
return
}
a := d.files[i].align
line, note, ok := a.markLine(mk)
if !ok {
f.msg = markMsg(letter, "the new file is empty")
return
}
at := a.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 a := f.marked; a != nil && a.pending != nil {
a.pending = nil
a.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 {
if part.diff == nil && part.commit != nil {
part.diff = part.commit.diff // loaded for the list meanwhile
}
for i := range f.commitLines(part) {
add(flowCommit, p, -1, i)
}
if part.diff == nil || len(part.diff.files) == 0 {
add(flowNote, p, -1, 0) // loading, why it cannot, or no files
}
if part.diff == nil {
continue
}
for i, e := range part.diff.files {
add(flowFile, p, i, 0)
if e.Binary {
add(flowNote, p, i, 0)
continue
}
screen, _ := part.diff.diffOf(e).screenLines(f.built)
for _, s := range screen {
f.rows = append(f.rows, flowRow{flowDiff, p, i, s.v, s.sub})
}
}
}
f.rowAt, f.stale = make(map[flowRow]int, len(f.rows)), false
for i, r := range slices.Backward(f.rows) {
f.rowAt[r] = i // the first of equal rows, as a search finds
}
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(*fileAlign))) {
var cur flowRow
var a *fileAlign
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]
a = f.parts[cur.part].diff.files[cur.file].align
row, reloads = a.rows[a.view[cur.line].row], a.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 *fileAlign) {
if rd != a || lost {
return
}
if i := a.rowAfterReload(row); i >= 0 {
row = a.rows[i]
} else {
lost = true
}
})
if a != nil {
// The files may have been listed again, in another order.
cur.file = slices.IndexFunc(f.parts[cur.part].diff.files, func(e *fileEntry) bool { return e.align == a })
}
f.relocate()
if a == nil || cur.file < 0 || a.reloads == reloads || lost {
return
}
if v := a.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 before to after: 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, before, after []*fileEntry) {
if f.cursor >= len(f.rows) {
return
}
r := &f.rows[f.cursor]
if r.part != part || r.file < 0 {
return
}
f.stale = true
for i := r.file; i < len(before); i++ {
if j := slices.Index(after, before[i]); j >= 0 {
if i == r.file {
r.file = j
} else {
*r = flowRow{flowFile, part, j, 0, 0}
}
return
}
}
*r = flowRow{flowFile, part, len(after) - 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])
f.stale = true
}
}
// 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 a := f.parts[cur.part].diff.files[cur.file].align; cur.line < len(a.view) {
sub = a.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})
}
// find returns the index of row r, or -1. The rows are indexed by build: a
// long log has many, and each move looks for rows. After a row changed in
// place, as followFiles and followParts do before the rows are rebuilt, they
// are searched.
func (f *Flow) find(r flowRow) int {
if !f.stale {
if i, ok := f.rowAt[r]; ok {
return i
}
return -1
}
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 }
// unloaded reports whether the files of part are not loaded yet, neither
// for the flow nor for the list.
func (f *Flow) unloaded(part int) bool {
p := f.parts[part]
return p.diff == nil && p.err == "" && (p.commit == nil || p.commit.diff == nil)
}
// nextLoad returns the command loading the next commit, nil while one loads
// or none is to: the one a search waits for, else the first on the screen
// not loaded yet, else the first within a screen from the cursor on the way
// of the last move, so that it is there when the cursor comes.
func (f *Flow) nextLoad() tea.Cmd {
if !f.inLog() || f.loading != nil {
return nil
}
p := -1
if w := f.waiting; w != nil {
p = slices.Index(f.parts, w.part)
}
h := f.bodyHeight()
first := func(from, to int) {
for i := max(from, 0); p < 0 && i < min(to, len(f.rows)); i++ {
if q := f.rows[i].part; f.unloaded(q) {
p = q
}
}
}
first(f.top, f.top+h)
switch {
case f.ahead > 0:
first(f.cursor, f.cursor+h+1)
case f.ahead < 0:
first(f.cursor-h, f.cursor+1)
}
if p < 0 || !f.unloaded(p) {
return nil
}
f.loading = f.parts[p]
return f.load(p)
}
// partLoaded takes the files of the commit of part, which the log loaded,
// or why they could not be. The cursor stays on its row and screen row, or
// after end on the last row; a search waiting for the commit goes on. It
// returns the command loading the next commit.
func (f *Flow) partLoaded(part *flowPart, err error) tea.Cmd {
if f.loading == part {
f.loading = nil
}
if err != nil {
part.err = firstLine(err)
}
f.keepScreenRow(f.relocate) // build takes the files from the commit
if f.atEnd {
_, f.cursor = f.bounds()
f.scrollToCursor()
}
if w := f.waiting; w != nil && !f.unloaded(slices.Index(f.parts, w.part)) {
f.waiting = nil
w.redo()
}
return f.nextLoad()
}
// await makes the search redo wait for commit p to load, saying so.
func (f *Flow) await(p int, redo func()) {
f.waiting = &flowWait{f.parts[p], redo}
f.msg = "loading " + f.parts[p].commit.Short + "…"
}
// moveCursor moves the cursor by delta rows, over the rows there are: the
// commits on the way load as they come on the screen. For far, end, the
// cursor stays on the last row as they load, until another key.
func (f *Flow) moveCursor(delta int) {
f.cursor = f.confine(max(min(f.cursor+delta, len(f.rows)-1), 0))
f.atEnd, f.ahead = delta >= far, direction(delta > 0)
f.scrollToCursor()
}
// 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.
func (f *Flow) scroll(delta int) {
f.ahead = direction(delta > 0)
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()
}
// searchResult is how a search ended.
type searchResult int
const (
notFound searchResult = iota
found
unloadedOnTheWay // a commit on the way is not loaded yet, see search
)
// search moves the cursor to the next row in direction dir for which ok
// holds. With stay set it does not leave the bounds; reaching them sets a
// warning. With wait, a commit on the way that is not loaded yet ends it,
// at the first of its rows that are there that ok does not take: its part
// is returned, for the caller to wait for it (see await), else -1.
func (f *Flow) search(dir int, ok func(int) bool, stay, wait bool) (searchResult, int) {
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 notFound, -1
}
if ok(i) {
f.cursor = i
f.scrollToCursor()
return found, -1
}
if p := f.rows[i].part; wait && f.inLog() && f.unloaded(p) {
return unloadedOnTheWay, p
}
}
return notFound, -1
}
// nextChange moves the cursor to the start of the next (dir > 0) or previous
// block of changed rows, waiting for the commits on the way to load.
func (f *Flow) nextChange(dir int) {
switch r, p := f.search(dir, f.changeStart, true, true); {
case r == unloadedOnTheWay:
f.await(p, func() { f.nextChange(dir) })
case r == notFound && f.msg == "":
f.msg = "no more changes"
}
}
// nextFile moves the cursor to the bar of the next (dir > 0) or previous
// file, at the top of the screen, waiting for the commits on the way to
// load.
func (f *Flow) nextFile(dir int) {
switch r, p := f.search(dir, f.isKind(flowFile, 0), true, true); {
case r == found:
f.barToTop()
case r == unloadedOnTheWay:
f.await(p, func() { f.nextFile(dir) })
case f.msg == "":
f.msg = ends(dir > 0, "last file", "first file")
}
}
// 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].diff.files[r.file].align.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) {
f.ahead, f.atEnd, f.waiting = 0, false, nil
// 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].diff == 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()
}
// 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. FileDiff.takeOver is the way
// back.
func (f *Flow) showFrom(part, file int, from *FileDiff) {
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
d := f.parts[part].diff
if d == nil {
return
}
var marked []*fileAlign
for i, e := range d.files {
a := e.align
if a == nil {
continue
}
if a.pending != nil {
if i == file {
marked = append([]*fileAlign{a}, marked...)
} else {
marked = append(marked, a)
}
}
if a.replace != nil {
f.replacing = a
}
}
if len(marked) == 0 {
return
}
for _, a := range marked[1:] {
f.marked = a
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() && p < len(f.parts) {
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].diff.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].diff.files
where = append(where, fmt.Sprintf("file %d/%d", file+1, len(files)))
if a := files[file].align; a != nil {
where = append(where, fmt.Sprintf("anchors %d", len(a.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.Heading.Inherit(t.Zero))
}
return mark + text(line, t.Zero)
case flowFile:
e := part.diff.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)
case part.diff == nil:
note = "loading…"
}
return mark + text(" "+note, t.Hint)
}
a := part.diff.files[r.file].align
return a.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"
"jdiff/internal/keys"
)
// binding is a key and what it does; a binding without key is a heading.
type binding struct{ key, what string }
// helpBindings returns the lines of the help popup of km, in the order of its
// help; helpLines puts the settings in their order.
func helpBindings(km *keys.Map) []binding {
var bs []binding
for _, h := range km.Help {
switch {
case h.Heading != "":
bs = append(bs, binding{"", h.Heading})
case h.Note != "":
bs = append(bs, binding{"", " " + km.Text(h.Note)})
default:
r := km.Row(h.Action)
bs = append(bs, binding{strings.Join(r.Keys, " "), km.Text(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
lines []binding // of cfg.Keys, which do not change
rows []string // the lines drawn, wrapped to the width while open
boxWidth int
open bool
top int // first shown row 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, lines: helpLines(cfg.Keys)}
}
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
if h.open { // else wrapped when opened: resizing stays cheap
h.wrap()
h.scroll(0)
}
case tea.KeyPressMsg:
if cmd, ok := popupKeys(h.cfg.Keys, 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(km *keys.Map, msg tea.KeyPressMsg, open bool, opener string, key func(string) tea.Cmd) (tea.Cmd, bool) {
k := msg.String()
if !open && !isAction(km, 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
h.wrap()
return nil
}
cmd, _ := popupNav(h.cfg.Keys, k, "help", &h.open, h.scroll)
return cmd
}
// popupNav handles the keys that all popups share, so that they close and
// move alike: a key of opener, esc and q close the popup, ctrl+c quits, down
// and j move by one, up and k back. It reports whether k was one of them;
// the popup handles the others.
func popupNav(km *keys.Map, k, opener string, open *bool, move func(delta int)) (tea.Cmd, bool) {
if isAction(km, k, opener) {
*open = false
return nil, true
}
switch k {
case "esc", "q":
*open = false
case "ctrl+c":
return tea.Quit, true
case "down", "j":
move(1)
case "up", "k":
move(-1)
default:
return nil, false
}
return nil, true
}
// boxHeight is the height of the popup.
func (h *Help) boxHeight() int { return popupHeight(len(h.rows), 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(h.rows)).first
}
// wrap draws the lines into rows as wide as the screen allows: what does not
// fit goes on in the rows below, under its first word, instead of being cut.
// The popup is as wide as its widest row.
func (h *Help) wrap() {
t := h.cfg.Theme
keyW := 0
for _, b := range h.lines {
keyW = max(keyW, ansi.StringWidth(b.key))
}
inner := h.width - 4 // border and padding
h.rows = h.rows[:0]
for _, b := range h.lines {
switch {
case b.key == "" && !strings.HasPrefix(b.what, " "):
h.rows = append(h.rows, wrapRows("", 0, b.what, inner, t.Branch)...)
case b.key == "":
h.rows = append(h.rows, wrapRows(strings.Repeat(" ", keyW+2), keyW+2, strings.TrimSpace(b.what), inner, t.Hint)...)
default:
key := t.Key.Render(fmt.Sprintf(" %-*s", keyW, b.key)) + " "
h.rows = append(h.rows, wrapRows(key, keyW+4, b.what, inner, lipgloss.NewStyle())...)
}
}
rowW := 0
for _, r := range h.rows {
rowW = max(rowW, ansi.StringWidth(r))
}
h.boxWidth = min(rowW+4, h.width)
}
// wrapRows returns the rows of text in style after prefix, which is indent
// columns wide, wrapped to width; the rows after the first are indented as
// far. Without room after the indent, text stays in one row.
func wrapRows(prefix string, indent int, text string, width int, style lipgloss.Style) []string {
if width-indent < 1 {
return []string{prefix + style.Render(text)}
}
parts := strings.Split(ansi.Wrap(text, width-indent, ""), "\n")
rows := make([]string, len(parts))
for i, p := range parts {
if i > 0 {
prefix = strings.Repeat(" ", indent)
}
rows[i] = prefix + style.Render(strings.TrimRight(p, " "))
}
return rows
}
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
lines := windowAt(h.top, h.boxHeight()-2, len(h.rows)).rows(t, len(h.rows), func(i int) string { return h.rows[i] })
title := t.CommitHash.Render(" keys ")
if h.boxHeight()-2 < len(h.rows) {
title += t.Hint.Render(fmt.Sprintf(" %d/%d ", h.top+1, len(h.rows)))
}
return overlay(base, box(t, h.boxWidth, h.boxHeight(), title, lines), h.boxWidth, 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 (
"context"
"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/keys"
"jdiff/internal/source"
)
// LoadFunc returns the files a commit changed. It runs in the background,
// and ends when ctx is done.
type LoadFunc func(ctx context.Context, c gitsrc.Commit) ([]source.File, error)
// CountFunc returns the line counts of the commits with the given hashes. It
// runs in the background, and ends when ctx is done.
type CountFunc func(ctx context.Context, 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 list 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
diff *Diff
}
// Log is the Bubble Tea model of the commit list (keys of commit_list).
// Enter shows the files of a commit, a Diff; 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
cols logColumns // see measure
loadGen int // counts the requests of requestCommit
pending *commitRequest // the commit loading, nil for none
relisting relistState // of the commits, see relistCommits
// ctx ends the git commands of the log, cancel ends ctx, see stop.
ctx context.Context
cancel context.CancelFunc
commits []*commitEntry
order historyOrder // of commits
list
open *commitEntry // commit whose files are shown, nil for the list
fileList 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 // the action, "jump", 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)}
l.ctx, l.cancel = context.WithCancel(context.Background())
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.measure()
l.flow = newFlow(cfg, parts, func(p int) tea.Cmd {
part := l.flow.parts[p]
return l.loadCmd(l.ctx, part.commit, 0, part)
})
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. It runs
// in the background, and ends when ctx is done.
type CommitsFunc func(ctx context.Context) (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
ctx, counts := l.ctx, l.counts
return func() tea.Msg {
c, err := counts(ctx, 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
}
}
l.measure()
}
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() {
_, cmd := l.flow.Update(msg)
return cmd
}
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() {
return l.filesOfFlow()
}
l.open, l.fileList = nil, false
return nil
case flowAtMsg:
return l.showFlow(int(msg), nil)
case switchCommitMsg:
return l.switchCommit(int(msg))
case commitLoadedMsg:
return l.commitLoaded(msg)
case refreshMsg:
return l.relistCommits(msg.quiet)
case commitsListedMsg:
if msg.log == l {
return l.commitsListed(msg)
}
return nil
case showMsg:
l.setMsg(string(msg))
return nil
case toggleUntrackedMsg:
l.setMsg(untrackedOnlyInDiff)
return nil
case toggleFlowMsg:
cmd := l.toggleFlow()
if w := saveState(l.cfg); w != "" {
l.setMsg(w)
}
return cmd
}
if l.flowShown() {
_, cmd := l.flow.Update(msg)
l.cursor, _, _ = l.flow.position()
l.scrollToCursor()
return cmd
}
if l.open != nil {
_, cmd := l.open.diff.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 := l.cfg.Keys.Key(keys.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 {
return l.showFlow(-1, nil)
}
return l.requestCommit(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, jumpPrompt
default:
return pageKey(l.cfg.Keys, 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.fileList }
// 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), and returns the command
// loading the commits on the screen. A list that loading again emptied has
// no commit to show.
func (l *Log) showFlow(file int, from *FileDiff) tea.Cmd {
if len(l.commits) == 0 {
l.msg = "no commits"
return nil
}
l.open, l.fileList = l.commits[l.cursor], false
l.flow.width, l.flow.height = l.width, l.height
l.flow.showFrom(l.cursor, file, from)
return l.flow.nextLoad()
}
// toggleFlow switches between the flow view and the files of the commit
// under the cursor, keeping the cursor on its line and screen row, and
// returns the command loading the commits on the screen of the flow view.
func (l *Log) toggleFlow() tea.Cmd {
st := &l.cfg.Settings
if !st.Flow {
st.Flow = true
switch {
case l.open == nil:
return l.showFlow(-1, nil)
case l.open.diff.open == nil:
return l.showFlow(l.open.diff.cursor, nil)
default:
return l.showFlow(l.open.diff.cursor, l.open.diff.open.view)
}
}
st.Flow = false
if l.open == nil {
return nil
}
if l.fileList { // the file list stays
l.fileList = false
return nil
}
p, file, line := l.flow.position()
screenRow := l.flow.cursor - l.flow.top
path := ""
if e := l.commits[p]; e.diff != nil && file >= 0 {
path = e.diff.files[file].Path
}
l.open = nil
if l.commits[p].diff == nil { // still loading: its files when they come
return l.requestCommit(p, "")
}
if !l.openCommit(p, path) {
return nil
}
d := l.open.diff
if d.open == nil && file >= 0 {
d.cursor = file
d.scrollToCursor()
}
if d.open != nil && d.open.Path == path {
d.open.view.takeOver(l.flow, line, screenRow)
}
return nil
}
// diff returns the file list of commit i, loading its files on first
// use.
func (l *Log) diff(i int) (*Diff, error) {
e := l.commits[i]
if e.diff == nil {
files, err := l.load(l.ctx, e.Commit)
if err != nil {
return nil, err
}
e.diff = newDiff(l.cfg, files, &e.Commit)
}
return e.diff, nil
}
// openCommit shows the files of commit i, opening path directly when the
// commit changed it.
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.diff(i); err != nil {
l.msg = firstLine(err)
return false
}
e.diff.Update(tea.WindowSizeMsg{Width: l.width, Height: l.height})
if path == "" || !e.diff.openPath(path) {
e.diff.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, and returns
// the command loading it, see requestCommit.
func (l *Log) switchCommit(dir int) tea.Cmd {
if l.open == nil {
return nil
}
d := l.open.diff
path := ""
if d.open != nil {
path = d.open.Path
}
i := l.cursor + l.order.step(dir)
if i < 0 || i >= len(l.commits) {
d.setMsg(ends(dir > 0, "last commit", "first commit"))
return nil
}
if d.open != nil {
d.open.updateStats()
}
return l.requestCommit(i, path)
}
// commitLoadedMsg brings the files of the commit with hash, or why they
// could not be loaded: for the request number gen of requestCommit or
// filesOfFlow, 0 for none, and for part of the flow view, nil for none.
type commitLoadedMsg struct {
gen int
hash string
files []source.File
err error
part *flowPart
}
// loadCmd returns the command loading the files of e in the background, for
// request gen and the flow's part, see commitLoadedMsg; ctx ends it. Only git
// runs there: the views are made when the files come, on the keys' side,
// which owns the settings.
func (l *Log) loadCmd(ctx context.Context, e *commitEntry, gen int, part *flowPart) tea.Cmd {
c, load := e.Commit, l.load
return func() tea.Msg {
files, err := load(ctx, c)
return commitLoadedMsg{gen, c.Hash, files, err, part}
}
}
// commitRequest is the commit requestCommit loads: shown with path open
// when it arrives, if the view is still from, which it was asked from; with
// files, as filesOfFlow asks, its file list when the flow view still shows
// it. msg says that it loads; cancel ends its git commands.
type commitRequest struct {
gen int
path, msg string
from *commitEntry
files bool
cancel context.CancelFunc
}
// request starts loading the files of e for req, ending any request before
// and its git commands, and returns the command; the status bar says that
// it loads.
func (l *Log) request(e *commitEntry, req commitRequest) tea.Cmd {
if l.pending != nil {
l.pending.cancel()
}
l.loadGen++
req.gen, req.msg = l.loadGen, "loading "+e.Short+"…"
ctx, cancel := context.WithCancel(l.ctx)
req.cancel = cancel
l.pending = &req
l.setMsg(req.msg)
return l.loadCmd(ctx, e, req.gen, nil)
}
// stop ends the git commands the log runs in the background: the view is
// replaced, or the program ends.
func (l *Log) stop() { l.cancel() }
// requestCommit shows commit i as openCommit does, at once when its files
// are loaded, else it returns the command loading them, which runs git
// without blocking the keys. The status bar says what loads meanwhile, and
// the view stays usable; moving to another view, or another request, drops
// the files when they come.
func (l *Log) requestCommit(i int, path string) tea.Cmd {
if i < 0 || i >= len(l.commits) {
return nil
}
e := l.commits[i]
if e.diff != nil {
if !l.openCommit(i, path) && l.open != nil {
l.open.diff.setMsg(l.msg)
}
return nil
}
return l.request(e, commitRequest{path: path, from: l.open})
}
// commitLoaded takes the files msg brings: they become the commit's, unless
// it has some already, loaded for the list or the flow view meanwhile. The
// flow view, when it loaded them, shows them; a request shows the commit,
// when it is still the one asked for last and the view is the one it was
// asked from. It returns the command loading the next commit of the flow
// view.
func (l *Log) commitLoaded(msg commitLoadedMsg) tea.Cmd {
e := l.byHash[msg.hash]
if e != nil && e.diff == nil && msg.err == nil {
e.diff = newDiff(l.cfg, msg.files, &e.Commit)
}
var cmd tea.Cmd
if msg.part != nil {
cmd = l.flow.partLoaded(msg.part, msg.err)
}
req := l.pending
if req == nil || msg.gen != req.gen {
return cmd
}
l.pending = nil
req.cancel()
l.dropMsg(req.msg)
switch {
case e == nil:
case req.files:
if p, _, _ := l.flow.position(); l.flowShown() && l.commits[p] == e {
cmd = batch(cmd, l.filesOfFlow())
}
case l.open != req.from || l.flowShown():
case msg.err != nil:
l.setMsg(firstLine(msg.err))
default:
l.openCommit(slices.Index(l.commits, e), req.path)
}
return cmd
}
// commitsListedMsg brings the commits log listed again, with the branch, or
// why it could not.
type commitsListedMsg struct {
log *Log
branch string
commits []gitsrc.Commit
err error
quiet bool
}
// relistCommits returns the command listing the commits again (r) in the
// background, as Diff.relistFiles does the files: the commits stay usable
// meanwhile, and a listing asked for while one runs runs after it.
func (l *Log) relistCommits(quiet bool) tea.Cmd {
if l.relist == nil {
l.setMsg(nothingToReload)
return nil
}
if !l.relisting.start(quiet) {
return nil
}
if !quiet {
l.setMsg(reloading)
}
ctx, list := l.ctx, l.relist
return func() tea.Msg {
branch, commits, err := list(ctx)
return commitsListedMsg{l, branch, commits, err, quiet}
}
}
// commitsListed takes the commits listed again. 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. It returns
// the command listing them once more when that was asked for meanwhile.
func (l *Log) commitsListed(res commitsListedMsg) tea.Cmd {
var next tea.Cmd
if again, quiet := l.relisting.done(); again {
next = l.relistCommits(quiet)
}
branch, commits, quiet := res.branch, res.commits, res.quiet
if res.err != nil {
l.setMsg(firstLine(res.err))
return next
}
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)
l.measure()
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)
next = batch(next, l.flow.nextLoad())
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)
}
return next
}
// 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 that
// cannot be loaded has no list to show: the commits are shown, on it. A
// commit still loading shows its list when its files come; it returns the
// command loading them.
func (l *Log) filesOfFlow() tea.Cmd {
p, file, _ := l.flow.position()
l.cursor = p
l.scrollToCursor()
e := l.commits[p]
if e.diff == nil && l.flow.parts[p].err == "" { // its list when it comes
return l.request(e, commitRequest{files: true})
}
if e.diff == nil {
l.open, l.fileList = nil, false
return nil
}
d := e.diff
d.Update(tea.WindowSizeMsg{Width: l.width, Height: l.height})
d.open = nil
if file >= 0 {
d.cursor = file
d.scrollToCursor()
}
l.open, l.fileList = l.commits[p], true
return nil
}
// dropMsg clears msg where setMsg may have put it, unless another message
// took its place.
func (l *Log) dropMsg(msg string) {
if l.msg == msg {
l.msg = ""
}
if l.flow.msg == msg {
l.flow.msg = ""
}
for _, e := range l.commits {
if d := e.diff; d != nil {
if d.msg == msg {
d.msg = ""
}
if d.open != nil && d.open.view != nil && d.open.view.msg == msg {
d.open.view.msg = ""
}
}
}
}
// 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.diff.setMsg(msg)
default:
l.msg = msg
}
}
// maxAuthorWidth caps the author column.
const maxAuthorWidth = 16
// logColumns are the widths the columns of the commit list need: the digits
// of the counts, the author, the subject, and hash and date together.
type logColumns struct{ added, removed, author, subject, fixed int }
// measure computes the widths of the columns, after the commits or their
// counts changed: measuring them on each frame takes too long in a long
// history.
func (l *Log) measure() {
c := logColumns{added: l.minCount, removed: l.minCount}
for _, e := range l.commits {
c.added = max(c.added, len(fmt.Sprint(e.Added)))
c.removed = max(c.removed, len(fmt.Sprint(e.Removed)))
c.author = max(c.author, min(ansi.StringWidth(e.Author), maxAuthorWidth))
c.subject = max(c.subject, ansi.StringWidth(e.Subject))
c.fixed = max(c.fixed, ansi.StringWidth(e.Short)+ansi.StringWidth(e.Date))
}
l.cols = c
}
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.diff.frame()
}
if l.width == 0 {
return ""
}
t := l.cfg.Theme
addW, remW, authW := l.cols.added, l.cols.removed, l.cols.author
// The subject gets the room left by the other columns, so that author
// and date stay aligned and visible.
fixedW := l.cols.fixed + 1 + 2 + (addW + 1) + 1 + (remW + 1) + 2 + 2 + authW + 2
subjW := max(min(l.cols.subject, l.width-fixedW), 1)
count := fmt.Sprintf("(%d commits)", len(l.commits))
if len(l.commits) == 1 {
count = "(1 commit)"
}
head := " " + t.Branch.Render(l.branch) + " " + t.Heading.Render(count)
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.Heading.Inherit(t.Zero).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 Readme 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"
"jdiff/internal/keys"
)
// markJumpMsg asks the file list 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 a and
// returns the message to show.
func setMark(cfg *config.Config, a *fileAlign, letter string, v int) string {
line := a.lineAt(right, v)
if line < 0 {
return "no line of the new file here"
}
m := config.Mark{Path: a.markPath, Line: line + 1, Text: strings.TrimSuffix(a.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 a: 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 (a *fileAlign) markLine(m config.Mark) (line int, note string, ok bool) {
lines := a.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 a in the screen lines of
// sh, unfolding the block of unchanged rows the line is in.
func (a *fileAlign) reveal(s side, line int, sh shape) spot {
row := a.rowOf[s][line]
for _, b := range a.blocks {
if row >= b.start && row < b.end && !a.blockOpen(b) {
a.foldOpen[a.rows[b.start].Left] = true
a.buildView()
break
}
}
return a.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(p.cfg.Keys, 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 cmd, ok := popupNav(p.cfg.Keys, k, "marks", &p.open, p.move); ok {
return cmd
}
marks := p.cfg.Marks()
switch k {
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(keys.Name(p.cfg.Keys.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"
"jdiff/internal/keys"
)
// switchFileMsg asks the file list 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 list 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 list 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"
// The messages of loading again: nothingToReload where nothing is shown that
// could be, reloading while it runs in the background.
const (
nothingToReload = "nothing to load again"
reloading = "loading again…"
)
// The prompts of the keys of marks, until the letter is pressed.
const (
setMarkPrompt = "set mark: press a letter"
jumpPrompt = "jump to mark: press its letter"
)
// relistState is the state of listing again in the background, which Diff
// (its files) and Log (its commits) must keep the same way: while a listing
// runs, another one is not started, but run once it is done, quietly only
// when all that asked for it meanwhile asked quietly.
type relistState struct {
running, again, againQuiet bool
}
// start returns whether a listing with quiet starts now; while one runs, it
// notes the listing for after it instead.
func (r *relistState) start(quiet bool) bool {
if r.running {
r.againQuiet = quiet && (!r.again || r.againQuiet)
r.again = true
return false
}
r.running = true
return true
}
// done ends the listing that ran. It returns whether another one was asked
// for meanwhile, and whether quietly.
func (r *relistState) done() (again, quiet bool) {
r.running = false
again, r.again = r.again, false
return again, r.againQuiet
}
// askAutoReloadMsg asks App 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 App 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(km *keys.Map, action string, logFlow bool) tea.Cmd {
options := settingWords(km, settingTable)
i := slices.IndexFunc(options, func(o settingOption) bool { return o.key != "" && o.action == action })
if i < 0 || options[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(km *keys.Map, h keys.Hit, 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(km, h.Action, logFlow)
}
// settingChangedMsg tells the page below SettingsPopup that a toggle or a
// number 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 list 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 list 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 keys.Hit) int { return direction(h.Index == 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 keys.Hit) 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 keys.Hit, page, far int) (delta int, paging, ok bool) {
switch h.Action {
case "move":
return direction(h.Index == 0), false, true
case "page":
return direction(h.Index == 1) * page, true, true
case "ends":
return direction(h.Index == 1) * far, false, true
}
return 0, false, false
}
// diffKeyView is what the keys that work alike in the diff view and the flow
// view act on, see diffKey.
type diffKeyView interface {
moveCursor(delta int)
scroll(delta int) // up and down, the window along
scrollSideways(cols int) // the lines, not before their start
switchPane()
nextChange(dir int)
findText(dir int)
askLetter(action, msg string) // the prefix and its message, until the mark's letter
}
// diffKey handles the keys that must work alike in the diff view and the
// flow view, and reports whether h was one of them: motion, quit, pane,
// half_page, scroll, change, search, match, set_mark and jump. page is the
// paging step, far the move to the first or last row. Moving between files
// and commits, anchors, folds and moved lines differ, and stay with each
// view.
func diffKey(v diffKeyView, h keys.Hit, page, far int) (tea.Cmd, bool) {
if delta, paging, ok := motion(h, page, far); ok {
if paging {
v.scroll(delta)
} else {
v.moveCursor(delta)
}
return nil, true
}
switch h.Action {
case "quit":
return tea.Quit, true
case "pane":
v.switchPane()
case "half_page":
v.scroll(halfPage(h, page))
case "scroll":
v.scrollSideways(direction(h.Index == 1) * scrollStep)
case "change":
v.nextChange(direction(h.Index == 0))
case "search":
return askSearch, true
case "match":
v.findText(direction(h.Index == 0))
case "set_mark":
v.askLetter(h.Action, setMarkPrompt)
case "jump":
v.askLetter(h.Action, jumpPrompt)
default:
return nil, false
}
return nil, true
}
// scrollStep is how many columns the scroll keys move the lines sideways,
// in the diff view and the flow view alike.
const scrollStep = 8
// halfPage returns how many rows half_page key h scrolls, half of page rows,
// the paging step, in the diff view, the flow view and the readme alike.
func halfPage(h keys.Hit, page int) int { return direction(h.Index == 1) * (page / 2) }
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
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 []*fileAlign
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 a, whose sides are called names, to the group.
func (g *moveGroup) add(a *fileAlign, names [2]string) {
a.group, a.groupIndex = g, len(g.diffs)
g.diffs = append(g.diffs, a)
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, a := range g.diffs {
fresh = fresh && g.aligns[i] == a.aligns
}
if fresh {
return
}
in := make([]diff.MoveInput, len(g.diffs))
g.aligns = make([]int, len(g.diffs))
for i, a := range g.diffs {
in[i] = diff.MoveInput{Old: a.lines[left], New: a.lines[right], Rows: a.rows}
g.aligns[i] = a.aligns
}
g.moves = diff.FindMoves(in)
}
// movedTo returns where line of side s of a moved: for a removed line the
// line it moved to, for an added line the line it came from.
func (a *fileAlign) movedTo(s side, line int) (diff.Ref, bool) {
if a.group == nil || line < 0 {
return diff.Ref{}, false
}
a.group.ensure()
moves := a.group.moves.To
if s == left {
moves = a.group.moves.From
}
r, ok := moves[a.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 (a *fileAlign) otherEnd(at spot, sh shape) (end moveEnd, msg string, ok bool) {
s := at.side
if sh.unified {
s = a.unifiedSide(at, sh)
}
line := a.lineAt(s, at.v)
r, ok := a.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 = a.lineAt(s, at.v)
r, ok = a.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, a.group.names[a.groupIndex][s], line+1), true
}
// spotOf returns the cursor on the start of line of side s of a in the
// screen lines of sh (see firstSub).
func (a *fileAlign) spotOf(s side, line int, sh shape) spot {
v := a.viewOf[a.rowOf[s][line]]
return spot{v, a.firstSub(v, s, sh), s}
}
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"
"jdiff/internal/keys"
)
// Readme shows the pages of jdiff's documentation, markdown rendered for the
// terminal: jdiff --readme (keys of doc_view). 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 Readme 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
}
// NewReadme returns the documentation in docs, whose pages are order, on page
// order[page].
func NewReadme(cfg *config.Config, docs fs.FS, order []string, page int) *Readme {
return &Readme{cfg: cfg, docs: docs, order: order, page: order[page], anchors: map[string]map[string]int{}, link: -1}
}
func (r *Readme) Init() tea.Cmd { return nil }
func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
r.width, r.height = msg.Width, msg.Height
r.render()
case tea.KeyPressMsg:
return r, r.key(msg.String())
case findMsg:
r.findText(int(msg))
}
return r, nil
}
// textWidth is the width pages are rendered for: the screen but a column
// on each side, the left one for the cursor.
func (r *Readme) textWidth() int { return max(r.width-2, 1) }
// bodyHeight is the number of lines shown, between the title and the status
// bar.
func (r *Readme) bodyHeight() int { return max(r.height-2, 1) }
// render renders the page for the width of the screen, unless it is already.
func (r *Readme) render() {
if r.forWidth == r.textWidth() {
return
}
src, err := fs.ReadFile(r.docs, r.page)
if err != nil {
r.msg = err.Error()
}
r.forWidth = r.textWidth()
if path.Ext(r.page) == ".md" {
r.rendered = renderMarkdown(src, r.forWidth)
} else {
r.rendered = renderText(src, r.forWidth)
}
for i, l := range r.rendered.links {
_, _, ok := r.resolve(l.target)
r.rendered.links[i].broken = !ok
}
r.scrollTo(r.top)
r.moveTo(r.cursor)
}
// show shows page from line top, the cursor on line cursor.
func (r *Readme) show(page string, top, cursor int) {
r.page, r.forWidth = page, 0
r.render()
r.scrollTo(top)
r.moveTo(cursor)
}
// scrollTo scrolls to line top, kept where lines fill the screen.
func (r *Readme) scrollTo(top int) {
r.top = max(min(top, len(r.rendered.lines)-r.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 (r *Readme) moveTo(i int) {
n := len(r.rendered.lines)
r.cursor = max(min(i, n-1), 0)
r.top = scrollWindow(r.cursor, r.top, r.bodyHeight(), n)
r.link = -1
if r.cursor < n {
for _, s := range r.rendered.lines[r.cursor].segs {
if r.live(s.link) {
r.link = s.link
break
}
}
}
}
func (r *Readme) key(k string) tea.Cmd {
r.msg = ""
h := r.cfg.Keys.Key(keys.DocView, k)
page := max(r.bodyHeight()-1, 1)
if delta, paging, ok := motion(h, page, len(r.rendered.lines)); ok {
if paging {
r.scrollTo(r.top + delta)
}
r.moveTo(r.cursor + delta)
return nil
}
switch h.Action {
case "quit":
return tea.Quit
case "half_page":
delta := halfPage(h, page)
r.scrollTo(r.top + delta)
r.moveTo(r.cursor + delta)
case "link":
r.nextLink(direction(h.Index == 0))
case "line_link":
r.lineLink(direction(h.Index == 1))
case "follow":
r.follow()
case "back":
if len(r.back) == 0 {
r.msg = "no link followed to go back from"
break
}
p := r.back[len(r.back)-1]
r.back = r.back[:len(r.back)-1]
r.show(p.page, p.top, p.cursor)
r.link = p.link
case "next_page":
i := slices.Index(r.order, r.page)
next := i + direction(h.Index == 0)
switch {
case i < 0:
r.msg = r.page + " is no page of " + r.cfg.Keys.Text("{next_page}") + ": " + r.cfg.Keys.Text("{back}") + " goes back"
case next < 0 || next >= len(r.order):
r.msg = ends(h.Index == 0, "last page", "first page")
default:
r.show(r.order[next], 0, 0)
}
case "search":
return askSearch
case "match":
r.findText(direction(h.Index == 0))
}
return nil
}
// live reports whether link i of the page is one: it resolves.
func (r *Readme) live(i int) bool { return i >= 0 && !r.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 (r *Readme) nextLink(dir int) {
links := r.rendered.links
next := -1
for i := range links {
j := i
if dir < 0 {
j = len(links) - 1 - i
}
after := r.link >= 0 && (j-r.link)*dir > 0 || r.link < 0 && (links[j].line-r.cursor)*dir > 0
if after && r.live(j) {
next = j
break
}
}
if next < 0 {
r.msg = ends(dir > 0, "no link further down", "no link further up")
return
}
r.moveTo(links[next].line)
r.link = next
}
// lineLink selects the next link on the cursor's line in direction dir,
// right or left.
func (r *Readme) lineLink(dir int) {
var on []int
for _, s := range r.rendered.lines[min(r.cursor, len(r.rendered.lines)-1)].segs {
if r.live(s.link) && !slices.Contains(on, s.link) {
on = append(on, s.link)
}
}
i := slices.Index(on, r.link) + dir
switch {
case len(on) == 0:
r.msg = "no link on this line"
case i < 0 || i >= len(on):
r.msg = ends(dir > 0, "no link further right", "no link further left")
default:
r.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 (r *Readme) follow() {
if r.link < 0 {
r.msg = "no link on this line: " + r.cfg.Keys.Text("{link}") + " go to the next, previous"
return
}
target := r.rendered.links[r.link].target
page, anchor, _ := r.resolve(target)
if page == "" {
return
}
r.back = append(r.back, docPlace{r.page, r.top, r.cursor, r.link})
r.show(page, 0, 0)
if line := r.rendered.anchors[anchor]; anchor != "" {
r.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 (r *Readme) resolve(target string) (page, anchor string, ok bool) {
file, anchor, _ := strings.Cut(target, "#")
if strings.Contains(file, ":") { // https:, mailto:
return "", "", true
}
page = r.page
if file != "" {
page = path.Join(path.Dir(r.page), file)
if _, err := fs.Stat(r.docs, page); err != nil {
return page, anchor, false
}
}
if anchor == "" {
return page, "", true
}
_, ok = r.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 (r *Readme) anchorsOf(page string) map[string]int {
if page == r.page {
return r.rendered.anchors
}
a, ok := r.anchors[page]
if !ok {
if src, err := fs.ReadFile(r.docs, page); err == nil && path.Ext(page) == ".md" {
a = renderMarkdown(src, 80).anchors
}
r.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 (r *Readme) findText(dir int) {
q := r.cfg.Search.Query
m := matcherOf(q)
if m == nil {
r.msg = nothingSearched
return
}
var hits []int
for i, l := range r.rendered.lines {
if len(m.spans(l.plain())) > 0 {
hits = append(hits, i)
}
}
if len(hits) == 0 {
r.msg = noMatch(q, "this page")
return
}
i, ok := nextHit(len(hits), dir, func(i int) int { return cmp.Compare(hits[i], r.cursor) })
if !ok {
r.msg = noFurther(q, dir, "")
return
}
r.moveTo(hits[i])
r.msg = searchMsg(q, i, len(hits))
}
func (r *Readme) View() tea.View { return tea.NewView(r.frame()) }
func (r *Readme) frame() string {
if r.width == 0 {
return ""
}
t := r.cfg.Theme
lines := r.rendered.lines
var b strings.Builder
var where []string
if i := slices.Index(r.order, r.page); i >= 0 {
where = append(where, fmt.Sprintf("page %d/%d", i+1, len(r.order)))
}
if n := len(lines); n > r.bodyHeight() {
where = append(where, fmt.Sprintf("%d%%", 100*min(r.top+r.bodyHeight(), n)/n))
}
b.WriteString(statusLine(t, r.width, t.Title.Render(" "+printable(r.page)), strings.Join(where, " · ")+" ") + "\n")
m := matcherOf(r.cfg.Search.Query)
for i := r.top; i < r.top+r.bodyHeight(); i++ {
if i < len(lines) {
margin := " "
if i == r.cursor {
margin = t.Cursor.Render(margin)
}
b.WriteString(margin + r.drawLine(lines[i], m))
}
b.WriteString("\n")
}
keys := statusKeys(r.cfg, barKeys{enter: "follow", back: "back", text: true})
b.WriteString(statusLine(t, r.width, pageStatus(r.cfg, r.msg, keys), ""))
return r.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 (r *Readme) withWebAddress(frame string) string {
if r.link < 0 {
return frame
}
target := r.rendered.links[r.link].target
if page, _, _ := r.resolve(target); page != "" {
return frame
}
row := 1 + r.cursor - r.top // in frame, below the title
y := row + 1
if y+minBoxHeight > 1+r.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 r.rendered.lines[r.cursor].segs {
if s.link == r.link {
break
}
x += ansi.StringWidth(s.text)
}
address := printable(target)
w := min(ansi.StringWidth(address)+4, r.width)
return overlayAt(frame, box(r.cfg.Theme, w, minBoxHeight, "", []string{address}), max(min(x, r.width-w), 0), y, w, r.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 (r *Readme) 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 := r.style(s.style)
switch {
case s.link >= 0 && s.link == r.link:
st = r.cfg.Theme.Cursor
case s.link >= 0 && !r.live(s.link):
st = r.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(r.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 (r *Readme) style(s docStyle) lipgloss.Style {
t := r.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 (
"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 !hasUpper(src, true) {
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 := hasUpper(q, false)
for i := 0; i < len(text); {
end := -1
switch {
case exact && strings.HasPrefix(text[i:], q):
end = i + len(q)
case !exact:
if n := foldPrefix(text[i:], q); n >= 0 {
end = i + n
}
}
if end > i {
out = append(out, diff.Span{Start: i, End: end})
i = end
continue
}
_, n := utf8.DecodeRuneInString(text[i:])
i += n
}
return out
}
// foldPrefix returns the length in bytes of the start of s that is q with
// case ignored, rune by rune, or -1. The lengths may differ, as of K and the
// Kelvin sign.
func foldPrefix(s, q string) int {
j := 0
for _, qr := range q {
if j >= len(s) {
return -1
}
r, n := utf8.DecodeRuneInString(s[j:])
if r != qr && !strings.EqualFold(string(r), string(qr)) {
return -1
}
j += n
}
return j
}
// hasUpper reports whether query holds an upper case letter, which makes the
// search match case. In a regular expression the letters of escapes, as \S,
// \pL, \p{Lu} or \x4A, do not count.
func hasUpper(query string, regex bool) bool {
if !regex {
return strings.ContainsFunc(query, unicode.IsUpper)
}
for i := 0; i < len(query); {
r, n := utf8.DecodeRuneInString(query[i:])
i += n
switch {
case r == '\\' && i < len(query):
e, m := utf8.DecodeRuneInString(query[i:])
i += m
if e == 'p' || e == 'P' || e == 'x' {
switch {
case i < len(query) && query[i] == '{':
if j := strings.IndexByte(query[i:], '}'); j >= 0 {
i += j + 1
}
case e == 'x':
i = min(i+2, len(query))
case i < len(query):
_, m := utf8.DecodeRuneInString(query[i:])
i += m
}
}
case unicode.IsUpper(r):
return true
}
}
return false
}
// 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 a that m finds something in, in order: only
// the changed ones unless all is set.
func (a *fileAlign) hits(m *matcher, all bool) []hit {
var out []hit
for i, r := range a.rows {
if !all && r.Kind == diff.Equal {
continue
}
for _, s := range []side{left, right} {
if line := lineOf(r, s); line >= 0 && m.spans(a.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 a, 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 (a *fileAlign) showMatch(m *matcher, s side, line, xoff, viewWidth int) int {
spans := m.spans(a.lines[s][line])
if a.cfg.Settings.Wrap || len(spans) == 0 {
return xoff
}
width := a.contentWidth(viewWidth, !a.cfg.Settings.SideBySide)
text := a.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)
}
// noMatch returns the status message when q is found nowhere in where.
func noMatch(q config.Query, where string) string {
return queryName(q) + ": no match in " + where
}
// 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 *FileDiff) 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 = noMatch(q, 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
}
if f.inLog() && f.unloaded(cur) {
f.await(cur, func() { f.findText(dir) })
return
}
where := ""
if f.inLog() {
where = " of this commit"
}
var hits []flowHit
for p, part := range f.parts {
if part.diff == nil || f.inLog() && p != cur {
continue
}
for i, e := range part.diff.files {
if e.Binary {
continue
}
for _, h := range part.diff.diffOf(e).hits(mt, f.cfg.Settings.SearchAll) {
hits = append(hits, flowHit{p, i, h})
}
}
}
if len(hits) == 0 {
f.msg = noMatch(q, 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].diff.files[r.file].align.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]
a := f.parts[h.part].diff.files[h.file].align
line := lineOf(a.rows[h.row], h.side)
at := a.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 = a.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.paste(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"
"fmt"
"slices"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/keys"
)
// 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
}
// settingTable lists the settings without their keys and words, which
// settingOptions takes from the keys.
var settingTable = []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 }},
{action: "inotify_max_dirs", number: func(c *config.Config) *int { return &c.Settings.InotifyMaxDirs }},
}
// settingOptions returns the lines of the settings popup, with their keys and
// what they do from km, in its order.
func settingOptions(km *keys.Map) []settingOption {
options, _ := orderSettings(km.Order(), settingWords(km, settingTable), nil)
return options
}
// helpLines returns the lines of the help popup, from km, the settings in its
// order.
func helpLines(km *keys.Map) []binding {
_, bs := orderSettings(km.Order(), settingWords(km, settingTable), helpBindings(km))
return bs
}
// settingWords returns options with their keys and what they do from km.
func settingWords(km *keys.Map, options []settingOption) []settingOption {
options = slices.Clone(options)
for i := range options {
r := km.Row(options[i].action)
if len(r.Keys) > 0 {
options[i].key = r.Keys[0]
}
options[i].what = km.Text(r.Setting)
}
return options
}
// isAction reports whether key k does action on all pages of km: the popups
// over them open and close on such keys.
func isAction(km *keys.Map, k, action string) bool {
return km.Key(keys.Everywhere, k).Action == action
}
// orderSettings returns options and bindings with the settings in the order of
// the keys order. Options not in it, 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(order []string, options []settingOption, bs []binding) ([]settingOption, []binding) {
rank := func(k string) int {
if i := slices.Index(order, k); i >= 0 {
return i
}
return len(order)
}
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(order, 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
}
// keyOrderProblems returns what is wrong with text, a keybindings.toml, for the
// settings and the help of the built-in keys: a row of all pages with a key
// that does not exist or stands twice, a setting missing or without words, and
// the problems of its keys by themselves (keys.Map.Problems).
func keyOrderProblems(text string) []string {
p, err := keys.Parse(text)
if err != nil {
return []string{err.Error()}
}
var problems []string
order := p.Order()
if len(order) == 0 {
return []string{"no rows in everywhere"}
}
options, bindings := settingOptions(keys.Builtin()), helpLines(keys.Builtin())
for i, k := range order {
if slices.Contains(order[: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 options {
if o.key != "" && !o.logFlow && !slices.Contains(order, o.key) {
problems = append(problems, "setting "+o.key+" is not in the table")
}
}
for _, o := range options {
if p.Row(o.action).Setting == "" {
problems = append(problems, "setting "+o.action+" has no words")
}
}
return append(problems, p.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
options []settingOption // of cfg.Keys, which do not change
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, options: settingOptions(cfg.Keys)}
}
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(p.options, func(o settingOption) bool { return o.action == msg.action })
return p, p.flip(p.options[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.paste(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 cmd, ok := popupNav(p.cfg.Keys, k, "settings", &p.open, p.move); ok {
return cmd
}
switch k {
case "enter", "space":
return p.flip(p.options[p.cursor])
default:
if i := slices.IndexFunc(p.options, func(o settingOption) bool { return o.key != "" && keys.Name(o.key) == k }); i >= 0 {
p.move(i - p.cursor)
return p.flip(p.options[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
}
o := p.options[p.cursor]
*o.number(p.cfg) = n
p.input, p.msg = nil, saveState(p.cfg)
_, cmd := p.Model.Update(settingChangedMsg{action: o.action})
return cmd
default:
p.inputErr = ""
in.edit(k)
}
return nil
}
func (p *SettingsPopup) move(delta int) { p.moveIn(delta, len(p.options)) }
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
options := p.options
n := len(options)
all := make([]string, n)
states := make([]string, n)
keyW, stateW := 0, 0
for i, o := range options {
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 options {
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: control and
// format characters, among them those that change the direction of text and
// could reorder what is shown, zero-width spaces and joiners, and the line
// and paragraph separators. Spaces that take room, as the no-break space,
// are visible: they are drawn as they are.
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 unicode.In(r, unicode.Cc, unicode.Cf, unicode.Zl, unicode.Zp)
}
// zwj is the zero-width joiner, which joins emoji into one sign, as the man,
// woman and girl of a family.
const zwj = '\u200d'
// emojiJoiners returns the byte offsets of the joiners in s that join two
// emoji into one sign. They are not drawn, so that the sign shows as its
// emoji: terminals that cannot join emoji draw it so, wider than one sign,
// and the panes would not line up. A joiner elsewhere, as between letters,
// is invisible, a hidden character.
func emojiJoiners(s string) []int {
var out []int
for i, r := range s {
if r != zwj || i == 0 {
continue
}
prev, _ := utf8.DecodeLastRuneInString(s[:i])
next, _ := utf8.DecodeRuneInString(s[i+utf8.RuneLen(zwj):])
// Emoji are symbols; the one before may end in the selector of its
// emoji form or a skin tone.
if (unicode.Is(unicode.So, prev) || prev == '\ufe0f' || prev >= 0x1f3fb && prev <= 0x1f3ff) &&
unicode.Is(unicode.So, next) {
out = append(out, i)
}
}
return out
}
// dropJoiners returns text without its emoji joiners (see emojiJoiners), and
// the byte ranges spans and matches of text as ranges of the text left.
func dropJoiners(text string, spans, matches []diff.Span) (string, []diff.Span, []diff.Span) {
at := emojiJoiners(text)
if len(at) == 0 {
return text, spans, matches
}
n := utf8.RuneLen(zwj)
var b strings.Builder
from := 0
for _, i := range at {
b.WriteString(text[from:i])
from = i + n
}
b.WriteString(text[from:])
// shift moves an offset back by the joiners before it.
shift := func(p int) int {
before := 0
for _, i := range at {
if i < p {
before++
}
}
return p - before*n
}
moved := func(in []diff.Span) []diff.Span {
var out []diff.Span
for _, sp := range in {
out = append(out, diff.Span{Start: shift(sp.Start), End: shift(sp.End)})
}
return out
}
return b.String(), moved(spans), moved(matches)
}
// sideStyles returns the styles of side s for whole-line changes, the
// unchanged parts of changed lines, changed words and changed whitespace.
func (a *fileAlign) sideStyles(s side) (line, nonEmph, emph, indent lipgloss.Style) {
t := a.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 *FileDiff) 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 *FileDiff) View() tea.View { return tea.NewView(m.frame()) }
func (m *FileDiff) 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 (a *fileAlign) numWidth() int {
return len(strconv.Itoa(max(len(a.lines[left]), len(a.lines[right]), 1)))
}
// header renders the file names above both panes.
func (m *FileDiff) 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 *FileDiff) 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 (a *fileAlign) render(s sline, cursor bool, lo layout) string {
if lo.unified {
return a.unifiedLine(s.v, s.sub, cursor, lo)
}
return a.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 (a *fileAlign) unifiedLine(v, sub int, cursor bool, lo layout) string {
if a.view[v].hidden > 0 {
return a.fold(v, cursor, lo.width)
}
t := a.cfg.Theme
row := a.rows[a.view[v].row]
s, piece := a.unifiedPiece(v, sub, lo.shape)
nw := a.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 && a.isPending(row) && (row.Kind == diff.Equal || a.pending.side == s)
line := lineOf(row, s)
_, moved := a.movedTo(s, line)
ls := lineState{s: s, row: row, line: line, changed: changed, pending: pending, moved: moved,
cont: piece > 0, cursor: cursor, active: cursor}
gutter := a.gutter(ls, nums, t.Decoration)
width := a.contentWidth(lo.width, true)
return gutter + a.content(ls, width, 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 (a *fileAlign) bodyRow(v, sub int, cursor bool, lo layout) string {
if a.view[v].hidden > 0 {
return a.fold(v, cursor, lo.width)
}
w, nw := (lo.width-1)/2, a.numWidth()
return a.cell(left, v, sub, w, nw, cursor, lo) + a.cfg.Theme.Decoration.Render("│") +
a.cell(right, v, sub, w, nw, cursor, lo)
}
func (m *FileDiff) 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 (a *fileAlign) fold(v int, cursor bool, width int) string {
label := fmt.Sprintf(" ⋯ %d unchanged lines ⋯ ", a.view[v].hidden)
if cursor {
label = a.cfg.Theme.Cursor.Render(label)
}
side := max(width-ansi.StringWidth(label), 0)
line := a.cfg.Theme.Decoration.Render(strings.Repeat("─", side/2)) + label +
a.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 (a *fileAlign) cell(s side, v, piece, width, numWidth int, cursor bool, lo layout) string {
t := a.cfg.Theme
row := a.rows[a.view[v].row]
line := a.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 >= a.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 && a.pending != nil && a.pending.side == s && a.pending.line == line && line >= 0
_, moved := a.movedTo(s, line)
ls := lineState{s: s, row: row, line: line, changed: changed, pending: pending, moved: moved,
cont: piece > 0 && line >= 0, cursor: cursor, active: cursor && s == lo.active}
gutter := a.gutter(ls, num, sepStyle)
if line < 0 {
return gutter + t.Filler.Render(strings.Repeat("╱", contentWidth))
}
return gutter + a.content(ls, contentWidth, startCol(piece, contentWidth, lo), lo.wrap == 0)
}
// lineState is what gutter and content draw a line by; cell and unifiedLine
// each work it out once.
type lineState struct {
s side
row diff.Row // the row the line is shown in
line int // the line of side s, -1 for none
changed bool // a changed line of its side
pending bool // the pending end of an anchor
moved bool // moved, drawn in the moved style
cont bool // a further piece of a wrapped line
cursor bool // on the cursor row
active bool // on the cursor row and the active side
}
// gutter renders what precedes the line of ls: the marker, the line numbers
// nums and the separator in sepStyle. The marker is ↪ on further pieces of a
// wrapped line, ● on the pending end of an anchor, ◆ 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,
// on the active side the numbers are in the cursor style.
func (a *fileAlign) gutter(ls lineState, nums string, sepStyle lipgloss.Style) string {
t := a.cfg.Theme
s, changed, cursor := ls.s, ls.changed, ls.cursor
lineStyle, _, _, _ := a.sideStyles(s)
if ls.moved {
lineStyle = a.movedStyle(s)
}
numStyle := t.NumZero
marker := " "
switch {
case ls.cont && changed:
marker = boldIf(lineStyle, cursor).Render("↪")
case ls.cont:
marker = boldIf(t.Decoration, cursor).Render("↪")
case ls.pending:
marker = t.Pending.Render("●")
case ls.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 ls.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 (a *fileAlign) movedStyle(s side) lipgloss.Style {
if s == left {
return a.cfg.Theme.MinusMoved
}
return a.cfg.Theme.PlusMoved
}
// content renders the line of ls 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 (a *fileAlign) content(ls lineState, width, from int, cut bool) string {
t := a.cfg.Theme
s, row, line, cursor, moved := ls.s, ls.row, ls.line, ls.cursor, ls.moved
lineStyle, nonEmph, emph, indent := a.sideStyles(s)
st := lineStyles{base: boldIf(t.Zero, cursor && row.Kind == diff.Equal), ws: t.Whitespace,
showAll: a.cfg.Settings.ShowWhitespaces, plainSpace: !a.cfg.Settings.MarkWhitespace,
trailing: t.TrailingWhitespace, showTrailing: a.cfg.Settings.ShowTrailing, lineEnd: a.cfg.Settings.LineEnd,
cr: a.cfg.Settings.CREnd, showCR: a.cfg.Settings.ShowCR, hidden: t.HiddenChar}
var spans []diff.Span
switch {
case row.Kind == diff.Equal:
case moved:
st.base = a.movedStyle(s)
case row.Kind == diff.Change:
st.base, st.emph, st.space, spans = nonEmph, emph, indent, a.emphOf(a.rowOf[s][line], s)
if !a.cfg.Settings.BoldChanges {
st.emph, st.space = st.emph.UnsetBold(), st.space.UnsetBold()
}
default:
st.base = lineStyle
}
if m := matcherOf(a.cfg.Search.Query); m != nil && (a.cfg.Settings.SearchAll || row.Kind != diff.Equal) {
st.matches, st.match = m.spans(a.lines[s][line]), t.Search
}
// The pieces of a wrapped line are drawn from one highlight of it.
key := highlightKey{line, a.aligns, cursor, moved, a.cfg.Search.Query, a.cfg.Settings, t}
if c := &a.highlighted[s]; !c.ok || c.key != key {
*c = highlightCache{key, highlight(a.lines[s][line], spans, st), true}
}
content := a.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 := a.cfg.Theme.Overflow
if full > from+width {
content = fit(content, width-1) + mark.Render(a.cfg.Settings.Extends)
}
if from > 0 && full > 0 {
content = mark.Render(a.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]
}
text, spans, st.matches = dropJoiners(text, spans, st.matches)
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 unicode.IsSpace(r) && !hiddenChar(r) && visible:
// A space of another width, as the ideographic space: · in
// its room.
w := max(ansi.StringWidth(string(r)), 1)
b.WriteString("·" + strings.Repeat(" ", w-1))
*col += w
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. The
// joiners of emoji are left out, see emojiJoiners.
func printable(line string) string {
var b strings.Builder
col := 0
line, _, _ = dropJoiners(line, nil, nil)
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/source"
"jdiff/watch"
)
// 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 *watch.Watcher
paths []string
}
// watchEndedMsg reports that inotify watcher w stopped reporting changes: it
// was closed, or a directory could not be watched any more. The view whose
// watcher it still is checks its files itself from now on.
type watchEndedMsg struct{ w *watch.Watcher }
// waitChanges returns the command waiting for the next changes w reports.
func waitChanges(w *watch.Watcher) tea.Cmd {
return func() tea.Msg {
paths, ok := <-w.Changes()
if !ok {
return watchEndedMsg{w}
}
return changedMsg{w, paths}
}
}
// diskWatch watches the files of a view on disk: by the watcher App
// shares, which it gives the view before Init (shareWatcher) and waits on, or
// by one of the view's own, which the view waits on and closes. The files it
// does not watch, all without a watcher, the view checks every
// watch_interval (poll), ticking while such a check is pending.
type diskWatch struct {
w *watch.Watcher
own bool
ticking bool
}
// share makes dw watch by w, which App shares; a pending check stays.
func (dw *diskWatch) share(w *watch.Watcher) { dw.w, dw.own = w, false }
// poll returns the next check, for owner, of the files at paths, the view's
// to check, unless one is pending, auto reload is off, there is no
// watch_interval or all of them are watched.
func (dw *diskWatch) poll(cfg *config.Config, owner tea.Model, paths []string) tea.Cmd {
if dw.ticking || !cfg.AutoReload || cfg.Settings.WatchInterval <= 0 || !dw.unwatched(paths) {
return nil
}
dw.ticking = true
return watchTick(cfg, owner)
}
// ticked reports whether owner checks its files for msg: it is owner's check
// or anyone's, which is then no longer pending, and auto reload is on.
func (dw *diskWatch) ticked(cfg *config.Config, owner tea.Model, msg watchTickMsg) bool {
if msg.owner != nil && msg.owner != owner {
return false
}
dw.ticking = false
return cfg.AutoReload
}
// unwatched reports whether a change of a file at paths is not reported.
func (dw *diskWatch) unwatched(paths []string) bool {
return slices.ContainsFunc(paths, func(p string) bool { return !dw.watched(p) })
}
// set watches the files at paths in place of those before, starting the
// view's own watcher when it has none, there are files and inotify_max_dirs
// allows one. It returns the command waiting for the changes of a watcher
// just started, and why the system refused, or "".
func (dw *diskWatch) set(cfg *config.Config, paths []string) (cmd tea.Cmd, msg string) {
if cfg.Settings.WatchInterval <= 0 {
return nil, ""
}
if dw.w == nil {
if cfg.Settings.InotifyMaxDirs <= 0 || len(paths) == 0 {
return nil, ""
}
w, err := watch.New(cfg.Settings.InotifyMaxDirs)
if err != nil {
return nil, refused(cfg, err)
}
dw.w, dw.own, cmd = w, true, waitChanges(w)
}
if err := dw.w.SetFiles(paths); err != nil {
msg = refused(cfg, err)
}
return cmd, msg
}
// refused returns what to say when the system refused to watch, nothing
// where it cannot.
func refused(cfg *config.Config, err error) string {
if errors.Is(err, watch.ErrUnsupported) {
return ""
}
return err.Error() + "; checking files every " + strconv.FormatFloat(cfg.Settings.WatchInterval, 'g', -1, 64) + "s"
}
// next returns the command waiting for the next changes of the view's own
// watcher, nil for the shared one, which App waits on.
func (dw *diskWatch) next() tea.Cmd {
if !dw.own || dw.w == nil {
return nil
}
return waitChanges(dw.w)
}
// watched reports whether a change of the file at path is reported.
func (dw *diskWatch) watched(path string) bool { return dw.w != nil && dw.w.Watched(path) }
// touch counts the directories of the files at paths as used.
func (dw *diskWatch) touch(paths []string) {
if dw.w != nil {
dw.w.Touch(paths)
}
}
// limit applies inotify_max_dirs, set in the settings popup, also to the
// shared watcher, before the view's next poll; App applies it too, see "Kept
// on purpose" in doc/development.md.
func (dw *diskWatch) limit(cfg *config.Config) {
if dw.w != nil {
dw.w.SetLimit(cfg.Settings.InotifyMaxDirs)
}
}
// ended reports whether w, which stopped reporting, was the watcher, which
// is then dropped.
func (dw *diskWatch) ended(w *watch.Watcher) bool {
if w == nil || w != dw.w {
return false
}
dw.w = nil
return true
}
// stop stops watching the files: the view's own watcher is closed, the
// shared one, which stays, watches them no more.
func (dw *diskWatch) stop() {
switch {
case dw.w == nil:
case dw.own:
dw.w.Close()
dw.w = nil
default:
dw.w.SetFiles(nil)
}
}
// 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} })
}
// stale reports whether the file at path may differ from what was read with
// stamp: its stamp changed or, while its modification time is recent, its
// content may have. A file that cannot be read is not.
func stale(path string, stamp source.Stamp) bool {
st, err := source.StampOf(path)
return err == nil && (st != stamp || time.Since(st.ModTime) < racyWindow)
}
// refreshFile reloads the sides of f that changed on disk into f and into
// its diff a, unless nil, calling after, unless nil, after each reload of a
// 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, a *fileAlign, 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 !stale(sf.path, *sf.stamp) {
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 a != nil {
dropped += a.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"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"jdiff/internal/config"
"jdiff/internal/keys"
"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 := t.Heading
if sideBySide && s != active {
style = t.InactiveHeading
}
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 shown []string
add := func(row keys.Row, keyNames string, state int) {
if l := row.LabelIn(state); l != "" {
shown = append(shown, keyNames+" "+l)
}
}
first := func(action string, state int) {
if r := cfg.Keys.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 := cfg.Keys.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(shown, " · ")
}
// 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()
}
// minInputWidth is the least width of the line in the popups of inputBox.
const minInputWidth = 40
// 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 == ""
}
// paste adds pasted text, which comes as a tea.PasteMsg: on one line, line
// breaks and tabs as spaces, without characters that are not visible, which
// could reach the terminal.
func (in *lineInput) paste(text string) {
in.text += strings.Map(func(r rune) rune {
switch {
case r == '\n' || r == '\r' || r == '\t':
return ' '
case hiddenChar(r):
return -1
}
return r
}, 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 with paste.
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} }
// Stop ends the git commands that run in the background, and the watching
// of files, once the program ended: its git commands would else run on.
func (g *SizeGuard) Stop() {
if m, ok := g.Model.(interface{ stop() }); ok {
m.stop()
}
}
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"
"context"
"embed"
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
"time"
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/keys/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, that follows your edits",
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)
if mode == "readme" {
run(ui.WithSizeGuard(cfg, ui.WithSearch(cfg, ui.WithHelp(cfg, ui.NewReadme(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)
}
// d replaces whatever is shown by the files of git diff, which runs in
// the current directory.
run(ui.WithSizeGuard(cfg, ui.NewApp(cfg, m, gitDiff).WithAutoReload(gitStatus, gitsrc.Repo{}.WatchPaths, gitsrc.Repo{}.WorkDirs)))
}
// 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))
}
end, err := tea.NewProgram(page, opts...).Run()
// git commands still running in the background are of no use now: they
// are killed, which takes a moment.
if s, ok := end.(interface{ Stop() }); ok {
s.Stop()
gitsrc.Wait(time.Second)
}
if err != nil {
fatal(err)
}
}
// load opens the file list 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(context.Context) ([]source.File, error) { return source.LoadDirs(oldPath, newPath) }
return ui.NewDiff(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.NewWatchedFileDiff(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(ctx context.Context) (string, []gitsrc.Commit, error) {
r := repo.WithContext(ctx)
commits, err := r.Log(args)
return r.Branch(), commits, err
}
files := func(ctx context.Context, c gitsrc.Commit) ([]source.File, error) {
return repo.WithContext(ctx).Files(c)
}
counts := func(ctx context.Context, hashes []string) (map[string]gitsrc.LineCounts, error) {
return repo.WithContext(ctx).Counts(hashes)
}
return ui.NewLog(cfg, repo.Branch(), commits, gitsrc.Reversed(args), files, counts).WithRelist(relist), nil
}
// loadDiff opens the file list 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) {
files, err := gitDiff(context.Background(), args, cfg.Settings.Untracked)
if err != nil {
return nil, err
}
return ui.NewDiff(cfg, files).WithGitDiff(args, gitDiff), nil
}
// gitDiff runs git diff with args, with the untracked files when untracked
// is set; see ui.DiffFunc.
func gitDiff(ctx context.Context, args []string, untracked bool) ([]source.File, error) {
repo := gitsrc.Repo{}.WithContext(ctx)
if untracked {
return repo.DiffUntracked(args)
}
return repo.Diff(args)
}
// gitStatus returns the state of the repository for auto reload, see
// gitsrc.Repo.Status.
func gitStatus(ctx context.Context) (string, error) { return gitsrc.Repo{}.WithContext(ctx).Status() }
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
}
//go:build linux
package watch
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"sync"
"golang.org/x/sys/unix"
)
// mask selects the events that change a file's content or replace or
// remove it, and the move of a watched directory; 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_MOVE_SELF | unix.IN_ONLYDIR | unix.IN_EXCL_UNLINK
// inotify is the backend of Linux. All watches share one inotify instance,
// which takes one file descriptor. Its descriptor is used through Control,
// so that close cannot close it meanwhile.
type inotify struct {
file *os.File
mu sync.Mutex
live map[int32]bool // the watches not removed nor ended
ch chan event
done chan struct{}
once sync.Once
}
func newBackend() (backend, error) {
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.
b := &inotify{file: os.NewFile(uintptr(fd), "inotify"), live: map[int32]bool{},
ch: make(chan event), done: make(chan struct{})}
go b.read()
return b, nil
}
func (b *inotify) add(dir string) (int, error) {
var wd int
var err error
if cerr := b.control(func(fd int) { wd, err = unix.InotifyAddWatch(fd, dir, mask) }); cerr != nil {
return 0, cerr
}
if err != nil {
return 0, fmt.Errorf("inotify: %s: %w", dir, err)
}
b.mu.Lock()
b.live[int32(wd)] = true
b.mu.Unlock()
return wd, nil
}
func (b *inotify) remove(id int) {
b.mu.Lock()
live := b.live[int32(id)]
delete(b.live, int32(id))
b.mu.Unlock()
if live {
b.control(func(fd int) { unix.InotifyRmWatch(fd, uint32(id)) })
}
}
func (b *inotify) events() <-chan event { return b.ch }
func (b *inotify) close() error {
err := os.ErrClosed
b.once.Do(func() {
close(b.done)
err = b.file.Close()
})
return err
}
// control runs f with the file's descriptor.
func (b *inotify) control(f func(fd int)) error {
conn, err := b.file.SyscallConn()
if err != nil {
return err
}
return conn.Control(func(fd uintptr) { f(int(fd)) })
}
// read sends the events the kernel reports, until the file is closed. A
// watch removed is dropped, the end the kernel reports for it too. A
// directory moved away ends its watch, which would follow it.
func (b *inotify) read() {
defer close(b.ch)
buf := make([]byte, 64*1024)
for {
n, err := b.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
ev, ok := b.event(wd, evMask, string(name))
if !ok {
continue
}
select {
case b.ch <- ev:
case <-b.done:
return
}
}
}
}
// event returns the event of watch wd, with evMask and name, and whether
// there is one.
func (b *inotify) event(wd int32, evMask uint32, name string) (event, bool) {
if evMask&unix.IN_Q_OVERFLOW != 0 {
return event{overflow: true}, true
}
b.mu.Lock()
live := b.live[wd]
ended := live && evMask&(unix.IN_IGNORED|unix.IN_MOVE_SELF) != 0
if ended {
delete(b.live, wd)
}
b.mu.Unlock()
switch {
case !live:
return event{}, false
case ended:
if evMask&unix.IN_MOVE_SELF != 0 {
b.control(func(fd int) { unix.InotifyRmWatch(fd, uint32(wd)) })
}
return event{id: int(wd)}, true
case name == "":
return event{}, false
}
return event{id: int(wd), name: name}, true
}
// Package watch reports changes to files as the system 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, and only
// a directory sees a file appear. Only these directories are watched, not
// their subdirectories.
//
// A Watcher watches the directories of pinned files always, and of the others
// at most a number. When more are wanted, the most recently used ones are
// watched, see Watched, and the callers check the others themselves. A
// directory is used when something in it changes and when Touch says so;
// before either, at the time it or its newest file was modified.
//
// What a system offers is its backend. Only Linux (inotify) has one; elsewhere
// New fails with ErrUnsupported and the callers check the files themselves.
// Another system needs only a backend of its own (backend_<os>.go).
package watch
import (
"cmp"
"errors"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
"sync"
"time"
)
// ErrUnsupported is returned where the system cannot report changes.
var ErrUnsupported = errors.New("not supported on this system")
// 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
// backend watches single directories, without their subdirectories. Its
// methods may be called from any goroutine; add and remove must not wait for
// the events to be taken.
type backend interface {
// add starts watching dir and returns the id of its events. A missing
// dir is an error matching fs.ErrNotExist.
add(dir string) (int, error)
// remove stops watching id: no event of it comes after.
remove(id int)
// events returns the events, closed after close or when the system
// stops reporting.
events() <-chan event
close() error
}
// event is a change of the entry name of the directory watched as id. With
// name "", the directory itself is gone or moved away, and its watch ended.
// With overflow, the system lost events; id is not set.
type event struct {
id int
name string
overflow bool
}
// Watcher reports changes to files and directories. Its methods may be called
// from any goroutine.
type Watcher struct {
b backend
mu sync.Mutex
limit int // the most directories watched
files map[string][]string // SetFiles, see watched
pinned map[string][]string // Pin, see watched
whole map[string]bool // SetDirs: all their entries count
used map[string]time.Time // the directories wanted, but pinned ones, and when they were used
ids map[int]string // watch id -> directory
dirIDs map[string]int // watched directory -> watch id
now func() time.Time // the time of a use, time.Now but in tests
raw chan string
changes chan []string
done chan struct{}
once sync.Once
}
// New returns a watcher of at most limit directories, watching none yet. It
// fails when the system cannot report changes.
func New(limit int) (*Watcher, error) {
b, err := newBackend()
if err != nil {
return nil, err
}
return newWatcher(b, limit), nil
}
// newWatcher returns a watcher of at most limit directories with b.
func newWatcher(b backend, limit int) *Watcher {
w := &Watcher{b: b, limit: limit, used: map[string]time.Time{}, ids: map[int]string{}, dirIDs: map[string]int{}, now: time.Now,
raw: make(chan string), changes: make(chan []string), done: make(chan struct{})}
go w.read()
go w.batch()
return w
}
// SetFiles watches the files at paths, in place of those set before. A
// change of one is reported by its path as given; other entries of their
// directories are not reported. It returns the first error of the system,
// other than for a missing directory, which is left out; the directories
// that could be watched are.
func (w *Watcher) SetFiles(paths []string) error {
names := watched(paths)
modified := newest(names)
w.mu.Lock()
defer w.mu.Unlock()
w.files = names
w.want(modified)
return w.apply()
}
// Pin watches the files at paths as SetFiles does, in place of those pinned
// before, but always: their directories do not count towards the limit.
func (w *Watcher) Pin(paths []string) error {
names := watched(paths)
w.mu.Lock()
defer w.mu.Unlock()
w.pinned = names
return w.apply()
}
// SetDirs watches the directories of dirs, in place of those set before, with
// the time each was last modified. A change of any entry is reported by the
// directory joined with its name. It returns errors as SetFiles does.
func (w *Watcher) SetDirs(dirs map[string]time.Time) error {
cleaned := map[string]time.Time{}
for d, t := range dirs {
cleaned[filepath.Clean(d)] = t
}
w.mu.Lock()
defer w.mu.Unlock()
w.whole = map[string]bool{}
for d := range cleaned {
w.whole[d] = true
}
w.want(cleaned)
return w.apply()
}
// Touch counts the directories of the files at paths, those of symlink
// targets too, as used now: the most recent ones are watched.
func (w *Watcher) Touch(paths []string) error {
ds := dirs(watched(paths))
w.mu.Lock()
defer w.mu.Unlock()
now := w.now()
for _, d := range ds {
if _, ok := w.used[d]; ok {
w.used[d] = now
}
}
return w.apply()
}
// SetLimit sets the most directories watched besides those of pinned files;
// with 0 only those are.
func (w *Watcher) SetLimit(limit int) error {
w.mu.Lock()
defer w.mu.Unlock()
w.limit = limit
return w.apply()
}
// Watched reports whether a change of the file at path is reported now: its
// directory is watched, and so is that of its target when it is a symlink.
func (w *Watcher) Watched(path string) bool {
names := watched([]string{path})
w.mu.Lock()
defer w.mu.Unlock()
for name := range names {
if _, ok := w.dirIDs[filepath.Dir(name)]; !ok {
return false
}
}
return true
}
// Changes returns the channel of changed paths, in sorted batches. It is
// closed when the watcher is, and when the system stops reporting.
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.b.close()
})
return err
}
// want adds the directories of modified, at the time given, to those wanted
// that are not yet.
func (w *Watcher) want(modified map[string]time.Time) {
for d, t := range modified {
if _, ok := w.used[d]; !ok {
w.used[d] = t
}
}
}
// apply watches the pinned directories and the most recently used others, as
// many as the limit allows, and no others: those no longer wanted are dropped.
// A missing directory takes no place. It returns the first error of the
// system other than for a missing directory. w.mu is held.
func (w *Watcher) apply() error {
wanted := map[string]bool{}
for d := range w.whole {
wanted[d] = true
}
for _, d := range dirs(w.files) {
wanted[d] = true
}
maps.DeleteFunc(w.used, func(d string, _ time.Time) bool { return !wanted[d] })
pins := dirs(w.pinned)
order := slices.Clone(pins)
others := slices.Collect(maps.Keys(w.used))
slices.SortFunc(others, func(a, b string) int {
if c := w.used[b].Compare(w.used[a]); c != 0 {
return c
}
return cmp.Compare(a, b)
})
for _, d := range others {
if !slices.Contains(order, d) {
order = append(order, d)
}
}
// The watches beyond the limit go first, so that the system's limit
// is not passed on the way.
keep := order[:min(len(order), len(pins)+max(w.limit, 0))]
for d, id := range w.dirIDs {
if !slices.Contains(keep, d) {
w.b.remove(id)
delete(w.ids, id)
delete(w.dirIDs, d)
}
}
var first error
n := 0 // the others watched
for _, d := range order {
pinned := slices.Contains(pins, d)
if !pinned && n >= w.limit {
break
}
if _, ok := w.dirIDs[d]; ok {
if !pinned {
n++
}
continue
}
id, err := w.b.add(d)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) && first == nil {
first = err
}
continue
}
w.ids[id], w.dirIDs[d] = d, id
if !pinned {
n++
}
}
return first
}
// read hands the paths of the backend's events to batch, until the watcher
// is closed or the backend ends.
func (w *Watcher) read() {
defer close(w.raw)
for ev := range w.b.events() {
for _, p := range w.paths(ev) {
select {
case w.raw <- p:
case <-w.done:
return
}
}
}
}
// paths returns the paths ev reports. A change counts as use of its
// directory. When events were lost, all paths count as changed. When a
// watched directory is gone or moved away, as when a checkout removes and
// makes it again, its paths count as changed, and the directory at its path
// is watched again when there is one: else it is not watched, which Watched
// tells.
func (w *Watcher) paths(ev event) []string {
w.mu.Lock()
defer w.mu.Unlock()
if ev.overflow {
var out []string
for _, names := range []map[string][]string{w.files, w.pinned} {
for _, ps := range names {
out = append(out, ps...)
}
}
return append(out, slices.Collect(maps.Keys(w.whole))...)
}
dir, ok := w.ids[ev.id]
if !ok {
return nil // removed meanwhile
}
if ev.name == "" {
delete(w.ids, ev.id)
delete(w.dirIDs, dir)
var out []string
for _, names := range []map[string][]string{w.files, w.pinned} {
for p, ps := range names {
if filepath.Dir(p) == dir {
out = append(out, ps...)
}
}
}
if w.whole[dir] {
out = append(out, dir)
}
w.apply() // what it cannot watch, Watched tells
return out
}
if _, ok := w.used[dir]; ok {
w.used[dir] = w.now()
}
p := filepath.Join(dir, ev.name)
out := append(slices.Clone(w.files[p]), w.pinned[p]...)
if w.whole[dir] {
out = append(out, p)
}
return out
}
// batch collects the paths from raw for settle after the first one and
// sends them on as one batch, until raw is closed; the batch collected
// then is still sent.
func (w *Watcher) batch() {
defer close(w.changes)
for open := true; open; {
p, ok := <-w.raw
if !ok {
return
}
set := map[string]bool{p: true}
timer := time.NewTimer(settle)
collect:
for {
select {
case p, ok := <-w.raw:
if !ok {
timer.Stop()
open = false
break collect
}
set[p] = true
case <-timer.C:
break collect
}
}
select {
case w.changes <- slices.Sorted(maps.Keys(set)):
case <-w.done:
return
}
}
}
// watched maps each path the system 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)
}
// newest returns the directories of the paths of names with the time their
// newest file there was modified; a file that cannot be read counts as never.
func newest(names map[string][]string) map[string]time.Time {
out := map[string]time.Time{}
for p := range names {
d := filepath.Dir(p)
t := out[d]
if info, err := os.Stat(p); err == nil && info.ModTime().After(t) {
t = info.ModTime()
}
out[d] = t
}
return out
}